@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,569 @@
1
+ /**
2
+ * OIDC-based keyless signing using Sigstore
3
+ *
4
+ * This module provides keyless signing capabilities using OIDC identity tokens.
5
+ * It integrates with Fulcio for certificate issuance and Rekor for transparency logging.
6
+ *
7
+ * For CI environments (GitHub Actions, GitLab CI, etc.), the sigstore library's
8
+ * native OIDC support is used. For interactive local signing, we use a native
9
+ * OAuth implementation that opens a browser for authentication.
10
+ */
11
+
12
+ import { type SignOptions, attest, sign } from "sigstore";
13
+ import { attestWithCosign, isCosignAvailable, signWithCosign } from "./cosign";
14
+ import { OAuthIdentityProvider } from "./oauth";
15
+ import type {
16
+ SigningOptions as EnactSigningOptions,
17
+ FulcioCertificate,
18
+ OIDCIdentity,
19
+ OIDCProvider,
20
+ SigningResult,
21
+ SigstoreBundle,
22
+ } from "./types";
23
+
24
+ // Re-export SignOptions for external use
25
+ export type { SignOptions };
26
+
27
+ // ============================================================================
28
+ // Constants
29
+ // ============================================================================
30
+
31
+ /** Public Sigstore Fulcio URL */
32
+ export const FULCIO_PUBLIC_URL = "https://fulcio.sigstore.dev";
33
+
34
+ /** Public Sigstore Rekor URL */
35
+ export const REKOR_PUBLIC_URL = "https://rekor.sigstore.dev";
36
+
37
+ /** Public Sigstore TSA URL */
38
+ export const TSA_PUBLIC_URL = "https://timestamp.sigstore.dev";
39
+
40
+ /** OIDC issuer URLs for known providers */
41
+ export const OIDC_ISSUERS: Record<OIDCProvider, string> = {
42
+ github: "https://token.actions.githubusercontent.com",
43
+ google: "https://accounts.google.com",
44
+ microsoft: "https://login.microsoftonline.com",
45
+ gitlab: "https://gitlab.com",
46
+ custom: "",
47
+ };
48
+
49
+ // ============================================================================
50
+ // OIDC Identity Extraction
51
+ // ============================================================================
52
+
53
+ /**
54
+ * Decode a JWT token without verification (for extracting claims)
55
+ */
56
+ function decodeJWT(token: string): Record<string, unknown> {
57
+ const parts = token.split(".");
58
+ if (parts.length !== 3) {
59
+ throw new Error("Invalid JWT format");
60
+ }
61
+
62
+ const payloadPart = parts[1];
63
+ if (!payloadPart) {
64
+ throw new Error("Invalid JWT: missing payload");
65
+ }
66
+
67
+ try {
68
+ // Use standard base64 decoding with URL-safe character replacement
69
+ const base64 = payloadPart.replace(/-/g, "+").replace(/_/g, "/");
70
+ const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
71
+ const payload = Buffer.from(padded, "base64").toString("utf8");
72
+ return JSON.parse(payload);
73
+ } catch {
74
+ throw new Error("Failed to decode JWT payload");
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Detect OIDC provider from issuer URL
80
+ */
81
+ export function detectOIDCProvider(issuer: string): OIDCProvider {
82
+ for (const [provider, url] of Object.entries(OIDC_ISSUERS)) {
83
+ if (url && issuer.startsWith(url)) {
84
+ return provider as OIDCProvider;
85
+ }
86
+ }
87
+ return "custom";
88
+ }
89
+
90
+ /**
91
+ * Extract identity information from an OIDC token
92
+ *
93
+ * @param token - The OIDC identity token
94
+ * @returns Extracted identity information
95
+ */
96
+ export function extractOIDCIdentity(token: string): OIDCIdentity {
97
+ const claims = decodeJWT(token);
98
+
99
+ const issuer = claims.iss as string;
100
+ const provider = detectOIDCProvider(issuer);
101
+
102
+ const identity: OIDCIdentity = {
103
+ provider,
104
+ subject: claims.sub as string,
105
+ issuer,
106
+ claims,
107
+ };
108
+
109
+ // Extract email if present
110
+ if (claims.email) {
111
+ identity.email = claims.email as string;
112
+ }
113
+
114
+ // Extract GitHub-specific claims
115
+ if (provider === "github") {
116
+ if (claims.repository) {
117
+ identity.workflowRepository = claims.repository as string;
118
+ }
119
+ if (claims.ref) {
120
+ identity.workflowRef = claims.ref as string;
121
+ }
122
+ if (claims.event_name) {
123
+ identity.workflowTrigger = claims.event_name as string;
124
+ }
125
+ }
126
+
127
+ return identity;
128
+ }
129
+
130
+ /**
131
+ * Get OIDC token from environment (for CI/CD environments)
132
+ *
133
+ * @param provider - The OIDC provider
134
+ * @returns The OIDC token if available
135
+ */
136
+ export function getOIDCTokenFromEnvironment(provider: OIDCProvider): string | undefined {
137
+ switch (provider) {
138
+ case "github":
139
+ // GitHub Actions provides OIDC tokens via ACTIONS_ID_TOKEN_REQUEST_URL
140
+ return process.env.ACTIONS_ID_TOKEN;
141
+ case "gitlab":
142
+ return process.env.CI_JOB_JWT_V2 || process.env.CI_JOB_JWT;
143
+ default:
144
+ return undefined;
145
+ }
146
+ }
147
+
148
+ // ============================================================================
149
+ // Environment Detection
150
+ // ============================================================================
151
+
152
+ /**
153
+ * Check if we're running in a CI environment with native OIDC support
154
+ */
155
+ function isInCIEnvironment(): boolean {
156
+ // GitHub Actions
157
+ if (process.env.GITHUB_ACTIONS && process.env.ACTIONS_ID_TOKEN_REQUEST_URL) {
158
+ return true;
159
+ }
160
+ // GitLab CI
161
+ if (process.env.GITLAB_CI && (process.env.CI_JOB_JWT_V2 || process.env.CI_JOB_JWT)) {
162
+ return true;
163
+ }
164
+ // Generic CI detection with token
165
+ if (process.env.CI && process.env.SIGSTORE_ID_TOKEN) {
166
+ return true;
167
+ }
168
+ return false;
169
+ }
170
+
171
+ /**
172
+ * Check if we're in an interactive terminal environment
173
+ */
174
+ function isInteractiveEnvironment(): boolean {
175
+ return process.stdout.isTTY === true;
176
+ }
177
+
178
+ // ============================================================================
179
+ // Signing Functions
180
+ // ============================================================================
181
+
182
+ /**
183
+ * Sign an artifact using keyless (OIDC) signing
184
+ *
185
+ * In CI environments with native OIDC support (GitHub Actions, GitLab CI),
186
+ * uses the sigstore library directly. For interactive local signing,
187
+ * uses native OAuth implementation that opens browser for authentication.
188
+ *
189
+ * @param artifact - The artifact to sign (as a Buffer)
190
+ * @param options - Signing options
191
+ * @returns The signing result including the Sigstore bundle
192
+ *
193
+ * @example
194
+ * ```ts
195
+ * const artifact = Buffer.from(JSON.stringify(manifest));
196
+ * const result = await signArtifact(artifact, {
197
+ * oidc: { provider: "github" }
198
+ * });
199
+ * console.log(result.bundle);
200
+ * ```
201
+ */
202
+ export async function signArtifact(
203
+ artifact: Buffer,
204
+ options: EnactSigningOptions = {}
205
+ ): Promise<SigningResult> {
206
+ const { fulcioURL, rekorURL, timeout = 30000 } = options;
207
+
208
+ // Create sigstore sign options
209
+ const signOptions: SignOptions = {
210
+ fulcioURL: fulcioURL || FULCIO_PUBLIC_URL,
211
+ rekorURL: rekorURL || REKOR_PUBLIC_URL,
212
+ timeout,
213
+ };
214
+
215
+ // If we have an explicit OIDC token, use it directly
216
+ if (options.oidc?.token) {
217
+ signOptions.identityToken = options.oidc.token;
218
+ }
219
+ // If we're in a CI environment, sigstore library will handle OIDC
220
+ else if (isInCIEnvironment()) {
221
+ // No additional config needed - sigstore will use CI provider
222
+ }
223
+ // Interactive environment - try native OAuth first, fall back to cosign
224
+ else if (isInteractiveEnvironment()) {
225
+ // Try native OAuth with sigstore-js first
226
+ try {
227
+ const provider = new OAuthIdentityProvider();
228
+ signOptions.identityProvider = provider;
229
+
230
+ const bundle = await sign(artifact, signOptions);
231
+ const sigstoreBundle = bundle as unknown as SigstoreBundle;
232
+ const certificate = extractCertificateFromBundle(sigstoreBundle);
233
+
234
+ const result: SigningResult = {
235
+ bundle: sigstoreBundle,
236
+ timestamp: new Date(),
237
+ };
238
+
239
+ if (certificate) {
240
+ result.certificate = certificate;
241
+ }
242
+
243
+ return result;
244
+ } catch (nativeError) {
245
+ // Check if this is a BoringSSL/crypto compatibility issue
246
+ const errorMessage = nativeError instanceof Error ? nativeError.message : String(nativeError);
247
+ const isCryptoError =
248
+ errorMessage.includes("NO_DEFAULT_DIGEST") || errorMessage.includes("public key routines");
249
+
250
+ if (isCryptoError && isCosignAvailable()) {
251
+ // Fall back to cosign CLI
252
+ const result = await signWithCosign(artifact, {
253
+ timeout,
254
+ verbose: false,
255
+ });
256
+
257
+ return {
258
+ bundle: result.bundle,
259
+ timestamp: new Date(),
260
+ };
261
+ }
262
+
263
+ // If cosign is not available and we hit a crypto error, give helpful message
264
+ if (isCryptoError) {
265
+ throw new Error(
266
+ "Signing failed due to a crypto compatibility issue with Bun's BoringSSL.\n" +
267
+ "Install cosign CLI for local signing: brew install cosign\n" +
268
+ "Or run with Node.js instead of Bun.\n" +
269
+ "See: https://docs.sigstore.dev/cosign/system_config/installation/"
270
+ );
271
+ }
272
+
273
+ // Re-throw other errors
274
+ throw nativeError;
275
+ }
276
+ }
277
+ // Non-interactive, non-CI - error
278
+ else {
279
+ throw new Error(
280
+ "No OIDC token available and not in an interactive environment.\n" +
281
+ "Provide an OIDC token via options.oidc.token or run in a CI environment with OIDC support."
282
+ );
283
+ }
284
+
285
+ const bundle = await sign(artifact, signOptions);
286
+
287
+ // Parse the result
288
+ const sigstoreBundle = bundle as unknown as SigstoreBundle;
289
+ const certificate = extractCertificateFromBundle(sigstoreBundle);
290
+
291
+ const result: SigningResult = {
292
+ bundle: sigstoreBundle,
293
+ timestamp: new Date(),
294
+ };
295
+
296
+ if (certificate) {
297
+ result.certificate = certificate;
298
+ }
299
+
300
+ return result;
301
+ }
302
+
303
+ /**
304
+ * Sign an in-toto attestation using keyless signing
305
+ *
306
+ * In CI environments with native OIDC support, uses the sigstore library.
307
+ * For interactive local signing, uses native OAuth with browser authentication.
308
+ *
309
+ * @param attestation - The attestation to sign (in-toto statement)
310
+ * @param options - Signing options
311
+ * @returns The signing result including the Sigstore bundle
312
+ *
313
+ * @example
314
+ * ```ts
315
+ * const statement = {
316
+ * _type: "https://in-toto.io/Statement/v1",
317
+ * subject: [{ name: "tool.yaml", digest: { sha256: "abc123..." } }],
318
+ * predicateType: "https://slsa.dev/provenance/v1",
319
+ * predicate: { ... }
320
+ * };
321
+ * const result = await signAttestation(statement, { oidc: { provider: "github" } });
322
+ * ```
323
+ */
324
+ export async function signAttestation(
325
+ attestation: Record<string, unknown>,
326
+ options: EnactSigningOptions = {}
327
+ ): Promise<SigningResult> {
328
+ const { fulcioURL, rekorURL, timeout = 30000 } = options;
329
+
330
+ // Serialize attestation
331
+ const payload = Buffer.from(JSON.stringify(attestation));
332
+
333
+ // Create sigstore attest options
334
+ const attestOptions: SignOptions = {
335
+ fulcioURL: fulcioURL || FULCIO_PUBLIC_URL,
336
+ rekorURL: rekorURL || REKOR_PUBLIC_URL,
337
+ timeout,
338
+ };
339
+
340
+ // If we have an explicit OIDC token, use it directly
341
+ if (options.oidc?.token) {
342
+ attestOptions.identityToken = options.oidc.token;
343
+ }
344
+ // If we're in a CI environment, sigstore library will handle OIDC
345
+ else if (isInCIEnvironment()) {
346
+ // No additional config needed - sigstore will use CI provider
347
+ }
348
+ // Interactive environment - try native OAuth first, fall back to cosign
349
+ else if (isInteractiveEnvironment()) {
350
+ // Try native OAuth with sigstore-js first
351
+ try {
352
+ const provider = new OAuthIdentityProvider();
353
+ attestOptions.identityProvider = provider;
354
+
355
+ const bundle = await attest(payload, "application/vnd.in-toto+json", attestOptions);
356
+ const sigstoreBundle = bundle as unknown as SigstoreBundle;
357
+ const certificate = extractCertificateFromBundle(sigstoreBundle);
358
+
359
+ const result: SigningResult = {
360
+ bundle: sigstoreBundle,
361
+ timestamp: new Date(),
362
+ };
363
+
364
+ if (certificate) {
365
+ result.certificate = certificate;
366
+ }
367
+
368
+ return result;
369
+ } catch (nativeError) {
370
+ // Check if this is a BoringSSL/crypto compatibility issue
371
+ const errorMessage = nativeError instanceof Error ? nativeError.message : String(nativeError);
372
+ const isCryptoError =
373
+ errorMessage.includes("NO_DEFAULT_DIGEST") || errorMessage.includes("public key routines");
374
+
375
+ if (isCryptoError && isCosignAvailable()) {
376
+ // Fall back to cosign CLI
377
+ const result = await attestWithCosign(attestation, {
378
+ timeout,
379
+ verbose: false,
380
+ });
381
+
382
+ return {
383
+ bundle: result.bundle,
384
+ timestamp: new Date(),
385
+ };
386
+ }
387
+
388
+ // If cosign is not available and we hit a crypto error, give helpful message
389
+ if (isCryptoError) {
390
+ throw new Error(
391
+ "Signing failed due to a crypto compatibility issue with Bun's BoringSSL.\n" +
392
+ "Install cosign CLI for local signing: brew install cosign\n" +
393
+ "Or run with Node.js instead of Bun.\n" +
394
+ "See: https://docs.sigstore.dev/cosign/system_config/installation/"
395
+ );
396
+ }
397
+
398
+ // Re-throw other errors
399
+ throw nativeError;
400
+ }
401
+ }
402
+ // Non-interactive, non-CI - error
403
+ else {
404
+ throw new Error(
405
+ "No OIDC token available and not in an interactive environment.\n" +
406
+ "Provide an OIDC token via options.oidc.token or run in a CI environment with OIDC support."
407
+ );
408
+ }
409
+
410
+ const bundle = await attest(payload, "application/vnd.in-toto+json", attestOptions);
411
+
412
+ // Parse the result
413
+ const sigstoreBundle = bundle as unknown as SigstoreBundle;
414
+ const certificate = extractCertificateFromBundle(sigstoreBundle);
415
+
416
+ const result: SigningResult = {
417
+ bundle: sigstoreBundle,
418
+ timestamp: new Date(),
419
+ };
420
+
421
+ if (certificate) {
422
+ result.certificate = certificate;
423
+ }
424
+
425
+ return result;
426
+ }
427
+
428
+ // ============================================================================
429
+ // Certificate Extraction
430
+ // ============================================================================
431
+
432
+ /**
433
+ * Extract the signer email from a certificate's raw bytes
434
+ * Uses simple regex matching on the DER-encoded certificate
435
+ */
436
+ function extractEmailFromCertificate(rawBytes: Buffer): string | undefined {
437
+ try {
438
+ const certStr = rawBytes.toString("latin1");
439
+ // Look for email pattern in the SAN extension
440
+ const emailMatch = certStr.match(/[\w.+-]+@[\w.-]+\.[a-zA-Z]{2,}/);
441
+ return emailMatch?.[0];
442
+ } catch {
443
+ return undefined;
444
+ }
445
+ }
446
+
447
+ /**
448
+ * Extract GitHub username from certificate extensions
449
+ * GitHub OAuth includes the username in the certificate
450
+ */
451
+ function extractGitHubUsernameFromCertificate(rawBytes: Buffer): string | undefined {
452
+ try {
453
+ const certStr = rawBytes.toString("latin1");
454
+ // GitHub username is stored in a custom extension
455
+ // Look for pattern like "login" followed by the username
456
+ // The OID 1.3.6.1.4.1.57264.1.8 contains GitHub username
457
+ // For now, try to find it via common patterns
458
+
459
+ // Try to find GitHub user ID pattern (numeric)
460
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: We need to match control chars in cert strings
461
+ const userIdMatch = certStr.match(/github\.com[^\x00-\x1f]*?(\d{5,10})/i);
462
+ if (userIdMatch) {
463
+ // We found a user ID but need the username
464
+ // This would require an API call, which we handle elsewhere
465
+ }
466
+
467
+ return undefined;
468
+ } catch {
469
+ return undefined;
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Extract the OIDC issuer from a certificate's raw bytes
475
+ * Looks for common issuer URLs in the certificate extensions
476
+ */
477
+ function extractIssuerFromCertificate(rawBytes: Buffer): string | undefined {
478
+ try {
479
+ const certStr = rawBytes.toString("latin1");
480
+ // Look for common OIDC issuer patterns
481
+ const issuerPatterns = [
482
+ /https:\/\/accounts\.google\.com/,
483
+ /https:\/\/github\.com\/login\/oauth/,
484
+ /https:\/\/token\.actions\.githubusercontent\.com/,
485
+ /https:\/\/gitlab\.com/,
486
+ /https:\/\/login\.microsoftonline\.com\/[\w-]+\/v2\.0/,
487
+ ];
488
+
489
+ for (const pattern of issuerPatterns) {
490
+ const match = certStr.match(pattern);
491
+ if (match) {
492
+ return match[0];
493
+ }
494
+ }
495
+ return undefined;
496
+ } catch {
497
+ return undefined;
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Extract certificate information from a Sigstore bundle
503
+ */
504
+ export function extractCertificateFromBundle(
505
+ bundle: SigstoreBundle
506
+ ): FulcioCertificate | undefined {
507
+ if (!bundle.verificationMaterial?.certificate?.rawBytes) {
508
+ return undefined;
509
+ }
510
+
511
+ const rawBytes = Buffer.from(bundle.verificationMaterial.certificate.rawBytes, "base64");
512
+
513
+ // Extract email and issuer from certificate
514
+ const email = extractEmailFromCertificate(rawBytes);
515
+ const issuer = extractIssuerFromCertificate(rawBytes);
516
+
517
+ // Try to extract GitHub username (if GitHub OAuth)
518
+ const username = extractGitHubUsernameFromCertificate(rawBytes);
519
+
520
+ // Parse certificate (simplified - in production would use a proper X.509 parser)
521
+ const pem = `-----BEGIN CERTIFICATE-----\n${rawBytes
522
+ .toString("base64")
523
+ .match(/.{1,64}/g)
524
+ ?.join("\n")}\n-----END CERTIFICATE-----`;
525
+
526
+ // Build identity object, only including email if present
527
+ const identity: OIDCIdentity = {
528
+ provider: issuer?.includes("google")
529
+ ? "google"
530
+ : issuer?.includes("github")
531
+ ? "github"
532
+ : issuer?.includes("gitlab")
533
+ ? "gitlab"
534
+ : issuer?.includes("microsoft")
535
+ ? "microsoft"
536
+ : "custom",
537
+ subject: email || "unknown",
538
+ issuer: issuer || "https://fulcio.sigstore.dev",
539
+ };
540
+ if (email) {
541
+ identity.email = email;
542
+ }
543
+ if (username) {
544
+ identity.username = username;
545
+ }
546
+
547
+ return {
548
+ certificateChain: [pem],
549
+ serialNumber: "unknown", // Would need X.509 parsing
550
+ notBefore: new Date(),
551
+ notAfter: new Date(Date.now() + 10 * 60 * 1000), // Fulcio certs are valid for 10 minutes
552
+ subject: email || "unknown",
553
+ issuer: "sigstore",
554
+ identity,
555
+ raw: rawBytes,
556
+ };
557
+ }
558
+
559
+ /**
560
+ * Extract identity from a signing certificate in a bundle
561
+ *
562
+ * @param bundle - The Sigstore bundle
563
+ * @returns The OIDC identity if it can be extracted
564
+ */
565
+ export function extractIdentityFromBundle(bundle: SigstoreBundle): OIDCIdentity | undefined {
566
+ // Parse the X.509 certificate and extract identity from SAN extension
567
+ const certificate = extractCertificateFromBundle(bundle);
568
+ return certificate?.identity;
569
+ }