// A NaCl `crypto_box_seal`, in TypeScript, byte-compatible with Go's `box.SealAnonymous`. // // **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-control seals with `box.SealAnonymous` // (mesh-control 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-control 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. // // **Dependency-free on purpose.** The module runtime image carries only mesh-tools' node_modules // (novox/hq ADR 0052), so a module cannot pull `tweetnacl` at runtime. node:crypto gives X25519 but // not XSalsa20-Poly1305 or a 24-byte BLAKE2b, so the `crypto_box` and the nonce hash are transcribed // here from the public-domain TweetNaCl (Chestnykh/Mandiri, 2014) and blakejs (dcposch, RFC 7693). // X25519 (ephemeral key generation and the Diffie-Hellman) is left to node:crypto, which is // standards-conformant and interoperates with Go's curve25519 regardless of who generated a key. // // **How it is kept honest.** A cross-language test seals a fixture here and opens it in Go // (mesh-control internal/secrets/sealedbox_xcheck_test.go), and this module's own test round-trips // it against a second decrypt. A transcription slip 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. import { createPublicKey, diffieHellman, generateKeyPairSync, type KeyObject, } from "node:crypto"; const RAW_KEY_LEN = 32; // "expand 32-byte k", the Salsa20 constant. const SIGMA = new Uint8Array([ 101, 120, 112, 97, 110, 100, 32, 51, 50, 45, 98, 121, 116, 101, 32, 107, ]); /** * 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 recipientKey = importRawX25519Public(recipientPub); // Ephemeral-static ECDH: a throwaway X25519 key pair whose public half rides in front, and the // raw Diffie-Hellman point shared with the recipient. node:crypto does both. const eph = generateKeyPairSync("x25519"); const ephemeralPub = rawX25519Public(eph.publicKey); const dh = new Uint8Array(diffieHellman({ privateKey: eph.privateKey, publicKey: recipientKey })); // The crypto_box shared key is HSalsa20 of the DH point (crypto_box_beforenm). const boxKey = new Uint8Array(32); cryptoCoreHsalsa20(boxKey, new Uint8Array(16), dh, SIGMA); // nonce = blake2b(ephemeralPub ‖ recipientPub, 24), unkeyed — exactly Go's sealNonce. const nonce = blake2b24(concat(ephemeralPub, recipientPub)); const boxed = cryptoBox(value, nonce, boxKey); return Buffer.from(concat(ephemeralPub, boxed)).toString("base64"); } // --- X25519 via node:crypto ------------------------------------------------------------------ function importRawX25519Public(raw: Uint8Array): KeyObject { return createPublicKey({ key: { kty: "OKP", crv: "X25519", x: Buffer.from(raw).toString("base64url") }, format: "jwk", }); } function rawX25519Public(key: KeyObject): Uint8Array { const jwk = key.export({ format: "jwk" }) as { x?: string }; if (!jwk.x) throw new Error("a public key had no point"); return new Uint8Array(Buffer.from(jwk.x, "base64url")); } // --- crypto_box / crypto_secretbox (XSalsa20-Poly1305) --------------------------------------- // // Transcribed from TweetNaCl (public domain). crypto_box after the DH/HSalsa20 above is exactly // crypto_secretbox: XSalsa20 keystream XOR, then a Poly1305 tag over the ciphertext. /** crypto_box_afternm: secretbox(msg, nonce, key), returning tag(16) ‖ ciphertext. */ function cryptoBox(msg: Uint8Array, nonce: Uint8Array, key: Uint8Array): Uint8Array { // secretbox operates on a 32-byte-zero-prefixed message; its output's first 16 bytes are zero, // and the useful box is everything from byte 16 (the Poly1305 tag, then the ciphertext). const m = new Uint8Array(32 + msg.length); m.set(msg, 32); const c = new Uint8Array(m.length); cryptoSecretbox(c, m, m.length, nonce, key); return c.subarray(16); } function cryptoSecretbox( c: Uint8Array, m: Uint8Array, d: number, n: Uint8Array, k: Uint8Array, ): void { if (d < 32) throw new Error("secretbox message underflow"); cryptoStreamXor(c, 0, m, 0, d, n, k); cryptoOnetimeauth(c, 16, c, 32, d - 32, c); for (let i = 0; i < 16; i++) c[i] = 0; } function L32(x: number, c: number): number { return (x << c) | (x >>> (32 - c)); } function ld32(x: Uint8Array, i: number): number { let u = x[i + 3] & 0xff; u = (u << 8) | (x[i + 2] & 0xff); u = (u << 8) | (x[i + 1] & 0xff); return (u << 8) | (x[i + 0] & 0xff); } function st32(x: Uint8Array, j: number, u: number): void { for (let i = 0; i < 4; i++) { x[j + i] = u & 255; u >>>= 8; } } function core(out: Uint8Array, inp: Uint8Array, k: Uint8Array, c: Uint8Array, h: boolean): void { const w = new Uint32Array(16); const x = new Uint32Array(16); const y = new Uint32Array(16); const t = new Uint32Array(4); for (let i = 0; i < 4; i++) { x[5 * i] = ld32(c, 4 * i); x[1 + i] = ld32(k, 4 * i); x[6 + i] = ld32(inp, 4 * i); x[11 + i] = ld32(k, 16 + 4 * i); } for (let i = 0; i < 16; i++) y[i] = x[i]; for (let i = 0; i < 20; i++) { for (let j = 0; j < 4; j++) { for (let m = 0; m < 4; m++) t[m] = x[(5 * j + 4 * m) % 16]; t[1] ^= L32((t[0] + t[3]) | 0, 7); t[2] ^= L32((t[1] + t[0]) | 0, 9); t[3] ^= L32((t[2] + t[1]) | 0, 13); t[0] ^= L32((t[3] + t[2]) | 0, 18); for (let m = 0; m < 4; m++) w[4 * j + ((j + m) % 4)] = t[m]; } for (let m = 0; m < 16; m++) x[m] = w[m]; } if (h) { for (let i = 0; i < 16; i++) x[i] = (x[i] + y[i]) | 0; for (let i = 0; i < 4; i++) { x[5 * i] = (x[5 * i] - ld32(c, 4 * i)) | 0; x[6 + i] = (x[6 + i] - ld32(inp, 4 * i)) | 0; } for (let i = 0; i < 4; i++) { st32(out, 4 * i, x[5 * i]); st32(out, 16 + 4 * i, x[6 + i]); } } else { for (let i = 0; i < 16; i++) st32(out, 4 * i, (x[i] + y[i]) | 0); } } function cryptoCoreHsalsa20(out: Uint8Array, inp: Uint8Array, k: Uint8Array, c: Uint8Array): void { core(out, inp, k, c, true); } function cryptoStreamSalsa20Xor( c: Uint8Array, cpos: number, m: Uint8Array, mpos: number, b: number, n: Uint8Array, k: Uint8Array, ): void { const z = new Uint8Array(16); const x = new Uint8Array(64); if (!b) return; for (let i = 0; i < 8; i++) z[i] = n[i]; while (b >= 64) { coreSalsa20(x, z, k, SIGMA); for (let i = 0; i < 64; i++) c[cpos + i] = m[mpos + i] ^ x[i]; let u = 1; for (let i = 8; i < 16; i++) { u = (u + (z[i] & 0xff)) | 0; z[i] = u & 0xff; u >>>= 8; } b -= 64; cpos += 64; mpos += 64; } if (b > 0) { coreSalsa20(x, z, k, SIGMA); for (let i = 0; i < b; i++) c[cpos + i] = m[mpos + i] ^ x[i]; } } function coreSalsa20(out: Uint8Array, inp: Uint8Array, k: Uint8Array, c: Uint8Array): void { core(out, inp, k, c, false); } function cryptoStreamXor( c: Uint8Array, cpos: number, m: Uint8Array, mpos: number, d: number, n: Uint8Array, k: Uint8Array, ): void { const s = new Uint8Array(32); cryptoCoreHsalsa20(s, n, k, SIGMA); cryptoStreamSalsa20Xor(c, cpos, m, mpos, d, n.subarray(16), s); } const MINUSP = new Uint32Array([5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 252]); function add1305(h: Uint32Array, c: Uint32Array): void { let u = 0; for (let j = 0; j < 17; j++) { u = (u + ((h[j] + c[j]) | 0)) | 0; h[j] = u & 255; u >>>= 8; } } function cryptoOnetimeauth( out: Uint8Array, outpos: number, m: Uint8Array, mpos: number, n: number, k: Uint8Array, ): void { const x = new Uint32Array(17); const r = new Uint32Array(17); const h = new Uint32Array(17); const c = new Uint32Array(17); const g = new Uint32Array(17); for (let j = 0; j < 16; j++) r[j] = k[j]; r[3] &= 15; r[4] &= 252; r[7] &= 15; r[8] &= 252; r[11] &= 15; r[12] &= 252; r[15] &= 15; let j: number; while (n > 0) { for (j = 0; j < 17; j++) c[j] = 0; for (j = 0; j < 16 && j < n; ++j) c[j] = m[mpos + j]; c[j] = 1; mpos += j; n -= j; add1305(h, c); for (let i = 0; i < 17; i++) { x[i] = 0; for (j = 0; j < 17; j++) { x[i] = (x[i] + (h[j] * (j <= i ? r[i - j] : (320 * r[i + 17 - j]) | 0)) | 0) | 0; } } for (let i = 0; i < 17; i++) h[i] = x[i]; let u = 0; for (j = 0; j < 16; j++) { u = (u + h[j]) | 0; h[j] = u & 255; u >>>= 8; } u = (u + h[16]) | 0; h[16] = u & 3; u = (5 * (u >>> 2)) | 0; for (j = 0; j < 16; j++) { u = (u + h[j]) | 0; h[j] = u & 255; u >>>= 8; } u = (u + h[16]) | 0; h[16] = u; } for (j = 0; j < 17; j++) g[j] = h[j]; add1305(h, MINUSP); const s = -(h[16] >>> 7) | 0; for (j = 0; j < 17; j++) h[j] ^= s & (g[j] ^ h[j]); for (j = 0; j < 16; j++) c[j] = k[j + 16]; c[16] = 0; add1305(h, c); for (j = 0; j < 16; j++) out[outpos + j] = h[j]; } // --- BLAKE2b (24-byte, unkeyed) — the sealed-box nonce hash ---------------------------------- // // Transcribed from blakejs (RFC 7693 reference). Only the fixed path this needs: no key, no salt, // no personalisation, a single ≤128-byte input. const BLAKE2B_IV32 = new Uint32Array([ 0xf3bcc908, 0x6a09e667, 0x84caa73b, 0xbb67ae85, 0xfe94f82b, 0x3c6ef372, 0x5f1d36f1, 0xa54ff53a, 0xade682d1, 0x510e527f, 0x2b3e6c1f, 0x9b05688c, 0xfb41bd6b, 0x1f83d9ab, 0x137e2179, 0x5be0cd19, ]); const SIGMA82 = new Uint8Array( [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3, 11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4, 7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8, 9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13, 2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9, 12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11, 13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10, 6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5, 10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3, ].map((n) => n * 2), ); interface Blake2bCtx { b: Uint8Array; h: Uint32Array; t: number; c: number; outlen: number; } function b2bGet32(arr: Uint8Array, i: number): number { return (arr[i] ^ (arr[i + 1] << 8) ^ (arr[i + 2] << 16) ^ (arr[i + 3] << 24)) >>> 0; } function add64aa(v: Uint32Array, a: number, b: number): void { const o0 = v[a] + v[b]; let o1 = v[a + 1] + v[b + 1]; if (o0 >= 0x100000000) o1++; v[a] = o0; v[a + 1] = o1; } function add64ac(v: Uint32Array, a: number, b0: number, b1: number): void { let o0 = v[a] + b0; if (b0 < 0) o0 += 0x100000000; let o1 = v[a + 1] + b1; if (o0 >= 0x100000000) o1++; v[a] = o0; v[a + 1] = o1; } function b2bG( v: Uint32Array, m: Uint32Array, a: number, b: number, c: number, d: number, ix: number, iy: number, ): void { const x0 = m[ix]; const x1 = m[ix + 1]; const y0 = m[iy]; const y1 = m[iy + 1]; add64aa(v, a, b); add64ac(v, a, x0, x1); let xor0 = v[d] ^ v[a]; let xor1 = v[d + 1] ^ v[a + 1]; v[d] = xor1; v[d + 1] = xor0; add64aa(v, c, d); xor0 = v[b] ^ v[c]; xor1 = v[b + 1] ^ v[c + 1]; v[b] = (xor0 >>> 24) ^ (xor1 << 8); v[b + 1] = (xor1 >>> 24) ^ (xor0 << 8); add64aa(v, a, b); add64ac(v, a, y0, y1); xor0 = v[d] ^ v[a]; xor1 = v[d + 1] ^ v[a + 1]; v[d] = (xor0 >>> 16) ^ (xor1 << 16); v[d + 1] = (xor1 >>> 16) ^ (xor0 << 16); add64aa(v, c, d); xor0 = v[b] ^ v[c]; xor1 = v[b + 1] ^ v[c + 1]; v[b] = (xor1 >>> 31) ^ (xor0 << 1); v[b + 1] = (xor0 >>> 31) ^ (xor1 << 1); } function blake2bCompress(ctx: Blake2bCtx, last: boolean): void { const v = new Uint32Array(32); const m = new Uint32Array(32); for (let i = 0; i < 16; i++) { v[i] = ctx.h[i]; v[i + 16] = BLAKE2B_IV32[i]; } v[24] = v[24] ^ ctx.t; v[25] = v[25] ^ (ctx.t / 0x100000000); if (last) { v[28] = ~v[28]; v[29] = ~v[29]; } for (let i = 0; i < 32; i++) m[i] = b2bGet32(ctx.b, 4 * i); for (let i = 0; i < 12; i++) { b2bG(v, m, 0, 8, 16, 24, SIGMA82[i * 16 + 0], SIGMA82[i * 16 + 1]); b2bG(v, m, 2, 10, 18, 26, SIGMA82[i * 16 + 2], SIGMA82[i * 16 + 3]); b2bG(v, m, 4, 12, 20, 28, SIGMA82[i * 16 + 4], SIGMA82[i * 16 + 5]); b2bG(v, m, 6, 14, 22, 30, SIGMA82[i * 16 + 6], SIGMA82[i * 16 + 7]); b2bG(v, m, 0, 10, 20, 30, SIGMA82[i * 16 + 8], SIGMA82[i * 16 + 9]); b2bG(v, m, 2, 12, 22, 24, SIGMA82[i * 16 + 10], SIGMA82[i * 16 + 11]); b2bG(v, m, 4, 14, 16, 26, SIGMA82[i * 16 + 12], SIGMA82[i * 16 + 13]); b2bG(v, m, 6, 8, 18, 28, SIGMA82[i * 16 + 14], SIGMA82[i * 16 + 15]); } for (let i = 0; i < 16; i++) ctx.h[i] = ctx.h[i] ^ v[i] ^ v[i + 16]; } function blake2b24(input: Uint8Array): Uint8Array { const outlen = 24; const ctx: Blake2bCtx = { b: new Uint8Array(128), h: new Uint32Array(16), t: 0, c: 0, outlen, }; // Parameter block: outlen, keylen=0, fanout=1, depth=1; the rest zero. const param = new Uint8Array(64); param[0] = outlen; param[2] = 1; param[3] = 1; for (let i = 0; i < 16; i++) ctx.h[i] = BLAKE2B_IV32[i] ^ b2bGet32(param, i * 4); for (let i = 0; i < input.length; i++) { if (ctx.c === 128) { ctx.t += ctx.c; blake2bCompress(ctx, false); ctx.c = 0; } ctx.b[ctx.c++] = input[i]; } ctx.t += ctx.c; while (ctx.c < 128) ctx.b[ctx.c++] = 0; blake2bCompress(ctx, true); const out = new Uint8Array(outlen); for (let i = 0; i < outlen; i++) out[i] = ctx.h[i >> 2] >> (8 * (i & 3)); return out; } // --- small helpers --------------------------------------------------------------------------- function concat(a: Uint8Array, b: Uint8Array): Uint8Array { const out = new Uint8Array(a.length + b.length); out.set(a, 0); out.set(b, a.length); return out; }