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 --- PROTOCOL.md | 13 ++++++++++++- crypto.go | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++-------- js/axon.js | 30 ++++++++++++++++++++++++++++-- 3 files changed, 91 insertions(+), 11 deletions(-) diff --git a/PROTOCOL.md b/PROTOCOL.md index 42bf081..cd5253f 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -100,10 +100,11 @@ Two implementations that agree on this layout will always produce the same `id` |---|---|---| | Signing | Ed25519 | `crypto/ed25519` (stdlib) | | Key exchange | X25519 | `golang.org/x/crypto/curve25519` | +| Key derivation | HKDF-SHA256 | `golang.org/x/crypto/hkdf` | | Encryption | ChaCha20-Poly1305 | `golang.org/x/crypto/chacha20poly1305` | | Hashing / event ID | SHA-256 | `crypto/sha256` (stdlib) | -All dependencies are from the Go standard library or `golang.org/x/crypto`. No third-party crypto. Ed25519 keys are converted to X25519 for ECDH — one keypair serves both signing and encryption. ChaCha20-Poly1305 provides authenticated encryption (AEAD); the ciphertext cannot be tampered with without detection. +All dependencies are from the Go standard library or `golang.org/x/crypto`. No third-party crypto. Ed25519 keys are converted to X25519 for ECDH — one keypair serves both signing and encryption. The raw X25519 shared secret is passed through HKDF-SHA256 (info: `"axon-dm-v1"`) to derive the symmetric encryption key. ChaCha20-Poly1305 provides authenticated encryption (AEAD) with the sender and recipient public keys bound as associated data; the ciphertext cannot be tampered with or re-targeted without detection. --- @@ -303,6 +304,16 @@ Root marker is required on all replies. No fallback heuristics. Tag{ name: "p", values: [""] } ``` +**Encryption details:** + +1. Compute the X25519 shared secret from the sender's private key and recipient's public key +2. Derive a 32-byte symmetric key via HKDF-SHA256 (salt: nil, info: `"axon-dm-v1"`) +3. Generate a 12-byte random nonce +4. Encrypt with ChaCha20-Poly1305 using associated data = `sender_pubkey || recipient_pubkey` +5. Wire format of content field: `nonce (12 bytes) || ciphertext` + +The associated data binds the ciphertext to both parties, preventing key-confusion attacks where an attacker re-targets a ciphertext to a different recipient. + The relay indexes the `p` tag to route DMs to the recipient's subscription. Content is opaque; the relay cannot decrypt it. --- diff --git a/crypto.go b/crypto.go index 402d06b..2d8605c 100644 --- a/crypto.go +++ b/crypto.go @@ -3,13 +3,16 @@ package axon import ( "crypto/ed25519" "crypto/rand" + "crypto/sha256" "crypto/sha512" "errors" "fmt" + "io" "math/big" "golang.org/x/crypto/chacha20poly1305" "golang.org/x/crypto/curve25519" + "golang.org/x/crypto/hkdf" ) // p is the prime for Curve25519: 2^255 - 19. @@ -59,7 +62,9 @@ func Ed25519PubKeyToX25519(pubKey ed25519.PublicKey) ([32]byte, error) { den := new(big.Int).Sub(one, y) den.Mod(den, p) - den.ModInverse(den, p) + if den.ModInverse(den, p) == nil { + return [32]byte{}, errors.New("axon: degenerate public key (y=1)") + } u := new(big.Int).Mul(num, den) u.Mod(u, p) @@ -91,16 +96,43 @@ func DHSharedSecret(localPriv ed25519.PrivateKey, remotePub ed25519.PublicKey) ( return out, nil } +// dmKey derives a symmetric key from the raw DH shared secret using +// HKDF-SHA256. The salt is nil (HKDF uses a zero-filled hash-length salt) +// and the info string binds the key to the axon DM context. +func dmKey(shared [32]byte) ([32]byte, error) { + r := hkdf.New(sha256.New, shared[:], nil, []byte("axon-dm-v1")) + var key [32]byte + if _, err := io.ReadFull(r, key[:]); err != nil { + return [32]byte{}, fmt.Errorf("axon: hkdf: %w", err) + } + return key, nil +} + +// dmAD builds the associated data for DM encryption by concatenating the +// sender and recipient Ed25519 public keys, preventing key-confusion attacks. +func dmAD(senderPub, recipientPub ed25519.PublicKey) []byte { + ad := make([]byte, 0, len(senderPub)+len(recipientPub)) + ad = append(ad, senderPub...) + ad = append(ad, recipientPub...) + return ad +} + // EncryptDM encrypts plaintext for a DM using ChaCha20-Poly1305. The -// encryption key is the X25519 shared secret derived from senderPriv and -// recipientPub. The returned blob is: nonce (12 bytes) || ciphertext. +// encryption key is derived via HKDF-SHA256 from the X25519 shared secret +// of senderPriv and recipientPub. The sender and recipient public keys are +// bound as associated data. The returned blob is: nonce (12 bytes) || ciphertext. func EncryptDM(senderPriv ed25519.PrivateKey, recipientPub ed25519.PublicKey, plaintext []byte) ([]byte, error) { shared, err := DHSharedSecret(senderPriv, recipientPub) if err != nil { return nil, fmt.Errorf("axon: dh: %w", err) } - aead, err := chacha20poly1305.New(shared[:]) + key, err := dmKey(shared) + if err != nil { + return nil, err + } + + aead, err := chacha20poly1305.New(key[:]) if err != nil { return nil, fmt.Errorf("axon: create aead: %w", err) } @@ -110,7 +142,10 @@ func EncryptDM(senderPriv ed25519.PrivateKey, recipientPub ed25519.PublicKey, pl return nil, fmt.Errorf("axon: generate nonce: %w", err) } - ct := aead.Seal(nil, nonce, plaintext, nil) + senderPub := senderPriv.Public().(ed25519.PublicKey) + ad := dmAD(senderPub, recipientPub) + + ct := aead.Seal(nil, nonce, plaintext, ad) return append(nonce, ct...), nil } @@ -123,7 +158,12 @@ func DecryptDM(recipientPriv ed25519.PrivateKey, senderPub ed25519.PublicKey, bl return nil, fmt.Errorf("axon: dh: %w", err) } - aead, err := chacha20poly1305.New(shared[:]) + key, err := dmKey(shared) + if err != nil { + return nil, err + } + + aead, err := chacha20poly1305.New(key[:]) if err != nil { return nil, fmt.Errorf("axon: create aead: %w", err) } @@ -136,9 +176,12 @@ func DecryptDM(recipientPriv ed25519.PrivateKey, senderPub ed25519.PublicKey, bl nonce := blob[:nonceSize] ct := blob[nonceSize:] - pt, err := aead.Open(nil, nonce, ct, nil) + recipientPub := recipientPriv.Public().(ed25519.PublicKey) + ad := dmAD(senderPub, recipientPub) + + pt, err := aead.Open(nil, nonce, ct, ad) if err != nil { - return nil, fmt.Errorf("axon: decrypt: authentication failed") + return nil, fmt.Errorf("axon: decrypt: %w", err) } return pt, nil } 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