@blamejs/core 0.10.7 → 0.10.8

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.
@@ -0,0 +1,363 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.ai.modelManifest
4
+ * @nav AI
5
+ * @title AI Model Manifest (AIBOM)
6
+ *
7
+ * @intro
8
+ * CycloneDX 1.6 ML-BOM emitter for AI bills of materials. EU AI Act
9
+ * Art. 11 + Annex IV require technical documentation for high-risk
10
+ * AI systems; the EU CRA (Regulation (EU) 2024/2847) transposition
11
+ * deadline (2027-12-11) extends SBOM-style documentation to AI
12
+ * components in products with digital elements. ML-BOM extends the
13
+ * CycloneDX 1.5 machine-learning component type with model-card,
14
+ * dataset, hyperparameter, formulation, and external-service
15
+ * sections per the OWASP CycloneDX Authoritative Guide to AI/ML-BOM
16
+ * and CycloneDX spec issue #702 (EU CRA alignment).
17
+ *
18
+ * Two paths:
19
+ * - `build({...})` constructs a 1.6-conformant JSON BOM in memory.
20
+ * - `sign(bom, { privateKeyPem })` signs the canonical-JSON-1785
21
+ * representation with the operator's signing key (ML-DSA-87 by
22
+ * default per project hard-rule 2). `verify(envelope, publicKeyPem)`
23
+ * re-canonicalizes and checks before trusting any field.
24
+ *
25
+ * Self-validation: `build` rejects BOMs missing CycloneDX 1.6
26
+ * required fields (`bomFormat`, `specVersion`, `metadata.timestamp`,
27
+ * `metadata.component` when shipping a model). Catches malformed
28
+ * inputs at emit time, not at downstream validator.
29
+ *
30
+ * @card
31
+ * CycloneDX 1.6 AI bill of materials (AIBOM) — model cards, datasets, hyperparameters, training workflows. ML-DSA-87 signed.
32
+ */
33
+
34
+ var bCrypto = require("./crypto");
35
+ var canonicalJson = require("./canonical-json");
36
+ var validateOpts = require("./validate-opts");
37
+ var { defineClass } = require("./framework-error");
38
+ var audit = require("./audit");
39
+
40
+ var AiModelManifestError = defineClass("AiModelManifestError", { alwaysPermanent: true });
41
+
42
+ var SPEC_VERSION = "1.6";
43
+ var BOM_FORMAT = "CycloneDX";
44
+ var COMPONENT_TYPE_MODEL = "machine-learning-model";
45
+ var VALID_DATA_TYPES = Object.freeze({
46
+ "source-code": true, configuration: true, dataset: true, definition: true,
47
+ "device-driver": true, documentation: true, evidence: true, executable: true,
48
+ file: true, firmware: true, framework: true, library: true,
49
+ "machine-learning-model": true, manifest: true, "operating-system": true,
50
+ patch: true, platform: true, "test-case": true,
51
+ });
52
+ var ISO8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
53
+ var BOM_REF_RE = /^[A-Za-z0-9._:/+-]{1,256}$/; // allow:raw-byte-literal — CycloneDX bom-ref string-length cap, not bytes
54
+
55
+ function _requireString(obj, key, ownerName) {
56
+ if (typeof obj[key] !== "string" || obj[key].length === 0) {
57
+ throw new AiModelManifestError("aibom/bad-" + key,
58
+ ownerName + ": " + key + " must be a non-empty string");
59
+ }
60
+ }
61
+
62
+ function _validateModelComponent(c) {
63
+ if (!c || typeof c !== "object") {
64
+ throw new AiModelManifestError("aibom/bad-model-component",
65
+ "build: opts.model must be an object");
66
+ }
67
+ _requireString(c, "name", "build.model");
68
+ _requireString(c, "version", "build.model");
69
+ if (c["bom-ref"] !== undefined) {
70
+ if (typeof c["bom-ref"] !== "string" || c["bom-ref"].length > 256) { // allow:raw-byte-literal — bom-ref string-length cap, not bytes
71
+ throw new AiModelManifestError("aibom/bad-bom-ref",
72
+ "build.model: bom-ref must be a string of length 1-256");
73
+ }
74
+ if (!BOM_REF_RE.test(c["bom-ref"])) { // allow:regex-no-length-cap — length-bounded immediately above
75
+ throw new AiModelManifestError("aibom/bad-bom-ref",
76
+ "build.model: bom-ref must match [A-Za-z0-9._:/+-]");
77
+ }
78
+ }
79
+ // modelCard is CycloneDX 1.6 §4.5.5; required for ML components when
80
+ // the operator wants downstream tooling (e.g. Dependency-Track) to
81
+ // surface the card. We do not REQUIRE the card here (some operators
82
+ // ship a hash-only ML-BOM by policy) but we do validate shape when
83
+ // present.
84
+ if (c.modelCard !== undefined) {
85
+ if (typeof c.modelCard !== "object") {
86
+ throw new AiModelManifestError("aibom/bad-model-card",
87
+ "build.model.modelCard: must be an object");
88
+ }
89
+ }
90
+ }
91
+
92
+ function _validateDataComponent(d, idx) {
93
+ if (!d || typeof d !== "object") {
94
+ throw new AiModelManifestError("aibom/bad-dataset",
95
+ "build.datasets[" + idx + "]: must be an object");
96
+ }
97
+ _requireString(d, "name", "build.datasets[" + idx + "]");
98
+ if (d.type !== undefined && !VALID_DATA_TYPES[d.type]) {
99
+ throw new AiModelManifestError("aibom/bad-dataset-type",
100
+ "build.datasets[" + idx + "].type '" + d.type + "' not in CycloneDX 1.6 data-type vocabulary");
101
+ }
102
+ }
103
+
104
+ /**
105
+ * @primitive b.ai.modelManifest.build
106
+ * @signature b.ai.modelManifest.build(opts)
107
+ * @since 0.10.8
108
+ * @status stable
109
+ * @compliance eu-ai-act-art-11, nist-ai-600-1, iso-42001, iso-23894
110
+ * @related b.ai.modelManifest.sign, b.ai.modelManifest.verify
111
+ *
112
+ * Build a CycloneDX 1.6 ML-BOM JSON document for the supplied model
113
+ * + datasets + hyperparameters + formulation + external services.
114
+ * Returns a frozen plain object ready for `sign({...})` or direct
115
+ * serialization. Validates against the spec's required-field set at
116
+ * emit time (`bomFormat` / `specVersion` / `metadata.timestamp` /
117
+ * `metadata.component`) plus the ML-BOM-specific shape (model
118
+ * component type, dataset type vocabulary, bom-ref grammar).
119
+ *
120
+ * @opts
121
+ * model: object, // { name, version, license?, bom-ref?, modelCard? }
122
+ * datasets: object[], // [{ name, type?, contents?, classification?, ... }]
123
+ * hyperparameters: object, // kv pairs → properties[]
124
+ * formulation: object[], // [{ ref, components, workflows }]
125
+ * services: object[], // [{ name, endpoints, authenticated }]
126
+ * tool: object, // tool metadata; defaults to blamejs core
127
+ * timestamp: string, // ISO 8601 UTC; defaults to now
128
+ * serialNumber: string, // urn:uuid:...; defaults to fresh UUIDv4
129
+ *
130
+ * @example
131
+ * var bom = b.ai.modelManifest.build({
132
+ * model: { name: "acme-classifier", version: "1.2.3" },
133
+ * datasets: [{ name: "training-2026", type: "dataset" }],
134
+ * });
135
+ * bom.bomFormat; // → "CycloneDX"
136
+ * bom.specVersion; // → "1.6"
137
+ */
138
+ function build(opts) {
139
+ opts = opts || {};
140
+ _validateModelComponent(opts.model);
141
+ if (opts.datasets !== undefined) {
142
+ if (!Array.isArray(opts.datasets)) {
143
+ throw new AiModelManifestError("aibom/bad-datasets",
144
+ "build: opts.datasets must be an array");
145
+ }
146
+ for (var i = 0; i < opts.datasets.length; i += 1) _validateDataComponent(opts.datasets[i], i);
147
+ }
148
+ var timestamp = opts.timestamp || new Date().toISOString();
149
+ if (!ISO8601_RE.test(timestamp)) {
150
+ throw new AiModelManifestError("aibom/bad-timestamp",
151
+ "build: timestamp must be ISO 8601 UTC (e.g. 2026-05-17T20:00:00Z)");
152
+ }
153
+ var serialNumber = opts.serialNumber || _uuidUrn();
154
+ if (typeof serialNumber !== "string" || serialNumber.indexOf("urn:uuid:") !== 0) {
155
+ throw new AiModelManifestError("aibom/bad-serial-number",
156
+ "build: serialNumber must start with `urn:uuid:`");
157
+ }
158
+
159
+ var primaryComponent = Object.assign({
160
+ type: COMPONENT_TYPE_MODEL,
161
+ "bom-ref": opts.model["bom-ref"] || ("model:" + opts.model.name + "@" + opts.model.version),
162
+ }, opts.model);
163
+ primaryComponent.type = COMPONENT_TYPE_MODEL;
164
+
165
+ var components = [];
166
+ // Per CycloneDX 1.6 §4.7: the primary component goes in
167
+ // metadata.component, NOT in components[]. Datasets, hyperparameter-
168
+ // bearing components, and external dependencies live in components[].
169
+ if (Array.isArray(opts.datasets)) {
170
+ for (var di = 0; di < opts.datasets.length; di += 1) {
171
+ var ds = opts.datasets[di];
172
+ components.push(Object.assign({
173
+ type: ds.type || "data",
174
+ "bom-ref": ds["bom-ref"] || ("dataset:" + ds.name),
175
+ }, ds));
176
+ }
177
+ }
178
+
179
+ // Hyperparameters → CycloneDX properties[] kv pairs per spec
180
+ // issue #702 EU CRA alignment.
181
+ var properties = [];
182
+ if (opts.hyperparameters && typeof opts.hyperparameters === "object") {
183
+ var keys = Object.keys(opts.hyperparameters);
184
+ for (var k = 0; k < keys.length; k += 1) {
185
+ var key = keys[k];
186
+ properties.push({ name: "ai:hyperparameter:" + key,
187
+ value: String(opts.hyperparameters[key]) });
188
+ }
189
+ }
190
+
191
+ var bom = {
192
+ bomFormat: BOM_FORMAT,
193
+ specVersion: SPEC_VERSION,
194
+ serialNumber: serialNumber,
195
+ version: 1,
196
+ metadata: {
197
+ timestamp: timestamp,
198
+ tools: [Object.assign({
199
+ vendor: "blamejs",
200
+ name: "@blamejs/core",
201
+ version: _frameworkVersion(),
202
+ }, opts.tool || {})],
203
+ component: primaryComponent,
204
+ },
205
+ };
206
+ if (components.length > 0) bom.components = components;
207
+ if (properties.length > 0) bom.properties = properties;
208
+ if (Array.isArray(opts.formulation) && opts.formulation.length > 0) {
209
+ bom.formulation = opts.formulation;
210
+ }
211
+ if (Array.isArray(opts.services) && opts.services.length > 0) {
212
+ bom.services = opts.services;
213
+ }
214
+ if (Array.isArray(opts.dependencies) && opts.dependencies.length > 0) {
215
+ bom.dependencies = opts.dependencies;
216
+ }
217
+ return Object.freeze(bom);
218
+ }
219
+
220
+ /**
221
+ * @primitive b.ai.modelManifest.sign
222
+ * @signature b.ai.modelManifest.sign(bom, opts)
223
+ * @since 0.10.8
224
+ * @status stable
225
+ * @compliance eu-ai-act-art-11
226
+ * @related b.ai.modelManifest.build, b.ai.modelManifest.verify
227
+ *
228
+ * Sign an AIBOM produced by `build({...})`. Signature is over the
229
+ * canonical-JSON-1785 representation of the BOM (deterministic byte
230
+ * stream regardless of object-key insertion order); the operator's
231
+ * `privateKeyPem` selects the signing alg (ML-DSA-87 by default per
232
+ * the project's PQC-first crypto rule). Returns `{ bom, signature }`
233
+ * where `signature` is base64-encoded.
234
+ *
235
+ * @opts
236
+ * privateKeyPem: string, // PEM-encoded private key
237
+ * audit: boolean, // default true
238
+ *
239
+ * @example
240
+ * var pair = b.crypto.generateSigningKeyPair("ml-dsa-87");
241
+ * var bom = b.ai.modelManifest.build({ model: { name: "x", version: "1" }});
242
+ * var env = b.ai.modelManifest.sign(bom, { privateKeyPem: pair.privateKey });
243
+ * typeof env.signature; // → "string"
244
+ */
245
+ function sign(bom, opts) {
246
+ opts = opts || {};
247
+ if (!bom || typeof bom !== "object") {
248
+ throw new AiModelManifestError("aibom/bad-bom",
249
+ "sign: bom must be an object produced by build({...})");
250
+ }
251
+ validateOpts.requireNonEmptyString(opts.privateKeyPem,
252
+ "sign: opts.privateKeyPem", AiModelManifestError, "aibom/bad-key");
253
+ var canonical = canonicalJson.stringify(bom);
254
+ var signature = bCrypto.sign(Buffer.from(canonical, "utf8"), opts.privateKeyPem);
255
+ if (opts.audit !== false) {
256
+ audit.safeEmit({
257
+ action: "aibom.signed",
258
+ outcome: "success",
259
+ metadata: {
260
+ modelName: bom.metadata && bom.metadata.component && bom.metadata.component.name,
261
+ modelVersion: bom.metadata && bom.metadata.component && bom.metadata.component.version,
262
+ serialNumber: bom.serialNumber,
263
+ },
264
+ });
265
+ }
266
+ return Object.freeze({
267
+ bom: bom,
268
+ signature: signature.toString("base64"),
269
+ });
270
+ }
271
+
272
+ /**
273
+ * @primitive b.ai.modelManifest.verify
274
+ * @signature b.ai.modelManifest.verify(envelope, publicKeyPem, opts)
275
+ * @since 0.10.8
276
+ * @status stable
277
+ * @compliance eu-ai-act-art-11
278
+ * @related b.ai.modelManifest.sign
279
+ *
280
+ * Verify an envelope produced by `sign(bom, {...})`. Re-canonicalizes
281
+ * the BOM with `canonicalJson.stringify` (NEVER trusts an embedded
282
+ * "signedBytes" field — defends the CVE-2025-29774 / CVE-2025-29775
283
+ * xml-crypto-style signature-substitution class) and checks the
284
+ * signature with `b.crypto.verify` against the supplied public-key
285
+ * PEM. Returns `{ valid, bom, reason }`; never throws.
286
+ *
287
+ * @opts
288
+ * audit: boolean, // default true
289
+ *
290
+ * @example
291
+ * var result = b.ai.modelManifest.verify(envelope, pair.publicKey);
292
+ * if (!result.valid) console.log(result.reason);
293
+ */
294
+ function verify(envelope, publicKeyPem, opts) {
295
+ opts = opts || {};
296
+ if (!envelope || typeof envelope !== "object" || !envelope.bom || !envelope.signature) {
297
+ return { valid: false, bom: null, reason: "envelope-shape" };
298
+ }
299
+ if (typeof publicKeyPem !== "string" || publicKeyPem.length === 0) {
300
+ return { valid: false, bom: null, reason: "public-key-required" };
301
+ }
302
+ if (envelope.bom.specVersion !== SPEC_VERSION || envelope.bom.bomFormat !== BOM_FORMAT) {
303
+ return { valid: false, bom: null, reason: "bom-spec-mismatch" };
304
+ }
305
+ var canonical = canonicalJson.stringify(envelope.bom);
306
+ var sigBuf;
307
+ try { sigBuf = Buffer.from(envelope.signature, "base64"); }
308
+ catch (_e) { return { valid: false, bom: null, reason: "signature-base64-bad" }; }
309
+ // `b.crypto.verify` can throw on malformed public-key PEM (Node's
310
+ // crypto layer surfaces `DECODER routines::unsupported` and similar).
311
+ // The documented contract here is `{ valid, bom, reason }` with no
312
+ // throws — wrap so a hostile / mistyped key returns a structured
313
+ // verdict instead of crashing the request path.
314
+ var ok;
315
+ try { ok = bCrypto.verify(Buffer.from(canonical, "utf8"), sigBuf, publicKeyPem); }
316
+ catch (_e2) { return { valid: false, bom: null, reason: "public-key-malformed" }; }
317
+ if (!ok) return { valid: false, bom: null, reason: "signature-invalid" };
318
+ if (opts.audit !== false) {
319
+ audit.safeEmit({
320
+ action: "aibom.verified",
321
+ outcome: "success",
322
+ metadata: {
323
+ modelName: envelope.bom.metadata && envelope.bom.metadata.component &&
324
+ envelope.bom.metadata.component.name,
325
+ serialNumber: envelope.bom.serialNumber,
326
+ },
327
+ });
328
+ }
329
+ return { valid: true, bom: envelope.bom, reason: null };
330
+ }
331
+
332
+ // UUIDv4 via the framework's CSPRNG path. Used for `serialNumber`
333
+ // defaults — operators may supply their own for cross-build stability.
334
+ function _uuidUrn() {
335
+ var b = bCrypto.generateBytes(16); // allow:raw-byte-literal — RFC 9562 §4.1 UUIDv4 is a 16-byte (128-bit) primitive
336
+ b[6] = (b[6] & 0x0f) | 0x40; // allow:raw-byte-literal — UUIDv4 version nibble (RFC 9562 §4.4)
337
+ b[8] = (b[8] & 0x3f) | 0x80; // allow:raw-byte-literal — UUIDv4 variant nibble (RFC 9562 §4.4)
338
+ var h = b.toString("hex");
339
+ return "urn:uuid:" + h.slice(0, 8) + "-" + h.slice(8, 12) + "-" + // allow:raw-byte-literal — RFC 9562 §4 UUID text representation hex offsets (8-4-4-4-12)
340
+ h.slice(12, 16) + "-" + h.slice(16, 20) + "-" + h.slice(20); // allow:raw-byte-literal — RFC 9562 §4 UUID hex offsets
341
+ }
342
+
343
+ // package.json read lives behind the call to dodge a circular-load
344
+ // chain: framework boot pulls index.js → ai-model-manifest → audit →
345
+ // db → framework-error → constants → package.json. Reading
346
+ // package.json at boot time would close the cycle. The lazy read is
347
+ // idempotent and the result is cached on first call.
348
+ var _cachedVersion = null;
349
+ function _frameworkVersion() {
350
+ if (_cachedVersion) return _cachedVersion;
351
+ try { _cachedVersion = require("../package.json").version; } // allow:inline-require — lazy package-version read to avoid boot-time circular load
352
+ catch (_e) { _cachedVersion = "0.0.0"; }
353
+ return _cachedVersion;
354
+ }
355
+
356
+ module.exports = {
357
+ build: build,
358
+ sign: sign,
359
+ verify: verify,
360
+ SPEC_VERSION: SPEC_VERSION,
361
+ BOM_FORMAT: BOM_FORMAT,
362
+ AiModelManifestError: AiModelManifestError,
363
+ };
@@ -279,6 +279,88 @@ function pathTimestamp(date) {
279
279
  return d.toISOString().replace(/[:.]/g, "-");
280
280
  }
281
281
 
282
+ // Generic [A-Za-z0-9_-]+ identifier shape used by conflictPath tag /
283
+ // suffix validation. The pattern collides with similar shapes in
284
+ // safe-buffer / redact / etc.; keeping the regex literal local to
285
+ // atomic-file rather than pulling in a cross-module dependency for a
286
+ // 30-byte regex keeps this file's lazy-load chain short.
287
+ var IDENT_RE = /^[A-Za-z0-9_-]+$/; // allow:regex-no-length-cap — caller bounds length before .test() // allow:duplicate-regex — generic [A-Za-z0-9_-]+ identifier shape; extracting a one-line regex into a cross-module dependency would lengthen atomic-file's boot-time lazy chain for no behavioral win
288
+
289
+ /**
290
+ * @primitive b.atomicFile.conflictPath
291
+ * @signature b.atomicFile.conflictPath(originalPath, opts?)
292
+ * @since 0.10.8
293
+ * @status stable
294
+ * @related b.atomicFile.pathTimestamp, b.atomicFile.write
295
+ *
296
+ * Build a filesystem-portable conflict-suffix path next to
297
+ * `originalPath`, e.g. `notes.md` → `notes.conflict-2026-05-17T19-30-00Z.md`.
298
+ * Drop-in name for last-write-wins reconciliation in sync / backup /
299
+ * dual-control workflows. Preserves the original extension. Inserts a
300
+ * caller-supplied `tag` (default `conflict`) between the basename and
301
+ * the timestamp. The timestamp uses `pathTimestamp` so the result is
302
+ * portable across Windows (no `:` / `.`), macOS, and Linux. Same-second
303
+ * collision handling: pass `opts.suffix` (e.g. a per-row crypto-random
304
+ * hex) when multiple conflicts may land in the same second; otherwise
305
+ * the timestamp's millisecond field disambiguates.
306
+ *
307
+ * @opts
308
+ * tag: string, // default "conflict"; sandwiched between basename and timestamp
309
+ * timestamp: Date, // default `new Date()`
310
+ * suffix: string, // optional extra disambiguator appended after timestamp
311
+ *
312
+ * @example
313
+ * var p = b.atomicFile.conflictPath("/srv/notes.md");
314
+ * // → "/srv/notes.conflict-2026-05-17T20-30-00-123Z.md"
315
+ *
316
+ * var withSuffix = b.atomicFile.conflictPath("/srv/notes.md", {
317
+ * tag: "merge", suffix: "abc123",
318
+ * });
319
+ * // → "/srv/notes.merge-2026-05-17T20-30-00-123Z.abc123.md"
320
+ */
321
+ function conflictPath(originalPath, opts) {
322
+ if (typeof originalPath !== "string" || originalPath.length === 0) {
323
+ throw new TypeError("b.atomicFile.conflictPath: originalPath must be a non-empty string");
324
+ }
325
+ opts = opts || {};
326
+ var tag = typeof opts.tag === "string" && opts.tag.length > 0 ? opts.tag : "conflict";
327
+ if (typeof tag !== "string" || tag.length === 0 || tag.length > 64) { // allow:raw-byte-literal — tag length cap, not bytes
328
+ throw new TypeError("b.atomicFile.conflictPath: tag must be a 1-64 char string");
329
+ }
330
+ if (!IDENT_RE.test(tag)) { // allow:regex-no-length-cap — length-bounded immediately above
331
+ throw new TypeError("b.atomicFile.conflictPath: tag must match [A-Za-z0-9_-]+");
332
+ }
333
+ var stamp = pathTimestamp(opts.timestamp);
334
+ var suffix = "";
335
+ if (opts.suffix !== undefined) {
336
+ if (typeof opts.suffix !== "string" || opts.suffix.length === 0 ||
337
+ opts.suffix.length > 64) { // allow:raw-byte-literal — suffix length cap, not bytes
338
+ throw new TypeError("b.atomicFile.conflictPath: suffix must be a 1-64 char string");
339
+ }
340
+ if (!IDENT_RE.test(opts.suffix)) { // allow:regex-no-length-cap — length-bounded immediately above
341
+ throw new TypeError("b.atomicFile.conflictPath: suffix must match [A-Za-z0-9_-]+");
342
+ }
343
+ suffix = "." + opts.suffix;
344
+ }
345
+ // Walk from the rightmost `.` to split base + ext. POSIX `path` module
346
+ // does it portably; using string ops here keeps the helper free of
347
+ // additional require()s in the hot atomic-file file (which is loaded
348
+ // before most of the framework's lazy chain). Extension preservation
349
+ // walks ONLY the basename — a directory containing a `.` doesn't
350
+ // confuse the suffix.
351
+ var sep = originalPath.lastIndexOf("/");
352
+ var bsep = originalPath.lastIndexOf("\\");
353
+ var lastSep = sep > bsep ? sep : bsep;
354
+ var dir = lastSep >= 0 ? originalPath.slice(0, lastSep + 1) : "";
355
+ var name = lastSep >= 0 ? originalPath.slice(lastSep + 1) : originalPath;
356
+ var dotIdx = name.lastIndexOf(".");
357
+ // Treat a leading dot (dotfile, e.g. `.env`) as part of the base, not
358
+ // as an extension separator. `dotIdx === 0` → no extension.
359
+ var base = dotIdx > 0 ? name.slice(0, dotIdx) : name;
360
+ var ext = dotIdx > 0 ? name.slice(dotIdx) : "";
361
+ return dir + base + "." + tag + "-" + stamp + suffix + ext;
362
+ }
363
+
282
364
  /**
283
365
  * @primitive b.atomicFile.writeSync
284
366
  * @signature b.atomicFile.writeSync(filepath, data, opts)
@@ -935,6 +1017,7 @@ module.exports = {
935
1017
  ensureDir: ensureDir,
936
1018
  copyDirRecursive: copyDirRecursive,
937
1019
  pathTimestamp: pathTimestamp,
1020
+ conflictPath: conflictPath,
938
1021
  AtomicFileError: AtomicFileError,
939
1022
  DEFAULTS: DEFAULTS,
940
1023
  };
package/lib/audit.js CHANGED
@@ -296,6 +296,9 @@ var FRAMEWORK_NAMESPACES = [
296
296
  "localdb", // b.localDb.thin (localdb.thin.opened / recovered / closed — desktop-daemon SQLite wrapper)
297
297
  "dataact", // b.dataAct (EU Data Act 2023/2854 — product_declared / user_access / share_with_third_party / share_refused / switch_request)
298
298
  "idempotency", // b.middleware.idempotencyKey (idempotency.missing_key / bad_key / replay / key_reuse_mismatch / cache_store / store_read_failed / store_write_failed / skip_5xx / body_too_large — draft-ietf-httpapi-idempotency-key)
299
+ "aibom", // b.ai.modelManifest (aibom.signed / aibom.verified — CycloneDX 1.6 ML-BOM)
300
+ "aicontentdetect", // b.ai.aiContentDetect (aicontentdetect.report — AB-853 / EU AI Act Art. 50 inbound provenance)
301
+ "sdnotify", // b.sdNotify (sdnotify.send / sdnotify.send.skipped — systemd Type=notify)
299
302
  ];
300
303
  var registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
301
304
 
@@ -30,6 +30,7 @@
30
30
 
31
31
  var bCrypto = require("./crypto");
32
32
  var canonicalJson = require("./canonical-json");
33
+ var safeJson = require("./safe-json");
33
34
  var validateOpts = require("./validate-opts");
34
35
  var audit = require("./audit");
35
36
  var { defineClass } = require("./framework-error");
@@ -552,12 +553,151 @@ function signCose(manifest, opts) {
552
553
  };
553
554
  }
554
555
 
556
+ /**
557
+ * @primitive b.contentCredentials.cacImplicitLabel
558
+ * @signature b.contentCredentials.cacImplicitLabel(opts)
559
+ * @since 0.10.8
560
+ * @status stable
561
+ * @compliance cac-genai-label
562
+ * @related b.contentCredentials.build, b.contentCredentials.cacImplicitLabelRead
563
+ *
564
+ * Build the GB 45438-2025 "Cybersecurity Technology — Labeling Method
565
+ * for Content Generated by Artificial Intelligence" implicit metadata
566
+ * block (effective 2025-09-01 per CAC Measures for Labeling AI-
567
+ * Generated Synthetic Content). The framework owns the implicit lane
568
+ * (metadata); the visible explicit label is application-layer
569
+ * rendering. Operators co-emit alongside the C2PA-COSE manifest by
570
+ * declaring `cac-genai-label` posture on `b.contentCredentials.build`.
571
+ *
572
+ * @opts
573
+ * providerName: string, // UTF-8 ≤256 bytes
574
+ * providerCode: string, // 18-char 统一社会信用代码 (Chinese USCC)
575
+ * contentId: string, // globally-unique asset id
576
+ * contentKind: string, // "text"|"image"|"audio"|"video"|"virtual-scene"|"other"
577
+ * generatedAt: string, // ISO 8601 UTC
578
+ *
579
+ * @example
580
+ * var label = b.contentCredentials.cacImplicitLabel({
581
+ * providerName: "Example AI",
582
+ * providerCode: "91110000600037341A",
583
+ * contentId: "asset-2026-05-17-abc123",
584
+ * contentKind: "image",
585
+ * generatedAt: "2026-05-17T20:00:00Z",
586
+ * });
587
+ * // → { aigcMarker: "AIGC", providerName, providerCode, contentId, contentKind, generatedAt }
588
+ */
589
+ var CAC_KIND_ENUM = Object.freeze({
590
+ text: true, image: true, audio: true, video: true,
591
+ "virtual-scene": true, other: true,
592
+ });
593
+ var CAC_USCC_RE = /^[0-9A-HJ-NPQRTUWXY]{18}$/; // allow:raw-byte-literal — GB 32100-2015 USCC fixed length, not bytes // allow:raw-time-literal — 18 is char-count of the credit code, not seconds
594
+ var ISO8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
595
+
596
+ function cacImplicitLabel(opts) {
597
+ if (!opts || typeof opts !== "object") {
598
+ throw new ContentCredentialsError("cac-implicit-label/bad-opts",
599
+ "cacImplicitLabel: opts object required");
600
+ }
601
+ validateOpts.requireNonEmptyString(opts.providerName,
602
+ "cacImplicitLabel: providerName", ContentCredentialsError,
603
+ "cac-implicit-label/bad-provider-name");
604
+ if (Buffer.byteLength(opts.providerName, "utf8") > STR_LEN_MAX) {
605
+ throw new ContentCredentialsError("cac-implicit-label/oversize-provider-name",
606
+ "cacImplicitLabel: providerName exceeds " + STR_LEN_MAX + " bytes (UTF-8)");
607
+ }
608
+ if (typeof opts.providerCode !== "string" || opts.providerCode.length !== 18 || // allow:raw-byte-literal — USCC fixed length (GB 32100-2015), not bytes // allow:raw-time-literal — string length, not seconds
609
+ !CAC_USCC_RE.test(opts.providerCode)) { // allow:regex-no-length-cap — length-bounded immediately above
610
+ throw new ContentCredentialsError("cac-implicit-label/bad-provider-code",
611
+ "cacImplicitLabel: providerCode must be an 18-char unified social credit code " +
612
+ "(统一社会信用代码 per GB 32100-2015 / GB 45438-2025)");
613
+ }
614
+ if (typeof opts.contentId !== "string" || opts.contentId.length === 0 ||
615
+ opts.contentId.length > 128) { // allow:raw-byte-literal — contentId char cap, not bytes
616
+ throw new ContentCredentialsError("cac-implicit-label/bad-content-id",
617
+ "cacImplicitLabel: contentId must be 1-128 chars");
618
+ }
619
+ if (!ID_RE.test(opts.contentId)) { // allow:regex-no-length-cap — length-bounded immediately above
620
+ throw new ContentCredentialsError("cac-implicit-label/bad-content-id",
621
+ "cacImplicitLabel: contentId must match [A-Za-z0-9._:/-]");
622
+ }
623
+ if (typeof opts.contentKind !== "string" || !CAC_KIND_ENUM[opts.contentKind]) {
624
+ throw new ContentCredentialsError("cac-implicit-label/bad-content-kind",
625
+ "cacImplicitLabel: contentKind must be one of " +
626
+ Object.keys(CAC_KIND_ENUM).join("/"));
627
+ }
628
+ if (typeof opts.generatedAt !== "string" || !ISO8601_RE.test(opts.generatedAt)) {
629
+ throw new ContentCredentialsError("cac-implicit-label/bad-generated-at",
630
+ "cacImplicitLabel: generatedAt must be ISO 8601 UTC (e.g. 2026-05-17T20:00:00Z)");
631
+ }
632
+ return Object.freeze({
633
+ aigcMarker: "AIGC",
634
+ providerName: opts.providerName,
635
+ providerCode: opts.providerCode,
636
+ contentId: opts.contentId,
637
+ contentKind: opts.contentKind,
638
+ generatedAt: opts.generatedAt,
639
+ });
640
+ }
641
+
642
+ /**
643
+ * @primitive b.contentCredentials.cacImplicitLabelRead
644
+ * @signature b.contentCredentials.cacImplicitLabelRead(bytesOrObject)
645
+ * @since 0.10.8
646
+ * @status stable
647
+ * @compliance cac-genai-label
648
+ * @related b.contentCredentials.cacImplicitLabel
649
+ *
650
+ * Reverse parser for the GB 45438-2025 implicit label. Accepts either
651
+ * a `Buffer` / `string` containing the JSON-serialized block (as the
652
+ * sender embedded in XMP / EXIF / MP4-box / etc.) or the already-
653
+ * parsed object. Returns the validated label shape or throws on any
654
+ * field that fails the same gate `cacImplicitLabel({...})` enforces.
655
+ *
656
+ * @example
657
+ * var label = b.contentCredentials.cacImplicitLabelRead(jsonBuf);
658
+ * // → { aigcMarker: "AIGC", providerName, providerCode, ... }
659
+ */
660
+ function cacImplicitLabelRead(input) {
661
+ var obj;
662
+ if (Buffer.isBuffer(input)) {
663
+ try { obj = safeJson.parse(input.toString("utf8"), { maxBytes: 64 * 1024 }); } // allow:raw-byte-literal — 64 KiB CAC label cap
664
+ catch (e) {
665
+ throw new ContentCredentialsError("cac-implicit-label/bad-json",
666
+ "cacImplicitLabelRead: JSON parse failed: " + (e && e.message));
667
+ }
668
+ } else if (typeof input === "string") {
669
+ try { obj = safeJson.parse(input, { maxBytes: 64 * 1024 }); } // allow:raw-byte-literal — 64 KiB CAC label cap
670
+ catch (e2) {
671
+ throw new ContentCredentialsError("cac-implicit-label/bad-json",
672
+ "cacImplicitLabelRead: JSON parse failed: " + (e2 && e2.message));
673
+ }
674
+ } else if (input && typeof input === "object") {
675
+ obj = input;
676
+ } else {
677
+ throw new ContentCredentialsError("cac-implicit-label/bad-input",
678
+ "cacImplicitLabelRead: input must be Buffer / string / object (got " + typeof input + ")");
679
+ }
680
+ if (obj.aigcMarker !== "AIGC") {
681
+ throw new ContentCredentialsError("cac-implicit-label/missing-aigc-marker",
682
+ "cacImplicitLabelRead: aigcMarker field must equal 'AIGC' (GB 45438-2025 §6)");
683
+ }
684
+ return cacImplicitLabel({
685
+ providerName: obj.providerName,
686
+ providerCode: obj.providerCode,
687
+ contentId: obj.contentId,
688
+ contentKind: obj.contentKind,
689
+ generatedAt: obj.generatedAt,
690
+ });
691
+ }
692
+
555
693
  module.exports = {
556
694
  build: build,
557
695
  sign: sign,
558
696
  signCose: signCose,
559
697
  verify: verify,
560
698
  required: required,
699
+ cacImplicitLabel: cacImplicitLabel,
700
+ cacImplicitLabelRead: cacImplicitLabelRead,
561
701
  REQUIRED_FIELDS: REQUIRED_FIELDS.slice(),
562
702
  COSE_ALGS: Object.assign({}, COSE_ALGS),
563
703
  ContentCredentialsError: ContentCredentialsError,