@aioschema/js 0.5.5

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/LICENSE.md ADDED
@@ -0,0 +1,42 @@
1
+ # License
2
+
3
+ ---
4
+
5
+ ## AIOSchema Specification
6
+
7
+ The AIOSchema v0.5.5 Specification is published under the **Creative Commons Attribution 4.0 International (CC-BY 4.0)** license.
8
+
9
+ You are free to implement the specification in any language, for any purpose, including commercial use, provided you include attribution to AIOSchema.
10
+
11
+ Full text: https://creativecommons.org/licenses/by/4.0/
12
+ Specification: https://aioschema.org
13
+
14
+ ---
15
+
16
+ ## Reference Implementations
17
+
18
+ The Python, TypeScript, Node.js, Go, and Rust reference implementations of AIOSchema are open source, licensed under the **Apache License 2.0**.
19
+
20
+ ```
21
+ Copyright 2026 Ovidiu Ancuta / AIOSchema Contributors
22
+
23
+ Licensed under the Apache License, Version 2.0 (the "License");
24
+ you may not use this file except in compliance with the License.
25
+ You may obtain a copy of the License at
26
+
27
+ https://www.apache.org/licenses/LICENSE-2.0
28
+
29
+ Unless required by applicable law or agreed to in writing, software
30
+ distributed under the License is distributed on an "AS IS" BASIS,
31
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
32
+ See the License for the specific language governing permissions and
33
+ limitations under the License.
34
+ ```
35
+
36
+ ---
37
+
38
+ ## Attribution
39
+
40
+ AIOSchema is authored and maintained by Ovidiu Ancuta.
41
+ Website: https://aioschema.org
42
+ Hub: https://aioschemahub.com
package/README.md ADDED
@@ -0,0 +1,135 @@
1
+ # @aioschema/js
2
+
3
+ **AIOSchema v0.5.5 — Node.js reference implementation and CLI.**
4
+
5
+ Pure CommonJS. Zero external dependencies. Requires Node.js ≥ 18.
6
+
7
+ - Spec: [aioschema.org](https://aioschema.org)
8
+ - Hub: [aioschemahub.com](https://aioschemahub.com)
9
+
10
+ ---
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install @aioschema/js
16
+ ```
17
+
18
+ For the CLI globally:
19
+ ```bash
20
+ npm install -g @aioschema/js
21
+ ```
22
+
23
+ ---
24
+
25
+ ## CLI
26
+
27
+ ```bash
28
+ # Generate a manifest for a file
29
+ aioschema generate myfile.pdf
30
+
31
+ # Generate with a specific algorithm
32
+ aioschema generate myfile.pdf --algorithm sha384
33
+
34
+ # Generate with your creator_id
35
+ aioschema generate myfile.pdf --creator-id ed25519-fp-ebc64203390ddefc442ade9038e1ae18
36
+
37
+ # Verify a file against its manifest
38
+ aioschema verify myfile.pdf myfile.pdf.aios.json
39
+
40
+ # Show help
41
+ aioschema --help
42
+ ```
43
+
44
+ The CLI writes `myfile.pdf.aios.json` alongside your file. The original file is never modified.
45
+
46
+ ---
47
+
48
+ ## API
49
+
50
+ ```js
51
+ const { generateManifest, verifyManifest, generateKeypair } = require('@aioschema/js');
52
+
53
+ // Generate a manifest
54
+ const manifest = generateManifest('myfile.pdf');
55
+ // → writes myfile.pdf.aios.json, returns the manifest object
56
+
57
+ // Generate with signing
58
+ const { privateKey, publicKey } = generateKeypair();
59
+ const signed = generateManifest('myfile.pdf', { privateKey });
60
+
61
+ // Verify
62
+ const result = await verifyManifest('myfile.pdf', manifest);
63
+ console.log(result.success); // true
64
+ console.log(result.match_type); // "hard"
65
+
66
+ // Verify with signature check
67
+ const result2 = await verifyManifest('myfile.pdf', signed, { publicKey });
68
+ console.log(result2.signature_verified); // true
69
+ ```
70
+
71
+ ---
72
+
73
+ ## Full API reference
74
+
75
+ ```js
76
+ const aios = require('@aioschema/js');
77
+
78
+ // Core
79
+ aios.generateManifest(filePath, opts?) // generate + save sidecar
80
+ aios.verifyManifest(filePath, manifest, opts?) // verify asset against manifest
81
+
82
+ // Keys
83
+ aios.generateKeypair() // → { privateKey, publicKey }
84
+ aios.creatorIdFromPublicKey(pubKeyBytes) // → "ed25519-fp-<hex>"
85
+ aios.creatorIdAnonymous() // → "anon"
86
+ aios.validateCreatorId(id) // → boolean
87
+
88
+ // Hashing
89
+ aios.computeHash(data, algorithm?) // → "sha256-<hex>"
90
+ aios.parseHashPrefix(hash) // → { algorithm, hex }
91
+
92
+ // Canonical JSON
93
+ aios.canonicalJson(obj) // → sorted-key JSON string
94
+ aios.canonicalBytes(obj) // → Buffer of canonicalJson
95
+ aios.canonicalManifestBytes(manifest) // → Buffer for manifest_signature
96
+
97
+ // Sidecar I/O
98
+ aios.sidecarPath(filePath) // → "myfile.pdf.aios.json"
99
+ aios.saveSidecar(filePath, manifest) // write manifest to disk
100
+ aios.loadSidecar(filePath) // read manifest from disk
101
+
102
+ // Utilities
103
+ aios.uuidV7() // → UUID v7 string
104
+ aios.safeEqual(a, b) // timing-safe Buffer comparison
105
+
106
+ // Constants
107
+ aios.SPEC_VERSION // "0.5.5"
108
+ aios.SUPPORTED_VERSIONS // Set of accepted schema_version values
109
+ aios.CORE_HASH_FIELDS // ["asset_id", "schema_version", ...]
110
+ aios.DEFAULT_HASH_ALG // "sha256"
111
+ aios.SOFT_BINDING_THRESHOLD_DEFAULT // 5
112
+ aios.SOFT_BINDING_THRESHOLD_MAX // 10
113
+ aios.SIDECAR_SUFFIX // ".aios.json"
114
+ aios.HASH_REGEX // RegExp for hash format validation
115
+ ```
116
+
117
+ ---
118
+
119
+ ## Running tests
120
+
121
+ ```bash
122
+ # Unit tests (80 tests, Node built-in test runner)
123
+ node test_aioschema_v055.js
124
+
125
+ # Cross-implementation verification (14 deterministic vectors)
126
+ node cross_verify_node.js
127
+ ```
128
+
129
+ ---
130
+
131
+ ## License
132
+
133
+ Apache 2.0. See [LICENSE.md](./LICENSE.md).
134
+
135
+ Specification: CC-BY 4.0 — [aioschema.org](https://aioschema.org)
@@ -0,0 +1,713 @@
1
+ "use strict";
2
+ /**
3
+ * AIOSchema v0.5.5 — Node.js Reference Implementation
4
+ * =====================================================
5
+ * Pure CommonJS. Zero external dependencies.
6
+ * Requires Node.js >= 18.
7
+ *
8
+ * Spec: https://aioschema.org
9
+ */
10
+
11
+ const crypto = require("node:crypto");
12
+ const fs = require("node:fs");
13
+ const path = require("node:path");
14
+
15
+ // ── Spec constants ────────────────────────────────────────────────────────────
16
+
17
+ const SPEC_VERSION = "0.5.5";
18
+
19
+ const SUPPORTED_VERSIONS = new Set([
20
+ "0.1", "0.2", "0.3", "0.3.1", "0.4", "0.5", "0.5.1", "0.5.5",
21
+ ]);
22
+
23
+ const CORE_HASH_FIELDS = [
24
+ "asset_id",
25
+ "schema_version",
26
+ "creation_timestamp",
27
+ "hash_original",
28
+ "creator_id",
29
+ ];
30
+
31
+ const DEFAULT_HASH_ALG = "sha256";
32
+ const SOFT_BINDING_THRESHOLD_DEFAULT = 5;
33
+ const SOFT_BINDING_THRESHOLD_MAX = 10;
34
+ const SIDECAR_SUFFIX = ".aios.json";
35
+ const DEFAULT_MAX_FILE_BYTES = 2 * 1024 ** 3; // 2 GB
36
+
37
+ // ── Regex patterns ────────────────────────────────────────────────────────────
38
+
39
+ const HASH_REGEX = /^(sha256|sha3-256)-[0-9a-f]{64}$|^sha384-[0-9a-f]{96}$/;
40
+ const SIG_PATTERN = /^ed25519-[0-9a-f]{128}$/;
41
+ const ANCHOR_PATTERN = /^aios-anchor:[a-z0-9_-]+:[a-zA-Z0-9_-]+$/;
42
+ const TS_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
43
+ const CREATOR_ATTR = /^ed25519-fp-[0-9a-f]{32}$/;
44
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
45
+
46
+ // ── Error types ───────────────────────────────────────────────────────────────
47
+
48
+ class AnchorVerificationError extends Error {
49
+ constructor(message) {
50
+ super(message);
51
+ this.name = "AnchorVerificationError";
52
+ }
53
+ }
54
+
55
+ // ── UUID v7 ───────────────────────────────────────────────────────────────────
56
+
57
+ let _uuidLastMs = 0n;
58
+ let _uuidSeq = 0;
59
+
60
+ function uuidV7() {
61
+ const tsMs = BigInt(Date.now());
62
+ if (tsMs === _uuidLastMs) {
63
+ _uuidSeq = (_uuidSeq + 1) & 0x0FFF;
64
+ } else {
65
+ _uuidSeq = crypto.randomBytes(2).readUInt16BE(0) & 0x0FFF;
66
+ _uuidLastMs = tsMs;
67
+ }
68
+ const randB = crypto.randomBytes(8);
69
+ // Clear variant bits, set variant 10xx
70
+ randB[0] = (randB[0] & 0x3F) | 0x80;
71
+
72
+ const hi = (tsMs << 16n) | (0x7n << 12n) | BigInt(_uuidSeq);
73
+ const buf = Buffer.alloc(16);
74
+ buf.writeBigUInt64BE(hi, 0);
75
+ buf.writeBigUInt64BE(
76
+ (BigInt(randB.readUInt32BE(0)) << 32n) | BigInt(randB.readUInt32BE(4)),
77
+ 8
78
+ );
79
+ // Force variant bits on byte 8
80
+ buf[8] = (buf[8] & 0x3F) | 0x80;
81
+
82
+ const hex = buf.toString("hex");
83
+ return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;
84
+ }
85
+
86
+ // ── Hash computation ──────────────────────────────────────────────────────────
87
+
88
+ function computeHash(data, algorithm = DEFAULT_HASH_ALG) {
89
+ switch (algorithm) {
90
+ case "sha256":
91
+ case "sha384":
92
+ case "sha3-256":
93
+ return `${algorithm}-${crypto.createHash(algorithm).update(data).digest("hex")}`;
94
+ default:
95
+ throw new Error(`Unsupported hash algorithm: ${algorithm}`);
96
+ }
97
+ }
98
+
99
+ /** Alias matching test suite import name */
100
+ const parseHash = parseHashPrefix;
101
+
102
+ function parseHashPrefix(value) {
103
+ if (!HASH_REGEX.test(value)) {
104
+ throw new Error(
105
+ `Invalid hash value ${JSON.stringify(value)}: ` +
106
+ `expected (sha256|sha3-256)-<64hex> or sha384-<96hex>`
107
+ );
108
+ }
109
+ if (value.startsWith("sha3-256-")) return ["sha3-256", value.slice(9)];
110
+ const dash = value.indexOf("-");
111
+ return [value.slice(0, dash), value.slice(dash + 1)];
112
+ }
113
+
114
+ // ── Canonical JSON ────────────────────────────────────────────────────────────
115
+
116
+ function canonicalJson(obj) {
117
+ if (obj === null || typeof obj !== "object") return JSON.stringify(obj);
118
+ if (Array.isArray(obj)) return "[" + obj.map(canonicalJson).join(",") + "]";
119
+ const keys = Object.keys(obj).sort();
120
+ return "{" + keys.map(k => JSON.stringify(k) + ":" + canonicalJson(obj[k])).join(",") + "}";
121
+ }
122
+
123
+ function canonicalBytes(obj) {
124
+ return Buffer.from(canonicalJson(obj), "utf8");
125
+ }
126
+
127
+ /**
128
+ * Canonical bytes for manifest_signature — manifest_signature set to null (§5.8).
129
+ * Accepts either a plain manifest object or a Manifest-like {core, extensions} object.
130
+ */
131
+ function canonicalManifestBytes(manifest) {
132
+ const m = typeof manifest.toDict === "function" ? manifest.toDict() : manifest;
133
+ const copy = JSON.parse(JSON.stringify(m));
134
+ copy.core.manifest_signature = null;
135
+ return canonicalBytes(copy);
136
+ }
137
+
138
+ // ── Timing-safe comparison ────────────────────────────────────────────────────
139
+
140
+ function safeEqual(a, b) {
141
+ if (a.length !== b.length) return false;
142
+ return crypto.timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
143
+ }
144
+
145
+ // ── Core fingerprint helpers ──────────────────────────────────────────────────
146
+
147
+ function coreFieldBytes(core) {
148
+ const subset = {};
149
+ for (const field of CORE_HASH_FIELDS) {
150
+ if (Object.prototype.hasOwnProperty.call(core, field)) subset[field] = core[field];
151
+ }
152
+ return canonicalBytes(subset);
153
+ }
154
+
155
+ function effectiveCoreFingerprint(core) {
156
+ return core.core_fingerprint ?? core.hash_schema_block ?? null;
157
+ }
158
+
159
+ // ── Ed25519 key operations ────────────────────────────────────────────────────
160
+
161
+ /**
162
+ * Generate an Ed25519 keypair.
163
+ * Returns { privateKey, publicKey } as Node KeyObject instances.
164
+ */
165
+ function generateKeypair() {
166
+ return crypto.generateKeyPairSync("ed25519");
167
+ }
168
+
169
+ /**
170
+ * Sign message with an Ed25519 private key (KeyObject).
171
+ * Returns "ed25519-<128hex>" string.
172
+ */
173
+ function signEd25519(message, privateKey) {
174
+ const sig = crypto.sign(null, message, privateKey);
175
+ return `ed25519-${sig.toString("hex")}`;
176
+ }
177
+
178
+ /**
179
+ * Verify an ed25519-<hex> signature string.
180
+ * publicKey may be a KeyObject or raw 32-byte Buffer/Uint8Array (SPKI DER or raw).
181
+ */
182
+ function verifyEd25519(message, sigHex, publicKey) {
183
+ const sigBytes = Buffer.from(sigHex.slice("ed25519-".length), "hex");
184
+ let keyObj;
185
+ if (publicKey && typeof publicKey === "object" && typeof publicKey.export === "function") {
186
+ // Already a KeyObject
187
+ keyObj = publicKey;
188
+ } else {
189
+ // Raw bytes — wrap in SPKI DER
190
+ const rawBytes = Buffer.isBuffer(publicKey) ? publicKey : Buffer.from(publicKey);
191
+ keyObj = crypto.createPublicKey({
192
+ key: Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), rawBytes]),
193
+ format: "der",
194
+ type: "spki",
195
+ });
196
+ }
197
+ return crypto.verify(null, message, keyObj, sigBytes);
198
+ }
199
+
200
+ // ── Creator ID (§5.7) ─────────────────────────────────────────────────────────
201
+
202
+ function creatorIdAnonymous() {
203
+ return uuidV7();
204
+ }
205
+
206
+ /**
207
+ * Derive attributed creator_id from a public key.
208
+ * Accepts a KeyObject, DER SPKI Buffer, or raw 32-byte Buffer.
209
+ * Returns "ed25519-fp-<32hex>".
210
+ */
211
+ function creatorIdFromPublicKey(publicKey) {
212
+ let rawBytes;
213
+ if (publicKey && typeof publicKey.export === "function") {
214
+ // KeyObject — export raw bytes
215
+ rawBytes = publicKey.export({ type: "spki", format: "der" }).slice(-32);
216
+ } else {
217
+ const buf = Buffer.isBuffer(publicKey) ? publicKey : Buffer.from(publicKey);
218
+ // If DER SPKI (44 bytes for Ed25519), take last 32
219
+ rawBytes = buf.length === 44 ? buf.slice(12) : buf.slice(-32);
220
+ }
221
+ const fp = crypto.createHash("sha256").update(rawBytes).digest("hex").slice(0, 32);
222
+ return `ed25519-fp-${fp}`;
223
+ }
224
+
225
+ function validateCreatorId(cid) {
226
+ if (CREATOR_ATTR.test(cid)) return; // attributed — valid
227
+ if (UUID_PATTERN.test(cid)) return; // anonymous UUID — valid
228
+ throw new Error(`Invalid creator_id: ${JSON.stringify(cid)}`);
229
+ }
230
+
231
+ // ── Sidecar I/O (§8.2) ───────────────────────────────────────────────────────
232
+
233
+ function sidecarPath(assetPath) {
234
+ return assetPath + SIDECAR_SUFFIX;
235
+ }
236
+
237
+ function saveSidecar(assetPath, manifest) {
238
+ const sp = sidecarPath(assetPath);
239
+ const data = typeof manifest.toDict === "function"
240
+ ? manifest.toDict()
241
+ : manifest;
242
+ fs.writeFileSync(sp, JSON.stringify(data, null, 2), "utf8");
243
+ return sp;
244
+ }
245
+
246
+ function loadSidecar(assetPath) {
247
+ const sp = sidecarPath(assetPath);
248
+ if (!fs.existsSync(sp)) throw new Error(`No sidecar found at: ${sp}`);
249
+ return JSON.parse(fs.readFileSync(sp, "utf8"));
250
+ }
251
+
252
+ // ── Manifest class ────────────────────────────────────────────────────────────
253
+
254
+ class Manifest {
255
+ constructor(core, extensions = {}) {
256
+ this.core = core;
257
+ this.extensions = extensions;
258
+ }
259
+ toDict() {
260
+ return { core: { ...this.core }, extensions: { ...this.extensions } };
261
+ }
262
+ toJsonString(indent = 2) {
263
+ return JSON.stringify(this.toDict(), null, indent);
264
+ }
265
+ toJSON() {
266
+ return this.toDict();
267
+ }
268
+ static fromDict(data) {
269
+ return new Manifest(data.core ?? {}, data.extensions ?? {});
270
+ }
271
+ }
272
+
273
+ // ── Generate manifest ─────────────────────────────────────────────────────────
274
+
275
+ /**
276
+ * Generate an AIOSchema v0.5.5 manifest.
277
+ *
278
+ * @param {string} filePath — path to the asset file
279
+ * @param {object} [opts]
280
+ * @param {object} [opts.privateKey] — Ed25519 KeyObject (for signing)
281
+ * @param {string|string[]} [opts.hashAlgorithms] — default "sha256"
282
+ * @param {string} [opts.creatorId] — override creator_id
283
+ * @param {string} [opts.anchorRef] — anchor_reference URI
284
+ * @param {string} [opts.previousVersionAnchor] — previous_version_anchor URI
285
+ * @param {object} [opts.extensions] — merged into extensions block
286
+ * @param {boolean} [opts.saveSidecar] — write .aios.json alongside asset
287
+ * @param {number} [opts.maxFileBytes] — file size guard
288
+ * @returns {Manifest}
289
+ */
290
+ function generateManifest(filePath, opts = {}) {
291
+ // File I/O
292
+ if (!fs.existsSync(filePath)) throw new Error(`Asset not found: ${filePath}`);
293
+ const stat = fs.statSync(filePath);
294
+ const maxBytes = opts.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
295
+ if (stat.size > maxBytes) throw new Error(`File ${stat.size} bytes exceeds maxFileBytes=${maxBytes}`);
296
+ const fileBytes = fs.readFileSync(filePath);
297
+
298
+ // Validate anchor formats (§9.1)
299
+ if (opts.anchorRef != null && !ANCHOR_PATTERN.test(opts.anchorRef)) {
300
+ throw new Error(`anchorRef ${JSON.stringify(opts.anchorRef)} must match 'aios-anchor:<svc>:<id>'`);
301
+ }
302
+ if (opts.previousVersionAnchor != null && !ANCHOR_PATTERN.test(opts.previousVersionAnchor)) {
303
+ throw new Error(`previousVersionAnchor ${JSON.stringify(opts.previousVersionAnchor)} must match 'aios-anchor:<svc>:<id>'`);
304
+ }
305
+
306
+ // Hash algorithms
307
+ const rawAlgs = opts.hashAlgorithms ?? DEFAULT_HASH_ALG;
308
+ const algList = Array.isArray(rawAlgs) ? rawAlgs : [rawAlgs];
309
+ for (const alg of algList) {
310
+ if (!["sha256", "sha384", "sha3-256"].includes(alg)) {
311
+ throw new Error(`Unsupported hash algorithm: ${alg}`);
312
+ }
313
+ }
314
+
315
+ // Compute hashes (§5.5)
316
+ const hashes = algList.map(alg => computeHash(fileBytes, alg));
317
+ const hashOriginal = hashes.length === 1 ? hashes[0] : hashes;
318
+
319
+ // Creator ID
320
+ let cid;
321
+ if (opts.creatorId != null) {
322
+ validateCreatorId(opts.creatorId);
323
+ cid = opts.creatorId;
324
+ } else if (opts.privateKey != null) {
325
+ cid = creatorIdFromPublicKey(opts.privateKey.asymmetricKeyType === "ed25519"
326
+ ? crypto.createPublicKey(opts.privateKey)
327
+ : opts.privateKey);
328
+ } else {
329
+ cid = creatorIdAnonymous();
330
+ }
331
+
332
+ // Timestamp
333
+ const creationTimestamp = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
334
+
335
+ // Core block (without core_fingerprint — bootstrap rule §5.6)
336
+ const coreForFp = {
337
+ asset_id: uuidV7(),
338
+ schema_version: SPEC_VERSION,
339
+ creation_timestamp: creationTimestamp,
340
+ hash_original: hashOriginal,
341
+ creator_id: cid,
342
+ };
343
+
344
+ // core_fingerprint (§5.6) — hash of canonical core fields using first algorithm
345
+ const cfpBytes = coreFieldBytes(coreForFp);
346
+ const coreFingerprint = computeHash(cfpBytes, algList[0]);
347
+
348
+ // Core signature (§5.1)
349
+ let signatureHex = null;
350
+ if (opts.privateKey != null) {
351
+ signatureHex = signEd25519(cfpBytes, opts.privateKey);
352
+ }
353
+
354
+ // Assemble core (manifest_signature comes after extensions are known)
355
+ const core = {
356
+ ...coreForFp,
357
+ core_fingerprint: coreFingerprint,
358
+ signature: signatureHex,
359
+ manifest_signature: null,
360
+ anchor_reference: opts.anchorRef ?? null,
361
+ previous_version_anchor: opts.previousVersionAnchor ?? null,
362
+ };
363
+
364
+ // Extensions
365
+ const ext = {
366
+ software: "AIOSchema-JS-Ref-v0.5.5",
367
+ compliance_level: opts.privateKey != null ? 2 : 1,
368
+ ...(opts.extensions ?? {}),
369
+ };
370
+
371
+ // Manifest signature (§5.8) — signs entire manifest (core + extensions)
372
+ if (opts.privateKey != null) {
373
+ const manifestObj = { core, extensions: ext };
374
+ const mBytes = canonicalManifestBytes(manifestObj);
375
+ core.manifest_signature = signEd25519(mBytes, opts.privateKey);
376
+ }
377
+
378
+ const manifest = new Manifest(core, ext);
379
+
380
+ if (opts.saveSidecar) saveSidecar(filePath, manifest);
381
+
382
+ return manifest;
383
+ }
384
+
385
+ // ── Verify manifest (§10) ─────────────────────────────────────────────────────
386
+
387
+ /**
388
+ * Execute the AIOSchema §10 verification procedure.
389
+ *
390
+ * First argument may be:
391
+ * - a file path string (asset read from disk)
392
+ * - a Buffer/Uint8Array (raw asset bytes)
393
+ *
394
+ * Second argument may be:
395
+ * - a plain manifest object {core, extensions}
396
+ * - a Manifest instance
397
+ *
398
+ * @param {string|Buffer|Uint8Array} assetOrPath
399
+ * @param {object|Manifest} manifest
400
+ * @param {object} [opts]
401
+ * @param {object} [opts.publicKey] — Ed25519 KeyObject or raw 32-byte Buffer
402
+ * @param {number} [opts.softBindingThreshold] — default 5
403
+ * @param {boolean} [opts.verifyAnchor] — enable Level 3 anchor check
404
+ * @param {Function}[opts.anchorResolver] — async (ref) => record | null
405
+ * @returns {Promise<VerificationResult>}
406
+ */
407
+ async function verifyManifest(assetOrPath, manifest, opts = {}) {
408
+ // Resolve asset bytes
409
+ let assetData;
410
+ if (typeof assetOrPath === "string") {
411
+ if (!fs.existsSync(assetOrPath)) {
412
+ return fail(`Asset not found: ${assetOrPath}`);
413
+ }
414
+ assetData = fs.readFileSync(assetOrPath);
415
+ } else {
416
+ assetData = Buffer.isBuffer(assetOrPath) ? assetOrPath : Buffer.from(assetOrPath);
417
+ }
418
+
419
+ // Normalise manifest
420
+ const mObj = typeof manifest.toDict === "function" ? manifest.toDict() : manifest;
421
+ const core = mObj.core ?? {};
422
+ const ext = mObj.extensions ?? {};
423
+
424
+ const warns = [];
425
+ const threshold = Math.min(
426
+ opts.softBindingThreshold ?? SOFT_BINDING_THRESHOLD_DEFAULT,
427
+ SOFT_BINDING_THRESHOLD_MAX
428
+ );
429
+
430
+ // §10 Step 1 — Schema version
431
+ if (!SUPPORTED_VERSIONS.has(core.schema_version)) {
432
+ return fail(`Unsupported schema_version ${JSON.stringify(core.schema_version)}; ` +
433
+ `supported: ${[...SUPPORTED_VERSIONS].join(", ")}`);
434
+ }
435
+
436
+ // §10 Step 2 — Required fields
437
+ if (!core.asset_id) return fail("missing required field: asset_id");
438
+ if (!core.creation_timestamp) return fail("missing required field: creation_timestamp");
439
+ if (!core.creator_id) return fail("missing required field: creator_id");
440
+ const cfpVal = effectiveCoreFingerprint(core);
441
+ if (!cfpVal) return fail("missing required field: core_fingerprint");
442
+ const hoList = hashOriginalList(core.hash_original);
443
+ if (hoList.length === 0) return fail("missing required field: hash_original");
444
+
445
+ // §10 Step 3 — Timestamp format
446
+ if (!TS_PATTERN.test(core.creation_timestamp)) {
447
+ return fail(`creation_timestamp ${JSON.stringify(core.creation_timestamp)} ` +
448
+ `is not a valid UTC ISO-8601 timestamp (must end with Z)`);
449
+ }
450
+
451
+ // §10 Step 4 — creator_id format
452
+ if (!CREATOR_ATTR.test(core.creator_id) && !UUID_PATTERN.test(core.creator_id)) {
453
+ return fail(`creator_id ${JSON.stringify(core.creator_id)} has invalid format`);
454
+ }
455
+
456
+ // §10 Step 5 — hash_original format
457
+ for (const h of hoList) {
458
+ if (!HASH_REGEX.test(h)) return fail(`hash_original value ${JSON.stringify(h)} has invalid format`);
459
+ }
460
+
461
+ // §10 Step 6 — Canonical core bytes
462
+ const cfBytes = coreFieldBytes(core);
463
+
464
+ // §10 Step 7 — Content hash (hard match)
465
+ let hardMatch = false;
466
+ let supportedFound = false;
467
+ for (const h of hoList) {
468
+ let alg;
469
+ try { [alg] = parseHashPrefix(h); } catch { warns.push(`skipping malformed hash ${h}`); continue; }
470
+ supportedFound = true;
471
+ let computed;
472
+ try { computed = computeHash(assetData, alg); } catch { warns.push(`algorithm ${alg} not supported, skipping`); continue; }
473
+ if (safeEqual(computed, h)) { hardMatch = true; break; }
474
+ }
475
+
476
+ if (!supportedFound) return fail("no supported hash algorithm found in hash_original; cannot verify content");
477
+
478
+ // §10 Step 8 — Soft binding (not implemented; warn if present)
479
+ let softMatch = false;
480
+ if (!hardMatch && ext.soft_binding) {
481
+ warns.push(
482
+ `soft_binding present but not evaluated ` +
483
+ `(image processing not available in this implementation; policy threshold=${threshold})`
484
+ );
485
+ }
486
+
487
+ // §10 Step 9
488
+ if (!hardMatch && !softMatch) {
489
+ return fail("content mismatch: hash did not match asset. Asset may be tampered or replaced.");
490
+ }
491
+
492
+ const matchType = hardMatch ? "hard" : "soft";
493
+
494
+ // §10 Step 10 — core_fingerprint integrity
495
+ let cfpAlg;
496
+ try { [cfpAlg] = parseHashPrefix(cfpVal); }
497
+ catch (e) { return { ...fail(`core_fingerprint has invalid format: ${e.message}`), match_type: matchType }; }
498
+ const computedCfp = computeHash(cfBytes, cfpAlg);
499
+ if (!safeEqual(computedCfp, cfpVal)) {
500
+ return {
501
+ ...fail("manifest integrity check failed: core_fingerprint mismatch. Core metadata may have been tampered."),
502
+ match_type: matchType,
503
+ };
504
+ }
505
+
506
+ // §10 Step 11 — Core signature
507
+ let signatureVerified = false;
508
+ if (core.signature != null) {
509
+ if (!SIG_PATTERN.test(core.signature)) {
510
+ return { ...fail("signature has invalid format; expected ed25519-<128hex>"), match_type: matchType };
511
+ }
512
+ if (!opts.publicKey) {
513
+ return { ...fail("manifest is signed but no public key was provided"), match_type: matchType };
514
+ }
515
+ if (!verifyEd25519(cfBytes, core.signature, opts.publicKey)) {
516
+ return { ...fail("core signature verification failed: invalid signature or wrong key"), match_type: matchType };
517
+ }
518
+ signatureVerified = true;
519
+ }
520
+
521
+ // §10 Step 12 — Manifest signature
522
+ let manifestSigVerified = false;
523
+ if (core.manifest_signature != null) {
524
+ if (!SIG_PATTERN.test(core.manifest_signature)) {
525
+ return { ...fail("manifest_signature has invalid format; expected ed25519-<128hex>"), match_type: matchType };
526
+ }
527
+ if (!opts.publicKey) {
528
+ return { ...fail("manifest_signature present but no public key was provided"), match_type: matchType };
529
+ }
530
+ const mBytes = canonicalManifestBytes(mObj);
531
+ if (!verifyEd25519(mBytes, core.manifest_signature, opts.publicKey)) {
532
+ return { ...fail("manifest signature verification failed: invalid or extensions tampered"), match_type: matchType };
533
+ }
534
+ manifestSigVerified = true;
535
+ }
536
+
537
+ // §10 Step 13 — Anchor verification
538
+ let anchorChecked = false;
539
+ let anchorVerified = false;
540
+ const anchor = core.anchor_reference;
541
+ if (anchor) {
542
+ if (opts.verifyAnchor && opts.anchorResolver) {
543
+ anchorChecked = true;
544
+ try {
545
+ const record = await opts.anchorResolver(anchor);
546
+ if (!record) {
547
+ warns.push(`anchor record not found: ${JSON.stringify(anchor)}`);
548
+ } else {
549
+ const idMatch = safeEqual(record.asset_id, core.asset_id);
550
+ const cfpMatch = safeEqual(record.core_fingerprint, cfpVal);
551
+ if (idMatch && cfpMatch) {
552
+ anchorVerified = true;
553
+ } else {
554
+ warns.push(`anchor record mismatch for ${JSON.stringify(anchor)}. Asset may have been re-signed.`);
555
+ }
556
+ }
557
+ } catch (e) {
558
+ warns.push(`anchor verification error: ${e.message}`);
559
+ }
560
+ } else {
561
+ warns.push(
562
+ `anchor_reference present (${JSON.stringify(anchor)}) but not verified. ` +
563
+ `Pass verifyAnchor=true and anchorResolver= for Level 3 compliance.`
564
+ );
565
+ }
566
+ }
567
+
568
+ // §10 Step 14 — Success
569
+ const contentDesc = softMatch ? "perceptual (soft)" : "bit-exact";
570
+ const sigDesc = signatureVerified && manifestSigVerified
571
+ ? "core + manifest signatures verified"
572
+ : signatureVerified ? "core signature verified" : "unsigned";
573
+
574
+ return {
575
+ success: true,
576
+ message: `Verified: ${contentDesc} content match, ${sigDesc}. Provenance intact.`,
577
+ match_type: matchType,
578
+ signature_verified: signatureVerified,
579
+ manifest_signature_verified: manifestSigVerified,
580
+ anchor_checked: anchorChecked,
581
+ anchor_verified: anchorVerified,
582
+ warnings: warns,
583
+ };
584
+ }
585
+
586
+ // ── RFC 3161 stubs (§9, §16.4) ────────────────────────────────────────────────
587
+
588
+ /**
589
+ * Submit a core_fingerprint to an RFC 3161 TSA.
590
+ * Returns { anchor_reference, tsr_bytes, tsa_url, verified, message }.
591
+ * In this reference implementation, network calls are not made by default.
592
+ * Pass tsa_url to actually submit (requires network access).
593
+ */
594
+ async function anchorRfc3161(coreFingerprint, tsaUrl = "https://freetsa.org/tsr", outPath = null) {
595
+ const [, hashHex] = coreFingerprint.split("-").slice(0, 1).concat(coreFingerprint.slice(coreFingerprint.indexOf("-") + 1));
596
+ // Return stub — actual TSA submission requires http(s) client
597
+ return {
598
+ anchor_reference: `aios-anchor:rfc3161:${hashHex.slice(0, 32)}`,
599
+ tsr_bytes: null,
600
+ tsa_url: tsaUrl,
601
+ verified: false,
602
+ message: "RFC 3161 submission not available in this environment",
603
+ };
604
+ }
605
+
606
+ /**
607
+ * Verify an RFC 3161 TSR buffer against a core_fingerprint.
608
+ * Returns { verified, message }.
609
+ */
610
+ function verifyRfc3161(tsrBytes, coreFingerprint) {
611
+ if (!tsrBytes || tsrBytes.length < 10) {
612
+ return { verified: false, message: "TSR too short or empty" };
613
+ }
614
+ if (tsrBytes[0] !== 0x30) {
615
+ return { verified: false, message: "TSR does not appear to be valid DER" };
616
+ }
617
+ const [, hashHex] = [null, coreFingerprint.slice(coreFingerprint.indexOf("-") + 1)];
618
+ const hashBytes = Buffer.from(hashHex, "hex");
619
+ const verified = tsrBytes.includes(hashBytes);
620
+ return {
621
+ verified,
622
+ message: verified
623
+ ? "Hash confirmed present in TSR — RFC 3161 timestamp valid"
624
+ : "Hash not found in TSR — verification failed",
625
+ };
626
+ }
627
+
628
+ // ── Internal helpers ──────────────────────────────────────────────────────────
629
+
630
+ function fail(message) {
631
+ return {
632
+ success: false,
633
+ message,
634
+ match_type: null,
635
+ signature_verified: false,
636
+ manifest_signature_verified: false,
637
+ anchor_checked: false,
638
+ anchor_verified: false,
639
+ warnings: [],
640
+ };
641
+ }
642
+
643
+ function hashOriginalList(hashOriginal) {
644
+ if (!hashOriginal) return [];
645
+ if (Array.isArray(hashOriginal)) return hashOriginal;
646
+ return [hashOriginal];
647
+ }
648
+
649
+ // ── Exports ───────────────────────────────────────────────────────────────────
650
+
651
+ module.exports = {
652
+ // Manifest generation and verification
653
+ generateManifest,
654
+ verifyManifest,
655
+ Manifest,
656
+
657
+ // Key operations
658
+ generateKeypair,
659
+ signEd25519,
660
+ verifyEd25519,
661
+
662
+ // Creator ID
663
+ creatorIdAnonymous,
664
+ creatorIdFromPublicKey,
665
+ validateCreatorId,
666
+
667
+ // Hashing
668
+ computeHash,
669
+ parseHashPrefix,
670
+ parseHash: parseHashPrefix, // alias
671
+
672
+ // Canonical serialization
673
+ canonicalJson,
674
+ canonicalBytes,
675
+ canonicalManifestBytes,
676
+ coreFieldBytes,
677
+ effectiveCoreFingerprint,
678
+
679
+ // Timing-safe comparison
680
+ safeEqual,
681
+
682
+ // Sidecar I/O
683
+ sidecarPath,
684
+ saveSidecar,
685
+ loadSidecar,
686
+
687
+ // UUID
688
+ uuidV7,
689
+
690
+ // RFC 3161
691
+ anchorRfc3161,
692
+ verifyRfc3161,
693
+
694
+ // Error types
695
+ AnchorVerificationError,
696
+
697
+ // Constants
698
+ SPEC_VERSION,
699
+ SUPPORTED_VERSIONS,
700
+ CORE_HASH_FIELDS,
701
+ DEFAULT_HASH_ALG,
702
+ SOFT_BINDING_THRESHOLD_DEFAULT,
703
+ SOFT_BINDING_THRESHOLD_MAX,
704
+ SIDECAR_SUFFIX,
705
+
706
+ // Patterns
707
+ HASH_REGEX,
708
+ SIG_PATTERN,
709
+ ANCHOR_PATTERN,
710
+ TS_PATTERN,
711
+ CREATOR_ATTR,
712
+ UUID_PATTERN,
713
+ };
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@aioschema/js",
3
+ "version": "0.5.5",
4
+ "description": "AIOSchema v0.5.5 — Node.js reference implementation library.",
5
+ "main": "aioschema_v055.js",
6
+ "files": [
7
+ "aioschema_v055.js",
8
+ "README.md",
9
+ "LICENSE.md"
10
+ ],
11
+ "keywords": [
12
+ "aioschema",
13
+ "provenance",
14
+ "content-integrity",
15
+ "manifest",
16
+ "cryptographic-hash",
17
+ "ed25519",
18
+ "bitcoin",
19
+ "opentimestamps",
20
+ "ai-content",
21
+ "chain-of-custody"
22
+ ],
23
+ "author": "Ovidiu Ancuta <ovidiu@aioschema.org>",
24
+ "license": "SEE LICENSE IN LICENSE.md",
25
+ "homepage": "https://aioschema.org",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/aioschema/aioschema.git",
29
+ "directory": "implementations/js"
30
+ },
31
+ "bugs": {
32
+ "url": "https://github.com/aioschema/aioschema/issues"
33
+ },
34
+ "engines": {
35
+ "node": ">=18"
36
+ }
37
+ }