@enactprotocol/trust 2.0.0

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,355 @@
1
+ /**
2
+ * Sigstore verification module
3
+ *
4
+ * This module provides verification capabilities for Sigstore bundles and attestations.
5
+ * It verifies signatures, certificates, and transparency log entries.
6
+ *
7
+ * NOTE: This implementation bypasses TUF (The Update Framework) and uses bundled trusted
8
+ * roots directly. This is necessary for Bun compatibility because TUF verification fails
9
+ * with BoringSSL's stricter signature algorithm requirements.
10
+ */
11
+
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import { bundleFromJSON } from "@sigstore/bundle";
15
+ import { TrustedRoot } from "@sigstore/protobuf-specs";
16
+ import { Verifier, toSignedEntity, toTrustMaterial } from "@sigstore/verify";
17
+ import { extractIdentityFromBundle } from "./signing";
18
+ import type {
19
+ ExpectedIdentity,
20
+ OIDCIdentity,
21
+ SigstoreBundle,
22
+ VerificationDetails,
23
+ VerificationOptions,
24
+ VerificationResult,
25
+ } from "./types";
26
+
27
+ // ============================================================================
28
+ // Constants
29
+ // ============================================================================
30
+
31
+ /**
32
+ * Get the path to bundled TUF seeds
33
+ * We need to navigate from the @sigstore/tuf main entry point to find seeds.json
34
+ */
35
+ function getTufSeedsPath(): string {
36
+ // The package.json main points to dist/index.js, but seeds.json is at package root
37
+ const tufPkgPath = require.resolve("@sigstore/tuf/package.json");
38
+ return path.join(path.dirname(tufPkgPath), "seeds.json");
39
+ }
40
+
41
+ // ============================================================================
42
+ // Trusted Root Management
43
+ // ============================================================================
44
+
45
+ /**
46
+ * Load the trusted root from bundled TUF seeds
47
+ * This bypasses TUF's online verification which fails with BoringSSL
48
+ */
49
+ async function loadTrustedRoot(): Promise<ReturnType<typeof TrustedRoot.fromJSON>> {
50
+ const seedsPath = getTufSeedsPath();
51
+ const seeds = JSON.parse(fs.readFileSync(seedsPath, "utf8"));
52
+ const seedData = seeds["https://tuf-repo-cdn.sigstore.dev"];
53
+ const trustedRootB64 = seedData.targets["trusted_root.json"];
54
+ const trustedRootJson = JSON.parse(Buffer.from(trustedRootB64, "base64").toString());
55
+ return TrustedRoot.fromJSON(trustedRootJson);
56
+ }
57
+
58
+ /**
59
+ * Create trust material from the bundled trusted root
60
+ */
61
+ async function createTrustMaterial() {
62
+ const trustedRoot = await loadTrustedRoot();
63
+ return toTrustMaterial(trustedRoot);
64
+ }
65
+
66
+ // ============================================================================
67
+ // Verification Functions
68
+ // ============================================================================
69
+
70
+ /**
71
+ * Verify a Sigstore bundle
72
+ *
73
+ * @param bundle - The Sigstore bundle to verify
74
+ * @param artifact - Optional artifact data (for message signature bundles)
75
+ * @param options - Verification options
76
+ * @returns Verification result with detailed checks
77
+ *
78
+ * @example
79
+ * ```ts
80
+ * const result = await verifyBundle(bundle, artifact, {
81
+ * expectedIdentity: {
82
+ * subjectAlternativeName: "user@example.com",
83
+ * issuer: "https://accounts.google.com"
84
+ * }
85
+ * });
86
+ * if (result.verified) {
87
+ * console.log("Bundle verified successfully");
88
+ * }
89
+ * ```
90
+ */
91
+ export async function verifyBundle(
92
+ bundle: SigstoreBundle,
93
+ artifact?: Buffer,
94
+ options: VerificationOptions = {}
95
+ ): Promise<VerificationResult> {
96
+ const details: VerificationDetails = {
97
+ signatureValid: false,
98
+ certificateValid: false,
99
+ certificateWithinValidity: false,
100
+ rekorEntryValid: false,
101
+ inclusionProofValid: false,
102
+ errors: [],
103
+ };
104
+
105
+ try {
106
+ // Create trust material from bundled roots
107
+ const trustMaterial = await createTrustMaterial();
108
+
109
+ // Create verifier
110
+ const verifier = new Verifier(trustMaterial);
111
+
112
+ // Convert bundle to proper format
113
+ const parsedBundle = bundleFromJSON(bundle);
114
+ const signedEntity = toSignedEntity(parsedBundle, artifact);
115
+
116
+ // Perform verification
117
+ verifier.verify(signedEntity);
118
+
119
+ // If we get here, verification passed
120
+ details.signatureValid = true;
121
+ details.certificateValid = true;
122
+ details.certificateWithinValidity = true;
123
+ details.rekorEntryValid = true;
124
+ details.inclusionProofValid = true;
125
+
126
+ // Extract identity from bundle
127
+ const identity = extractIdentityFromBundle(bundle);
128
+
129
+ // Check identity if expected identity is provided
130
+ if (options.expectedIdentity) {
131
+ details.identityMatches = matchesExpectedIdentity(identity, options.expectedIdentity);
132
+ if (!details.identityMatches) {
133
+ details.errors.push("Identity does not match expected values");
134
+ const result: VerificationResult = {
135
+ verified: false,
136
+ error: "Identity mismatch",
137
+ details,
138
+ };
139
+ if (identity) result.identity = identity;
140
+ const timestamp = extractTimestampFromBundle(bundle);
141
+ if (timestamp) result.timestamp = timestamp;
142
+ return result;
143
+ }
144
+ }
145
+
146
+ const result: VerificationResult = {
147
+ verified: true,
148
+ details,
149
+ };
150
+ if (identity) result.identity = identity;
151
+ const timestamp = extractTimestampFromBundle(bundle);
152
+ if (timestamp) result.timestamp = timestamp;
153
+ return result;
154
+ } catch (error) {
155
+ const errorMessage = error instanceof Error ? error.message : String(error);
156
+ details.errors.push(errorMessage);
157
+
158
+ // Try to determine which check failed based on error message
159
+ categorizeVerificationError(errorMessage, details);
160
+
161
+ return {
162
+ verified: false,
163
+ error: errorMessage,
164
+ details,
165
+ };
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Create a reusable verifier for multiple verifications
171
+ *
172
+ * @param options - Verification options
173
+ * @returns A verifier object that can verify multiple bundles
174
+ *
175
+ * @example
176
+ * ```ts
177
+ * const verifier = await createBundleVerifier({
178
+ * expectedIdentity: { issuer: "https://accounts.google.com" }
179
+ * });
180
+ *
181
+ * // Verify multiple bundles efficiently
182
+ * for (const bundle of bundles) {
183
+ * verifier.verify(bundle);
184
+ * }
185
+ * ```
186
+ */
187
+ export async function createBundleVerifier(options: VerificationOptions = {}) {
188
+ // Create trust material once and reuse
189
+ const trustMaterial = await createTrustMaterial();
190
+ const verifier = new Verifier(trustMaterial);
191
+
192
+ return {
193
+ /**
194
+ * Verify a bundle using the cached verifier
195
+ */
196
+ verify: async (bundle: SigstoreBundle, artifact?: Buffer): Promise<VerificationResult> => {
197
+ const details: VerificationDetails = {
198
+ signatureValid: false,
199
+ certificateValid: false,
200
+ certificateWithinValidity: false,
201
+ rekorEntryValid: false,
202
+ inclusionProofValid: false,
203
+ errors: [],
204
+ };
205
+
206
+ try {
207
+ // Convert bundle to proper format
208
+ const parsedBundle = bundleFromJSON(bundle);
209
+ const signedEntity = toSignedEntity(parsedBundle, artifact);
210
+
211
+ // Perform verification
212
+ verifier.verify(signedEntity);
213
+
214
+ details.signatureValid = true;
215
+ details.certificateValid = true;
216
+ details.certificateWithinValidity = true;
217
+ details.rekorEntryValid = true;
218
+ details.inclusionProofValid = true;
219
+
220
+ const identity = extractIdentityFromBundle(bundle);
221
+
222
+ if (options.expectedIdentity) {
223
+ details.identityMatches = matchesExpectedIdentity(identity, options.expectedIdentity);
224
+ if (!details.identityMatches) {
225
+ details.errors.push("Identity does not match expected values");
226
+ const result: VerificationResult = {
227
+ verified: false,
228
+ error: "Identity mismatch",
229
+ details,
230
+ };
231
+ if (identity) result.identity = identity;
232
+ const timestamp = extractTimestampFromBundle(bundle);
233
+ if (timestamp) result.timestamp = timestamp;
234
+ return result;
235
+ }
236
+ }
237
+
238
+ const result: VerificationResult = {
239
+ verified: true,
240
+ details,
241
+ };
242
+ if (identity) result.identity = identity;
243
+ const timestamp = extractTimestampFromBundle(bundle);
244
+ if (timestamp) result.timestamp = timestamp;
245
+ return result;
246
+ } catch (error) {
247
+ const errorMessage = error instanceof Error ? error.message : String(error);
248
+ details.errors.push(errorMessage);
249
+ categorizeVerificationError(errorMessage, details);
250
+
251
+ return {
252
+ verified: false,
253
+ error: errorMessage,
254
+ details,
255
+ };
256
+ }
257
+ },
258
+ };
259
+ }
260
+
261
+ /**
262
+ * Quick verification check - returns boolean only
263
+ *
264
+ * @param bundle - The Sigstore bundle to verify
265
+ * @param artifact - Optional artifact data
266
+ * @returns True if verification passes, false otherwise
267
+ */
268
+ export async function isVerified(bundle: SigstoreBundle, artifact?: Buffer): Promise<boolean> {
269
+ try {
270
+ const trustMaterial = await createTrustMaterial();
271
+ const verifier = new Verifier(trustMaterial);
272
+ const parsedBundle = bundleFromJSON(bundle);
273
+ const signedEntity = toSignedEntity(parsedBundle, artifact);
274
+ verifier.verify(signedEntity);
275
+ return true;
276
+ } catch {
277
+ return false;
278
+ }
279
+ }
280
+
281
+ // ============================================================================
282
+ // Helper Functions
283
+ // ============================================================================
284
+
285
+ /**
286
+ * Check if an identity matches expected values
287
+ */
288
+ function matchesExpectedIdentity(
289
+ identity: OIDCIdentity | undefined,
290
+ expected: ExpectedIdentity
291
+ ): boolean {
292
+ if (!identity) {
293
+ return false;
294
+ }
295
+
296
+ // Check issuer
297
+ if (expected.issuer && identity.issuer !== expected.issuer) {
298
+ return false;
299
+ }
300
+
301
+ // Check subject alternative name (could be email or URI)
302
+ if (expected.subjectAlternativeName) {
303
+ const san = expected.subjectAlternativeName;
304
+ if (identity.email !== san && identity.subject !== san) {
305
+ return false;
306
+ }
307
+ }
308
+
309
+ // Check GitHub workflow repository
310
+ if (expected.workflowRepository && identity.workflowRepository !== expected.workflowRepository) {
311
+ return false;
312
+ }
313
+
314
+ // Check GitHub workflow ref
315
+ if (expected.workflowRef && identity.workflowRef !== expected.workflowRef) {
316
+ return false;
317
+ }
318
+
319
+ return true;
320
+ }
321
+
322
+ /**
323
+ * Extract timestamp from a Sigstore bundle
324
+ */
325
+ function extractTimestampFromBundle(bundle: SigstoreBundle): Date | undefined {
326
+ // Try to get timestamp from transparency log entry
327
+ const tlogEntry = bundle.verificationMaterial?.tlogEntries?.[0];
328
+ if (tlogEntry?.integratedTime) {
329
+ const timestamp = Number.parseInt(tlogEntry.integratedTime, 10);
330
+ if (!Number.isNaN(timestamp)) {
331
+ return new Date(timestamp * 1000);
332
+ }
333
+ }
334
+
335
+ return undefined;
336
+ }
337
+
338
+ /**
339
+ * Categorize a verification error to update details
340
+ */
341
+ function categorizeVerificationError(errorMessage: string, details: VerificationDetails): void {
342
+ const lowerError = errorMessage.toLowerCase();
343
+
344
+ if (lowerError.includes("signature")) {
345
+ details.signatureValid = false;
346
+ } else if (lowerError.includes("certificate") && lowerError.includes("expired")) {
347
+ details.certificateWithinValidity = false;
348
+ } else if (lowerError.includes("certificate")) {
349
+ details.certificateValid = false;
350
+ } else if (lowerError.includes("rekor") || lowerError.includes("transparency")) {
351
+ details.rekorEntryValid = false;
352
+ } else if (lowerError.includes("inclusion") || lowerError.includes("proof")) {
353
+ details.inclusionProofValid = false;
354
+ }
355
+ }
package/src/types.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Type definitions for security operations
3
+ */
4
+
5
+ // ============================================================================
6
+ // Hash Types
7
+ // ============================================================================
8
+
9
+ /**
10
+ * Supported hash algorithms
11
+ */
12
+ export type HashAlgorithm = "sha256" | "sha512";
13
+
14
+ /**
15
+ * Result of a hash operation
16
+ */
17
+ export interface HashResult {
18
+ /** The hash algorithm used */
19
+ algorithm: HashAlgorithm;
20
+ /** The hash digest in hexadecimal format */
21
+ digest: string;
22
+ }
23
+
24
+ /**
25
+ * Options for file hashing operations
26
+ */
27
+ export interface FileHashOptions {
28
+ /** Hash algorithm to use (default: sha256) */
29
+ algorithm?: HashAlgorithm;
30
+ /** Progress callback for large files */
31
+ onProgress?: (bytesRead: number, totalBytes: number) => void;
32
+ }
33
+
34
+ // ============================================================================
35
+ // Key Management Types
36
+ // ============================================================================
37
+
38
+ /**
39
+ * Supported key types
40
+ */
41
+ export type KeyType = "rsa" | "ed25519" | "ecdsa";
42
+
43
+ /**
44
+ * Key format for storage
45
+ */
46
+ export type KeyFormat = "pem" | "der" | "jwk";
47
+
48
+ /**
49
+ * A cryptographic key pair
50
+ */
51
+ export interface KeyPair {
52
+ /** Public key */
53
+ publicKey: string;
54
+ /** Private key (encrypted or plain) */
55
+ privateKey: string;
56
+ /** Key type */
57
+ type: KeyType;
58
+ /** Key format */
59
+ format: KeyFormat;
60
+ }
61
+
62
+ /**
63
+ * Options for key generation
64
+ */
65
+ export interface KeyGenerationOptions {
66
+ /** Key type to generate */
67
+ type: KeyType;
68
+ /** Output format */
69
+ format?: KeyFormat;
70
+ /** RSA key size in bits (only for RSA keys) */
71
+ modulusLength?: number;
72
+ /** Passphrase for encrypting private key */
73
+ passphrase?: string;
74
+ }
75
+
76
+ // ============================================================================
77
+ // Placeholder for other types
78
+ // ============================================================================
79
+
80
+ export type SecurityConfig = Record<string, unknown>;
@@ -0,0 +1,180 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { hashBuffer, hashContent, hashFile } from "../src/hash";
5
+
6
+ const TEST_DIR = join(import.meta.dir, "fixtures");
7
+ const TEST_FILE = join(TEST_DIR, "test.txt");
8
+ const LARGE_FILE = join(TEST_DIR, "large.txt");
9
+ const BINARY_FILE = join(TEST_DIR, "binary.dat");
10
+
11
+ describe("hash utilities", () => {
12
+ beforeAll(() => {
13
+ // Create test directory and files
14
+ mkdirSync(TEST_DIR, { recursive: true });
15
+ writeFileSync(TEST_FILE, "hello world");
16
+
17
+ // Create a larger file for streaming tests
18
+ writeFileSync(LARGE_FILE, "x".repeat(1024 * 1024)); // 1MB file
19
+
20
+ // Create a binary file
21
+ const binaryData = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe, 0xfd]);
22
+ writeFileSync(BINARY_FILE, binaryData);
23
+ });
24
+
25
+ afterAll(() => {
26
+ // Clean up test files
27
+ rmSync(TEST_DIR, { recursive: true, force: true });
28
+ });
29
+
30
+ describe("hashContent", () => {
31
+ test("hashes string content with sha256", () => {
32
+ const result = hashContent("hello world");
33
+
34
+ expect(result.algorithm).toBe("sha256");
35
+ expect(result.digest).toBe(
36
+ "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
37
+ );
38
+ });
39
+
40
+ test("hashes string content with sha512", () => {
41
+ const result = hashContent("hello world", "sha512");
42
+
43
+ expect(result.algorithm).toBe("sha512");
44
+ expect(result.digest).toBe(
45
+ "309ecc489c12d6eb4cc40f50c902f2b4d0ed77ee511a7c7a9bcd3ca86d4cd86f989dd35bc5ff499670da34255b45b0cfd830e81f605dcf7dc5542e93ae9cd76f"
46
+ );
47
+ });
48
+
49
+ test("hashes buffer content", () => {
50
+ const buffer = Buffer.from("hello world");
51
+ const result = hashContent(buffer);
52
+
53
+ expect(result.digest).toBe(
54
+ "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
55
+ );
56
+ });
57
+
58
+ test("produces consistent hashes for same content", () => {
59
+ const result1 = hashContent("test content");
60
+ const result2 = hashContent("test content");
61
+
62
+ expect(result1.digest).toBe(result2.digest);
63
+ });
64
+
65
+ test("produces different hashes for different content", () => {
66
+ const result1 = hashContent("content 1");
67
+ const result2 = hashContent("content 2");
68
+
69
+ expect(result1.digest).not.toBe(result2.digest);
70
+ });
71
+
72
+ test("handles empty string", () => {
73
+ const result = hashContent("");
74
+
75
+ expect(result.digest).toBe(
76
+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
77
+ );
78
+ });
79
+
80
+ test("handles unicode content", () => {
81
+ const result = hashContent("Hello δΈ–η•Œ 🌍");
82
+
83
+ expect(result.algorithm).toBe("sha256");
84
+ expect(result.digest).toHaveLength(64); // sha256 produces 64 hex chars
85
+ });
86
+ });
87
+
88
+ describe("hashBuffer", () => {
89
+ test("hashes buffer data", () => {
90
+ const buffer = Buffer.from("hello world");
91
+ const result = hashBuffer(buffer);
92
+
93
+ expect(result.digest).toBe(
94
+ "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
95
+ );
96
+ });
97
+
98
+ test("hashes binary data", () => {
99
+ const buffer = Buffer.from([0x00, 0x01, 0x02, 0xff]);
100
+ const result = hashBuffer(buffer);
101
+
102
+ expect(result.algorithm).toBe("sha256");
103
+ expect(result.digest).toHaveLength(64);
104
+ });
105
+
106
+ test("supports sha512 algorithm", () => {
107
+ const buffer = Buffer.from("test");
108
+ const result = hashBuffer(buffer, "sha512");
109
+
110
+ expect(result.algorithm).toBe("sha512");
111
+ expect(result.digest).toHaveLength(128); // sha512 produces 128 hex chars
112
+ });
113
+ });
114
+
115
+ describe("hashFile", () => {
116
+ test("hashes file content", async () => {
117
+ const result = await hashFile(TEST_FILE);
118
+
119
+ expect(result.algorithm).toBe("sha256");
120
+ expect(result.digest).toBe(
121
+ "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
122
+ );
123
+ });
124
+
125
+ test("hashes file with sha512", async () => {
126
+ const result = await hashFile(TEST_FILE, { algorithm: "sha512" });
127
+
128
+ expect(result.algorithm).toBe("sha512");
129
+ expect(result.digest).toHaveLength(128);
130
+ });
131
+
132
+ test("hashes large file with streaming", async () => {
133
+ const result = await hashFile(LARGE_FILE);
134
+
135
+ expect(result.algorithm).toBe("sha256");
136
+ expect(result.digest).toHaveLength(64);
137
+ });
138
+
139
+ test("hashes binary file", async () => {
140
+ const result = await hashFile(BINARY_FILE);
141
+
142
+ expect(result.algorithm).toBe("sha256");
143
+ expect(result.digest).toHaveLength(64);
144
+ });
145
+
146
+ test("calls progress callback during hashing", async () => {
147
+ const progressCalls: Array<{ read: number; total: number }> = [];
148
+
149
+ await hashFile(LARGE_FILE, {
150
+ onProgress: (bytesRead, totalBytes) => {
151
+ progressCalls.push({ read: bytesRead, total: totalBytes });
152
+ },
153
+ });
154
+
155
+ expect(progressCalls.length).toBeGreaterThan(0);
156
+
157
+ // Last call should have read all bytes
158
+ const lastCall = progressCalls[progressCalls.length - 1];
159
+ if (lastCall) {
160
+ expect(lastCall.read).toBe(lastCall.total);
161
+ }
162
+ });
163
+
164
+ test("throws error for non-existent file", async () => {
165
+ await expect(hashFile("/nonexistent/file.txt")).rejects.toThrow("Failed to access file");
166
+ });
167
+
168
+ test("throws error for directory path", async () => {
169
+ await expect(hashFile(TEST_DIR)).rejects.toThrow("Path is not a file");
170
+ });
171
+
172
+ test("produces same hash as hashContent for same data", async () => {
173
+ const content = "hello world";
174
+ const contentResult = hashContent(content);
175
+ const fileResult = await hashFile(TEST_FILE);
176
+
177
+ expect(fileResult.digest).toBe(contentResult.digest);
178
+ });
179
+ });
180
+ });
@@ -0,0 +1,8 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { version } from "../src/index";
3
+
4
+ describe("@enactprotocol/security", () => {
5
+ test("exports version", () => {
6
+ expect(version).toBe("0.1.0");
7
+ });
8
+ });