CASE STUDY / 04
Encryption that remembers nothing.
qCrypt turns a handful of personal security questions into an AES-256 key, and keeps no part of the answers — not the text, not a hash, not a hint. It runs on Tails, refuses to start while the machine is online, and leaves nothing behind when the machine powers down.
01 / The brief
A vault whose key exists only in someone's memory.
Password managers move the problem rather than solve it: there is still a master secret somewhere to be phished, seized, or lost with a database. The brief was a tool where losing the device, the backup and the developer all cost the owner nothing — and where forgetting the answers is the acknowledged, deliberate, unrecoverable failure mode.
AT A GLANCE
THE PRIMITIVES
- Cipher
- AES-256-GCM · 96-bit nonce
- Key derivation
- Argon2id · 128 MB · 4 passes
- Integrity
- HMAC-SHA256 · keyed by the salt
- Host
- Python 3 · Tails OS · air-gapped
Excerpts are abridged — console output and colour codes are dropped so the logic reads at a glance. Every line shown is from the shipped source.
THE ROUND TRIP
Where the key comes from, and where it goes.
- 01Questions + answerstyped once, held in RAM
- 02Normalisetrim · lowercase · collapse spaces
- 03Argon2id128 MB · 4 passes · 32-byte salt
- 04AES-256-GCM96-bit nonce · 128-bit auth tag
del data, answers, key- 05Verify HMACkeyed by the salt, before anything is asked
- 06Show the questionsread from the file in plain text
- 07Argon2idsame salt + same answers → same key
- 08decrypt_and_verifyraises on a tampered file
02 / WHAT REACHES DISK
Read the record. Notice what is missing.
This is the entire persisted artefact. There is a nonce, an authentication tag, the questions in plain text, and the ciphertext — and nothing derived from the answers at all. The questions are not the secret, so storing them costs nothing and means a returning owner can be prompted properly. Then the answers and the key are dropped on the next line.
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 / KEY DERIVATION
128 MB of RAM per guess.
Argon2id with 128 MB of memory, four passes and four threads. Memory-hardness is the point: a GPU farm can parallelise SHA-256 almost for free, but it cannot cheaply give a hundred thousand cores 128 MB each. Note also what the code refuses to do — pre-hashing the answers would cap their entropy at 256 bits, so they are passed to Argon2 intact, joined by a separator no answer will contain.
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 / NORMALISATION
A trade-off argued in the source, not hidden in it.
Answers are lowercased, trimmed and space-collapsed before derivation. That is a real reduction in the character set, and the docstring says so out loud rather than quietly shipping it — because the alternative is an owner permanently locked out of their own data by a caps-lock key. Security work is full of these; the honest ones are written down.
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 / ENCRYPTION
Authenticated, so tampering fails loudly.
AES-256 in GCM mode: confidentiality and integrity from one primitive, with a fresh 96-bit nonce per file and no padding to get wrong. Decryption calls `decrypt_and_verify`, which means a modified ciphertext raises rather than silently returning plausible-looking garbage.
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 / INTEGRITY
Corruption and a wrong answer are different problems.
GCM already rejects a tampered file — but it cannot tell the owner *why* it failed, and "wrong answer" and "your USB stick is dying" call for very different responses. So the file also carries an HMAC-SHA256 over its own JSON, keyed by the salt. Verified before any answer is requested, it separates a corrupted file from a mistyped one, and names the third possibility most tools forget: the right data paired with the wrong 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 / THE AIR GAP
It refuses to run while you are online.
Not a warning in the README — a loop the program will not leave. It asks NetworkManager for its connectivity state, then independently checks for a default route, because either alone can be wrong. Only a machine that fails both checks gets to see a key derived.
# 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 / EPHEMERAL OUTPUT
Plaintext never touches the USB.
Decrypted files are written to the Tails desktop, falling back to /tmp — both of which live in RAM and are gone at shutdown. The encrypted side stays on the stick; the decrypted side deliberately cannot. The clipboard gets the same treatment from a daemon thread, and anything shown on screen clears itself on a 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 / PORTABILITY
Built for a machine you do not control.
Tails ships no tkinter, may expose PyCryptodome as either `Crypto` or `Cryptodome`, forbids pip without `--break-system-packages`, and routes everything through Tor. All four are handled by degrading rather than failing: a manual path prompt instead of a file dialog, both import names attempted, three pip invocations tried in turn including one 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 / VERIFICATION
Trust the copy only after checking it.
An encrypted backup that copied badly is indistinguishable from one that copied well until the day you need it. A companion script pins SHA-256 digests of every shipped file, and — the part that matters — sorts a mismatch by consequence: a changed `qCrypt.py` after an update is expected and safe, a changed `.bin` is permanent data loss and says so in those words.
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 / THE FORMAT
Two files, versioned, and one it will not open.
Salt and ciphertext are written separately so they can be stored apart — a stolen backup without its salt is inert. Each file records its format version, and the reader refuses anything below v3 outright instead of guessing at an old layout, because a cryptographic tool that improvises on unfamiliar input is a cryptographic tool with a bug in it.
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