From eca962d7c26bbea57801576935b98f3540e43da6 Mon Sep 17 00:00:00 2001 From: bndw Date: Mon, 9 Mar 2026 17:36:21 -0700 Subject: fix: harden DM crypto — HKDF key derivation, AEAD associated data, ModInverse nil check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Derive symmetric key via HKDF-SHA256 instead of using raw X25519 shared secret - Bind sender + recipient pubkeys as ChaCha20-Poly1305 associated data to prevent key-confusion attacks - Guard against ModInverse panic on degenerate public keys (y=1) - Wrap DecryptDM error instead of swallowing it - Update JS client to match Go implementation - Document encryption details in PROTOCOL.md --- js/axon.js | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) (limited to 'js') diff --git a/js/axon.js b/js/axon.js index 9f50340..761910c 100644 --- a/js/axon.js +++ b/js/axon.js @@ -8,6 +8,7 @@ import { ed25519, x25519, edwardsToMontgomeryPriv, edwardsToMontgomeryPub } from '@noble/curves/ed25519'; import { sha256 } from '@noble/hashes/sha256'; +import { hkdf } from '@noble/hashes/hkdf'; import { chacha20poly1305 } from '@noble/ciphers/chacha'; import { encode, decode } from '@msgpack/msgpack'; @@ -286,6 +287,25 @@ function dhSharedSecret(localSeed, remoteEd25519Pub) { return x25519.getSharedSecret(xPriv, xPub); } +/** + * dmKey derives a 32-byte symmetric key from the raw DH shared secret + * via HKDF-SHA256, matching Go's dmKey(). + */ +function dmKey(shared) { + return hkdf(sha256, shared, undefined, 'axon-dm-v1', 32); +} + +/** + * dmAD builds associated data by concatenating sender + recipient Ed25519 + * public keys, matching Go's dmAD(). + */ +function dmAD(senderPub, recipientPub) { + const ad = new Uint8Array(senderPub.length + recipientPub.length); + ad.set(senderPub); + ad.set(recipientPub, senderPub.length); + return ad; +} + /** * encryptDM(senderSeed, recipientPubkey, plaintext) → Uint8Array * @@ -296,8 +316,11 @@ export function encryptDM(senderSeed, recipientPubkey, plaintext) { const enc = new TextEncoder(); const pt = typeof plaintext === 'string' ? enc.encode(plaintext) : plaintext; const shared = dhSharedSecret(senderSeed, recipientPubkey); + const key = dmKey(shared); const nonce = crypto.getRandomValues(new Uint8Array(12)); - const ct = chacha20poly1305(shared, nonce).encrypt(pt); + const senderPub = ed25519.getPublicKey(senderSeed); + const ad = dmAD(senderPub, recipientPubkey); + const ct = chacha20poly1305(key, nonce, ad).encrypt(pt); const out = new Uint8Array(12 + ct.length); out.set(nonce); out.set(ct, 12); @@ -312,9 +335,12 @@ export function encryptDM(senderSeed, recipientPubkey, plaintext) { export function decryptDM(recipientSeed, senderPubkey, blob) { if (blob.length < 12) throw new Error('axon: dm blob too short'); const shared = dhSharedSecret(recipientSeed, senderPubkey); + const key = dmKey(shared); const nonce = blob.slice(0, 12); const ct = blob.slice(12); - return chacha20poly1305(shared, nonce).decrypt(ct); + const recipientPub = ed25519.getPublicKey(recipientSeed); + const ad = dmAD(senderPubkey, recipientPub); + return chacha20poly1305(key, nonce, ad).decrypt(ct); } // --------------------------------------------------------------------------- -- cgit v1.2.3