From 3ff2bc0530bb98da139a5f68202c8e119f9d4775 Mon Sep 17 00:00:00 2001 From: bndw Date: Sun, 8 Mar 2026 22:07:14 -0700 Subject: feat: implement Phase 1 Axon protocol core package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- axon.go | 272 +++++++++++++++++++++++++ axon_test.go | 550 ++++++++++++++++++++++++++++++++++++++++++++++++++ crypto.go | 144 +++++++++++++ encoding.go | 54 +++++ go.mod | 10 + go.sum | 8 + testdata/vectors.json | 34 ++++ 7 files changed, 1072 insertions(+) create mode 100644 axon.go create mode 100644 axon_test.go create mode 100644 crypto.go create mode 100644 encoding.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 testdata/vectors.json diff --git a/axon.go b/axon.go new file mode 100644 index 0000000..51ec22d --- /dev/null +++ b/axon.go @@ -0,0 +1,272 @@ +// Package axon implements the Axon protocol core: event signing, verification, +// canonical payload construction, and related helpers. +package axon + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "sort" +) + +// Event kind constants matching the registry in PROTOCOL.md. +const ( + KindProfile uint16 = 0 // Identity metadata + KindMessage uint16 = 1000 // Plain text note + KindDM uint16 = 2000 // Encrypted direct message + KindProgress uint16 = 3000 // Ephemeral progress/status indicator + KindJobRequest uint16 = 5000 // Request for agent work + KindJobResult uint16 = 6000 // Completed job output + KindJobFeedback uint16 = 7000 // In-progress status / error +) + +// Tag is a named list of string values attached to an Event. +type Tag struct { + Name string `msgpack:"name" json:"name"` + Values []string `msgpack:"values" json:"values"` +} + +// Event is the core Axon data structure. All fields use their wire types. +// id, pubkey and sig are raw 32/64-byte slices, not hex. +// content is opaque bytes (msgpack bin type). +type Event struct { + ID []byte `msgpack:"id"` // 32 bytes, SHA256 of canonical payload + PubKey []byte `msgpack:"pubkey"` // 32 bytes, Ed25519 public key + CreatedAt int64 `msgpack:"created_at"` // Unix timestamp + Kind uint16 `msgpack:"kind"` + Content []byte `msgpack:"content"` // opaque; msgpack bin type + Sig []byte `msgpack:"sig"` // 64 bytes, Ed25519 signature over id + Tags []Tag `msgpack:"tags"` +} + +// KeyPair holds an Ed25519 private and public key. +type KeyPair struct { + PrivKey ed25519.PrivateKey + PubKey ed25519.PublicKey +} + +// NewKeyPair generates a fresh Ed25519 keypair. +func NewKeyPair() (KeyPair, error) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return KeyPair{}, fmt.Errorf("axon: generate key: %w", err) + } + return KeyPair{PrivKey: priv, PubKey: pub}, nil +} + +// NewKeyPairFromSeed derives a keypair from a 32-byte seed (the canonical +// private key representation). Panics if seed is not 32 bytes. +func NewKeyPairFromSeed(seed []byte) KeyPair { + if len(seed) != ed25519.SeedSize { + panic(fmt.Sprintf("axon: seed must be %d bytes, got %d", ed25519.SeedSize, len(seed))) + } + priv := ed25519.NewKeyFromSeed(seed) + return KeyPair{PrivKey: priv, PubKey: priv.Public().(ed25519.PublicKey)} +} + +// CanonicalTags encodes tags into their canonical binary representation and +// returns the raw bytes (before hashing). This is exposed so callers can +// inspect the encoding in tests; normally you want CanonicalTagsHash. +// +// Encoding: +// +// uint16(num_tags) +// for each tag (sorted by name, then first value for ties): +// uint16(len(name)) || utf8(name) +// uint16(num_values) +// for each value: +// uint32(len(value)) || utf8(value) +// +// Returns an error if two tags share the same name and first value (protocol error). +func CanonicalTags(tags []Tag) ([]byte, error) { + // Sort a copy so we don't mutate the caller's slice. + sorted := make([]Tag, len(tags)) + copy(sorted, tags) + sort.SliceStable(sorted, func(i, j int) bool { + if sorted[i].Name != sorted[j].Name { + return sorted[i].Name < sorted[j].Name + } + vi := "" + if len(sorted[i].Values) > 0 { + vi = sorted[i].Values[0] + } + vj := "" + if len(sorted[j].Values) > 0 { + vj = sorted[j].Values[0] + } + return vi < vj + }) + + // Detect duplicates (same name + same first value). + for i := 1; i < len(sorted); i++ { + prev, cur := sorted[i-1], sorted[i] + if prev.Name != cur.Name { + continue + } + prevFirst := "" + if len(prev.Values) > 0 { + prevFirst = prev.Values[0] + } + curFirst := "" + if len(cur.Values) > 0 { + curFirst = cur.Values[0] + } + if prevFirst == curFirst { + return nil, fmt.Errorf("axon: duplicate tag (name=%q first_value=%q)", cur.Name, curFirst) + } + } + + // Estimate capacity to avoid repeated allocations. + buf := make([]byte, 0, 2+len(sorted)*16) + var hdr [4]byte + + binary.BigEndian.PutUint16(hdr[:2], uint16(len(sorted))) + buf = append(buf, hdr[:2]...) + + for _, tag := range sorted { + name := []byte(tag.Name) + binary.BigEndian.PutUint16(hdr[:2], uint16(len(name))) + buf = append(buf, hdr[:2]...) + buf = append(buf, name...) + + binary.BigEndian.PutUint16(hdr[:2], uint16(len(tag.Values))) + buf = append(buf, hdr[:2]...) + + for _, v := range tag.Values { + vb := []byte(v) + binary.BigEndian.PutUint32(hdr[:4], uint32(len(vb))) + buf = append(buf, hdr[:4]...) + buf = append(buf, vb...) + } + } + + return buf, nil +} + +// CanonicalTagsHash returns SHA256(canonical_tags encoding). +func CanonicalTagsHash(tags []Tag) ([32]byte, error) { + enc, err := CanonicalTags(tags) + if err != nil { + return [32]byte{}, err + } + return sha256.Sum256(enc), nil +} + +// CanonicalPayload constructs the deterministic byte payload that is hashed to +// produce the event ID. +// +// Layout: +// +// [0:2] uint16 = 32 pubkey length (always 32) +// [2:34] bytes pubkey +// [34:42] uint64 created_at +// [42:44] uint16 kind +// [44:48] uint32 content length +// [48:48+n] bytes content +// [48+n:80+n] bytes SHA256(canonical_tags), 32 bytes +func CanonicalPayload(pubkey []byte, createdAt int64, kind uint16, content []byte, tags []Tag) ([]byte, error) { + if len(pubkey) != 32 { + return nil, fmt.Errorf("axon: pubkey must be 32 bytes, got %d", len(pubkey)) + } + if len(content) > 65536 { + return nil, errors.New("axon: content exceeds 65536 byte limit") + } + + tagsHash, err := CanonicalTagsHash(tags) + if err != nil { + return nil, err + } + + n := len(content) + // Total size: 2 + 32 + 8 + 2 + 4 + n + 32 + payload := make([]byte, 80+n) + + binary.BigEndian.PutUint16(payload[0:2], 32) + copy(payload[2:34], pubkey) + binary.BigEndian.PutUint64(payload[34:42], uint64(createdAt)) + binary.BigEndian.PutUint16(payload[42:44], kind) + binary.BigEndian.PutUint32(payload[44:48], uint32(n)) + copy(payload[48:48+n], content) + copy(payload[48+n:80+n], tagsHash[:]) + + return payload, nil +} + +// EventID computes the canonical SHA256 event ID for the given fields. +func EventID(pubkey []byte, createdAt int64, kind uint16, content []byte, tags []Tag) ([]byte, error) { + payload, err := CanonicalPayload(pubkey, createdAt, kind, content, tags) + if err != nil { + return nil, err + } + h := sha256.Sum256(payload) + return h[:], nil +} + +// Sign fills in e.ID and e.Sig using kp. It also sets e.PubKey from kp. +// The caller should populate all other fields (CreatedAt, Kind, Content, Tags) +// before calling Sign. +func Sign(e *Event, kp KeyPair) error { + e.PubKey = []byte(kp.PubKey) + + id, err := EventID(e.PubKey, e.CreatedAt, e.Kind, e.Content, e.Tags) + if err != nil { + return err + } + e.ID = id + e.Sig = ed25519.Sign(kp.PrivKey, id) + return nil +} + +// Verify checks that e.Sig is a valid Ed25519 signature of e.ID using e.PubKey, +// and that e.ID matches the canonical payload derived from the event fields. +// Returns nil if both checks pass. +func Verify(e *Event) error { + if len(e.PubKey) != 32 { + return fmt.Errorf("axon: pubkey must be 32 bytes, got %d", len(e.PubKey)) + } + if len(e.Sig) != ed25519.SignatureSize { + return fmt.Errorf("axon: sig must be %d bytes, got %d", ed25519.SignatureSize, len(e.Sig)) + } + + expectedID, err := EventID(e.PubKey, e.CreatedAt, e.Kind, e.Content, e.Tags) + if err != nil { + return fmt.Errorf("axon: compute expected id: %w", err) + } + if len(e.ID) != 32 { + return fmt.Errorf("axon: id must be 32 bytes, got %d", len(e.ID)) + } + for i := range expectedID { + if expectedID[i] != e.ID[i] { + return errors.New("axon: event id does not match canonical payload") + } + } + + pub := ed25519.PublicKey(e.PubKey) + if !ed25519.Verify(pub, e.ID, e.Sig) { + return errors.New("axon: invalid signature") + } + return nil +} + +// SignChallenge signs the relay authentication challenge: +// +// sig = ed25519.Sign(privkey, SHA256(nonce || utf8(relay_url))) +func SignChallenge(kp KeyPair, nonce []byte, relayURL string) []byte { + h := sha256.New() + h.Write(nonce) + h.Write([]byte(relayURL)) + digest := h.Sum(nil) + return ed25519.Sign(kp.PrivKey, digest) +} + +// VerifyChallenge verifies a challenge signature. +func VerifyChallenge(pubkey []byte, nonce []byte, relayURL string, sig []byte) bool { + h := sha256.New() + h.Write(nonce) + h.Write([]byte(relayURL)) + digest := h.Sum(nil) + return ed25519.Verify(ed25519.PublicKey(pubkey), digest, sig) +} 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 @@ +package axon_test + +import ( + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "axon" +) + +// -------------------------------------------------------------------------- +// Helpers +// -------------------------------------------------------------------------- + +func mustDecodeHex(t *testing.T, s string) []byte { + t.Helper() + b, err := hex.DecodeString(s) + if err != nil { + t.Fatalf("hex decode %q: %v", s, err) + } + return b +} + +func hexOf(b []byte) string { return hex.EncodeToString(b) } + +// knownSeed returns a fixed 32-byte seed so tests are fully deterministic. +func knownSeed(n byte) []byte { + seed := make([]byte, 32) + for i := range seed { + seed[i] = n + byte(i) + } + return seed +} + +// -------------------------------------------------------------------------- +// Test: canonical_tags +// -------------------------------------------------------------------------- + +func TestCanonicalTags(t *testing.T) { + vectors := loadVectors(t) + tags := vectors.TagsInput + + enc, err := axon.CanonicalTags(tags) + if err != nil { + t.Fatalf("CanonicalTags: %v", err) + } + if len(enc) == 0 { + t.Fatal("CanonicalTags returned empty bytes") + } + + h, err := axon.CanonicalTagsHash(tags) + if err != nil { + t.Fatalf("CanonicalTagsHash: %v", err) + } + if h == [32]byte{} { + t.Fatal("CanonicalTagsHash returned zero hash") + } + + // Verify sort order: tags with the same name are sorted by first value. + // "e"/"reply-id" < "e"/"root-id" lexicographically. "e" < "p". + // So expected order: e/reply-id, e/root-id, p/alice + expectHash := mustDecodeHex(t, vectors.CanonicalTagsHash) + if hexOf(h[:]) != hexOf(expectHash) { + t.Errorf("CanonicalTagsHash mismatch:\n got %s\n want %s", hexOf(h[:]), hexOf(expectHash)) + } + + expectEnc := mustDecodeHex(t, vectors.CanonicalTagsHex) + if hexOf(enc) != hexOf(expectEnc) { + t.Errorf("canonical_tags bytes mismatch:\n got %s\n want %s", hexOf(enc), hexOf(expectEnc)) + } +} + +func TestCanonicalTagsDuplicateError(t *testing.T) { + tags := []axon.Tag{ + {Name: "e", Values: []string{"same-id", "root"}}, + {Name: "e", Values: []string{"same-id", "reply"}}, + } + _, err := axon.CanonicalTags(tags) + if err == nil { + t.Fatal("expected error for duplicate (name, first_value) pair") + } +} + +func TestCanonicalTagsEmpty(t *testing.T) { + enc, err := axon.CanonicalTags(nil) + if err != nil { + t.Fatalf("CanonicalTags(nil): %v", err) + } + // Should encode as uint16(0) = 2 zero bytes. + if len(enc) != 2 || enc[0] != 0 || enc[1] != 0 { + t.Errorf("empty tags encoding: got %x, want 0000", enc) + } +} + +// -------------------------------------------------------------------------- +// Test: canonical_payload +// -------------------------------------------------------------------------- + +func TestCanonicalPayload(t *testing.T) { + vectors := loadVectors(t) + kp := axon.NewKeyPairFromSeed(mustDecodeHex(t, vectors.SeedHex)) + content := mustDecodeHex(t, vectors.ContentHex) + + payload, err := axon.CanonicalPayload(kp.PubKey, vectors.CreatedAt, vectors.Kind, content, vectors.TagsInput) + if err != nil { + t.Fatalf("CanonicalPayload: %v", err) + } + + // Validate structure: first 2 bytes must be 0x00 0x20 (= 32). + if payload[0] != 0 || payload[1] != 32 { + t.Errorf("payload[0:2] = %x, want 0020", payload[0:2]) + } + // Bytes [2:34] = pubkey. + if hexOf(payload[2:34]) != hexOf(kp.PubKey) { + t.Errorf("pubkey in payload mismatch") + } + // Total length: 2 + 32 + 8 + 2 + 4 + len(content) + 32 + want := 2 + 32 + 8 + 2 + 4 + len(content) + 32 + if len(payload) != want { + t.Errorf("payload length = %d, want %d", len(payload), want) + } + + expectPayload := mustDecodeHex(t, vectors.CanonicalPayload) + if hexOf(payload) != hexOf(expectPayload) { + t.Errorf("canonical_payload mismatch:\n got %s\n want %s", hexOf(payload), hexOf(expectPayload)) + } +} + +// -------------------------------------------------------------------------- +// Test: event id +// -------------------------------------------------------------------------- + +func TestEventID(t *testing.T) { + vectors := loadVectors(t) + kp := axon.NewKeyPairFromSeed(mustDecodeHex(t, vectors.SeedHex)) + content := mustDecodeHex(t, vectors.ContentHex) + + id, err := axon.EventID(kp.PubKey, vectors.CreatedAt, vectors.Kind, content, vectors.TagsInput) + if err != nil { + t.Fatalf("EventID: %v", err) + } + if len(id) != 32 { + t.Fatalf("EventID returned %d bytes, want 32", len(id)) + } + + expectID := mustDecodeHex(t, vectors.EventID) + if hexOf(id) != hexOf(expectID) { + t.Errorf("event id mismatch:\n got %s\n want %s", hexOf(id), hexOf(expectID)) + } +} + +// -------------------------------------------------------------------------- +// Test: sign and verify +// -------------------------------------------------------------------------- + +func TestSignVerify(t *testing.T) { + kp := axon.NewKeyPairFromSeed(knownSeed(0x01)) + + e := &axon.Event{ + CreatedAt: 1700000000, + Kind: axon.KindMessage, + Content: []byte("hello axon"), + Tags: []axon.Tag{{Name: "p", Values: []string{"alice"}}}, + } + if err := axon.Sign(e, kp); err != nil { + t.Fatalf("Sign: %v", err) + } + + if len(e.ID) != 32 { + t.Errorf("ID is %d bytes, want 32", len(e.ID)) + } + if len(e.Sig) != ed25519.SignatureSize { + t.Errorf("Sig is %d bytes, want 64", len(e.Sig)) + } + + if err := axon.Verify(e); err != nil { + t.Fatalf("Verify: %v", err) + } + + // Tampering with content must invalidate. + e2 := *e + e2.Content = []byte("tampered") + if err := axon.Verify(&e2); err == nil { + t.Fatal("Verify should fail after content tamper") + } + + // Tampering with signature must invalidate. + e3 := *e + sig := make([]byte, len(e.Sig)) + copy(sig, e.Sig) + sig[0] ^= 0xff + e3.Sig = sig + if err := axon.Verify(&e3); err == nil { + t.Fatal("Verify should fail after sig tamper") + } +} + +func TestVerifyWrongKey(t *testing.T) { + kp1 := axon.NewKeyPairFromSeed(knownSeed(0x01)) + kp2 := axon.NewKeyPairFromSeed(knownSeed(0x02)) + + e := &axon.Event{ + CreatedAt: time.Now().Unix(), + Kind: axon.KindMessage, + Content: []byte("test"), + } + if err := axon.Sign(e, kp1); err != nil { + t.Fatalf("Sign: %v", err) + } + + // Replace pubkey with kp2's pubkey; should fail. + e.PubKey = kp2.PubKey + if err := axon.Verify(e); err == nil { + t.Fatal("Verify should fail with wrong pubkey") + } +} + +// -------------------------------------------------------------------------- +// Test: challenge sign/verify +// -------------------------------------------------------------------------- + +func TestChallengeSignVerify(t *testing.T) { + kp := axon.NewKeyPairFromSeed(knownSeed(0x03)) + nonce := make([]byte, 32) + for i := range nonce { + nonce[i] = byte(i) + } + relayURL := "wss://relay.example.com" + + sig := axon.SignChallenge(kp, nonce, relayURL) + if !axon.VerifyChallenge(kp.PubKey, nonce, relayURL, sig) { + t.Fatal("VerifyChallenge failed for valid signature") + } + if axon.VerifyChallenge(kp.PubKey, nonce, "wss://other.relay.com", sig) { + t.Fatal("VerifyChallenge should fail for different relay URL") + } +} + +// -------------------------------------------------------------------------- +// Test: X25519 key conversion +// -------------------------------------------------------------------------- + +func TestX25519Conversion(t *testing.T) { + kp := axon.NewKeyPairFromSeed(knownSeed(0x01)) + + // Converting same Ed25519 keypair from both sides should yield same shared secret. + kp2 := axon.NewKeyPairFromSeed(knownSeed(0x02)) + + shared1, err := axon.DHSharedSecret(kp.PrivKey, kp2.PubKey) + if err != nil { + t.Fatalf("DHSharedSecret(kp->kp2): %v", err) + } + shared2, err := axon.DHSharedSecret(kp2.PrivKey, kp.PubKey) + if err != nil { + t.Fatalf("DHSharedSecret(kp2->kp): %v", err) + } + if shared1 != shared2 { + t.Errorf("DH not commutative:\n kp->kp2: %s\n kp2->kp: %s", + hexOf(shared1[:]), hexOf(shared2[:])) + } + if shared1 == [32]byte{} { + t.Fatal("shared secret is all zeros") + } +} + +// -------------------------------------------------------------------------- +// Test: DM encrypt/decrypt round-trip +// -------------------------------------------------------------------------- + +func TestDMEncryptDecrypt(t *testing.T) { + alice := axon.NewKeyPairFromSeed(knownSeed(0x0a)) + bob := axon.NewKeyPairFromSeed(knownSeed(0x0b)) + + plaintext := []byte("secret message from alice to bob") + + ct, err := axon.EncryptDM(alice.PrivKey, bob.PubKey, plaintext) + if err != nil { + t.Fatalf("EncryptDM: %v", err) + } + + pt, err := axon.DecryptDM(bob.PrivKey, alice.PubKey, ct) + if err != nil { + t.Fatalf("DecryptDM: %v", err) + } + if string(pt) != string(plaintext) { + t.Errorf("decrypt mismatch: got %q, want %q", pt, plaintext) + } + + // Tamper with ciphertext — authentication should fail. + ct2 := make([]byte, len(ct)) + copy(ct2, ct) + ct2[len(ct2)-1] ^= 0xff + if _, err := axon.DecryptDM(bob.PrivKey, alice.PubKey, ct2); err == nil { + t.Fatal("DecryptDM should fail on tampered ciphertext") + } + + // Decrypt with wrong key should fail. + carol := axon.NewKeyPairFromSeed(knownSeed(0x0c)) + if _, err := axon.DecryptDM(carol.PrivKey, alice.PubKey, ct); err == nil { + t.Fatal("DecryptDM should fail with wrong recipient key") + } +} + +// -------------------------------------------------------------------------- +// Test: MessagePack encode/decode +// -------------------------------------------------------------------------- + +func TestMsgPackRoundTrip(t *testing.T) { + kp := axon.NewKeyPairFromSeed(knownSeed(0x05)) + e := &axon.Event{ + CreatedAt: 1700000000, + Kind: axon.KindMessage, + Content: []byte("msgpack test"), + Tags: []axon.Tag{ + {Name: "p", Values: []string{"recipient"}}, + {Name: "e", Values: []string{"ref-id", "root"}}, + }, + } + if err := axon.Sign(e, kp); err != nil { + t.Fatalf("Sign: %v", err) + } + + data, err := axon.MarshalEvent(e) + if err != nil { + t.Fatalf("MarshalEvent: %v", err) + } + if len(data) == 0 { + t.Fatal("MarshalEvent returned empty bytes") + } + + e2, err := axon.UnmarshalEvent(data) + if err != nil { + t.Fatalf("UnmarshalEvent: %v", err) + } + + // Verify round-tripped event is still valid. + if err := axon.Verify(e2); err != nil { + t.Fatalf("Verify after msgpack round-trip: %v", err) + } + + if hexOf(e.ID) != hexOf(e2.ID) { + t.Errorf("ID mismatch after round-trip") + } + if hexOf(e.Sig) != hexOf(e2.Sig) { + t.Errorf("Sig mismatch after round-trip") + } + if string(e.Content) != string(e2.Content) { + t.Errorf("Content mismatch after round-trip: got %q, want %q", e2.Content, e.Content) + } +} + +// -------------------------------------------------------------------------- +// Test vector generation and validation +// -------------------------------------------------------------------------- + +// Vectors is the schema for testdata/vectors.json. +type Vectors struct { + // canonical_tags vector + TagsInput []axon.Tag `json:"tags_input"` + CanonicalTagsHex string `json:"canonical_tags_hex"` + CanonicalTagsHash string `json:"canonical_tags_hash"` + + // canonical_payload vector + SeedHex string `json:"seed_hex"` + PubKeyHex string `json:"pubkey_hex"` + CreatedAt int64 `json:"created_at"` + Kind uint16 `json:"kind"` + ContentHex string `json:"content_hex"` + CanonicalPayload string `json:"canonical_payload"` + + // event id + EventID string `json:"event_id"` + + // signature (sign with known seed; Ed25519 is deterministic so sig is stable) + SigHex string `json:"sig_hex"` +} + +func vectorsPath() string { + return filepath.Join("testdata", "vectors.json") +} + +func loadVectors(t *testing.T) Vectors { + t.Helper() + data, err := os.ReadFile(vectorsPath()) + if err != nil { + t.Fatalf("load vectors: %v (run TestGenerateVectors first)", err) + } + var v Vectors + if err := json.Unmarshal(data, &v); err != nil { + t.Fatalf("parse vectors: %v", err) + } + return v +} + +// TestGenerateVectors generates testdata/vectors.json from known inputs. +// Run once with -run TestGenerateVectors to produce the file, then the other +// tests validate against it. The file is committed to the repo. +func TestGenerateVectors(t *testing.T) { + seed := knownSeed(0x01) + kp := axon.NewKeyPairFromSeed(seed) + + tags := []axon.Tag{ + {Name: "p", Values: []string{"alice"}}, + {Name: "e", Values: []string{"root-id", "root"}}, + {Name: "e", Values: []string{"reply-id", "reply"}}, + } + content := []byte("hello axon") + createdAt := int64(1700000000) + kind := axon.KindMessage + + // canonical_tags + tagsEnc, err := axon.CanonicalTags(tags) + if err != nil { + t.Fatalf("CanonicalTags: %v", err) + } + tagsHash, err := axon.CanonicalTagsHash(tags) + if err != nil { + t.Fatalf("CanonicalTagsHash: %v", err) + } + + // canonical_payload + payload, err := axon.CanonicalPayload(kp.PubKey, createdAt, kind, content, tags) + if err != nil { + t.Fatalf("CanonicalPayload: %v", err) + } + + // event id + id, err := axon.EventID(kp.PubKey, createdAt, kind, content, tags) + if err != nil { + t.Fatalf("EventID: %v", err) + } + + // sign + e := &axon.Event{ + CreatedAt: createdAt, + Kind: kind, + Content: content, + Tags: tags, + } + if err := axon.Sign(e, kp); err != nil { + t.Fatalf("Sign: %v", err) + } + // Verify the event so we know the sig is valid. + if err := axon.Verify(e); err != nil { + t.Fatalf("Verify generated event: %v", err) + } + + v := Vectors{ + TagsInput: tags, + CanonicalTagsHex: hexOf(tagsEnc), + CanonicalTagsHash: hexOf(tagsHash[:]), + + SeedHex: hexOf(seed), + PubKeyHex: hexOf(kp.PubKey), + CreatedAt: createdAt, + Kind: kind, + ContentHex: hexOf(content), + CanonicalPayload: hexOf(payload), + + EventID: hexOf(id), + SigHex: hexOf(e.Sig), + } + + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + t.Fatalf("marshal vectors: %v", err) + } + + if err := os.MkdirAll("testdata", 0o755); err != nil { + t.Fatalf("mkdir testdata: %v", err) + } + if err := os.WriteFile(vectorsPath(), data, 0o644); err != nil { + t.Fatalf("write vectors: %v", err) + } + + t.Logf("vectors written to %s", vectorsPath()) + t.Logf("canonical_tags_hash: %s", hexOf(tagsHash[:])) + t.Logf("event_id: %s", hexOf(id)) +} + +// TestVectors validates all vectors in testdata/vectors.json. This is the +// primary cross-language compatibility check. +func TestVectors(t *testing.T) { + v := loadVectors(t) + + seed := mustDecodeHex(t, v.SeedHex) + kp := axon.NewKeyPairFromSeed(seed) + + if hexOf(kp.PubKey) != v.PubKeyHex { + t.Errorf("pubkey mismatch:\n got %s\n want %s", hexOf(kp.PubKey), v.PubKeyHex) + } + + // canonical_tags + tagsEnc, err := axon.CanonicalTags(v.TagsInput) + if err != nil { + t.Fatalf("CanonicalTags: %v", err) + } + if hexOf(tagsEnc) != v.CanonicalTagsHex { + t.Errorf("canonical_tags bytes mismatch:\n got %s\n want %s", + hexOf(tagsEnc), v.CanonicalTagsHex) + } + + tagsHash, err := axon.CanonicalTagsHash(v.TagsInput) + if err != nil { + t.Fatalf("CanonicalTagsHash: %v", err) + } + if hexOf(tagsHash[:]) != v.CanonicalTagsHash { + t.Errorf("canonical_tags_hash mismatch:\n got %s\n want %s", + hexOf(tagsHash[:]), v.CanonicalTagsHash) + } + + // canonical_payload + content := mustDecodeHex(t, v.ContentHex) + payload, err := axon.CanonicalPayload(kp.PubKey, v.CreatedAt, v.Kind, content, v.TagsInput) + if err != nil { + t.Fatalf("CanonicalPayload: %v", err) + } + if hexOf(payload) != v.CanonicalPayload { + t.Errorf("canonical_payload mismatch:\n got %s\n want %s", + hexOf(payload), v.CanonicalPayload) + } + + // event id + id, err := axon.EventID(kp.PubKey, v.CreatedAt, v.Kind, content, v.TagsInput) + if err != nil { + t.Fatalf("EventID: %v", err) + } + if hexOf(id) != v.EventID { + t.Errorf("event_id mismatch:\n got %s\n want %s", hexOf(id), v.EventID) + } + + // verify the stored signature + sig := mustDecodeHex(t, v.SigHex) + e := &axon.Event{ + ID: id, + PubKey: kp.PubKey, + CreatedAt: v.CreatedAt, + Kind: v.Kind, + Content: content, + Sig: sig, + Tags: v.TagsInput, + } + if err := axon.Verify(e); err != nil { + t.Errorf("Verify stored vector event: %v", err) + } +} diff --git a/crypto.go b/crypto.go new file mode 100644 index 0000000..402d06b --- /dev/null +++ b/crypto.go @@ -0,0 +1,144 @@ +package axon + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha512" + "errors" + "fmt" + "math/big" + + "golang.org/x/crypto/chacha20poly1305" + "golang.org/x/crypto/curve25519" +) + +// p is the prime for Curve25519: 2^255 - 19. +var curveP = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 255), big.NewInt(19)) + +// Ed25519PrivKeyToX25519 converts an Ed25519 private key to an X25519 scalar +// by clamping the SHA-512 hash of the 32-byte seed (per RFC 8032 §5.1.5). +func Ed25519PrivKeyToX25519(privKey ed25519.PrivateKey) [32]byte { + seed := privKey.Seed() + h := sha512.Sum512(seed) + // Clamp per RFC 8032. + h[0] &= 248 + h[31] &= 127 + h[31] |= 64 + var scalar [32]byte + copy(scalar[:], h[:32]) + return scalar +} + +// Ed25519PubKeyToX25519 converts an Ed25519 public key (compressed Edwards +// y-coordinate) to the corresponding Curve25519 Montgomery u-coordinate via +// the birational equivalence: u = (1 + y) / (1 - y) mod p. +func Ed25519PubKeyToX25519(pubKey ed25519.PublicKey) ([32]byte, error) { + if len(pubKey) != 32 { + return [32]byte{}, fmt.Errorf("axon: ed25519 pubkey must be 32 bytes, got %d", len(pubKey)) + } + + // Extract the y coordinate: clear the sign bit in byte 31. + yBytes := make([]byte, 32) + copy(yBytes, pubKey) + yBytes[31] &= 0x7f + + // big.Int expects big-endian; reverse the little-endian bytes. + reversedY := make([]byte, 32) + for i, b := range yBytes { + reversedY[31-i] = b + } + y := new(big.Int).SetBytes(reversedY) + + p := curveP + + // u = (1 + y) * modInverse(1 - y, p) mod p + one := big.NewInt(1) + + num := new(big.Int).Add(one, y) + num.Mod(num, p) + + den := new(big.Int).Sub(one, y) + den.Mod(den, p) + den.ModInverse(den, p) + + u := new(big.Int).Mul(num, den) + u.Mod(u, p) + + // Encode as little-endian 32 bytes. + uBE := u.Bytes() // big-endian, variable length + var out [32]byte + for i, b := range uBE { + out[len(uBE)-1-i] = b + } + return out, nil +} + +// DHSharedSecret computes the X25519 Diffie-Hellman shared secret between a +// local Ed25519 private key and a remote Ed25519 public key. Both keys are +// converted to their X25519 equivalents before the scalar multiplication. +func DHSharedSecret(localPriv ed25519.PrivateKey, remotePub ed25519.PublicKey) ([32]byte, error) { + scalar := Ed25519PrivKeyToX25519(localPriv) + point, err := Ed25519PubKeyToX25519(remotePub) + if err != nil { + return [32]byte{}, fmt.Errorf("axon: convert remote pubkey to x25519: %w", err) + } + shared, err := curve25519.X25519(scalar[:], point[:]) + if err != nil { + return [32]byte{}, fmt.Errorf("axon: x25519: %w", err) + } + var out [32]byte + copy(out[:], shared) + return out, nil +} + +// EncryptDM encrypts plaintext for a DM using ChaCha20-Poly1305. The +// encryption key is the X25519 shared secret derived from senderPriv and +// recipientPub. The returned blob is: nonce (12 bytes) || ciphertext. +func EncryptDM(senderPriv ed25519.PrivateKey, recipientPub ed25519.PublicKey, plaintext []byte) ([]byte, error) { + shared, err := DHSharedSecret(senderPriv, recipientPub) + if err != nil { + return nil, fmt.Errorf("axon: dh: %w", err) + } + + aead, err := chacha20poly1305.New(shared[:]) + if err != nil { + return nil, fmt.Errorf("axon: create aead: %w", err) + } + + nonce := make([]byte, aead.NonceSize()) // 12 bytes + if _, err := rand.Read(nonce); err != nil { + return nil, fmt.Errorf("axon: generate nonce: %w", err) + } + + ct := aead.Seal(nil, nonce, plaintext, nil) + return append(nonce, ct...), nil +} + +// DecryptDM decrypts a blob produced by EncryptDM. recipientPriv is the +// recipient's Ed25519 private key; senderPub is the sender's Ed25519 public +// key. The blob must be nonce (12 bytes) || ciphertext. +func DecryptDM(recipientPriv ed25519.PrivateKey, senderPub ed25519.PublicKey, blob []byte) ([]byte, error) { + shared, err := DHSharedSecret(recipientPriv, senderPub) + if err != nil { + return nil, fmt.Errorf("axon: dh: %w", err) + } + + aead, err := chacha20poly1305.New(shared[:]) + if err != nil { + return nil, fmt.Errorf("axon: create aead: %w", err) + } + + nonceSize := aead.NonceSize() + if len(blob) < nonceSize { + return nil, errors.New("axon: ciphertext too short to contain nonce") + } + + nonce := blob[:nonceSize] + ct := blob[nonceSize:] + + pt, err := aead.Open(nil, nonce, ct, nil) + if err != nil { + return nil, fmt.Errorf("axon: decrypt: authentication failed") + } + return pt, nil +} diff --git a/encoding.go b/encoding.go new file mode 100644 index 0000000..4ed9da6 --- /dev/null +++ b/encoding.go @@ -0,0 +1,54 @@ +package axon + +import ( + "fmt" + + "github.com/vmihailenco/msgpack/v5" +) + +// MarshalEvent encodes an Event to MessagePack. Fields are encoded in the +// canonical struct order using the msgpack struct tags. Binary fields (id, +// pubkey, sig, content) are encoded as msgpack bin type ([]byte). +func MarshalEvent(e *Event) ([]byte, error) { + b, err := msgpack.Marshal(e) + if err != nil { + return nil, fmt.Errorf("axon: marshal event: %w", err) + } + return b, nil +} + +// UnmarshalEvent decodes a MessagePack blob into an Event. +func UnmarshalEvent(data []byte) (*Event, error) { + var e Event + if err := msgpack.Unmarshal(data, &e); err != nil { + return nil, fmt.Errorf("axon: unmarshal event: %w", err) + } + return &e, nil +} + +// MarshalMessage encodes a wire message as a msgpack array: [type, payload]. +// messageType is a uint16; payload is any msgpack-serializable value. +func MarshalMessage(messageType uint16, payload interface{}) ([]byte, error) { + b, err := msgpack.Marshal([]interface{}{messageType, payload}) + if err != nil { + return nil, fmt.Errorf("axon: marshal message: %w", err) + } + return b, nil +} + +// UnmarshalMessageType reads only the first element of a [type, payload] +// msgpack array, returning the message type without decoding the payload. +func UnmarshalMessageType(data []byte) (uint16, error) { + var arr []msgpack.RawMessage + if err := msgpack.Unmarshal(data, &arr); err != nil { + return 0, fmt.Errorf("axon: unmarshal message: %w", err) + } + if len(arr) < 1 { + return 0, fmt.Errorf("axon: message array is empty") + } + var t uint16 + if err := msgpack.Unmarshal(arr[0], &t); err != nil { + return 0, fmt.Errorf("axon: unmarshal message type: %w", err) + } + return t, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4d76e2e --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module axon + +go 1.25.5 + +require ( + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/sys v0.41.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..29b748f --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +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 @@ +{ + "tags_input": [ + { + "name": "p", + "values": [ + "alice" + ] + }, + { + "name": "e", + "values": [ + "root-id", + "root" + ] + }, + { + "name": "e", + "values": [ + "reply-id", + "reply" + ] + } + ], + "canonical_tags_hex": "00030001650002000000087265706c792d6964000000057265706c79000165000200000007726f6f742d696400000004726f6f74000170000100000005616c696365", + "canonical_tags_hash": "2813b33f953e03e88160c651cbd4feb000c37a9b25690cad22fa26eb272bc9e6", + "seed_hex": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "pubkey_hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "created_at": 1700000000, + "kind": 1000, + "content_hex": "68656c6c6f2061786f6e", + "canonical_payload": "002079b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664000000006553f10003e80000000a68656c6c6f2061786f6e2813b33f953e03e88160c651cbd4feb000c37a9b25690cad22fa26eb272bc9e6", + "event_id": "16744a01674a332bab4e8814500b56b4a3c907c154dca01099ba6ed3aaba24df", + "sig_hex": "0706e27133980aedb544b3618b4afc63d234fad37e80e67018132dac7aad6c12a0923b1494390103ef301b2066a7e62870a623c91f0d21c3d1af1f9ac322ff08" +} \ No newline at end of file -- cgit v1.2.3