// 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); }