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.
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
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.
- 01Fragen + Antworteneinmal getippt, bleibt im RAM
- 02Normalisierentrimmen · kleinschreiben · Leerzeichen
- 03Argon2id128 MB · 4 Durchgänge · 32-Byte-Salt
- 04AES-256-GCM96-Bit-Nonce · 128-Bit-Auth-Tag
del data, answers, key- 05HMAC prüfenmit dem Salt, bevor irgendetwas gefragt wird
- 06Fragen anzeigenim Klartext aus der Datei gelesen
- 07Argon2idgleiches Salt + gleiche Antworten → gleicher Schlüssel
- 08decrypt_and_verifywirft bei manipulierter Datei
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.
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 / 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.
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 / 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.
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 / 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.
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 / 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.
# 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 / 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.
# 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 / 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.
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 / 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.
# 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 / 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.
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 / 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.
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