aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorbndw <ben@bdw.to>2026-03-08 22:07:14 -0700
committerbndw <ben@bdw.to>2026-03-08 22:07:14 -0700
commit3ff2bc0530bb98da139a5f68202c8e119f9d4775 (patch)
treebcca197dee7f13823ac17d2b5ec1b62b94b897a8
parent53b10eab74d83522dd90af697773e32279469b30 (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.go272
-rw-r--r--axon_test.go550
-rw-r--r--crypto.go144
-rw-r--r--encoding.go54
-rw-r--r--go.mod10
-rw-r--r--go.sum8
-rw-r--r--testdata/vectors.json34
7 files changed, 1072 insertions, 0 deletions
diff --git a/axon.go b/axon.go
new file mode 100644
index 0000000..51ec22d
--- /dev/null
+++ b/axon.go
@@ -0,0 +1,272 @@
1// Package axon implements the Axon protocol core: event signing, verification,
2// canonical payload construction, and related helpers.
3package axon
4
5import (
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.
16const (
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.
27type 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).
35type 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.
46type KeyPair struct {
47 PrivKey ed25519.PrivateKey
48 PubKey ed25519.PublicKey
49}
50
51// NewKeyPair generates a fresh Ed25519 keypair.
52func 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.
62func 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).
84func 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).
150func 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
170func 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.
199func 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.
211func 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.
226func 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)))
257func 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.
266func 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 @@
1package axon_test
2
3import (
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
19func 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
28func hexOf(b []byte) string { return hex.EncodeToString(b) }
29
30// knownSeed returns a fixed 32-byte seed so tests are fully deterministic.
31func 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
43func 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
77func 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
88func 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
103func 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
137func 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
160func 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
202func 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
226func 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
247func 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
274func 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
312func 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.
361type 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
382func vectorsPath() string {
383 return filepath.Join("testdata", "vectors.json")
384}
385
386func 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.
402func 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.
487func 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 @@
1package axon
2
3import (
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.
16var 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).
20func 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.
35func 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.
79func 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.
97func 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.
120func 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 @@
1package axon
2
3import (
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).
12func 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.
21func 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.
31func 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.
41func 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}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..4d76e2e
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,10 @@
1module axon
2
3go 1.25.5
4
5require (
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)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..29b748f
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,8 @@
1github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
2github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
3github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
4github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
5golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
6golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
7golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
8golang.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