@blamejs/core 0.15.20 → 0.15.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.15.x
10
10
 
11
+ - v0.15.21 (2026-06-24) — **Adds a portable compare-and-swap UPDATE, an IPv6 /64 rate-limit key, a verify-only legacy-TOTP path, request-id AsyncLocalStorage scoping, schema-agnostic backup signing, side-effect-free FSM transition resolution, and the public codepoint-threat catalog — and fixes an FSM state transition that committed without an audit record when its entry hook threw.** A batch of additive primitives that close gaps operators hit composing the framework: b.sql.guardedUpdate builds the cross-instance-safe compare-and-swap UPDATE (the conditional-INSERT sibling) with b.sql.casWon reading the won/lost result; b.requestHelpers.ipKey (and a rateLimit ipKeyMode) key an IPv6 client by its routing-significant /64 so one end-site can't rotate the low 64 bits to evade a per-IP limit; b.auth.totp gains an opt-in verify-only SHA-1 path for one-final-login legacy-secret migration while generation stays SHA-512-only; b.middleware.requestId can bind the id into an AsyncLocalStorage scope that survives the awaited route chain; b.backupManifest.signBytes/verifyBytes authenticate a consumer's own canonical bytes with the framework signing key; b.fsm gains a side-effect-free instance.target() and a way to defer its transition audit when composed with an external claim; and the codepoint-threat catalog the guard family composes is exposed as b.codepointClass. A correctness fix: an FSM transition whose onEnter hook threw left the state committed but emitted no audit record — the record now always fires (stamped a failure outcome with the hook error) so a state change is never unaudited. **Added:** *b.sql.guardedUpdate + b.sql.casWon — portable compare-and-swap UPDATE* — b.sql.guardedUpdate(table) builds an UPDATE whose required guardWhere(col, expected) fence makes it land only when the row is still in the expected value — the cross-instance-safe way to advance a status or version on a single-statement-per-request backend (a D1-over-HTTP bridge or any autocommit-only adapter without interactive transactions), and the conditional-UPDATE sibling of b.sql.insertSelectWhere. It refuses to render without a fence (an unfenced guardedUpdate is just a plain update). b.sql.casWon(result) reads the won/lost verdict from the affected-row count, normalizing the rowCount / changes / affectedRows field-name divergence across adapters and throwing on an indeterminate result so a phantom win can't ship. Standard SQL across SQLite / Postgres / MySQL; guardWhereOp(col, op, expected) supports a non-equality fence (an optimistic-version or balance guard), and a null fence renders IS NULL. · *b.requestHelpers.ipKey + rateLimit ipKeyMode — IPv6 /64 keying* — b.requestHelpers.ipKey(ip, { ipv6Bits }) derives a stable rate-limit / blocklist key: an IPv4 address verbatim (one IPv4 is one host) but an IPv6 address collapsed to its routing-significant /64 prefix. A single IPv6 end-site is allocated a whole /64 (RFC 6177 / RFC 4291) and freely rotates the low 64 bits, so keying on the full 128-bit address lets one site mint unlimited fresh keys and walk a per-IP throttle or evade an exact-address block; keying on the /64 closes that while still distinguishing real end-sites. b.middleware.rateLimit gains ipKeyMode: "prefix64" to apply this to its default key (the audit record still logs the full client IP). · *b.auth.totp verify-only SHA-1 path for legacy-secret migration* — b.auth.totp.verify(secret, code, { algorithm: "sha1", verifyOnly: true }) authenticates a single legacy code during a re-enrollment flow, so a consumer migrating pre-existing RFC-6238-default (SHA-1) secrets can reuse the maintained verifier — separator stripping, 64-bit counter, drift / replay semantics — instead of hand-rolling a parallel HOTP. SHA-1 is still refused on every generation path (compute / generate / uri), so new-enrollment posture stays SHA-512-only, and the flag is honored only by verify(); each such verification emits an auth.totp.legacy_sha1_verify audit signal. · *b.middleware.requestId AsyncLocalStorage scoping* — b.middleware.requestId({ asyncContext: true }) binds the request id into the framework's AsyncLocalStorage scope so b.log.getRequestId() (and every b.log.create-built logger) returns it inside awaited route-handler code, not just on req.requestId. Because the b.router dispatch model runs the route handler after the middleware returns, the binding uses AsyncLocalStorage.enterWith (which persists forward across the awaited chain) rather than a callback wrap that would close before the handler runs; each request runs in its own async context so the binding stays request-scoped. The underlying b.log.enterRequestId(id) is exposed for callers wiring their own middleware. · *b.backupManifest.signBytes / verifyBytes — schema-agnostic signing* — b.backupManifest.signBytes(canonicalBytes) and verifyBytes(canonicalBytes, signatureBlock, { expectedFingerprint }) sign and verify a consumer's own canonical bytes with the same audit-sign keypair and fingerprint pinning as the v1-manifest sign() / verifySignature(), without adopting the framework's manifest schema — so a bespoke backup-header format can authenticate itself against the framework signing key. Fingerprint pinning is bound to the key the signature actually verifies under: the pin is checked against the fingerprint recomputed from the signature block's own public key (the new b.auditSign.fingerprintOf(publicKeyPem) helper), not the block's self-asserted fingerprint field, so a block can't claim a trusted fingerprint while being signed by a different key. The same binding now also applies to the v1-manifest verifySignature(). b.auditSign.verify additionally no longer requires init() when given an explicit public key, so a downstream verifier that holds only a trusted public key can check a detached signature. · *b.fsm side-effect-free transition resolution + deferrable audit* — instance.target(event) resolves a transition's destination state side-effect-free — the same edge and guard check as can() but returning the to-state (or null when the edge is illegal or guard-refused) — so a consumer composing an external compare-and-swap (b.sql.guardedUpdate on an autocommit-only substrate) can build the SET status = <to> claim without calling transition(), which would mutate state and emit an audit before the cross-instance claim is known to land. transition(event, { audit: false }) suppresses the built-in emit so that composition can emit its own enriched record once the claim resolves. · *b.codepointClass — the codepoint-threat catalog on the public surface* — The Unicode bidi-override / C0-control / zero-width / null-byte / Unicode-Tags tables and the UTS #39 confusable-script detector that the b.guard* family composes internally are now exposed as b.codepointClass, so a consumer can build a custom unconstrained-free-text screen — detectCharThreats / assertNoCharThreats / applyCharStripPolicies / scriptFor / detectMixedScripts plus the compiled regexes — without re-rolling the regexes (where the zero-width class is mistyped and the astral Unicode-Tags block forgotten) or coupling to an internal module path. For a ready-made free-text guard, b.guardText remains the first-class entry point. **Fixed:** *An FSM transition committed its state change without an audit record when the entry hook threw* — b.fsm commits the new state and pushes the history entry before running the destination state's onEnter hook. When that hook threw, control skipped the audit emission entirely — leaving a committed state transition with no audit record, a compliance gap for any regime that requires state changes to be auditable. The transition audit now always fires even when onEnter throws: it records the committed transition stamped with a failure outcome and the hook error, and still re-raises the error to the caller (the documented contract that an onEnter throw surfaces so the operator can roll back is unchanged).
12
+
11
13
  - v0.15.20 (2026-06-24) — **Vendored SBOM version fields are derived from the bundle so they cannot drift, and the Public Suffix List + @simplewebauthn/server bundles are refreshed.** The vendor manifest recorded each bundled package's version in two scanner-facing places that were hand-maintained and could drift from the code actually shipped — the structured components[].version (the CycloneDX component versions) and the cpe string. Two had drifted: peculiar-pki's @peculiar/x509 component read 1.13.0 while the bundle shipped 2.0.0, and @noble/curves' cpe read 0.0.0 while the bundle shipped 2.2.0. A CVE scanner (Trivy / Grype / a CycloneDX export, or a consumer mirroring the manifest into its own SBOM) keys on those structured fields, so an advisory was matched against the wrong version — a false negative on a real fix or a false positive on a patched one. Both fields are corrected, the vendor-bundle script now derives them from the actually-installed package versions at bundle time so they cannot drift again, and a manifest gate fails the build if they ever disagree. Separately, the vendored Mozilla Public Suffix List is refreshed to the current upstream revision and @simplewebauthn/server is refreshed to 13.3.2. **Changed:** *Vendored Public Suffix List and @simplewebauthn/server refreshed* — The vendored Mozilla Public Suffix List is refreshed to the current upstream revision (used by b.publicSuffix for DMARC / BIMI / cookie-scope / same-site domain classification). @simplewebauthn/server is refreshed from 13.3.1 to 13.3.2, which improves WebAuthn attestation certificate-path validation; the published-tarball diff was reviewed (no install scripts, no network/eval, a self-contained code change) before re-vendoring. **Fixed:** *Vendored SBOM version metadata is bundle-derived, not hand-maintained* — lib/vendor/MANIFEST.json recorded each package's version in two places a CVE/SBOM scanner reads — the structured components[].version sub-object and the cpe string — both hand-maintained alongside the human version string, so they could (and did) drift from the bundled code. @peculiar/x509's component version read 1.13.0 while the bundle shipped 2.0.0; @noble/curves' cpe read 0.0.0 while the bundle shipped 2.2.0. Either drift makes a scanner match advisories against the wrong version. Both are corrected to the shipped versions, and the durable fix is structural: scripts/vendor-update.sh now writes both the structured component versions and the cpe version from the ACTUALLY-INSTALLED package versions captured at bundle time, so a maintainer can no longer update one field and forget the other. A smoke-time manifest gate additionally fails the build if any component or cpe version disagrees with the package version, catching a manual drift before it ships.
12
14
 
13
15
  - v0.15.19 (2026-06-22) — **Restores the wiki container build by keeping its base image on the continuously-patched rolling tag.** A follow-up to 0.15.18. The framework code is unchanged from 0.15.18 — this patch reverts one part of that release's supply-chain pass: the example wiki container's Chainguard base images had been pinned to specific digests, but Chainguard rebuilds those images continuously to ship CVE fixes, so the frozen digest fell behind an upstream fix within hours and the container's Trivy CRITICAL/HIGH release gate rejected the build (a fixed npm/undici denial-of-service the rolling tag already carried). For a deployed, Trivy-gated image, tracking the rolling, always-patched tag is the correct posture; the wiki Dockerfile is back on it. The ClusterFuzzLite fuzz base (not deployed, not release-gated) stays digest-pinned with Dependabot keeping it current. **Fixed:** *Wiki container builds again on a continuously-patched base* — 0.15.18 digest-pinned the example wiki container's Chainguard base images (runtime + builder). Because Chainguard rebuilds those images continuously to ship CVE fixes, the pinned digest went stale within hours and the container's Trivy CRITICAL/HIGH release gate rejected the build over an already-fixed npm/undici DoS (CVE-2026-12151) the rolling tag carried. The wiki Dockerfile tracks the rolling tag again, so the deployed image is always CVE-current and the build passes the release gate. To keep the Trivy-scanned image identical to the multi-arch image that is published (they are built separately), the release workflow resolves the rolling tag to a digest once at build time and feeds it to both builds via build-args — scan-equals-publish without a committed pin that goes stale. (This trades the OSSF Scorecard PinnedDependencies signal for CVE currency on a deployed, gate-scanned image — the right call here; the non-deployed ClusterFuzzLite fuzz base remains digest-pinned with Dependabot bumping it.) The framework package itself is identical to 0.15.18.
package/index.js CHANGED
@@ -124,6 +124,7 @@ var handlers = require("./lib/handlers");
124
124
  var safeSql = require("./lib/safe-sql");
125
125
  var sql = require("./lib/sql");
126
126
  var chainWriter = require("./lib/chain-writer");
127
+ var codepointClass = require("./lib/codepoint-class");
127
128
  var safeBuffer = require("./lib/safe-buffer");
128
129
  var safeDecompress = require("./lib/safe-decompress").safeDecompress;
129
130
  var safeMountInfo = require("./lib/safe-mount-info");
@@ -530,6 +531,7 @@ module.exports = {
530
531
  safeSql: safeSql,
531
532
  sql: sql,
532
533
  chainWriter: chainWriter,
534
+ codepointClass: codepointClass,
533
535
  safeBuffer: safeBuffer,
534
536
  safeDecompress: safeDecompress,
535
537
  safeMountInfo: safeMountInfo,
package/lib/audit-sign.js CHANGED
@@ -498,13 +498,45 @@ function sign(payload) {
498
498
  * // → true (when payload + signature were produced under that key)
499
499
  */
500
500
  function verify(payload, signature, publicKeyPem) {
501
- _requireInit();
501
+ // Only the DEFAULT-key path needs an initialized keypair; when the caller
502
+ // supplies an explicit publicKeyPem, verification needs no private material
503
+ // or in-memory key state. Gating init on the absence of publicKeyPem lets a
504
+ // verifier-only process (one that holds a trusted public key but never ran
505
+ // init()) verify a detached signature — the path b.backupManifest.verifyBytes
506
+ // relies on for downstream verifiers.
507
+ if (!publicKeyPem) _requireInit();
502
508
  var buf = Buffer.isBuffer(payload) ? payload : Buffer.from(String(payload), "utf8");
503
509
  var sigBuf = Buffer.isBuffer(signature) ? signature : Buffer.from(signature);
504
510
  var pub = publicKeyPem || keys.publicKey;
505
511
  return nodeCrypto.verify(null, buf, pub, sigBuf);
506
512
  }
507
513
 
514
+ /**
515
+ * @primitive b.auditSign.fingerprintOf
516
+ * @signature b.auditSign.fingerprintOf(publicKeyPem)
517
+ * @since 0.15.21
518
+ * @status stable
519
+ * @related b.auditSign.getPublicKeyFingerprint, b.auditSign.verify
520
+ *
521
+ * Compute the SHA3-512 fingerprint (lowercase hex) of a SPKI-PEM public key —
522
+ * the same derivation `getPublicKeyFingerprint()` returns for the active key,
523
+ * but for any supplied key and WITHOUT requiring `init()`. A verifier pins a
524
+ * trusted fingerprint and checks it against `fingerprintOf(block.publicKey)`
525
+ * before trusting a detached signature block, so an attacker can't substitute
526
+ * their own key while claiming the trusted fingerprint.
527
+ *
528
+ * @example
529
+ * var fp = b.auditSign.fingerprintOf(block.publicKey);
530
+ * if (fp !== trustedFingerprint) throw new Error("untrusted signing key");
531
+ */
532
+ function fingerprintOf(publicKeyPem) {
533
+ if (typeof publicKeyPem !== "string" || publicKeyPem.length === 0) {
534
+ throw new AuditSignError("audit-sign/bad-public-key",
535
+ "fingerprintOf: publicKeyPem must be a non-empty PEM string");
536
+ }
537
+ return _computeFingerprint(publicKeyPem);
538
+ }
539
+
508
540
  /**
509
541
  * @primitive b.auditSign.getPublicKey
510
542
  * @signature b.auditSign.getPublicKey()
@@ -1073,6 +1105,7 @@ module.exports = {
1073
1105
  getPublicKey: getPublicKey,
1074
1106
  getPublicKeyFingerprint: getPublicKeyFingerprint,
1075
1107
  getPublicKeyByFingerprint: getPublicKeyByFingerprint,
1108
+ fingerprintOf: fingerprintOf,
1076
1109
  getMode: getMode,
1077
1110
  getAlgorithm: getAlgorithm,
1078
1111
  DEFAULT_SIGNING_ALG: DEFAULT_SIGNING_ALG,
@@ -314,47 +314,39 @@ function serialize(manifest) {
314
314
  // or SLH-DSA-SHAKE-256f — whichever audit-sign was initialized with).
315
315
  // The signature covers the manifest's canonical bytes WITHOUT the
316
316
  // signature field; appending it does not change the signed payload.
317
- function sign(manifest) {
318
- var v = validate(manifest);
319
- if (!v.ok) {
320
- throw new BackupManifestError("backup-manifest/invalid",
321
- "sign: " + v.errors.join("; "));
322
- }
317
+ // Sign caller-supplied canonical bytes with the audit-sign keypair, returning a
318
+ // detached signature block. Shared by sign() (v1 manifest schema) and
319
+ // signBytes() (schema-agnostic). `payload` is a Buffer (signed verbatim) or a
320
+ // string (signed as UTF-8) — auditSign.sign normalizes both identically.
321
+ function _signPayload(payload, who) {
323
322
  var signer = auditSign();
324
323
  if (!signer || typeof signer.sign !== "function") {
325
324
  throw new BackupManifestError("backup-manifest/no-signer",
326
- "sign: audit-sign module is not available; call b.auditSign.init() first");
325
+ who + ": audit-sign module is not available; call b.auditSign.init() first");
327
326
  }
328
- var payload = signingPayload(manifest);
329
327
  var signatureBytes;
330
328
  try { signatureBytes = signer.sign(payload); }
331
329
  catch (e) {
332
330
  throw new BackupManifestError("backup-manifest/sign-failed",
333
- "sign: audit-sign.sign threw: " + ((e && e.message) || String(e)));
331
+ who + ": audit-sign.sign threw: " + ((e && e.message) || String(e)));
334
332
  }
335
- manifest.signature = {
333
+ return {
336
334
  algorithm: signer.getAlgorithm(),
337
335
  publicKey: signer.getPublicKey(),
338
336
  fingerprint: signer.getPublicKeyFingerprint(),
339
337
  value: signatureBytes.toString("base64"),
340
338
  signedAt: new Date().toISOString(),
341
339
  };
342
- return manifest;
343
340
  }
344
341
 
345
- // Verify a previously-signed manifest. Returns { ok, reason?,
346
- // fingerprint? }. Caller policy decides whether a missing or
347
- // fingerprint-mismatched signature is fatal — verifyManifestSignature
348
- // in lib/backup/index.js wraps this with operator-facing semantics.
349
- function verifySignature(manifest, opts) {
342
+ // Verify `payload` against a detached signature block (the shape _signPayload
343
+ // returns). Shared by verifySignature() and verifyBytes(). Returns
344
+ // { ok, reason?, fingerprint? }; enforces opts.expectedFingerprint pinning.
345
+ function _verifyPayloadAgainstBlock(payload, sig, opts) {
350
346
  opts = opts || {};
351
- if (!manifest || typeof manifest !== "object") {
352
- return { ok: false, reason: "manifest must be an object" };
353
- }
354
- if (!manifest.signature || typeof manifest.signature !== "object") {
355
- return { ok: false, reason: "manifest has no signature block" };
347
+ if (!sig || typeof sig !== "object") {
348
+ return { ok: false, reason: "signature block must be an object" };
356
349
  }
357
- var sig = manifest.signature;
358
350
  if (typeof sig.algorithm !== "string" || sig.algorithm.length === 0) {
359
351
  return { ok: false, reason: "signature.algorithm is required" };
360
352
  }
@@ -364,28 +356,50 @@ function verifySignature(manifest, opts) {
364
356
  if (typeof sig.value !== "string" || sig.value.length === 0) {
365
357
  return { ok: false, reason: "signature.value is required" };
366
358
  }
367
- // Caller may pin the expected fingerprint operators tracking key
368
- // rotation pass the active audit-sign fingerprint and refuse any
369
- // bundle signed under a different historical key.
370
- if (typeof opts.expectedFingerprint === "string" &&
371
- opts.expectedFingerprint.length > 0 &&
372
- sig.fingerprint !== opts.expectedFingerprint) {
373
- return {
374
- ok: false,
375
- reason: "signature.fingerprint=" + sig.fingerprint +
376
- " does not match expectedFingerprint=" + opts.expectedFingerprint,
377
- fingerprint: sig.fingerprint,
378
- };
359
+ // The fingerprint of the key the signature is ACTUALLY verified under —
360
+ // recomputed from sig.publicKey, never the block's self-asserted
361
+ // sig.fingerprint (which an attacker controls). Used for the pin check below
362
+ // and returned on success so the caller sees the real key, not a claim.
363
+ var derivedFingerprint = null;
364
+ try {
365
+ var signerForFp = auditSign();
366
+ if (signerForFp && typeof signerForFp.fingerprintOf === "function") {
367
+ derivedFingerprint = signerForFp.fingerprintOf(sig.publicKey);
368
+ }
369
+ } catch (fpErr) {
370
+ return { ok: false, reason: "could not derive fingerprint from publicKey: " +
371
+ ((fpErr && fpErr.message) || String(fpErr)) };
372
+ }
373
+ // Caller may pin the expected fingerprint — operators tracking key rotation
374
+ // pass the active audit-sign fingerprint and refuse any payload signed under
375
+ // a different historical key. CRITICAL: the pin is checked against the
376
+ // fingerprint RECOMPUTED from the block's own publicKey, NOT the block's
377
+ // self-asserted `fingerprint` field. An attacker can sign arbitrary bytes
378
+ // with their own key and set `fingerprint` to the trusted value; binding the
379
+ // pin to fingerprintOf(publicKey) — the key the signature is actually
380
+ // verified under — closes that substitution (the signature can only verify
381
+ // under publicKey, and publicKey must hash to the trusted fingerprint).
382
+ if (typeof opts.expectedFingerprint === "string" && opts.expectedFingerprint.length > 0) {
383
+ if (derivedFingerprint === null) {
384
+ return { ok: false, reason: "fingerprint pinning requires audit-sign.fingerprintOf (unavailable)" };
385
+ }
386
+ if (derivedFingerprint !== opts.expectedFingerprint) {
387
+ return {
388
+ ok: false,
389
+ reason: "publicKey fingerprint=" + derivedFingerprint +
390
+ " does not match expectedFingerprint=" + opts.expectedFingerprint,
391
+ fingerprint: derivedFingerprint,
392
+ };
393
+ }
379
394
  }
380
- var payload = signingPayload(manifest);
381
395
  var sigBuf;
382
396
  try { sigBuf = Buffer.from(sig.value, "base64"); }
383
397
  catch (_e) {
384
398
  return { ok: false, reason: "signature.value is not valid base64" };
385
399
  }
386
- // Use audit-sign.verify when available (handles algorithm dispatch
387
- // identically to the signer); fall back to nodeCrypto.verify for
388
- // verifier processes that don't init audit-sign.
400
+ // Use audit-sign.verify when available (handles algorithm dispatch identically
401
+ // to the signer); fall back to nodeCrypto.verify for verifier processes that
402
+ // don't init audit-sign.
389
403
  var ok;
390
404
  try {
391
405
  var signer = auditSign();
@@ -393,23 +407,154 @@ function verifySignature(manifest, opts) {
393
407
  ok = signer.verify(payload, sigBuf, sig.publicKey);
394
408
  } else {
395
409
  ok = require("node:crypto").verify(null,
396
- Buffer.from(payload, "utf8"), sig.publicKey, sigBuf);
410
+ Buffer.isBuffer(payload) ? payload : Buffer.from(payload, "utf8"), sig.publicKey, sigBuf);
397
411
  }
398
412
  } catch (e) {
399
413
  return {
400
- ok: false,
401
- reason: "verify threw: " + ((e && e.message) || String(e)),
402
- fingerprint: sig.fingerprint,
414
+ ok: false,
415
+ reason: "verify threw: " + ((e && e.message) || String(e)),
416
+ fingerprint: sig.fingerprint,
403
417
  };
404
418
  }
405
419
  if (!ok) {
406
420
  return {
407
421
  ok: false,
408
422
  reason: "signature did not verify under provided publicKey",
409
- fingerprint: sig.fingerprint,
423
+ fingerprint: derivedFingerprint || sig.fingerprint,
410
424
  };
411
425
  }
412
- return { ok: true, fingerprint: sig.fingerprint };
426
+ // Return the fingerprint of the key the signature actually verified under
427
+ // (recomputed), not the block's self-asserted value.
428
+ return { ok: true, fingerprint: derivedFingerprint || sig.fingerprint };
429
+ }
430
+
431
+ /**
432
+ * @primitive b.backupManifest.sign
433
+ * @signature b.backupManifest.sign(manifest)
434
+ * @since 0.6.0
435
+ * @status stable
436
+ * @related b.backupManifest.verifySignature, b.backupManifest.signBytes, b.auditSign.sign
437
+ *
438
+ * Sign a v1 backup manifest in place with the audit-sign keypair, attaching a
439
+ * detached `signature` block over the manifest's canonical bytes (the
440
+ * serialization WITHOUT the signature field, so appending it doesn't change
441
+ * the signed payload). Validates the manifest against the v1 schema first;
442
+ * throws `backup-manifest/invalid` on a malformed manifest and
443
+ * `backup-manifest/no-signer` when `b.auditSign.init()` hasn't run. For a
444
+ * schema-agnostic alternative see `signBytes`.
445
+ *
446
+ * @example
447
+ * b.backupManifest.sign(manifest);
448
+ * manifest.signature.fingerprint; // the signing key's fingerprint
449
+ */
450
+ function sign(manifest) {
451
+ var v = validate(manifest);
452
+ if (!v.ok) {
453
+ throw new BackupManifestError("backup-manifest/invalid",
454
+ "sign: " + v.errors.join("; "));
455
+ }
456
+ manifest.signature = _signPayload(signingPayload(manifest), "sign");
457
+ return manifest;
458
+ }
459
+
460
+ /**
461
+ * @primitive b.backupManifest.signBytes
462
+ * @signature b.backupManifest.signBytes(canonicalBytes)
463
+ * @since 0.15.21
464
+ * @status stable
465
+ * @related b.backupManifest.verifyBytes, b.backupManifest.sign, b.auditSign.sign
466
+ *
467
+ * Sign caller-supplied canonical bytes with the framework's audit-sign keypair,
468
+ * returning a detached signature block — the schema-agnostic counterpart of
469
+ * `sign()`. Where `sign()` is bound to the v1 manifest schema (it `validate()`s
470
+ * the whole `{ version, framework, files[] }` shape before signing), this signs
471
+ * any bytes a consumer canonicalizes itself, so a bespoke backup-header /
472
+ * manifest format reuses the same post-quantum signing keypair + fingerprint
473
+ * pinning without adopting the framework schema.
474
+ *
475
+ * `canonicalBytes` is a Buffer (signed verbatim) or a string (signed as UTF-8).
476
+ * Returns `{ algorithm, publicKey, fingerprint, value, signedAt }`. Requires
477
+ * `b.auditSign.init()` (throws `backup-manifest/no-signer` otherwise).
478
+ *
479
+ * @example
480
+ * var sig = b.backupManifest.signBytes(myCanonicalHeaderBuffer);
481
+ * // store sig alongside the header; later:
482
+ * var v = b.backupManifest.verifyBytes(myCanonicalHeaderBuffer, sig,
483
+ * { expectedFingerprint: b.auditSign.getPublicKeyFingerprint() });
484
+ * // v.ok === true
485
+ */
486
+ function signBytes(canonicalBytes) {
487
+ if (typeof canonicalBytes !== "string" && !Buffer.isBuffer(canonicalBytes)) {
488
+ throw new BackupManifestError("backup-manifest/bad-input",
489
+ "signBytes: canonicalBytes must be a string or Buffer");
490
+ }
491
+ return _signPayload(canonicalBytes, "signBytes");
492
+ }
493
+
494
+ // Verify a previously-signed manifest. Returns { ok, reason?,
495
+ // fingerprint? }. Caller policy decides whether a missing or
496
+ // fingerprint-mismatched signature is fatal — verifyManifestSignature
497
+ // in lib/backup/index.js wraps this with operator-facing semantics.
498
+ /**
499
+ * @primitive b.backupManifest.verifySignature
500
+ * @signature b.backupManifest.verifySignature(manifest, opts)
501
+ * @since 0.6.0
502
+ * @status stable
503
+ * @related b.backupManifest.sign, b.backupManifest.verifyBytes
504
+ *
505
+ * Verify a signed v1 backup manifest's detached `signature` block over its
506
+ * canonical bytes. Returns `{ ok, reason?, fingerprint? }` — never throws — so
507
+ * a caller decides whether a missing / mismatched signature is fatal. Pass
508
+ * `opts.expectedFingerprint` to pin the active audit-sign key and refuse a
509
+ * manifest signed under a different (rotated) key. For a schema-agnostic
510
+ * alternative see `verifyBytes`.
511
+ *
512
+ * @opts
513
+ * expectedFingerprint: string, // refuse a manifest whose fingerprint differs
514
+ *
515
+ * @example
516
+ * var v = b.backupManifest.verifySignature(manifest,
517
+ * { expectedFingerprint: b.auditSign.getPublicKeyFingerprint() });
518
+ * if (!v.ok) throw new Error("untrusted backup: " + v.reason);
519
+ */
520
+ function verifySignature(manifest, opts) {
521
+ if (!manifest || typeof manifest !== "object") {
522
+ return { ok: false, reason: "manifest must be an object" };
523
+ }
524
+ if (!manifest.signature || typeof manifest.signature !== "object") {
525
+ return { ok: false, reason: "manifest has no signature block" };
526
+ }
527
+ return _verifyPayloadAgainstBlock(signingPayload(manifest), manifest.signature, opts);
528
+ }
529
+
530
+ /**
531
+ * @primitive b.backupManifest.verifyBytes
532
+ * @signature b.backupManifest.verifyBytes(canonicalBytes, signatureBlock, opts?)
533
+ * @since 0.15.21
534
+ * @status stable
535
+ * @related b.backupManifest.signBytes, b.backupManifest.verifySignature
536
+ *
537
+ * Verify caller-supplied canonical bytes against a detached signature block
538
+ * produced by `signBytes()` — the schema-agnostic counterpart of
539
+ * `verifySignature()`. Returns `{ ok, reason?, fingerprint? }`. Pass
540
+ * `opts.expectedFingerprint` to pin the active audit-sign key and refuse a
541
+ * block signed under a different (rotated / historical) key. Falls back to
542
+ * `node:crypto.verify` when audit-sign isn't initialized, so a downstream
543
+ * verifier process can check a signature without holding the signing key.
544
+ *
545
+ * @opts
546
+ * expectedFingerprint: string, // refuse a block whose fingerprint differs
547
+ *
548
+ * @example
549
+ * var v = b.backupManifest.verifyBytes(headerBytes, sigBlock,
550
+ * { expectedFingerprint: trustedFingerprint });
551
+ * if (!v.ok) throw new Error("untrusted header: " + v.reason);
552
+ */
553
+ function verifyBytes(canonicalBytes, signatureBlock, opts) {
554
+ if (typeof canonicalBytes !== "string" && !Buffer.isBuffer(canonicalBytes)) {
555
+ return { ok: false, reason: "verifyBytes: canonicalBytes must be a string or Buffer" };
556
+ }
557
+ return _verifyPayloadAgainstBlock(canonicalBytes, signatureBlock, opts);
413
558
  }
414
559
 
415
560
  function parse(jsonStr) {
@@ -440,8 +585,10 @@ module.exports = {
440
585
  serialize: serialize,
441
586
  parse: parse,
442
587
  sign: sign,
588
+ signBytes: signBytes,
443
589
  signingPayload: signingPayload,
444
590
  verifySignature: verifySignature,
591
+ verifyBytes: verifyBytes,
445
592
  FORMAT_VERSION: FORMAT_VERSION,
446
593
  FRAMEWORK_NAME: FRAMEWORK_NAME,
447
594
  VALID_KINDS: VALID_KINDS,