Yasiba← Retour à tous les cas

ÉTUDE DE CAS / 04

Un chiffrement qui ne retient rien.

qCrypt transforme quelques questions de sécurité personnelles en une clé AES-256 et ne conserve aucune partie des réponses — ni le texte, ni un hachage, ni un indice. Il tourne sur Tails, refuse de démarrer tant que la machine est en ligne, et ne laisse rien derrière lui à l’extinction.

ClientqCrypt
SecteurOutils de sécurité
Ce que nous avons faitModélisation des menacesIngénierie cryptographiqueOutillage Python / TailsDocumentation opérationnelle
Année2025

01 / Le brief

Un coffre dont la clé n’existe que dans une mémoire.

Les gestionnaires de mots de passe déplacent le problème au lieu de le résoudre : il reste quelque part un secret maître, susceptible d’être hameçonné, saisi ou perdu avec une base de données. Le brief : un outil où perdre l’appareil, la sauvegarde et le développeur ne coûte rien au propriétaire — et où oublier ses réponses est le mode de défaillance assumé, délibéré et irréversible.

EN CHIFFRES

128Mo de RAM par tentative
256bits de clé, authentifiés en GCM
32octets de sel, uniques par fichier
0réponse stockée, hachée ou récupérable

LES PRIMITIVES

Chiffrement
AES-256-GCM · nonce 96 bits
Dérivation
Argon2id · 128 Mo · 4 passes
Intégrité
HMAC-SHA256 · clé dérivée du sel
Hôte
Python 3 · Tails OS · hors ligne

Les extraits sont abrégés — sorties console et codes couleur retirés pour que la logique se lise d’un coup d’œil. Chaque ligne montrée provient du code livré.

L’ALLER-RETOUR

D’où vient la clé, et où elle finit.

CHIFFRER
  1. 01Questions + réponsessaisies une fois, gardées en RAM
  2. 02Normaliserélaguer · minuscules · espaces réduits
  3. 03Argon2id128 Mo · 4 passes · sel de 32 octets
  4. 04AES-256-GCMnonce 96 bits · étiquette 128 bits
Jetédel data, answers, key
SUR LE DISQUE
nom.binnonce · étiquette · questions · chiffré · hmac
nom_salt.bin32 octets aléatoires — à ranger à part
DÉCHIFFRER
  1. 05Vérifier le HMACavec le sel, avant toute question
  2. 06Afficher les questionslues en clair dans le fichier
  3. 07Argon2idmême sel + mêmes réponses → même clé
  4. 08decrypt_and_verifylève une erreur si le fichier est altéré
Texte en clairRAM uniquement — disparaît à l’extinction
Rien de ce schéma n’est jamais transmis, et la clé n’existe qu’entre les étapes 3 et 4.

02 / CE QUI ATTEINT LE DISQUE

Lisez l’enregistrement. Notez ce qui manque.

Voici l’intégralité de ce qui est écrit. Un nonce, une étiquette d’authentification, les questions en clair et le chiffré — et rien qui dérive des réponses. Les questions ne sont pas le secret : les stocker ne coûte rien et permet d’interroger correctement un propriétaire qui revient. Les réponses et la clé sont ensuite jetées à la ligne suivante.

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 / DÉRIVATION DE CLÉ

128 Mo de RAM par tentative.

Argon2id avec 128 Mo de mémoire, quatre passes et quatre fils. La dureté mémoire est tout l’intérêt : une ferme de GPU parallélise SHA-256 presque gratuitement, mais ne peut pas offrir à bas coût 128 Mo à chacun de cent mille cœurs. Notez aussi ce que le code refuse de faire : pré-hacher les réponses plafonnerait leur entropie à 256 bits, elles partent donc intactes vers Argon2, jointes par un séparateur qu’aucune réponse ne contiendra.

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

Un arbitrage argumenté dans le code, pas dissimulé.

Les réponses sont mises en minuscules, élaguées et débarrassées des espaces multiples avant dérivation. C’est une vraie réduction du jeu de caractères, et la docstring le dit à voix haute plutôt que de le livrer en silence — car l’alternative est un propriétaire définitivement exclu par une touche de verrouillage majuscule. Le travail de sécurité est plein de ces arbitrages ; les honnêtes sont écrits.

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

Authentifié, donc l’altération échoue bruyamment.

AES-256 en mode GCM : confidentialité et intégrité issues d’une seule primitive, avec un nonce de 96 bits neuf par fichier et aucun remplissage à rater. Le déchiffrement passe par `decrypt_and_verify` : un chiffré modifié lève une erreur au lieu de rendre discrètement un charabia plausible.

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 / INTÉGRITÉ

Corruption et mauvaise réponse sont deux problèmes distincts.

GCM rejette déjà un fichier altéré — mais ne peut pas dire au propriétaire pourquoi, et « mauvaise réponse » et « votre clé USB meurt » appellent des réactions très différentes. Le fichier porte donc aussi un HMAC-SHA256 sur son propre JSON, avec le sel pour clé. Vérifié avant même de demander une réponse, il sépare le fichier corrompu de la faute de frappe — et nomme la troisième possibilité que la plupart des outils oublient : les bonnes données avec le mauvais sel.

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 / LA COUPURE RÉSEAU

Il refuse de tourner tant que vous êtes en ligne.

Pas un avertissement dans le README, mais une boucle dont le programme ne sort pas. Il interroge NetworkManager sur l’état de connectivité, puis vérifie indépendamment la présence d’une route par défaut, car chacun des deux peut se tromper seul. Seule une machine qui échoue aux deux contrôles voit une clé dérivée.

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 / SORTIE ÉPHÉMÈRE

Le clair ne touche jamais la clé USB.

Les fichiers déchiffrés sont écrits sur le bureau Tails, avec /tmp en repli — tous deux en RAM et effacés à l’extinction. Le côté chiffré reste sur la clé ; le côté déchiffré ne le peut délibérément pas. Le presse-papiers subit le même traitement via un fil démon, et ce qui s’affiche à l’écran s’efface sur minuterie.

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É

Conçu pour une machine qui n’est pas la vôtre.

Tails ne fournit pas tkinter, peut exposer PyCryptodome sous `Crypto` ou `Cryptodome`, interdit pip sans `--break-system-packages` et fait tout passer par Tor. Les quatre sont absorbés en dégradant plutôt qu’en échouant : une saisie manuelle de chemin au lieu d’une boîte de dialogue, les deux noms d’import tentés, trois invocations de pip essayées tour à tour dont une 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 / VÉRIFICATION

Ne faites confiance à la copie qu’après l’avoir vérifiée.

Une sauvegarde chiffrée mal copiée est indiscernable d’une bonne — jusqu’au jour où on en a besoin. Un script compagnon fige les empreintes SHA-256 de chaque fichier livré et trie — c’est là l’essentiel — les écarts par conséquence : un `qCrypt.py` modifié après une mise à jour est attendu et sans danger, un `.bin` modifié est une perte de données définitive, et il le dit en ces termes.

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

Deux fichiers, versionnés, et un qu’il refuse d’ouvrir.

Sel et chiffré sont écrits séparément afin d’être conservés à part — une sauvegarde volée sans son sel est inerte. Chaque fichier note sa version de format, et le lecteur refuse net tout ce qui est antérieur à la v3 au lieu de deviner une ancienne disposition : un outil cryptographique qui improvise sur une entrée inconnue est un outil cryptographique bogué.

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