@enactprotocol/trust 2.1.15 → 2.1.17

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,587 @@
1
+ /**
2
+ * Checksum manifest creation and verification
3
+ *
4
+ * Creates deterministic manifests of file checksums for signing.
5
+ * This enables pre-publish signing by avoiding tar.gz non-determinism.
6
+ *
7
+ * Based on recommendation from Bob Callaway (Google Sigstore team):
8
+ * "Most folks would just create and sign a manifest of checksums."
9
+ */
10
+
11
+ import { readdirSync, statSync } from "node:fs";
12
+ import { join, posix, relative, sep } from "node:path";
13
+ import { hashContent, hashFile } from "./hash";
14
+ import type { HashResult } from "./types";
15
+
16
+ // ============================================================================
17
+ // Types
18
+ // ============================================================================
19
+
20
+ /**
21
+ * Checksum manifest version
22
+ */
23
+ export const MANIFEST_VERSION = "1.0" as const;
24
+
25
+ /**
26
+ * Individual file checksum entry
27
+ */
28
+ export interface FileChecksum {
29
+ /** Relative path from tool root (always uses forward slashes) */
30
+ path: string;
31
+ /** SHA-256 hash of file contents */
32
+ sha256: string;
33
+ /** File size in bytes */
34
+ size: number;
35
+ }
36
+
37
+ /**
38
+ * Complete checksum manifest for a tool
39
+ */
40
+ export interface ChecksumManifest {
41
+ /** Manifest format version */
42
+ version: typeof MANIFEST_VERSION;
43
+ /** Tool metadata */
44
+ tool: {
45
+ /** Tool name (e.g., "author/tool-name") */
46
+ name: string;
47
+ /** Tool version (e.g., "1.0.0") */
48
+ version: string;
49
+ };
50
+ /** Array of file checksums, sorted by path */
51
+ files: FileChecksum[];
52
+ /** Hash of the manifest itself (for signing) */
53
+ manifestHash: {
54
+ algorithm: "sha256";
55
+ digest: string;
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Options for creating a checksum manifest
61
+ */
62
+ export interface CreateManifestOptions {
63
+ /** Patterns to ignore (glob-style, relative to tool root) */
64
+ ignorePatterns?: string[] | undefined;
65
+ /** Progress callback for each file processed */
66
+ onProgress?: ((file: string) => void) | undefined;
67
+ }
68
+
69
+ /**
70
+ * Result of manifest verification
71
+ */
72
+ export interface ManifestVerificationResult {
73
+ /** Whether all files match the manifest */
74
+ valid: boolean;
75
+ /** Error messages if verification failed */
76
+ errors?: string[] | undefined;
77
+ /** Files in manifest but missing from directory */
78
+ missingFiles?: string[] | undefined;
79
+ /** Files that exist but have different hashes */
80
+ modifiedFiles?: string[] | undefined;
81
+ /** Files in directory but not in manifest */
82
+ extraFiles?: string[] | undefined;
83
+ }
84
+
85
+ // ============================================================================
86
+ // Default Ignore Patterns
87
+ // ============================================================================
88
+
89
+ /**
90
+ * Files that should always be ignored when creating manifests
91
+ */
92
+ const ALWAYS_IGNORE = [
93
+ // Manifest artifacts (we're creating these)
94
+ ".enact-manifest.json",
95
+ ".sigstore-bundle.json",
96
+ // Version control
97
+ ".git",
98
+ ".gitignore",
99
+ ".gitattributes",
100
+ // OS artifacts
101
+ ".DS_Store",
102
+ "Thumbs.db",
103
+ // IDE/Editor
104
+ ".vscode",
105
+ ".idea",
106
+ "*.swp",
107
+ "*.swo",
108
+ // Dependencies (should be installed, not bundled)
109
+ "node_modules",
110
+ "__pycache__",
111
+ ".pytest_cache",
112
+ "*.pyc",
113
+ "*.pyo",
114
+ // Build artifacts
115
+ "dist",
116
+ "build",
117
+ ".next",
118
+ // Logs
119
+ "*.log",
120
+ "npm-debug.log*",
121
+ ];
122
+
123
+ // ============================================================================
124
+ // Helper Functions
125
+ // ============================================================================
126
+
127
+ /**
128
+ * Check if a path should be ignored
129
+ */
130
+ function shouldIgnore(
131
+ relativePath: string,
132
+ fileName: string,
133
+ customPatterns: string[] = []
134
+ ): boolean {
135
+ const allPatterns = [...ALWAYS_IGNORE, ...customPatterns];
136
+
137
+ for (const pattern of allPatterns) {
138
+ // Exact match
139
+ if (pattern === fileName || pattern === relativePath) {
140
+ return true;
141
+ }
142
+
143
+ // Glob pattern matching (simplified)
144
+ if (pattern.startsWith("*")) {
145
+ const suffix = pattern.slice(1);
146
+ if (fileName.endsWith(suffix) || relativePath.endsWith(suffix)) {
147
+ return true;
148
+ }
149
+ }
150
+
151
+ // Directory pattern (pattern without extension matches directories)
152
+ if (!pattern.includes(".") && !pattern.includes("*")) {
153
+ if (relativePath.startsWith(`${pattern}/`) || relativePath === pattern) {
154
+ return true;
155
+ }
156
+ }
157
+ }
158
+
159
+ return false;
160
+ }
161
+
162
+ /**
163
+ * Normalize path to use forward slashes (for cross-platform consistency)
164
+ */
165
+ function normalizePath(filePath: string): string {
166
+ return filePath.split(sep).join(posix.sep);
167
+ }
168
+
169
+ /**
170
+ * Recursively collect all files in a directory
171
+ */
172
+ function collectFiles(
173
+ dir: string,
174
+ baseDir: string,
175
+ ignorePatterns: string[],
176
+ files: string[] = []
177
+ ): string[] {
178
+ const entries = readdirSync(dir, { withFileTypes: true });
179
+
180
+ for (const entry of entries) {
181
+ const fullPath = join(dir, entry.name);
182
+ const relativePath = normalizePath(relative(baseDir, fullPath));
183
+
184
+ // Check if should be ignored
185
+ if (shouldIgnore(relativePath, entry.name, ignorePatterns)) {
186
+ continue;
187
+ }
188
+
189
+ if (entry.isDirectory()) {
190
+ collectFiles(fullPath, baseDir, ignorePatterns, files);
191
+ } else if (entry.isFile()) {
192
+ files.push(fullPath);
193
+ }
194
+ // Skip symlinks and other special files
195
+ }
196
+
197
+ return files;
198
+ }
199
+
200
+ /**
201
+ * Create canonical JSON string for hashing
202
+ *
203
+ * - Sorts object keys recursively
204
+ * - No whitespace (minified)
205
+ * - Consistent output across platforms
206
+ */
207
+ function canonicalJSON(obj: unknown): string {
208
+ if (obj === null || typeof obj !== "object") {
209
+ return JSON.stringify(obj);
210
+ }
211
+
212
+ if (Array.isArray(obj)) {
213
+ return `[${obj.map(canonicalJSON).join(",")}]`;
214
+ }
215
+
216
+ // Sort keys and recursively process
217
+ const sortedKeys = Object.keys(obj).sort();
218
+ const pairs = sortedKeys.map(
219
+ (key) => `${JSON.stringify(key)}:${canonicalJSON((obj as Record<string, unknown>)[key])}`
220
+ );
221
+
222
+ return `{${pairs.join(",")}}`;
223
+ }
224
+
225
+ // ============================================================================
226
+ // Main Functions
227
+ // ============================================================================
228
+
229
+ /**
230
+ * Create a checksum manifest for a tool directory
231
+ *
232
+ * Scans all files in the directory, computes SHA-256 hashes,
233
+ * and creates a manifest suitable for signing.
234
+ *
235
+ * @param toolDir - Path to the tool directory
236
+ * @param toolName - Tool name (e.g., "author/tool-name")
237
+ * @param toolVersion - Tool version (e.g., "1.0.0")
238
+ * @param options - Optional settings for ignore patterns and progress
239
+ * @returns Complete checksum manifest ready for signing
240
+ *
241
+ * @example
242
+ * ```ts
243
+ * const manifest = await createChecksumManifest(
244
+ * "./my-tool",
245
+ * "alice/my-tool",
246
+ * "1.0.0",
247
+ * { onProgress: (file) => console.log(`Hashing: ${file}`) }
248
+ * );
249
+ * ```
250
+ */
251
+ export async function createChecksumManifest(
252
+ toolDir: string,
253
+ toolName: string,
254
+ toolVersion: string,
255
+ options: CreateManifestOptions = {}
256
+ ): Promise<ChecksumManifest> {
257
+ const { ignorePatterns = [], onProgress } = options;
258
+
259
+ // Collect all files
260
+ const filePaths = collectFiles(toolDir, toolDir, ignorePatterns);
261
+
262
+ // Hash each file
263
+ const fileChecksums: FileChecksum[] = [];
264
+
265
+ for (const filePath of filePaths) {
266
+ const relativePath = normalizePath(relative(toolDir, filePath));
267
+
268
+ if (onProgress) {
269
+ onProgress(relativePath);
270
+ }
271
+
272
+ const stats = statSync(filePath);
273
+ const hashResult = await hashFile(filePath);
274
+
275
+ fileChecksums.push({
276
+ path: relativePath,
277
+ sha256: hashResult.digest,
278
+ size: stats.size,
279
+ });
280
+ }
281
+
282
+ // Sort by path for deterministic ordering (using simple string comparison)
283
+ fileChecksums.sort((a, b) => {
284
+ if (a.path < b.path) return -1;
285
+ if (a.path > b.path) return 1;
286
+ return 0;
287
+ });
288
+
289
+ // Create manifest without the hash first
290
+ const manifestWithoutHash = {
291
+ version: MANIFEST_VERSION,
292
+ tool: {
293
+ name: toolName,
294
+ version: toolVersion,
295
+ },
296
+ files: fileChecksums,
297
+ };
298
+
299
+ // Compute manifest hash from canonical JSON
300
+ const manifestHash = computeManifestHash(manifestWithoutHash);
301
+
302
+ // Return complete manifest
303
+ return {
304
+ ...manifestWithoutHash,
305
+ manifestHash: {
306
+ algorithm: "sha256",
307
+ digest: manifestHash.digest,
308
+ },
309
+ };
310
+ }
311
+
312
+ /**
313
+ * Compute the canonical hash of a manifest (for signing)
314
+ *
315
+ * Creates a deterministic hash by:
316
+ * 1. Excluding the manifestHash field itself
317
+ * 2. Using canonical JSON (sorted keys, no whitespace)
318
+ * 3. Computing SHA-256
319
+ *
320
+ * @param manifest - The manifest to hash (manifestHash field is ignored)
321
+ * @returns Hash result with algorithm and digest
322
+ */
323
+ export function computeManifestHash(
324
+ manifest: Omit<ChecksumManifest, "manifestHash"> | ChecksumManifest
325
+ ): HashResult {
326
+ // Create a copy without manifestHash
327
+ const { manifestHash: _, ...manifestWithoutHash } = manifest as ChecksumManifest;
328
+
329
+ // Compute canonical JSON and hash
330
+ const canonical = canonicalJSON(manifestWithoutHash);
331
+ return hashContent(canonical, "sha256");
332
+ }
333
+
334
+ /**
335
+ * Verify that files in a directory match a checksum manifest
336
+ *
337
+ * @param toolDir - Path to the tool directory
338
+ * @param manifest - The manifest to verify against
339
+ * @param options - Optional settings for ignore patterns
340
+ * @returns Verification result with details on any mismatches
341
+ *
342
+ * @example
343
+ * ```ts
344
+ * const result = await verifyChecksumManifest("./my-tool", manifest);
345
+ * if (!result.valid) {
346
+ * console.error("Verification failed:", result.errors);
347
+ * }
348
+ * ```
349
+ */
350
+ export async function verifyChecksumManifest(
351
+ toolDir: string,
352
+ manifest: ChecksumManifest,
353
+ options: { ignorePatterns?: string[] } = {}
354
+ ): Promise<ManifestVerificationResult> {
355
+ const { ignorePatterns = [] } = options;
356
+ const errors: string[] = [];
357
+ const missingFiles: string[] = [];
358
+ const modifiedFiles: string[] = [];
359
+ const extraFiles: string[] = [];
360
+
361
+ // First, verify the manifest hash itself
362
+ const computedHash = computeManifestHash(manifest);
363
+ if (computedHash.digest !== manifest.manifestHash.digest) {
364
+ errors.push(
365
+ `Manifest hash mismatch: expected ${manifest.manifestHash.digest}, got ${computedHash.digest}`
366
+ );
367
+ }
368
+
369
+ // Collect current files in directory
370
+ const currentFilePaths = collectFiles(toolDir, toolDir, ignorePatterns);
371
+ const currentFiles = new Set(currentFilePaths.map((fp) => normalizePath(relative(toolDir, fp))));
372
+
373
+ // Check each file in manifest
374
+ const manifestFiles = new Set<string>();
375
+
376
+ for (const fileEntry of manifest.files) {
377
+ manifestFiles.add(fileEntry.path);
378
+ const fullPath = join(toolDir, fileEntry.path);
379
+
380
+ // Check if file exists
381
+ if (!currentFiles.has(fileEntry.path)) {
382
+ missingFiles.push(fileEntry.path);
383
+ errors.push(`Missing file: ${fileEntry.path}`);
384
+ continue;
385
+ }
386
+
387
+ // Check hash
388
+ try {
389
+ const hashResult = await hashFile(fullPath);
390
+ if (hashResult.digest !== fileEntry.sha256) {
391
+ modifiedFiles.push(fileEntry.path);
392
+ errors.push(
393
+ `Modified file: ${fileEntry.path} (expected ${fileEntry.sha256.slice(0, 12)}..., got ${hashResult.digest.slice(0, 12)}...)`
394
+ );
395
+ }
396
+ } catch (err) {
397
+ errors.push(`Failed to hash file ${fileEntry.path}: ${err}`);
398
+ }
399
+ }
400
+
401
+ // Check for extra files not in manifest
402
+ for (const currentFile of currentFiles) {
403
+ if (!manifestFiles.has(currentFile)) {
404
+ extraFiles.push(currentFile);
405
+ errors.push(`Extra file not in manifest: ${currentFile}`);
406
+ }
407
+ }
408
+
409
+ return {
410
+ valid: errors.length === 0,
411
+ errors: errors.length > 0 ? errors : undefined,
412
+ missingFiles: missingFiles.length > 0 ? missingFiles : undefined,
413
+ modifiedFiles: modifiedFiles.length > 0 ? modifiedFiles : undefined,
414
+ extraFiles: extraFiles.length > 0 ? extraFiles : undefined,
415
+ };
416
+ }
417
+
418
+ /**
419
+ * Parse a checksum manifest from JSON string
420
+ *
421
+ * @param json - JSON string containing the manifest
422
+ * @returns Parsed manifest
423
+ * @throws Error if JSON is invalid or manifest structure is wrong
424
+ */
425
+ export function parseChecksumManifest(json: string): ChecksumManifest {
426
+ const parsed = JSON.parse(json);
427
+
428
+ // Validate structure
429
+ if (!parsed.version || parsed.version !== MANIFEST_VERSION) {
430
+ throw new Error(
431
+ `Invalid manifest version: expected ${MANIFEST_VERSION}, got ${parsed.version}`
432
+ );
433
+ }
434
+
435
+ if (!parsed.tool?.name || !parsed.tool?.version) {
436
+ throw new Error("Invalid manifest: missing tool name or version");
437
+ }
438
+
439
+ if (!Array.isArray(parsed.files)) {
440
+ throw new Error("Invalid manifest: files must be an array");
441
+ }
442
+
443
+ if (!parsed.manifestHash?.algorithm || !parsed.manifestHash?.digest) {
444
+ throw new Error("Invalid manifest: missing manifestHash");
445
+ }
446
+
447
+ return parsed as ChecksumManifest;
448
+ }
449
+
450
+ /**
451
+ * Serialize a checksum manifest to JSON string (pretty-printed for storage)
452
+ *
453
+ * @param manifest - The manifest to serialize
454
+ * @returns Pretty-printed JSON string
455
+ */
456
+ export function serializeChecksumManifest(manifest: ChecksumManifest): string {
457
+ return JSON.stringify(manifest, null, 2);
458
+ }
459
+
460
+ // ============================================================================
461
+ // Attestation Verification
462
+ // ============================================================================
463
+
464
+ /**
465
+ * Result of verifying a manifest attestation
466
+ */
467
+ export interface ManifestAttestationVerificationResult {
468
+ /** Whether the attestation is valid */
469
+ valid: boolean;
470
+ /** The auditor identity (email) from the certificate */
471
+ auditor?: string | undefined;
472
+ /** The OIDC provider used for signing */
473
+ provider?: string | undefined;
474
+ /** Error message if verification failed */
475
+ error?: string | undefined;
476
+ /** Detailed verification results */
477
+ details?:
478
+ | {
479
+ /** Whether the Sigstore bundle was verified */
480
+ bundleVerified: boolean;
481
+ /** Whether the manifest hash matches what was signed */
482
+ manifestHashMatches: boolean;
483
+ /** Whether the manifest matches the current files */
484
+ manifestMatchesFiles?: boolean | undefined;
485
+ }
486
+ | undefined;
487
+ }
488
+
489
+ /**
490
+ * Verify a manifest-based attestation
491
+ *
492
+ * This verifies that:
493
+ * 1. The Sigstore bundle is valid
494
+ * 2. The bundle was signed over the manifest hash
495
+ * 3. Optionally, the manifest matches the current files on disk
496
+ *
497
+ * @param manifest - The checksum manifest that was signed
498
+ * @param sigstoreBundle - The Sigstore bundle containing the signature
499
+ * @param options - Verification options
500
+ * @returns Verification result
501
+ */
502
+ export async function verifyManifestAttestation(
503
+ manifest: ChecksumManifest,
504
+ sigstoreBundle: unknown,
505
+ options: {
506
+ /** If provided, also verify manifest matches files in this directory */
507
+ toolDir?: string | undefined;
508
+ /** Additional patterns to ignore when verifying against directory */
509
+ ignorePatterns?: string[] | undefined;
510
+ } = {}
511
+ ): Promise<ManifestAttestationVerificationResult> {
512
+ // Dynamic import to avoid circular dependencies
513
+ const { verifyBundle, extractIdentityFromBundle } = await import("./sigstore");
514
+ type SigstoreBundleType = import("./sigstore/types").SigstoreBundle;
515
+
516
+ try {
517
+ // Get the manifest hash that should have been signed
518
+ const expectedHash = manifest.manifestHash.digest;
519
+
520
+ // Convert hash to Buffer for verification
521
+ const hashBuffer = Buffer.from(
522
+ expectedHash.match(/.{1,2}/g)!.map((byte) => Number.parseInt(byte, 16))
523
+ );
524
+
525
+ // Cast the bundle to the expected type
526
+ const bundle = sigstoreBundle as SigstoreBundleType;
527
+
528
+ // Verify the Sigstore bundle
529
+ const bundleResult = await verifyBundle(bundle, hashBuffer);
530
+
531
+ if (!bundleResult.verified) {
532
+ return {
533
+ valid: false,
534
+ error: bundleResult.error ?? "Sigstore bundle verification failed",
535
+ details: {
536
+ bundleVerified: false,
537
+ manifestHashMatches: false,
538
+ },
539
+ };
540
+ }
541
+
542
+ // Extract identity from the bundle
543
+ const identity = extractIdentityFromBundle(bundle);
544
+
545
+ // If toolDir is provided, also verify manifest matches files
546
+ let manifestMatchesFiles: boolean | undefined;
547
+ if (options.toolDir) {
548
+ const ignoreOpts = options.ignorePatterns ? { ignorePatterns: options.ignorePatterns } : {};
549
+ const fileVerification = await verifyChecksumManifest(options.toolDir, manifest, ignoreOpts);
550
+ manifestMatchesFiles = fileVerification.valid;
551
+
552
+ if (!fileVerification.valid) {
553
+ return {
554
+ valid: false,
555
+ auditor: identity?.email ?? identity?.subject,
556
+ provider: identity?.issuer,
557
+ error: `Manifest does not match files: ${fileVerification.errors?.join(", ")}`,
558
+ details: {
559
+ bundleVerified: true,
560
+ manifestHashMatches: true,
561
+ manifestMatchesFiles: false,
562
+ },
563
+ };
564
+ }
565
+ }
566
+
567
+ return {
568
+ valid: true,
569
+ auditor: identity?.email ?? identity?.subject,
570
+ provider: identity?.issuer,
571
+ details: {
572
+ bundleVerified: true,
573
+ manifestHashMatches: true,
574
+ manifestMatchesFiles,
575
+ },
576
+ };
577
+ } catch (error) {
578
+ return {
579
+ valid: false,
580
+ error: `Verification failed: ${(error as Error).message}`,
581
+ details: {
582
+ bundleVerified: false,
583
+ manifestHashMatches: false,
584
+ },
585
+ };
586
+ }
587
+ }