CASE STUDY / 04
Encryptie die niets onthoudt.
qCrypt maakt van een handvol persoonlijke beveiligingsvragen een AES-256-sleutel en bewaart geen enkel deel van de antwoorden — geen tekst, geen hash, geen hint. Het draait op Tails, weigert te starten zolang de machine online is, en laat niets achter wanneer de machine uitgaat.
01 / De vraag
Een kluis waarvan de sleutel alleen in iemands geheugen bestaat.
Wachtwoordmanagers verplaatsen het probleem in plaats van het op te lossen: er blijft ergens één hoofdgeheim dat gephisht, gevorderd of samen met een database verloren kan gaan. De vraag was een tool waarbij het verlies van het apparaat, de back-up én de ontwikkelaar de eigenaar niets kost — en waarbij je antwoorden vergeten de erkende, bewuste, onherstelbare faalmodus is.
IN CIJFERS
DE PRIMITIEVEN
- Cipher
- AES-256-GCM · 96-bit nonce
- Sleutelafleiding
- Argon2id · 128 MB · 4 passes
- Integriteit
- HMAC-SHA256 · met het zout als sleutel
- Host
- Python 3 · Tails OS · air-gapped
Fragmenten zijn ingekort — console-uitvoer en kleurcodes zijn weggelaten zodat de logica in één oogopslag leesbaar blijft. Elke getoonde regel komt uit de geleverde broncode.
DE RONDGANG
Waar de sleutel vandaan komt, en waar hij blijft.
- 01Vragen + antwoordenéén keer getypt, blijft in RAM
- 02Normaliserentrimmen · kleine letters · spaties samenvoegen
- 03Argon2id128 MB · 4 passes · 32-byte zout
- 04AES-256-GCM96-bit nonce · 128-bit auth tag
del data, answers, key- 05HMAC controlerenmet het zout, vóór er iets gevraagd wordt
- 06Vragen tonenin platte tekst uit het bestand gelezen
- 07Argon2idzelfde zout + zelfde antwoorden → zelfde sleutel
- 08decrypt_and_verifyfaalt bij een gemanipuleerd bestand
02 / WAT OP SCHIJF BELANDT
Lees het record. Let op wat ontbreekt.
Dit is het volledige opgeslagen artefact. Er is een nonce, een authenticatietag, de vragen in platte tekst en de ciphertext — en helemaal niets dat van de antwoorden is afgeleid. De vragen zijn het geheim niet, dus ze opslaan kost niets en zorgt dat een terugkerende eigenaar netjes bevraagd kan worden. Daarna worden de antwoorden en de sleutel op de volgende regel weggegooid.
file_data = {
"version": 4, # Versie 4: HMAC toegevoegd voor bestandsintegriteitsverificatie
"nonce": base64.b64encode(nonce).decode(),
"tag": base64.b64encode(tag).decode(),
"questions": questions, # Opgeslagen in platte tekst zodat gebruiker ze kan zien
"encrypted_data": base64.b64encode(encrypted_data).decode()
}
with open(filepath, "w") as f:
json.dump(file_data, f, indent=2)
# Veilige opruiming
del data, answers, key03 / SLEUTELAFLEIDING
128 MB RAM per poging.
Argon2id met 128 MB geheugen, vier passes en vier threads. Geheugen-hardheid is precies het punt: een GPU-farm parallelliseert SHA-256 vrijwel gratis, maar kan niet goedkoop honderdduizend cores elk 128 MB geven. Let ook op wat de code weigert te doen — de antwoorden vooraf hashen zou hun entropie op 256 bits aftoppen, dus gaan ze intact naar Argon2, verbonden door een scheidingsteken dat in geen enkel antwoord voorkomt.
SALT_LENGTH = 32 # Zoutgrootte in bytes. Aanbevolen: 32 (256 bits)
NONCE_LENGTH = 12 # GCM nonce grootte. Aanbevolen: 12 (96 bits, standaard)
TIME_COST = 4 # Argon2 iteraties. Aanbevolen: 3-5 (hoger = langzamer)
MEMORY_COST = 131072 # Argon2 geheugen in KB. Aanbevolen: 65536-262144 (64-256 MB)
PARALLELISM = 4 # Argon2 threads. Aanbevolen: 2-4 (gebaseerd op CPU cores)
KEY_LENGTH = 32 # AES sleutelgrootte. Vereist: 32 (256 bits voor AES-256)def generate_key(answers, salt):
"""
Why no pre-hashing?
- Passing answers directly preserves full entropy
- SHA-256 pre-hashing would limit entropy to 256 bits regardless of input
- Argon2 handles any input size efficiently
"""
normalized_answers = [normalize_answer(a) for a in answers]
# Combine answers with a separator that's unlikely to appear in answers
# This preserves the full entropy of each answer
separator = b'\x00\x1f\x00' # Null + Unit Separator + Null
combined = separator.join(a.encode('utf-8') for a in normalized_answers)
key = hash_secret_raw(
combined,
salt,
time_cost=TIME_COST,
memory_cost=MEMORY_COST,
parallelism=PARALLELISM,
hash_len=KEY_LENGTH,
type=Type.ID # Argon2id
)
return key04 / NORMALISATIE
Een afweging beargumenteerd in de broncode, niet erin verstopt.
Antwoorden worden vóór de afleiding omgezet naar kleine letters, getrimd en van dubbele spaties ontdaan. Dat is een echte verkleining van de tekenset, en de docstring zegt dat hardop in plaats van het stilletjes mee te leveren — want het alternatief is een eigenaar die permanent buitengesloten raakt door een caps-locktoets. Beveiligingswerk zit vol met zulke afwegingen; de eerlijke worden opgeschreven.
def normalize_answer(answer):
"""
Normalizes an answer to prevent lockouts from minor typos.
This trades a small amount of entropy for significantly better usability.
"My Dog", "my dog", " my dog " all become "my dog"
Security note: Lowercasing reduces character set from 62 to 36,
but prevents frustrating lockouts from caps lock or shift mistakes.
"""
# Strip, lowercase, and collapse multiple spaces
normalized = answer.strip().lower()
normalized = re.sub(r'\s+', ' ', normalized)
return normalized05 / ENCRYPTIE
Geverifieerd, dus manipulatie faalt hoorbaar.
AES-256 in GCM-modus: vertrouwelijkheid en integriteit uit één primitief, met een verse 96-bit nonce per bestand en geen padding om fout te doen. Ontsleutelen gebruikt `decrypt_and_verify`, wat betekent dat gewijzigde ciphertext een fout geeft in plaats van stilletjes geloofwaardig ogende onzin terug te geven.
salt = get_random_bytes(SALT_LENGTH)
key = generate_key(answers, salt)
nonce = get_random_bytes(NONCE_LENGTH)
# Versleutel data (geen padding nodig voor GCM)
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
encrypted_data, tag = cipher.encrypt_and_digest(data.encode('utf-8'))cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
decrypted_bytes = cipher.decrypt_and_verify(encrypted_data, tag)
decrypted_data = decrypted_bytes.decode('utf-8')06 / INTEGRITEIT
Corruptie en een fout antwoord zijn verschillende problemen.
GCM wijst een gemanipuleerd bestand al af — maar het kan de eigenaar niet vertellen wáárom, en "verkeerd antwoord" en "je USB-stick gaat kapot" vragen om heel andere reacties. Daarom draagt het bestand ook een HMAC-SHA256 over zijn eigen JSON, met het zout als sleutel. Die wordt gecontroleerd vóór er een antwoord gevraagd wordt, en scheidt een beschadigd bestand van een vertypt antwoord — inclusief de derde mogelijkheid die de meeste tools vergeten: de juiste data met het verkeerde zout.
# Bereken HMAC van bestandsdata met zout als sleutel
# Dit maakt het mogelijk om bestandscorruptie vs verkeerde antwoorden te detecteren
file_json = json.dumps(file_data, sort_keys=True)
hmac_obj = HMAC.new(salt, file_json.encode('utf-8'), digestmod=SHA256)
file_data["hmac"] = hmac_obj.hexdigest()stored_hmac = file_data.pop("hmac", None)
if stored_hmac:
# Herbereken HMAC om bestandsintegriteit te verifiëren
file_json = json.dumps(file_data, sort_keys=True)
hmac_obj = HMAC.new(salt, file_json.encode('utf-8'), digestmod=SHA256)
try:
hmac_obj.hexverify(stored_hmac)
except ValueError:
print(" BESTAND BESCHADIGD OF GEMANIPULEERD!")
print(" - Bestandscorruptie tijdens opslag/overdracht")
print(" - Opzettelijke manipulatie door een aanvaller")
print(" - Verkeerd zoutbestand gekoppeld aan dit databestand")
return07 / DE AIR GAP
Het weigert te draaien zolang je online bent.
Geen waarschuwing in de README maar een lus die het programma niet verlaat. Het vraagt NetworkManager naar de verbindingsstatus en controleert daarnaast onafhankelijk op een default route, omdat elk van beide op zichzelf fout kan zitten. Alleen een machine die op beide controles zakt, krijgt een afgeleide sleutel te zien.
# Methode 1: Check of NetworkManager zegt dat we verbonden zijn
try:
result = subprocess.run(
['nmcli', 'networking', 'connectivity', 'check'],
capture_output=True, text=True, timeout=10
)
status = result.stdout.strip().lower()
if status in ('full', 'limited', 'portal'):
return True
except Exception:
pass
# Methode 2: Check of er actieve netwerkinterfaces zijn (behalve lo)
try:
result = subprocess.run(
['ip', 'route', 'show', 'default'],
capture_output=True, text=True, timeout=5
)
if result.stdout.strip():
return True
except Exception:
pass
return False# Force user to disconnect internet
while True:
input("Druk op ENTER om offline status te verifiëren...")
if check_internet_connection(show_visual=True):
print(" [!] INTERNET GEDETECTEERD - VERBREEK DE VERBINDING")
else:
print(" [OK] VEILIG - GEEN INTERNETVERBINDING GEDETECTEERD")
time.sleep(1)
break08 / VLUCHTIGE UITVOER
Platte tekst raakt de USB nooit.
Ontsleutelde bestanden worden op het Tails-bureaublad geschreven, met /tmp als terugval — beide leven in RAM en zijn weg bij afsluiten. De versleutelde kant blijft op de stick; de ontsleutelde kant kan dat met opzet niet. Het klembord krijgt dezelfde behandeling via een daemon-thread, en wat op het scherm komt wist zichzelf op een timer.
def get_decrypted_files_dir():
"""
Retourneert ALTIJD de Tails Desktop voor ontsleutelde bestanden.
Dit zorgt ervoor dat ontsleutelde data NOOIT op USB blijft staan
en automatisch wordt gewist wanneer Tails afsluit.
BEVEILIGING: Ontsleutelde bestanden mogen NOOIT op permanente opslag!
"""
# Tails Desktop - wordt gewist bij shutdown
desktop_dir = os.path.expanduser('~/Desktop/qCrypt_DECRYPTED')
try:
os.makedirs(desktop_dir, exist_ok=True)
return desktop_dir
except (PermissionError, OSError):
pass
# Fallback naar /tmp (ook gewist bij shutdown)
tmp_dir = '/tmp/qCrypt_DECRYPTED'
os.makedirs(tmp_dir, exist_ok=True)
return tmp_dirdef clear_clipboard_after_delay(delay=CLIPBOARD_TIMEOUT):
"""
Start een achtergrond-thread die het klembord wist na een vertraging.
Dit voorkomt dat gevoelige data in het klembord blijft staan.
"""
if not CLIPBOARD_AVAILABLE:
return
def clear():
time.sleep(delay)
try:
_clipboard_copy('')
except Exception:
pass
thread = threading.Thread(target=clear, daemon=True)
thread.start()09 / DRAAGBAARHEID
Gebouwd voor een machine die je niet beheert.
Tails levert geen tkinter, kan PyCryptodome als `Crypto` óf `Cryptodome` aanbieden, verbiedt pip zonder `--break-system-packages` en stuurt alles via Tor. Alle vier worden opgevangen door te degraderen in plaats van te falen: een handmatige padvraag in plaats van een dialoog, beide importnamen geprobeerd, drie pip-aanroepen op volgorde waaronder één via torsocks.
# Tkinter is optioneel - voor bestandsselectie dialoog
# Tails heeft standaard GEEN tkinter, dus we maken het optioneel
TKINTER_AVAILABLE = False
try:
import tkinter as tk
from tkinter import filedialog
TKINTER_AVAILABLE = True
except ImportError:
pass # Tkinter niet beschikbaar - bestandsselectie wordt handmatig
# Probeer Crypto eerst, dan Cryptodome (Tails kan beide hebben)
try:
from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes
from Crypto.Hash import HMAC, SHA256
except ImportError:
from Cryptodome.Cipher import AES
from Cryptodome.Random import get_random_bytes
from Cryptodome.Hash import HMAC, SHA256pip_commands = [
['pip3', 'install', '--user', '--break-system-packages'],
['torsocks', 'pip3', 'install', '--user', '--break-system-packages'],
['python3', '-m', 'pip', 'install', '--user', '--break-system-packages'],
]
for pip_name, apt_name in missing:
installed = False
for pip_cmd in pip_commands:
try:
cmd = pip_cmd + [pip_name]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
if result.returncode == 0:
installed = True
break
except Exception:
continue10 / VERIFICATIE
Vertrouw de kopie pas nadat je hem gecontroleerd hebt.
Een versleutelde back-up die slecht gekopieerd is, is niet te onderscheiden van een goede — tot de dag dat je hem nodig hebt. Een begeleidend script legt de SHA-256-digests van elk meegeleverd bestand vast en sorteert — dat is het punt — een mismatch op gevolg: een gewijzigde `qCrypt.py` na een update is verwacht en veilig, een gewijzigde `.bin` is permanent dataverlies en zegt dat ook met zoveel woorden.
declare -A ORIGINAL_CHECKSUMS
ORIGINAL_CHECKSUMS["qCrypt.py"]="1b825d1eb48fb786f2…"
ORIGINAL_CHECKSUMS["encrypted_files/seed.bin"]="f5f05a1c7615a676ac…"
ORIGINAL_CHECKSUMS["encrypted_files/seed_salt.bin"]="9e6ac560775e2ed43c…"
verify_file() {
local filename="$1"
local original_checksum="$2"
local current_checksum=$(sha256sum "$filename" 2>/dev/null | cut -d' ' -f1)
if [[ "$current_checksum" == "$original_checksum" ]]; then
return 0
else
return 1
fi
}# Categoriseer de mismatch
if [[ "$filename" == "qCrypt.py" || "$filename" == "start.sh" ]]; then
code_mismatches+=("$filename")
elif [[ "$filename" == *".bin" ]]; then
critical_mismatches+=("$filename")
fi
# Een mismatch op de code is verwacht na een update; op een .bin is het dataverlies.
echo " Als de qCrypt code recent is bijgewerkt,"
echo " is een mismatch voor deze bestanden VERWACHT."
echo " Deze .bin bestanden bevatten je versleutelde data!"
echo " NEGEER DIT NIET! Dit leidt tot PERMANENT DATAVERLIES!"11 / HET FORMAAT
Twee bestanden, geversioneerd, en één dat het niet opent.
Zout en ciphertext worden apart weggeschreven zodat ze los bewaard kunnen worden — een gestolen back-up zonder zout is waardeloos. Elk bestand noteert zijn formaatversie, en de lezer weigert alles onder v3 ronduit in plaats van naar een oude indeling te gissen: een cryptografische tool die improviseert op onbekende invoer is een cryptografische tool met een bug.
save_dir = ENCRYPTED_FILES_DIR
filepath = os.path.join(save_dir, filename)
salt_file_path = os.path.join(save_dir, filename.replace(".bin", "_salt.bin"))
# Sla zout apart op
with open(salt_file_path, "wb") as salt_file:
salt_file.write(salt)# Controleer bestandsversie
version = file_data.get("version", 1)
if version < 3:
print("Dit bestand gebruikt een oud formaat (v" + str(version) + ")")
print("Alleen versie 3+ bestanden kunnen worden ontsleuteld.")
return