The parts of the agent module that hold whichever way the console is registered: X25519 + HKDF + AES-GCM from Node's own library so the bundle carries no dependency; the predecessor's lineage rule (rotation only if newer, a re-issue adopted, a switch regardless) with its incidents as tests; an atomic 0600 write that strips any refresh token and keeps keys it does not know; the account read from the agent's own state file. Manifest and renderer follow.
78 lines
3.4 KiB
TypeScript
78 lines
3.4 KiB
TypeScript
// Sealing a token to one recipient (novox/hq ADR 0183): the manager seals what it hands a node to that
|
|
// node's agent module key, and a node seals a waiting login to the key the manager names. X25519 for
|
|
// the agreement, HKDF-SHA256 for the key, AES-256-GCM for the box — all from Node's own library, so a
|
|
// bundle carries no dependency and no secret ever crosses the bus in the clear.
|
|
//
|
|
// A sealed box is `{ v: 1, eph, iv, tag, ct }`, every field base64. `eph` is a one-time public key, so
|
|
// two boxes of one value to one recipient share nothing, and only the recipient's private key opens it.
|
|
|
|
import {
|
|
createCipheriv, createDecipheriv, createPrivateKey, createPublicKey, diffieHellman,
|
|
generateKeyPairSync, hkdfSync, randomBytes, type KeyObject,
|
|
} from "node:crypto";
|
|
|
|
export interface SealedBox {
|
|
readonly v: 1;
|
|
readonly eph: string;
|
|
readonly iv: string;
|
|
readonly tag: string;
|
|
readonly ct: string;
|
|
}
|
|
|
|
/** A recipient's keypair, as the two PEM strings it is kept and published as. */
|
|
export interface KeyPairPem {
|
|
readonly publicKey: string;
|
|
readonly privateKey: string;
|
|
}
|
|
|
|
const INFO = Buffer.from("novox-mesh sealed box v1");
|
|
|
|
export function generateKeyPair(): KeyPairPem {
|
|
const { publicKey, privateKey } = generateKeyPairSync("x25519");
|
|
return {
|
|
publicKey: publicKey.export({ type: "spki", format: "pem" }).toString(),
|
|
privateKey: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
|
|
};
|
|
}
|
|
|
|
function keyFor(secret: Buffer, eph: Buffer, recipient: Buffer): Buffer {
|
|
// The ephemeral and the recipient's public halves are bound into the key, so a box cannot be
|
|
// re-addressed to another recipient by swapping its `eph`.
|
|
return Buffer.from(hkdfSync("sha256", secret, Buffer.concat([eph, recipient]), INFO, 32));
|
|
}
|
|
|
|
function rawPublic(key: KeyObject): Buffer {
|
|
return key.export({ type: "spki", format: "der" }).subarray(-32);
|
|
}
|
|
|
|
export function seal(plaintext: string, recipientPublicPem: string): SealedBox {
|
|
const recipient = createPublicKey(recipientPublicPem);
|
|
const eph = generateKeyPairSync("x25519");
|
|
const secret = diffieHellman({ privateKey: eph.privateKey, publicKey: recipient });
|
|
const ephRaw = eph.publicKey.export({ type: "spki", format: "der" });
|
|
const key = keyFor(secret, ephRaw, rawPublic(recipient));
|
|
const iv = randomBytes(12);
|
|
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
return {
|
|
v: 1,
|
|
eph: ephRaw.toString("base64"),
|
|
iv: iv.toString("base64"),
|
|
tag: cipher.getAuthTag().toString("base64"),
|
|
ct: ct.toString("base64"),
|
|
};
|
|
}
|
|
|
|
/** Open a box with the recipient's private key. Throws on a box for another key or one tampered with. */
|
|
export function open(box: SealedBox, privateKeyPem: string): string {
|
|
if (!box || box.v !== 1) throw new Error("not a sealed box this module can open");
|
|
const priv = createPrivateKey(privateKeyPem);
|
|
const ephRaw = Buffer.from(box.eph, "base64");
|
|
const eph = createPublicKey({ key: ephRaw, format: "der", type: "spki" });
|
|
const secret = diffieHellman({ privateKey: priv, publicKey: eph });
|
|
const key = keyFor(secret, ephRaw, rawPublic(createPublicKey(priv)));
|
|
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(box.iv, "base64"));
|
|
decipher.setAuthTag(Buffer.from(box.tag, "base64"));
|
|
return Buffer.concat([decipher.update(Buffer.from(box.ct, "base64")), decipher.final()]).toString("utf8");
|
|
}
|