diff options
| author | bndw <ben@bdw.to> | 2026-03-09 17:36:21 -0700 |
|---|---|---|
| committer | bndw <ben@bdw.to> | 2026-03-09 17:36:21 -0700 |
| commit | eca962d7c26bbea57801576935b98f3540e43da6 (patch) | |
| tree | 19653f192107b5b74a8113af570d4b9c979a7e0d | |
| parent | 9886a5c9054f3308482fdcca0fa545c8befbcf5b (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.md | 13 | ||||
| -rw-r--r-- | crypto.go | 59 | ||||
| -rw-r--r-- | 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` | |||
| 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 | ||
| 106 | 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. | 107 | 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. |
| 107 | 108 | ||
| 108 | --- | 109 | --- |
| 109 | 110 | ||
| @@ -303,6 +304,16 @@ Root marker is required on all replies. No fallback heuristics. | |||
| 303 | Tag{ name: "p", values: ["<recipient-pubkey>"] } | 304 | Tag{ name: "p", values: ["<recipient-pubkey>"] } |
| 304 | ``` | 305 | ``` |
| 305 | 306 | ||
| 307 | **Encryption details:** | ||
| 308 | |||
| 309 | 1. Compute the X25519 shared secret from the sender's private key and recipient's public key | ||
| 310 | 2. Derive a 32-byte symmetric key via HKDF-SHA256 (salt: nil, info: `"axon-dm-v1"`) | ||
| 311 | 3. Generate a 12-byte random nonce | ||
| 312 | 4. Encrypt with ChaCha20-Poly1305 using associated data = `sender_pubkey || recipient_pubkey` | ||
| 313 | 5. Wire format of content field: `nonce (12 bytes) || ciphertext` | ||
| 314 | |||
| 315 | The associated data binds the ciphertext to both parties, preventing key-confusion attacks where an attacker re-targets a ciphertext to a different recipient. | ||
| 316 | |||
| 306 | The relay indexes the `p` tag to route DMs to the recipient's subscription. Content is opaque; the relay cannot decrypt it. | 317 | The 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 | --- |
| @@ -3,13 +3,16 @@ package axon | |||
| 3 | import ( | 3 | import ( |
| 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. | ||
| 102 | func 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. | ||
| 113 | func 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. | ||
| 97 | func EncryptDM(senderPriv ed25519.PrivateKey, recipientPub ed25519.PublicKey, plaintext []byte) ([]byte, error) { | 124 | func 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 | } |
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | ||
| 9 | import { ed25519, x25519, edwardsToMontgomeryPriv, edwardsToMontgomeryPub } from '@noble/curves/ed25519'; | 9 | import { ed25519, x25519, edwardsToMontgomeryPriv, edwardsToMontgomeryPub } from '@noble/curves/ed25519'; |
| 10 | import { sha256 } from '@noble/hashes/sha256'; | 10 | import { sha256 } from '@noble/hashes/sha256'; |
| 11 | import { hkdf } from '@noble/hashes/hkdf'; | ||
| 11 | import { chacha20poly1305 } from '@noble/ciphers/chacha'; | 12 | import { chacha20poly1305 } from '@noble/ciphers/chacha'; |
| 12 | import { encode, decode } from '@msgpack/msgpack'; | 13 | import { 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 | */ | ||
| 294 | function 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 | */ | ||
| 302 | function 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) { | |||
| 312 | export function decryptDM(recipientSeed, senderPubkey, blob) { | 335 | export 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 | // --------------------------------------------------------------------------- |
