3d9b4ce392
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).
132 lines
5.9 KiB
TypeScript
132 lines
5.9 KiB
TypeScript
// 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);
|
|
}
|