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 | |
| 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.
| -rw-r--r-- | axon.go | 272 | ||||
| -rw-r--r-- | axon_test.go | 550 | ||||
| -rw-r--r-- | crypto.go | 144 | ||||
| -rw-r--r-- | encoding.go | 54 | ||||
| -rw-r--r-- | go.mod | 10 | ||||
| -rw-r--r-- | go.sum | 8 | ||||
| -rw-r--r-- | testdata/vectors.json | 34 |
7 files changed, 1072 insertions, 0 deletions
| @@ -0,0 +1,272 @@ | |||
| 1 | // Package axon implements the Axon protocol core: event signing, verification, | ||
| 2 | // canonical payload construction, and related helpers. | ||
| 3 | package axon | ||
| 4 | |||
| 5 | import ( | ||
| 6 | "crypto/ed25519" | ||
| 7 | "crypto/rand" | ||
| 8 | "crypto/sha256" | ||
| 9 | "encoding/binary" | ||
| 10 | "errors" | ||
| 11 | "fmt" | ||
| 12 | "sort" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // Event kind constants matching the registry in PROTOCOL.md. | ||
| 16 | const ( | ||
| 17 | KindProfile uint16 = 0 // Identity metadata | ||
| 18 | KindMessage uint16 = 1000 // Plain text note | ||
| 19 | KindDM uint16 = 2000 // Encrypted direct message | ||
| 20 | KindProgress uint16 = 3000 // Ephemeral progress/status indicator | ||
| 21 | KindJobRequest uint16 = 5000 // Request for agent work | ||
| 22 | KindJobResult uint16 = 6000 // Completed job output | ||
| 23 | KindJobFeedback uint16 = 7000 // In-progress status / error | ||
| 24 | ) | ||
| 25 | |||
| 26 | // Tag is a named list of string values attached to an Event. | ||
| 27 | type Tag struct { | ||
| 28 | Name string `msgpack:"name" json:"name"` | ||
| 29 | Values []string `msgpack:"values" json:"values"` | ||
| 30 | } | ||
| 31 | |||
| 32 | // Event is the core Axon data structure. All fields use their wire types. | ||
| 33 | // id, pubkey and sig are raw 32/64-byte slices, not hex. | ||
| 34 | // content is opaque bytes (msgpack bin type). | ||
| 35 | type Event struct { | ||
| 36 | ID []byte `msgpack:"id"` // 32 bytes, SHA256 of canonical payload | ||
| 37 | PubKey []byte `msgpack:"pubkey"` // 32 bytes, Ed25519 public key | ||
| 38 | CreatedAt int64 `msgpack:"created_at"` // Unix timestamp | ||
| 39 | Kind uint16 `msgpack:"kind"` | ||
| 40 | Content []byte `msgpack:"content"` // opaque; msgpack bin type | ||
| 41 | Sig []byte `msgpack:"sig"` // 64 bytes, Ed25519 signature over id | ||
| 42 | Tags []Tag `msgpack:"tags"` | ||
| 43 | } | ||
| 44 | |||
| 45 | // KeyPair holds an Ed25519 private and public key. | ||
| 46 | type KeyPair struct { | ||
| 47 | PrivKey ed25519.PrivateKey | ||
| 48 | PubKey ed25519.PublicKey | ||
| 49 | } | ||
| 50 | |||
| 51 | // NewKeyPair generates a fresh Ed25519 keypair. | ||
| 52 | func NewKeyPair() (KeyPair, error) { | ||
| 53 | pub, priv, err := ed25519.GenerateKey(rand.Reader) | ||
| 54 | if err != nil { | ||
| 55 | return KeyPair{}, fmt.Errorf("axon: generate key: %w", err) | ||
| 56 | } | ||
| 57 | return KeyPair{PrivKey: priv, PubKey: pub}, nil | ||
| 58 | } | ||
| 59 | |||
| 60 | // NewKeyPairFromSeed derives a keypair from a 32-byte seed (the canonical | ||
| 61 | // private key representation). Panics if seed is not 32 bytes. | ||
| 62 | func NewKeyPairFromSeed(seed []byte) KeyPair { | ||
| 63 | if len(seed) != ed25519.SeedSize { | ||
| 64 | panic(fmt.Sprintf("axon: seed must be %d bytes, got %d", ed25519.SeedSize, len(seed))) | ||
| 65 | } | ||
| 66 | priv := ed25519.NewKeyFromSeed(seed) | ||
| 67 | return KeyPair{PrivKey: priv, PubKey: priv.Public().(ed25519.PublicKey)} | ||
| 68 | } | ||
| 69 | |||
| 70 | // CanonicalTags encodes tags into their canonical binary representation and | ||
| 71 | // returns the raw bytes (before hashing). This is exposed so callers can | ||
| 72 | // inspect the encoding in tests; normally you want CanonicalTagsHash. | ||
| 73 | // | ||
| 74 | // Encoding: | ||
| 75 | // | ||
| 76 | // uint16(num_tags) | ||
| 77 | // for each tag (sorted by name, then first value for ties): | ||
| 78 | // uint16(len(name)) || utf8(name) | ||
| 79 | // uint16(num_values) | ||
| 80 | // for each value: | ||
| 81 | // uint32(len(value)) || utf8(value) | ||
| 82 | // | ||
| 83 | // Returns an error if two tags share the same name and first value (protocol error). | ||
| 84 | func CanonicalTags(tags []Tag) ([]byte, error) { | ||
| 85 | // Sort a copy so we don't mutate the caller's slice. | ||
| 86 | sorted := make([]Tag, len(tags)) | ||
| 87 | copy(sorted, tags) | ||
| 88 | sort.SliceStable(sorted, func(i, j int) bool { | ||
| 89 | if sorted[i].Name != sorted[j].Name { | ||
| 90 | return sorted[i].Name < sorted[j].Name | ||
| 91 | } | ||
| 92 | vi := "" | ||
| 93 | if len(sorted[i].Values) > 0 { | ||
| 94 | vi = sorted[i].Values[0] | ||
| 95 | } | ||
| 96 | vj := "" | ||
| 97 | if len(sorted[j].Values) > 0 { | ||
| 98 | vj = sorted[j].Values[0] | ||
| 99 | } | ||
| 100 | return vi < vj | ||
| 101 | }) | ||
| 102 | |||
| 103 | // Detect duplicates (same name + same first value). | ||
| 104 | for i := 1; i < len(sorted); i++ { | ||
| 105 | prev, cur := sorted[i-1], sorted[i] | ||
| 106 | if prev.Name != cur.Name { | ||
| 107 | continue | ||
| 108 | } | ||
| 109 | prevFirst := "" | ||
| 110 | if len(prev.Values) > 0 { | ||
| 111 | prevFirst = prev.Values[0] | ||
| 112 | } | ||
| 113 | curFirst := "" | ||
| 114 | if len(cur.Values) > 0 { | ||
| 115 | curFirst = cur.Values[0] | ||
| 116 | } | ||
| 117 | if prevFirst == curFirst { | ||
| 118 | return nil, fmt.Errorf("axon: duplicate tag (name=%q first_value=%q)", cur.Name, curFirst) | ||
| 119 | } | ||
| 120 | } | ||
| 121 | |||
| 122 | // Estimate capacity to avoid repeated allocations. | ||
| 123 | buf := make([]byte, 0, 2+len(sorted)*16) | ||
| 124 | var hdr [4]byte | ||
| 125 | |||
| 126 | binary.BigEndian.PutUint16(hdr[:2], uint16(len(sorted))) | ||
| 127 | buf = append(buf, hdr[:2]...) | ||
| 128 | |||
| 129 | for _, tag := range sorted { | ||
| 130 | name := []byte(tag.Name) | ||
| 131 | binary.BigEndian.PutUint16(hdr[:2], uint16(len(name))) | ||
| 132 | buf = append(buf, hdr[:2]...) | ||
| 133 | buf = append(buf, name...) | ||
| 134 | |||
| 135 | binary.BigEndian.PutUint16(hdr[:2], uint16(len(tag.Values))) | ||
| 136 | buf = append(buf, hdr[:2]...) | ||
| 137 | |||
| 138 | for _, v := range tag.Values { | ||
| 139 | vb := []byte(v) | ||
| 140 | binary.BigEndian.PutUint32(hdr[:4], uint32(len(vb))) | ||
| 141 | buf = append(buf, hdr[:4]...) | ||
| 142 | buf = append(buf, vb...) | ||
| 143 | } | ||
| 144 | } | ||
| 145 | |||
| 146 | return buf, nil | ||
| 147 | } | ||
| 148 | |||
| 149 | // CanonicalTagsHash returns SHA256(canonical_tags encoding). | ||
| 150 | func CanonicalTagsHash(tags []Tag) ([32]byte, error) { | ||
| 151 | enc, err := CanonicalTags(tags) | ||
| 152 | if err != nil { | ||
| 153 | return [32]byte{}, err | ||
| 154 | } | ||
| 155 | return sha256.Sum256(enc), nil | ||
| 156 | } | ||
| 157 | |||
| 158 | // CanonicalPayload constructs the deterministic byte payload that is hashed to | ||
| 159 | // produce the event ID. | ||
| 160 | // | ||
| 161 | // Layout: | ||
| 162 | // | ||
| 163 | // [0:2] uint16 = 32 pubkey length (always 32) | ||
| 164 | // [2:34] bytes pubkey | ||
| 165 | // [34:42] uint64 created_at | ||
| 166 | // [42:44] uint16 kind | ||
| 167 | // [44:48] uint32 content length | ||
| 168 | // [48:48+n] bytes content | ||
| 169 | // [48+n:80+n] bytes SHA256(canonical_tags), 32 bytes | ||
| 170 | func CanonicalPayload(pubkey []byte, createdAt int64, kind uint16, content []byte, tags []Tag) ([]byte, error) { | ||
| 171 | if len(pubkey) != 32 { | ||
| 172 | return nil, fmt.Errorf("axon: pubkey must be 32 bytes, got %d", len(pubkey)) | ||
| 173 | } | ||
| 174 | if len(content) > 65536 { | ||
| 175 | return nil, errors.New("axon: content exceeds 65536 byte limit") | ||
| 176 | } | ||
| 177 | |||
| 178 | tagsHash, err := CanonicalTagsHash(tags) | ||
| 179 | if err != nil { | ||
| 180 | return nil, err | ||
| 181 | } | ||
| 182 | |||
| 183 | n := len(content) | ||
| 184 | // Total size: 2 + 32 + 8 + 2 + 4 + n + 32 | ||
| 185 | payload := make([]byte, 80+n) | ||
| 186 | |||
| 187 | binary.BigEndian.PutUint16(payload[0:2], 32) | ||
| 188 | copy(payload[2:34], pubkey) | ||
| 189 | binary.BigEndian.PutUint64(payload[34:42], uint64(createdAt)) | ||
| 190 | binary.BigEndian.PutUint16(payload[42:44], kind) | ||
| 191 | binary.BigEndian.PutUint32(payload[44:48], uint32(n)) | ||
| 192 | copy(payload[48:48+n], content) | ||
| 193 | copy(payload[48+n:80+n], tagsHash[:]) | ||
| 194 | |||
| 195 | return payload, nil | ||
| 196 | } | ||
| 197 | |||
| 198 | // EventID computes the canonical SHA256 event ID for the given fields. | ||
| 199 | func EventID(pubkey []byte, createdAt int64, kind uint16, content []byte, tags []Tag) ([]byte, error) { | ||
| 200 | payload, err := CanonicalPayload(pubkey, createdAt, kind, content, tags) | ||
| 201 | if err != nil { | ||
| 202 | return nil, err | ||
| 203 | } | ||
| 204 | h := sha256.Sum256(payload) | ||
| 205 | return h[:], nil | ||
| 206 | } | ||
| 207 | |||
| 208 | // Sign fills in e.ID and e.Sig using kp. It also sets e.PubKey from kp. | ||
| 209 | // The caller should populate all other fields (CreatedAt, Kind, Content, Tags) | ||
| 210 | // before calling Sign. | ||
| 211 | func Sign(e *Event, kp KeyPair) error { | ||
| 212 | e.PubKey = []byte(kp.PubKey) | ||
| 213 | |||
| 214 | id, err := EventID(e.PubKey, e.CreatedAt, e.Kind, e.Content, e.Tags) | ||
| 215 | if err != nil { | ||
| 216 | return err | ||
| 217 | } | ||
| 218 | e.ID = id | ||
| 219 | e.Sig = ed25519.Sign(kp.PrivKey, id) | ||
| 220 | return nil | ||
| 221 | } | ||
| 222 | |||
| 223 | // Verify checks that e.Sig is a valid Ed25519 signature of e.ID using e.PubKey, | ||
| 224 | // and that e.ID matches the canonical payload derived from the event fields. | ||
| 225 | // Returns nil if both checks pass. | ||
| 226 | func Verify(e *Event) error { | ||
| 227 | if len(e.PubKey) != 32 { | ||
| 228 | return fmt.Errorf("axon: pubkey must be 32 bytes, got %d", len(e.PubKey)) | ||
| 229 | } | ||
| 230 | if len(e.Sig) != ed25519.SignatureSize { | ||
| 231 | return fmt.Errorf("axon: sig must be %d bytes, got %d", ed25519.SignatureSize, len(e.Sig)) | ||
| 232 | } | ||
| 233 | |||
| 234 | expectedID, err := EventID(e.PubKey, e.CreatedAt, e.Kind, e.Content, e.Tags) | ||
| 235 | if err != nil { | ||
| 236 | return fmt.Errorf("axon: compute expected id: %w", err) | ||
| 237 | } | ||
| 238 | if len(e.ID) != 32 { | ||
| 239 | return fmt.Errorf("axon: id must be 32 bytes, got %d", len(e.ID)) | ||
| 240 | } | ||
| 241 | for i := range expectedID { | ||
| 242 | if expectedID[i] != e.ID[i] { | ||
| 243 | return errors.New("axon: event id does not match canonical payload") | ||
| 244 | } | ||
| 245 | } | ||
| 246 | |||
| 247 | pub := ed25519.PublicKey(e.PubKey) | ||
| 248 | if !ed25519.Verify(pub, e.ID, e.Sig) { | ||
| 249 | return errors.New("axon: invalid signature") | ||
| 250 | } | ||
| 251 | return nil | ||
| 252 | } | ||
| 253 | |||
| 254 | // SignChallenge signs the relay authentication challenge: | ||
| 255 | // | ||
| 256 | // sig = ed25519.Sign(privkey, SHA256(nonce || utf8(relay_url))) | ||
| 257 | func SignChallenge(kp KeyPair, nonce []byte, relayURL string) []byte { | ||
| 258 | h := sha256.New() | ||
| 259 | h.Write(nonce) | ||
| 260 | h.Write([]byte(relayURL)) | ||
| 261 | digest := h.Sum(nil) | ||
| 262 | return ed25519.Sign(kp.PrivKey, digest) | ||
| 263 | } | ||
| 264 | |||
| 265 | // VerifyChallenge verifies a challenge signature. | ||
| 266 | func VerifyChallenge(pubkey []byte, nonce []byte, relayURL string, sig []byte) bool { | ||
| 267 | h := sha256.New() | ||
| 268 | h.Write(nonce) | ||
| 269 | h.Write([]byte(relayURL)) | ||
| 270 | digest := h.Sum(nil) | ||
| 271 | return ed25519.Verify(ed25519.PublicKey(pubkey), digest, sig) | ||
| 272 | } | ||
diff --git a/axon_test.go b/axon_test.go new file mode 100644 index 0000000..b7f5bd6 --- /dev/null +++ b/axon_test.go | |||
| @@ -0,0 +1,550 @@ | |||
| 1 | package axon_test | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "crypto/ed25519" | ||
| 5 | "encoding/hex" | ||
| 6 | "encoding/json" | ||
| 7 | "os" | ||
| 8 | "path/filepath" | ||
| 9 | "testing" | ||
| 10 | "time" | ||
| 11 | |||
| 12 | "axon" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // -------------------------------------------------------------------------- | ||
| 16 | // Helpers | ||
| 17 | // -------------------------------------------------------------------------- | ||
| 18 | |||
| 19 | func mustDecodeHex(t *testing.T, s string) []byte { | ||
| 20 | t.Helper() | ||
| 21 | b, err := hex.DecodeString(s) | ||
| 22 | if err != nil { | ||
| 23 | t.Fatalf("hex decode %q: %v", s, err) | ||
| 24 | } | ||
| 25 | return b | ||
| 26 | } | ||
| 27 | |||
| 28 | func hexOf(b []byte) string { return hex.EncodeToString(b) } | ||
| 29 | |||
| 30 | // knownSeed returns a fixed 32-byte seed so tests are fully deterministic. | ||
| 31 | func knownSeed(n byte) []byte { | ||
| 32 | seed := make([]byte, 32) | ||
| 33 | for i := range seed { | ||
| 34 | seed[i] = n + byte(i) | ||
| 35 | } | ||
| 36 | return seed | ||
| 37 | } | ||
| 38 | |||
| 39 | // -------------------------------------------------------------------------- | ||
| 40 | // Test: canonical_tags | ||
| 41 | // -------------------------------------------------------------------------- | ||
| 42 | |||
| 43 | func TestCanonicalTags(t *testing.T) { | ||
| 44 | vectors := loadVectors(t) | ||
| 45 | tags := vectors.TagsInput | ||
| 46 | |||
| 47 | enc, err := axon.CanonicalTags(tags) | ||
| 48 | if err != nil { | ||
| 49 | t.Fatalf("CanonicalTags: %v", err) | ||
| 50 | } | ||
| 51 | if len(enc) == 0 { | ||
| 52 | t.Fatal("CanonicalTags returned empty bytes") | ||
| 53 | } | ||
| 54 | |||
| 55 | h, err := axon.CanonicalTagsHash(tags) | ||
| 56 | if err != nil { | ||
| 57 | t.Fatalf("CanonicalTagsHash: %v", err) | ||
| 58 | } | ||
| 59 | if h == [32]byte{} { | ||
| 60 | t.Fatal("CanonicalTagsHash returned zero hash") | ||
| 61 | } | ||
| 62 | |||
| 63 | // Verify sort order: tags with the same name are sorted by first value. | ||
| 64 | // "e"/"reply-id" < "e"/"root-id" lexicographically. "e" < "p". | ||
| 65 | // So expected order: e/reply-id, e/root-id, p/alice | ||
| 66 | expectHash := mustDecodeHex(t, vectors.CanonicalTagsHash) | ||
| 67 | if hexOf(h[:]) != hexOf(expectHash) { | ||
| 68 | t.Errorf("CanonicalTagsHash mismatch:\n got %s\n want %s", hexOf(h[:]), hexOf(expectHash)) | ||
| 69 | } | ||
| 70 | |||
| 71 | expectEnc := mustDecodeHex(t, vectors.CanonicalTagsHex) | ||
| 72 | if hexOf(enc) != hexOf(expectEnc) { | ||
| 73 | t.Errorf("canonical_tags bytes mismatch:\n got %s\n want %s", hexOf(enc), hexOf(expectEnc)) | ||
| 74 | } | ||
| 75 | } | ||
| 76 | |||
| 77 | func TestCanonicalTagsDuplicateError(t *testing.T) { | ||
| 78 | tags := []axon.Tag{ | ||
| 79 | {Name: "e", Values: []string{"same-id", "root"}}, | ||
| 80 | {Name: "e", Values: []string{"same-id", "reply"}}, | ||
| 81 | } | ||
| 82 | _, err := axon.CanonicalTags(tags) | ||
| 83 | if err == nil { | ||
| 84 | t.Fatal("expected error for duplicate (name, first_value) pair") | ||
| 85 | } | ||
| 86 | } | ||
| 87 | |||
| 88 | func TestCanonicalTagsEmpty(t *testing.T) { | ||
| 89 | enc, err := axon.CanonicalTags(nil) | ||
| 90 | if err != nil { | ||
| 91 | t.Fatalf("CanonicalTags(nil): %v", err) | ||
| 92 | } | ||
| 93 | // Should encode as uint16(0) = 2 zero bytes. | ||
| 94 | if len(enc) != 2 || enc[0] != 0 || enc[1] != 0 { | ||
| 95 | t.Errorf("empty tags encoding: got %x, want 0000", enc) | ||
| 96 | } | ||
| 97 | } | ||
| 98 | |||
| 99 | // -------------------------------------------------------------------------- | ||
| 100 | // Test: canonical_payload | ||
| 101 | // -------------------------------------------------------------------------- | ||
| 102 | |||
| 103 | func TestCanonicalPayload(t *testing.T) { | ||
| 104 | vectors := loadVectors(t) | ||
| 105 | kp := axon.NewKeyPairFromSeed(mustDecodeHex(t, vectors.SeedHex)) | ||
| 106 | content := mustDecodeHex(t, vectors.ContentHex) | ||
| 107 | |||
| 108 | payload, err := axon.CanonicalPayload(kp.PubKey, vectors.CreatedAt, vectors.Kind, content, vectors.TagsInput) | ||
| 109 | if err != nil { | ||
| 110 | t.Fatalf("CanonicalPayload: %v", err) | ||
| 111 | } | ||
| 112 | |||
| 113 | // Validate structure: first 2 bytes must be 0x00 0x20 (= 32). | ||
| 114 | if payload[0] != 0 || payload[1] != 32 { | ||
| 115 | t.Errorf("payload[0:2] = %x, want 0020", payload[0:2]) | ||
| 116 | } | ||
| 117 | // Bytes [2:34] = pubkey. | ||
| 118 | if hexOf(payload[2:34]) != hexOf(kp.PubKey) { | ||
| 119 | t.Errorf("pubkey in payload mismatch") | ||
| 120 | } | ||
| 121 | // Total length: 2 + 32 + 8 + 2 + 4 + len(content) + 32 | ||
| 122 | want := 2 + 32 + 8 + 2 + 4 + len(content) + 32 | ||
| 123 | if len(payload) != want { | ||
| 124 | t.Errorf("payload length = %d, want %d", len(payload), want) | ||
| 125 | } | ||
| 126 | |||
| 127 | expectPayload := mustDecodeHex(t, vectors.CanonicalPayload) | ||
| 128 | if hexOf(payload) != hexOf(expectPayload) { | ||
| 129 | t.Errorf("canonical_payload mismatch:\n got %s\n want %s", hexOf(payload), hexOf(expectPayload)) | ||
| 130 | } | ||
| 131 | } | ||
| 132 | |||
| 133 | // -------------------------------------------------------------------------- | ||
| 134 | // Test: event id | ||
| 135 | // -------------------------------------------------------------------------- | ||
| 136 | |||
| 137 | func TestEventID(t *testing.T) { | ||
| 138 | vectors := loadVectors(t) | ||
| 139 | kp := axon.NewKeyPairFromSeed(mustDecodeHex(t, vectors.SeedHex)) | ||
| 140 | content := mustDecodeHex(t, vectors.ContentHex) | ||
| 141 | |||
| 142 | id, err := axon.EventID(kp.PubKey, vectors.CreatedAt, vectors.Kind, content, vectors.TagsInput) | ||
| 143 | if err != nil { | ||
| 144 | t.Fatalf("EventID: %v", err) | ||
| 145 | } | ||
| 146 | if len(id) != 32 { | ||
| 147 | t.Fatalf("EventID returned %d bytes, want 32", len(id)) | ||
| 148 | } | ||
| 149 | |||
| 150 | expectID := mustDecodeHex(t, vectors.EventID) | ||
| 151 | if hexOf(id) != hexOf(expectID) { | ||
| 152 | t.Errorf("event id mismatch:\n got %s\n want %s", hexOf(id), hexOf(expectID)) | ||
| 153 | } | ||
| 154 | } | ||
| 155 | |||
| 156 | // -------------------------------------------------------------------------- | ||
| 157 | // Test: sign and verify | ||
| 158 | // -------------------------------------------------------------------------- | ||
| 159 | |||
| 160 | func TestSignVerify(t *testing.T) { | ||
| 161 | kp := axon.NewKeyPairFromSeed(knownSeed(0x01)) | ||
| 162 | |||
| 163 | e := &axon.Event{ | ||
| 164 | CreatedAt: 1700000000, | ||
| 165 | Kind: axon.KindMessage, | ||
| 166 | Content: []byte("hello axon"), | ||
| 167 | Tags: []axon.Tag{{Name: "p", Values: []string{"alice"}}}, | ||
| 168 | } | ||
| 169 | if err := axon.Sign(e, kp); err != nil { | ||
| 170 | t.Fatalf("Sign: %v", err) | ||
| 171 | } | ||
| 172 | |||
| 173 | if len(e.ID) != 32 { | ||
| 174 | t.Errorf("ID is %d bytes, want 32", len(e.ID)) | ||
| 175 | } | ||
| 176 | if len(e.Sig) != ed25519.SignatureSize { | ||
| 177 | t.Errorf("Sig is %d bytes, want 64", len(e.Sig)) | ||
| 178 | } | ||
| 179 | |||
| 180 | if err := axon.Verify(e); err != nil { | ||
| 181 | t.Fatalf("Verify: %v", err) | ||
| 182 | } | ||
| 183 | |||
| 184 | // Tampering with content must invalidate. | ||
| 185 | e2 := *e | ||
| 186 | e2.Content = []byte("tampered") | ||
| 187 | if err := axon.Verify(&e2); err == nil { | ||
| 188 | t.Fatal("Verify should fail after content tamper") | ||
| 189 | } | ||
| 190 | |||
| 191 | // Tampering with signature must invalidate. | ||
| 192 | e3 := *e | ||
| 193 | sig := make([]byte, len(e.Sig)) | ||
| 194 | copy(sig, e.Sig) | ||
| 195 | sig[0] ^= 0xff | ||
| 196 | e3.Sig = sig | ||
| 197 | if err := axon.Verify(&e3); err == nil { | ||
| 198 | t.Fatal("Verify should fail after sig tamper") | ||
| 199 | } | ||
| 200 | } | ||
| 201 | |||
| 202 | func TestVerifyWrongKey(t *testing.T) { | ||
| 203 | kp1 := axon.NewKeyPairFromSeed(knownSeed(0x01)) | ||
| 204 | kp2 := axon.NewKeyPairFromSeed(knownSeed(0x02)) | ||
| 205 | |||
| 206 | e := &axon.Event{ | ||
| 207 | CreatedAt: time.Now().Unix(), | ||
| 208 | Kind: axon.KindMessage, | ||
| 209 | Content: []byte("test"), | ||
| 210 | } | ||
| 211 | if err := axon.Sign(e, kp1); err != nil { | ||
| 212 | t.Fatalf("Sign: %v", err) | ||
| 213 | } | ||
| 214 | |||
| 215 | // Replace pubkey with kp2's pubkey; should fail. | ||
| 216 | e.PubKey = kp2.PubKey | ||
| 217 | if err := axon.Verify(e); err == nil { | ||
| 218 | t.Fatal("Verify should fail with wrong pubkey") | ||
| 219 | } | ||
| 220 | } | ||
| 221 | |||
| 222 | // -------------------------------------------------------------------------- | ||
| 223 | // Test: challenge sign/verify | ||
| 224 | // -------------------------------------------------------------------------- | ||
| 225 | |||
| 226 | func TestChallengeSignVerify(t *testing.T) { | ||
| 227 | kp := axon.NewKeyPairFromSeed(knownSeed(0x03)) | ||
| 228 | nonce := make([]byte, 32) | ||
| 229 | for i := range nonce { | ||
| 230 | nonce[i] = byte(i) | ||
| 231 | } | ||
| 232 | relayURL := "wss://relay.example.com" | ||
| 233 | |||
| 234 | sig := axon.SignChallenge(kp, nonce, relayURL) | ||
| 235 | if !axon.VerifyChallenge(kp.PubKey, nonce, relayURL, sig) { | ||
| 236 | t.Fatal("VerifyChallenge failed for valid signature") | ||
| 237 | } | ||
| 238 | if axon.VerifyChallenge(kp.PubKey, nonce, "wss://other.relay.com", sig) { | ||
| 239 | t.Fatal("VerifyChallenge should fail for different relay URL") | ||
| 240 | } | ||
| 241 | } | ||
| 242 | |||
| 243 | // -------------------------------------------------------------------------- | ||
| 244 | // Test: X25519 key conversion | ||
| 245 | // -------------------------------------------------------------------------- | ||
| 246 | |||
| 247 | func TestX25519Conversion(t *testing.T) { | ||
| 248 | kp := axon.NewKeyPairFromSeed(knownSeed(0x01)) | ||
| 249 | |||
| 250 | // Converting same Ed25519 keypair from both sides should yield same shared secret. | ||
| 251 | kp2 := axon.NewKeyPairFromSeed(knownSeed(0x02)) | ||
| 252 | |||
| 253 | shared1, err := axon.DHSharedSecret(kp.PrivKey, kp2.PubKey) | ||
| 254 | if err != nil { | ||
| 255 | t.Fatalf("DHSharedSecret(kp->kp2): %v", err) | ||
| 256 | } | ||
| 257 | shared2, err := axon.DHSharedSecret(kp2.PrivKey, kp.PubKey) | ||
| 258 | if err != nil { | ||
| 259 | t.Fatalf("DHSharedSecret(kp2->kp): %v", err) | ||
| 260 | } | ||
| 261 | if shared1 != shared2 { | ||
| 262 | t.Errorf("DH not commutative:\n kp->kp2: %s\n kp2->kp: %s", | ||
| 263 | hexOf(shared1[:]), hexOf(shared2[:])) | ||
| 264 | } | ||
| 265 | if shared1 == [32]byte{} { | ||
| 266 | t.Fatal("shared secret is all zeros") | ||
| 267 | } | ||
| 268 | } | ||
| 269 | |||
| 270 | // -------------------------------------------------------------------------- | ||
| 271 | // Test: DM encrypt/decrypt round-trip | ||
| 272 | // -------------------------------------------------------------------------- | ||
| 273 | |||
| 274 | func TestDMEncryptDecrypt(t *testing.T) { | ||
| 275 | alice := axon.NewKeyPairFromSeed(knownSeed(0x0a)) | ||
| 276 | bob := axon.NewKeyPairFromSeed(knownSeed(0x0b)) | ||
| 277 | |||
| 278 | plaintext := []byte("secret message from alice to bob") | ||
| 279 | |||
| 280 | ct, err := axon.EncryptDM(alice.PrivKey, bob.PubKey, plaintext) | ||
| 281 | if err != nil { | ||
| 282 | t.Fatalf("EncryptDM: %v", err) | ||
| 283 | } | ||
| 284 | |||
| 285 | pt, err := axon.DecryptDM(bob.PrivKey, alice.PubKey, ct) | ||
| 286 | if err != nil { | ||
| 287 | t.Fatalf("DecryptDM: %v", err) | ||
| 288 | } | ||
| 289 | if string(pt) != string(plaintext) { | ||
| 290 | t.Errorf("decrypt mismatch: got %q, want %q", pt, plaintext) | ||
| 291 | } | ||
| 292 | |||
| 293 | // Tamper with ciphertext — authentication should fail. | ||
| 294 | ct2 := make([]byte, len(ct)) | ||
| 295 | copy(ct2, ct) | ||
| 296 | ct2[len(ct2)-1] ^= 0xff | ||
| 297 | if _, err := axon.DecryptDM(bob.PrivKey, alice.PubKey, ct2); err == nil { | ||
| 298 | t.Fatal("DecryptDM should fail on tampered ciphertext") | ||
| 299 | } | ||
| 300 | |||
| 301 | // Decrypt with wrong key should fail. | ||
| 302 | carol := axon.NewKeyPairFromSeed(knownSeed(0x0c)) | ||
| 303 | if _, err := axon.DecryptDM(carol.PrivKey, alice.PubKey, ct); err == nil { | ||
| 304 | t.Fatal("DecryptDM should fail with wrong recipient key") | ||
| 305 | } | ||
| 306 | } | ||
| 307 | |||
| 308 | // -------------------------------------------------------------------------- | ||
| 309 | // Test: MessagePack encode/decode | ||
| 310 | // -------------------------------------------------------------------------- | ||
| 311 | |||
| 312 | func TestMsgPackRoundTrip(t *testing.T) { | ||
| 313 | kp := axon.NewKeyPairFromSeed(knownSeed(0x05)) | ||
| 314 | e := &axon.Event{ | ||
| 315 | CreatedAt: 1700000000, | ||
| 316 | Kind: axon.KindMessage, | ||
| 317 | Content: []byte("msgpack test"), | ||
| 318 | Tags: []axon.Tag{ | ||
| 319 | {Name: "p", Values: []string{"recipient"}}, | ||
| 320 | {Name: "e", Values: []string{"ref-id", "root"}}, | ||
| 321 | }, | ||
| 322 | } | ||
| 323 | if err := axon.Sign(e, kp); err != nil { | ||
| 324 | t.Fatalf("Sign: %v", err) | ||
| 325 | } | ||
| 326 | |||
| 327 | data, err := axon.MarshalEvent(e) | ||
| 328 | if err != nil { | ||
| 329 | t.Fatalf("MarshalEvent: %v", err) | ||
| 330 | } | ||
| 331 | if len(data) == 0 { | ||
| 332 | t.Fatal("MarshalEvent returned empty bytes") | ||
| 333 | } | ||
| 334 | |||
| 335 | e2, err := axon.UnmarshalEvent(data) | ||
| 336 | if err != nil { | ||
| 337 | t.Fatalf("UnmarshalEvent: %v", err) | ||
| 338 | } | ||
| 339 | |||
| 340 | // Verify round-tripped event is still valid. | ||
| 341 | if err := axon.Verify(e2); err != nil { | ||
| 342 | t.Fatalf("Verify after msgpack round-trip: %v", err) | ||
| 343 | } | ||
| 344 | |||
| 345 | if hexOf(e.ID) != hexOf(e2.ID) { | ||
| 346 | t.Errorf("ID mismatch after round-trip") | ||
| 347 | } | ||
| 348 | if hexOf(e.Sig) != hexOf(e2.Sig) { | ||
| 349 | t.Errorf("Sig mismatch after round-trip") | ||
| 350 | } | ||
| 351 | if string(e.Content) != string(e2.Content) { | ||
| 352 | t.Errorf("Content mismatch after round-trip: got %q, want %q", e2.Content, e.Content) | ||
| 353 | } | ||
| 354 | } | ||
| 355 | |||
| 356 | // -------------------------------------------------------------------------- | ||
| 357 | // Test vector generation and validation | ||
| 358 | // -------------------------------------------------------------------------- | ||
| 359 | |||
| 360 | // Vectors is the schema for testdata/vectors.json. | ||
| 361 | type Vectors struct { | ||
| 362 | // canonical_tags vector | ||
| 363 | TagsInput []axon.Tag `json:"tags_input"` | ||
| 364 | CanonicalTagsHex string `json:"canonical_tags_hex"` | ||
| 365 | CanonicalTagsHash string `json:"canonical_tags_hash"` | ||
| 366 | |||
| 367 | // canonical_payload vector | ||
| 368 | SeedHex string `json:"seed_hex"` | ||
| 369 | PubKeyHex string `json:"pubkey_hex"` | ||
| 370 | CreatedAt int64 `json:"created_at"` | ||
| 371 | Kind uint16 `json:"kind"` | ||
| 372 | ContentHex string `json:"content_hex"` | ||
| 373 | CanonicalPayload string `json:"canonical_payload"` | ||
| 374 | |||
| 375 | // event id | ||
| 376 | EventID string `json:"event_id"` | ||
| 377 | |||
| 378 | // signature (sign with known seed; Ed25519 is deterministic so sig is stable) | ||
| 379 | SigHex string `json:"sig_hex"` | ||
| 380 | } | ||
| 381 | |||
| 382 | func vectorsPath() string { | ||
| 383 | return filepath.Join("testdata", "vectors.json") | ||
| 384 | } | ||
| 385 | |||
| 386 | func loadVectors(t *testing.T) Vectors { | ||
| 387 | t.Helper() | ||
| 388 | data, err := os.ReadFile(vectorsPath()) | ||
| 389 | if err != nil { | ||
| 390 | t.Fatalf("load vectors: %v (run TestGenerateVectors first)", err) | ||
| 391 | } | ||
| 392 | var v Vectors | ||
| 393 | if err := json.Unmarshal(data, &v); err != nil { | ||
| 394 | t.Fatalf("parse vectors: %v", err) | ||
| 395 | } | ||
| 396 | return v | ||
| 397 | } | ||
| 398 | |||
| 399 | // TestGenerateVectors generates testdata/vectors.json from known inputs. | ||
| 400 | // Run once with -run TestGenerateVectors to produce the file, then the other | ||
| 401 | // tests validate against it. The file is committed to the repo. | ||
| 402 | func TestGenerateVectors(t *testing.T) { | ||
| 403 | seed := knownSeed(0x01) | ||
| 404 | kp := axon.NewKeyPairFromSeed(seed) | ||
| 405 | |||
| 406 | tags := []axon.Tag{ | ||
| 407 | {Name: "p", Values: []string{"alice"}}, | ||
| 408 | {Name: "e", Values: []string{"root-id", "root"}}, | ||
| 409 | {Name: "e", Values: []string{"reply-id", "reply"}}, | ||
| 410 | } | ||
| 411 | content := []byte("hello axon") | ||
| 412 | createdAt := int64(1700000000) | ||
| 413 | kind := axon.KindMessage | ||
| 414 | |||
| 415 | // canonical_tags | ||
| 416 | tagsEnc, err := axon.CanonicalTags(tags) | ||
| 417 | if err != nil { | ||
| 418 | t.Fatalf("CanonicalTags: %v", err) | ||
| 419 | } | ||
| 420 | tagsHash, err := axon.CanonicalTagsHash(tags) | ||
| 421 | if err != nil { | ||
| 422 | t.Fatalf("CanonicalTagsHash: %v", err) | ||
| 423 | } | ||
| 424 | |||
| 425 | // canonical_payload | ||
| 426 | payload, err := axon.CanonicalPayload(kp.PubKey, createdAt, kind, content, tags) | ||
| 427 | if err != nil { | ||
| 428 | t.Fatalf("CanonicalPayload: %v", err) | ||
| 429 | } | ||
| 430 | |||
| 431 | // event id | ||
| 432 | id, err := axon.EventID(kp.PubKey, createdAt, kind, content, tags) | ||
| 433 | if err != nil { | ||
| 434 | t.Fatalf("EventID: %v", err) | ||
| 435 | } | ||
| 436 | |||
| 437 | // sign | ||
| 438 | e := &axon.Event{ | ||
| 439 | CreatedAt: createdAt, | ||
| 440 | Kind: kind, | ||
| 441 | Content: content, | ||
| 442 | Tags: tags, | ||
| 443 | } | ||
| 444 | if err := axon.Sign(e, kp); err != nil { | ||
| 445 | t.Fatalf("Sign: %v", err) | ||
| 446 | } | ||
| 447 | // Verify the event so we know the sig is valid. | ||
| 448 | if err := axon.Verify(e); err != nil { | ||
| 449 | t.Fatalf("Verify generated event: %v", err) | ||
| 450 | } | ||
| 451 | |||
| 452 | v := Vectors{ | ||
| 453 | TagsInput: tags, | ||
| 454 | CanonicalTagsHex: hexOf(tagsEnc), | ||
| 455 | CanonicalTagsHash: hexOf(tagsHash[:]), | ||
| 456 | |||
| 457 | SeedHex: hexOf(seed), | ||
| 458 | PubKeyHex: hexOf(kp.PubKey), | ||
| 459 | CreatedAt: createdAt, | ||
| 460 | Kind: kind, | ||
| 461 | ContentHex: hexOf(content), | ||
| 462 | CanonicalPayload: hexOf(payload), | ||
| 463 | |||
| 464 | EventID: hexOf(id), | ||
| 465 | SigHex: hexOf(e.Sig), | ||
| 466 | } | ||
| 467 | |||
| 468 | data, err := json.MarshalIndent(v, "", " ") | ||
| 469 | if err != nil { | ||
| 470 | t.Fatalf("marshal vectors: %v", err) | ||
| 471 | } | ||
| 472 | |||
| 473 | if err := os.MkdirAll("testdata", 0o755); err != nil { | ||
| 474 | t.Fatalf("mkdir testdata: %v", err) | ||
| 475 | } | ||
| 476 | if err := os.WriteFile(vectorsPath(), data, 0o644); err != nil { | ||
| 477 | t.Fatalf("write vectors: %v", err) | ||
| 478 | } | ||
| 479 | |||
| 480 | t.Logf("vectors written to %s", vectorsPath()) | ||
| 481 | t.Logf("canonical_tags_hash: %s", hexOf(tagsHash[:])) | ||
| 482 | t.Logf("event_id: %s", hexOf(id)) | ||
| 483 | } | ||
| 484 | |||
| 485 | // TestVectors validates all vectors in testdata/vectors.json. This is the | ||
| 486 | // primary cross-language compatibility check. | ||
| 487 | func TestVectors(t *testing.T) { | ||
| 488 | v := loadVectors(t) | ||
| 489 | |||
| 490 | seed := mustDecodeHex(t, v.SeedHex) | ||
| 491 | kp := axon.NewKeyPairFromSeed(seed) | ||
| 492 | |||
| 493 | if hexOf(kp.PubKey) != v.PubKeyHex { | ||
| 494 | t.Errorf("pubkey mismatch:\n got %s\n want %s", hexOf(kp.PubKey), v.PubKeyHex) | ||
| 495 | } | ||
| 496 | |||
| 497 | // canonical_tags | ||
| 498 | tagsEnc, err := axon.CanonicalTags(v.TagsInput) | ||
| 499 | if err != nil { | ||
| 500 | t.Fatalf("CanonicalTags: %v", err) | ||
| 501 | } | ||
| 502 | if hexOf(tagsEnc) != v.CanonicalTagsHex { | ||
| 503 | t.Errorf("canonical_tags bytes mismatch:\n got %s\n want %s", | ||
| 504 | hexOf(tagsEnc), v.CanonicalTagsHex) | ||
| 505 | } | ||
| 506 | |||
| 507 | tagsHash, err := axon.CanonicalTagsHash(v.TagsInput) | ||
| 508 | if err != nil { | ||
| 509 | t.Fatalf("CanonicalTagsHash: %v", err) | ||
| 510 | } | ||
| 511 | if hexOf(tagsHash[:]) != v.CanonicalTagsHash { | ||
| 512 | t.Errorf("canonical_tags_hash mismatch:\n got %s\n want %s", | ||
| 513 | hexOf(tagsHash[:]), v.CanonicalTagsHash) | ||
| 514 | } | ||
| 515 | |||
| 516 | // canonical_payload | ||
| 517 | content := mustDecodeHex(t, v.ContentHex) | ||
| 518 | payload, err := axon.CanonicalPayload(kp.PubKey, v.CreatedAt, v.Kind, content, v.TagsInput) | ||
| 519 | if err != nil { | ||
| 520 | t.Fatalf("CanonicalPayload: %v", err) | ||
| 521 | } | ||
| 522 | if hexOf(payload) != v.CanonicalPayload { | ||
| 523 | t.Errorf("canonical_payload mismatch:\n got %s\n want %s", | ||
| 524 | hexOf(payload), v.CanonicalPayload) | ||
| 525 | } | ||
| 526 | |||
| 527 | // event id | ||
| 528 | id, err := axon.EventID(kp.PubKey, v.CreatedAt, v.Kind, content, v.TagsInput) | ||
| 529 | if err != nil { | ||
| 530 | t.Fatalf("EventID: %v", err) | ||
| 531 | } | ||
| 532 | if hexOf(id) != v.EventID { | ||
| 533 | t.Errorf("event_id mismatch:\n got %s\n want %s", hexOf(id), v.EventID) | ||
| 534 | } | ||
| 535 | |||
| 536 | // verify the stored signature | ||
| 537 | sig := mustDecodeHex(t, v.SigHex) | ||
| 538 | e := &axon.Event{ | ||
| 539 | ID: id, | ||
| 540 | PubKey: kp.PubKey, | ||
| 541 | CreatedAt: v.CreatedAt, | ||
| 542 | Kind: v.Kind, | ||
| 543 | Content: content, | ||
| 544 | Sig: sig, | ||
| 545 | Tags: v.TagsInput, | ||
| 546 | } | ||
| 547 | if err := axon.Verify(e); err != nil { | ||
| 548 | t.Errorf("Verify stored vector event: %v", err) | ||
| 549 | } | ||
| 550 | } | ||
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 | } | ||
diff --git a/encoding.go b/encoding.go new file mode 100644 index 0000000..4ed9da6 --- /dev/null +++ b/encoding.go | |||
| @@ -0,0 +1,54 @@ | |||
| 1 | package axon | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | |||
| 6 | "github.com/vmihailenco/msgpack/v5" | ||
| 7 | ) | ||
| 8 | |||
| 9 | // MarshalEvent encodes an Event to MessagePack. Fields are encoded in the | ||
| 10 | // canonical struct order using the msgpack struct tags. Binary fields (id, | ||
| 11 | // pubkey, sig, content) are encoded as msgpack bin type ([]byte). | ||
| 12 | func MarshalEvent(e *Event) ([]byte, error) { | ||
| 13 | b, err := msgpack.Marshal(e) | ||
| 14 | if err != nil { | ||
| 15 | return nil, fmt.Errorf("axon: marshal event: %w", err) | ||
| 16 | } | ||
| 17 | return b, nil | ||
| 18 | } | ||
| 19 | |||
| 20 | // UnmarshalEvent decodes a MessagePack blob into an Event. | ||
| 21 | func UnmarshalEvent(data []byte) (*Event, error) { | ||
| 22 | var e Event | ||
| 23 | if err := msgpack.Unmarshal(data, &e); err != nil { | ||
| 24 | return nil, fmt.Errorf("axon: unmarshal event: %w", err) | ||
| 25 | } | ||
| 26 | return &e, nil | ||
| 27 | } | ||
| 28 | |||
| 29 | // MarshalMessage encodes a wire message as a msgpack array: [type, payload]. | ||
| 30 | // messageType is a uint16; payload is any msgpack-serializable value. | ||
| 31 | func MarshalMessage(messageType uint16, payload interface{}) ([]byte, error) { | ||
| 32 | b, err := msgpack.Marshal([]interface{}{messageType, payload}) | ||
| 33 | if err != nil { | ||
| 34 | return nil, fmt.Errorf("axon: marshal message: %w", err) | ||
| 35 | } | ||
| 36 | return b, nil | ||
| 37 | } | ||
| 38 | |||
| 39 | // UnmarshalMessageType reads only the first element of a [type, payload] | ||
| 40 | // msgpack array, returning the message type without decoding the payload. | ||
| 41 | func UnmarshalMessageType(data []byte) (uint16, error) { | ||
| 42 | var arr []msgpack.RawMessage | ||
| 43 | if err := msgpack.Unmarshal(data, &arr); err != nil { | ||
| 44 | return 0, fmt.Errorf("axon: unmarshal message: %w", err) | ||
| 45 | } | ||
| 46 | if len(arr) < 1 { | ||
| 47 | return 0, fmt.Errorf("axon: message array is empty") | ||
| 48 | } | ||
| 49 | var t uint16 | ||
| 50 | if err := msgpack.Unmarshal(arr[0], &t); err != nil { | ||
| 51 | return 0, fmt.Errorf("axon: unmarshal message type: %w", err) | ||
| 52 | } | ||
| 53 | return t, nil | ||
| 54 | } | ||
| @@ -0,0 +1,10 @@ | |||
| 1 | module axon | ||
| 2 | |||
| 3 | go 1.25.5 | ||
| 4 | |||
| 5 | require ( | ||
| 6 | github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect | ||
| 7 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect | ||
| 8 | golang.org/x/crypto v0.48.0 // indirect | ||
| 9 | golang.org/x/sys v0.41.0 // indirect | ||
| 10 | ) | ||
| @@ -0,0 +1,8 @@ | |||
| 1 | github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= | ||
| 2 | github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= | ||
| 3 | github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= | ||
| 4 | github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= | ||
| 5 | golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= | ||
| 6 | golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= | ||
| 7 | golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= | ||
| 8 | golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= | ||
diff --git a/testdata/vectors.json b/testdata/vectors.json new file mode 100644 index 0000000..7325fa5 --- /dev/null +++ b/testdata/vectors.json | |||
| @@ -0,0 +1,34 @@ | |||
| 1 | { | ||
| 2 | "tags_input": [ | ||
| 3 | { | ||
| 4 | "name": "p", | ||
| 5 | "values": [ | ||
| 6 | "alice" | ||
| 7 | ] | ||
| 8 | }, | ||
| 9 | { | ||
| 10 | "name": "e", | ||
| 11 | "values": [ | ||
| 12 | "root-id", | ||
| 13 | "root" | ||
| 14 | ] | ||
| 15 | }, | ||
| 16 | { | ||
| 17 | "name": "e", | ||
| 18 | "values": [ | ||
| 19 | "reply-id", | ||
| 20 | "reply" | ||
| 21 | ] | ||
| 22 | } | ||
| 23 | ], | ||
| 24 | "canonical_tags_hex": "00030001650002000000087265706c792d6964000000057265706c79000165000200000007726f6f742d696400000004726f6f74000170000100000005616c696365", | ||
| 25 | "canonical_tags_hash": "2813b33f953e03e88160c651cbd4feb000c37a9b25690cad22fa26eb272bc9e6", | ||
| 26 | "seed_hex": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", | ||
| 27 | "pubkey_hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", | ||
| 28 | "created_at": 1700000000, | ||
| 29 | "kind": 1000, | ||
| 30 | "content_hex": "68656c6c6f2061786f6e", | ||
| 31 | "canonical_payload": "002079b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664000000006553f10003e80000000a68656c6c6f2061786f6e2813b33f953e03e88160c651cbd4feb000c37a9b25690cad22fa26eb272bc9e6", | ||
| 32 | "event_id": "16744a01674a332bab4e8814500b56b4a3c907c154dca01099ba6ed3aaba24df", | ||
| 33 | "sig_hex": "0706e27133980aedb544b3618b4afc63d234fad37e80e67018132dac7aad6c12a0923b1494390103ef301b2066a7e62870a623c91f0d21c3d1af1f9ac322ff08" | ||
| 34 | } \ No newline at end of file | ||
