documentation
Kohinos v3 Doc générée le commit 8d60b1904

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.

⛔ Règle dure projet (.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èreApport 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-friendlyAPI é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 PiEd25519 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 modernesEd25519 (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 / ReticulumReticulum 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érercrypto_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

UsageFonction PHPAlgorithmeTaille
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.

⚠ Migration des hashes legacy

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

⛔ Ce que vous ne devez JAMAIS faire
  • Jamais appeler openssl_* pour signer ou hasher une opération financière. sodium_crypto_* uniquement.
  • Jamais modifier flux.hash, operation.hash, operation.signature aprè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_KEY dans 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 — utiliser sodium_memcmp() pour éviter les fuites par timing.
  • Jamais réutiliser un nonce secretbox avec la même clé. Générer systématiquement via random_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ènementAction
Compromission suspectéeRévocation immédiate · marquage account.public_key_revoked_at · gel des opérations sortantes · génération d'une nouvelle paire Ed25519.
Rotation planifiéePé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.
SauvegardeClé privée chiffrée hors-ligne (passphrase Argon2id → secretbox) sur deux supports physiques distincts.
Audit annuelLancer 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 :

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

Sources & références
  • src/Entity/Flux.php, src/Entity/Operation.php — champs hash (legacy bcrypt, à migrer)
  • src/Service/IbanEncryptionService.php — implémentation AES-CBC actuelle (à migrer secretbox)
  • src/Command/MigrateIbanEncryptionCommand.php — commande de migration des IBAN
  • src/Migrations/Version20260303000000.php — migration colonne IBAN chiffré
  • .claude/rules/global.md — règle libsodium-only
  • doc/ARCHITECTURE.md, doc/DOMAIN.md — modèle Flux / Operation
  • doc.libsodium.org — référence des primitives
  • Reticulum — Cryptography
  • Invariants financiers · Webhooks (signature)