Audit Signing
SLH-DSA-SHAKE-256f post-quantum signature for audit-chain checkpoints. Wrapped vs plaintext on-disk modes, key derivation from an operator passphrase, periodic checkpoint sign / verify, multiple-key support so a key rotation doesn't strand history.
Algorithm: SLH-DSA-SHAKE-256f (FIPS 205) by default. ML-DSA-87 (FIPS 204 Category 5) and ML-DSA-65 (FIPS 204 Category 3, ~192-bit symmetric security, smaller signatures + faster verify than 87) ship as opt-in alternatives for throughput-sensitive deployments. SLH-DSA-SHAKE-256f is hash-only — its security depends solely on the underlying hash function, with no lattice / module-hardness assumptions — and matches the framework's SHAKE256 KDF + SHA3-512 hash family. Audit checkpoints are long-lived integrity attestations (must verify for the data retention period — years for HIPAA / SOX), so the conservative-PQC posture carries more weight here than the smaller ML-DSA signatures (~5 KB at 87, ~3.3 KB at 65) and faster sign (~0.6 ms vs 76 ms).
The algorithm is recorded in the on-disk key file's algorithm field. The framework refuses to load a key file that lacks it. Operators upgrading the algorithm rotate their audit-signing key via b.auditSign.rotateSigningKey({ algorithm }).
Design: - Different keypair from the vault encryption keys. Compromise of the vault DOES NOT let an attacker forge audit checkpoints. - Stored at
Threat model: - Vault key compromised + DB write access: attacker can read sealed values + rewrite audit_log rows + recompute per-row chain hashes. They CANNOT forge new audit_checkpoint rows — each checkpoint requires the audit-signing private key. - Audit signing key compromised: attacker can forge new checkpoints but cannot read sealed values. Existing checkpoints still anchor history that pre-dated the compromise (operator should rotate signing key on detection). - Both compromised: framework cannot defend against this — the operator's physical / administrative controls (HIPAA §164.310, GDPR Art. 32(1)(d)) cover this case.
b.auditSign.publicKeyFromHistory(dataDir, fingerprint) #
Resolve a public key by fingerprint from a data directory's unsealed public-key history WITHOUT initializing signing. Returns the SPKI PEM, or null when the directory records no such key.
getPublicKeyByFingerprint answers the same question for a process that has signing loaded. This one exists for a process that does not and should not: a deployment running auditSigning: false still has to verify signatures that were written while signing was on — a purge anchor especially, since it decides which rows are allowed to be missing. Bootstrapping a keypair just to read a public key would write to a volume the operator opened without signing, which is the opposite of what they asked for.
Public keys carry no secret, so no passphrase is involved. A key that was never rotated is not in the history file, so this can return null for a key that exists; the caller decides what an unresolvable fingerprint means.
var pem = b.auditSign.publicKeyFromHistory("/var/lib/blamejs/data", fp);
// -> "-----BEGIN PUBLIC KEY-----\n..." (or null)
b.auditSign.getPublicKeyByFingerprint(fingerprint) #
Resolve the audit-signing public key (SPKI PEM) for a fingerprint: the live key, or a rotated-out key recorded in the unsealed public-key history that rotateSigningKey maintains. Returns null when no key matches. Only public material is consulted, so no passphrase is needed - this is what lets b.audit.verifyCheckpoints verify a checkpoint signed under a now-rotated key without stranding history.
var pem = b.auditSign.getPublicKeyByFingerprint(checkpoint.publicKeyFingerprint);
// -> "-----BEGIN PUBLIC KEY-----\n..." (or null if the key is unknown)
b.auditSign.publicKeyLicensingDeletion(fingerprint) #
Resolve the audit-signing public key for a fingerprint, for the one question where a rotated-out key is NOT good enough: whether a purge boundary may license missing rows. Returns the live key when the fingerprint is its own, and null otherwise — the rotated-key history is deliberately not consulted.
The history file is unsealed, because a verifier holding no passphrase has to be able to resolve a key that signed a checkpoint before a rotation. Making an entry hash to its own label stops one key being filed under another's name, but it cannot make the file AUTHORITATIVE: generating a keypair and filing a self-consistent entry for it does not require a secret. An attacker who can write the audit store can write that file too, so anything it authorizes is worth exactly as much as their own say-so.
A checkpoint attests that a tip existed; a forged one adds a claim. A purge anchor licenses rows to be ABSENT, so a forged one erases. Only the live key — which lives sealed, behind a passphrase this process had to be given — carries that weight.
var pem = b.auditSign.publicKeyLicensingDeletion(anchor.publicKeyFingerprint);
// -> the live key's PEM, or null when the anchor names any other key
b.auditSign.canonicalPublicKeyPem(publicKeyPem) #
Re-export a PEM public key through the parsed key, giving one spelling for one key. Throws TypeError when the input is not a readable PEM.
A fingerprint is the hash of the PEM TEXT, so the spelling is the identity: the same key saved with CRLF line endings hashes differently from the same key saved with LF, and two readers that disagree about which to hash disagree about which key signed something. Anywhere a key is ingested or pinned, put it through here first.
var pem = b.auditSign.canonicalPublicKeyPem(fs.readFileSync(p, "utf8"));
var fp = b.auditSign.fingerprintOf(pem);
b.auditSign.pinnedKeyResolver(publicKeyPem) #
Build a resolvePublicKey(fingerprint) function for ONE operator-supplied key: it answers with that key for either fingerprint the key can legitimately carry, and null for anything else. Throws TypeError when the PEM is not readable.
One key can answer to two fingerprints because a fingerprint hashes the PEM TEXT. A key handed to rotateSigningKey before that call canonicalized what it ingested was fingerprinted exactly as written, CRLF and all, and the anchors signed under it carry that hash — so hashing only a re-export reports those volumes as signed by a key nobody knows. Hashing only the file's bytes refuses the same key for how it was saved. Both derive from a PEM that parsed into the key in hand, so carrying the pair resolves either spelling without widening what counts as a valid key, and the spelling that matched is what comes back — the text the fingerprint was taken over.
var resolve = b.auditSign.pinnedKeyResolver(fs.readFileSync(p, "utf8"));
var vc = await b.auditChain.verifyChain(q, "audit_log", { resolvePublicKey: resolve });
b.auditSign.init(opts) #
{
dataDir: string, // required — directory holding the key file
mode: "wrapped" | "plaintext", // default "wrapped"
algorithm: "slh-dsa-shake-256f" | "ml-dsa-87" | "ml-dsa-65", // default "slh-dsa-shake-256f"; only consulted when generating a fresh key
readOnly: boolean // default false — load an existing key but never create the directory, sweep orphaned temp files, or generate a keypair. A volume with no key leaves signing uninitialized rather than acquiring one.
}
Boot the audit-signing keypair. Called once during b.db.init(); later calls are no-ops. First run generates a fresh PQC keypair and either seals it under an operator passphrase ('wrapped' mode, default) or writes it plaintext at 0600 ('plaintext' mode, opt-out with stderr warning). Subsequent boots load the existing key file and refuse if both wrapped + plaintext copies exist on disk (KEY_FILE_CONFLICT) or the on-disk mode disagrees with opts.mode (MODE_MISMATCH).
await b.auditSign.init({
dataDir: "/var/lib/blamejs/data",
mode: "wrapped",
algorithm: "slh-dsa-shake-256f",
});
b.auditSign.getMode(); // → "wrapped"
b.auditSign.getAlgorithm(); // → "slh-dsa-shake-256f"
b.auditSign.sign(payload) #
Sign a payload (Buffer or string) with the in-memory PQC private key. Returns the raw signature bytes as a Buffer. Throws if init() has not been awaited. Used by b.audit.checkpoint() to anchor the chain tip; operators normally don't call it directly.
await b.auditSign.init({ dataDir: "/var/lib/blamejs/data" });
// Sign a chain checkpoint payload (the audit module passes the
// chain tip's row hash + monotonic counter as canonical bytes).
var tip = { rowHash: "9f4e2c3a", counter: 1042 };
var payload = Buffer.from(JSON.stringify(tip), "utf8");
var signature = b.auditSign.sign(payload);
// → 49,856 bytes under SLH-DSA-SHAKE-256f, the default
// (FIPS 205 Table 2). The `256s` variant is 29,792; ML-DSA-87 is
// 4,627 and ML-DSA-65 3,309, which is what a deployment sizing
// checkpoint storage trades verify speed for.
b.auditSign.verify(payload, signature, publicKeyPem) #
Verify a signature against the supplied (or current) public key. Returns true when the signature is valid, false otherwise; never throws on a forgery — callers branch on the boolean. The third argument lets verification use a HISTORICAL key (read from audit-sign.key.sealed.history-*) so a checkpoint signed years earlier still verifies after rotation.
await b.auditSign.init({ dataDir: "/var/lib/blamejs/data" });
// Re-walk every checkpoint to confirm chain integrity.
var tip = { rowHash: "9f4e2c3a", counter: 1042 };
var payload = Buffer.from(JSON.stringify(tip), "utf8");
var signature = b.auditSign.sign(payload);
var ok = b.auditSign.verify(payload, signature);
// → true
// A historical checkpoint signed under an old key:
var oldPubPem = "-----BEGIN PUBLIC KEY-----\nMII...\n-----END PUBLIC KEY-----";
b.auditSign.verify(payload, signature, oldPubPem);
// → true (when payload + signature were produced under that key)
b.auditSign.fingerprintOf(publicKeyPem) #
Compute the SHA3-512 fingerprint (lowercase hex) of a SPKI-PEM public key — the same derivation getPublicKeyFingerprint() returns for the active key, but for any supplied key and WITHOUT requiring init(). A verifier pins a trusted fingerprint and checks it against fingerprintOf(block.publicKey) before trusting a detached signature block, so an attacker can't substitute their own key while claiming the trusted fingerprint.
var fp = b.auditSign.fingerprintOf(block.publicKey);
if (fp !== trustedFingerprint) throw new Error("untrusted signing key");
b.auditSign.getPublicKey() #
Return the in-memory public key as a SPKI PEM string. Operators publish this so external auditors can verify checkpoint signatures without holding any private material.
await b.auditSign.init({ dataDir: "/var/lib/blamejs/data" });
var pem = b.auditSign.getPublicKey();
// → "-----BEGIN PUBLIC KEY-----\nMII...\n-----END PUBLIC KEY-----\n"
b.auditSign.getPublicKeyFingerprint() #
Return the SHA3-512 fingerprint of the public key as a lowercase hex string. Stable across boots for the same keypair; a different fingerprint after rotateSigningKey() is the signal that the rotation actually changed material.
await b.auditSign.init({ dataDir: "/var/lib/blamejs/data" });
var fp = b.auditSign.getPublicKeyFingerprint();
// → "9f4e2c3a..." (128 hex chars, SHA3-512)
b.auditSign.getMode() #
Return the on-disk storage mode chosen at init() — "wrapped" (passphrase-sealed, default) or "plaintext" (0600 file, opt-out). Returns null before init() runs.
await b.auditSign.init({ dataDir: "/var/lib/blamejs/data" });
b.auditSign.getMode();
// → "wrapped"
b.auditSign.getAlgorithm() #
Return the algorithm of the currently-loaded keypair — "slh-dsa-shake-256f", "ml-dsa-87", or "ml-dsa-65". Read from the on-disk key file, not from the operator's init() opts (the file's algorithm wins so a key generated under one alg keeps verifying under that alg even when a later boot passes a different default).
await b.auditSign.init({ dataDir: "/var/lib/blamejs/data" });
b.auditSign.getAlgorithm();
// → "slh-dsa-shake-256f"
b.auditSign.reSignAll(iter, opts) #
{
onProgress: function (entry), // called with { id, newSignature } per re-sign; errors in the hook are drop-silent
}
Re-sign every payload in iter under the CURRENT in-memory key. Each iteration yields { id, payload, signature, oldPublicKeyPem } — payloads whose old signature fails to verify under oldPublicKeyPem are skipped (already tampered or never signed under that key) rather than aborting the whole walk. Returns { reSigned, skipped, errors }. The caller (typically the audit module's checkpoint store) persists the new bytes; this primitive does not touch storage.
await b.auditSign.init({ dataDir: "/var/lib/blamejs/data" });
async function* allCheckpoints() {
yield {
id: 1,
payload: Buffer.from("{\"counter\":1}", "utf8"),
signature: Buffer.from("00", "hex"),
oldPublicKeyPem: b.auditSign.getPublicKey(),
};
}
var summary = await b.auditSign.reSignAll(allCheckpoints(), {
onProgress: function (entry) {
// persist entry.newSignature against entry.id atomically
},
});
// → { reSigned: 1, skipped: 0, errors: 0 }
b.auditSign.rotateSigningKey(opts) #
{
privateKeyPem: string, // BYO keypair (pair with publicKeyPem); when omitted the framework generates fresh material
publicKeyPem: string,
algorithm: "slh-dsa-shake-256f" | "ml-dsa-87" | "ml-dsa-65" // defaults to the current keypair's algorithm
}
Generate (or accept) a fresh keypair, copy the existing sealed / plaintext key file to a timestamped *.history- path, and persist the new key to disk through the same wrap path as boot. The in-memory swap happens last so a write failure leaves the framework with the OLD key still in memory + on disk. Refuses (audit-sign/rotate-noop) when the new keypair has the same fingerprint as the current one, audit-sign/rotate-bad-alg for an algorithm outside SUPPORTED_SIGNING_ALGS, audit-sign/rotate-bad-key when publicKeyPem does not read as a PEM public key, and audit-sign/rotate-in-progress when a rotation is already running on this thread: two rotations cannot interleave over one sealed key, so the second is refused rather than queued, and that refusal is permanent. Operators rotating the audit-signing key in production typically: read existing checkpoints, call rotateSigningKey(), walk the checkpoints through reSignAll(), then write the new signatures back atomically. Returns metadata about the rotation including the historyPath so external tools can verify pre-rotation checkpoints later.
A volume that has ever purged needs one more step: call b.auditTools.signExistingPurgeAnchor() after rotating. A checkpoint signed by the outgoing key still verifies from the public-key history, but a purge anchor does not — the history is unsealed, so it can say which key signed something and cannot say which key was ALLOWED to license deleted rows. Until the anchor is re-signed under the new key the chain refuses to verify, with a message naming that call.
Rotating before b.auditSign.init() has run throws audit-sign/not-initialized.
// requires: a signing passphrase source (BLAMEJS_AUDIT_SIGNING_PASSPHRASE,
// BLAMEJS_AUDIT_SIGNING_PASSPHRASE_FILE, or a TTY on stdin)
await b.auditSign.init({ dataDir: "/var/lib/blamejs/data" });
// Annual rotation — same algorithm, framework-generated material:
var result = await b.auditSign.rotateSigningKey();
// → {
// previousFingerprint: "9f4e...",
// newFingerprint: "3a7c...",
// algorithm: "slh-dsa-shake-256f",
// rotatedAt: "2026-05-09T12:00:00.000Z",
// historyPath: "/var/lib/blamejs/data/audit-sign.key.sealed.history-2026-05-09T12-00-00-000Z-9f4e2c3aabbccdd0",
// ...
// }
// Algorithm upgrade — same call, with explicit `algorithm`:
await b.auditSign.rotateSigningKey({ algorithm: "ml-dsa-65" });
b.auditSign.anchor(tip, opts?) #
{
format: string, // default "blamejs-chain-anchor-v1" — domain-separation magic in the signed payload
createdAt: number, // default Date.now() — the anchor timestamp (also signed)
}
Sign a hash-chain tip with the in-memory PQC key, returning a self-describing anchor object the consumer persists in THEIR OWN store. This is the b.audit.checkpoint() protocol lifted off the framework audit_log / audit_checkpoints tables: a consumer running their own append-only chain anchors its tip the same tamper-evident way, with no framework table, no clusterStorage, and no leader requirement. A full-chain rewrite that recomputes every row hash still cannot forge the signature without the audit-signing private key, so a later verifyAnchorChain detects it.
tip.prevTipHash (optional) is bound into the signed bytes, so truncation / reorder of a stored anchor sequence is caught by the signature, not just a plaintext compare. opts.format domain-separates a consumer's anchors (default "blamejs-chain-anchor-v1").
Verification resolves the public key from the recorded fingerprint via the key-history file under the init({ dataDir }) directory, so it is bound to that key store (not fully store-free) — keep the history with the anchors.
Throws AuditSignError (audit-sign/anchor-bad-tip / audit-sign/anchor-bad-counter / audit-sign/anchor-bad-tiphash / audit-sign/anchor-bad-prev / audit-sign/anchor-bad-format) on a malformed tip — including any format / tipHash / prevTipHash that carries a newline, which would make the signed bytes ambiguous; audit-sign/not-initialized when init() has not been awaited.
await b.auditSign.init({ dataDir: "/var/lib/blamejs/data" });
var a = b.auditSign.anchor({ counter: 42, tipHash: "9f4e", prevTipHash: "1b7d" },
{ format: "my-app-ledger-v1" });
// → { format, counter, tipHash, prevTipHash, createdAt, algorithm,
// publicKeyFingerprint, signature }
b.auditSign.verifyAnchor(anchor) #
Verify a single anchor produced by b.auditSign.anchor. Resolves the public key by the anchor's recorded fingerprint (live key or a rotated-out key from the unsealed history), rebuilds the canonical payload, and checks the post-quantum signature. Returns { ok: true } when valid, or { ok: false, reason } for a forgery, an unknown signing key, malformed hex, or a missing field. Never throws on adversarial content.
var a = b.auditSign.anchor({ counter: 1, tipHash: "ab12" });
b.auditSign.verifyAnchor(a); // → { ok: true }
b.auditSign.verifyAnchorChain(anchors, opts?) #
{
requireLinkage: boolean, // default true — every non-genesis anchor must carry a matching prevTipHash
}
Walk an ordered array of anchors (oldest first) and verify each one's signature AND that the sequence is internally consistent: counters strictly increase, and each anchor's prevTipHash equals the previous anchor's tipHash. This catches the two attacks a single-anchor check cannot — a stored-anchor truncation / reorder (link break) and a full-chain rewrite (signature break). Returns { ok: true, anchorsVerified }, or { ok: false, anchorsVerified, breakAt, reason } at the first break.
requireLinkage (default true) makes a non-genesis anchor that omits prevTipHash a break, so an attacker can't drop the link to bypass the check. Pass requireLinkage: false for unlinked anchors.
var a1 = b.auditSign.anchor({ counter: 1, tipHash: "h1" });
var a2 = b.auditSign.anchor({ counter: 2, tipHash: "h2", prevTipHash: "h1" });
b.auditSign.verifyAnchorChain([a1, a2]); // → { ok: true, anchorsVerified: 2 }