Yasiba← Terug naar geselecteerde cases

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.

KlantqCrypt
SectorBeveiligingstooling
Wat we dedenDreigingsmodelleringCryptografische engineeringPython- / Tails-toolingOperationele documentatie
Jaar2025

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

128MB RAM per wachtwoordpoging
256bit sleutels, geverifieerd met GCM
32byte zout, uniek per bestand
0antwoorden bewaard, gehasht of herstelbaar

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.

VERSLEUTELEN
  1. 01Vragen + antwoordenéén keer getypt, blijft in RAM
  2. 02Normaliserentrimmen · kleine letters · spaties samenvoegen
  3. 03Argon2id128 MB · 4 passes · 32-byte zout
  4. 04AES-256-GCM96-bit nonce · 128-bit auth tag
Weggegooiddel data, answers, key
OP SCHIJF
naam.binnonce · tag · vragen · ciphertext · hmac
naam_salt.bin32 willekeurige bytes — apart bewaren
ONTSLEUTELEN
  1. 05HMAC controlerenmet het zout, vóór er iets gevraagd wordt
  2. 06Vragen tonenin platte tekst uit het bestand gelezen
  3. 07Argon2idzelfde zout + zelfde antwoorden → zelfde sleutel
  4. 08decrypt_and_verifyfaalt bij een gemanipuleerd bestand
Platte tekstalleen RAM — weg bij afsluiten
Niets op dit schema wordt ooit verzonden, en de sleutel bestaat alleen tussen stap 3 en 4.

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.

qCrypt.py — encrypt_data()python
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, key

03 / 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.

qCrypt.py — beveiligingsconfiguratiepython
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)
qCrypt.py — generate_key()python
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 key

04 / 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.

qCrypt.py — normalize_answer()python
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 normalized

05 / 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.

qCrypt.py — encrypt_data()python
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'))
qCrypt.py — decrypt_data()python
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.

qCrypt.py — schrijvenpython
# 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()
qCrypt.py — verifiërenpython
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")
        return

07 / 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.

qCrypt.py — check_internet_connection()python
# 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
qCrypt.py — install_dependencies()python
# 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)
        break

08 / 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.

qCrypt.py — get_decrypted_files_dir()python
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_dir
qCrypt.py — clear_clipboard_after_delay()python
def 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.

qCrypt.py — importspython
# 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, SHA256
qCrypt.py — install_dependencies()python
pip_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:
            continue

10 / 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.

checksum.shbash
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
}
checksum.sh — categorisatiebash
# 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.

qCrypt.py — twee bestandenpython
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)
qCrypt.py — decrypt_data()python
# 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