diff options
| author | bndw <ben@bdw.to> | 2026-03-08 22:07:14 -0700 |
|---|---|---|
| committer | bndw <ben@bdw.to> | 2026-03-08 22:07:14 -0700 |
| commit | 3ff2bc0530bb98da139a5f68202c8e119f9d4775 (patch) | |
| tree | bcca197dee7f13823ac17d2b5ec1b62b94b897a8 /crypto.go | |
| parent | 53b10eab74d83522dd90af697773e32279469b30 (diff) | |
feat: implement Phase 1 Axon protocol core package
Adds the foundational Go package implementing the full Axon protocol
signing and crypto spec per PROTOCOL.md:
- Event/Tag structs and all kind constants (KindProfile through KindJobFeedback)
- Byte-exact canonical_payload construction per the PROTOCOL.md layout table
- Tag sorting and canonical_tags SHA256 hash (duplicate detection included)
- Ed25519 sign/verify, challenge sign/verify
- X25519 key conversion from Ed25519 keypair (RFC 8032 §5.1.5 clamping +
birational Edwards→Montgomery map for pubkeys)
- ChaCha20-Poly1305 encrypt/decrypt for DMs (nonce prepended)
- MessagePack encode/decode for events and wire messages
Test vectors written first in testdata/vectors.json covering canonical_tags,
canonical_payload, event_id, and signature verification — all deterministic
known-input → known-output pairs for cross-language validation in Phase 4.
13 tests, all passing.
Diffstat (limited to 'crypto.go')
| -rw-r--r-- | crypto.go | 144 |
1 files changed, 144 insertions, 0 deletions
diff --git a/crypto.go b/crypto.go new file mode 100644 index 0000000..402d06b --- /dev/null +++ b/crypto.go | |||
| @@ -0,0 +1,144 @@ | |||
| 1 | package axon | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "crypto/ed25519" | ||
| 5 | "crypto/rand" | ||
| 6 | "crypto/sha512" | ||
| 7 | "errors" | ||
| 8 | "fmt" | ||
| 9 | "math/big" | ||
| 10 | |||
| 11 | "golang.org/x/crypto/chacha20poly1305" | ||
| 12 | "golang.org/x/crypto/curve25519" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // p is the prime for Curve25519: 2^255 - 19. | ||
| 16 | var curveP = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 255), big.NewInt(19)) | ||
| 17 | |||
| 18 | // Ed25519PrivKeyToX25519 converts an Ed25519 private key to an X25519 scalar | ||
| 19 | // by clamping the SHA-512 hash of the 32-byte seed (per RFC 8032 §5.1.5). | ||
| 20 | func Ed25519PrivKeyToX25519(privKey ed25519.PrivateKey) [32]byte { | ||
| 21 | seed := privKey.Seed() | ||
| 22 | h := sha512.Sum512(seed) | ||
| 23 | // Clamp per RFC 8032. | ||
| 24 | h[0] &= 248 | ||
| 25 | h[31] &= 127 | ||
| 26 | h[31] |= 64 | ||
| 27 | var scalar [32]byte | ||
| 28 | copy(scalar[:], h[:32]) | ||
| 29 | return scalar | ||
| 30 | } | ||
| 31 | |||
| 32 | // Ed25519PubKeyToX25519 converts an Ed25519 public key (compressed Edwards | ||
| 33 | // y-coordinate) to the corresponding Curve25519 Montgomery u-coordinate via | ||
| 34 | // the birational equivalence: u = (1 + y) / (1 - y) mod p. | ||
| 35 | func Ed25519PubKeyToX25519(pubKey ed25519.PublicKey) ([32]byte, error) { | ||
| 36 | if len(pubKey) != 32 { | ||
| 37 | return [32]byte{}, fmt.Errorf("axon: ed25519 pubkey must be 32 bytes, got %d", len(pubKey)) | ||
| 38 | } | ||
| 39 | |||
| 40 | // Extract the y coordinate: clear the sign bit in byte 31. | ||
| 41 | yBytes := make([]byte, 32) | ||
| 42 | copy(yBytes, pubKey) | ||
| 43 | yBytes[31] &= 0x7f | ||
| 44 | |||
| 45 | // big.Int expects big-endian; reverse the little-endian bytes. | ||
| 46 | reversedY := make([]byte, 32) | ||
| 47 | for i, b := range yBytes { | ||
| 48 | reversedY[31-i] = b | ||
| 49 | } | ||
| 50 | y := new(big.Int).SetBytes(reversedY) | ||
| 51 | |||
| 52 | p := curveP | ||
| 53 | |||
| 54 | // u = (1 + y) * modInverse(1 - y, p) mod p | ||
| 55 | one := big.NewInt(1) | ||
| 56 | |||
| 57 | num := new(big.Int).Add(one, y) | ||
| 58 | num.Mod(num, p) | ||
| 59 | |||
| 60 | den := new(big.Int).Sub(one, y) | ||
| 61 | den.Mod(den, p) | ||
| 62 | den.ModInverse(den, p) | ||
| 63 | |||
| 64 | u := new(big.Int).Mul(num, den) | ||
| 65 | u.Mod(u, p) | ||
| 66 | |||
| 67 | // Encode as little-endian 32 bytes. | ||
| 68 | uBE := u.Bytes() // big-endian, variable length | ||
| 69 | var out [32]byte | ||
| 70 | for i, b := range uBE { | ||
| 71 | out[len(uBE)-1-i] = b | ||
| 72 | } | ||
| 73 | return out, nil | ||
| 74 | } | ||
| 75 | |||
| 76 | // DHSharedSecret computes the X25519 Diffie-Hellman shared secret between a | ||
| 77 | // local Ed25519 private key and a remote Ed25519 public key. Both keys are | ||
| 78 | // converted to their X25519 equivalents before the scalar multiplication. | ||
| 79 | func DHSharedSecret(localPriv ed25519.PrivateKey, remotePub ed25519.PublicKey) ([32]byte, error) { | ||
| 80 | scalar := Ed25519PrivKeyToX25519(localPriv) | ||
| 81 | point, err := Ed25519PubKeyToX25519(remotePub) | ||
| 82 | if err != nil { | ||
| 83 | return [32]byte{}, fmt.Errorf("axon: convert remote pubkey to x25519: %w", err) | ||
| 84 | } | ||
| 85 | shared, err := curve25519.X25519(scalar[:], point[:]) | ||
| 86 | if err != nil { | ||
| 87 | return [32]byte{}, fmt.Errorf("axon: x25519: %w", err) | ||
| 88 | } | ||
| 89 | var out [32]byte | ||
| 90 | copy(out[:], shared) | ||
| 91 | return out, nil | ||
| 92 | } | ||
| 93 | |||
| 94 | // EncryptDM encrypts plaintext for a DM using ChaCha20-Poly1305. The | ||
| 95 | // encryption key is the X25519 shared secret derived from senderPriv and | ||
| 96 | // recipientPub. The returned blob is: nonce (12 bytes) || ciphertext. | ||
| 97 | func EncryptDM(senderPriv ed25519.PrivateKey, recipientPub ed25519.PublicKey, plaintext []byte) ([]byte, error) { | ||
| 98 | shared, err := DHSharedSecret(senderPriv, recipientPub) | ||
| 99 | if err != nil { | ||
| 100 | return nil, fmt.Errorf("axon: dh: %w", err) | ||
| 101 | } | ||
| 102 | |||
| 103 | aead, err := chacha20poly1305.New(shared[:]) | ||
| 104 | if err != nil { | ||
| 105 | return nil, fmt.Errorf("axon: create aead: %w", err) | ||
| 106 | } | ||
| 107 | |||
| 108 | nonce := make([]byte, aead.NonceSize()) // 12 bytes | ||
| 109 | if _, err := rand.Read(nonce); err != nil { | ||
| 110 | return nil, fmt.Errorf("axon: generate nonce: %w", err) | ||
| 111 | } | ||
| 112 | |||
| 113 | ct := aead.Seal(nil, nonce, plaintext, nil) | ||
| 114 | return append(nonce, ct...), nil | ||
| 115 | } | ||
| 116 | |||
| 117 | // DecryptDM decrypts a blob produced by EncryptDM. recipientPriv is the | ||
| 118 | // recipient's Ed25519 private key; senderPub is the sender's Ed25519 public | ||
| 119 | // key. The blob must be nonce (12 bytes) || ciphertext. | ||
| 120 | func DecryptDM(recipientPriv ed25519.PrivateKey, senderPub ed25519.PublicKey, blob []byte) ([]byte, error) { | ||
| 121 | shared, err := DHSharedSecret(recipientPriv, senderPub) | ||
| 122 | if err != nil { | ||
| 123 | return nil, fmt.Errorf("axon: dh: %w", err) | ||
| 124 | } | ||
| 125 | |||
| 126 | aead, err := chacha20poly1305.New(shared[:]) | ||
| 127 | if err != nil { | ||
| 128 | return nil, fmt.Errorf("axon: create aead: %w", err) | ||
| 129 | } | ||
| 130 | |||
| 131 | nonceSize := aead.NonceSize() | ||
| 132 | if len(blob) < nonceSize { | ||
| 133 | return nil, errors.New("axon: ciphertext too short to contain nonce") | ||
| 134 | } | ||
| 135 | |||
| 136 | nonce := blob[:nonceSize] | ||
| 137 | ct := blob[nonceSize:] | ||
| 138 | |||
| 139 | pt, err := aead.Open(nil, nonce, ct, nil) | ||
| 140 | if err != nil { | ||
| 141 | return nil, fmt.Errorf("axon: decrypt: authentication failed") | ||
| 142 | } | ||
| 143 | return pt, nil | ||
| 144 | } | ||
