Cryptographie financière — libsodium, Ed25519, chaînage
Kohinos signe et chaîne ses opérations monétaires (Flux, Operation) de manière
vérifiable, locale et auditable. Le projet adopte libsodium comme bibliothèque
cryptographique unique : primitives modernes (Ed25519, Curve25519, BLAKE2b), audit-friendly,
performante sur Raspberry Pi, et compatible avec la roadmap offline-first (Reticulum / LoRa-mesh).
Aucun mécanisme financier critique ne doit dépendre d'openssl_* ou d'une API maison.
.claude/rules/global.md)
Interdit : openssl_* pour les signatures financières.
Obligatoire : sodium_crypto_* uniquement
(Ed25519, Curve25519, XSalsa20-Poly1305, BLAKE2b, Argon2id).
État actuel du code (à confirmer dans le repo) : le hash de Flux et Operation
est encore généré via password_hash(..., PASSWORD_BCRYPT), et
IbanEncryptionService utilise openssl_encrypt en AES-256-CBC.
Ces deux points sont des dettes techniques à corriger : ce document décrit la
cible libsodium vers laquelle le code doit converger.
Pourquoi libsodium
| Critère | Apport libsodium |
|---|---|
| Souveraineté | Bibliothèque open source, embarquée dans PHP 8.x (extension sodium par défaut). Aucun appel réseau, pas de SaaS. |
| Audit-friendly | API étroite, choix de primitives forcé : pas de combinaison naïve algorithme/mode/IV. Une seule façon de signer, une seule façon de chiffrer. |
| Raspberry Pi | Ed25519 et BLAKE2b sont nettement plus rapides que RSA/ECDSA-P256 et SHA-256 sur ARMv7/ARMv8. Vérification d'une chaîne de plusieurs milliers d'Operation reste sous-seconde. |
| Primitives modernes | Ed25519 (signature déterministe, pas de RNG à la signature), Curve25519 (échange de clés), BLAKE2b (hash rapide, résistant collision), Argon2id (KDF résistant GPU). |
| Roadmap LoRa / Reticulum | Reticulum utilise nativement Curve25519 + Ed25519. Réutiliser ces mêmes primitives prépare un déploiement mesh local-first sans nouveau modèle de clés. |
| Pas d'IV à gérer | crypto_secretbox demande un nonce, mais l'API échoue bruyamment si on le réutilise par accident — moins de pièges qu'OpenSSL CBC. |
Diagramme — signature et vérification d'une opération
sequenceDiagram
autonumber
participant App as Application
participant KeyStore as KeyStore
(env / Symfony secrets)
participant Sodium as ext-sodium
participant DB as MariaDB
participant Audit as bin/console
kohinos:chain:verify
Note over App,Sodium: 1. Émission d'une Operation
App->>KeyStore: getPrivateKey(emitterId)
KeyStore-->>App: ed25519_secret_key (32B)
App->>DB: SELECT hash FROM operation WHERE id=preceding ORDER BY created_at DESC LIMIT 1
DB-->>App: previousHash
App->>Sodium: sodium_crypto_generichash(previousHash || payload)
Sodium-->>App: hash (32B BLAKE2b)
App->>Sodium: sodium_crypto_sign_detached(hash, secret_key)
Sodium-->>App: signature (64B Ed25519)
App->>DB: INSERT operation(hash, signature, public_key, ...)
Note over Audit,DB: 2. Vérification a posteriori
Audit->>DB: SELECT * FROM operation ORDER BY created_at
loop pour chaque op
Audit->>Sodium: recompute hash(prev || payload)
Audit->>Sodium: sodium_crypto_sign_verify_detached(sig, hash, pubkey)
end
Audit-->>App: rapport: chaîne intacte / rompue à op #N
Pipeline cible. Aucune écriture ne référence openssl_*. La clé privée ne quitte jamais le KeyStore.
Primitives libsodium utilisées
| Usage | Fonction PHP | Algorithme | Taille |
|---|---|---|---|
| Signature opération | sodium_crypto_sign_detached()sodium_crypto_sign_verify_detached() |
Ed25519 (EdDSA sur Curve25519) | signature 64 B · clé privée 64 B · clé publique 32 B |
| Hash d'opération / chaînage | sodium_crypto_generichash() |
BLAKE2b | sortie 32 B (256 bits) par défaut |
| Dérivation depuis passphrase | sodium_crypto_pwhash() |
Argon2id | sortie 32 B · OPSLIMIT_MODERATE, MEMLIMIT_MODERATE en cible |
| Chiffrement IBAN au repos | sodium_crypto_secretbox()sodium_crypto_secretbox_open() |
XSalsa20-Poly1305 (authentifié) | clé 32 B · nonce 24 B |
| Échange de clé (roadmap Reticulum/LoRa) | sodium_crypto_box_keypair()sodium_crypto_box() / _open() |
Curve25519 + XSalsa20-Poly1305 | clé 32 B · nonce 24 B |
| Comparaison constante | sodium_memcmp() |
— | évite les attaques par timing sur les hashes |
| Effacement mémoire | sodium_memzero() |
— | à appeler systématiquement après usage d'une clé privée |
Chaînage cryptographique des opérations
Chaque Operation (et symétriquement chaque Flux) porte
deux champs critiques : hash et signature. Le hash chaîne
l'opération à la précédente du même compte, ce qui forme un registre vérifiable
de bout en bout (modèle inspiré d'un append-only ledger).
Définition du hash (proposition cible)
// Pseudo-spec — à confirmer / implémenter dans src/Service/Crypto/OperationHasher.php
hash(op) = BLAKE2b_256(
previousHash(op.account) // 32 B, ou 32 octets de zéros si genèse du compte
|| canonicalPayload(op) // JSON canonique trié, UTF-8 NFC
|| op.accountId // UUID, 16 B
|| op.amount.toString() // "1234.56" — toujours BigDecimal-string, jamais float
|| op.createdAt.toIso8601()
)
L'opérateur || est la concaténation binaire. Le payload canonique doit
être strictement déterministe : clés JSON triées, pas d'espaces, encodage UTF-8 NFC,
pour qu'un re-hash 5 ans plus tard donne le même résultat à l'octet près.
Définition de la signature
signature(op) = sodium_crypto_sign_detached(
hash(op), // message = le hash chaîné
op.emitter.ed25519_secret_key // clé privée du compte émetteur (32 B seed + 32 B pubkey = 64 B)
)
// Vérification :
sodium_crypto_sign_verify_detached(
op.signature, // 64 B
op.hash, // 32 B
op.emitter.ed25519_public_key // 32 B, stocké dans la table account
)
Schéma BDD cible (à confirmer / migration à prévoir)
ALTER TABLE operation
ADD COLUMN hash BINARY(32) NOT NULL,
ADD COLUMN signature BINARY(64) NOT NULL,
ADD COLUMN public_key BINARY(32) NOT NULL,
ADD COLUMN previous_id CHAR(36) NULL,
ADD CONSTRAINT fk_operation_previous FOREIGN KEY (previous_id) REFERENCES operation(id);
CREATE INDEX idx_operation_account_created ON operation(account_id, created_at);
Le champ actuel operation.hash est typé TEXT et contient un
password_hash() bcrypt. C'est une dette technique : bcrypt n'est pas
déterministe (sel aléatoire à chaque appel), donc impossible à re-vérifier sans
stocker la chaîne d'entrée. La migration consistera à régénérer les hashes BLAKE2b
sur l'historique en parallèle, à valider, puis à basculer.
Tant que la transition n'est pas effectuée, conserver les anciens hashes bcrypt en
colonne legacy_hash et calculer la nouvelle chaîne BLAKE2b dans une
colonne séparée. Une commande kohinos:chain:rebuild --dry-run doit
permettre de valider la cohérence avant suppression de l'ancienne colonne.
Exemple d'implémentation (proposition)
<?php
declare(strict_types=1);
namespace App\Service\Crypto;
final class OperationHasher
{
public function hash(Operation $op, ?string $previousHash): string
{
$payload = $this->canonicalize([
'account' => $op->getAccount()->getId()->toBinary(),
'amount' => $op->getMontant()->toString(), // BigDecimal-string
'type' => $op->getType(),
'reference' => $op->getReference(),
'createdAt' => $op->getCreatedAt()->format(\DateTimeInterface::ATOM),
]);
$material = ($previousHash ?? str_repeat("\0", 32)) . $payload;
return sodium_crypto_generichash($material, '', SODIUM_CRYPTO_GENERICHASH_BYTES);
}
private function canonicalize(array $data): string
{
ksort($data);
return json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
}
}
final class OperationSigner
{
public function __construct(private readonly KeyStore $keys) {}
public function sign(Operation $op, string $hash): string
{
$secret = $this->keys->getSecretKey($op->getEmitter());
try {
return sodium_crypto_sign_detached($hash, $secret);
} finally {
sodium_memzero($secret); // efface la clé de la mémoire
}
}
public function verify(string $signature, string $hash, string $publicKey): bool
{
return sodium_crypto_sign_verify_detached($signature, $hash, $publicKey);
}
}
Règles dures cryptographie
- Jamais appeler
openssl_*pour signer ou hasher une opération financière.sodium_crypto_*uniquement. - Jamais modifier
flux.hash,operation.hash,operation.signatureaprès la création. Ces champs sont en lecture seule au niveau métier — toute mutation est une rupture du chaînage et donc une fraude potentielle. - Jamais commiter une clé privée ou un
ENCRYPT_KEYdans le dépôt. Utiliser.env.local(non versionné) ou les Symfony Secrets chiffrés (config/secrets/prod/). - Jamais écrire sa propre crypto : pas de XOR maison, pas de "petit hash custom", pas de réimplémentation Ed25519 en PHP pur. libsodium fournit tout.
- Jamais comparer deux hashes avec
===dans un contexte sensible — utilisersodium_memcmp()pour éviter les fuites par timing. - Jamais réutiliser un nonce
secretboxavec la même clé. Générer systématiquement viarandom_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES). - Jamais logger une clé privée, un IBAN en clair, ni un payload signé contenant des données sensibles. Voir
docs/rgpd/GUIDE_*.
Politique de rotation des clés
| Évènement | Action |
|---|---|
| Compromission suspectée | Révocation immédiate · marquage account.public_key_revoked_at · gel des opérations sortantes · génération d'une nouvelle paire Ed25519. |
| Rotation planifiée | Période de double-validation : l'opération est signée par l'ancienne et la nouvelle clé pendant 30 jours, puis on coupe l'ancienne. |
| Sauvegarde | Clé privée chiffrée hors-ligne (passphrase Argon2id → secretbox) sur deux supports physiques distincts. |
| Audit annuel | Lancer bin/console kohinos:chain:verify --full avant et après rotation pour preuve d'intégrité. |
Chiffrement IBAN au repos
Les IBAN des adhérents sont chiffrés au niveau colonne. L'implémentation actuelle
(App\Service\IbanEncryptionService) utilise AES-256-CBC via OpenSSL avec
un suffixe <ENC> et la clé ENCRYPT_KEY (base64, 32 octets) issue de
l'environnement. Cible libsodium (à confirmer / refactor) :
<?php
declare(strict_types=1);
namespace App\Service;
final class IbanEncryptionService
{
private const SUFFIX = '<SODIUM>';
public function __construct(
#[\SensitiveParameter] private readonly string $key // 32 octets bruts
) {
if (strlen($this->key) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
throw new \InvalidArgumentException('ENCRYPT_KEY must be 32 bytes.');
}
}
public function encrypt(string $plain): string
{
if ($this->isEncrypted($plain)) {
return $plain;
}
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$cipher = sodium_crypto_secretbox($plain, $nonce, $this->key);
return base64_encode($nonce . $cipher) . self::SUFFIX;
}
public function decrypt(string $value): ?string
{
if (!$this->isEncrypted($value)) {
return $value;
}
$raw = base64_decode(str_replace(self::SUFFIX, '', $value), true);
if ($raw === false || strlen($raw) < SODIUM_CRYPTO_SECRETBOX_NONCEBYTES) {
return null;
}
$nonce = substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$cipher = substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$plain = sodium_crypto_secretbox_open($cipher, $nonce, $this->key);
return $plain === false ? null : $plain;
}
public function isEncrypted(string $value): bool
{
return str_ends_with($value, self::SUFFIX);
}
}
La migration est portée par la commande bin/console app:migrate-iban-encryption [--dry-run].
Elle doit, dans sa version libsodium : détecter les valeurs avec ancien suffixe <ENC>,
déchiffrer en AES-CBC, rechiffrer en secretbox, et écrire avec le nouveau suffixe
<SODIUM>. Idempotent et rejouable.
Audit & vérification de la chaîne
Une commande dédiée doit permettre de rejouer la chaîne complète et de signaler toute rupture (proposition d'API) :
# Vérifie la chaîne de toutes les opérations
php bin/console kohinos:chain:verify
# Sur un compte précis
php bin/console kohinos:chain:verify --account=<uuid>
# Vérifie aussi la signature Ed25519 (plus lent)
php bin/console kohinos:chain:verify --with-signatures
# Sortie JSON pour CI / monitoring
php bin/console kohinos:chain:verify --format=json > var/log/chain-audit.json
Pseudo-code de vérification :
foreach ($account->getOperationsOrderedByDate() as $op) {
$expected = $hasher->hash($op, $previous?->getHash());
if (!sodium_memcmp($expected, $op->getHash())) {
$report->rupture($op, 'hash mismatch');
}
if ($withSignatures && !$signer->verify($op->getSignature(), $op->getHash(), $op->getPublicKey())) {
$report->rupture($op, 'signature invalid');
}
$previous = $op;
}
Ce contrôle est complémentaire des invariants financiers : voir Invariants financiers pour la part "double-entry / somme nulle".
Roadmap offline / Reticulum
Kohinos est conçu pour pouvoir un jour fonctionner hors-Internet via un mesh radio bas-débit (LoRa) sur la pile Reticulum. Ce choix impose dès aujourd'hui :
- Curve25519 pour l'échange de clés et l'identification des nœuds — réutilisable directement par Reticulum.
- Ed25519 pour les signatures — compact (64 B), pas de RNG à la signature, déterministe, donc validable sur lien intermittent.
- BLAKE2b pour les hashes de chaînage — rapide même sur ARM Cortex-M, sortie 32 B compatible avec les MTU radio.
- Pas de dépendance HTTP/TLS dans le chemin critique de signature : tout est calculable et vérifiable localement.
- Format payload canonique dès maintenant : une opération signée doit pouvoir transiter par paquet LoRa et être re-vérifiée mois plus tard sur un autre nœud, à l'octet près.
Conséquence opérationnelle : aucune nouvelle dépendance crypto ne doit être introduite si elle n'est pas couverte par libsodium. C'est le seul moyen de garder l'option Reticulum ouverte sans réécriture massive.
Pour aller plus loin
src/Entity/Flux.php,src/Entity/Operation.php— champshash(legacy bcrypt, à migrer)src/Service/IbanEncryptionService.php— implémentation AES-CBC actuelle (à migrer secretbox)src/Command/MigrateIbanEncryptionCommand.php— commande de migration des IBANsrc/Migrations/Version20260303000000.php— migration colonne IBAN chiffré.claude/rules/global.md— règle libsodium-onlydoc/ARCHITECTURE.md,doc/DOMAIN.md— modèleFlux/Operation- doc.libsodium.org — référence des primitives
- Reticulum — Cryptography
- Invariants financiers · Webhooks (signature)