aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorbndw <ben@bdw.to>2026-03-09 17:36:21 -0700
committerbndw <ben@bdw.to>2026-03-09 17:36:21 -0700
commiteca962d7c26bbea57801576935b98f3540e43da6 (patch)
tree19653f192107b5b74a8113af570d4b9c979a7e0d
parent9886a5c9054f3308482fdcca0fa545c8befbcf5b (diff)
fix: harden DM crypto — HKDF key derivation, AEAD associated data, ModInverse nil check
- 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
-rw-r--r--PROTOCOL.md13
-rw-r--r--crypto.go59
-rw-r--r--js/axon.js30
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`
100|---|---|---| 100|---|---|---|
101| Signing | Ed25519 | `crypto/ed25519` (stdlib) | 101| Signing | Ed25519 | `crypto/ed25519` (stdlib) |
102| Key exchange | X25519 | `golang.org/x/crypto/curve25519` | 102| Key exchange | X25519 | `golang.org/x/crypto/curve25519` |
103| Key derivation | HKDF-SHA256 | `golang.org/x/crypto/hkdf` |
103| Encryption | ChaCha20-Poly1305 | `golang.org/x/crypto/chacha20poly1305` | 104| Encryption | ChaCha20-Poly1305 | `golang.org/x/crypto/chacha20poly1305` |
104| Hashing / event ID | SHA-256 | `crypto/sha256` (stdlib) | 105| Hashing / event ID | SHA-256 | `crypto/sha256` (stdlib) |
105 106
106All 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. 107All 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.
107 108
108--- 109---
109 110
@@ -303,6 +304,16 @@ Root marker is required on all replies. No fallback heuristics.
303Tag{ name: "p", values: ["<recipient-pubkey>"] } 304Tag{ name: "p", values: ["<recipient-pubkey>"] }
304``` 305```
305 306
307**Encryption details:**
308
3091. Compute the X25519 shared secret from the sender's private key and recipient's public key
3102. Derive a 32-byte symmetric key via HKDF-SHA256 (salt: nil, info: `"axon-dm-v1"`)
3113. Generate a 12-byte random nonce
3124. Encrypt with ChaCha20-Poly1305 using associated data = `sender_pubkey || recipient_pubkey`
3135. Wire format of content field: `nonce (12 bytes) || ciphertext`
314
315The associated data binds the ciphertext to both parties, preventing key-confusion attacks where an attacker re-targets a ciphertext to a different recipient.
316
306The relay indexes the `p` tag to route DMs to the recipient's subscription. Content is opaque; the relay cannot decrypt it. 317The relay indexes the `p` tag to route DMs to the recipient's subscription. Content is opaque; the relay cannot decrypt it.
307 318
308--- 319---
diff --git a/crypto.go b/crypto.go
index 402d06b..2d8605c 100644
--- a/crypto.go
+++ b/crypto.go
@@ -3,13 +3,16 @@ package axon
3import ( 3import (
4 "crypto/ed25519" 4 "crypto/ed25519"
5 "crypto/rand" 5 "crypto/rand"
6 "crypto/sha256"
6 "crypto/sha512" 7 "crypto/sha512"
7 "errors" 8 "errors"
8 "fmt" 9 "fmt"
10 "io"
9 "math/big" 11 "math/big"
10 12
11 "golang.org/x/crypto/chacha20poly1305" 13 "golang.org/x/crypto/chacha20poly1305"
12 "golang.org/x/crypto/curve25519" 14 "golang.org/x/crypto/curve25519"
15 "golang.org/x/crypto/hkdf"
13) 16)
14 17
15// p is the prime for Curve25519: 2^255 - 19. 18// p is the prime for Curve25519: 2^255 - 19.
@@ -59,7 +62,9 @@ func Ed25519PubKeyToX25519(pubKey ed25519.PublicKey) ([32]byte, error) {
59 62
60 den := new(big.Int).Sub(one, y) 63 den := new(big.Int).Sub(one, y)
61 den.Mod(den, p) 64 den.Mod(den, p)
62 den.ModInverse(den, p) 65 if den.ModInverse(den, p) == nil {
66 return [32]byte{}, errors.New("axon: degenerate public key (y=1)")
67 }
63 68
64 u := new(big.Int).Mul(num, den) 69 u := new(big.Int).Mul(num, den)
65 u.Mod(u, p) 70 u.Mod(u, p)
@@ -91,16 +96,43 @@ func DHSharedSecret(localPriv ed25519.PrivateKey, remotePub ed25519.PublicKey) (
91 return out, nil 96 return out, nil
92} 97}
93 98
99// dmKey derives a symmetric key from the raw DH shared secret using
100// HKDF-SHA256. The salt is nil (HKDF uses a zero-filled hash-length salt)
101// and the info string binds the key to the axon DM context.
102func dmKey(shared [32]byte) ([32]byte, error) {
103 r := hkdf.New(sha256.New, shared[:], nil, []byte("axon-dm-v1"))
104 var key [32]byte
105 if _, err := io.ReadFull(r, key[:]); err != nil {
106 return [32]byte{}, fmt.Errorf("axon: hkdf: %w", err)
107 }
108 return key, nil
109}
110
111// dmAD builds the associated data for DM encryption by concatenating the
112// sender and recipient Ed25519 public keys, preventing key-confusion attacks.
113func dmAD(senderPub, recipientPub ed25519.PublicKey) []byte {
114 ad := make([]byte, 0, len(senderPub)+len(recipientPub))
115 ad = append(ad, senderPub...)
116 ad = append(ad, recipientPub...)
117 return ad
118}
119
94// EncryptDM encrypts plaintext for a DM using ChaCha20-Poly1305. The 120// EncryptDM encrypts plaintext for a DM using ChaCha20-Poly1305. The
95// encryption key is the X25519 shared secret derived from senderPriv and 121// encryption key is derived via HKDF-SHA256 from the X25519 shared secret
96// recipientPub. The returned blob is: nonce (12 bytes) || ciphertext. 122// of senderPriv and recipientPub. The sender and recipient public keys are
123// bound as associated data. The returned blob is: nonce (12 bytes) || ciphertext.
97func EncryptDM(senderPriv ed25519.PrivateKey, recipientPub ed25519.PublicKey, plaintext []byte) ([]byte, error) { 124func EncryptDM(senderPriv ed25519.PrivateKey, recipientPub ed25519.PublicKey, plaintext []byte) ([]byte, error) {
98 shared, err := DHSharedSecret(senderPriv, recipientPub) 125 shared, err := DHSharedSecret(senderPriv, recipientPub)
99 if err != nil { 126 if err != nil {
100 return nil, fmt.Errorf("axon: dh: %w", err) 127 return nil, fmt.Errorf("axon: dh: %w", err)
101 } 128 }
102 129
103 aead, err := chacha20poly1305.New(shared[:]) 130 key, err := dmKey(shared)
131 if err != nil {
132 return nil, err
133 }
134
135 aead, err := chacha20poly1305.New(key[:])
104 if err != nil { 136 if err != nil {
105 return nil, fmt.Errorf("axon: create aead: %w", err) 137 return nil, fmt.Errorf("axon: create aead: %w", err)
106 } 138 }
@@ -110,7 +142,10 @@ func EncryptDM(senderPriv ed25519.PrivateKey, recipientPub ed25519.PublicKey, pl
110 return nil, fmt.Errorf("axon: generate nonce: %w", err) 142 return nil, fmt.Errorf("axon: generate nonce: %w", err)
111 } 143 }
112 144
113 ct := aead.Seal(nil, nonce, plaintext, nil) 145 senderPub := senderPriv.Public().(ed25519.PublicKey)
146 ad := dmAD(senderPub, recipientPub)
147
148 ct := aead.Seal(nil, nonce, plaintext, ad)
114 return append(nonce, ct...), nil 149 return append(nonce, ct...), nil
115} 150}
116 151
@@ -123,7 +158,12 @@ func DecryptDM(recipientPriv ed25519.PrivateKey, senderPub ed25519.PublicKey, bl
123 return nil, fmt.Errorf("axon: dh: %w", err) 158 return nil, fmt.Errorf("axon: dh: %w", err)
124 } 159 }
125 160
126 aead, err := chacha20poly1305.New(shared[:]) 161 key, err := dmKey(shared)
162 if err != nil {
163 return nil, err
164 }
165
166 aead, err := chacha20poly1305.New(key[:])
127 if err != nil { 167 if err != nil {
128 return nil, fmt.Errorf("axon: create aead: %w", err) 168 return nil, fmt.Errorf("axon: create aead: %w", err)
129 } 169 }
@@ -136,9 +176,12 @@ func DecryptDM(recipientPriv ed25519.PrivateKey, senderPub ed25519.PublicKey, bl
136 nonce := blob[:nonceSize] 176 nonce := blob[:nonceSize]
137 ct := blob[nonceSize:] 177 ct := blob[nonceSize:]
138 178
139 pt, err := aead.Open(nil, nonce, ct, nil) 179 recipientPub := recipientPriv.Public().(ed25519.PublicKey)
180 ad := dmAD(senderPub, recipientPub)
181
182 pt, err := aead.Open(nil, nonce, ct, ad)
140 if err != nil { 183 if err != nil {
141 return nil, fmt.Errorf("axon: decrypt: authentication failed") 184 return nil, fmt.Errorf("axon: decrypt: %w", err)
142 } 185 }
143 return pt, nil 186 return pt, nil
144} 187}
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 @@
8 8
9import { ed25519, x25519, edwardsToMontgomeryPriv, edwardsToMontgomeryPub } from '@noble/curves/ed25519'; 9import { ed25519, x25519, edwardsToMontgomeryPriv, edwardsToMontgomeryPub } from '@noble/curves/ed25519';
10import { sha256 } from '@noble/hashes/sha256'; 10import { sha256 } from '@noble/hashes/sha256';
11import { hkdf } from '@noble/hashes/hkdf';
11import { chacha20poly1305 } from '@noble/ciphers/chacha'; 12import { chacha20poly1305 } from '@noble/ciphers/chacha';
12import { encode, decode } from '@msgpack/msgpack'; 13import { encode, decode } from '@msgpack/msgpack';
13 14
@@ -287,6 +288,25 @@ function dhSharedSecret(localSeed, remoteEd25519Pub) {
287} 288}
288 289
289/** 290/**
291 * dmKey derives a 32-byte symmetric key from the raw DH shared secret
292 * via HKDF-SHA256, matching Go's dmKey().
293 */
294function dmKey(shared) {
295 return hkdf(sha256, shared, undefined, 'axon-dm-v1', 32);
296}
297
298/**
299 * dmAD builds associated data by concatenating sender + recipient Ed25519
300 * public keys, matching Go's dmAD().
301 */
302function dmAD(senderPub, recipientPub) {
303 const ad = new Uint8Array(senderPub.length + recipientPub.length);
304 ad.set(senderPub);
305 ad.set(recipientPub, senderPub.length);
306 return ad;
307}
308
309/**
290 * encryptDM(senderSeed, recipientPubkey, plaintext) → Uint8Array 310 * encryptDM(senderSeed, recipientPubkey, plaintext) → Uint8Array
291 * 311 *
292 * Returns nonce(12) || ciphertext+tag, matching Go's EncryptDM. 312 * Returns nonce(12) || ciphertext+tag, matching Go's EncryptDM.
@@ -296,8 +316,11 @@ export function encryptDM(senderSeed, recipientPubkey, plaintext) {
296 const enc = new TextEncoder(); 316 const enc = new TextEncoder();
297 const pt = typeof plaintext === 'string' ? enc.encode(plaintext) : plaintext; 317 const pt = typeof plaintext === 'string' ? enc.encode(plaintext) : plaintext;
298 const shared = dhSharedSecret(senderSeed, recipientPubkey); 318 const shared = dhSharedSecret(senderSeed, recipientPubkey);
319 const key = dmKey(shared);
299 const nonce = crypto.getRandomValues(new Uint8Array(12)); 320 const nonce = crypto.getRandomValues(new Uint8Array(12));
300 const ct = chacha20poly1305(shared, nonce).encrypt(pt); 321 const senderPub = ed25519.getPublicKey(senderSeed);
322 const ad = dmAD(senderPub, recipientPubkey);
323 const ct = chacha20poly1305(key, nonce, ad).encrypt(pt);
301 const out = new Uint8Array(12 + ct.length); 324 const out = new Uint8Array(12 + ct.length);
302 out.set(nonce); 325 out.set(nonce);
303 out.set(ct, 12); 326 out.set(ct, 12);
@@ -312,9 +335,12 @@ export function encryptDM(senderSeed, recipientPubkey, plaintext) {
312export function decryptDM(recipientSeed, senderPubkey, blob) { 335export function decryptDM(recipientSeed, senderPubkey, blob) {
313 if (blob.length < 12) throw new Error('axon: dm blob too short'); 336 if (blob.length < 12) throw new Error('axon: dm blob too short');
314 const shared = dhSharedSecret(recipientSeed, senderPubkey); 337 const shared = dhSharedSecret(recipientSeed, senderPubkey);
338 const key = dmKey(shared);
315 const nonce = blob.slice(0, 12); 339 const nonce = blob.slice(0, 12);
316 const ct = blob.slice(12); 340 const ct = blob.slice(12);
317 return chacha20poly1305(shared, nonce).decrypt(ct); 341 const recipientPub = ed25519.getPublicKey(recipientSeed);
342 const ad = dmAD(senderPubkey, recipientPub);
343 return chacha20poly1305(key, nonce, ad).decrypt(ct);
318} 344}
319 345
320// --------------------------------------------------------------------------- 346// ---------------------------------------------------------------------------