@moonglade/provenance 0.0.0-stage → 2.6.1

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/dist/index.js ADDED
@@ -0,0 +1,782 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
36
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.ProvenanceVault = exports.ProvenanceEngine = exports.MAX_SIDECAR_BYTES = exports.MAX_ARTIFACT_BYTES = exports.BUNDLE_V3 = exports.BUNDLE_V2 = exports.BINARY_EXTENSIONS = exports.TEXT_EXTENSIONS = void 0;
40
+ exports.isTextContent = isTextContent;
41
+ exports.canonicalizeJson = canonicalizeJson;
42
+ exports.readRegularFile = readRegularFile;
43
+ exports.writeSidecar = writeSidecar;
44
+ exports.validateSidecar = validateSidecar;
45
+ const crypto = __importStar(require("crypto"));
46
+ const fs = __importStar(require("fs/promises"));
47
+ const path = __importStar(require("path"));
48
+ const merkle_1 = require("./merkle");
49
+ const timestamp_1 = require("./timestamp");
50
+ const badge_1 = require("./badge");
51
+ exports.TEXT_EXTENSIONS = new Set([
52
+ 'md', 'markdown', 'txt', 'text', 'json', 'csv', 'tsv',
53
+ 'html', 'htm', 'xml', 'yaml', 'yml', 'js', 'ts', 'jsx', 'tsx', 'css',
54
+ 'py', 'rs', 'go', 'c', 'cpp', 'h', 'hpp', 'sh', 'bat', 'ps1', 'sql'
55
+ ]);
56
+ exports.BINARY_EXTENSIONS = new Set([
57
+ 'pdf', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'ico',
58
+ 'mp4', 'mov', 'avi', 'mkv', 'webm', 'mp3', 'wav', 'flac', 'ogg',
59
+ 'zip', 'tar', 'gz', 'bz2', '7z', 'rar', 'wasm', 'bin', 'exe', 'dll', 'so', 'dylib', 'onnx'
60
+ ]);
61
+ /**
62
+ * Checks if a filename or byte buffer represents text rather than binary data.
63
+ */
64
+ function isTextContent(filename, bytes) {
65
+ const ext = path.extname(filename).replace(/^\./, '').toLowerCase();
66
+ if (exports.BINARY_EXTENSIONS.has(ext)) {
67
+ return false;
68
+ }
69
+ if (exports.TEXT_EXTENSIONS.has(ext)) {
70
+ return true;
71
+ }
72
+ // If extension unknown, sample first 1024 bytes for null bytes (binary indicator)
73
+ const sample = bytes.subarray(0, Math.min(bytes.length, 1024));
74
+ for (let i = 0; i < sample.length; i++) {
75
+ if (sample[i] === 0x00) {
76
+ return false;
77
+ }
78
+ }
79
+ return true;
80
+ }
81
+ /**
82
+ * Deterministic JSON Canonicalization Scheme (RFC 8785 subset for generic claims, draft -03 section 3.3).
83
+ * Sorts object keys lexicographically by UTF-16 code units with no whitespace. Numbers must be safe
84
+ * integers; floats, NaN, Infinity and lone surrogates are rejected, exactly as in the Python and Rust engines.
85
+ */
86
+ function canonicalizeJson(value) {
87
+ const seen = new Set();
88
+ const string = (v) => {
89
+ for (let i = 0; i < v.length; i++) {
90
+ const c = v.charCodeAt(i);
91
+ if (c >= 0xd800 && c <= 0xdbff) {
92
+ const next = v.charCodeAt(++i);
93
+ if (!(next >= 0xdc00 && next <= 0xdfff))
94
+ throw new Error('Lone surrogate in JSON');
95
+ }
96
+ else if (c >= 0xdc00 && c <= 0xdfff)
97
+ throw new Error('Lone surrogate in JSON');
98
+ }
99
+ return JSON.stringify(v);
100
+ };
101
+ const visit = (v, depth) => {
102
+ if (depth > 64)
103
+ throw new Error('JSON nesting exceeds 64');
104
+ if (v === null)
105
+ return 'null';
106
+ if (typeof v === 'string')
107
+ return string(v);
108
+ if (typeof v === 'boolean')
109
+ return JSON.stringify(v);
110
+ if (typeof v === 'number') {
111
+ // Draft -03 section 3.3: IEEE 754 integers within +-(2^53-1), judged by value, so 1.0 and 1e2 are
112
+ // accepted (as 1 and 100) and 1.5, 2^53, NaN and Infinity are not. -0 is emitted as 0.
113
+ if (!Number.isSafeInteger(v))
114
+ throw new Error('Claims numbers must be safe integers');
115
+ return JSON.stringify(v);
116
+ }
117
+ if (typeof v !== 'object')
118
+ throw new Error('Unsupported JSON value');
119
+ if (seen.has(v))
120
+ throw new Error('Cyclic JSON');
121
+ if (!Array.isArray(v) && Object.getPrototypeOf(v) !== Object.prototype && Object.getPrototypeOf(v) !== null)
122
+ throw new Error('JSON requires plain objects');
123
+ if (Reflect.ownKeys(v).some(k => typeof k === 'symbol'))
124
+ throw new Error('Symbol JSON key');
125
+ for (const d of Object.values(Object.getOwnPropertyDescriptors(v))) {
126
+ if (d.get || d.set)
127
+ throw new Error('JSON accessors are unsupported');
128
+ }
129
+ seen.add(v);
130
+ let result;
131
+ if (Array.isArray(v)) {
132
+ if (Object.keys(v).length !== v.length)
133
+ throw new Error('Sparse or decorated JSON array');
134
+ result = '[' + Array.from(v, item => visit(item, depth + 1)).join(',') + ']';
135
+ }
136
+ else
137
+ result = '{' + Object.keys(v).sort().map(k => string(k) + ':' + visit(v[k], depth + 1)).join(',') + '}';
138
+ seen.delete(v);
139
+ return result;
140
+ };
141
+ return visit(value, 0);
142
+ }
143
+ exports.BUNDLE_V2 = 'Moonglade Provenance Attestation Bundle v2';
144
+ exports.BUNDLE_V3 = 'Moonglade Provenance Attestation Bundle v3';
145
+ exports.MAX_ARTIFACT_BYTES = 64 * 1024 * 1024;
146
+ exports.MAX_SIDECAR_BYTES = 1024 * 1024;
147
+ /** Reject links and oversized inputs. This local tool assumes no concurrent hostile filesystem writer. */
148
+ async function readRegularFile(file, limit) {
149
+ const resolved = path.resolve(file);
150
+ let cursor = path.parse(resolved).root;
151
+ for (const part of resolved.slice(cursor.length).split(path.sep)) {
152
+ cursor = path.join(cursor, part);
153
+ if ((await fs.lstat(cursor)).isSymbolicLink())
154
+ throw new Error('Symbolic links are unsupported');
155
+ }
156
+ const handle = await fs.open(resolved, 'r');
157
+ try {
158
+ const stat = await handle.stat();
159
+ if (!stat.isFile() || stat.size > limit)
160
+ throw new Error('Input is not a bounded regular file');
161
+ const buffer = Buffer.alloc(Math.min(stat.size + 1, limit + 1));
162
+ let length = 0;
163
+ while (length < buffer.length) {
164
+ const read = await handle.read(buffer, length, buffer.length - length, null);
165
+ if (!read.bytesRead)
166
+ break;
167
+ length += read.bytesRead;
168
+ }
169
+ if (length > stat.size || length > limit)
170
+ throw new Error('Input grew while reading');
171
+ return buffer.subarray(0, length);
172
+ }
173
+ finally {
174
+ await handle.close();
175
+ }
176
+ }
177
+ async function writeSidecar(file, value) {
178
+ try {
179
+ if (!(await fs.lstat(file)).isFile() || (await fs.lstat(file)).isSymbolicLink())
180
+ throw new Error('Unsafe sidecar target');
181
+ }
182
+ catch (error) {
183
+ if (error.code !== 'ENOENT')
184
+ throw error;
185
+ }
186
+ await fs.writeFile(file, JSON.stringify(value, null, 2), 'utf8');
187
+ }
188
+ function validateSidecar(sidecar) {
189
+ if (!sidecar || typeof sidecar !== 'object' || Array.isArray(sidecar) ||
190
+ ![exports.BUNDLE_V2, exports.BUNDLE_V3].includes(sidecar.standard) ||
191
+ typeof sidecar.artifact !== 'string' || !sidecar.artifact ||
192
+ !/^[a-f0-9]{64}$/.test(sidecar.sha256_at_last_write) ||
193
+ !Array.isArray(sidecar.attestations) || !sidecar.attestations.length || sidecar.attestations.length > 64 ||
194
+ sidecar.attestations.some((a) => !a || typeof a !== 'object' || Array.isArray(a) || typeof a.method !== 'string')) {
195
+ throw new Error('Unsupported bundle or malformed sidecar');
196
+ }
197
+ if (sidecar.status === 'revoked')
198
+ throw new Error('Revoked sidecar');
199
+ const v3 = sidecar.standard === exports.BUNDLE_V3;
200
+ if (v3) {
201
+ if (!sidecar.claims || typeof sidecar.claims !== 'object' || Array.isArray(sidecar.claims) ||
202
+ !/^[a-f0-9]{64}$/.test(sidecar.claims_sha256))
203
+ throw new Error('Missing or malformed v3 claims');
204
+ const digest = crypto.createHash('sha256').update(canonicalizeJson(sidecar.claims)).digest('hex');
205
+ if (digest !== sidecar.claims_sha256)
206
+ throw new Error('Claims hash mismatch');
207
+ }
208
+ else if (sidecar.claims !== undefined || sidecar.claims_sha256 !== undefined)
209
+ throw new Error('Claims require v3');
210
+ if (sidecar.attestations.some((a) => a.method.startsWith('moonglade-ed25519-') && a.method !== (v3 ? 'moonglade-ed25519-v3' : 'moonglade-ed25519-v2')))
211
+ throw new Error('Bundle and attestation versions differ');
212
+ }
213
+ class ProvenanceEngine {
214
+ /**
215
+ * Canonicalizes an in-memory buffer according to Moonglade Provenance rules.
216
+ * Text documents: strips UTF-8 BOM, normalizes CRLF to LF, and applies Unicode NFC.
217
+ * Binary documents (PDFs, images, binaries): preserves exact byte fidelity.
218
+ */
219
+ static canonicalizeBytes(raw, filename = 'document.txt') {
220
+ if (!isTextContent(filename, raw)) {
221
+ return raw;
222
+ }
223
+ let text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(raw);
224
+ // Strip UTF-8 Byte Order Mark (BOM) if present
225
+ if (text.charCodeAt(0) === 0xFEFF) {
226
+ text = text.slice(1);
227
+ }
228
+ // Normalize CRLF to LF and apply Unicode NFC
229
+ const normalized = text.replace(/\r\n/g, '\n').normalize('NFC');
230
+ return Buffer.from(normalized, 'utf8');
231
+ }
232
+ /**
233
+ * Canonicalize a file's bytes deterministically:
234
+ * For text documents, normalize CRLF to LF, strip UTF-8 BOM, and apply Unicode NFC.
235
+ * For binary documents (PDFs, images, binaries), preserve byte-exact fidelity.
236
+ *
237
+ * This exists so a git checkout on Windows does not invalidate text signatures
238
+ * in the repository, while ensuring binary structures (like PDF xref tables)
239
+ * remain completely untouched.
240
+ */
241
+ static async canonicalizeFile(filePath) {
242
+ const raw = await readRegularFile(filePath, exports.MAX_ARTIFACT_BYTES);
243
+ return this.canonicalizeBytes(raw, filePath);
244
+ }
245
+ /**
246
+ * Generates an Ed25519 keypair for testing/local identities if none exists.
247
+ */
248
+ static generateEd25519KeyPair() {
249
+ return crypto.generateKeyPairSync('ed25519', {
250
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
251
+ publicKeyEncoding: { type: 'spki', format: 'pem' }
252
+ });
253
+ }
254
+ /**
255
+ * The bytes that are actually signed.
256
+ *
257
+ * Until v2 the signature covered the document bytes alone, which meant the
258
+ * scope, the signer identity and the timestamp were unauthenticated JSON
259
+ * sitting beside it. Anyone could rewrite the scope in a sidecar and the
260
+ * signature still verified - so the stated property, that a signature made
261
+ * for one scope will not verify under another, was not true here.
262
+ *
263
+ * v2 signs a structured claim instead. Three properties matter:
264
+ *
265
+ * 1. Domain separation - the version string is inside the payload, so a
266
+ * signature over one payload type cannot be replayed as another.
267
+ * 2. Field binding - scope, artifact hash and signer are all covered.
268
+ * 3. Unambiguous framing - newlines delimit fields, and fields are
269
+ * validated to contain none, so a crafted scope cannot smuggle in a
270
+ * forged signer line.
271
+ */
272
+ static PAYLOAD_VERSION = 'moonglade-provenance/v2';
273
+ static PAYLOAD_VERSION_V3 = 'moonglade-provenance/v3';
274
+ static buildSigningPayload(fields) {
275
+ for (const [k, v] of Object.entries(fields)) {
276
+ if (typeof v !== 'string' || v.length === 0) {
277
+ throw new Error(`signing payload: ${k} must be a non-empty string`);
278
+ }
279
+ if (/[\x00-\x1f\x7f]/.test(v)) {
280
+ throw new Error(`signing payload: ${k} must not contain control characters`);
281
+ }
282
+ }
283
+ const body = [
284
+ this.PAYLOAD_VERSION,
285
+ `scope:${fields.scope}`,
286
+ `sha256:${fields.sha256}`,
287
+ `signer:${fields.signer}`,
288
+ ''
289
+ ].join('\n');
290
+ return Buffer.from(body, 'utf8');
291
+ }
292
+ static buildSigningPayloadV3(fields) {
293
+ for (const [k, v] of Object.entries(fields)) {
294
+ if (typeof v !== 'string' || v.length === 0) {
295
+ throw new Error(`signing payload v3: ${k} must be a non-empty string`);
296
+ }
297
+ if (/[\x00-\x1f\x7f]/.test(v)) {
298
+ throw new Error(`signing payload v3: ${k} must not contain control characters`);
299
+ }
300
+ }
301
+ const body = [
302
+ this.PAYLOAD_VERSION_V3,
303
+ `scope:${fields.scope}`,
304
+ `target-sha256:${fields.targetSha256}`,
305
+ `signer:${fields.signer}`,
306
+ `claims-sha256:${fields.claimsSha256}`,
307
+ ''
308
+ ].join('\n');
309
+ return Buffer.from(body, 'utf8');
310
+ }
311
+ /**
312
+ * Build the attestation scope for an artifact.
313
+ *
314
+ * Scope isolation is the stated security property: a signature made for one
315
+ * scope must not be presentable for another. Until 2026-08-30 this used
316
+ * `path.basename(filePath)`, which gave every README.md in the tree the same
317
+ * scope - a collision in any repository with repeated filenames, which is all
318
+ * of them.
319
+ *
320
+ * It is now the path relative to `vaultRoot`, matching the Python
321
+ * implementation's `namespace_for()`.
322
+ *
323
+ * Separators are forced to POSIX. `path.relative` yields backslashes on
324
+ * Windows and forward slashes elsewhere, so without this the same file signed
325
+ * on two machines would carry two different scopes. That is the same class of
326
+ * defect canonicalizeFile exists to prevent for line endings, and it would be
327
+ * careless to fix one and not the other.
328
+ */
329
+ static scopeFor(filePath, vaultRoot, explicit) {
330
+ if (explicit)
331
+ return `moonglade:vault:${explicit}`;
332
+ const root = vaultRoot ?? path.dirname(path.resolve(filePath));
333
+ const rel = path.relative(root, path.resolve(filePath));
334
+ if (path.isAbsolute(rel) || rel === '..' || rel.startsWith('..' + path.sep))
335
+ throw new Error('Artifact is outside scope root');
336
+ const safe = rel || path.basename(filePath);
337
+ return `moonglade:vault:${safe.split(path.sep).join('/')}`;
338
+ }
339
+ /**
340
+ * Sign a document and generate the .provenance.json sidecar.
341
+ * Supports both v2 standard bundles and v3 extended bundles with generic claims.
342
+ */
343
+ static async sealDocument(filePath, privateKeyPem, signerIdentity, gitCommit = 'unknown', opts = {}) {
344
+ if (crypto.createPrivateKey(privateKeyPem).asymmetricKeyType !== 'ed25519')
345
+ throw new Error('Expected Ed25519 private key');
346
+ if (opts.version !== undefined && !['v2', 'v3'].includes(opts.version))
347
+ throw new Error('Unsupported signing version');
348
+ const canonicalBytes = await this.canonicalizeFile(filePath);
349
+ // Hash
350
+ const hash = crypto.createHash('sha256').update(canonicalBytes).digest('hex');
351
+ const digestBuffer = crypto.createHash('sha256').update(canonicalBytes).digest();
352
+ const scope = this.scopeFor(filePath, opts.vaultRoot, opts.scope);
353
+ const isV3 = Boolean(opts.claims !== undefined || opts.version === 'v3');
354
+ const attestations = [];
355
+ let claimsHash;
356
+ if (isV3) {
357
+ const claims = opts.claims || {};
358
+ const claimsJson = canonicalizeJson(claims);
359
+ claimsHash = crypto.createHash('sha256').update(claimsJson, 'utf8').digest('hex');
360
+ const payload = this.buildSigningPayloadV3({
361
+ scope,
362
+ targetSha256: hash,
363
+ signer: signerIdentity,
364
+ claimsSha256: claimsHash
365
+ });
366
+ const signature = crypto.sign(null, payload, privateKeyPem).toString('base64');
367
+ attestations.push({
368
+ method: "moonglade-ed25519-v3",
369
+ signer: signerIdentity,
370
+ scope,
371
+ sha256: hash,
372
+ target_sha256: hash,
373
+ claims_sha256: claimsHash,
374
+ created: new Date().toISOString(),
375
+ git_commit: gitCommit,
376
+ signature
377
+ });
378
+ }
379
+ else {
380
+ // Sign the structured claim, not the raw bytes - see buildSigningPayload.
381
+ const payload = this.buildSigningPayload({ scope, sha256: hash, signer: signerIdentity });
382
+ const signature = crypto.sign(null, payload, privateKeyPem).toString('base64');
383
+ attestations.push({
384
+ method: "moonglade-ed25519-v2",
385
+ signer: signerIdentity,
386
+ scope,
387
+ sha256: hash,
388
+ created: new Date().toISOString(),
389
+ git_commit: gitCommit,
390
+ signature
391
+ });
392
+ }
393
+ if (opts.timestamp) {
394
+ const dtaOpts = typeof opts.timestamp === 'object' ? opts.timestamp : {};
395
+ const dta = await (0, timestamp_1.concurrentDegradingTimestamp)(digestBuffer, dtaOpts);
396
+ if (dta.token_b64)
397
+ attestations.push({
398
+ method: "rfc3161",
399
+ tier: dta.tier,
400
+ tsa: dta.tsa,
401
+ tsa_time: dta.tsa_time,
402
+ token_b64: dta.token_b64,
403
+ probes: dta.probes
404
+ });
405
+ }
406
+ const sidecar = {
407
+ standard: isV3 ? "Moonglade Provenance Attestation Bundle v3" : "Moonglade Provenance Attestation Bundle v2",
408
+ artifact: path.basename(filePath),
409
+ ...(isV3 ? { claims: opts.claims || {}, claims_sha256: claimsHash } : {}),
410
+ attestations,
411
+ sha256_at_last_write: hash,
412
+ updated: new Date().toISOString()
413
+ };
414
+ validateSidecar(sidecar);
415
+ const sidecarPath = `${filePath}.provenance.json`;
416
+ await writeSidecar(sidecarPath, sidecar);
417
+ return sidecar;
418
+ }
419
+ /**
420
+ * Countersign an existing document and append the attestation to the sidecar.
421
+ */
422
+ static async countersignDocument(filePath, privateKeyPem, signerIdentity, gitCommit = 'unknown', opts = {}) {
423
+ const sidecarPath = `${filePath}.provenance.json`;
424
+ if (crypto.createPrivateKey(privateKeyPem).asymmetricKeyType !== 'ed25519')
425
+ throw new Error('Expected Ed25519 private key');
426
+ if (!opts.verificationKeys)
427
+ throw new Error('Cannot countersign without verificationKeys');
428
+ const validation = await this.verifyDocument(filePath, opts.verificationKeys, opts.policy);
429
+ if (!validation.verified)
430
+ throw new Error('Cannot countersign: ' + validation.error);
431
+ const sidecarData = (await readRegularFile(sidecarPath, exports.MAX_SIDECAR_BYTES)).toString('utf8');
432
+ const sidecar = JSON.parse(sidecarData);
433
+ const canonicalBytes = await this.canonicalizeFile(filePath);
434
+ const hash = crypto.createHash('sha256').update(canonicalBytes).digest('hex');
435
+ if (hash !== sidecar.sha256_at_last_write) {
436
+ throw new Error('Cannot countersign: document has been mutated since sealing.');
437
+ }
438
+ const scope = opts.scope ? this.scopeFor(filePath, opts.vaultRoot, opts.scope) : validation.scope;
439
+ if (scope !== validation.scope)
440
+ throw new Error('Countersigner scope differs');
441
+ const isV3 = sidecar.standard === exports.BUNDLE_V3;
442
+ if (isV3) {
443
+ const claimsHash = sidecar.claims_sha256 || crypto.createHash('sha256').update(canonicalizeJson(sidecar.claims || {}), 'utf8').digest('hex');
444
+ const payload = this.buildSigningPayloadV3({
445
+ scope,
446
+ targetSha256: hash,
447
+ signer: signerIdentity,
448
+ claimsSha256: claimsHash
449
+ });
450
+ const signature = crypto.sign(null, payload, privateKeyPem).toString('base64');
451
+ sidecar.attestations.push({
452
+ method: "moonglade-ed25519-v3",
453
+ signer: signerIdentity,
454
+ scope,
455
+ sha256: hash,
456
+ target_sha256: hash,
457
+ claims_sha256: claimsHash,
458
+ created: new Date().toISOString(),
459
+ git_commit: gitCommit,
460
+ signature
461
+ });
462
+ }
463
+ else {
464
+ const payload = this.buildSigningPayload({ scope, sha256: hash, signer: signerIdentity });
465
+ const signature = crypto.sign(null, payload, privateKeyPem).toString('base64');
466
+ sidecar.attestations.push({
467
+ method: "moonglade-ed25519-v2",
468
+ signer: signerIdentity,
469
+ scope,
470
+ sha256: hash,
471
+ created: new Date().toISOString(),
472
+ git_commit: gitCommit,
473
+ signature
474
+ });
475
+ }
476
+ sidecar.updated = new Date().toISOString();
477
+ await writeSidecar(sidecarPath, sidecar);
478
+ return sidecar;
479
+ }
480
+ /**
481
+ * Attest RFC 3161 timestamping for an artifact via Concurrent Multi-Source DTA.
482
+ */
483
+ static async attestTimestamp(filePath, opts = {}) {
484
+ const canonicalBytes = await this.canonicalizeFile(filePath);
485
+ const digestBuffer = crypto.createHash('sha256').update(canonicalBytes).digest();
486
+ const dta = await (0, timestamp_1.concurrentDegradingTimestamp)(digestBuffer, opts);
487
+ if (!dta.token_b64)
488
+ return dta;
489
+ // If sidecar exists, attach or update the rfc3161 attestation
490
+ const sidecarPath = `${filePath}.provenance.json`;
491
+ try {
492
+ const sidecarData = await fs.readFile(sidecarPath, 'utf8');
493
+ const sidecar = JSON.parse(sidecarData);
494
+ if (Array.isArray(sidecar.attestations)) {
495
+ const existingIdx = sidecar.attestations.findIndex(a => a.method === 'rfc3161');
496
+ const tsAtt = {
497
+ method: "rfc3161",
498
+ tier: dta.tier,
499
+ tsa: dta.tsa,
500
+ tsa_time: dta.tsa_time,
501
+ token_b64: dta.token_b64,
502
+ probes: dta.probes
503
+ };
504
+ if (existingIdx >= 0) {
505
+ sidecar.attestations[existingIdx] = tsAtt;
506
+ }
507
+ else {
508
+ sidecar.attestations.push(tsAtt);
509
+ }
510
+ sidecar.updated = new Date().toISOString();
511
+ await writeSidecar(sidecarPath, sidecar);
512
+ }
513
+ }
514
+ catch {
515
+ // Sidecar does not exist or unreadable, still return DTAResult
516
+ }
517
+ return dta;
518
+ }
519
+ /**
520
+ * Verify a document against its sidecar.
521
+ * Supports both v2 and v3 attestation bundles.
522
+ */
523
+ static async verifyDocument(filePath, publicKeyPem, policy = {}) {
524
+ let documentSignatureVerified = false;
525
+ let timestampStatus = 'ABSENT';
526
+ try {
527
+ const sidecarPath = `${filePath}.provenance.json`;
528
+ const sidecarData = new TextDecoder('utf-8', { fatal: true }).decode(await readRegularFile(sidecarPath, exports.MAX_SIDECAR_BYTES));
529
+ const sidecar = JSON.parse(sidecarData);
530
+ validateSidecar(sidecar);
531
+ const canonicalBytes = await this.canonicalizeFile(filePath);
532
+ const currentHash = crypto.createHash('sha256').update(canonicalBytes).digest('hex');
533
+ if (currentHash !== sidecar.sha256_at_last_write) {
534
+ return { verified: false, error: "Hash mismatch: Document has been mutated since sealing." };
535
+ }
536
+ const attestations = Array.isArray(sidecar.attestations) ? sidecar.attestations : [];
537
+ const SUPPORTED_V2 = 'moonglade-ed25519-v2';
538
+ const SUPPORTED_V3 = 'moonglade-ed25519-v3';
539
+ const signatures = attestations.filter(a => a.method === SUPPORTED_V2 || a.method === SUPPORTED_V3);
540
+ const unknownMethods = Array.from(new Set(attestations.filter(a => a.method !== SUPPORTED_V2 && a.method !== SUPPORTED_V3 && a.method !== 'rfc3161').map(a => a.method)));
541
+ if (signatures.length === 0) {
542
+ return {
543
+ verified: false,
544
+ error: 'No moonglade-ed25519-v2 or v3 attestation in the bundle. An ssh-ed25519 (SSHSIG) attestation must be verified with ssh-keygen -Y verify, not by this package.',
545
+ attestationsChecked: 0,
546
+ unknownMethods
547
+ };
548
+ }
549
+ if (unknownMethods.length)
550
+ return { verified: false, status: 'INDETERMINATE', unknownMethods, error: 'Unsupported attestation method; use ssh-keygen -Y verify for SSHSIG outside this package' };
551
+ if (sidecar.artifact !== path.basename(filePath))
552
+ return { verified: false, error: 'Artifact filename mismatch' };
553
+ if (policy.requiredSigners && (!Array.isArray(policy.requiredSigners) || policy.requiredSigners.some(s => typeof s !== 'string' || !s)))
554
+ throw new Error('Invalid required signer policy');
555
+ for (const signer of policy.requiredSigners || []) {
556
+ if (!signatures.some(a => a.signer === signer))
557
+ return { verified: false, error: 'Missing required signer: ' + signer };
558
+ }
559
+ // If claims are present in sidecar, verify their canonical hash
560
+ if (sidecar.claims !== undefined && sidecar.claims_sha256 !== undefined) {
561
+ const computedClaimsHash = crypto.createHash('sha256').update(canonicalizeJson(sidecar.claims), 'utf8').digest('hex');
562
+ if (computedClaimsHash !== sidecar.claims_sha256) {
563
+ return {
564
+ verified: false,
565
+ error: 'Claims hash mismatch: claims payload has been mutated.',
566
+ attestationsChecked: signatures.length,
567
+ unknownMethods
568
+ };
569
+ }
570
+ }
571
+ // Every signature must hold. A good signature does not redeem a forged sibling.
572
+ for (const att of signatures) {
573
+ if (att.scope !== signatures[0].scope || (policy.expectedScope !== undefined && att.scope !== policy.expectedScope))
574
+ return { verified: false, error: 'Scope mismatch' };
575
+ if (typeof att.signature !== 'string' || !/^[A-Za-z0-9+/]{86}==$/.test(att.signature))
576
+ return { verified: false, error: 'Invalid Ed25519 signature encoding' };
577
+ if (!att.signature || !att.signer || !att.scope || (!att.sha256 && !att.target_sha256)) {
578
+ return {
579
+ verified: false,
580
+ error: 'Incomplete signature attestation block.',
581
+ attestationsChecked: signatures.length,
582
+ unknownMethods
583
+ };
584
+ }
585
+ const targetHash = att.target_sha256 || att.sha256;
586
+ if (targetHash !== currentHash) {
587
+ return {
588
+ verified: false,
589
+ error: `Attestation for scope "${att.scope}" records a different hash.`,
590
+ attestationsChecked: signatures.length,
591
+ unknownMethods
592
+ };
593
+ }
594
+ let payload;
595
+ if (att.method === SUPPORTED_V3) {
596
+ if (!att.claims_sha256) {
597
+ return {
598
+ verified: false,
599
+ error: 'Missing claims_sha256 in v3 attestation.',
600
+ attestationsChecked: signatures.length,
601
+ unknownMethods
602
+ };
603
+ }
604
+ if (sidecar.claims_sha256 && att.claims_sha256 !== sidecar.claims_sha256) {
605
+ return {
606
+ verified: false,
607
+ error: 'Attestation claims_sha256 does not match bundle claims_sha256.',
608
+ attestationsChecked: signatures.length,
609
+ unknownMethods
610
+ };
611
+ }
612
+ payload = ProvenanceEngine.buildSigningPayloadV3({
613
+ scope: att.scope,
614
+ targetSha256: targetHash,
615
+ signer: att.signer,
616
+ claimsSha256: att.claims_sha256
617
+ });
618
+ }
619
+ else {
620
+ payload = ProvenanceEngine.buildSigningPayload({
621
+ scope: att.scope,
622
+ sha256: targetHash,
623
+ signer: att.signer
624
+ });
625
+ }
626
+ const keyForSigner = typeof publicKeyPem === 'object'
627
+ ? (Object.prototype.hasOwnProperty.call(publicKeyPem, att.signer) ? publicKeyPem[att.signer] : undefined)
628
+ : publicKeyPem;
629
+ if (!keyForSigner) {
630
+ return {
631
+ verified: false,
632
+ error: `No public key provided for signer "${att.signer}".`,
633
+ attestationsChecked: signatures.length,
634
+ unknownMethods
635
+ };
636
+ }
637
+ if (crypto.createPublicKey(keyForSigner).asymmetricKeyType !== 'ed25519')
638
+ return { verified: false, status: 'INDETERMINATE', error: 'Expected Ed25519 public key' };
639
+ const ok = crypto.verify(null, payload, keyForSigner, Buffer.from(att.signature, 'base64'));
640
+ if (!ok) {
641
+ return {
642
+ verified: false,
643
+ error: `Signature validation failed for signer "${att.signer}".`,
644
+ attestationsChecked: signatures.length,
645
+ unknownMethods
646
+ };
647
+ }
648
+ }
649
+ documentSignatureVerified = true;
650
+ const timestamps = attestations.filter(a => a.method === 'rfc3161');
651
+ if (timestamps.length)
652
+ timestampStatus = 'INVALID';
653
+ if (timestamps.length > 1)
654
+ throw new Error('Multiple timestamp attestations are unsupported');
655
+ let timestampVerified;
656
+ let timestampSigner;
657
+ let timestamp;
658
+ let timestampTier;
659
+ if (timestamps.length) {
660
+ const token = timestamps[0].token_b64;
661
+ if (typeof token !== 'string' || !token.length || token.length % 4 || !/^[A-Za-z0-9+/]*={0,2}$/.test(token))
662
+ throw new Error('Timestamp token missing or invalid');
663
+ const tsRes = await (0, timestamp_1.verifyTimeStampToken)(Buffer.from(token, 'base64'), canonicalBytes, { trustAnchors: policy.timestampTrustAnchors });
664
+ if (!tsRes.verified)
665
+ throw new Error('Timestamp token verification failed: ' + tsRes.error);
666
+ timestampVerified = tsRes.trusted === true;
667
+ timestampSigner = tsRes.signerSubject;
668
+ timestamp = tsRes.genTime;
669
+ timestampTier = tsRes.trusted ? 'ANCHORED' : 'INTEGRITY_ONLY';
670
+ timestampStatus = tsRes.trusted ? 'ANCHORED' : 'INTEGRITY_ONLY';
671
+ }
672
+ const badge = (0, badge_1.formatBadge)('SEALED', {
673
+ signer: signatures[0].signer,
674
+ hash: currentHash,
675
+ tier: timestampTier
676
+ });
677
+ return {
678
+ verified: true,
679
+ status: 'VERIFIED',
680
+ documentSignatureVerified, timestampStatus,
681
+ signer: signatures[0].signer,
682
+ scope: signatures[0].scope,
683
+ timestamp,
684
+ timestampTier,
685
+ timestampVerified,
686
+ timestampSigner,
687
+ attestationsChecked: signatures.length,
688
+ unknownMethods,
689
+ badge,
690
+ claims: sidecar.claims,
691
+ claimsSha256: sidecar.claims_sha256
692
+ };
693
+ }
694
+ catch (e) {
695
+ return { verified: false, documentSignatureVerified, timestampStatus, error: `Verification error: ${e.message}` };
696
+ }
697
+ }
698
+ }
699
+ exports.ProvenanceEngine = ProvenanceEngine;
700
+ /**
701
+ * Vault Macro-Provenance: Merkle Tree Hash Registry
702
+ */
703
+ class ProvenanceVault {
704
+ rootPath;
705
+ salt;
706
+ // `salt` is retained for API compatibility but no longer participates in the
707
+ // root. RFC 6962 domain separation supersedes it, and a salted root cannot be
708
+ // verified by a third party who does not know the salt - which defeats the
709
+ // purpose of publishing one.
710
+ constructor(rootPath, salt = "moonglade-v1") {
711
+ this.rootPath = rootPath;
712
+ this.salt = salt;
713
+ }
714
+ async walk(dir, fileList = [], depth = 0) {
715
+ if (depth > 64 || fileList.length > 100000)
716
+ throw new Error('Vault scan limit exceeded');
717
+ if ((await fs.lstat(dir)).isSymbolicLink())
718
+ throw new Error('Symbolic links are unsupported in vault scans');
719
+ const files = await fs.readdir(dir);
720
+ for (const file of files) {
721
+ const filepath = path.join(dir, file);
722
+ const stat = await fs.lstat(filepath);
723
+ if (stat.isSymbolicLink())
724
+ throw new Error('Symbolic links are unsupported in vault scans');
725
+ if (stat.isDirectory()) {
726
+ if (!file.startsWith('.') && file !== 'node_modules') {
727
+ await this.walk(filepath, fileList, depth + 1);
728
+ }
729
+ }
730
+ else {
731
+ fileList.push(filepath);
732
+ }
733
+ }
734
+ return fileList.sort(); // Deterministic ordering
735
+ }
736
+ /**
737
+ * Hash every file under the root and build a real Merkle tree over them.
738
+ *
739
+ * Until v2 this returned a flat SHA-256 over the concatenated hashes. That
740
+ * detected change and nothing else - it could not produce an inclusion proof,
741
+ * which is the only reason to use a tree. See merkle.ts.
742
+ *
743
+ * Paths are POSIX-normalised before hashing, so a vault scanned on Windows and
744
+ * on Linux yields the same root. Without that the two would silently disagree,
745
+ * which is the same defect canonicalizeFile prevents for line endings.
746
+ */
747
+ async scanAndAudit() {
748
+ const files = await this.walk(this.rootPath);
749
+ const artifacts = Object.create(null);
750
+ for (const file of files) {
751
+ const bytes = await readRegularFile(file, exports.MAX_ARTIFACT_BYTES);
752
+ const rel = path.relative(this.rootPath, file).split(path.sep).join('/');
753
+ artifacts[rel] = { sha256: crypto.createHash('sha256').update(bytes).digest('hex') };
754
+ }
755
+ return {
756
+ merkleRoot: (0, merkle_1.merkleRoot)(artifacts),
757
+ algorithm: merkle_1.MERKLE_ALGORITHM,
758
+ fileCount: files.length,
759
+ artifacts
760
+ };
761
+ }
762
+ /** Audit path for one artifact, by its POSIX path relative to the root. */
763
+ async proveInclusion(relPath) {
764
+ const { artifacts } = await this.scanAndAudit();
765
+ return (0, merkle_1.inclusionProof)(artifacts, relPath);
766
+ }
767
+ }
768
+ exports.ProvenanceVault = ProvenanceVault;
769
+ // RFC 6962 Merkle tree and inclusion proofs.
770
+ __exportStar(require("./merkle"), exports);
771
+ // RFC 3161 and Concurrent Degrading Timestamping Architecture (DTA).
772
+ __exportStar(require("./timestamp"), exports);
773
+ // Model Context Protocol (MCP) Server.
774
+ __exportStar(require("./mcp"), exports);
775
+ // Artifact generation observer and provisional signer.
776
+ __exportStar(require("./artifactHook"), exports);
777
+ // Shared identity and evidence contract for all Moonglade artifact types.
778
+ __exportStar(require("./artifactEnvelope"), exports);
779
+ // Standalone zero-dependency install and environment verification suite.
780
+ __exportStar(require("./self-test"), exports);
781
+ // Standard Artifact Provenance Badges.
782
+ __exportStar(require("./badge"), exports);