É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.
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
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.
- 01Questions + réponsessaisies une fois, gardées en RAM
- 02Normaliserélaguer · minuscules · espaces réduits
- 03Argon2id128 Mo · 4 passes · sel de 32 octets
- 04AES-256-GCMnonce 96 bits · étiquette 128 bits
del data, answers, key- 05Vérifier le HMACavec le sel, avant toute question
- 06Afficher les questionslues en clair dans le fichier
- 07Argon2idmême sel + mêmes réponses → même clé
- 08decrypt_and_verifylève une erreur si le fichier est altéré
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.
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 / 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.
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
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.
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 / 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.
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 / 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.
# 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 / 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.
# 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 / 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.
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É
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.
# 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 / 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.
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 / 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é.
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