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_test.go | 550 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 550 insertions(+) create mode 100644 axon_test.go (limited to 'axon_test.go') 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) + } +} -- cgit v1.2.3