feat(bus): TypeScript SDK crypto + frame, parity-verified against Go

First half of the browser-native bus SDK (issue 0001, Phase 1):

- crypto.ts: Ed25519 sign/verify (@noble), ChaCha20-Poly1305 AEAD (@noble),
  endpoint id (sha256+base64url), and the anonymous sealed box for room-key
  distribution. The sealed-box nonce is BLAKE2b-192 over ephPub||recipientPub,
  matching Go's nacl/box.SealAnonymous (NOT SHA-512) so a Go-sealed key opens here.
- frame.ts: the Frame wire format, reproducing Go encoding/json byte-for-byte —
  struct field order, omitempty rules, base64-std byte fields, and the default
  HTML escaping (<, >, &, U+2028/U+2029) — plus sign/verify over canonical bytes.

vectors.test.ts checks all of it against the golden vectors generated by unibus
cmd/busvectors. 11/11 green: endpoint id, Ed25519 (incl. frame signature),
AEAD seal+open, sealed box open + round-trip, and frame signing-bytes + wire
marshal. This pins cross-language interop with Go/Kotlin peers.

Adds @noble/ciphers, tweetnacl (runtime) and vitest (dev).
This commit is contained in:
agent
2026-06-13 22:30:38 +02:00
parent cb6b51156a
commit 3d9b4ce392
5 changed files with 669 additions and 3 deletions
+131
View File
@@ -0,0 +1,131 @@
// Bus crypto primitives, ported to the browser to match the Go reference
// implementation (functions/cybersecurity in fn-registry) byte-for-byte. The bus
// is end-to-end encrypted; doing the crypto here is what keeps the user's private
// key on the device and out of any server (issue 0001). Parity with Go is enforced
// by the vectors in testdata/vectors.json (see vectors.test.ts).
//
// Primitive map (Go -> here):
// EndpointID -> endpointID : base64url(sha256(signPub)), unpadded
// SignEd25519 -> signEd25519 : Ed25519 detached signature
// verify -> verifyEd25519
// SealAEAD/Open -> sealAEAD/openAEAD : ChaCha20-Poly1305 (IETF, 12-byte nonce)
// SealKeyBox/Open -> sealKeyBox/openKeyBox : NaCl anonymous sealed box (X25519),
// with the nonce derived as sha512(ephPub||recipientPub)[:24]
// EXACTLY as Go's nacl/box.SealAnonymous (Go uses SHA-512, not
// libsodium's blake2b — matching this is the whole point).
import { ed25519 } from "@noble/curves/ed25519.js";
import { chacha20poly1305 } from "@noble/ciphers/chacha.js";
import { sha256 } from "@noble/hashes/sha2.js";
import { blake2b } from "@noble/hashes/blake2.js";
import { concatBytes } from "@noble/hashes/utils.js";
import nacl from "tweetnacl";
// sealedBoxNonce derives the 24-byte nonce for an anonymous sealed box the same way
// Go's nacl/box.SealAnonymous (and libsodium's crypto_box_seal) do: BLAKE2b-192 over
// ephemeralPub || recipientPub. NOT SHA-512 — matching the exact hash is what makes
// a Go-sealed room key openable here.
function sealedBoxNonce(ephPub: Uint8Array, recipientPub: Uint8Array): Uint8Array {
return blake2b(concatBytes(ephPub, recipientPub), { dkLen: 24 });
}
// --- byte / encoding helpers (browser-safe; no Buffer) -----------------------
export function bytesToHex(b: Uint8Array): string {
let s = "";
for (const x of b) s += x.toString(16).padStart(2, "0");
return s;
}
export function hexToBytes(hex: string): Uint8Array {
if (hex.length % 2 !== 0) throw new Error("hex: odd 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;
}
// base64 standard (with padding) — matches Go's encoding/json for []byte fields.
export function bytesToBase64(b: Uint8Array): string {
let bin = "";
for (const x of b) bin += String.fromCharCode(x);
return btoa(bin);
}
export function base64ToBytes(s: string): Uint8Array {
const bin = atob(s);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
// base64url without padding — matches Go's base64.RawURLEncoding (EndpointID).
export function bytesToBase64URL(b: Uint8Array): string {
return bytesToBase64(b).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
// --- identity / signing ------------------------------------------------------
// endpointID is the stable, transport-agnostic peer id: base64url(sha256(signPub)).
export function endpointID(signPub: Uint8Array): string {
return bytesToBase64URL(sha256(signPub));
}
// signEd25519 signs msg with an Ed25519 private key. It accepts the bus/Go 64-byte
// private key (seed || pub) OR a bare 32-byte seed; @noble signs from the 32-byte
// seed, so we slice the seed out of the 64-byte form.
export function signEd25519(priv: Uint8Array, msg: Uint8Array): Uint8Array {
const seed = priv.length === 64 ? priv.subarray(0, 32) : priv;
return ed25519.sign(msg, seed);
}
export function verifyEd25519(sig: Uint8Array, msg: Uint8Array, pub: Uint8Array): boolean {
return ed25519.verify(sig, msg, pub);
}
// --- AEAD (room message content) ---------------------------------------------
// sealAEAD encrypts plaintext with ChaCha20-Poly1305 (IETF, 12-byte nonce). The
// caller supplies the nonce so the operation is testable; in the bus a fresh random
// nonce is generated per message and stored alongside the ciphertext.
export function sealAEAD(key: Uint8Array, nonce: Uint8Array, plaintext: Uint8Array, aad?: Uint8Array): Uint8Array {
return chacha20poly1305(key, nonce, aad).encrypt(plaintext);
}
export function openAEAD(key: Uint8Array, nonce: Uint8Array, ciphertext: Uint8Array, aad?: Uint8Array): Uint8Array {
return chacha20poly1305(key, nonce, aad).decrypt(ciphertext);
}
// randomNonce returns a fresh 12-byte AEAD nonce (ChaCha20-Poly1305 IETF size).
export function randomNonce(): Uint8Array {
return crypto.getRandomValues(new Uint8Array(12));
}
// --- anonymous sealed box (room key distribution) ----------------------------
// sealKeyBox seals secret to a recipient's X25519 public key as an anonymous NaCl
// sealed box, matching Go's nacl/box.SealAnonymous: an ephemeral keypair is created,
// the nonce is sha512(ephPub || recipientPub)[:24], and the output is
// ephPub(32) || box(secret). The recipient opens it with openKeyBox; the sender is
// anonymous (no long-term sender key is revealed).
export function sealKeyBox(recipientKexPub: Uint8Array, secret: Uint8Array): Uint8Array {
const eph = nacl.box.keyPair();
const nonce = sealedBoxNonce(eph.publicKey, recipientKexPub);
const boxed = nacl.box(secret, nonce, recipientKexPub, eph.secretKey);
return concatBytes(eph.publicKey, boxed);
}
// openKeyBox opens an anonymous sealed box produced by sealKeyBox (or Go's
// SealKeyBox). It re-derives the same sha512-based nonce from the embedded ephemeral
// public key and the recipient's own public key, then opens the box with the
// recipient's private key. Returns null if authentication fails.
export function openKeyBox(
recipientKexPub: Uint8Array,
recipientKexPriv: Uint8Array,
sealed: Uint8Array,
): Uint8Array | null {
if (sealed.length < 32) return null;
const ephPub = sealed.subarray(0, 32);
const boxed = sealed.subarray(32);
const nonce = sealedBoxNonce(ephPub, recipientKexPub);
return nacl.box.open(boxed, nonce, ephPub, recipientKexPriv);
}
+140
View File
@@ -0,0 +1,140 @@
// The wire format of the unibus message bus, ported from Go pkg/frame. A Frame is
// the unit transported over NATS: a cleartext envelope plus an optional AEAD
// ciphertext payload, signed end-to-end with Ed25519.
//
// The signature covers the canonical JSON of the frame with the signature field
// cleared, so the marshaler here must reproduce Go's encoding/json BYTE FOR BYTE or
// signatures verified by Go peers would fail. That means: struct field order, the
// `omitempty` rules, base64-standard encoding of []byte fields, and Go's default
// HTML escaping of <, >, & and the U+2028/U+2029 separators inside strings. Parity
// is pinned by testdata/vectors.json (vectors.test.ts).
import {
bytesToBase64,
base64ToBytes,
signEd25519,
verifyEd25519,
endpointID,
} from "./crypto.js";
export enum FrameType {
PUB = 0,
INVITE = 1,
JOIN = 2,
LEAVE = 3,
KICK = 4,
ACK = 5,
REACT = 6,
}
export interface BlobRef {
hash: string; // sha256 hex of the blob ciphertext
nonce: Uint8Array; // AEAD nonce used to encrypt the blob
size: number; // ciphertext size in bytes
}
export interface Frame {
type: FrameType;
subject: string;
sender: string; // endpoint id = endpointID(signPub)
msgID: string; // ULID
epoch: number; // epoch of the room key used to encrypt
threadID?: string; // root message id of the thread (optional)
replyTo?: string; // message id this frame replies to / reacts to (optional)
nonce?: Uint8Array; // AEAD nonce (encrypted rooms only)
payload?: Uint8Array; // AEAD ciphertext (or cleartext if the room is not encrypted)
blob?: BlobRef;
sig?: Uint8Array; // Ed25519 signature over signingBytes()
}
// Go's encoding/json HTML-escapes these code points inside strings by default. We
// replay the exact same set so our canonical bytes match Go's. The two separators
// (U+2028 line separator, U+2029 paragraph separator) are built via fromCharCode so
// this source file holds no invisible characters while the RegExp still matches the
// real code points at runtime.
const GO_ESCAPES: ReadonlyArray<[RegExp, string]> = [
[/</g, "\\u003c"],
[/>/g, "\\u003e"],
[/&/g, "\\u0026"],
[new RegExp(String.fromCharCode(0x2028), "g"), "\\u2028"],
[new RegExp(String.fromCharCode(0x2029), "g"), "\\u2029"],
];
// goJSONStringify serializes obj the way Go's encoding/json does: compact (no
// spaces), insertion-ordered keys, and the default HTML escaping above. Apply only
// to objects built key-by-key in field order, so the output matches Go's struct
// marshaling exactly.
function goJSONStringify(obj: Record<string, unknown>): string {
let s = JSON.stringify(obj);
for (const [re, rep] of GO_ESCAPES) s = s.replace(re, rep);
return s;
}
// frameObject builds the plain object with keys inserted in Go struct-declaration
// order, applying each field's omitempty rule. includeSig controls whether the
// signature field is emitted: false yields the canonical signing-bytes object.
function frameObject(f: Frame, includeSig: boolean): Record<string, unknown> {
const o: Record<string, unknown> = {};
// Always-present fields (no omitempty in Go).
o.t = f.type;
o.s = f.subject;
o.from = f.sender;
o.id = f.msgID;
o.e = f.epoch;
// omitempty fields, in declaration order.
if (f.threadID) o.thr = f.threadID;
if (f.replyTo) o.re = f.replyTo;
if (f.nonce && f.nonce.length) o.n = bytesToBase64(f.nonce);
if (f.payload && f.payload.length) o.p = bytesToBase64(f.payload);
if (f.blob) o.b = { h: f.blob.hash, n: bytesToBase64(f.blob.nonce), sz: f.blob.size };
if (includeSig && f.sig && f.sig.length) o.sig = bytesToBase64(f.sig);
return o;
}
// marshal returns the wire bytes of the frame (UTF-8 of the canonical JSON).
export function marshal(f: Frame): Uint8Array {
return new TextEncoder().encode(goJSONStringify(frameObject(f, true)));
}
// signingBytes returns the canonical bytes that are signed and verified: the frame
// JSON with the signature field cleared.
export function signingBytes(f: Frame): Uint8Array {
return new TextEncoder().encode(goJSONStringify(frameObject(f, false)));
}
// unmarshal parses wire bytes back into a Frame, decoding the base64 []byte fields.
export function unmarshal(b: Uint8Array): Frame {
const o = JSON.parse(new TextDecoder().decode(b));
const f: Frame = {
type: o.t ?? 0,
subject: o.s ?? "",
sender: o.from ?? "",
msgID: o.id ?? "",
epoch: o.e ?? 0,
};
if (o.thr) f.threadID = o.thr;
if (o.re) f.replyTo = o.re;
if (o.n) f.nonce = base64ToBytes(o.n);
if (o.p) f.payload = base64ToBytes(o.p);
if (o.b) f.blob = { hash: o.b.h, nonce: base64ToBytes(o.b.n), size: o.b.sz };
if (o.sig) f.sig = base64ToBytes(o.sig);
return f;
}
// signFrame fills f.sig with an Ed25519 signature over signingBytes(f). signPriv is
// the 64-byte (seed||pub) or 32-byte seed private key.
export function signFrame(f: Frame, signPriv: Uint8Array): Frame {
f.sig = signEd25519(signPriv, signingBytes(f));
return f;
}
// verifyFrame checks f.sig against signPub over signingBytes(f).
export function verifyFrame(f: Frame, signPub: Uint8Array): boolean {
if (!f.sig) return false;
return verifyEd25519(f.sig, signingBytes(f), signPub);
}
// senderEndpoint derives the canonical sender endpoint id from a signing public key.
export function senderEndpoint(signPub: Uint8Array): string {
return endpointID(signPub);
}
+127
View File
@@ -0,0 +1,127 @@
// Cross-language parity tests: the TypeScript bus SDK must reproduce the Go
// reference implementation byte-for-byte. The golden vectors in testdata/vectors.json
// are generated by unibus `cmd/busvectors`. Any divergence here means a browser
// client and a Go/Kotlin peer would not interoperate (issue 0001, Phase 1).
import { describe, it, expect } from "vitest";
import vectors from "./testdata/vectors.json";
import {
hexToBytes,
bytesToHex,
base64ToBytes,
endpointID,
signEd25519,
verifyEd25519,
sealAEAD,
openAEAD,
openKeyBox,
sealKeyBox,
} from "./crypto.js";
import { Frame, FrameType, marshal, signingBytes, signFrame, verifyFrame } from "./frame.js";
describe("endpoint id", () => {
it("matches Go EndpointID = base64url(sha256(signPub))", () => {
const v = vectors.endpoint_id;
expect(endpointID(hexToBytes(v.sign_pub_hex))).toBe(v.endpoint_id);
});
});
describe("Ed25519 signing", () => {
it("produces the same deterministic signature as Go", () => {
const v = vectors.sign;
const sig = signEd25519(hexToBytes(v.sign_priv_hex), hexToBytes(v.message_hex));
expect(bytesToHex(sig)).toBe(v.sig_hex);
});
it("verifies the Go-produced signature", () => {
const v = vectors.sign;
const ok = verifyEd25519(hexToBytes(v.sig_hex), hexToBytes(v.message_hex), hexToBytes(v.sign_pub_hex));
expect(ok).toBe(true);
});
});
describe("ChaCha20-Poly1305 AEAD", () => {
it("opens the Go-sealed ciphertext", () => {
const v = vectors.aead;
const pt = openAEAD(
hexToBytes(v.key_hex),
hexToBytes(v.nonce_hex),
hexToBytes(v.ciphertext_hex),
hexToBytes(v.aad_hex),
);
expect(bytesToHex(pt)).toBe(v.plaintext_hex);
});
it("seals to the same ciphertext as Go with a fixed nonce", () => {
const v = vectors.aead;
const ct = sealAEAD(
hexToBytes(v.key_hex),
hexToBytes(v.nonce_hex),
hexToBytes(v.plaintext_hex),
hexToBytes(v.aad_hex),
);
expect(bytesToHex(ct)).toBe(v.ciphertext_hex);
});
});
describe("anonymous sealed box (room key distribution)", () => {
it("opens the Go-sealed room key", () => {
const v = vectors.keybox;
const secret = openKeyBox(
hexToBytes(v.recipient_kex_pub_hex),
hexToBytes(v.recipient_kex_priv_hex),
hexToBytes(v.sealed_hex),
);
expect(secret).not.toBeNull();
expect(bytesToHex(secret!)).toBe(v.secret_hex);
});
it("round-trips a TS-sealed box (seal then open)", () => {
const v = vectors.keybox;
const pub = hexToBytes(v.recipient_kex_pub_hex);
const priv = hexToBytes(v.recipient_kex_priv_hex);
const secret = hexToBytes(v.secret_hex);
const sealed = sealKeyBox(pub, secret);
const opened = openKeyBox(pub, priv, sealed);
expect(opened).not.toBeNull();
expect(bytesToHex(opened!)).toBe(v.secret_hex);
});
});
describe("Frame wire format", () => {
function vectorFrame(): Frame {
const v = vectors.frame;
return {
type: v.type as FrameType,
subject: v.subject,
sender: v.sender,
msgID: v.msg_id,
epoch: v.epoch,
nonce: hexToBytes(v.nonce_hex),
payload: hexToBytes(v.payload_hex),
};
}
it("produces the same canonical signing bytes as Go", () => {
const got = signingBytes(vectorFrame());
const want = base64ToBytes(vectors.frame.signing_bytes_b64);
expect(bytesToHex(got)).toBe(bytesToHex(want));
});
it("signs the frame to the same Ed25519 signature as Go", () => {
const f = signFrame(vectorFrame(), hexToBytes(vectors.sign.sign_priv_hex));
expect(bytesToHex(f.sig!)).toBe(vectors.frame.sig_hex);
});
it("marshals the signed frame to the same wire bytes as Go", () => {
const f = signFrame(vectorFrame(), hexToBytes(vectors.sign.sign_priv_hex));
const got = marshal(f);
const want = base64ToBytes(vectors.frame.wire_b64);
expect(bytesToHex(got)).toBe(bytesToHex(want));
});
it("verifies the marshaled frame signature against the signer pubkey", () => {
const f = signFrame(vectorFrame(), hexToBytes(vectors.sign.sign_priv_hex));
expect(verifyFrame(f, hexToBytes(vectors.sign.sign_pub_hex))).toBe(true);
});
});