One name per thing, per the HQ glossary: the module/container/image/binary/repo becomes mesh-controller, the seat the-controller, and the store+broker pair the foundation (embedded base bundles, default template and example lock renamed with their go:embed directives). No behaviour change — a pure vocabulary rename. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
58 lines
3.5 KiB
TypeScript
58 lines
3.5 KiB
TypeScript
// A NaCl `crypto_box_seal`, byte-compatible with Go's `box.SealAnonymous`, over the audited
|
|
// `tweetnacl-sealedbox-js`.
|
|
//
|
|
// **Why this file exists, and why it is exactly this.** novox/hq ADR 0050's refreshable-grant
|
|
// carve-out delivers the refresh token to the manager module the way the mesh delivers every other
|
|
// credential: sealed to the node's key, and unsealed by the *host* — never by the module. The host
|
|
// unseals with Go's `golang.org/x/crypto/nacl/box.OpenAnonymous` (mesh-host
|
|
// internal/identity/sealing.go), and mesh-controller seals with `box.SealAnonymous`
|
|
// (mesh-controller internal/secrets/seal.go). Both are NaCl `crypto_box_seal`:
|
|
//
|
|
// sealed = ephemeralPub(32) ‖ crypto_box(msg, nonce, recipientPub, ephemeralSecret)
|
|
// nonce = blake2b( ephemeralPub ‖ recipientPub , 24 bytes, unkeyed )
|
|
//
|
|
// When the vendor rotates the refresh token, the manager module must store the new one back the
|
|
// same way — sealed to the manager node's own sealing key — so mesh-controller keeps it without ever
|
|
// reading it and the host can later unseal it to deliver the cleartext again. That reseal happens
|
|
// here, on the manager node, in TypeScript. It therefore has to produce the *identical* byte format
|
|
// Go's `Open` accepts, or the host would refuse the delivery.
|
|
//
|
|
// **The crypto is not ours.** `tweetnacl-sealedbox-js` is `crypto_box_seal` built on the audited
|
|
// TweetNaCl (`tweetnacl`) and blakejs — the same construction, and the same libraries, the mesh used
|
|
// to validate this seal during Phase C. It generates the ephemeral X25519 key pair, derives the
|
|
// nonce as `blake2b(ephemeralPub ‖ recipientPub, 24)`, and produces `ephemeralPub ‖ box`. That is
|
|
// exactly what Go's `box.OpenAnonymous` opens: the wire format is unchanged from the hand-transcribed
|
|
// version this replaces — only the implementation is now a maintained, reviewed dependency rather
|
|
// than a copy of TweetNaCl and blakejs carried inline. The module runtime image bundles it
|
|
// (package.json dependencies; novox/hq ADR 0052).
|
|
//
|
|
// **How it is kept honest.** A cross-language test seals a fixture here and opens it in Go
|
|
// (mesh-controller internal/secrets/sealedbox_xcheck_test.go); the fixture is regenerated from this
|
|
// `seal()`. A drift between this seal and Go's box surfaces there as a seal Go cannot open, not as a
|
|
// refresh token silently mangled in production.
|
|
//
|
|
// This module SEALS only. It never opens — opening is the host's job, with the node private key the
|
|
// module is deliberately never given.
|
|
|
|
// A default import, not `{ seal }`: the library is a CommonJS UMD bundle, and Node's ESM loader
|
|
// cannot statically see its named exports — only its default, which is the whole module object.
|
|
import sealedbox from "tweetnacl-sealedbox-js";
|
|
|
|
const RAW_KEY_LEN = 32;
|
|
|
|
/**
|
|
* Seal a value to a node's public sealing key, producing what Go's `box.OpenAnonymous` opens.
|
|
*
|
|
* @param value the plaintext (e.g. a rotated refresh token)
|
|
* @param recipientPublicB64 the node's raw 32-byte X25519 public key, standard base64
|
|
* @returns standard-base64( ephemeralPub ‖ box )
|
|
*/
|
|
export function seal(value: Uint8Array, recipientPublicB64: string): string {
|
|
const recipientPub = Buffer.from(recipientPublicB64, "base64");
|
|
if (recipientPub.length !== RAW_KEY_LEN) {
|
|
throw new Error(`a sealing public key is 32 bytes, not ${recipientPub.length}`);
|
|
}
|
|
const sealed = sealedbox.seal(value, new Uint8Array(recipientPub));
|
|
return Buffer.from(sealed).toString("base64");
|
|
}
|