From 4ee6da5fd54a2e40b537ed4126e907342c06d54d Mon Sep 17 00:00:00 2001 From: bndw Date: Mon, 9 Mar 2026 17:26:53 -0700 Subject: feat: phase 3 CLI + phase 4 JS client library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI (cmd/axon): - Add explicit Unsubscribe on req exit (after EOSE and on Ctrl-C) - Add reconnect with exponential backoff (1s→30s) for req --stream JS library (js/axon.js): - Canonical tag encoding and payload construction matching Go byte-for-byte - Ed25519 sign/verify, keypair generation, challenge signing - AxonClient: WebSocket connect with auth handshake, publish, subscribe, unsubscribe, reconnect-ready callback API - encryptDM/decryptDM: X25519 ECDH (Ed25519 key conversion) + ChaCha20-Poly1305 - runVectors: validates all 6 Phase 1 test vectors against Go ground truth --- cmd/axon/main.go | 118 ++++++++----- js/axon.js | 519 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 595 insertions(+), 42 deletions(-) create mode 100644 js/axon.js diff --git a/cmd/axon/main.go b/cmd/axon/main.go index f7aaf63..9076687 100644 --- a/cmd/axon/main.go +++ b/cmd/axon/main.go @@ -56,10 +56,6 @@ type authPayload struct { Sig []byte `msgpack:"sig"` } -type okPayload struct { - Message string `msgpack:"message"` -} - type errorPayload struct { Code uint16 `msgpack:"code"` Message string `msgpack:"message"` @@ -79,7 +75,7 @@ type eventPayload struct { Event axon.Event `msgpack:"event"` } -type eosePayload struct { +type unsubscribePayload struct { SubID string `msgpack:"sub_id"` } @@ -344,6 +340,55 @@ func cmdPub(args []string) { // ── req ────────────────────────────────────────────────────────────────────── +// streamOnce dials, subscribes, and reads events until EOSE (non-stream mode), +// context cancellation, or a connection error. Sends Unsubscribe before returning. +// Returns nil on clean EOSE-exit or context cancel; returns the error otherwise. +func streamOnce(ctx context.Context, relayURL string, kp axon.KeyPair, subID string, filter axon.Filter, stream bool) error { + conn, err := dial(relayURL, kp) + if err != nil { + return err + } + defer conn.CloseConn() + + if err := send(conn, msgTypeSubscribe, subscribePayload{SubID: subID, Filter: filter}); err != nil { + return fmt.Errorf("subscribe: %w", err) + } + + for { + t, raw, err := recv(conn, ctx) + if err != nil { + if ctx.Err() != nil { + _ = send(conn, msgTypeUnsubscribe, unsubscribePayload{SubID: subID}) + return nil + } + return err + } + switch t { + case msgTypeEvent: + var ep eventPayload + if err := msgpack.Unmarshal(raw, &ep); err != nil { + log.Printf("decode event: %v", err) + continue + } + printEvent(&ep.Event) + + case msgTypeEose: + if !stream { + _ = send(conn, msgTypeUnsubscribe, unsubscribePayload{SubID: subID}) + return nil + } + + case msgTypeError: + var ep errorPayload + msgpack.Unmarshal(raw, &ep) + return fmt.Errorf("error %d: %s", ep.Code, ep.Message) + + default: + log.Printf("unexpected message type %d", t) + } + } +} + func cmdReq(args []string) { fs := flag.NewFlagSet("req", flag.ExitOnError) fs.Usage = func() { @@ -387,21 +432,11 @@ func cmdReq(args []string) { filter.Tags = append(filter.Tags, axon.TagFilter{Name: t.Name, Values: t.Values}) } - conn, err := dial(relayURL, kp) - if err != nil { - log.Fatalf("connect: %v", err) - } - defer conn.CloseConn() - subID := "req-" + strconv.FormatInt(time.Now().UnixNano(), 36) - if err := send(conn, msgTypeSubscribe, subscribePayload{SubID: subID, Filter: filter}); err != nil { - log.Fatalf("subscribe: %v", err) - } ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // Cancel on Ctrl-C when streaming. if *stream { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) @@ -411,36 +446,35 @@ func cmdReq(args []string) { }() } + if !*stream { + if err := streamOnce(ctx, relayURL, kp, subID, filter, false); err != nil { + log.Fatalf("req: %v", err) + } + return + } + + // Stream mode: reconnect with exponential backoff on disconnect. + const maxBackoff = 30 * time.Second + backoff := time.Second for { - t, raw, err := recv(conn, ctx) + if ctx.Err() != nil { + return + } + err := streamOnce(ctx, relayURL, kp, subID, filter, true) + if ctx.Err() != nil { + return + } if err != nil { - if ctx.Err() != nil { - return // clean cancellation - } - log.Fatalf("recv: %v", err) + log.Printf("disconnected: %v; reconnecting in %s", err, backoff) } - switch t { - case msgTypeEvent: - var ep eventPayload - if err := msgpack.Unmarshal(raw, &ep); err != nil { - log.Printf("decode event: %v", err) - continue - } - printEvent(&ep.Event) - - case msgTypeEose: - if !*stream { - return - } - // Keep looping for live events. - - case msgTypeError: - var ep errorPayload - msgpack.Unmarshal(raw, &ep) - log.Fatalf("error %d: %s", ep.Code, ep.Message) - - default: - log.Printf("unexpected message type %d", t) + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + backoff *= 2 + if backoff > maxBackoff { + backoff = maxBackoff } } } diff --git a/js/axon.js b/js/axon.js new file mode 100644 index 0000000..9f50340 --- /dev/null +++ b/js/axon.js @@ -0,0 +1,519 @@ +/** + * axon.js — framework-agnostic Axon protocol client + * + * Wire message types: + * Client → Relay: AUTH=1, SUBSCRIBE=2, UNSUBSCRIBE=3, PUBLISH=4 + * Relay → Client: CHALLENGE=10, EVENT=11, EOSE=12, OK=13, ERROR=14 + */ + +import { ed25519, x25519, edwardsToMontgomeryPriv, edwardsToMontgomeryPub } from '@noble/curves/ed25519'; +import { sha256 } from '@noble/hashes/sha256'; +import { chacha20poly1305 } from '@noble/ciphers/chacha'; +import { encode, decode } from '@msgpack/msgpack'; + +// --------------------------------------------------------------------------- +// Event kind constants +// --------------------------------------------------------------------------- + +export const KIND = { + PROFILE: 0, + MESSAGE: 1000, + DM: 2000, + PROGRESS: 3000, + JOB_REQ: 5000, + JOB_RESULT: 6000, + JOB_FEEDBACK: 7000, +}; + +// --------------------------------------------------------------------------- +// Hex utilities +// --------------------------------------------------------------------------- + +export function toHex(bytes) { + return Array.from(bytes) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); +} + +export function fromHex(hex) { + if (hex.length % 2 !== 0) throw new Error('axon: odd hex length'); + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return out; +} + +// --------------------------------------------------------------------------- +// Key generation +// --------------------------------------------------------------------------- + +export function generateSeed() { + const seed = new Uint8Array(32); + crypto.getRandomValues(seed); + return seed; +} + +/** + * keypairFromSeed(seed) → { seed: Uint8Array(32), pubkey: Uint8Array(32) } + */ +export function keypairFromSeed(seed) { + const pubkey = ed25519.getPublicKey(seed); + return { seed: seed instanceof Uint8Array ? seed : new Uint8Array(seed), pubkey }; +} + +// --------------------------------------------------------------------------- +// Canonical encoding (internal helpers) +// --------------------------------------------------------------------------- + +/** + * canonicalTagsBytes encodes tags in the Axon canonical format (big-endian). + * + * Tags are sorted by name, then by first value for ties (ascending). + * + * Format: + * uint16(num_tags) + * for each tag: + * uint16(len(name)) || utf8(name) + * uint16(num_values) + * for each value: + * uint32(len(value)) || utf8(value) + */ +function canonicalTagsBytes(tags) { + const enc = new TextEncoder(); + + // Sort a copy: by name ascending, then first value ascending for ties. + const sorted = tags.slice().sort((a, b) => { + if (a.name !== b.name) return a.name < b.name ? -1 : 1; + const va = (a.values && a.values.length > 0) ? a.values[0] : ''; + const vb = (b.values && b.values.length > 0) ? b.values[0] : ''; + return va < vb ? -1 : va > vb ? 1 : 0; + }); + + // First pass: compute total byte length. + let size = 2; // uint16 num_tags + for (const tag of sorted) { + const nameBytes = enc.encode(tag.name); + size += 2 + nameBytes.length; // uint16(len) + name + size += 2; // uint16(num_values) + for (const v of (tag.values || [])) { + const vb = enc.encode(v); + size += 4 + vb.length; // uint32(len) + value + } + } + + const buf = new Uint8Array(size); + const dv = new DataView(buf.buffer); + let off = 0; + + dv.setUint16(off, sorted.length, false); off += 2; + + for (const tag of sorted) { + const nameBytes = enc.encode(tag.name); + dv.setUint16(off, nameBytes.length, false); off += 2; + buf.set(nameBytes, off); off += nameBytes.length; + + const values = tag.values || []; + dv.setUint16(off, values.length, false); off += 2; + + for (const v of values) { + const vb = enc.encode(v); + dv.setUint32(off, vb.length, false); off += 4; + buf.set(vb, off); off += vb.length; + } + } + + return buf; +} + +/** + * canonicalPayload builds the deterministic byte blob that is SHA256-hashed + * to produce the event ID. + * + * Layout (big-endian): + * [0:2] uint16 = 32 pubkey length + * [2:34] bytes pubkey (32 bytes) + * [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_bytes), 32 bytes + */ +function canonicalPayload(pubkey, createdAt, kind, content, tags) { + const tagsHash = sha256(canonicalTagsBytes(tags)); + const n = content.length; + // Total: 2 + 32 + 8 + 2 + 4 + n + 32 = 80 + n + const buf = new Uint8Array(80 + n); + const dv = new DataView(buf.buffer); + + dv.setUint16(0, 32, false); // pubkey length + buf.set(pubkey, 2); // pubkey [2:34] + dv.setBigUint64(34, BigInt(createdAt), false); // created_at [34:42] + dv.setUint16(42, kind, false); // kind [42:44] + dv.setUint32(44, n, false); // content length [44:48] + buf.set(content, 48); // content [48:48+n] + buf.set(tagsHash, 48 + n); // tags hash [48+n:80+n] + + return buf; +} + +// --------------------------------------------------------------------------- +// Event signing +// --------------------------------------------------------------------------- + +/** + * signEvent(kp, kind, content, tags=[]) → Event object + * + * content may be a string (will be UTF-8 encoded) or Uint8Array. + * tags is an array of { name: string, values: string[] }. + * + * Returns: + * { id, pubkey, created_at, kind, content, sig, tags } + * where binary fields are Uint8Array. + */ +export function signEvent(kp, kind, content, tags = []) { + const enc = new TextEncoder(); + const contentBytes = typeof content === 'string' ? enc.encode(content) : content; + const createdAt = Math.floor(Date.now() / 1000); + + const payload = canonicalPayload(kp.pubkey, createdAt, kind, contentBytes, tags); + const eventID = sha256(payload); + const sig = ed25519.sign(eventID, kp.seed); + + return { + id: eventID, + pubkey: kp.pubkey, + created_at: createdAt, + kind, + content: contentBytes, + sig, + tags, + }; +} + +// --------------------------------------------------------------------------- +// Challenge signing +// --------------------------------------------------------------------------- + +/** + * signChallenge(kp, nonce, relayURL) → Uint8Array(64) + * + * sig = ed25519.sign(SHA256(nonce || utf8(relay_url)), seed) + */ +export function signChallenge(kp, nonce, relayURL) { + const enc = new TextEncoder(); + const urlBytes = enc.encode(relayURL); + const msg = new Uint8Array(nonce.length + urlBytes.length); + msg.set(nonce, 0); + msg.set(urlBytes, nonce.length); + const digest = sha256(msg); + return ed25519.sign(digest, kp.seed); +} + +// --------------------------------------------------------------------------- +// Test vector runner +// --------------------------------------------------------------------------- + +/** + * runVectors(vectors) → { pass: bool, results: [{label, pass, got, expected}] } + * + * Checks all 6 fields from the vectors JSON: + * canonical_tags_hex, canonical_tags_hash, pubkey_hex, + * canonical_payload, event_id, sig_hex + */ +export function runVectors(vectors) { + const results = []; + + function check(label, got, expected) { + const pass = got === expected; + results.push({ label, pass, got, expected }); + } + + // Reconstruct inputs from vectors. + const seed = fromHex(vectors.seed_hex); + const kp = keypairFromSeed(seed); + const content = fromHex(vectors.content_hex); + const tags = vectors.tags_input; // [{ name, values }] + + // 1. canonical_tags_hex + const tagsBytes = canonicalTagsBytes(tags); + check('canonical_tags_hex', toHex(tagsBytes), vectors.canonical_tags_hex); + + // 2. canonical_tags_hash + const tagsHash = sha256(tagsBytes); + check('canonical_tags_hash', toHex(tagsHash), vectors.canonical_tags_hash); + + // 3. pubkey_hex + check('pubkey_hex', toHex(kp.pubkey), vectors.pubkey_hex); + + // 4. canonical_payload + const payload = canonicalPayload( + kp.pubkey, + vectors.created_at, + vectors.kind, + content, + tags, + ); + check('canonical_payload', toHex(payload), vectors.canonical_payload); + + // 5. event_id + const eventID = sha256(payload); + check('event_id', toHex(eventID), vectors.event_id); + + // 6. sig_hex + const sig = ed25519.sign(eventID, seed); + check('sig_hex', toHex(sig), vectors.sig_hex); + + const pass = results.every(r => r.pass); + return { pass, results }; +} + +// --------------------------------------------------------------------------- +// DM encryption (X25519 ECDH + ChaCha20-Poly1305) +// --------------------------------------------------------------------------- + +/** + * dhSharedSecret(senderSeed, recipientEd25519Pub) → Uint8Array(32) + * + * Converts both keys to X25519 form, then runs the DH scalar multiplication. + * Matches Go's DHSharedSecret exactly: + * - private: SHA-512(seed)[0:32] clamped per RFC 8032 + * - public: Edwards y → Montgomery u via (1+y)/(1-y) mod p + */ +function dhSharedSecret(localSeed, remoteEd25519Pub) { + const xPriv = edwardsToMontgomeryPriv(localSeed); + const xPub = edwardsToMontgomeryPub(remoteEd25519Pub); + return x25519.getSharedSecret(xPriv, xPub); +} + +/** + * encryptDM(senderSeed, recipientPubkey, plaintext) → Uint8Array + * + * Returns nonce(12) || ciphertext+tag, matching Go's EncryptDM. + * plaintext may be string or Uint8Array. + */ +export function encryptDM(senderSeed, recipientPubkey, plaintext) { + const enc = new TextEncoder(); + const pt = typeof plaintext === 'string' ? enc.encode(plaintext) : plaintext; + const shared = dhSharedSecret(senderSeed, recipientPubkey); + const nonce = crypto.getRandomValues(new Uint8Array(12)); + const ct = chacha20poly1305(shared, nonce).encrypt(pt); + const out = new Uint8Array(12 + ct.length); + out.set(nonce); + out.set(ct, 12); + return out; +} + +/** + * decryptDM(recipientSeed, senderPubkey, blob) → Uint8Array + * + * blob is nonce(12) || ciphertext+tag. Throws on authentication failure. + */ +export function decryptDM(recipientSeed, senderPubkey, blob) { + if (blob.length < 12) throw new Error('axon: dm blob too short'); + const shared = dhSharedSecret(recipientSeed, senderPubkey); + const nonce = blob.slice(0, 12); + const ct = blob.slice(12); + return chacha20poly1305(shared, nonce).decrypt(ct); +} + +// --------------------------------------------------------------------------- +// AxonClient +// --------------------------------------------------------------------------- + +const MSG = { + // Client → Relay + AUTH: 1, + SUBSCRIBE: 2, + UNSUBSCRIBE: 3, + PUBLISH: 4, + // Relay → Client + CHALLENGE: 10, + EVENT: 11, + EOSE: 12, + OK: 13, + ERROR: 14, +}; + +export class AxonClient { + #ws = null; + #kp = null; + #relayURL = ''; + #authed = false; + #listeners = {}; + + constructor(relayURL, kp) { + this.#relayURL = relayURL; + this.#kp = kp; + } + + // ------------------------------------------------------------------------- + // Event emitter + // ------------------------------------------------------------------------- + + on(event, fn) { + if (!this.#listeners[event]) this.#listeners[event] = []; + this.#listeners[event].push(fn); + return this; + } + + #emit(name, ...args) { + for (const fn of (this.#listeners[name] || [])) { + try { fn(...args); } catch { /* ignore listener errors */ } + } + } + + // ------------------------------------------------------------------------- + // Wire encoding + // ------------------------------------------------------------------------- + + #send(type, payload) { + if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) return; + this.#ws.send(encode([type, payload])); + } + + // ------------------------------------------------------------------------- + // Connection lifecycle + // ------------------------------------------------------------------------- + + /** + * connect() → Promise + * + * Opens the WebSocket, performs the CHALLENGE → AUTH → OK handshake, + * then resolves. Rejects on error or if auth fails. + */ + connect() { + return new Promise((resolve, reject) => { + let settled = false; + const settle = (ok, value) => { + if (settled) return; + settled = true; + if (ok) resolve(value); + else reject(value); + }; + + const ws = new WebSocket(this.#relayURL); + ws.binaryType = 'arraybuffer'; + this.#ws = ws; + + ws.onopen = () => { + // Wait for CHALLENGE from relay; do not resolve yet. + }; + + ws.onclose = (e) => { + this.#authed = false; + settle(false, new Error(`axon: ws closed (${e.code})`)); + this.#emit('disconnected', e.code, e.reason); + }; + + ws.onerror = (e) => { + settle(false, new Error('axon: ws error')); + this.#emit('error', 0, 'websocket error'); + }; + + ws.onmessage = (e) => { + let msg; + try { + msg = decode(new Uint8Array(e.data)); + } catch (err) { + this.#emit('error', 0, 'decode error: ' + err.message); + return; + } + + if (!Array.isArray(msg) || msg.length < 2) { + this.#emit('error', 0, 'malformed message'); + return; + } + + const [type, payload] = msg; + + if (!this.#authed) { + // Pre-auth: only handle CHALLENGE and OK. + if (type === MSG.CHALLENGE) { + const nonce = payload.nonce; + const sig = signChallenge(this.#kp, nonce, this.#relayURL); + this.#send(MSG.AUTH, { pubkey: this.#kp.pubkey, sig }); + } else if (type === MSG.OK) { + this.#authed = true; + settle(true, undefined); + this.#emit('connected'); + } else if (type === MSG.ERROR) { + settle(false, new Error(`axon: auth error ${payload.code}: ${payload.message}`)); + this.#emit('error', payload.code, payload.message); + } + return; + } + + // Post-auth: handle all message types. + switch (type) { + case MSG.EVENT: { + const ev = payload.event; + this.#emit('event', payload.sub_id, ev); + break; + } + case MSG.EOSE: + this.#emit('eose', payload.sub_id); + break; + case MSG.OK: + this.#emit('ok', payload.message); + break; + case MSG.ERROR: + this.#emit('error', payload.code, payload.message); + break; + default: + // Unknown message type — ignore. + break; + } + }; + }); + } + + disconnect() { + if (this.#ws) { + this.#ws.close(1000); + this.#ws = null; + } + this.#authed = false; + } + + // ------------------------------------------------------------------------- + // Protocol operations + // ------------------------------------------------------------------------- + + /** + * publish(kind, content, tags=[]) + * + * Signs and publishes an event. content may be string or Uint8Array. + */ + publish(kind, content, tags = []) { + const event = signEvent(this.#kp, kind, content, tags); + this.#send(MSG.PUBLISH, { event }); + } + + /** + * subscribe(subID, filter={}) + * + * Sends a SUBSCRIBE message. All filter fields are sent explicitly. + */ + subscribe(subID, filter = {}) { + this.#send(MSG.SUBSCRIBE, { + sub_id: subID, + filter: { + ids: filter.ids ?? [], + authors: filter.authors ?? [], + kinds: filter.kinds ?? [], + since: filter.since ?? 0, + until: filter.until ?? 0, + limit: filter.limit ?? 0, + tags: filter.tags ?? [], + }, + }); + } + + /** + * unsubscribe(subID) + */ + unsubscribe(subID) { + this.#send(MSG.UNSUBSCRIBE, { sub_id: subID }); + } +} -- cgit v1.2.3