Yasiba← Back to all cases

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.

ClientqCrypt
IndustrySecurity tooling
What we didThreat modellingCryptographic engineeringPython / Tails toolingOperational documentation
Year2025

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

128MB of RAM per password guess
256bit keys, authenticated with GCM
32byte salt, unique per file
0answers stored, hashed or recoverable

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.

ENCRYPT
  1. 01Questions + answerstyped once, held in RAM
  2. 02Normalisetrim · lowercase · collapse spaces
  3. 03Argon2id128 MB · 4 passes · 32-byte salt
  4. 04AES-256-GCM96-bit nonce · 128-bit auth tag
Discardeddel data, answers, key
ON DISK
name.binnonce · tag · questions · ciphertext · hmac
name_salt.bin32 random bytes — store separately
DECRYPT
  1. 05Verify HMACkeyed by the salt, before anything is asked
  2. 06Show the questionsread from the file in plain text
  3. 07Argon2idsame salt + same answers → same key
  4. 08decrypt_and_verifyraises on a tampered file
PlaintextRAM only — gone at shutdown
Nothing on this diagram is ever transmitted, and the key exists only between steps 3 and 4.

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.

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

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

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

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

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

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

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

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

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

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