@blamejs/core 0.12.10 → 0.12.12

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,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.12.x
10
10
 
11
+ - v0.12.12 (2026-05-23) — **`b.ai.disclosure.chatbot` + `b.ai.disclosure.deepfake` + `b.ai.disclosure.emotion` — EU AI Act Art. 50 transparency obligations (calendar-locked 2026-08-02) with US-CA AB-853 + China CAC GenAI cross-walk.** EU AI Act Art. 50 transparency primitives land ahead of the 2026-08-02 enforcement deadline. `b.ai.disclosure.chatbot(session, opts)` emits the Art. 50(1) first-contact "you are interacting with an AI system" disclosure with placement control (`first-message` / `always` / `on-request`). `b.ai.disclosure.deepfake(content, { contentType, placement, jurisdiction })` emits the Art. 50(4) synthetic-content label + machine-readable metadata payload for image / audio / video / text. `b.ai.disclosure.emotion({ systemType })` emits the Art. 50(3) emotion-recognition / biometric-categorisation notice. Each primitive emits a tamper-evident `ai-act/*-disclosure-applied` audit event so the compliance trail backs the user-facing notice. Cross-jurisdiction cross-walk lives in `opts.jurisdiction`: `"eu"` (default), `"us-ca"` adds AB-853 §22949.91 to the cross-walk array, `"cn"` adds CAC GenAI Measures Art. 12. The deepfake primitive returns a `schema: "c2pa-v1.4-ready"` metadata field that the v0.12.21 `b.contentCredentials` C2PA adapter will consume when it lands — this patch ships the label markup + schema; the C2PA manifest emission is the next composition. **Added:** *`b.ai.disclosure.chatbot(session, opts)` — Art. 50(1) first-contact disclosure* — Operators interacting with natural persons via an AI system get a primitive that emits the "you are interacting with an AI system" notice + audits the emission. `placement` opts: `"first-message"` (default — emit on first contact only, tracked via `session.aiDisclosureEmitted`), `"always"` (every response), `"on-request"` (operator wires their own trigger). Returns `{ text, language, jurisdiction, placement, shouldEmit, article, regulation }` — `shouldEmit` is the operator-consumable boolean for response-wire-up logic. · *`b.ai.disclosure.deepfake(content, opts)` — Art. 50(4) synthetic-content label* — Operators emitting model-generated or model-manipulated content get a primitive that returns both the visible label markup AND the machine-readable metadata payload. `contentType: "image" | "audio" | "video" | "text"` is required; `placement: "label" | "metadata" | "both"` (default `"both"`) controls what the primitive populates. The metadata payload includes `schema: "c2pa-v1.4-ready"` — the v0.12.21 `b.contentCredentials` C2PA adapter will consume this schema field when it lands. `crossWalk` array carries `["eu-ai-act/Art. 50(4)"]` plus the per-jurisdiction reference (AB-853 §22949.91 / CAC GenAI Art. 12). · *`b.ai.disclosure.emotion(opts)` — Art. 50(3) emotion-recognition / biometric-categorisation notice* — Operators deploying emotion-recognition or biometric-categorisation systems get the consent-flow notice primitive. `systemType: "emotion" | "biometric-categorisation"` (default `"emotion"`) selects which Art. 50(3) sub-obligation applies. Returns the notice payload + emits an `ai-act/emotion-disclosure-applied` audit event. · *Cross-jurisdiction cross-walk: EU + US-CA + China in a single primitive* — The `opts.jurisdiction` opt accepts `"eu"` (default — Regulation (EU) 2024/1689), `"us-ca"` (California AB-853 effective 2026), or `"cn"` (China CAC GenAI Measures). The chatbot + deepfake primitives both honour the cross-walk: the deepfake response's `crossWalk` array carries every jurisdiction-specific legal reference the same emission satisfies, so operators serving multi-region traffic emit one notice + audit one event + reference all applicable regimes. **Security:** *Drop-silent audit emission preserves the disclosure path under audit-bus failure* — If `opts.audit` is supplied but its `safeEmit` throws (network bus down, audit-sign chain malformed), the disclosure primitive still returns the user-facing notice payload. The Art. 50 obligation is the user-facing notice itself; the audit emission is a parallel best-effort chain-of-custody record. Refusing the disclosure to defend the audit chain would fail the wrong direction — the regulatory contract is satisfied by emitting the notice. Matches the framework's `audit.safeEmit` drop-silent contract for hot-path observability sinks. **Migration:** *C2PA manifest emission lands in v0.12.21* — The deepfake primitive's metadata payload includes a `schema: "c2pa-v1.4-ready"` field that the v0.12.21 `b.contentCredentials` adapter will consume. Operators emitting image / audio / video for v0.12.12-0.12.20 get the label markup + structured metadata; the actual C2PA manifest (signed JUMBF assertion chain) is the next composition layer.
12
+
13
+ - v0.12.11 (2026-05-23) — **`b.archive.wrapWithPassphrase` + `b.archive.unwrapWithPassphrase` — Argon2id + XChaCha20-Poly1305 archive envelope + `b.backup` `cryptoStrategy: "passphrase"` with HIPAA / PCI-DSS 128-bit entropy floor.** Passphrase wrap lands as the second `b.archive` envelope strategy alongside v0.12.10's recipient wrap. `b.archive.wrapWithPassphrase(bytes, { passphrase, minEntropyBits })` produces a `BAWPP`-prefixed envelope under Argon2id (RFC 9106; framework-default 64 MiB / 3 iterations / 4 parallelism) key derivation with XChaCha20-Poly1305 AEAD; each envelope carries its own fresh salt in the wire format (5-byte magic + 1-byte version + 1-byte saltLen + salt + 24-byte nonce + ciphertext+tag) so KDF parameters can rotate in future minors without per-envelope version bumps. `b.archive.unwrapWithPassphrase(sealed, { passphrase })` verifies the `BAWPP` header before any Argon2id compute so non-envelope inputs fail with `archive-wrap/bad-magic` rather than burning the KDF on bad bytes. `b.backup.bundleAdapterStorage({ cryptoStrategy: "passphrase", passphrase })` composes the wrap layer transparently — bundle bytes hitting the adapter's `writeFile` are an opaque passphrase-derived envelope. Default `passphraseMinEntropyBits: 80` matches OWASP strong-password guidance; HIPAA + PCI-DSS postures raise the floor to 128 bits automatically (matching the framework's existing crypto-grade-password discipline for sealed-storage). The recipient strategy from v0.12.10 + passphrase strategy from v0.12.11 + plaintext strategy from v0.12.7 cover the operator's posture matrix: HIPAA / PCI-DSS pick recipient or passphrase; non-regulated deployments may stay on `"none"` when the storage layer is itself the protective boundary. **Added:** *`b.archive.wrapWithPassphrase(bytes, { passphrase, minEntropyBits })` — Argon2id-derived archive envelope* — Composes `b.backupCrypto.encryptWithFreshSalt(bytes, passphrase)` (Argon2id KDF + XChaCha20-Poly1305 AEAD, fresh per-envelope salt) and prepends a 7-byte `BAWPP` envelope header (5-byte magic + 1-byte version + 1-byte saltLen) so format sniffers can identify passphrase wrap output without trial KDF work. Entropy estimate uses observed-alphabet bit-count (the standard NIST/OWASP character-class approximation). `minEntropyBits` defaults to 80; the gate refuses upfront with `archive-wrap/weak-passphrase` when the estimate falls short. · *`b.archive.unwrapWithPassphrase(sealed, { passphrase })` — inverse with magic-check upfront* — Verifies the 7-byte `BAWPP` header (magic + version + saltLen) before any cryptographic work so non-envelope inputs (raw archives, recipient-wrap envelopes, truncated buffers) fail with `archive-wrap/bad-magic` / `archive-wrap/bad-version` / `archive-wrap/truncated-envelope` rather than wasting Argon2id compute. Routes through `b.backupCrypto.decryptWithPassphrase(encrypted, passphrase, saltHex)` so the framework's locked Argon2id parameters apply. · *`b.backup.bundleAdapterStorage({ cryptoStrategy: "passphrase", passphrase })` — Argon2id-keyed bundle storage* — Composes `b.archive.wrapWithPassphrase` transparently — every `writeBundle` payload is wrapped before `adapter.writeFile`; every `readBundle` payload is unwrapped after `adapter.readFile`. The `passphraseMinEntropyBits` opt defaults to 80 (OWASP strong-password floor); HIPAA + PCI-DSS postures raise the floor to 128 bits automatically. Passphrase + directory format combination refused upfront (same contract as recipient + directory). Wire-format envelope on disk is opaque ciphertext — no information leakage about archive contents through the storage adapter. · *HIPAA + PCI-DSS postures raise entropy floor to 128 bits under passphrase strategy* — `bundleAdapterStorage({ posture: "hipaa", cryptoStrategy: "passphrase", passphrase })` enforces `passphraseMinEntropyBits >= 128` regardless of the operator-supplied opt. The 128-bit floor matches the framework's existing crypto-grade-password discipline for sealed-storage cells. Operators sourcing passphrases from a CSPRNG (`b.crypto.generateBytes(16).toString("base64url")` → ~128 bits) pass without issue; operators typing dictionary phrases trip the gate. **Security:** *Magic-check before KDF work — non-envelope inputs can't burn Argon2id compute* — Adversarial inputs that look like passphrase envelopes but aren't (random bytes, recipient envelopes, raw archives) fail at byte 0-4 (magic check) rather than after a 64 MiB Argon2id round. Operators handing user-supplied bundles to readBundle on a server with concurrent load get bounded refusal latency rather than worst-case KDF compute under a chosen-bytes attack.
14
+
11
15
  - v0.12.10 (2026-05-23) — **`b.archive.wrap` + `b.archive.unwrap` — recipient-encrypted archive envelopes (Flavor 1) + `b.backup` `cryptoStrategy: "recipient"` + HIPAA/PCI-DSS posture refusal.** Flavor 1 lands as the whole-archive recipient-wrap substrate. `b.archive.wrap(bytes, { recipient })` produces a sealed envelope under the framework's hybrid PQC seal (ML-KEM-1024 + P-384 ECDH + SHAKE256 + XChaCha20-Poly1305) prefixed with a 6-byte `BAWRP` archive-wrap header so format sniffers can identify wrap envelopes without trial decryption. `b.archive.unwrap(sealed, { recipient })` is the inverse with magic-check upfront so non-envelope inputs throw `archive-wrap/bad-magic` rather than a crypto-level error. Recipient strategies: static keypair (`{ publicKey, ecPublicKey }`) and peer-cert (`{ peerCertDer, peerKemPubkey }`); the tenant strategy lands in v0.12.11 alongside the backup-crypto refactor + per-tenant key resolution. `b.backup.bundleAdapterStorage({ cryptoStrategy: "recipient", recipient })` composes the wrap/unwrap layer transparently: the bytes hitting the adapter's `writeFile` are a `BAWRP`-prefixed envelope, never the raw tar / tar.gz / directory bundle. HIPAA + PCI-DSS postures refuse `cryptoStrategy: "none"` upfront with `backup/posture-requires-encryption` — the storage adapter cannot itself satisfy the encryption-at-rest requirement; the recipient envelope is the framework-side gate. Flavor 2 (per-entry ZIP wrap with the 0xBADC extra-field marker) and the backup-crypto refactor into `lib/_crypto-base.js` ship in v0.12.11. **Added:** *`b.archive.wrap(bytes, { recipient })` — recipient-encrypted archive envelope* — Composes `b.crypto.encrypt` (or `b.crypto.encryptEnvelopeAsCertPeer` for the peer-cert strategy) under the framework's hybrid PQC seal. The output is a Buffer carrying a 6-byte `BAWRP` archive-wrap header (5-byte magic + 1-byte version) followed by the base64-encoded envelope bytes. Recipient strategies: `{ publicKey, ecPublicKey }` for the static-keypair path (ML-KEM-1024 PEM + P-384 ECDH PEM); `{ peerCertDer, peerKemPubkey }` for the peer-cert path (extracts the P-384 half from the cert per `b.crypto.encryptEnvelopeAsCertPeer`). `"tenant"` returns `archive-wrap/tenant-strategy-deferred` upfront — that strategy lands in v0.12.11 with the per-tenant key resolution. · *`b.archive.unwrap(sealed, { recipient })` — inverse with upfront magic check* — Verifies the 6-byte `BAWRP` header before any cryptographic work so non-envelope inputs (raw archives, other-magic envelopes, truncated buffers) fail with `archive-wrap/bad-magic` / `archive-wrap/bad-version` rather than a downstream `crypto/*` error. Routes through `b.crypto.decrypt(envelope, recipient, { raw: true })` so binary archive payloads (gzip, ZIP, tar) round-trip losslessly — `raw: true` is the contract that preserves bytes vs the default utf-8 decoding. · *`b.backup.bundleAdapterStorage({ cryptoStrategy: "recipient", recipient })` — opt-in envelope storage* — `cryptoStrategy: "none"` (default, v0.12.7-9 behaviour) writes plaintext bundle bytes to the adapter — safe for storage layers that are themselves the protective boundary (S3 SSE, disk-encrypted hosts). `cryptoStrategy: "recipient"` requires `opts.recipient` and wraps every `writeBundle` payload through `b.archive.wrap` before `adapter.writeFile`; `readBundle` unwraps after `adapter.readFile`. The wrap layer sits OUTSIDE the gz / tar layers so the bundle on disk is opaque ciphertext under the operator-controlled recipient key. Passphrase strategy is deferred to v0.12.11 alongside the `_crypto-base.js` refactor. · *HIPAA + PCI-DSS posture refuses plaintext bundles* — `bundleAdapterStorage({ posture: "hipaa" })` (or `"pci-dss"`) refuses `cryptoStrategy: "none"` upfront with `backup/posture-requires-encryption` — adapter-storage's plaintext default cannot itself satisfy encryption-at-rest requirements. Operators under these postures pass `cryptoStrategy: "recipient"` + a recipient key. The refusal message includes the posture name + the strategy that fails so audit-trail operators see exactly which gate blocked the call. **Security:** *Wrap envelope is the framework's hybrid PQC seal — ML-KEM-1024 + P-384 ECDH + SHAKE256 + XChaCha20-Poly1305* — Defence-in-depth posture: a CRQC against ML-KEM-1024 alone still has to defeat the classical P-384 ECDH leg; a future ECDH break alone still has to defeat ML-KEM-1024. The 4-byte envelope header (magic + KEM ID + cipher ID + KDF ID) is bound as AEAD AAD so a header-substitution attack fails Poly1305 verification. `b.archive.wrap` prepends a separate 6-byte archive-wrap header BEFORE the base64 envelope so format sniffing can identify wrap output without trial decryption — non-envelope inputs are refused at byte 0-4 (magic check) instead of after fruitless decapsulation work. **Detectors:** *`backup-adapter-storage-without-posture-check` — postures that mandate encryption must propagate to `cryptoStrategy`* — When a primitive that wires `b.backup.bundleAdapterStorage` carries a `posture:` opt drawn from the HIPAA / PCI-DSS / etc. set, the same code path must propagate `cryptoStrategy: "recipient"` (or refuse before reaching writeBundle). The detector matches `bundleAdapterStorage({ ... posture: ... })` invocations in `lib/` and requires a matching `cryptoStrategy` opt; missing it surfaces during the codebase-patterns gate so a future caller can't silently drop the contract. **Migration:** *Flavor 2 — per-entry ZIP recipient wrap with 0xBADC extra-field* — Per-entry encryption inside the carrier ZIP (method=STORE with the encrypted bytes as the stored payload + a 0xBADC user-defined-range extra-field marker carrying the recipient hint). Inspect-without-decrypt is the operator value: entry list + name-safety gating happens BEFORE any key resolution. Lands in v0.12.11 alongside the backup-crypto refactor. · *`lib/_crypto-base.js` refactor — backup-crypto, Flavor 1, Flavor 2 share substrate* — The legacy per-file Argon2id + XChaCha20-Poly1305 path in `lib/backup/crypto.js` gets factored into a private `_crypto-base.js` helper so all three encryption flavors compose the same primitive set. No operator-visible API change; closes the each-feature-rolls-its-own-crypto smell. · *`cryptoStrategy: "passphrase"` + tenant strategy* — Passphrase strategy on `bundleAdapterStorage` (Argon2id-derived key + XChaCha20-Poly1305) and the `"tenant"` recipient string (composes `b.vault.derivedKey({ tenant, purpose: "archive-wrap" })`) both ship in v0.12.11. The v0.12.10 surface is the recipient substrate; v0.12.11 lights up the per-tenant + passphrase strategies that consume it.
12
16
 
13
17
  - v0.12.9 (2026-05-23) — **`b.archive.gz` + `b.archive.read.gz` — gzip composition with `b.safeDecompress` bomb caps + `b.backup` `tar.gz` bundle format + `sha-to-tag verify` fetches `origin/main`.** gzip lands as the composition layer over the archive family. `b.archive.gz(bytes)` produces an RFC 1952 gzip stream with the same `toBuffer()` / `toAdapter(adapter)` / `digest()` shape every archive builder ships, and `b.archive.read.gz(adapter, opts)` reads it back through `b.safeDecompress` so a malicious `tar.gz` fails the gzip-layer bomb cap (1 GiB output / 100× ratio defaults) before the tar walker ever sees a decompressed byte. The reader exposes `toBuffer()` / `asTar(opts)` / `asZip(opts)` so operators can hand the decompressed bytes directly to a downstream archive reader without a round-trip through disk. `b.archive.tar().toGzip(adapter, opts)` is the write-side convenience for the most common combination. `b.backup.bundleAdapterStorage({ format: "tar.gz" })` adds gzip compression on the wire — bundle sizes drop ~3-5× on text-heavy backups (databases, JSON exports, mail spools); the readback path detects the format from the storage key suffix and composes `b.safeDecompress` automatically. The `sha-to-tag verify` workflow now explicitly fetches `origin/main` before walking the first-parent history, fixing a stale-ref bug that silently failed v0.12.6 through v0.12.8 tag verifications (the publish workflow itself was unaffected; the gate is independent). **Added:** *`b.archive.gz(bytes)` — standalone gzip write builder* — RFC 1952 gzip envelope with the standard archive-builder shape. `toBuffer()` returns the compressed bytes; `toAdapter(adapter)` writes through any writable adapter (fs / object-store / http) that exposes `.write(bytes)` + optional `.close()`; `digest()` returns a SHA3-512 hex hash of the compressed payload for operator integrity logs. `opts.level` accepts 0-9 (zlib default 6). Composes cleanly under `b.archive.tar().toGzip(adapter)` / `b.archive.zip()` for tar.gz / zip.gz convenience. · *`b.archive.read.gz(adapter, opts)` — gunzip reader with `b.safeDecompress` bomb caps* — Every decompression routes through `b.safeDecompress({ algorithm: "gzip", maxOutputBytes, maxRatio })` so a hostile gzip stream fails the bomb gate before any downstream parsing happens. Defaults: `maxDecompressedBytes` = 1 GiB, `maxExpansionRatio` = 100×. The reader exposes three downstream entry points: `toBuffer()` returns the raw decompressed bytes; `asTar(opts)` returns a `b.archive.read.tar` reader over the decompressed payload; `asZip(opts)` returns a `b.archive.read.zip` reader. `fromGzip` is the documented alias the spec uses (operators may reach for either). Refuses non-gzip input upfront via the `0x1f 0x8b` magic check (`archive-gz/bad-magic`). · *`b.archive.tar().toGzip(adapter, opts)` — tar.gz write convenience* — Pipes the tar builder's `toBuffer()` through `b.archive.gz()` and writes the resulting gzip envelope to a writable adapter. Equivalent to `b.archive.gz(t.toBuffer()).toAdapter(adapter)` but lets the operator stay in the tar-builder fluent chain when composing under fs / object-store / http adapters. · *`b.backup.bundleAdapterStorage({ format: "tar.gz" })` — compressed-on-the-wire bundles* — Adds gzip compression to the v0.12.8 tar bundle format. Bundle sizes drop ~3-5× on text-heavy backups (databases, JSON exports, mail spools); binary-heavy backups (compressed databases, encrypted archives) see ~1.0-1.1×. Read paths auto-detect via the `<bundleId>/bundle.tar.gz` storage key suffix and route through `b.safeDecompress` on readback. The v0.12.8 `maxBundleBytes` cap continues to gate against pathological projected-uncompressed sizes; `tar.gz` does not bypass it. · *`b.safeArchive.extract({ format: "tar.gz" })` — explicit tar.gz dispatch* — Operators handed a `.tar.gz` upload pass `format: "tar.gz"` explicitly; the orchestrator composes `b.archive.read.gz` → `.asTar()` and feeds the standard tar bomb-policy + entry-type-policy + guardProfile through. Defer-with-condition: auto-sniff for tar.gz (peek inside the gzip envelope for ustar magic at offset 257 of the decompressed prefix) lands when operator demand surfaces; today operators with `auto` mode on a `.tar.gz` payload get `format-unsupported gzip` with the explicit-format hint in the error message. **Fixed:** *`sha-to-tag verify` workflow fetches `origin/main` before first-parent walk* — The release-tag integrity gate runs on every `v*` tag push and verifies the tag's commit SHA appears on `main`'s first-parent history. `actions/checkout` was being asked for full history of the tag ref alone — `origin/main` wasn't fetched as a side effect, so `git rev-list --first-parent origin/main | grep -qx "$SHA"` walked a stale (or absent) ref and falsely refused. The check now explicitly fetches `origin/main` after checkout so the walk sees the current squash-merge HEAD. Affected releases (v0.12.6 / v0.12.7 / v0.12.8) had publish workflows that completed normally — `sha-to-tag verify` is an independent gate that was silently failing alongside successful publishes; nothing about the published artifacts was wrong. **Security:** *Bomb caps ride at the gz layer, not the tar/zip layer* — The decompression gate is enforced BEFORE the downstream archive reader sees any bytes — a hostile `tar.gz` that would decompress to 10 GiB of zero-filled tar entries fails the 1 GiB `maxDecompressedBytes` default cap during gunzip, never reaching the tar walker. Operators with legitimately large compressed archives pass `maxDecompressedBytes` higher; the framework refuses without an explicit opt-in. RFC 1952 §2.3.1 magic enforcement prevents content-type confusion (gzip-pretending-to-be-something-else inputs). **Detectors:** *`archive-gz-without-safedecompress` — direct `node:zlib` gunzip in `lib/` must compose `b.safeDecompress`* — Mirrors the v0.11.5 must-compose pattern: any `lib/` call to `zlib.gunzipSync` / `zlib.createGunzip` / `gunzip` outside `lib/archive-gz.js` (which IS the canonical gunzip site, with `b.safeDecompress` wired in) must carry an `allow:archive-gz-without-safedecompress` marker explaining why the bomb gate is bypassed. The detector locks the contract so v0.13+ work that touches a gzip-handling primitive can't quietly drop the cap.
package/index.js CHANGED
@@ -442,6 +442,7 @@ module.exports = {
442
442
  input: aiInput,
443
443
  aiContentDetect: require("./lib/ai-content-detect"),
444
444
  modelManifest: require("./lib/ai-model-manifest"),
445
+ disclosure: require("./lib/ai-disclosure"),
445
446
  },
446
447
  promisePool: require("./lib/promise-pool"),
447
448
  sdNotify: require("./lib/sd-notify"),
@@ -0,0 +1,349 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.ai.disclosure
4
+ * @nav Compliance
5
+ * @title AI Act Art. 50 disclosures
6
+ *
7
+ * @intro
8
+ * EU AI Act Regulation (EU) 2024/1689 Article 50 transparency
9
+ * obligations enter force 2026-08-02. This module ships the active
10
+ * runtime primitives that emit disclosure markup at request time:
11
+ *
12
+ * - `b.ai.disclosure.chatbot(session, opts)` — Art. 50(1).
13
+ * Operators interacting with natural persons must disclose the
14
+ * AI nature of the interaction. Returns the disclosure payload
15
+ * (visible text / structured metadata) to wire into the response.
16
+ *
17
+ * - `b.ai.disclosure.deepfake(content, opts)` — Art. 50(4).
18
+ * Operators emitting AI-generated / AI-manipulated content
19
+ * (image / audio / video / text) must label the output as
20
+ * synthetic. Returns the disclosure payload + suggested
21
+ * embedding points (visible label / C2PA metadata / both).
22
+ *
23
+ * - `b.ai.disclosure.emotion(opts)` — Art. 50(3). Emotion-
24
+ * recognition / biometric-categorisation systems must inform
25
+ * the natural person of operation. Returns the notice payload.
26
+ *
27
+ * Cross-jurisdiction:
28
+ * - California AB-853 (effective 2026) — watermarking on
29
+ * AI-generated content. The deepfake primitive emits both
30
+ * AI Act Art. 50(4) AND AB-853 markup when `jurisdiction:
31
+ * "us-ca"` is requested.
32
+ * - China CAC GenAI Measures — content review marker. Same
33
+ * primitive handles the cross-walk via the `jurisdiction:
34
+ * "cn"` opt.
35
+ *
36
+ * Composition:
37
+ * - `b.audit-sign` chains every disclosure emission so the
38
+ * Art. 50 compliance trail is tamper-evident.
39
+ * - `b.agent.idempotency` (v0.9.22) ensures the chatbot
40
+ * first-contact disclosure isn't double-emitted across
41
+ * retry / reconnect.
42
+ * - `b.contentCredentials` (v0.12.21 — deferred) will wire
43
+ * C2PA manifest emission alongside the visible label.
44
+ *
45
+ * Out of scope (this patch):
46
+ * - C2PA manifest emission — defers to v0.12.21 b.contentCredentials.
47
+ * - Watermark frame embedding into image/audio/video bytes —
48
+ * operator's encoder pipeline (this primitive supplies the
49
+ * label markup; the operator chooses the embed point).
50
+ * - Real-time prohibited-content moderation — orthogonal,
51
+ * composes with b.ai.input.refuseIfMalicious.
52
+ *
53
+ * @card
54
+ * EU AI Act Art. 50 transparency obligation primitives — chatbot
55
+ * disclosure, deepfake / synthetic-content labels, emotion-
56
+ * recognition notices. Calendar-locked 2026-08-02.
57
+ */
58
+
59
+ var { defineClass } = require("./framework-error");
60
+
61
+ var AiDisclosureError = defineClass("AiDisclosureError", { alwaysPermanent: true });
62
+
63
+ // Audit emissions route through opts.audit (operator-supplied
64
+ // instance) — see _emitAudit below. No framework-side audit
65
+ // require needed; the primitive is a pure value-returning function
66
+ // with the optional safeEmit-via-opts side-effect.
67
+
68
+ var DEFAULT_CHATBOT_TEXT = "You are interacting with an AI system.";
69
+ var DEFAULT_DEEPFAKE_TEXT = "This content has been generated or manipulated using artificial intelligence.";
70
+ var DEFAULT_EMOTION_TEXT = "This system uses AI to recognise emotions or biometrically categorise individuals.";
71
+
72
+ // Recognised jurisdiction codes. EU is implicit (the AI Act applies
73
+ // throughout the Union); US-CA layers in AB-853; CN layers in the
74
+ // CAC GenAI Measures.
75
+ var SUPPORTED_JURISDICTIONS = ["eu", "us-ca", "cn"];
76
+
77
+ // Content types eligible for a deepfake notice per Art. 50(4):
78
+ // image / audio / video / text. Each carries different recommended
79
+ // placement defaults (image+video → both label & metadata; audio →
80
+ // audible preamble or metadata; text → visible disclaimer).
81
+ var DEEPFAKE_CONTENT_TYPES = ["image", "audio", "video", "text"];
82
+
83
+ /**
84
+ * @primitive b.ai.disclosure.chatbot
85
+ * @signature b.ai.disclosure.chatbot(session, opts)
86
+ * @since 0.12.12
87
+ * @status stable
88
+ * @compliance eu-ai-act, ca-ab-853, cac-genai-label
89
+ * @related b.ai.disclosure.deepfake, b.ai.disclosure.emotion, b.audit
90
+ *
91
+ * EU AI Act Art. 50(1) first-contact disclosure. Operators
92
+ * interacting with natural persons via an AI system must inform
93
+ * the person they are interacting with AI unless it is obvious from
94
+ * the circumstances (Art. 50(1) carve-out). This primitive returns
95
+ * the disclosure payload + emits an audit event per emission so
96
+ * the compliance trail is tamper-evident under `b.audit-sign`.
97
+ *
98
+ * @opts
99
+ * placement: "first-message" | "always" | "on-request", // default "first-message"
100
+ * language: string, // BCP 47 tag; defaults to en
101
+ * text: string, // override the default disclosure text
102
+ * jurisdiction: string, // "eu" (default) | "us-ca" | "cn"
103
+ * audit: object, // b.audit instance for tamper-evident logging
104
+ * correlationId: string, // audit chain correlation
105
+ *
106
+ * @example
107
+ * var disclosure = b.ai.disclosure.chatbot({ id: "session-42" }, {
108
+ * placement: "first-message",
109
+ * language: "en",
110
+ * });
111
+ * // disclosure.text → "You are interacting with an AI system."
112
+ * // disclosure.shouldEmit → true (first contact)
113
+ * // operator wires disclosure.text into the response payload
114
+ */
115
+ function chatbot(session, opts) {
116
+ opts = opts || {};
117
+ if (!session || typeof session !== "object") {
118
+ throw new AiDisclosureError("ai-disclosure/bad-session",
119
+ "chatbot: session must be an object carrying at minimum an id");
120
+ }
121
+ if (typeof session.id !== "string" || session.id.length === 0) {
122
+ throw new AiDisclosureError("ai-disclosure/bad-session",
123
+ "chatbot: session.id must be a non-empty string");
124
+ }
125
+ var placement = opts.placement || "first-message";
126
+ if (placement !== "first-message" && placement !== "always" && placement !== "on-request") {
127
+ throw new AiDisclosureError("ai-disclosure/bad-arg",
128
+ "chatbot: opts.placement must be \"first-message\" (default) | \"always\" | \"on-request\"; got " +
129
+ JSON.stringify(placement));
130
+ }
131
+ var jurisdiction = opts.jurisdiction || "eu";
132
+ _validateJurisdiction(jurisdiction, "chatbot");
133
+ var text = typeof opts.text === "string" && opts.text.length > 0
134
+ ? opts.text
135
+ : DEFAULT_CHATBOT_TEXT;
136
+ // Codex P1A on v0.12.12 PR #163 — "on-request" placement gates on
137
+ // the operator's explicit `opts.requested: true` signal. Without
138
+ // it, "on-request" collapsed into "always" semantics and emitted
139
+ // every call. The operator wires this from an explicit user
140
+ // gesture ("show me what AI features are in use") or an admin
141
+ // toggle. Default false so the gate stays closed when the opt
142
+ // isn't passed.
143
+ var requested = opts.requested === true;
144
+ var firstSeen = !session.aiDisclosureEmitted;
145
+ var shouldEmit = placement === "always" ||
146
+ (placement === "first-message" && firstSeen) ||
147
+ (placement === "on-request" && requested);
148
+ var emission = {
149
+ text: text,
150
+ language: opts.language || "en",
151
+ jurisdiction: jurisdiction,
152
+ placement: placement,
153
+ shouldEmit: shouldEmit,
154
+ article: "Art. 50(1)",
155
+ regulation: "Regulation (EU) 2024/1689",
156
+ };
157
+ if (shouldEmit) {
158
+ // Codex P1B on v0.12.12 PR #163 — mark the session so subsequent
159
+ // calls with the same session under "first-message" placement
160
+ // see `aiDisclosureEmitted: true` and return shouldEmit=false.
161
+ // Without this mutation operators had to remember to flip the
162
+ // flag themselves; the default would re-emit on every call.
163
+ if (placement === "first-message") {
164
+ session.aiDisclosureEmitted = true;
165
+ }
166
+ _emitAudit(opts, "ai-act/chatbot-disclosure-applied", "success", {
167
+ sessionId: session.id,
168
+ placement: placement,
169
+ jurisdiction: jurisdiction,
170
+ correlationId: opts.correlationId || null,
171
+ });
172
+ }
173
+ return emission;
174
+ }
175
+
176
+ /**
177
+ * @primitive b.ai.disclosure.deepfake
178
+ * @signature b.ai.disclosure.deepfake(content, opts)
179
+ * @since 0.12.12
180
+ * @status stable
181
+ * @compliance eu-ai-act, ca-ab-853, cac-genai-label
182
+ * @related b.ai.disclosure.chatbot, b.contentCredentials
183
+ *
184
+ * EU AI Act Art. 50(4) synthetic-content disclosure. Operators
185
+ * emitting AI-generated or AI-manipulated content (image / audio /
186
+ * video / text) must label the output as synthetic in a clear and
187
+ * machine-readable manner. This primitive returns the disclosure
188
+ * payload (visible label + structured metadata) the operator wires
189
+ * into the encoder / response pipeline. C2PA manifest emission is
190
+ * deferred to v0.12.21 `b.contentCredentials` — this primitive
191
+ * supplies the label markup and the metadata schema that the C2PA
192
+ * adapter consumes when it lands.
193
+ *
194
+ * @opts
195
+ * contentType: "image" | "audio" | "video" | "text", // required
196
+ * placement: "label" | "metadata" | "both", // default "both"
197
+ * jurisdiction: string, // "eu" (default) | "us-ca" | "cn"
198
+ * language: string, // BCP 47 tag; defaults to en
199
+ * text: string, // override the default disclosure text
200
+ * audit: object,
201
+ * correlationId: string,
202
+ *
203
+ * @example
204
+ * var disclosure = b.ai.disclosure.deepfake(imageBytes, {
205
+ * contentType: "image",
206
+ * placement: "both",
207
+ * jurisdiction: "us-ca",
208
+ * });
209
+ * // disclosure.label → "This content has been generated ..."
210
+ * // disclosure.metadata → { ai_generated: true, schema: "c2pa-v1.4-ready" }
211
+ * // disclosure.crossWalk → ["eu-ai-act/Art. 50(4)", "us-ca/AB-853 §22949.91"]
212
+ */
213
+ function deepfake(content, opts) {
214
+ opts = opts || {};
215
+ if (content === undefined || content === null) {
216
+ throw new AiDisclosureError("ai-disclosure/bad-content",
217
+ "deepfake: content is required (Buffer | string | { type, bytes })");
218
+ }
219
+ if (typeof opts.contentType !== "string" ||
220
+ DEEPFAKE_CONTENT_TYPES.indexOf(opts.contentType) === -1) {
221
+ throw new AiDisclosureError("ai-disclosure/bad-arg",
222
+ "deepfake: opts.contentType must be one of " +
223
+ DEEPFAKE_CONTENT_TYPES.join(" | ") + "; got " + JSON.stringify(opts.contentType));
224
+ }
225
+ var placement = opts.placement || "both";
226
+ if (placement !== "label" && placement !== "metadata" && placement !== "both") {
227
+ throw new AiDisclosureError("ai-disclosure/bad-arg",
228
+ "deepfake: opts.placement must be \"label\" | \"metadata\" | \"both\" (default); got " +
229
+ JSON.stringify(placement));
230
+ }
231
+ var jurisdiction = opts.jurisdiction || "eu";
232
+ _validateJurisdiction(jurisdiction, "deepfake");
233
+ var text = typeof opts.text === "string" && opts.text.length > 0
234
+ ? opts.text
235
+ : DEFAULT_DEEPFAKE_TEXT;
236
+ var crossWalk = ["eu-ai-act/Art. 50(4)"];
237
+ if (jurisdiction === "us-ca") crossWalk.push("us-ca/AB-853 §22949.91");
238
+ if (jurisdiction === "cn") crossWalk.push("cn/CAC-GenAI Measures Art. 12");
239
+ var emission = {
240
+ label: placement === "metadata" ? null : text,
241
+ metadata: placement === "label" ? null : {
242
+ ai_generated: true,
243
+ content_type: opts.contentType,
244
+ schema: "c2pa-v1.4-ready", // v0.12.21 b.contentCredentials lights this up
245
+ jurisdiction: jurisdiction,
246
+ regulation: "Regulation (EU) 2024/1689",
247
+ article: "Art. 50(4)",
248
+ },
249
+ language: opts.language || "en",
250
+ contentType: opts.contentType,
251
+ placement: placement,
252
+ crossWalk: crossWalk,
253
+ };
254
+ _emitAudit(opts, "ai-act/deepfake-disclosure-applied", "success", {
255
+ contentType: opts.contentType,
256
+ placement: placement,
257
+ jurisdiction: jurisdiction,
258
+ correlationId: opts.correlationId || null,
259
+ });
260
+ return emission;
261
+ }
262
+
263
+ /**
264
+ * @primitive b.ai.disclosure.emotion
265
+ * @signature b.ai.disclosure.emotion(opts)
266
+ * @since 0.12.12
267
+ * @status stable
268
+ * @compliance eu-ai-act
269
+ * @related b.ai.disclosure.chatbot, b.ai.disclosure.deepfake
270
+ *
271
+ * EU AI Act Art. 50(3) emotion-recognition / biometric-
272
+ * categorisation disclosure. Operators deploying these systems
273
+ * must inform the natural person of operation. Returns the notice
274
+ * payload the operator wires into the consent / pre-interaction
275
+ * flow.
276
+ *
277
+ * @opts
278
+ * language: string,
279
+ * text: string,
280
+ * systemType: "emotion" | "biometric-categorisation", // default "emotion"
281
+ * audit: object,
282
+ * correlationId: string,
283
+ *
284
+ * @example
285
+ * var notice = b.ai.disclosure.emotion({ systemType: "emotion" });
286
+ * // notice.text → "This system uses AI to recognise emotions ..."
287
+ * // notice.article → "Art. 50(3)"
288
+ */
289
+ function emotion(opts) {
290
+ opts = opts || {};
291
+ var systemType = opts.systemType || "emotion";
292
+ if (systemType !== "emotion" && systemType !== "biometric-categorisation") {
293
+ throw new AiDisclosureError("ai-disclosure/bad-arg",
294
+ "emotion: opts.systemType must be \"emotion\" (default) | \"biometric-categorisation\"; got " +
295
+ JSON.stringify(systemType));
296
+ }
297
+ var text = typeof opts.text === "string" && opts.text.length > 0
298
+ ? opts.text
299
+ : DEFAULT_EMOTION_TEXT;
300
+ var emission = {
301
+ text: text,
302
+ language: opts.language || "en",
303
+ systemType: systemType,
304
+ article: "Art. 50(3)",
305
+ regulation: "Regulation (EU) 2024/1689",
306
+ };
307
+ _emitAudit(opts, "ai-act/emotion-disclosure-applied", "success", {
308
+ systemType: systemType,
309
+ correlationId: opts.correlationId || null,
310
+ });
311
+ return emission;
312
+ }
313
+
314
+ function _validateJurisdiction(jurisdiction, primitive) {
315
+ if (SUPPORTED_JURISDICTIONS.indexOf(jurisdiction) === -1) {
316
+ throw new AiDisclosureError("ai-disclosure/bad-jurisdiction",
317
+ primitive + ": opts.jurisdiction must be one of " +
318
+ SUPPORTED_JURISDICTIONS.join(" | ") + " (eu = default; us-ca = California AB-853; " +
319
+ "cn = China CAC GenAI Measures); got " + JSON.stringify(jurisdiction));
320
+ }
321
+ }
322
+
323
+ function _emitAudit(opts, action, outcome, metadata) {
324
+ if (!opts.audit || typeof opts.audit.safeEmit !== "function") return;
325
+ try {
326
+ opts.audit.safeEmit({
327
+ action: action,
328
+ outcome: outcome,
329
+ metadata: metadata || {},
330
+ });
331
+ } catch (_e) {
332
+ // drop-silent — audit emit failure cannot crash the disclosure
333
+ // path. The Art. 50 obligation is the user-facing notice the
334
+ // primitive returns; the audit emission is a parallel best-
335
+ // effort chain-of-custody record. Throwing here would refuse
336
+ // the disclosure to defend the audit chain, which fails the
337
+ // wrong direction (the regulatory contract is satisfied by
338
+ // emitting the notice; the audit trail backs it up).
339
+ }
340
+ }
341
+
342
+ module.exports = {
343
+ chatbot: chatbot,
344
+ deepfake: deepfake,
345
+ emotion: emotion,
346
+ AiDisclosureError: AiDisclosureError,
347
+ SUPPORTED_JURISDICTIONS: Object.freeze(SUPPORTED_JURISDICTIONS.slice()),
348
+ DEEPFAKE_CONTENT_TYPES: Object.freeze(DEEPFAKE_CONTENT_TYPES.slice()),
349
+ };
@@ -28,15 +28,23 @@ var { defineClass } = require("./framework-error");
28
28
  var ArchiveWrapError = defineClass("ArchiveWrapError", { alwaysPermanent: true });
29
29
 
30
30
  var bCrypto = lazyRequire(function () { return require("./crypto"); });
31
+ var backupCrypto = lazyRequire(function () { return require("./backup/crypto"); });
31
32
 
32
33
  // Envelope magic — 5-byte ASCII prefix the safe-archive sniffer
33
34
  // recognises. Distinct from b.crypto.encrypt's base64 envelope so
34
35
  // archive-wrap output can carry an unambiguous "this is an archive
35
- // recipient-wrap envelope" magic before the operator-controlled
36
- // payload.
37
- var ARCH_WRAP_MAGIC = "BAWRP"; // allow:raw-byte-literal — 5-byte ASCII archive-wrap envelope magic
38
- var ARCH_WRAP_VERSION = 0x01; // allow:raw-byte-literal — version byte
36
+ // wrap envelope" magic before the operator-controlled payload.
37
+ var ARCH_WRAP_MAGIC = "BAWRP"; // allow:raw-byte-literal — 5-byte ASCII archive-wrap recipient envelope magic
38
+ var ARCH_WRAP_VERSION = 0x01; // allow:raw-byte-literal — recipient version byte
39
39
  var ARCH_WRAP_HEADER_BYTES = C.BYTES.bytes(6); // magic(5) + version(1)
40
+ // Passphrase variant — wire format: magic(5) + version(1) + saltLen(1)
41
+ // + salt(saltLen bytes) + encrypted bytes (24-byte nonce + ciphertext+tag
42
+ // from backup-crypto encryptWithPassphrase). The salt-prefix shape
43
+ // lets the framework rotate KDF parameters in future minors without
44
+ // per-envelope version bumps (each envelope carries its own salt).
45
+ var ARCH_PASSPHRASE_MAGIC = "BAWPP"; // allow:raw-byte-literal — 5-byte passphrase-wrap envelope magic
46
+ var ARCH_PASSPHRASE_VERSION = 0x01; // allow:raw-byte-literal — passphrase version byte
47
+ var ARCH_PASSPHRASE_HEADER_BYTES = C.BYTES.bytes(7); // magic(5) + version(1) + saltLen(1)
40
48
 
41
49
  /**
42
50
  * @primitive b.archive.wrap
@@ -227,11 +235,213 @@ function _isWrapMagic(buf) {
227
235
  buf.slice(0, 5).toString("ascii") === ARCH_WRAP_MAGIC;
228
236
  }
229
237
 
238
+ function _isPassphraseMagic(buf) {
239
+ return buf.length >= ARCH_PASSPHRASE_HEADER_BYTES &&
240
+ buf.slice(0, 5).toString("ascii") === ARCH_PASSPHRASE_MAGIC;
241
+ }
242
+
243
+ /**
244
+ * @primitive b.archive.wrapWithPassphrase
245
+ * @signature b.archive.wrapWithPassphrase(bytes, opts)
246
+ * @since 0.12.11
247
+ * @status stable
248
+ * @related b.archive.unwrapWithPassphrase, b.archive.wrap, b.backupCrypto
249
+ *
250
+ * Wrap archive bytes in a passphrase-derived envelope. The envelope
251
+ * wire format is the framework's standard Argon2id (RFC 9106) +
252
+ * XChaCha20-Poly1305 AEAD with a fresh per-envelope salt prefixed in
253
+ * a 7-byte `BAWPP` header (5-byte magic + 1-byte version + 1-byte
254
+ * salt length). Operators choosing the passphrase strategy (vs the
255
+ * recipient strategy from `b.archive.wrap`) reach for this primitive
256
+ * when they don't want to manage KEM keypairs but do want
257
+ * encryption-at-rest under operator-controlled material.
258
+ *
259
+ * @opts
260
+ * passphrase: Buffer | string, // required; >= minEntropyBits
261
+ * minEntropyBits: number, // default 80; HIPAA recipe sets 128
262
+ *
263
+ * @example
264
+ * var sealed = await b.archive.wrapWithPassphrase(tarBytes, {
265
+ * passphrase: "operator-supplied-long-passphrase",
266
+ * minEntropyBits: 128,
267
+ * });
268
+ */
269
+ async function wrapWithPassphrase(bytes, opts) {
270
+ opts = opts || {};
271
+ if (!Buffer.isBuffer(bytes) && !(bytes instanceof Uint8Array)) {
272
+ throw new ArchiveWrapError("archive-wrap/bad-input",
273
+ "wrapWithPassphrase: bytes must be a Buffer or Uint8Array");
274
+ }
275
+ if (bytes.length === 0) {
276
+ throw new ArchiveWrapError("archive-wrap/empty-input",
277
+ "wrapWithPassphrase: bytes is empty");
278
+ }
279
+ if (typeof opts.passphrase !== "string" && !Buffer.isBuffer(opts.passphrase)) {
280
+ throw new ArchiveWrapError("archive-wrap/no-passphrase",
281
+ "wrapWithPassphrase: opts.passphrase is required (string or Buffer)");
282
+ }
283
+ var passLen = typeof opts.passphrase === "string"
284
+ ? Buffer.byteLength(opts.passphrase, "utf-8")
285
+ : opts.passphrase.length;
286
+ // Entropy estimate — character-set-aware bit count via Shannon's
287
+ // bound assuming uniform random selection over the observed
288
+ // alphabet. Operators sourcing passphrases from a random-bytes
289
+ // generator (high entropy density) pass without issue; operators
290
+ // typing dictionary phrases trip the gate.
291
+ // Codex P1 on v0.12.11 PR #162 — typeof NaN === "number" passes
292
+ // typeof gate but bypasses downstream comparisons. Use isFinite
293
+ // so NaN / Infinity can't slip past the entropy gate.
294
+ var minEntropy;
295
+ if (opts.minEntropyBits === undefined || opts.minEntropyBits === null) {
296
+ minEntropy = 80; // allow:raw-byte-literal — entropy-bits default, not byte count
297
+ } else if (Number.isFinite(opts.minEntropyBits) && opts.minEntropyBits >= 0) {
298
+ minEntropy = Math.floor(opts.minEntropyBits);
299
+ } else {
300
+ throw new ArchiveWrapError("archive-wrap/bad-arg",
301
+ "wrapWithPassphrase: opts.minEntropyBits must be a finite non-negative number; got " +
302
+ JSON.stringify(opts.minEntropyBits) + " (NaN / Infinity refused so the entropy gate can't be bypassed)");
303
+ }
304
+ var estimated = _estimatePassphraseEntropyBits(opts.passphrase);
305
+ if (estimated < minEntropy) {
306
+ throw new ArchiveWrapError("archive-wrap/weak-passphrase",
307
+ "wrapWithPassphrase: passphrase estimated entropy " + estimated +
308
+ " bits is below opts.minEntropyBits=" + minEntropy +
309
+ " (length=" + passLen + " bytes). Strengthen the passphrase or lower the gate; " +
310
+ "HIPAA recipe is 128+ bits.");
311
+ }
312
+ var fresh = await backupCrypto().encryptWithFreshSalt(bytes, opts.passphrase);
313
+ var saltBytes = Buffer.from(fresh.salt, "hex");
314
+ if (saltBytes.length > 0xff) {
315
+ throw new ArchiveWrapError("archive-wrap/salt-too-long",
316
+ "wrapWithPassphrase: salt length " + saltBytes.length +
317
+ " exceeds 255-byte wire limit");
318
+ }
319
+ var header = Buffer.alloc(ARCH_PASSPHRASE_HEADER_BYTES);
320
+ header.write(ARCH_PASSPHRASE_MAGIC, 0, 5, "ascii");
321
+ header[5] = ARCH_PASSPHRASE_VERSION;
322
+ header[6] = saltBytes.length;
323
+ return Buffer.concat([header, saltBytes, fresh.encrypted]);
324
+ }
325
+
326
+ /**
327
+ * @primitive b.archive.unwrapWithPassphrase
328
+ * @signature b.archive.unwrapWithPassphrase(sealed, opts)
329
+ * @since 0.12.11
330
+ * @status stable
331
+ * @related b.archive.wrapWithPassphrase
332
+ *
333
+ * Recover archive bytes from a passphrase-derived envelope produced
334
+ * by `b.archive.wrapWithPassphrase`. Verifies the 7-byte `BAWPP`
335
+ * header before attempting key derivation so non-envelope inputs
336
+ * fail with `archive-wrap/bad-magic` rather than burning Argon2id
337
+ * compute on bad bytes.
338
+ *
339
+ * @opts
340
+ * passphrase: Buffer | string, // required; same passphrase used at wrap-time
341
+ *
342
+ * @example
343
+ * var recovered = await b.archive.unwrapWithPassphrase(sealed, {
344
+ * passphrase: "operator-supplied-long-passphrase",
345
+ * });
346
+ */
347
+ async function unwrapWithPassphrase(sealed, opts) {
348
+ opts = opts || {};
349
+ if (!Buffer.isBuffer(sealed) && !(sealed instanceof Uint8Array)) {
350
+ throw new ArchiveWrapError("archive-wrap/bad-input",
351
+ "unwrapWithPassphrase: sealed must be a Buffer or Uint8Array");
352
+ }
353
+ if (sealed.length < ARCH_PASSPHRASE_HEADER_BYTES) {
354
+ throw new ArchiveWrapError("archive-wrap/bad-magic",
355
+ "unwrapWithPassphrase: input shorter than 7-byte BAWPP header");
356
+ }
357
+ var buf = Buffer.isBuffer(sealed) ? sealed : Buffer.from(sealed);
358
+ var magic = buf.slice(0, 5).toString("ascii");
359
+ if (magic !== ARCH_PASSPHRASE_MAGIC) {
360
+ throw new ArchiveWrapError("archive-wrap/bad-magic",
361
+ "unwrapWithPassphrase: input does not start with passphrase-wrap magic " +
362
+ JSON.stringify(ARCH_PASSPHRASE_MAGIC) + "; got " + JSON.stringify(magic));
363
+ }
364
+ var version = buf[5];
365
+ if (version !== ARCH_PASSPHRASE_VERSION) {
366
+ throw new ArchiveWrapError("archive-wrap/bad-version",
367
+ "unwrapWithPassphrase: passphrase-wrap version " + version + " not supported");
368
+ }
369
+ if (typeof opts.passphrase !== "string" && !Buffer.isBuffer(opts.passphrase)) {
370
+ throw new ArchiveWrapError("archive-wrap/no-passphrase",
371
+ "unwrapWithPassphrase: opts.passphrase is required");
372
+ }
373
+ var saltLen = buf[6];
374
+ if (sealed.length < ARCH_PASSPHRASE_HEADER_BYTES + saltLen) {
375
+ throw new ArchiveWrapError("archive-wrap/truncated-envelope",
376
+ "unwrapWithPassphrase: header claims " + saltLen + "-byte salt but only " +
377
+ (sealed.length - ARCH_PASSPHRASE_HEADER_BYTES) + " bytes remain");
378
+ }
379
+ var saltHex = buf.slice(ARCH_PASSPHRASE_HEADER_BYTES,
380
+ ARCH_PASSPHRASE_HEADER_BYTES + saltLen).toString("hex");
381
+ var encrypted = buf.slice(ARCH_PASSPHRASE_HEADER_BYTES + saltLen);
382
+ try {
383
+ return await backupCrypto().decryptWithPassphrase(encrypted, opts.passphrase, saltHex);
384
+ } catch (e) {
385
+ var err = new ArchiveWrapError("archive-wrap/decrypt-failed",
386
+ "unwrapWithPassphrase: decryption refused (wrong passphrase or tampered envelope): " +
387
+ ((e && e.message) || String(e)));
388
+ err.cause = e;
389
+ throw err;
390
+ }
391
+ }
392
+
393
+ function _estimatePassphraseEntropyBits(passphrase) {
394
+ // Codex P2 on v0.12.11 PR #162 — Buffer passphrases (CSPRNG-
395
+ // generated random bytes) shouldn't be UTF-8 decoded for entropy
396
+ // estimation; the decoding artifacts (invalid sequences, BOM,
397
+ // surrogate pairs) make the alphabet-class measure unstable and
398
+ // falsely reject strong random buffers. Treat Buffer input as
399
+ // raw bytes: observed-alphabet bit count over the byte values
400
+ // gives a stable approximation that credits CSPRNG output
401
+ // correctly (a 16-byte buffer with full byte variation scores
402
+ // 16 * log2(16+) ≈ 64-128 bits) and refuses all-zero buffers
403
+ // (alphabet=1, score 0).
404
+ if (Buffer.isBuffer(passphrase)) {
405
+ if (passphrase.length === 0) return 0;
406
+ var seen = new Set();
407
+ for (var bi = 0; bi < passphrase.length; bi += 1) {
408
+ seen.add(passphrase[bi]);
409
+ }
410
+ var byteAlphabet = seen.size;
411
+ if (byteAlphabet === 0) return 0;
412
+ return Math.floor(passphrase.length * Math.log2(byteAlphabet));
413
+ }
414
+ var s = typeof passphrase === "string" ? passphrase : String(passphrase);
415
+ if (s.length === 0) return 0;
416
+ // String passphrases — operator-typed phrases. Observed character-
417
+ // class alphabet count. log2(alphabetSize) bits per character is
418
+ // the standard NIST/OWASP "estimate by character classes" measure.
419
+ var hasLower = false, hasUpper = false, hasDigit = false, hasSpecial = false;
420
+ for (var i = 0; i < s.length; i += 1) {
421
+ var c = s.charCodeAt(i);
422
+ if (c >= 0x61 && c <= 0x7a) hasLower = true;
423
+ else if (c >= 0x41 && c <= 0x5a) hasUpper = true;
424
+ else if (c >= 0x30 && c <= 0x39) hasDigit = true;
425
+ else hasSpecial = true;
426
+ }
427
+ var alphabet = 0;
428
+ if (hasLower) alphabet += 26; // allow:raw-byte-literal — alphabet-size term, not byte count
429
+ if (hasUpper) alphabet += 26; // allow:raw-byte-literal — alphabet-size term, not byte count
430
+ if (hasDigit) alphabet += 10; // allow:raw-byte-literal — alphabet-size term, not byte count
431
+ if (hasSpecial) alphabet += 32; // allow:raw-byte-literal — alphabet-size term, not byte count
432
+ if (alphabet === 0) return 0;
433
+ return Math.floor(s.length * Math.log2(alphabet));
434
+ }
435
+
230
436
  module.exports = {
231
- wrap: wrap,
232
- unwrap: unwrap,
233
- ArchiveWrapError: ArchiveWrapError,
437
+ wrap: wrap,
438
+ unwrap: unwrap,
439
+ wrapWithPassphrase: wrapWithPassphrase,
440
+ unwrapWithPassphrase: unwrapWithPassphrase,
441
+ ArchiveWrapError: ArchiveWrapError,
234
442
  // Exposed for sibling modules + sniffer
235
- _isWrapMagic: _isWrapMagic,
236
- ARCH_WRAP_MAGIC: ARCH_WRAP_MAGIC,
443
+ _isWrapMagic: _isWrapMagic,
444
+ _isPassphraseMagic: _isPassphraseMagic,
445
+ ARCH_WRAP_MAGIC: ARCH_WRAP_MAGIC,
446
+ ARCH_PASSPHRASE_MAGIC: ARCH_PASSPHRASE_MAGIC,
237
447
  };
package/lib/archive.js CHANGED
@@ -549,11 +549,13 @@ var archiveGz = require("./archive-gz");
549
549
  var archiveWrap = require("./archive-wrap");
550
550
 
551
551
  module.exports = {
552
- zip: zip,
553
- tar: archiveTar.tar,
554
- gz: archiveGz.gz,
555
- wrap: archiveWrap.wrap,
556
- unwrap: archiveWrap.unwrap,
552
+ zip: zip,
553
+ tar: archiveTar.tar,
554
+ gz: archiveGz.gz,
555
+ wrap: archiveWrap.wrap,
556
+ unwrap: archiveWrap.unwrap,
557
+ wrapWithPassphrase: archiveWrap.wrapWithPassphrase,
558
+ unwrapWithPassphrase: archiveWrap.unwrapWithPassphrase,
557
559
  ArchiveError: ArchiveError,
558
560
  TarError: archiveTar.TarError,
559
561
  ArchiveGzError: archiveGz.ArchiveGzError,
@@ -1083,11 +1083,11 @@ function bundleAdapterStorage(opts) {
1083
1083
  // BACKUP_ENCRYPTION_REQUIRED_POSTURES) REFUSE
1084
1084
  // "none" + require "recipient".
1085
1085
  var cryptoStrategy = opts.cryptoStrategy || "none";
1086
- if (cryptoStrategy !== "none" && cryptoStrategy !== "recipient") {
1086
+ if (cryptoStrategy !== "none" && cryptoStrategy !== "recipient" &&
1087
+ cryptoStrategy !== "passphrase") {
1087
1088
  throw new BackupError("backup/bad-crypto-strategy",
1088
- "bundleAdapterStorage: cryptoStrategy must be \"none\" (default — adapter-encrypted storage) " +
1089
- "or \"recipient\" (v0.12.10 — wraps bundle bytes in a hybrid PQC envelope before writeFile). " +
1090
- "Passphrase strategy is deferred to v0.12.11.");
1089
+ "bundleAdapterStorage: cryptoStrategy must be \"none\" (default — adapter-encrypted storage), " +
1090
+ "\"recipient\" (v0.12.10 — hybrid PQC envelope wrap), or \"passphrase\" (v0.12.11 Argon2id + XChaCha20-Poly1305 wrap)");
1091
1091
  }
1092
1092
  var recipient = opts.recipient;
1093
1093
  if (cryptoStrategy === "recipient" && (!recipient || typeof recipient !== "object")) {
@@ -1096,29 +1096,67 @@ function bundleAdapterStorage(opts) {
1096
1096
  "({ publicKey, ecPublicKey } for the hybrid PQC envelope OR { peerCertDer, peerKemPubkey } " +
1097
1097
  "for the peer-cert envelope)");
1098
1098
  }
1099
- // Codex P1 on v0.12.10 PR #161 — the wrap layer composes only
1100
- // with the tar / tar.gz writeBundle branches. Pairing recipient
1101
- // strategy with format: "directory" would silently write plaintext
1102
- // per-file payloads because the directory branch doesn't apply
1103
- // the envelope. Refuse the combination upfront so operators see
1104
- // the contract gap rather than discover it via disk inspection.
1105
- // Per-file recipient encryption for directory format is a v0.12.11
1106
- // follow-up alongside the _crypto-base.js refactor.
1107
- if (cryptoStrategy === "recipient" && format === "directory") {
1108
- throw new BackupError("backup/recipient-strategy-needs-bundled-format",
1109
- "bundleAdapterStorage: cryptoStrategy: \"recipient\" requires format: \"tar\" or \"tar.gz\". " +
1110
- "Directory format writes per-file plaintext to the adapter — the wrap layer composes only " +
1111
- "with tar / tar.gz bundles in v0.12.10. Per-file recipient encryption is a v0.12.11 follow-up.");
1099
+ var passphrase = opts.passphrase;
1100
+ // HIPAA + PCI-DSS recipe raises the floor to 128 bits (per
1101
+ // BACKUP_ENCRYPTION_REQUIRED_POSTURES below); default 80 matches
1102
+ // OWASP "strong password" guidance for generic deployments.
1103
+ // Codex P1 on v0.12.11 PR #162 typeof NaN === "number" and
1104
+ // typeof Infinity === "number" both pass the typeof gate but
1105
+ // bypass downstream comparisons (NaN < 128 is false; estimated
1106
+ // < NaN is false). Use Number.isFinite + a finite integer check
1107
+ // so the entropy floor can't be NaN'd out under HIPAA.
1108
+ var passphraseMinEntropyBits;
1109
+ if (opts.passphraseMinEntropyBits === undefined ||
1110
+ opts.passphraseMinEntropyBits === null) {
1111
+ passphraseMinEntropyBits = 80; // allow:raw-byte-literal entropy-bits default floor, not byte count
1112
+ } else if (Number.isFinite(opts.passphraseMinEntropyBits) &&
1113
+ opts.passphraseMinEntropyBits >= 0) {
1114
+ passphraseMinEntropyBits = Math.floor(opts.passphraseMinEntropyBits);
1115
+ } else {
1116
+ throw new BackupError("backup/bad-arg",
1117
+ "bundleAdapterStorage: passphraseMinEntropyBits must be a finite non-negative number; " +
1118
+ "got " + JSON.stringify(opts.passphraseMinEntropyBits) +
1119
+ " (NaN / Infinity are refused upfront so the HIPAA / PCI-DSS 128-bit floor can't be bypassed)");
1120
+ }
1121
+ if (cryptoStrategy === "passphrase") {
1122
+ if (typeof passphrase !== "string" && !Buffer.isBuffer(passphrase)) {
1123
+ throw new BackupError("backup/no-passphrase",
1124
+ "bundleAdapterStorage: cryptoStrategy: \"passphrase\" requires opts.passphrase " +
1125
+ "(string or Buffer; Argon2id key derivation + XChaCha20-Poly1305 AEAD). " +
1126
+ "passphraseMinEntropyBits defaults to 80; HIPAA / PCI-DSS postures raise the floor to 128.");
1127
+ }
1128
+ }
1129
+ // Codex P1 on v0.12.10 PR #161 — the wrap layers (recipient AND
1130
+ // passphrase) compose only with the tar / tar.gz writeBundle
1131
+ // branches. Pairing encryption strategy with format: "directory"
1132
+ // would silently write plaintext per-file payloads. Refuse upfront
1133
+ // so operators see the contract gap rather than discover it via
1134
+ // disk inspection. Per-file encryption for directory format is a
1135
+ // future patch alongside the _crypto-base.js refactor.
1136
+ if ((cryptoStrategy === "recipient" || cryptoStrategy === "passphrase") &&
1137
+ format === "directory") {
1138
+ throw new BackupError("backup/" + cryptoStrategy + "-strategy-needs-bundled-format",
1139
+ "bundleAdapterStorage: cryptoStrategy: " + JSON.stringify(cryptoStrategy) +
1140
+ " requires format: \"tar\" or \"tar.gz\". Directory format writes per-file plaintext to " +
1141
+ "the adapter — the wrap layer composes only with tar / tar.gz bundles. Per-file " +
1142
+ "encryption for directory format is a future patch alongside the _crypto-base.js refactor.");
1112
1143
  }
1113
1144
  var posture = opts.posture;
1114
- if (posture && BACKUP_ENCRYPTION_REQUIRED_POSTURES.indexOf(posture) !== -1 &&
1115
- cryptoStrategy === "none") {
1116
- throw new BackupError("backup/posture-requires-encryption",
1117
- "bundleAdapterStorage: posture=" + JSON.stringify(posture) +
1118
- " requires cryptoStrategy: \"recipient\" (the adapter-storage layer cannot itself " +
1119
- "satisfy HIPAA / PCI-DSS encryption-at-rest with cryptoStrategy: \"none\"). " +
1120
- "The recipient+directory combination is refused separately so operators don't slip " +
1121
- "plaintext per-file payloads past the posture gate.");
1145
+ if (posture && BACKUP_ENCRYPTION_REQUIRED_POSTURES.indexOf(posture) !== -1) {
1146
+ if (cryptoStrategy === "none") {
1147
+ throw new BackupError("backup/posture-requires-encryption",
1148
+ "bundleAdapterStorage: posture=" + JSON.stringify(posture) +
1149
+ " requires cryptoStrategy: \"recipient\" or \"passphrase\" (the adapter-storage layer " +
1150
+ "cannot itself satisfy HIPAA / PCI-DSS encryption-at-rest with cryptoStrategy: \"none\"). " +
1151
+ "The recipient+directory and passphrase+directory combinations are refused separately so " +
1152
+ "operators don't slip plaintext per-file payloads past the posture gate.");
1153
+ }
1154
+ // v0.12.11 — passphrase strategy under HIPAA / PCI-DSS raises
1155
+ // the entropy floor to 128 bits (matches the framework's
1156
+ // existing crypto-grade-password discipline for sealed-storage).
1157
+ if (cryptoStrategy === "passphrase" && passphraseMinEntropyBits < 128) { // allow:raw-byte-literal — entropy-bits floor, not byte count
1158
+ passphraseMinEntropyBits = 128; // allow:raw-byte-literal — entropy-bits floor, not byte count
1159
+ }
1122
1160
  }
1123
1161
  // Codex P2 on v0.12.8 PR #159 — tar mode builds the whole archive
1124
1162
  // in memory before adapter.writeFile because the v0.12.8 adapter
@@ -1229,6 +1267,11 @@ function bundleAdapterStorage(opts) {
1229
1267
  : t.toBuffer();
1230
1268
  if (cryptoStrategy === "recipient") {
1231
1269
  payloadBytes = archiveLazy().wrap(payloadBytes, { recipient: recipient });
1270
+ } else if (cryptoStrategy === "passphrase") {
1271
+ payloadBytes = await archiveLazy().wrapWithPassphrase(payloadBytes, {
1272
+ passphrase: passphrase,
1273
+ minEntropyBits: passphraseMinEntropyBits,
1274
+ });
1232
1275
  }
1233
1276
  await adapter.writeFile(bundleId + keySuffix, payloadBytes);
1234
1277
  return;
@@ -1272,6 +1315,8 @@ function bundleAdapterStorage(opts) {
1272
1315
  var gzBytes = await adapter.readFile(bundleId + TAR_GZ_KEY_SUFFIX);
1273
1316
  if (cryptoStrategy === "recipient") {
1274
1317
  gzBytes = archiveLazy().unwrap(gzBytes, { recipient: recipient });
1318
+ } else if (cryptoStrategy === "passphrase") {
1319
+ gzBytes = await archiveLazy().unwrapWithPassphrase(gzBytes, { passphrase: passphrase });
1275
1320
  }
1276
1321
  var gzReader = archiveLazy().read.gz(archiveAdaptersLazy().buffer(gzBytes), {
1277
1322
  maxDecompressedBytes: maxBundleBytes,
@@ -1285,6 +1330,8 @@ function bundleAdapterStorage(opts) {
1285
1330
  var tarBytes = await adapter.readFile(bundleId + TAR_KEY_SUFFIX);
1286
1331
  if (cryptoStrategy === "recipient") {
1287
1332
  tarBytes = archiveLazy().unwrap(tarBytes, { recipient: recipient });
1333
+ } else if (cryptoStrategy === "passphrase") {
1334
+ tarBytes = await archiveLazy().unwrapWithPassphrase(tarBytes, { passphrase: passphrase });
1288
1335
  }
1289
1336
  var reader = archiveLazy().read.tar(archiveAdaptersLazy().buffer(tarBytes));
1290
1337
  await reader.extract({ destination: destDir });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.12.10",
3
+ "version": "0.12.12",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:15ca08ce-3216-4a0c-bd31-d9c198e71e36",
5
+ "serialNumber": "urn:uuid:c9470d27-8ecf-46a8-8a75-2a26724c7831",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-23T19:05:59.300Z",
8
+ "timestamp": "2026-05-23T22:09:15.262Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.12.10",
22
+ "bom-ref": "@blamejs/core@0.12.12",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.12.10",
25
+ "version": "0.12.12",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.12.10",
29
+ "purl": "pkg:npm/%40blamejs/core@0.12.12",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.12.10",
57
+ "ref": "@blamejs/core@0.12.12",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]