Yasiba← Zurück zu allen Cases

CASE STUDY / 04

Verschlüsselung, die sich nichts merkt.

qCrypt macht aus einer Handvoll persönlicher Sicherheitsfragen einen AES-256-Schlüssel und behält keinen Teil der Antworten — keinen Text, keinen Hash, keinen Hinweis. Es läuft auf Tails, verweigert den Start, solange die Maschine online ist, und hinterlässt beim Herunterfahren nichts.

KundeqCrypt
BrancheSecurity-Werkzeuge
Was wir gemacht habenBedrohungsmodellierungKryptografische EntwicklungPython- / Tails-WerkzeugeBetriebsdokumentation
Jahr2025

01 / Die Aufgabe

Ein Tresor, dessen Schlüssel nur im Gedächtnis existiert.

Passwortmanager verschieben das Problem, statt es zu lösen: irgendwo bleibt ein Hauptgeheimnis, das gephisht, beschlagnahmt oder mit einer Datenbank verloren werden kann. Gefragt war ein Werkzeug, bei dem der Verlust des Geräts, des Backups und der Entwickler die Besitzerin nichts kostet — und bei dem das Vergessen der Antworten der anerkannte, bewusste, unwiderrufliche Fehlerfall ist.

IN ZAHLEN

128MB RAM pro Rateversuch
256Bit Schlüssel, mit GCM authentifiziert
32Byte Salt, pro Datei einmalig
0gespeicherte, gehashte oder wiederherstellbare Antworten

DIE PRIMITIVE

Cipher
AES-256-GCM · 96-Bit-Nonce
Schlüsselableitung
Argon2id · 128 MB · 4 Durchgänge
Integrität
HMAC-SHA256 · mit dem Salt als Schlüssel
Host
Python 3 · Tails OS · Air-Gap

Auszüge sind gekürzt — Konsolenausgabe und Farbcodes entfallen, damit die Logik auf einen Blick lesbar bleibt. Jede gezeigte Zeile stammt aus dem ausgelieferten Quelltext.

DER RUNDWEG

Woher der Schlüssel kommt und wohin er geht.

VERSCHLÜSSELN
  1. 01Fragen + Antworteneinmal getippt, bleibt im RAM
  2. 02Normalisierentrimmen · kleinschreiben · Leerzeichen
  3. 03Argon2id128 MB · 4 Durchgänge · 32-Byte-Salt
  4. 04AES-256-GCM96-Bit-Nonce · 128-Bit-Auth-Tag
Verworfendel data, answers, key
AUF DER PLATTE
name.binNonce · Tag · Fragen · Chiffretext · HMAC
name_salt.bin32 zufällige Bytes — getrennt aufbewahren
ENTSCHLÜSSELN
  1. 05HMAC prüfenmit dem Salt, bevor irgendetwas gefragt wird
  2. 06Fragen anzeigenim Klartext aus der Datei gelesen
  3. 07Argon2idgleiches Salt + gleiche Antworten → gleicher Schlüssel
  4. 08decrypt_and_verifywirft bei manipulierter Datei
Klartextnur RAM — beim Herunterfahren fort
Nichts in diesem Diagramm wird je übertragen, und der Schlüssel existiert nur zwischen Schritt 3 und 4.

02 / WAS AUF DIE PLATTE KOMMT

Lies den Datensatz. Achte auf das Fehlende.

Das ist das vollständige gespeicherte Artefakt. Es gibt eine Nonce, ein Authentifizierungs-Tag, die Fragen im Klartext und den Chiffretext — und nichts, was aus den Antworten abgeleitet wäre. Die Fragen sind nicht das Geheimnis, ihr Speichern kostet also nichts und erlaubt es, eine zurückkehrende Besitzerin sauber abzufragen. Danach werden Antworten und Schlüssel in der nächsten Zeile verworfen.

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 / SCHLÜSSELABLEITUNG

128 MB RAM pro Versuch.

Argon2id mit 128 MB Speicher, vier Durchgängen und vier Threads. Die Speicherhärte ist genau der Punkt: eine GPU-Farm parallelisiert SHA-256 nahezu umsonst, kann aber nicht günstig hunderttausend Kernen je 128 MB geben. Bemerkenswert ist auch, was der Code nicht tut — ein Vorab-Hash der Antworten würde ihre Entropie auf 256 Bit deckeln, also gehen sie unverändert an Argon2, verbunden durch ein Trennzeichen, das in keiner Antwort vorkommt.

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 / NORMALISIERUNG

Eine Abwägung, im Quelltext begründet statt versteckt.

Antworten werden vor der Ableitung kleingeschrieben, getrimmt und von Mehrfachleerzeichen befreit. Das verkleinert den Zeichensatz tatsächlich, und der Docstring sagt es laut, statt es still mitzuliefern — denn die Alternative ist eine Besitzerin, die eine Feststelltaste dauerhaft aussperrt. Sicherheitsarbeit ist voll solcher Abwägungen; die ehrlichen werden aufgeschrieben.

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 / VERSCHLÜSSELUNG

Authentifiziert, damit Manipulation hörbar scheitert.

AES-256 im GCM-Modus: Vertraulichkeit und Integrität aus einem Primitiv, mit frischer 96-Bit-Nonce je Datei und ohne Padding, das man falsch machen könnte. Entschlüsselt wird mit `decrypt_and_verify` — veränderter Chiffretext wirft also einen Fehler, statt still plausibel aussehenden Unsinn zurückzugeben.

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 / INTEGRITÄT

Beschädigung und falsche Antwort sind verschiedene Probleme.

GCM weist eine manipulierte Datei bereits ab — kann der Besitzerin aber nicht sagen, warum, und "falsche Antwort" und "dein USB-Stick stirbt" verlangen sehr verschiedene Reaktionen. Deshalb trägt die Datei zusätzlich einen HMAC-SHA256 über ihr eigenes JSON, mit dem Salt als Schlüssel. Er wird geprüft, bevor überhaupt eine Antwort erfragt wird, und trennt die beschädigte Datei von der vertippten — samt der dritten Möglichkeit, die die meisten Werkzeuge vergessen: richtige Daten mit dem falschen Salt.

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 / DER AIR GAP

Es weigert sich zu laufen, solange du online bist.

Keine Warnung in der README, sondern eine Schleife, die das Programm nicht verlässt. Es fragt den NetworkManager nach dem Verbindungsstatus und prüft unabhängig davon auf eine Default-Route, weil jede der beiden für sich irren kann. Nur eine Maschine, die an beiden Prüfungen scheitert, bekommt einen abgeleiteten Schlüssel zu sehen.

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 / FLÜCHTIGE AUSGABE

Klartext berührt den USB-Stick nie.

Entschlüsselte Dateien landen auf dem Tails-Desktop, ersatzweise in /tmp — beides liegt im RAM und ist beim Herunterfahren fort. Die verschlüsselte Seite bleibt auf dem Stick; die entschlüsselte kann es bewusst nicht. Die Zwischenablage bekommt dieselbe Behandlung über einen Daemon-Thread, und was auf dem Bildschirm erscheint, löscht sich per 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 / PORTABILITÄT

Gebaut für eine Maschine, die dir nicht gehört.

Tails liefert kein tkinter, kann PyCryptodome als `Crypto` oder `Cryptodome` bereitstellen, verbietet pip ohne `--break-system-packages` und leitet alles über Tor. Alle vier werden durch Degradieren statt Scheitern aufgefangen: eine manuelle Pfadabfrage statt eines Dialogs, beide Importnamen versucht, drei pip-Aufrufe der Reihe nach, einer davon über 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 / VERIFIKATION

Der Kopie erst trauen, wenn sie geprüft ist.

Ein schlecht kopiertes verschlüsseltes Backup ist von einem guten nicht zu unterscheiden — bis zu dem Tag, an dem man es braucht. Ein Begleitskript hält die SHA-256-Digests jeder ausgelieferten Datei fest und sortiert — das ist der Punkt — eine Abweichung nach Konsequenz: eine geänderte `qCrypt.py` nach einem Update ist erwartbar und harmlos, eine geänderte `.bin` ist endgültiger Datenverlust und sagt das genau so.

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 / DAS FORMAT

Zwei Dateien, versioniert, und eine, die es nicht öffnet.

Salt und Chiffretext werden getrennt geschrieben, damit sie getrennt aufbewahrt werden können — ein gestohlenes Backup ohne Salt ist wertlos. Jede Datei notiert ihre Formatversion, und der Leser verweigert alles unter v3 rundheraus, statt ein altes Layout zu erraten: ein kryptografisches Werkzeug, das bei unbekannter Eingabe improvisiert, ist ein kryptografisches Werkzeug mit einem Fehler.

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