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.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.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
}
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);
// → roughly 29.5 KB for SLH-DSA-SHAKE-256f
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 (ROTATE_NOOP) when the new keypair has the same fingerprint as the current one. 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.
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 (ANCHOR_BAD_TIP / ANCHOR_BAD_COUNTER / ANCHOR_BAD_TIPHASH / ANCHOR_BAD_PREV / 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 }
Last updated 2026-08-08T16:39:49.652Z by seeder.