aboutsummaryrefslogtreecommitdiffstats
path: root/js/axon.js
diff options
context:
space:
mode:
authorbndw <ben@bdw.to>2026-03-09 17:26:53 -0700
committerbndw <ben@bdw.to>2026-03-09 17:26:53 -0700
commit4ee6da5fd54a2e40b537ed4126e907342c06d54d (patch)
treed59ebf4bd52b165731faa96fd885c86791f9f679 /js/axon.js
parentede67d9722509f1553fcb0f84edd55aa42f1c503 (diff)
feat: phase 3 CLI + phase 4 JS client library
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
Diffstat (limited to 'js/axon.js')
-rw-r--r--js/axon.js519
1 files changed, 519 insertions, 0 deletions
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 @@
1/**
2 * axon.js — framework-agnostic Axon protocol client
3 *
4 * Wire message types:
5 * Client → Relay: AUTH=1, SUBSCRIBE=2, UNSUBSCRIBE=3, PUBLISH=4
6 * Relay → Client: CHALLENGE=10, EVENT=11, EOSE=12, OK=13, ERROR=14
7 */
8
9import { ed25519, x25519, edwardsToMontgomeryPriv, edwardsToMontgomeryPub } from '@noble/curves/ed25519';
10import { sha256 } from '@noble/hashes/sha256';
11import { chacha20poly1305 } from '@noble/ciphers/chacha';
12import { encode, decode } from '@msgpack/msgpack';
13
14// ---------------------------------------------------------------------------
15// Event kind constants
16// ---------------------------------------------------------------------------
17
18export const KIND = {
19 PROFILE: 0,
20 MESSAGE: 1000,
21 DM: 2000,
22 PROGRESS: 3000,
23 JOB_REQ: 5000,
24 JOB_RESULT: 6000,
25 JOB_FEEDBACK: 7000,
26};
27
28// ---------------------------------------------------------------------------
29// Hex utilities
30// ---------------------------------------------------------------------------
31
32export function toHex(bytes) {
33 return Array.from(bytes)
34 .map(b => b.toString(16).padStart(2, '0'))
35 .join('');
36}
37
38export function fromHex(hex) {
39 if (hex.length % 2 !== 0) throw new Error('axon: odd hex length');
40 const out = new Uint8Array(hex.length / 2);
41 for (let i = 0; i < out.length; i++) {
42 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
43 }
44 return out;
45}
46
47// ---------------------------------------------------------------------------
48// Key generation
49// ---------------------------------------------------------------------------
50
51export function generateSeed() {
52 const seed = new Uint8Array(32);
53 crypto.getRandomValues(seed);
54 return seed;
55}
56
57/**
58 * keypairFromSeed(seed) → { seed: Uint8Array(32), pubkey: Uint8Array(32) }
59 */
60export function keypairFromSeed(seed) {
61 const pubkey = ed25519.getPublicKey(seed);
62 return { seed: seed instanceof Uint8Array ? seed : new Uint8Array(seed), pubkey };
63}
64
65// ---------------------------------------------------------------------------
66// Canonical encoding (internal helpers)
67// ---------------------------------------------------------------------------
68
69/**
70 * canonicalTagsBytes encodes tags in the Axon canonical format (big-endian).
71 *
72 * Tags are sorted by name, then by first value for ties (ascending).
73 *
74 * Format:
75 * uint16(num_tags)
76 * for each tag:
77 * uint16(len(name)) || utf8(name)
78 * uint16(num_values)
79 * for each value:
80 * uint32(len(value)) || utf8(value)
81 */
82function canonicalTagsBytes(tags) {
83 const enc = new TextEncoder();
84
85 // Sort a copy: by name ascending, then first value ascending for ties.
86 const sorted = tags.slice().sort((a, b) => {
87 if (a.name !== b.name) return a.name < b.name ? -1 : 1;
88 const va = (a.values && a.values.length > 0) ? a.values[0] : '';
89 const vb = (b.values && b.values.length > 0) ? b.values[0] : '';
90 return va < vb ? -1 : va > vb ? 1 : 0;
91 });
92
93 // First pass: compute total byte length.
94 let size = 2; // uint16 num_tags
95 for (const tag of sorted) {
96 const nameBytes = enc.encode(tag.name);
97 size += 2 + nameBytes.length; // uint16(len) + name
98 size += 2; // uint16(num_values)
99 for (const v of (tag.values || [])) {
100 const vb = enc.encode(v);
101 size += 4 + vb.length; // uint32(len) + value
102 }
103 }
104
105 const buf = new Uint8Array(size);
106 const dv = new DataView(buf.buffer);
107 let off = 0;
108
109 dv.setUint16(off, sorted.length, false); off += 2;
110
111 for (const tag of sorted) {
112 const nameBytes = enc.encode(tag.name);
113 dv.setUint16(off, nameBytes.length, false); off += 2;
114 buf.set(nameBytes, off); off += nameBytes.length;
115
116 const values = tag.values || [];
117 dv.setUint16(off, values.length, false); off += 2;
118
119 for (const v of values) {
120 const vb = enc.encode(v);
121 dv.setUint32(off, vb.length, false); off += 4;
122 buf.set(vb, off); off += vb.length;
123 }
124 }
125
126 return buf;
127}
128
129/**
130 * canonicalPayload builds the deterministic byte blob that is SHA256-hashed
131 * to produce the event ID.
132 *
133 * Layout (big-endian):
134 * [0:2] uint16 = 32 pubkey length
135 * [2:34] bytes pubkey (32 bytes)
136 * [34:42] uint64 created_at
137 * [42:44] uint16 kind
138 * [44:48] uint32 content length
139 * [48:48+n] bytes content
140 * [48+n:80+n] bytes SHA256(canonical_tags_bytes), 32 bytes
141 */
142function canonicalPayload(pubkey, createdAt, kind, content, tags) {
143 const tagsHash = sha256(canonicalTagsBytes(tags));
144 const n = content.length;
145 // Total: 2 + 32 + 8 + 2 + 4 + n + 32 = 80 + n
146 const buf = new Uint8Array(80 + n);
147 const dv = new DataView(buf.buffer);
148
149 dv.setUint16(0, 32, false); // pubkey length
150 buf.set(pubkey, 2); // pubkey [2:34]
151 dv.setBigUint64(34, BigInt(createdAt), false); // created_at [34:42]
152 dv.setUint16(42, kind, false); // kind [42:44]
153 dv.setUint32(44, n, false); // content length [44:48]
154 buf.set(content, 48); // content [48:48+n]
155 buf.set(tagsHash, 48 + n); // tags hash [48+n:80+n]
156
157 return buf;
158}
159
160// ---------------------------------------------------------------------------
161// Event signing
162// ---------------------------------------------------------------------------
163
164/**
165 * signEvent(kp, kind, content, tags=[]) → Event object
166 *
167 * content may be a string (will be UTF-8 encoded) or Uint8Array.
168 * tags is an array of { name: string, values: string[] }.
169 *
170 * Returns:
171 * { id, pubkey, created_at, kind, content, sig, tags }
172 * where binary fields are Uint8Array.
173 */
174export function signEvent(kp, kind, content, tags = []) {
175 const enc = new TextEncoder();
176 const contentBytes = typeof content === 'string' ? enc.encode(content) : content;
177 const createdAt = Math.floor(Date.now() / 1000);
178
179 const payload = canonicalPayload(kp.pubkey, createdAt, kind, contentBytes, tags);
180 const eventID = sha256(payload);
181 const sig = ed25519.sign(eventID, kp.seed);
182
183 return {
184 id: eventID,
185 pubkey: kp.pubkey,
186 created_at: createdAt,
187 kind,
188 content: contentBytes,
189 sig,
190 tags,
191 };
192}
193
194// ---------------------------------------------------------------------------
195// Challenge signing
196// ---------------------------------------------------------------------------
197
198/**
199 * signChallenge(kp, nonce, relayURL) → Uint8Array(64)
200 *
201 * sig = ed25519.sign(SHA256(nonce || utf8(relay_url)), seed)
202 */
203export function signChallenge(kp, nonce, relayURL) {
204 const enc = new TextEncoder();
205 const urlBytes = enc.encode(relayURL);
206 const msg = new Uint8Array(nonce.length + urlBytes.length);
207 msg.set(nonce, 0);
208 msg.set(urlBytes, nonce.length);
209 const digest = sha256(msg);
210 return ed25519.sign(digest, kp.seed);
211}
212
213// ---------------------------------------------------------------------------
214// Test vector runner
215// ---------------------------------------------------------------------------
216
217/**
218 * runVectors(vectors) → { pass: bool, results: [{label, pass, got, expected}] }
219 *
220 * Checks all 6 fields from the vectors JSON:
221 * canonical_tags_hex, canonical_tags_hash, pubkey_hex,
222 * canonical_payload, event_id, sig_hex
223 */
224export function runVectors(vectors) {
225 const results = [];
226
227 function check(label, got, expected) {
228 const pass = got === expected;
229 results.push({ label, pass, got, expected });
230 }
231
232 // Reconstruct inputs from vectors.
233 const seed = fromHex(vectors.seed_hex);
234 const kp = keypairFromSeed(seed);
235 const content = fromHex(vectors.content_hex);
236 const tags = vectors.tags_input; // [{ name, values }]
237
238 // 1. canonical_tags_hex
239 const tagsBytes = canonicalTagsBytes(tags);
240 check('canonical_tags_hex', toHex(tagsBytes), vectors.canonical_tags_hex);
241
242 // 2. canonical_tags_hash
243 const tagsHash = sha256(tagsBytes);
244 check('canonical_tags_hash', toHex(tagsHash), vectors.canonical_tags_hash);
245
246 // 3. pubkey_hex
247 check('pubkey_hex', toHex(kp.pubkey), vectors.pubkey_hex);
248
249 // 4. canonical_payload
250 const payload = canonicalPayload(
251 kp.pubkey,
252 vectors.created_at,
253 vectors.kind,
254 content,
255 tags,
256 );
257 check('canonical_payload', toHex(payload), vectors.canonical_payload);
258
259 // 5. event_id
260 const eventID = sha256(payload);
261 check('event_id', toHex(eventID), vectors.event_id);
262
263 // 6. sig_hex
264 const sig = ed25519.sign(eventID, seed);
265 check('sig_hex', toHex(sig), vectors.sig_hex);
266
267 const pass = results.every(r => r.pass);
268 return { pass, results };
269}
270
271// ---------------------------------------------------------------------------
272// DM encryption (X25519 ECDH + ChaCha20-Poly1305)
273// ---------------------------------------------------------------------------
274
275/**
276 * dhSharedSecret(senderSeed, recipientEd25519Pub) → Uint8Array(32)
277 *
278 * Converts both keys to X25519 form, then runs the DH scalar multiplication.
279 * Matches Go's DHSharedSecret exactly:
280 * - private: SHA-512(seed)[0:32] clamped per RFC 8032
281 * - public: Edwards y → Montgomery u via (1+y)/(1-y) mod p
282 */
283function dhSharedSecret(localSeed, remoteEd25519Pub) {
284 const xPriv = edwardsToMontgomeryPriv(localSeed);
285 const xPub = edwardsToMontgomeryPub(remoteEd25519Pub);
286 return x25519.getSharedSecret(xPriv, xPub);
287}
288
289/**
290 * encryptDM(senderSeed, recipientPubkey, plaintext) → Uint8Array
291 *
292 * Returns nonce(12) || ciphertext+tag, matching Go's EncryptDM.
293 * plaintext may be string or Uint8Array.
294 */
295export function encryptDM(senderSeed, recipientPubkey, plaintext) {
296 const enc = new TextEncoder();
297 const pt = typeof plaintext === 'string' ? enc.encode(plaintext) : plaintext;
298 const shared = dhSharedSecret(senderSeed, recipientPubkey);
299 const nonce = crypto.getRandomValues(new Uint8Array(12));
300 const ct = chacha20poly1305(shared, nonce).encrypt(pt);
301 const out = new Uint8Array(12 + ct.length);
302 out.set(nonce);
303 out.set(ct, 12);
304 return out;
305}
306
307/**
308 * decryptDM(recipientSeed, senderPubkey, blob) → Uint8Array
309 *
310 * blob is nonce(12) || ciphertext+tag. Throws on authentication failure.
311 */
312export function decryptDM(recipientSeed, senderPubkey, blob) {
313 if (blob.length < 12) throw new Error('axon: dm blob too short');
314 const shared = dhSharedSecret(recipientSeed, senderPubkey);
315 const nonce = blob.slice(0, 12);
316 const ct = blob.slice(12);
317 return chacha20poly1305(shared, nonce).decrypt(ct);
318}
319
320// ---------------------------------------------------------------------------
321// AxonClient
322// ---------------------------------------------------------------------------
323
324const MSG = {
325 // Client → Relay
326 AUTH: 1,
327 SUBSCRIBE: 2,
328 UNSUBSCRIBE: 3,
329 PUBLISH: 4,
330 // Relay → Client
331 CHALLENGE: 10,
332 EVENT: 11,
333 EOSE: 12,
334 OK: 13,
335 ERROR: 14,
336};
337
338export class AxonClient {
339 #ws = null;
340 #kp = null;
341 #relayURL = '';
342 #authed = false;
343 #listeners = {};
344
345 constructor(relayURL, kp) {
346 this.#relayURL = relayURL;
347 this.#kp = kp;
348 }
349
350 // -------------------------------------------------------------------------
351 // Event emitter
352 // -------------------------------------------------------------------------
353
354 on(event, fn) {
355 if (!this.#listeners[event]) this.#listeners[event] = [];
356 this.#listeners[event].push(fn);
357 return this;
358 }
359
360 #emit(name, ...args) {
361 for (const fn of (this.#listeners[name] || [])) {
362 try { fn(...args); } catch { /* ignore listener errors */ }
363 }
364 }
365
366 // -------------------------------------------------------------------------
367 // Wire encoding
368 // -------------------------------------------------------------------------
369
370 #send(type, payload) {
371 if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) return;
372 this.#ws.send(encode([type, payload]));
373 }
374
375 // -------------------------------------------------------------------------
376 // Connection lifecycle
377 // -------------------------------------------------------------------------
378
379 /**
380 * connect() → Promise
381 *
382 * Opens the WebSocket, performs the CHALLENGE → AUTH → OK handshake,
383 * then resolves. Rejects on error or if auth fails.
384 */
385 connect() {
386 return new Promise((resolve, reject) => {
387 let settled = false;
388 const settle = (ok, value) => {
389 if (settled) return;
390 settled = true;
391 if (ok) resolve(value);
392 else reject(value);
393 };
394
395 const ws = new WebSocket(this.#relayURL);
396 ws.binaryType = 'arraybuffer';
397 this.#ws = ws;
398
399 ws.onopen = () => {
400 // Wait for CHALLENGE from relay; do not resolve yet.
401 };
402
403 ws.onclose = (e) => {
404 this.#authed = false;
405 settle(false, new Error(`axon: ws closed (${e.code})`));
406 this.#emit('disconnected', e.code, e.reason);
407 };
408
409 ws.onerror = (e) => {
410 settle(false, new Error('axon: ws error'));
411 this.#emit('error', 0, 'websocket error');
412 };
413
414 ws.onmessage = (e) => {
415 let msg;
416 try {
417 msg = decode(new Uint8Array(e.data));
418 } catch (err) {
419 this.#emit('error', 0, 'decode error: ' + err.message);
420 return;
421 }
422
423 if (!Array.isArray(msg) || msg.length < 2) {
424 this.#emit('error', 0, 'malformed message');
425 return;
426 }
427
428 const [type, payload] = msg;
429
430 if (!this.#authed) {
431 // Pre-auth: only handle CHALLENGE and OK.
432 if (type === MSG.CHALLENGE) {
433 const nonce = payload.nonce;
434 const sig = signChallenge(this.#kp, nonce, this.#relayURL);
435 this.#send(MSG.AUTH, { pubkey: this.#kp.pubkey, sig });
436 } else if (type === MSG.OK) {
437 this.#authed = true;
438 settle(true, undefined);
439 this.#emit('connected');
440 } else if (type === MSG.ERROR) {
441 settle(false, new Error(`axon: auth error ${payload.code}: ${payload.message}`));
442 this.#emit('error', payload.code, payload.message);
443 }
444 return;
445 }
446
447 // Post-auth: handle all message types.
448 switch (type) {
449 case MSG.EVENT: {
450 const ev = payload.event;
451 this.#emit('event', payload.sub_id, ev);
452 break;
453 }
454 case MSG.EOSE:
455 this.#emit('eose', payload.sub_id);
456 break;
457 case MSG.OK:
458 this.#emit('ok', payload.message);
459 break;
460 case MSG.ERROR:
461 this.#emit('error', payload.code, payload.message);
462 break;
463 default:
464 // Unknown message type — ignore.
465 break;
466 }
467 };
468 });
469 }
470
471 disconnect() {
472 if (this.#ws) {
473 this.#ws.close(1000);
474 this.#ws = null;
475 }
476 this.#authed = false;
477 }
478
479 // -------------------------------------------------------------------------
480 // Protocol operations
481 // -------------------------------------------------------------------------
482
483 /**
484 * publish(kind, content, tags=[])
485 *
486 * Signs and publishes an event. content may be string or Uint8Array.
487 */
488 publish(kind, content, tags = []) {
489 const event = signEvent(this.#kp, kind, content, tags);
490 this.#send(MSG.PUBLISH, { event });
491 }
492
493 /**
494 * subscribe(subID, filter={})
495 *
496 * Sends a SUBSCRIBE message. All filter fields are sent explicitly.
497 */
498 subscribe(subID, filter = {}) {
499 this.#send(MSG.SUBSCRIBE, {
500 sub_id: subID,
501 filter: {
502 ids: filter.ids ?? [],
503 authors: filter.authors ?? [],
504 kinds: filter.kinds ?? [],
505 since: filter.since ?? 0,
506 until: filter.until ?? 0,
507 limit: filter.limit ?? 0,
508 tags: filter.tags ?? [],
509 },
510 });
511 }
512
513 /**
514 * unsubscribe(subID)
515 */
516 unsubscribe(subID) {
517 this.#send(MSG.UNSUBSCRIBE, { sub_id: subID });
518 }
519}