Alice Bob
│ │
│◄──────── Échange clés publiques ──────────►│
│ (QR code v2 : X25519 + ML-KEM) │
│ │
│ shared_secret = X25519(sk_A, pk_B) │ shared_secret = X25519(sk_B, pk_A)
│ root_key = HKDF(shared_secret) │ root_key = HKDF(shared_secret)
│ send_chain = HKDF(root, "init-send") │ recv_chain = HKDF(root, "init-send")
│ recv_chain = HKDF(root, "init-recv") │ send_chain = HKDF(root, "init-recv")
│ │
│ ┌─ PQXDH (premier message) ────────────┐ │
│ │ kem_ct = ML-KEM-1024.Encaps(pk_kem_B) │ │
│ │ kem_ss = shared secret ML-KEM │ │
│ │ root_key' = HKDF(root_key || kem_ss) │ │
│ └──────────────────────────────────────┘ │
│ │
│ msg_key = HMAC(send_chain, 0x01) │
│ send_chain = HMAC(send_chain, 0x02) │
│ ct = AES-GCM(msg_key, iv, plaintext) │
│ │
│ ──── {ct, iv, ephKey, kemCiphertext} ────►│
│ (Tor P2P .onion) │
│ │
│ │ kem_ss = ML-KEM-1024.Decaps(sk_kem_B, kem_ct)
│ │ root_key' = HKDF(root_key || kem_ss)
│ │ msg_key = HMAC(recv_chain, 0x01)
│ │ recv_chain = HMAC(recv_chain, 0x02)
│ │ plaintext = AES-GCM-dec(msg_key, iv, ct)
- Seed Ed25519 (32 bytes) généré au premier lancement — secret maître unique
- Seed → Android Keystore (StrongBox si disponible)
- À partir du seed, dérivation de :
- Ed25519 paire de clés (signature + identité maître)
- Account ID = SHA3-256(pubkey) → Base58 (ex :
Fa3x...9Z) - Adresse .onion (encodage Tor v3 depuis la pubkey Ed25519)
- X25519 clé DH (birational map Edwards → Montgomery)
- ML-KEM-1024 paire de clés (HKDF(seed, "fialka-ml-kem", 64 bytes) → KeyGen déterministe)
- Empreinte = SHA-256 → 16 emojis (96-bit)
- Clé publique → Base64 + QR code pour partage
- Backup : seed → 24 mots BIP-39 (256 bits + 8-bit checksum SHA-256)
- Restauration : 24 mots → seed → re-dérive toutes les clés (Ed25519, X25519, ML-KEM, .onion)
- Alice montre son QR code (ou partage sa clé publique)
- Bob scanne le QR → le pseudo d'Alice est pré-rempli automatiquement → crée le contact
- Chaque côté calcule :
shared_secret = X25519(ma_clé_privée, clé_publique_contact) - Le rôle (initiator/responder) est déterminé par l'ordre lexicographique des clés publiques
- Le QR v2 encode aussi la clé publique ML-KEM-1024 pour l'upgrade PQXDH
Format QR v2 :
fialka://contact?key=<X25519_base64>&kem=<ML-KEM-1024_base64>&name=<displayName>
Chaque conversation a une empreinte partagée calculée à partir des deux clés publiques :
sorted_keys = sort_lexicographic(pubKeyA, pubKeyB)
hash = SHA-256(sorted_keys[0] + sorted_keys[1])
fingerprint = 16 emojis choisis dans une palette de 64
= 16 × log2(64) = 96 bits d'entropie
Format : 🔥🐱🦄🍕 🌟🚀💎⚡ 🎸📱🔔🎉 🌈🐶🎯🍀 (4 × 4 emojis)
Les deux téléphones calculent la même empreinte. L'utilisateur compare visuellement (en personne ou par appel vidéo) pour détecter une attaque MITM.
- ✅ Palette de 64 emojis (puissance de 2 → zéro biais modulo)
- ✅ 96 bits d'entropie (7.9 × 10²⁸ combinaisons)
- ✅ Badge dans le chat : ✅ Vérifié /
⚠️ Non vérifié - ✅ Vérification indépendante par utilisateur (état local Room uniquement)
- ✅ Messages système dans le chat lors de la vérification/retrait (avec lien cliquable "Voir l'empreinte")
- ✅ Notification événementielle via Tor P2P — notifie le pair, ne synchronise pas l'état
En plus de la comparaison visuelle des emojis, les utilisateurs peuvent vérifier l'empreinte via QR code :
sorted_keys = sort_lexicographic(pubKeyA, pubKeyB)
hash = SHA-256(sorted_keys[0] + sorted_keys[1])
qr_data = hex(hash) // 64 caractères ASCII (a-f0-9)
- ✅ Le QR encode le SHA-256 en hexadécimal (pas les emojis) pour éviter les problèmes d'encodage Unicode
- ✅ Méthode
getSharedFingerprintHex()dans CryptoManager - ✅ Scanner utilise
CustomScannerActivity(identique à l'invitation de contact) - ✅ Comparaison hex
ignoreCase = true(case-insensitive) - ✅ Vérification automatique : scan → match → dialogue ✅ ; mismatch → dialogue ❌ MITM
Initialisation (à l'acceptation du contact) :
root_key = HKDF(shared_secret, "Fialka-DR-root")
send_chain = HKDF(root_key, "Fialka-DR-chain-init-send")
recv_chain = HKDF(root_key, "Fialka-DR-chain-init-recv") (swapped pour responder)
ephemeral = X25519.generateKeyPair()
Pour chaque message N (KDF chain) :
message_key[N] = HMAC-SHA256(chain_key[N], 0x01) ← clé unique
chain_key[N+1] = HMAC-SHA256(chain_key[N], 0x02) ← avancement irréversible
DH Ratchet (healing) — quand l'éphémère remote change :
dh_secret = X25519(local_ephemeral_priv, remote_ephemeral_pub)
new_root_key = HKDF(root_key || dh_secret, "root-ratchet")
new_chain = HKDF(root_key || dh_secret, "chain-ratchet")
→ Nouvelle clé éphémère locale générée
plaintext → pad(plaintext) → AES-256-GCM(message_key[N], random_iv_12B) → ciphertext
Avant chiffrement, chaque message est paddé vers le bucket supérieur :
| Taille message | Bucket |
|---|---|
| ≤ 256 B | 256 B |
| ≤ 1 KB | 1 KB |
| ≤ 4 KB | 4 KB |
| > 4 KB | 16 KB |
- Header : 2 octets (Big-Endian) = longueur réelle du plaintext
- Remplissage :
SecureRandombytes jusqu'à la taille du bucket - Suppression du padding à la réception via le header 2 octets
- ✅ Chaque message a sa propre clé de chiffrement (KDF chain)
- ✅ L'avancement de la chaîne est irréversible (one-way function)
- ✅ Healing : compromission d'une chain key → DH ratchet guérit au prochain échange
- ✅ Compromettre la clé actuelle ne révèle pas les clés passées
- ✅ Les clés intermédiaires sont zérorisées de la mémoire après usage
- ✅ HKDF IKM, PRK et expandInput zérorisés après chaque dérivation
- ✅ Encodage/décodage mnemonic zérorise tous les tableaux d'octets intermédiaires et nettoie le StringBuilder
- ✅ Clés éphémères X25519 renouvelées à chaque changement de direction
Chaque message est signé avec une clé Ed25519 dédiée (séparée de la clé d'identité X25519) via Fialka-Core (Rust, JNI).
Envoi :
signingKeyPair = getOrDeriveSigningKeyPair() (FialkaSecurePrefs)
dataToSign = ciphertext.UTF8 || conversationId.UTF8 || createdAt.bigEndian8bytes
signature = Ed25519.sign(signingKeyPair.private, dataToSign)
→ envoyé dans le message via Tor : { ..., "signature": Base64(signature) }
Réception :
signingPublicKey = fetchSigningPublicKeyByIdentity(contact.publicKey)
dataToVerify = ciphertext.UTF8 || conversationId.UTF8 || createdAt.bigEndian8bytes
valid = Ed25519.verify(signingPublicKey, dataToVerify, signature)
→ Badge : ✅ (valid=true) ou ⚠️ (valid=false ou clé absente)
- ✅ Anti-falsification : seul le détenteur de la clé privée Ed25519 peut signer
- ✅ Anti-replay inter-conversation :
conversationIdinclus dans les données signées - ✅ Anti-manipulation temporelle :
createdAt(timestamp client) inclus dans les données signées - ✅ Clé de signature séparée de la clé d'identité X25519 (pas de mélange des usages)
- ✅ Nettoyage : clé de signature effacée localement à la suppression du compte
Fialka implémente un échange de clés hybride combinant X25519 (classique) et ML-KEM-1024 (post-quantique) via le protocole PQXDH.
À l'ajout du contact (QR scan) :
Les clés publiques X25519 ET ML-KEM-1024 sont échangées via le QR code v2.
La conversation démarre en mode classique X25519 uniquement (root_key classique).
Premier message (initiator) :
kem_ct, kem_ss = ML-KEM-1024.Encaps(contact_kem_publicKey)
root_key' = HKDF(root_key || kem_ss, "pqxdh-upgrade")
→ message via Tor inclut { ..., "kemCiphertext": Base64(kem_ct) }
→ root_key upgradé localement (chains recalculées)
Réception du premier message (responder) :
kem_ss = ML-KEM-1024.Decaps(my_kem_privateKey, kemCiphertext)
root_key' = HKDF(root_key || kem_ss, "pqxdh-upgrade")
→ root_key upgradé localement (chains recalculées)
Messages suivants :
kemCiphertext n'est plus envoyé (upgrade unique)
Double Ratchet continue avec root_key' (hybride)
- ✅ Résistance post-quantique : même si X25519 est cassé par un ordinateur quantique, ML-KEM-1024 protège le root_key
- ✅ Upgrade différée : pas de message bootstrap — l'upgrade se fait au premier vrai message
- ✅ Aucune régression : si ML-KEM échoue, la conversation reste protégée par X25519 classique
- ✅ Fialka-Core (Rust) : implémentation ML-KEM-1024 native, byte-for-byte compatible avec BouncyCastle 1.83 (migration transparente)
- ✅ StrongBox probe :
DeviceSecurityManagerdétecte le support matériel StrongBox pour la protection des clés
Après l'upgrade PQXDH initiale, le Double Ratchet classique reprend avec des échanges X25519 uniquement. SPQR (Supplementary Post-Quantum Ratchet) ajoute une ré-encapsulation ML-KEM-1024 périodique pour maintenir la résistance post-quantique au fil du temps.
Toutes les PQ_RATCHET_INTERVAL = 10 messages envoyés :
Sender (Alice) :
kem_ct, kem_ss = ML-KEM-1024.Encaps(contact_kem_publicKey)
root_key' = HKDF(root_key, kem_ss, info="Fialka-SPQR-pq-ratchet")
→ message via Tor inclut { ..., "kemCiphertext": Base64(kem_ct) }
→ pqRatchetCounter remis à 0
Receiver (Bob) :
Si pqxdhInitialized ET kemCiphertext présent ET pas un PQXDH initial :
kem_ss = ML-KEM-1024.Decaps(my_kem_privateKey, kemCiphertext)
root_key' = HKDF(root_key, kem_ss, info="Fialka-SPQR-pq-ratchet")
→ pqRatchetCounter remis à 0
- ✅ Healing PQ continu : même si un secret PQ est compromis, il est renouvelé 10 messages plus tard
- ✅ Rétrocompatibilité : réutilise le champ
kemCiphertextexistant (distingué du PQXDH initial parpqxdhInitialized) - ✅ Zéro overhead réseau : le ciphertext ML-KEM n'est envoyé que toutes les 10 messages (pas à chaque message)
- ✅ Compteur persistant :
pqRatchetCounterdansRatchetState(Room), survit aux redémarrages
Fialka sélectionne automatiquement le chiffrement symétrique optimal selon le hardware :
| Hardware | Chiffrement | Raison |
|---|---|---|
| ARMv8 avec Crypto Extension (API 33+) | AES-256-GCM | Accélération matérielle disponible |
| Sans accélération AES | ChaCha20-Poly1305 | Plus rapide en logiciel pur |
hasHardwareAes() :
→ Initialise AES-GCM avec une clé de test
→ Si l'init prend < 1ms → hardware AES présent → AES-256-GCM
→ Sinon → ChaCha20-Poly1305 (BouncyCastle)
Le champ cipherSuite dans le message indique l'algorithme utilisé :
0(ou absent) = AES-256-GCM (défaut, rétrocompatible)1= ChaCha20-Poly1305
Le receiver déchiffre automatiquement avec le bon algorithme.
- ✅ Sélection transparente : l'utilisateur ne choisit pas — le hardware dicte
- ✅ Rétrocompatibilité : les anciens messages (sans
cipherSuite) sont déchiffrés en AES-GCM - ✅ Même sécurité : AES-256-GCM et ChaCha20-Poly1305 offrent tous deux une sécurité 256-bit AEAD
{
"ciphertext": "a3F4bWx...",
"iv": "dG9rZW4...",
"createdAt": 1700000000000,
"senderHash": "HMAC-SHA256(accountId, conversationId)",
"signature": "Ed25519(ciphertext || conversationId || createdAt)"
}senderHash=HMAC-SHA256(accountId, conversationId)→ l'ID brut n'est plus visible- Le message paddé (cf. section Padding) est chiffré → taille uniforme sur le réseau
signature= Ed25519 surciphertext_UTF8 || conversationId_UTF8 || createdAt_bigEndian8bytes→ anti-falsification + anti-replaycreatedAt=System.currentTimeMillis()côté client pour cohérence signature- Tous les messages transitent via Tor Hidden Services (.onion vers .onion) — zéro relais central
La durée éphémère est synchronisée directement entre pairs via le canal Tor chiffré.
- Notification événementielle uniquement — ne synchronise pas l'état de vérification
- Chaque utilisateur gère son état
fingerprintVerifiedlocalement en Room
senderPublicKey— inutile en 1-to-1 (le destinataire connaît déjà la clé du contact)messageIndex— chiffré dans le payload AES-GCM (trial decryption côté réception)
Jamais envoyé : texte en clair, clés privées, clés de chaîne, position du ratchet.
Envoi :
fichier → clé AES-256-GCM aléatoire (fileKey)
→ chiffre fichier (encryptFile) → envoie cipherBytes P2P via Tor
→ message = "FILE|" + fileId + "|" + Base64(fileKey) + "|" + fileName
→ chiffre message avec Double Ratchet → envoie via Tor
Réception :
→ déchiffre message → détecte préfixe "FILE|"
→ reçoit fichier chiffré via Tor P2P
→ déchiffre avec fileKey → sauvegarde stockage interne
{
"senderPublicKey": "Ed25519_base64...",
"senderDisplayName": "Alice",
"conversationId": "conv_abc123",
"createdAt": 1700000000000
}Les demandes de contact sont délivrées directement au service .onion du destinataire, ou stockées dans un Fialka Mailbox si le destinataire est hors-ligne.
| Menace | Protégé ? | Détail |
|---|---|---|
| Serveur lit les messages | ✅ | Aucun serveur central — tout P2P via Tor Hidden Services, chiffré E2E |
| Compromission serveur central | N/A | Aucun serveur central n'existe |
| Compromission d'une clé message | ✅ | PFS — chaque message a sa propre clé |
| Replay d'anciens messages | ✅ | messageIndex intégré dans le ciphertext, état par conversation |
| Race conditions ratchet | ✅ | Mutex par conversation (thread-safe) |
| Attaque MITM | ✅ | Fingerprint emojis 96-bit (vérification visuelle) + handshake ML-DSA-44 |
| Vol du téléphone déverrouillé | ✅ | Keystore, SQLCipher, App Lock PIN + biométrie, auto-lock |
| Messages sensibles oubliés | ✅ | Messages éphémères (timer sur envoi / lecture) |
| Falsification de message | ✅ | Signature Ed25519 par message — badge ✅/ |
| Métadonnées (qui/quand) | ✅ | Tor Hidden Services (sealed sender), padding uniforme, dummy traffic |
| Analyse de trafic | ✅ | Dummy traffic (30-90 s, même pipeline), padding par buckets, couverture Tor |
| Fichiers interceptés | ✅ | Chiffrement AES-256-GCM par fichier, clé dans canal E2E, P2P via Tor |
| Perte du téléphone | ✅ | Phrase mnémonique 24 mots (BIP-39) pour restaurer l'identité entière |
| Contact supprime son compte | ✅ | Détection automatique conversation morte + nettoyage + re-invitation |
| Ordinateur quantique (futur) | ✅ | PQXDH hybride ML-KEM-1024 + SPQR ré-encapsulation + handshake ML-DSA-44 |
| Désynchronisation ratchet | ✅ | Sync à l'acceptation, mutex par conversation |
| Perf sans accélération AES | ✅ | ChaCha20-Poly1305 sélectionné automatiquement sans ARMv8 Crypto Extension |
| Screenshot / enregistrement écran | ✅ | FLAG_SECURE sur toutes les fenêtres et dialogs sensibles |
| Tapjacking / attaque overlay | ✅ | filterTouchesWhenObscured sur les activités sensibles |
| Injection deep link | ✅ | Whitelist paramètres, limites de taille, validation Base64, rejet caractères de contrôle |
| Fuite presse-papiers | ✅ | EXTRA_IS_SENSITIVE + auto-effacement 30s |
| Récupération forensique de fichiers | ✅ | SecureFileManager écrasement 2 passes (aléatoire + zéros) avant suppression |
| Exposition adresse IP | ✅ | Tout le trafic via Tor — IP réelle jamais exposée aux pairs ou au réseau |
| Métadonnées push notification | ✅ | UnifiedPush + ntfy.sh — zéro contenu de message, auto-hébergeable |
Voir aussi
SECURITY.mdpour l'analyse complète des mesures de sécurité.