diff --git a/jest.config.js b/jest.config.js index 8875c58..4a17ca6 100644 --- a/jest.config.js +++ b/jest.config.js @@ -17,6 +17,6 @@ module.exports = { "^.+\\.[tj]sx?$": ["ts-jest", { isolatedModules: true, tsconfig: { allowJs: true } }], }, transformIgnorePatterns: [ - "node_modules/(?!(@stellar/stellar-sdk|@noble|uint8array-extras|hpke|@starkware-libs/starknet-privacy-sdk)/)", + "node_modules/(?!(@stellar/stellar-sdk|@noble|@freedomofpress|uint8array-extras|hpke|@starkware-libs/starknet-privacy-sdk)/)", ], }; diff --git a/package-lock.json b/package-lock.json index 878551a..0035aee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@expo/config-plugins": "^57.0.0", + "@freedomofpress/sigstore-browser": "0.1.15", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.6.0", "@noble/hashes": "^1.5.0", @@ -37,6 +38,7 @@ "typescript": "^5.6.3" }, "peerDependencies": { + "@starkware-libs/starknet-privacy-sdk": "^0.14.3-rc.5", "expo-linking": ">=7", "expo-modules-core": ">=2", "expo-web-browser": ">=14", @@ -49,6 +51,9 @@ "@expo/config-plugins": { "optional": true }, + "@starkware-libs/starknet-privacy-sdk": { + "optional": true + }, "expo-linking": { "optional": true }, @@ -1255,6 +1260,65 @@ "integrity": "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==", "license": "MIT" }, + "node_modules/@freedomofpress/crypto-browser": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@freedomofpress/crypto-browser/-/crypto-browser-0.1.8.tgz", + "integrity": "sha512-jvD36HxllT2UCPYzcs4R2UxA8r6dI+4Y62Gm+eVAnC4S3yCrzy2E0Ag1O48FhmMmVo3ZjwchcU8vAJzUSKHzEQ==", + "license": "Apache-2.0", + "dependencies": { + "@noble/curves": "^1.6.0" + }, + "peerDependencies": { + "@noble/curves": "^1.6.0" + } + }, + "node_modules/@freedomofpress/sigstore-browser": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/@freedomofpress/sigstore-browser/-/sigstore-browser-0.1.15.tgz", + "integrity": "sha512-INccYE/yJyOc7SUYrQXP69y82w/2u51ZcG3x0RlfjD9qyEl8lUGBmWYAg0WPtv7r4NHf9lSxeDWOZ+Ibo0WaeQ==", + "license": "MIT", + "dependencies": { + "@freedomofpress/crypto-browser": "^0.1.8", + "@freedomofpress/tuf-browser": "^0.1.11", + "@noble/curves": "^2.0.1" + } + }, + "node_modules/@freedomofpress/sigstore-browser/node_modules/@noble/curves": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@freedomofpress/sigstore-browser/node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@freedomofpress/tuf-browser": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@freedomofpress/tuf-browser/-/tuf-browser-0.1.11.tgz", + "integrity": "sha512-d76ohB/AS5+zI+lnbiFMX/BIK3nT18hZ8q3Na6X4vyYaSYjXkeknzhAnbx1da+8ObWLwsaZLue46haEch28qtQ==", + "license": "MIT", + "dependencies": { + "@freedomofpress/crypto-browser": "^0.1.7" + } + }, "node_modules/@hpke/chacha20poly1305": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@hpke/chacha20poly1305/-/chacha20poly1305-1.8.0.tgz", diff --git a/package.json b/package.json index 3eca438..0ba6ad9 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "license": "MIT", "dependencies": { "@expo/config-plugins": "^57.0.0", + "@freedomofpress/sigstore-browser": "0.1.15", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.6.0", "@noble/hashes": "^1.5.0", diff --git a/src/auth/AuthProvider.ts b/src/auth/AuthProvider.ts index 103f729..348028a 100644 --- a/src/auth/AuthProvider.ts +++ b/src/auth/AuthProvider.ts @@ -13,12 +13,12 @@ export interface Identity { email?: string; /** * Display name from the OAuth id_token (standard OIDC `name` claim). - * Populated by Google sign-in; absent for email and Apple + * Populated by Google and GitHub sign-in; absent for email and Apple * (their tokens don't carry it). Optional on purpose — * consumers should fall back to `email` / `userId` when unset. */ name?: string; - provider?: "google" | "apple" | "email" | "otp" | string; + provider?: "google" | "apple" | "email" | "github" | "otp" | string; } /** diff --git a/src/auth/CavosAuth.ts b/src/auth/CavosAuth.ts index 74b787e..b9bc999 100644 --- a/src/auth/CavosAuth.ts +++ b/src/auth/CavosAuth.ts @@ -2,12 +2,13 @@ import type { AuthProvider, Identity } from "./AuthProvider"; import { createSocialRecoveryCredential, ENCLAVE_EMAIL_ISSUER, + ENCLAVE_GITHUB_ISSUER, isSocialRecoveryIssuer, type SocialRecoveryCredential, } from "../recovery/SocialRecoveryCredential"; import type { AttestationPolicy } from "../recovery/SocialRecoveryClient"; import { DEFAULT_SOCIAL_RECOVERY_ATTESTATION } from "../recovery/attestationDefaults"; -import { runEnclaveJob, verifiedChannelKey } from "../recovery/enclaveChannel"; +import { fetchEnclaveRelease, runEnclaveJob, verifiedChannelKey } from "../recovery/enclaveChannel"; import { secureRandomBytes } from "../crypto/encoding"; import { normalizeEmailCode } from "./emailCode"; import { @@ -39,13 +40,15 @@ export interface CavosAuthOptions { /** * Hosted login (Privy-like) backed by the Cavos backend — same endpoints * `@cavos/react` uses for Google / Apple OAuth, plus an email code the recovery - * enclave generates and verifies itself. Login only + * enclave generates and verifies itself, and GitHub, which has no id_token of + * its own: the enclave verifies the GitHub grant and signs one. Login only * resolves a stable `userId` (the OAuth `sub` claim); it never touches signing. * Feed the returned `Identity` to `Cavos.connect`. * * const auth = new CavosAuth({ appId }); * // social: open the returned URL; user returns, then: * const identity = await auth.handleCallback(window.location.search); + * // GitHub: the same, from getGitHubOAuthUrl * // or email OTP: * await auth.sendOtp(email); const identity = await auth.verifyOtp(email, code); * const cavos = await Cavos.connect({ network, appSalt, identity, paymasterApiKey }); @@ -65,8 +68,11 @@ export class CavosAuth implements AuthProvider { private readonly identityStorageKey: string; private readonly authTokenKey: string; private readonly loginSecretKey: string; + private readonly pkceKey: string; /** The login secret, for a callback this same instance handles. */ private loginSecret: string | null = null; + /** The PKCE verifier of the GitHub login in flight, for a callback this same instance handles. */ + private pkceVerifier: string | null = null; private last: Identity | null = null; /** Fresh OIDC proof retained only in memory for an immediate TEE recovery. * Never persisted to localStorage and never sent to the Cavos control plane @@ -93,6 +99,7 @@ export class CavosAuth implements AuthProvider { this.identityStorageKey = `cavos-kit:identity:${opts.appId ?? "default"}`; this.authTokenKey = `cavos-kit:token:${opts.appId ?? "default"}`; this.loginSecretKey = `cavos-kit:login-secret:${opts.appId ?? "default"}`; + this.pkceKey = `cavos-kit:pkce:${opts.appId ?? "default"}`; if (opts.persistSession === false && typeof window !== "undefined") { // An identity saved before the app turned persistence off would // otherwise keep this browser signed in for good. @@ -155,6 +162,32 @@ export class CavosAuth implements AuthProvider { return this.oauthUrl("apple", redirectUri); } + /** + * Redirect URL for GitHub login. + * + * GitHub issues no id_token, so the enclave verifies the sign-in and signs + * one itself. The URL is built by the backend, and a code issued without our + * PKCE challenge could be redeemed without this browser, so a URL that does + * not carry the challenge this login made is refused here. + */ + async getGitHubOAuthUrl(redirectUri?: string): Promise { + if (typeof window === "undefined") throw new Error("kit/auth: OAuth requires a browser"); + const appId = this.requireAppId(); + this.startLogin(); + const verifier = this.startPkce(); + const challenge = loginNonce(verifier); + const params = new URLSearchParams({ + code_challenge: challenge, + redirect_uri: redirectUri ?? window.location.href, + app_id: appId, + }); + const { url } = await this.get(`/api/oauth/v2/github?${params}`); + if (!isGitHubAuthorizeUrl(url, challenge)) { + throw new Error("kit/auth: the GitHub sign-in URL is not the one this login asked for"); + } + return url; + } + private async oauthUrl(provider: "google" | "apple", redirectUri?: string): Promise { if (typeof window === "undefined") throw new Error("kit/auth: OAuth requires a browser"); const params = new URLSearchParams({ @@ -181,6 +214,9 @@ export class CavosAuth implements AuthProvider { app_id: this.opts.appId, redirect_uri: callbackRedirectUri, }); + if (typeof result.github_grant === "string") { + return this.githubIdentity(result.github_grant); + } return this.identityFromAuthData(JSON.stringify(result), "oauth"); } const authData = extractAuthData(authDataOrSearch); @@ -192,7 +228,7 @@ export class CavosAuth implements AuthProvider { * recovery enclave, so the Cavos control plane never sees it. */ async sendOtp(email: string): Promise { - await this.emailPost("/api/oauth/email/code", { + await this.enclavePost("/api/oauth/email/code", { app_id: this.requireAppId(), email: normalizeEmail(email), }); @@ -204,37 +240,84 @@ export class CavosAuth implements AuthProvider { * created here, and that token is used exactly like a Google id_token. */ async verifyOtp(email: string, code: string): Promise { - const appId = this.requireAppId(); const address = normalizeEmail(email); const secret = loginSecretFrom(secureRandomBytes(32)); - const session = await this.emailPost("/api/oauth/email/session", { app_id: appId }); + const idToken = await this.enclaveLogin( + { + action: "verify_email", + email: address, + code: normalizeEmailCode(code), + client_nonce: loginNonce(secret), + }, + "/api/oauth/email/verify", + {}, + "email_verified", + "kit/auth: the enclave did not verify this email", + ); + if (parseJwt(idToken).iss !== ENCLAVE_EMAIL_ISSUER) { + throw new Error("kit/auth: the email token was not issued by the enclave"); + } + return this.identityFromAuthData(idToken, "email", address, secret); + } + + /** + * Redeem a GitHub grant inside the attested enclave. The grant is only + * useful with the PKCE verifier and login secret this browser kept, and the + * token the enclave signs is bound to that secret exactly like a Google one. + */ + private async githubIdentity(githubGrant: string): Promise { + const secret = this.takeLoginSecret(); + const verifier = this.takePkceVerifier(); + if (!secret || !verifier) { + throw new Error( + "kit/auth: this sign-in did not start here, or the browser could not keep its state. Sign in again.", + ); + } + const idToken = await this.enclaveLogin( + { action: "verify_github", code_verifier: verifier, client_nonce: loginNonce(secret) }, + "/api/oauth/github/verify", + { github_grant: githubGrant }, + "github_verified", + "kit/auth: the enclave did not verify this GitHub sign-in", + ); + if (parseJwt(idToken).iss !== ENCLAVE_GITHUB_ISSUER) { + throw new Error("kit/auth: the GitHub token was not issued by the enclave"); + } + return this.identityFromAuthData(idToken, "github", undefined, secret); + } + + /** + * Open an attested enclave session, run one encrypted login job through it + * and return the id_token the enclave signed. + */ + private async enclaveLogin( + job: Record, + verifyPath: string, + extraBody: Record, + expectedResult: string, + failure: string, + ): Promise { + const session = await this.enclavePost("/api/oauth/email/session", { app_id: this.requireAppId() }); const sessionId: unknown = session?.session_id; if (typeof sessionId !== "string") { - throw new Error("kit/auth: the email session carried no id"); + throw new Error("kit/auth: the enclave session carried no id"); } const channelKey = await verifiedChannelKey( sessionId, session.attestation_document_b64, this.opts.attestation ?? DEFAULT_SOCIAL_RECOVERY_ATTESTATION, + (pcr0Hex) => fetchEnclaveRelease(`${this.backendUrl}/api/enclave/releases/${pcr0Hex}`), ); const result = await runEnclaveJob<{ result?: string; id_token?: unknown }>( sessionId, channelKey, - { - action: "verify_email", - email: address, - code: normalizeEmailCode(code), - client_nonce: loginNonce(secret), - }, - (job) => this.emailPost("/api/oauth/email/verify", { session_id: sessionId, job }), + job, + (sealed) => this.enclavePost(verifyPath, { session_id: sessionId, job: sealed, ...extraBody }), ); - if (result.result !== "email_verified" || typeof result.id_token !== "string") { - throw new Error("kit/auth: the enclave did not verify this email"); + if (result.result !== expectedResult || typeof result.id_token !== "string") { + throw new Error(failure); } - if (parseJwt(result.id_token).iss !== ENCLAVE_EMAIL_ISSUER) { - throw new Error("kit/auth: the email token was not issued by the enclave"); - } - return this.identityFromAuthData(result.id_token, "email", address, secret); + return result.id_token; } /** AuthProvider: returns the identity resolved by the last login step. */ @@ -395,6 +478,30 @@ export class CavosAuth implements AuthProvider { return loginNonce(secret); } + /** Begin the PKCE half of a GitHub login: keep a fresh verifier and return it. */ + private startPkce(): string { + const verifier = loginSecretFrom(secureRandomBytes(32)); + try { + window.sessionStorage.setItem(this.pkceKey, verifier); + } catch { + this.pkceVerifier = verifier; + } + return verifier; + } + + /** The PKCE verifier of the login in flight, taken exactly once. */ + private takePkceVerifier(): string | null { + const kept = this.pkceVerifier; + this.pkceVerifier = null; + try { + const stored = window.sessionStorage.getItem(this.pkceKey); + window.sessionStorage.removeItem(this.pkceKey); + return stored ?? kept; + } catch { + return kept; + } + } + /** The secret of the login in flight, taken exactly once. */ private takeLoginSecret(): string | null { let secret = this.loginSecret; @@ -429,11 +536,11 @@ export class CavosAuth implements AuthProvider { } private requireAppId(): string { - if (!this.opts.appId) throw new Error("kit/auth: email sign-in needs an appId"); + if (!this.opts.appId) throw new Error("kit/auth: this sign-in needs an appId"); return this.opts.appId; } - private async emailPost(path: string, body: unknown): Promise { + private async enclavePost(path: string, body: unknown): Promise { const r = await fetch(`${this.backendUrl}${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -447,7 +554,7 @@ export class CavosAuth implements AuthProvider { } catch { code = undefined; } - const known = typeof code === "string" ? EMAIL_ERRORS[code] : undefined; + const known = typeof code === "string" ? ENCLAVE_LOGIN_ERRORS[code] : undefined; throw new Error(known ?? `kit/auth: ${path} -> ${r.status} ${text}`); } @@ -462,17 +569,36 @@ export class CavosAuth implements AuthProvider { } } -const EMAIL_ERRORS: Record = { +const ENCLAVE_LOGIN_ERRORS: Record = { invalid_request: "Enter a valid email address.", app_not_found: "kit/auth: this app is not registered with Cavos.", email_undeliverable: "This email provider is not supported yet.", code_cooldown: "A code was just sent to this address. Wait a minute before requesting another.", rate_limited: "Too many attempts. Try again in a few minutes.", email_unavailable: "Email sign-in is unavailable right now. Try again later.", - enclave_unavailable: "Email sign-in is unavailable right now. Try again later.", + enclave_unavailable: "Sign-in is unavailable right now. Try again later.", code_rejected: "That code is wrong or has expired. Request a new one.", + github_rejected: "GitHub did not accept this sign-in. Try again.", + invalid_grant: "This GitHub sign-in expired. Sign in again.", }; +/** Whether `url` is GitHub's authorize page, asked for with exactly this PKCE challenge. */ +function isGitHubAuthorizeUrl(url: unknown, challenge: string): boolean { + if (typeof url !== "string") return false; + try { + const parsed = new URL(url); + return ( + parsed.protocol === "https:" && + parsed.host === "github.com" && + parsed.pathname === "/login/oauth/authorize" && + parsed.searchParams.get("code_challenge") === challenge && + parsed.searchParams.get("code_challenge_method") === "S256" + ); + } catch { + return false; + } +} + function normalizeEmail(email: string): string { return email.trim().toLowerCase(); } @@ -524,6 +650,8 @@ function providerFromClaims(claims: any, fallback: string): string { return "apple"; case ENCLAVE_EMAIL_ISSUER: return "email"; + case ENCLAVE_GITHUB_ISSUER: + return "github"; default: return claims?.provider ?? fallback; } diff --git a/src/auth/githubLogin.test.ts b/src/auth/githubLogin.test.ts new file mode 100644 index 0000000..bf13342 --- /dev/null +++ b/src/auth/githubLogin.test.ts @@ -0,0 +1,243 @@ +import { CavosAuth } from "./CavosAuth"; +import { loginNonce, requiresLoginBinding } from "./loginBinding"; +import { ENCLAVE_GITHUB_ISSUER } from "../recovery/SocialRecoveryCredential"; +import { verifyNitroAttestation } from "../recovery/nitro/attestation"; + +jest.mock("../recovery/nitro/attestation", () => ({ + verifyNitroAttestation: jest.fn(), +})); + +const APP_ID = "0f9d5a1c-3b2e-4c5a-8d7f-1a2b3c4d5e6f"; +const SESSION_ID = "7ee71f25-9f59-43f6-8091-317a94074209"; +const CALLBACK_CODE = "c".repeat(43); +const REDIRECT = "https://app.test/callback"; + +const b64 = (bytes: Uint8Array) => Buffer.from(bytes).toString("base64url"); +const unb64 = (value: string) => new Uint8Array(Buffer.from(value, "base64url")); +const text = (value: string) => new TextEncoder().encode(value); + +function unsignedJwt(claims: Record): string { + const header = Buffer.from('{"alg":"ES256","typ":"JWT"}').toString("base64url"); + return `${header}.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.sig`; +} + +interface FakeGitHub { + fetch: jest.Mock; + authorizeUrl: () => string; + sentJob: () => Record; + verifyBody: () => Record; +} + +interface FakeOptions { + authorizeUrl?: (challenge: string | null) => string; + issuer?: string; + exchange?: Record; +} + +async function startGitHub(options: FakeOptions = {}): Promise { + const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]); + const publicRaw = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey)); + const binding = new Uint8Array(await crypto.subtle.digest("SHA-256", text(SESSION_ID))); + (verifyNitroAttestation as jest.Mock).mockResolvedValue({ publicKey: publicRaw, userData: binding }); + + let job: Record = {}; + let verifyBody: Record = {}; + let authorize = ""; + const derive = async (clientPublicRaw: Uint8Array) => { + const clientPublic = await crypto.subtle.importKey( + "raw", clientPublicRaw as unknown as BufferSource, { name: "ECDH", namedCurve: "P-256" }, false, [], + ); + const shared = await crypto.subtle.deriveBits({ name: "ECDH", public: clientPublic }, pair.privateKey, 256); + const hkdf = await crypto.subtle.importKey("raw", shared, "HKDF", false, ["deriveKey"]); + const key = (info: string) => + crypto.subtle.deriveKey( + { name: "HKDF", hash: "SHA-256", salt: text(SESSION_ID), info: text(info) }, + hkdf, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"], + ); + return { + request: await key("cavos-confidential-channel-v1"), + response: await key("cavos-confidential-channel-v1/response"), + }; + }; + + const fetchMock = jest.fn(async (url: string, init?: { body: string }) => { + if (url.includes("/api/oauth/v2/github")) { + const challenge = new URL(url).searchParams.get("code_challenge"); + authorize = options.authorizeUrl + ? options.authorizeUrl(challenge) + : `https://github.com/login/oauth/authorize?client_id=x&code_challenge=${challenge}&code_challenge_method=S256`; + return { ok: true, json: async () => ({ url: authorize }) }; + } + if (url.endsWith("/api/oauth/callback/exchange")) { + return { ok: true, json: async () => options.exchange ?? { github_grant: "grant-1" } }; + } + if (url.endsWith("/api/oauth/email/session")) { + return { + ok: true, + json: async () => ({ + session_id: SESSION_ID, + ephemeral_public_key_b64: b64(publicRaw), + attestation_document_b64: "AAAA", + }), + }; + } + const body = JSON.parse(init!.body); + verifyBody = body; + const keys = await derive(unb64(body.job.client_public_key_b64)); + const opened = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: unb64(body.job.nonce_b64) as unknown as BufferSource, additionalData: text(SESSION_ID) }, + keys.request, + unb64(body.job.ciphertext_b64) as unknown as BufferSource, + ); + job = JSON.parse(new TextDecoder().decode(opened)); + const token = unsignedJwt({ + iss: options.issuer ?? ENCLAVE_GITHUB_ISSUER, + aud: "cavos-github", + sub: "b".repeat(64), + name: "Ada Lovelace", + preferred_username: "ada", + nonce: `${APP_ID}.${job.client_nonce as string}`, + }); + const nonce = crypto.getRandomValues(new Uint8Array(12)); + const sealed = await crypto.subtle.encrypt( + { name: "AES-GCM", iv: nonce as unknown as BufferSource, additionalData: text(SESSION_ID) }, + keys.response, + text(JSON.stringify({ result: "github_verified", id_token: token })), + ); + return { + ok: true, + json: async () => ({ sealed_result: { nonce_b64: b64(nonce), ciphertext_b64: b64(new Uint8Array(sealed)) } }), + }; + }); + return { + fetch: fetchMock, + authorizeUrl: () => authorize, + sentJob: () => job, + verifyBody: () => verifyBody, + }; +} + +describe("GitHub login", () => { + const realFetch = globalThis.fetch; + const scope = globalThis as { window?: unknown }; + const hadWindow = "window" in scope; + + beforeEach(() => { + scope.window = {}; + }); + + afterEach(() => { + globalThis.fetch = realFetch; + if (!hadWindow) delete scope.window; + jest.clearAllMocks(); + }); + + function authWith(fake: FakeGitHub) { + globalThis.fetch = fake.fetch as unknown as typeof fetch; + return new CavosAuth({ backendUrl: "https://cavos.test", appId: APP_ID }); + } + + it("sends a PKCE challenge that is S256 of the verifier it keeps", async () => { + const fake = await startGitHub(); + const auth = authWith(fake); + const url = await auth.getGitHubOAuthUrl(REDIRECT); + + const requested = new URL(fake.fetch.mock.calls[0][0] as string); + const challenge = requested.searchParams.get("code_challenge")!; + expect(challenge).toHaveLength(43); + expect(requested.searchParams.get("redirect_uri")).toBe(REDIRECT); + expect(requested.searchParams.get("app_id")).toBe(APP_ID); + expect(requested.searchParams.has("nonce")).toBe(false); + expect(url).toBe(fake.authorizeUrl()); + + await auth.handleCallback(CALLBACK_CODE, REDIRECT); + expect(loginNonce(fake.sentJob().code_verifier as string)).toBe(challenge); + }); + + it("needs an appId", async () => { + const auth = new CavosAuth({ backendUrl: "https://cavos.test" }); + await expect(auth.getGitHubOAuthUrl(REDIRECT)).rejects.toThrow(/needs an appId/); + }); + + it.each([ + ["has no challenge", () => "https://github.com/login/oauth/authorize?client_id=x"], + [ + "has another challenge", + () => `https://github.com/login/oauth/authorize?code_challenge=${"z".repeat(43)}&code_challenge_method=S256`, + ], + [ + "has the wrong method", + (c: string | null) => `https://github.com/login/oauth/authorize?code_challenge=${c}&code_challenge_method=plain`, + ], + [ + "is on another host", + (c: string | null) => `https://github.com.evil.test/login/oauth/authorize?code_challenge=${c}&code_challenge_method=S256`, + ], + [ + "is on another path", + (c: string | null) => `https://github.com/login/device?code_challenge=${c}&code_challenge_method=S256`, + ], + [ + "is not https", + (c: string | null) => `http://github.com/login/oauth/authorize?code_challenge=${c}&code_challenge_method=S256`, + ], + ])("refuses a URL that %s", async (_name, authorizeUrl) => { + const auth = authWith(await startGitHub({ authorizeUrl })); + await expect(auth.getGitHubOAuthUrl(REDIRECT)).rejects.toThrow(/not the one this login asked for/); + }); + + it("verifies the grant through the attested channel and keeps the login secret for recovery", async () => { + const fake = await startGitHub(); + const auth = authWith(fake); + await auth.getGitHubOAuthUrl(REDIRECT); + const identity = await auth.handleCallback(`?cavos_auth_code=${CALLBACK_CODE}`, REDIRECT); + + expect(identity).toMatchObject({ provider: "github", name: "Ada Lovelace", userId: "b".repeat(64) }); + expect(fake.sentJob()).toMatchObject({ action: "verify_github" }); + expect(fake.verifyBody()).toMatchObject({ session_id: SESSION_ID, github_grant: "grant-1" }); + const credential = auth.getSocialRecoveryCredential(); + expect(credential.provider).toBe("github"); + expect(loginNonce(credential.noncePreimage!)).toBe(fake.sentJob().client_nonce); + expect(auth.getAuthToken()).toBe(credential.idToken); + }); + + it("refuses a callback this browser did not start", async () => { + const auth = authWith(await startGitHub()); + await expect(auth.handleCallback(CALLBACK_CODE, REDIRECT)).rejects.toThrow(/did not start here/); + expect(auth.hasSocialRecoveryCredential()).toBe(false); + }); + + it("spends the verifier and secret on the first callback", async () => { + const fake = await startGitHub(); + const auth = authWith(fake); + await auth.getGitHubOAuthUrl(REDIRECT); + await auth.handleCallback(CALLBACK_CODE, REDIRECT); + await expect(auth.handleCallback(CALLBACK_CODE, REDIRECT)).rejects.toThrow(/did not start here/); + }); + + it("refuses a token the enclave did not issue for GitHub", async () => { + const fake = await startGitHub({ issuer: "https://enclave.cavos.xyz" }); + const auth = authWith(fake); + await auth.getGitHubOAuthUrl(REDIRECT); + await expect(auth.handleCallback(CALLBACK_CODE, REDIRECT)).rejects.toThrow(/not issued by the enclave/); + expect(auth.hasSocialRecoveryCredential()).toBe(false); + }); + + it("explains a rejected sign-in", async () => { + const fake = await startGitHub(); + const auth = authWith(fake); + await auth.getGitHubOAuthUrl(REDIRECT); + const inner = fake.fetch.getMockImplementation()!; + fake.fetch.mockImplementation(async (url: string, init?: { body: string }) => + url.endsWith("/api/oauth/github/verify") + ? { ok: false, status: 422, text: async () => JSON.stringify({ error: "invalid_grant" }) } + : inner(url, init), + ); + await expect(auth.handleCallback(CALLBACK_CODE, REDIRECT)).rejects.toThrow(/expired. Sign in again/); + }); + + it("binds the enclave's GitHub token to this login", () => { + expect(requiresLoginBinding(ENCLAVE_GITHUB_ISSUER)).toBe(true); + expect(requiresLoginBinding(`${ENCLAVE_GITHUB_ISSUER}/`)).toBe(false); + }); +}); diff --git a/src/auth/loginBinding.ts b/src/auth/loginBinding.ts index 14c77c1..85e0345 100644 --- a/src/auth/loginBinding.ts +++ b/src/auth/loginBinding.ts @@ -1,11 +1,11 @@ import { sha256 } from "@noble/hashes/sha256"; import { bytesToBase64Url, utf8ToBytes } from "../crypto/encoding"; -import { ENCLAVE_EMAIL_ISSUER } from "../recovery/SocialRecoveryCredential"; +import { ENCLAVE_EMAIL_ISSUER, ENCLAVE_GITHUB_ISSUER } from "../recovery/SocialRecoveryCredential"; /** * Binding a provider token to the login that asked for it. * - * Google, Apple and the enclave's email token sign one value the client + * Google, Apple and the enclave's email and GitHub tokens sign one value the client * chooses: the `nonce`. The kit * sends the hash of a secret it keeps; the Cavos backend prefixes the app, so * the token carries `.`. The recovery @@ -27,13 +27,14 @@ export function loginNonce(secret: string): string { return bytesToBase64Url(sha256(utf8ToBytes(secret))); } -/** Google, Apple and enclave email tokens all carry a nonce this login chose. */ +/** Google, Apple and enclave email and GitHub tokens all carry a nonce this login chose. */ export function requiresLoginBinding(issuer: unknown): boolean { return ( issuer === "https://accounts.google.com" || issuer === "accounts.google.com" || issuer === "https://appleid.apple.com" || - issuer === ENCLAVE_EMAIL_ISSUER + issuer === ENCLAVE_EMAIL_ISSUER || + issuer === ENCLAVE_GITHUB_ISSUER ); } diff --git a/src/auth/providerFromClaims.test.ts b/src/auth/providerFromClaims.test.ts index abc6d12..8aa33ee 100644 --- a/src/auth/providerFromClaims.test.ts +++ b/src/auth/providerFromClaims.test.ts @@ -38,6 +38,11 @@ describe("provider from the credential", () => { expect(id.provider).toBe("email"); }); + it("names the enclave's GitHub token github", async () => { + const id = await identityFrom({ iss: "https://enclave.cavos.xyz/github", sub: "u1", name: "Ada" }); + expect(id.provider).toBe("github"); + }); + it("falls back rather than inventing a provider it cannot know", async () => { const id = await identityFrom({ iss: "https://example.test", sub: "u1" }); expect(id.provider).toBe("oauth"); diff --git a/src/index.ts b/src/index.ts index 0ce043d..1441a4c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,6 +45,7 @@ export type { SocialRecoveryResult, } from "./recovery/SocialRecoveryClient"; export { DEFAULT_SOCIAL_RECOVERY_ATTESTATION } from "./recovery/attestationDefaults"; +export type { EnclaveReleasePolicy } from "./recovery/nitro/release"; // Signers export type { DevicePublicKey } from "./signer/DeviceSigner"; diff --git a/src/react/CavosAuthModal.tsx b/src/react/CavosAuthModal.tsx index 15b8218..8df0b48 100644 --- a/src/react/CavosAuthModal.tsx +++ b/src/react/CavosAuthModal.tsx @@ -24,7 +24,7 @@ export interface CavosAuthModalProps { /** Logo height in px. Applies to both a custom `appLogo` (img) and the * default Cavos star. Defaults to 40 (img) / 34 (star). */ appLogoSize?: number; - providers?: ('google' | 'apple' | 'email')[]; + providers?: ('google' | 'apple' | 'email' | 'github')[]; primaryColor?: string; /** 'light' (default) or 'dark' */ theme?: 'light' | 'dark'; @@ -274,6 +274,12 @@ const AppleIcon = ({ size = 16 }: { size?: number } = {}) => ( ); +const GitHubIcon = ({ size = 16 }: { size?: number } = {}) => ( + + + +); + const EmailIcon = () => ( @@ -888,7 +894,7 @@ export function CavosAuthModal({ } }; - const handleOAuth = async (provider: 'google' | 'apple') => { + const handleOAuth = async (provider: 'google' | 'apple' | 'github') => { setError(''); setBusy(true); try { await login(provider); @@ -1159,6 +1165,8 @@ export function CavosAuthModal({ return { name: 'Google', icon: }; case 'apple': return { name: 'Apple', icon: }; + case 'github': + return { name: 'GitHub', icon: }; case 'email': return { name: 'email', icon: null }; default: @@ -1368,7 +1376,8 @@ export function CavosAuthModal({ const showEmail = providers.includes('email'); const showGoogle = providers.includes('google'); const showApple = providers.includes('apple'); - const hasSocial = showGoogle || showApple; + const showGitHub = providers.includes('github'); + const hasSocial = showGoogle || showApple || showGitHub; // Absolute-positioned icon so the label stays optically centered (matches the // Privy/Clerk full-width button look) instead of shifting when text changes. // No background box — the brand logos render clean, like native sign-in buttons. @@ -1423,6 +1432,14 @@ export function CavosAuthModal({ )} + {showGitHub && ( + Continue with GitHub + {recentTag('github')} + + )} + {showEmail && hasSocial && (
diff --git a/src/react/CavosProvider.tsx b/src/react/CavosProvider.tsx index bcc7ab4..d473937 100644 --- a/src/react/CavosProvider.tsx +++ b/src/react/CavosProvider.tsx @@ -147,7 +147,7 @@ export interface CavosModalConfig { /** Logo height in px. Applies to both a custom `appLogo` (img) and the * default Cavos star. Defaults to 40 (img) / 34 (star). */ appLogoSize?: number; - providers?: ('google' | 'apple' | 'email')[]; + providers?: ('google' | 'apple' | 'email' | 'github')[]; primaryColor?: string; /** 'light' (default) or 'dark'. */ theme?: 'light' | 'dark'; @@ -245,7 +245,7 @@ export interface CavosContextValue { /** Clear `authError` (e.g. when the user starts a new login attempt). */ clearAuthError: () => void; /** OAuth social login — opens the provider's hosted flow. */ - login: (provider: 'google' | 'apple') => Promise; + login: (provider: 'google' | 'apple' | 'github') => Promise; /** Email a 9-character sign-in code generated by the recovery enclave. */ sendOtp: (email: string) => Promise; /** Verify the emailed code inside the enclave and deploy the wallet. */ @@ -564,7 +564,7 @@ export function CavosProvider({ .then((data) => { setSocialRecovery({ enabled: data.enabled === true, - provider: ['google', 'apple', 'email'].includes(data.provider) + provider: ['google', 'apple', 'email', 'github'].includes(data.provider) ? data.provider : null, }); @@ -987,7 +987,7 @@ export function CavosProvider({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isExternalAuth, externalUserId, connect]); - const login = useCallback(async (provider: 'google' | 'apple') => { + const login = useCallback(async (provider: 'google' | 'apple' | 'github') => { if (isExternalAuth) { throw new Error( 'kit/react: this provider is driven by your own auth (the `identity` prop), so Cavos login is disabled. ' + @@ -996,9 +996,12 @@ export function CavosProvider({ } if (typeof window === 'undefined') throw new Error('OAuth requires a browser'); setAuthError(null); - const url = await (provider === 'google' - ? auth.getGoogleOAuthUrl(window.location.origin + window.location.pathname) - : auth.getAppleOAuthUrl(window.location.origin + window.location.pathname)); + const redirectUri = window.location.origin + window.location.pathname; + const url = await { + google: () => auth.getGoogleOAuthUrl(redirectUri), + apple: () => auth.getAppleOAuthUrl(redirectUri), + github: () => auth.getGitHubOAuthUrl(redirectUri), + }[provider](); window.location.href = url; }, [auth]); diff --git a/src/recovery/SocialRecoveryClient.ts b/src/recovery/SocialRecoveryClient.ts index fd6f6d2..7252aa9 100644 --- a/src/recovery/SocialRecoveryClient.ts +++ b/src/recovery/SocialRecoveryClient.ts @@ -1,9 +1,9 @@ import { bytesToBase64Url } from "../crypto/encoding"; import type { SocialRecoveryCredential } from "./SocialRecoveryCredential"; import type { NitroAttestationPolicy } from "./nitro/attestation"; -import { runEnclaveJob, verifiedChannelKey } from "./enclaveChannel"; +import { fetchEnclaveRelease, runEnclaveJob, verifiedChannelKey } from "./enclaveChannel"; -export type SocialRecoveryProvider = "google" | "apple" | "email"; +export type SocialRecoveryProvider = "google" | "apple" | "email" | "github"; export type SocialRecoveryAction = "enroll" | "recover"; /** @@ -86,6 +86,9 @@ interface StartedSession { export class SocialRecoveryClient { constructor(private readonly opts: SocialRecoveryClientOptions) {} + private readonly fetchRelease = (pcr0Hex: string): Promise => + fetchEnclaveRelease(new URL(`/api/enclave/releases/${pcr0Hex}`, this.opts.baseUrl)); + async lookupDekEnrollment(params: { provider: SocialRecoveryCredential["provider"]; subject: string; @@ -131,6 +134,7 @@ export class SocialRecoveryClient { session.session_id, session.attestation_document_b64, this.opts.attestation, + this.fetchRelease, ); if (session.sealed_record_b64) { // The wallet already has a record. What it holds is the enclave's to say, @@ -173,6 +177,7 @@ export class SocialRecoveryClient { session.session_id, session.attestation_document_b64, this.opts.attestation, + this.fetchRelease, ); const result = await this.runJob(session.session_id, channelKey, { action: "recover", diff --git a/src/recovery/SocialRecoveryCredential.test.ts b/src/recovery/SocialRecoveryCredential.test.ts index df775b0..d913799 100644 --- a/src/recovery/SocialRecoveryCredential.test.ts +++ b/src/recovery/SocialRecoveryCredential.test.ts @@ -33,6 +33,13 @@ describe("socialRecoveryProvider", () => { expect(socialRecoveryProvider(token({ iss: "accounts.google.com" }))).toBe("google"); expect(socialRecoveryProvider(token({ iss: "https://appleid.apple.com" }))).toBe("apple"); expect(socialRecoveryProvider(token({ iss: "https://enclave.cavos.xyz" }))).toBe("email"); + expect(socialRecoveryProvider(token({ iss: "https://enclave.cavos.xyz/github" }))).toBe("github"); + }); + + it("accepts the enclave's GitHub issuer and nothing near it", () => { + expect(isSocialRecoveryIssuer("https://enclave.cavos.xyz/github")).toBe(true); + expect(isSocialRecoveryIssuer("https://enclave.cavos.xyz/github/")).toBe(false); + expect(isSocialRecoveryIssuer("https://enclave.cavos.xyz/githubx")).toBe(false); }); it("accepts the enclave's issuer and refuses Firebase's", () => { diff --git a/src/recovery/SocialRecoveryCredential.ts b/src/recovery/SocialRecoveryCredential.ts index 19391c4..ea99d9f 100644 --- a/src/recovery/SocialRecoveryCredential.ts +++ b/src/recovery/SocialRecoveryCredential.ts @@ -7,11 +7,14 @@ import { utf8ToBytes } from "../crypto/encoding"; * sent to the control plane to reserve a single recovery session; the token * itself is sent only through the attested encrypted channel. */ -export type SocialRecoveryProvider = "google" | "apple" | "email"; +export type SocialRecoveryProvider = "google" | "apple" | "email" | "github"; /** The `iss` of the tokens the recovery enclave itself issues for email login. */ export const ENCLAVE_EMAIL_ISSUER = "https://enclave.cavos.xyz"; +/** The `iss` of the tokens the recovery enclave issues after verifying a GitHub sign-in. */ +export const ENCLAVE_GITHUB_ISSUER = "https://enclave.cavos.xyz/github"; + export interface SocialRecoveryCredential { idToken: string; tokenFingerprint: string; @@ -61,6 +64,7 @@ export function socialRecoveryProvider( } if (issuer === "https://appleid.apple.com") return "apple"; if (issuer === ENCLAVE_EMAIL_ISSUER) return "email"; + if (issuer === ENCLAVE_GITHUB_ISSUER) return "github"; return "google"; } @@ -100,7 +104,8 @@ export function isSocialRecoveryIssuer(issuer: unknown): issuer is string { issuer === "https://accounts.google.com" || issuer === "accounts.google.com" || issuer === "https://appleid.apple.com" || - issuer === ENCLAVE_EMAIL_ISSUER + issuer === ENCLAVE_EMAIL_ISSUER || + issuer === ENCLAVE_GITHUB_ISSUER ); } diff --git a/src/recovery/attestationDefaults.ts b/src/recovery/attestationDefaults.ts index 70cfcc6..6a79b43 100644 --- a/src/recovery/attestationDefaults.ts +++ b/src/recovery/attestationDefaults.ts @@ -1,27 +1,40 @@ import type { AttestationPolicy } from "./SocialRecoveryClient"; /** - * The Nitro enclave image Cavos runs for hardware-isolated recovery. + * The Nitro enclave Cavos runs for hardware-isolated recovery and sign-in. * * These values answer one question: *which enclave am I willing to talk to?* * They ship here, in a versioned package you install and can audit, rather than - * arriving from the recovery API — an API that told the client which - * measurement to trust could simply name its own, and the attestation would - * prove nothing. The attestation itself is signed by AWS, so authenticity is - * already anchored outside Cavos; this policy only fixes the workload's - * identity. + * arriving from the recovery API — an API that told the client which enclave + * to trust could simply name its own, and the attestation would prove nothing. + * The attestation itself is signed by AWS, so authenticity is already anchored + * outside Cavos; this policy only fixes the workload's identity. * - * `pcr0` is the SHA-384 measurement of the enclave image file, as reported by - * `nitro-cli build-enclave`. It accepts a list so a rollout can overlap: - * publish the new measurement alongside the one still deployed, ship the - * release, then redeploy. Apps on either version keep working through the - * transition. Order is newest first. + * An enclave is accepted on either of two paths: * - * Override via `socialRecoveryAttestation` when you run your own enclave. + * - `release`: its PCR0, PCR1 and PCR2 are vouched for by a signed release of + * the public `cavos-labs/cavos-recovery` repository — a Sigstore attestation + * made by that repository's release workflow on a `v` tag, logged in + * Rekor. The bundle is served by the Cavos API but verified here, so a new + * enclave release needs no new SDK. `minVersion` revokes older releases. + * - `pcr0`: the SHA-384 measurement of the image, as reported by + * `nitro-cli build-enclave`, is listed. This list is kept only for the + * currently deployed images, which predate signed releases; it shrinks to + * nothing once those are replaced. + * + * Override via `socialRecoveryAttestation` when you run your own enclave, or + * pass a `pcr0` list alone to pin exactly the images you audited. */ export const DEFAULT_SOCIAL_RECOVERY_ATTESTATION: AttestationPolicy = { pcr0: [ + "7f32a8b51fabf733339db5425d31851ad2919322581fe21793db17975c44a33702fa9be0c81a391b32049a0daf220cdb", "f388b0bf627bdad34968ebab083688b999da2aaa369a65323a3085725de0fc79d7cf0fab6527d57e06b4b10950a150f9", - "5ec163560006f105b5448c2db77ecf5ec442e00abd475f65349d6d93e837279652708770c50b3aa2e8676f553f572825", ], + release: { + repository: "cavos-labs/cavos-recovery", + repositoryId: "1333734222", + ownerId: "216094156", + workflow: ".github/workflows/release.yml", + minVersion: 1, + }, }; diff --git a/src/recovery/attestationPolicy.test.ts b/src/recovery/attestationPolicy.test.ts index 95aaf9f..7f1799c 100644 --- a/src/recovery/attestationPolicy.test.ts +++ b/src/recovery/attestationPolicy.test.ts @@ -40,10 +40,20 @@ describe("resolveSocialRecoveryPolicy", () => { describe("shipped attestation defaults", () => { const measurements = () => { - const { pcr0 } = DEFAULT_SOCIAL_RECOVERY_ATTESTATION; + const { pcr0 = [] } = DEFAULT_SOCIAL_RECOVERY_ATTESTATION; return Array.isArray(pcr0) ? pcr0 : [pcr0]; }; + it("accepts signed releases of the public cavos-recovery repository", () => { + expect(DEFAULT_SOCIAL_RECOVERY_ATTESTATION.release).toEqual({ + repository: "cavos-labs/cavos-recovery", + repositoryId: "1333734222", + ownerId: "216094156", + workflow: ".github/workflows/release.yml", + minVersion: 1, + }); + }); + // This is deliberately allowed to be empty right now: the enclave image has // not been published, and an empty list fails closed, so social recovery is // simply unavailable rather than accepting an unverified enclave. The shape diff --git a/src/recovery/enclaveChannel.test.ts b/src/recovery/enclaveChannel.test.ts index ff2c64c..c904acc 100644 --- a/src/recovery/enclaveChannel.test.ts +++ b/src/recovery/enclaveChannel.test.ts @@ -1,4 +1,4 @@ -import { encryptForEnclave } from "./enclaveChannel"; +import { encryptForEnclave, fetchEnclaveRelease } from "./enclaveChannel"; /** * The enclave's side of the channel, in WebCrypto, to the same construction @@ -82,3 +82,29 @@ describe("the channel to the enclave", () => { ).rejects.toThrow("failed authentication"); }); }); + +describe("fetchEnclaveRelease", () => { + const realFetch = global.fetch; + afterEach(() => { + global.fetch = realFetch; + }); + + it("returns the bundle the API serves", async () => { + const bundle = { mediaType: "application/vnd.dev.sigstore.bundle.v0.3+json" }; + global.fetch = jest.fn().mockResolvedValue(new Response(JSON.stringify(bundle))) as any; + await expect(fetchEnclaveRelease("https://cavos.xyz/api/enclave/releases/ab")).resolves.toEqual(bundle); + expect(global.fetch).toHaveBeenCalledWith("https://cavos.xyz/api/enclave/releases/ab", expect.anything()); + }); + + it("fails clearly when no release is published", async () => { + global.fetch = jest.fn().mockResolvedValue(new Response("", { status: 404 })) as any; + await expect(fetchEnclaveRelease("https://cavos.xyz/x")).rejects.toThrow(/no published release/); + }); + + it("fails on other errors and on a non-JSON body", async () => { + global.fetch = jest.fn().mockResolvedValue(new Response("", { status: 500 })) as any; + await expect(fetchEnclaveRelease("https://cavos.xyz/x")).rejects.toThrow(/lookup failed \(500\)/); + global.fetch = jest.fn().mockResolvedValue(new Response("")) as any; + await expect(fetchEnclaveRelease("https://cavos.xyz/x")).rejects.toThrow(/not JSON/); + }); +}); diff --git a/src/recovery/enclaveChannel.ts b/src/recovery/enclaveChannel.ts index 55b16f0..a58b3a5 100644 --- a/src/recovery/enclaveChannel.ts +++ b/src/recovery/enclaveChannel.ts @@ -21,11 +21,16 @@ export async function verifiedChannelKey( sessionId: string, attestationDocumentB64: string | undefined, policy: NitroAttestationPolicy, + fetchRelease?: (pcr0Hex: string) => Promise, ): Promise { if (!attestationDocumentB64) { throw new Error("kit/social-recovery: the session carried no attestation"); } - const attestation = await verifyNitroAttestation(base64UrlToBytes(attestationDocumentB64), policy); + const attestation = await verifyNitroAttestation( + base64UrlToBytes(attestationDocumentB64), + policy, + { fetchRelease }, + ); if (!attestation.publicKey) { throw new Error("kit/social-recovery: the attestation carried no channel key"); @@ -39,6 +44,27 @@ export async function verifiedChannelKey( return attestation.publicKey; } +/** + * Fetch the signed release bundle the Cavos API serves for an enclave image. + * + * The host is not trusted: it can only withhold a release, because the bundle + * is verified against Sigstore before anything in it is believed. + */ +export async function fetchEnclaveRelease(url: string | URL): Promise { + const response = await fetch(url, { headers: { Accept: "application/json" } }); + if (response.status === 404) { + throw new Error("kit/social-recovery: this enclave image has no published release"); + } + if (!response.ok) { + throw new Error(`kit/social-recovery: the enclave release lookup failed (${response.status})`); + } + try { + return await response.json(); + } catch { + throw new Error("kit/social-recovery: the enclave release is not JSON"); + } +} + /** * Encrypt a job to the attested key, hand it to `send`, and return the * enclave's answer opened from its sealed copy. diff --git a/src/recovery/nitro/attestation.ts b/src/recovery/nitro/attestation.ts index 277bd56..60f837f 100644 --- a/src/recovery/nitro/attestation.ts +++ b/src/recovery/nitro/attestation.ts @@ -18,6 +18,7 @@ import { decodeCbor, encodeSigStructure, type CborValue } from "./cbor"; import { parseCertificate, type ParsedCertificate } from "./der"; +import { verifyEnclaveRelease, type EnclaveReleasePolicy } from "./release"; /** * The AWS Nitro Attestation PKI root, DER-encoded. @@ -57,16 +58,22 @@ const P384_COORDINATE_BYTES = 48; const MAX_DOCUMENT_AGE_MS = 5 * 60_000; /** - * Which enclave the SDK is willing to talk to. + * Which enclave the SDK is willing to talk to. At least one of the two paths + * must be set; a policy with neither accepts nothing. * - * `pcr0` accepts a list so an image rollout can overlap: publish the new - * measurement alongside the one still deployed, ship the release, then - * redeploy. Apps on either version keep working through the transition. - * Order is newest first. + * `pcr0` is strict pinning, for integrators who audit one image and want + * nothing else: an enclave is accepted if its PCR0 is listed. It accepts a + * list so an image rollout can overlap. Order is newest first. + * + * `release` accepts any enclave image properly published as a signed release + * (see `release.ts`), so a redeploy needs no new SDK. It is consulted only + * when the PCR0 is not pinned. */ export interface NitroAttestationPolicy { /** Accepted PCR0 values (hex, no `0x`) — the measurement of the enclave image. */ - pcr0: string | string[]; + pcr0?: string | string[]; + /** The release workflow whose signed images are accepted. */ + release?: EnclaveReleasePolicy; } export interface NitroAttestationDocument { @@ -88,13 +95,20 @@ export interface NitroAttestationDocument { * * `now` is injectable so tests can pin a moment inside a captured document's * validity window; production callers should leave it unset. + * + * `fetchRelease` retrieves the signed release bundle for an attested PCR0. It + * is only called when the PCR0 is not pinned and `policy.release` is set, and + * whatever it returns is verified, never trusted. */ export async function verifyNitroAttestation( document: Uint8Array, policy: NitroAttestationPolicy, - options: { now?: Date } = {}, + options: { now?: Date; fetchRelease?: (pcr0Hex: string) => Promise } = {}, ): Promise { const now = options.now ?? new Date(); + if (policy.pcr0 === undefined && !policy.release) { + throw new Error("kit/nitro: the attestation policy accepts no enclave"); + } const { protectedHeader, payload, signature } = readCoseSign1(document); assertEs384(protectedHeader); @@ -128,11 +142,19 @@ export async function verifyNitroAttestation( } const pcr0 = attestation.pcrs.get(0); - if (!pcr0 || !isAcceptedMeasurement(toHex(pcr0), policy.pcr0)) { + if (!pcr0) { throw new Error("kit/nitro: enclave image measurement is not accepted"); } - - return attestation; + const pcr0Hex = toHex(pcr0); + if (policy.pcr0 !== undefined && isAcceptedMeasurement(pcr0Hex, policy.pcr0)) { + return attestation; + } + if (policy.release && options.fetchRelease) { + const bundle = await options.fetchRelease(pcr0Hex); + await verifyEnclaveRelease(bundle, attestation.pcrs, policy.release, now); + return attestation; + } + throw new Error("kit/nitro: enclave image measurement is not accepted"); } /** diff --git a/src/recovery/nitro/attestationRelease.test.ts b/src/recovery/nitro/attestationRelease.test.ts new file mode 100644 index 0000000..f54009a --- /dev/null +++ b/src/recovery/nitro/attestationRelease.test.ts @@ -0,0 +1,82 @@ +import { verifyNitroAttestation } from "./attestation"; +import { verifyEnclaveRelease, type EnclaveReleasePolicy } from "./release"; +import { REAL_ATTESTATION_B64, REAL_ATTESTATION_TIMESTAMP } from "./fixtures/realAttestation"; + +jest.mock("./release", () => ({ verifyEnclaveRelease: jest.fn() })); + +/** + * How `verifyNitroAttestation` chooses between a pinned PCR0 and a signed + * release, run on the genuine AWS document from `attestation.test.ts`. + */ + +const REAL_PCR0 = + "4b8d4cf2a99e05ce1b5bddaf9d21cb446eb0e606c5bebd1ebf02b473a22165f7b68b0bb0d1ac5a90f0311e493522cfab"; +const now = new Date(REAL_ATTESTATION_TIMESTAMP); +const release: EnclaveReleasePolicy = { + repository: "cavos-labs/cavos-recovery", + repositoryId: "1333734222", + ownerId: "216094156", + workflow: ".github/workflows/release.yml", + minVersion: 1, +}; +const document = () => Uint8Array.from(Buffer.from(REAL_ATTESTATION_B64, "base64")); +const verifyRelease = verifyEnclaveRelease as jest.Mock; + +beforeEach(() => { + verifyRelease.mockReset(); + verifyRelease.mockResolvedValue(1); +}); + +describe("verifyNitroAttestation policy paths", () => { + it("accepts a pinned PCR0 without consulting releases", async () => { + const fetchRelease = jest.fn(); + await verifyNitroAttestation(document(), { pcr0: REAL_PCR0, release }, { now, fetchRelease }); + expect(fetchRelease).not.toHaveBeenCalled(); + expect(verifyRelease).not.toHaveBeenCalled(); + }); + + it("verifies the release fetched for the attested PCR0 when it is not pinned", async () => { + const bundle = { mediaType: "bundle" }; + const fetchRelease = jest.fn().mockResolvedValue(bundle); + const attestation = await verifyNitroAttestation( + document(), + { pcr0: ["00".repeat(48)], release }, + { now, fetchRelease }, + ); + expect(fetchRelease).toHaveBeenCalledWith(REAL_PCR0); + expect(verifyRelease).toHaveBeenCalledWith(bundle, attestation.pcrs, release, now); + }); + + it("accepts a release-only policy", async () => { + const fetchRelease = jest.fn().mockResolvedValue({}); + await verifyNitroAttestation(document(), { release }, { now, fetchRelease }); + expect(verifyRelease).toHaveBeenCalledTimes(1); + }); + + it("rejects when the release does not verify", async () => { + verifyRelease.mockRejectedValue(new Error("kit/nitro: the enclave release has expired")); + await expect( + verifyNitroAttestation(document(), { release }, { now, fetchRelease: jest.fn() }), + ).rejects.toThrow(/expired/); + }); + + it("rejects when the release cannot be fetched", async () => { + const fetchRelease = jest.fn().mockRejectedValue(new Error("no published release")); + await expect( + verifyNitroAttestation(document(), { release }, { now, fetchRelease }), + ).rejects.toThrow(/no published release/); + expect(verifyRelease).not.toHaveBeenCalled(); + }); + + it("rejects an unpinned PCR0 when there is no way to fetch a release", async () => { + await expect(verifyNitroAttestation(document(), { release }, { now })).rejects.toThrow( + /image measurement is not accepted/, + ); + }); + + it("rejects a policy that names neither path", async () => { + await expect( + verifyNitroAttestation(document(), {}, { now, fetchRelease: jest.fn() }), + ).rejects.toThrow(/accepts no enclave/); + }); +}); diff --git a/src/recovery/nitro/fixtures/uvReleaseBundle.json b/src/recovery/nitro/fixtures/uvReleaseBundle.json new file mode 100644 index 0000000..03b3d6c --- /dev/null +++ b/src/recovery/nitro/fixtures/uvReleaseBundle.json @@ -0,0 +1 @@ +{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json","verificationMaterial":{"certificate":{"rawBytes":"MIIG1DCCBlqgAwIBAgIUeZke0HD1qRu3KrZTQC3CxYB9hm4wCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjYxMDI3MDA0NzM1WhcNMjYxMDI3MDA1NzM1WjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEjxLPwB4LIlQ/QsYY8D79XGexCOLx5wgMH0uA7S5ThNkCttVrqh2Y02Ug3jgfPBJ9wJ9IBUOo8cJabnwRazcoZqOCBXkwggV1MA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAfBgNVHSMEGDAWgBTf0+nPViQRlvmo2OkoVaLGLhhkPzBbBgNVHREBAf8EUTBPhk1odHRwczovL2dpdGh1Yi5jb20vYXN0cmFsLXNoL3V2Ly5naXRodWIvd29ya2Zsb3dzL3JlbGVhc2UueW1sQHJlZnMvaGVhZHMvbWFpbjA5BgorBgEEAYO/MAEBBCtodHRwczovL3Rva2VuLmFjdGlvbnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tMB8GCisGAQQBg78wAQIEEXdvcmtmbG93X2Rpc3BhdGNoMDYGCisGAQQBg78wAQMEKDMyOWJmOTQ0OGFmZDlmOTA4OGQxNGE0ZDU3ZmJjNjFlYjQ1NGYxMTcwFQYKKwYBBAGDvzABBAQHUmVsZWFzZTAaBgorBgEEAYO/MAEFBAxhc3RyYWwtc2gvdXYwHQYKKwYBBAGDvzABBgQPcmVmcy9oZWFkcy9tYWluMDsGCisGAQQBg78wAQgELQwraHR0cHM6Ly90b2tlbi5hY3Rpb25zLmdpdGh1YnVzZXJjb250ZW50LmNvbTBdBgorBgEEAYO/MAEJBE8MTWh0dHBzOi8vZ2l0aHViLmNvbS9hc3RyYWwtc2gvdXYvLmdpdGh1Yi93b3JrZmxvd3MvcmVsZWFzZS55bWxAcmVmcy9oZWFkcy9tYWluMDgGCisGAQQBg78wAQoEKgwoMzI5YmY5NDQ4YWZkOWY5MDg4ZDE0YTRkNTdmYmM2MWViNDU0ZjExNzAdBgorBgEEAYO/MAELBA8MDWdpdGh1Yi1ob3N0ZWQwLwYKKwYBBAGDvzABDAQhDB9odHRwczovL2dpdGh1Yi5jb20vYXN0cmFsLXNoL3V2MDgGCisGAQQBg78wAQ0EKgwoMzI5YmY5NDQ4YWZkOWY5MDg4ZDE0YTRkNTdmYmM2MWViNDU0ZjExNzAfBgorBgEEAYO/MAEOBBEMD3JlZnMvaGVhZHMvbWFpbjAZBgorBgEEAYO/MAEPBAsMCTY5OTUzMjY0NTAsBgorBgEEAYO/MAEQBB4MHGh0dHBzOi8vZ2l0aHViLmNvbS9hc3RyYWwtc2gwGQYKKwYBBAGDvzABEQQLDAkxMTU5NjI4MzkwXQYKKwYBBAGDvzABEgRPDE1odHRwczovL2dpdGh1Yi5jb20vYXN0cmFsLXNoL3V2Ly5naXRodWIvd29ya2Zsb3dzL3JlbGVhc2UueW1sQHJlZnMvaGVhZHMvbWFpbjA4BgorBgEEAYO/MAETBCoMKDMyOWJmOTQ0OGFmZDlmOTA4OGQxNGE0ZDU3ZmJjNjFlYjQ1NGYxMTcwIQYKKwYBBAGDvzABFAQTDBF3b3JrZmxvd19kaXNwYXRjaDBTBgorBgEEAYO/MAEVBEUMQ2h0dHBzOi8vZ2l0aHViLmNvbS9hc3RyYWwtc2gvdXYvYWN0aW9ucy9ydW5zLzQwNDI1MTQxNTk4L2F0dGVtcHRzLzEwFgYKKwYBBAGDvzABFgQIDAZwdWJsaWMwFwYKKwYBBAGDvzABFwQJDAdyZWxlYXNlMDUGCisGAQQBg78wARgEJwwlcmVwbzphc3RyYWwtc2gvdXY6ZW52aXJvbm1lbnQ6cmVsZWFzZTCBiwYKKwYBBAHWeQIEAgR9BHsAeQB3AN09MGrGxxEyYxkeHJlnNwKiSl643jyt/4eKcoAvKe6OAAABoXrTrYMAAAQDAEgwRgIhAIvTgJ/NQTp+Ut02QOte/wkhLUw/T9e9TaHyIl7ymgOAAiEAi5kvxgysJ2/UotkGz6xEBud6OElG/M/0NRFLnFCs6GcwCgYIKoZIzj0EAwMDaAAwZQIwa82t2JHdk6EZAU66K7gY0Qc/Z+JDYBC1GqcT/ytjWYNcdSDUCAIohwW9dn1useDRAjEA9JyrMEAN6Wqx8T6qTId+noSVvwY5wJ6Hp9/A2K1fxL6lViCcZEsjf0BCT+yD5KwZ"},"tlogEntries":[{"logIndex":"3500975664","logId":{"keyId":"wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="},"kindVersion":{"kind":"dsse","version":"0.0.1"},"integratedTime":"1793062055","inclusionPromise":{"signedEntryTimestamp":"MEUCIFemVH177oFJeBORu3VUp2JrAIr3OSbO6IGn6bC27T4oAiEAjtAXpIg2zYgY52UDrPG6zJZO4fUgWYkuOgrhREuk7Kw="},"inclusionProof":{"logIndex":"3379071402","rootHash":"nT6ThJY24dnvQpSO3268P/degNi9guRJ5Lwt9QOZyAM=","treeSize":"3379071417","hashes":["sxLj7+T+IOQq2xdGVrhsrI/Mzwf08xdnMeRB+zHzuSA=","ksKKS6bySyjrFjU6UqjV3B17S6M4Oro6Ans+gbD3Nng=","UtFW+y78Z2e0W431HMwzeil6A0cGPgSw/mlwfwi7lOI=","hubb3ry52hAMAdafSVPzfxJiogRX8+1E2uwFlV7VjYI=","ltwOtIZ0AGmfPpceHTBBEQEm3J1XSyqyPYraM1iNjlY=","kZcj+YSzhd9hlgMxGkHwvDKCWaLHn1MFQmIttRnjQqs=","sEbWLXC7eAHfWqzDgNn3eya1Qk9KL/9iRypTOuCEPvc=","NKvlUMCsXGVGd6gSLO+0jWHgziMeEj8dD1V4U42uV8g=","WWzPxpgV71nDyqBmYHcl5t6l8RRFns8ch43wwzDPex4=","k19QVFGnTUtvaQK5I24q5itCn94JRPLjaJAgBg8N+NI=","zXjnLfPJ7N8NRTqBACWE129PZQeUsP5Ip+0WxolALcY=","JGBPdnCoWc1Oj7IDUQMnJJ6JNLrtBh2lN5jMCHdJClA=","CL3FFvdQsRRQiZ+ca6/NnLCcRouaXwL11uMaseZyn84=","6isO7d5SZuUjk8FoC2N/4lrs2iljQ9nlCzDM90al4Mw=","zGfeoWKmrZlXMOvyi3R2GDbGpmI1skJW/wLTXCCgF0o=","1DaDKzrCNNBhmSdY5vFjz+NpAmxmYfYEgBCdPLrMP1A=","xH/DCseLHr9eKoYT8qsORZK7zVdEGYWHuVtsVrD95wY="],"checkpoint":{"envelope":"rekor.sigstore.dev - 1193050959916656506\n3379071417\nnT6ThJY24dnvQpSO3268P/degNi9guRJ5Lwt9QOZyAM=\n\n— rekor.sigstore.dev wNI9ajBFAiEAygDU2A9QKeGCSTg/0MUcFi7SeCHOFum63DvS0Y8/niMCIALyU8HXJngDp0JgjnoYkL3ALy75J+2f5TG1XjlzLSqq\n"}},"canonicalizedBody":"eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiZHNzZSIsInNwZWMiOnsiZW52ZWxvcGVIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiYTBlZjMwOWE1ZjJiZGQxYzBjZjk3YjU3OTIwNjUxMDI2ZmI0YWI0ZGUxN2U5MTdjMTE2ZGM3OGUzZTljOGQ3NSJ9LCJwYXlsb2FkSGFzaCI6eyJhbGdvcml0aG0iOiJzaGEyNTYiLCJ2YWx1ZSI6IjJjNjA1YmI1OGNmZGI5ZDEyMTk3YjAzOWMwOWY3OGJkY2ZhYmMxZmI5NTQzNzFjMTg2MjUxOTY2NmUyYWM1ZDMifSwic2lnbmF0dXJlcyI6W3sic2lnbmF0dXJlIjoiTUVVQ0lRRFhZR0ZBTXVjdU8vaUUwbkloSmpCaktoVjNpcWRtb1JxZnR6cGlxdW5DU2dJZ1A4dUxzNDNGZWpkU2dlb1kxaENhQ05hWVk1bFY5VXUzNFRtcUhOZjlpbVk9IiwidmVyaWZpZXIiOiJMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VjeFJFTkRRbXh4WjBGM1NVSkJaMGxWWlZwclpUQklSREZ4VW5VelMzSmFWRkZETTBONFdVSTVhRzAwZDBObldVbExiMXBKZW1vd1JVRjNUWGNLVG5wRlZrMUNUVWRCTVZWRlEyaE5UV015Ykc1ak0xSjJZMjFWZFZwSFZqSk5ValIzU0VGWlJGWlJVVVJGZUZaNllWZGtlbVJIT1hsYVV6RndZbTVTYkFwamJURnNXa2RzYUdSSFZYZElhR05PVFdwWmVFMUVTVE5OUkVFd1RucE5NVmRvWTA1TmFsbDRUVVJKTTAxRVFURk9lazB4VjJwQlFVMUdhM2RGZDFsSUNrdHZXa2w2YWpCRFFWRlpTVXR2V2tsNmFqQkVRVkZqUkZGblFVVnFlRXhRZDBJMFRFbHNVUzlSYzFsWk9FUTNPVmhIWlhoRFQweDROWGRuVFVnd2RVRUtOMU0xVkdoT2EwTjBkRlp5Y1dneVdUQXlWV2N6YW1kbVVFSktPWGRLT1VsQ1ZVOXZPR05LWVdKdWQxSmhlbU52V25GUFEwSllhM2RuWjFZeFRVRTBSd3BCTVZWa1JIZEZRaTkzVVVWQmQwbElaMFJCVkVKblRsWklVMVZGUkVSQlMwSm5aM0pDWjBWR1FsRmpSRUY2UVdaQ1owNVdTRk5OUlVkRVFWZG5RbFJtQ2pBcmJsQldhVkZTYkhadGJ6SlBhMjlXWVV4SFRHaG9hMUI2UW1KQ1owNVdTRkpGUWtGbU9FVlZWRUpRYUdzeGIyUklVbmRqZW05MlRESmtjR1JIYURFS1dXazFhbUl5TUhaWldFNHdZMjFHYzB4WVRtOU1NMVl5VEhrMWJtRllVbTlrVjBsMlpESTVlV0V5V25OaU0yUjZURE5LYkdKSFZtaGpNbFYxWlZjeGN3cFJTRXBzV201TmRtRkhWbWhhU0UxMllsZEdjR0pxUVRWQ1oyOXlRbWRGUlVGWlR5OU5RVVZDUWtOMGIyUklVbmRqZW05MlRETlNkbUV5Vm5WTWJVWnFDbVJIYkhaaWJrMTFXakpzTUdGSVZtbGtXRTVzWTIxT2RtSnVVbXhpYmxGMVdUSTVkRTFDT0VkRGFYTkhRVkZSUW1jM09IZEJVVWxGUlZoa2RtTnRkRzBLWWtjNU0xZ3lVbkJqTTBKb1pFZE9iMDFFV1VkRGFYTkhRVkZSUW1jM09IZEJVVTFGUzBSTmVVOVhTbTFQVkZFd1QwZEdiVnBFYkcxUFZFRTBUMGRSZUFwT1IwVXdXa1JWTTFwdFNtcE9ha1pzV1dwUk1VNUhXWGhOVkdOM1JsRlpTMHQzV1VKQ1FVZEVkbnBCUWtKQlVVaFZiVlp6V2xkR2VscFVRV0ZDWjI5eUNrSm5SVVZCV1U4dlRVRkZSa0pCZUdoak0xSjVXVmQzZEdNeVozWmtXRmwzU0ZGWlMwdDNXVUpDUVVkRWRucEJRa0puVVZCamJWWnRZM2s1YjFwWFJtc0tZM2s1ZEZsWGJIVk5SSE5IUTJselIwRlJVVUpuTnpoM1FWRm5SVXhSZDNKaFNGSXdZMGhOTmt4NU9UQmlNblJzWW1rMWFGa3pVbkJpTWpWNlRHMWtjQXBrUjJneFdXNVdlbHBZU21waU1qVXdXbGMxTUV4dFRuWmlWRUprUW1kdmNrSm5SVVZCV1U4dlRVRkZTa0pGT0UxVVYyZ3daRWhDZWs5cE9IWmFNbXd3Q21GSVZtbE1iVTUyWWxNNWFHTXpVbmxaVjNkMFl6Sm5kbVJZV1haTWJXUndaRWRvTVZscE9UTmlNMHB5V20xNGRtUXpUWFpqYlZaeldsZEdlbHBUTlRVS1lsZDRRV050Vm0xamVUbHZXbGRHYTJONU9YUlpWMngxVFVSblIwTnBjMGRCVVZGQ1p6YzRkMEZSYjBWTFozZHZUWHBKTlZsdFdUVk9SRkUwV1ZkYWF3cFBWMWsxVFVSbk5GcEVSVEJaVkZKclRsUmtiVmx0VFRKTlYxWnBUa1JWTUZwcVJYaE9la0ZrUW1kdmNrSm5SVVZCV1U4dlRVRkZURUpCT0UxRVYyUndDbVJIYURGWmFURnZZak5PTUZwWFVYZE1kMWxMUzNkWlFrSkJSMFIyZWtGQ1JFRlJhRVJDT1c5a1NGSjNZM3B2ZGt3eVpIQmtSMmd4V1drMWFtSXlNSFlLV1ZoT01HTnRSbk5NV0U1dlRETldNazFFWjBkRGFYTkhRVkZSUW1jM09IZEJVVEJGUzJkM2IwMTZTVFZaYlZrMVRrUlJORmxYV210UFYxazFUVVJuTkFwYVJFVXdXVlJTYTA1VVpHMVpiVTB5VFZkV2FVNUVWVEJhYWtWNFRucEJaa0puYjNKQ1owVkZRVmxQTDAxQlJVOUNRa1ZOUkROS2JGcHVUWFpoUjFab0NscElUWFppVjBad1ltcEJXa0puYjNKQ1owVkZRVmxQTDAxQlJWQkNRWE5OUTFSWk5VOVVWWHBOYWxrd1RsUkJjMEpuYjNKQ1owVkZRVmxQTDAxQlJWRUtRa0kwVFVoSGFEQmtTRUo2VDJrNGRsb3liREJoU0ZacFRHMU9kbUpUT1doak0xSjVXVmQzZEdNeVozZEhVVmxMUzNkWlFrSkJSMFIyZWtGQ1JWRlJUQXBFUVd0NFRWUlZOVTVxU1RSTmVtdDNXRkZaUzB0M1dVSkNRVWRFZG5wQlFrVm5VbEJFUlRGdlpFaFNkMk42YjNaTU1tUndaRWRvTVZscE5XcGlNakIyQ2xsWVRqQmpiVVp6VEZoT2Iwd3pWakpNZVRWdVlWaFNiMlJYU1haa01qbDVZVEphYzJJelpIcE1NMHBzWWtkV2FHTXlWWFZsVnpGelVVaEtiRnB1VFhZS1lVZFdhRnBJVFhaaVYwWndZbXBCTkVKbmIzSkNaMFZGUVZsUEwwMUJSVlJDUTI5TlMwUk5lVTlYU20xUFZGRXdUMGRHYlZwRWJHMVBWRUUwVDBkUmVBcE9SMFV3V2tSVk0xcHRTbXBPYWtac1dXcFJNVTVIV1hoTlZHTjNTVkZaUzB0M1dVSkNRVWRFZG5wQlFrWkJVVlJFUWtZellqTktjbHB0ZUhaa01UbHJDbUZZVG5kWldGSnFZVVJDVkVKbmIzSkNaMFZGUVZsUEwwMUJSVlpDUlZWTlVUSm9NR1JJUW5wUGFUaDJXakpzTUdGSVZtbE1iVTUyWWxNNWFHTXpVbmtLV1ZkM2RHTXlaM1prV0ZsMldWZE9NR0ZYT1hWamVUbDVaRmMxZWt4NlVYZE9SRWt4VFZSUmVFNVVhelJNTWtZd1pFZFdkR05JVW5wTWVrVjNSbWRaU3dwTGQxbENRa0ZIUkhaNlFVSkdaMUZKUkVGYWQyUlhTbk5oVjAxM1JuZFpTMHQzV1VKQ1FVZEVkbnBCUWtaM1VVcEVRV1I1V2xkNGJGbFlUbXhOUkZWSENrTnBjMGRCVVZGQ1p6YzRkMEZTWjBWS2QzZHNZMjFXZDJKNmNHaGpNMUo1V1ZkM2RHTXlaM1prV0ZrMldsYzFNbUZZU25aaWJURnNZbTVSTm1OdFZuTUtXbGRHZWxwVVEwSnBkMWxMUzNkWlFrSkJTRmRsVVVsRlFXZFNPVUpJYzBGbFVVSXpRVTR3T1UxSGNrZDRlRVY1V1hoclpVaEtiRzVPZDB0cFUydzJOQW96YW5sMEx6UmxTMk52UVhaTFpUWlBRVUZCUW05WWNsUnlXVTFCUVVGUlJFRkZaM2RTWjBsb1FVbDJWR2RLTDA1UlZIQXJWWFF3TWxGUGRHVXZkMnRvQ2t4VmR5OVVPV1U1VkdGSWVVbHNOM2x0WjA5QlFXbEZRV2sxYTNaNFozbHpTakl2Vlc5MGEwZDZObmhGUW5Wa05rOUZiRWN2VFM4d1RsSkdURzVHUTNNS05rZGpkME5uV1VsTGIxcEplbW93UlVGM1RVUmhRVUYzV2xGSmQyRTRNblF5U2toa2F6WkZXa0ZWTmpaTE4yZFpNRkZqTDFvclNrUlpRa014UjNGalZBb3ZlWFJxVjFsT1kyUlRSRlZEUVVsdmFIZFhPV1J1TVhWelpVUlNRV3BGUVRsS2VYSk5SVUZPTmxkeGVEaFVObkZVU1dRcmJtOVRWblozV1RWM1NqWklDbkE1TDBFeVN6Rm1lRXcyYkZacFEyTmFSWE5xWmpCQ1ExUXJlVVExUzNkYUNpMHRMUzB0UlU1RUlFTkZVbFJKUmtsRFFWUkZMUzB0TFMwSyJ9XX19"}],"timestampVerificationData":{}},"dsseEnvelope":{"payload":"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoiZGlzdC1tYW5pZmVzdC5qc29uIiwiZGlnZXN0Ijp7InNoYTI1NiI6ImM3ZTZkNWYwODE2MDViMzQ3Y2FmZTJlYWQ5NzRkNjBiYzczN2RiMDFmNjk0ZWE5YWI5YTY0NGI2NmM1OWM0MGYifX0seyJuYW1lIjoic291cmNlLnRhci5neiIsImRpZ2VzdCI6eyJzaGEyNTYiOiIzN2FmNTdjMzFmNzgwNDg4YmNlNzk4YjczZmNkMzgyOTQ5ZmFlZjcyZTdhOThhN2U3M2ZlMGYyNDg3NGVkOTI0In19LHsibmFtZSI6InV2LWFhcmNoNjQtYXBwbGUtZGFyd2luLnRhci5neiIsImRpZ2VzdCI6eyJzaGEyNTYiOiI2MGZiZDVjOGExZWQ2ODE4NDVlZWEyOGQ5ZDYyMmZjZGUwM2M1MWU5MWE0MzQ2NWI2NGExZTE3ZGIxZWQzNDcyIn19LHsibmFtZSI6InV2LWFhcmNoNjQtcGMtd2luZG93cy1tc3ZjLnppcCIsImRpZ2VzdCI6eyJzaGEyNTYiOiJjMjQyNmMwYzhlMjhiNTNkOGM0NDBlNDdhMThkOTZhZDRkYjUxZTE1OTg2NmMwZTgxNDM1YjE5NmQ3ZjAwODFjIn19LHsibmFtZSI6InV2LWFhcmNoNjQtdW5rbm93bi1saW51eC1nbnUudGFyLmd6IiwiZGlnZXN0Ijp7InNoYTI1NiI6IjIyOWUxOTQyNTVjNzk1OGQ4NmQ1NmU4NzkwZDk2OWE2Zjc0NzM2NTkyMTMzZjBkZmQwMDc2Zjc1NGM2NDJjMWQifX0seyJuYW1lIjoidXYtYWFyY2g2NC11bmtub3duLWxpbnV4LW11c2wudGFyLmd6IiwiZGlnZXN0Ijp7InNoYTI1NiI6IjgxOTY5MmU0MDRkMjNlNWFhZTZiYzQ0YzBlMjYwZjFmNTQ5MmZkMTkxMGFiNDY5M2IyYzY1MGY5MjZkYmU4MDAifX0seyJuYW1lIjoidXYtYXJtLXVua25vd24tbGludXgtbXVzbGVhYmloZi50YXIuZ3oiLCJkaWdlc3QiOnsic2hhMjU2IjoiNTdhYzBmN2MwNGRlYjQyYzVkNjg2YjRlZWVlZjU1NWM0NDJkYjZmZmVhMTZmZTRjYmQ3YWRiMDM4NDQwMzc5NCJ9fSx7Im5hbWUiOiJ1di1hcm12Ny11bmtub3duLWxpbnV4LWdudWVhYmloZi50YXIuZ3oiLCJkaWdlc3QiOnsic2hhMjU2IjoiZDAwY2Q0OTQ5YzQ1MjRjOTM0YWZjZmVlMWFlZGZhOWQzMzdjZjRlNDNiZTRkNmI2OTJhZDhkNzRkMGE2NTU2MiJ9fSx7Im5hbWUiOiJ1di1hcm12Ny11bmtub3duLWxpbnV4LW11c2xlYWJpaGYudGFyLmd6IiwiZGlnZXN0Ijp7InNoYTI1NiI6ImIyMTUzOThjODg2ZThjYzJjZmEyODJmZTM0NmMwMjAyNTk1N2Y4NjMzYjc1YTNkNGZkODQ4ZTBhZGRhNGVkZGQifX0seyJuYW1lIjoidXYtaTY4Ni1wYy13aW5kb3dzLW1zdmMuemlwIiwiZGlnZXN0Ijp7InNoYTI1NiI6IjMyY2ViNjRkYjIzMWNiZWRmZDQ5OWY3NjU0YzcyZTRjMGI1NjRmMjg0NmY5ZTc2YzBhNGE3YjJkMDNkN2U2N2MifX0seyJuYW1lIjoidXYtaTY4Ni11bmtub3duLWxpbnV4LWdudS50YXIuZ3oiLCJkaWdlc3QiOnsic2hhMjU2IjoiNjY1ZWE4MzFjMzhkMzQyOTAwOTUwZTYwNmJlYjJjY2MzN2I3YmEwZmNlZmMzN2I4MTRlYjRiMDc2MzA1ZjJiNiJ9fSx7Im5hbWUiOiJ1di1pNjg2LXVua25vd24tbGludXgtbXVzbC50YXIuZ3oiLCJkaWdlc3QiOnsic2hhMjU2IjoiZGUzYTAxNWM0YjBmY2U2M2MxNzhhZTRmYTk1Yzk0Yzg3M2FkZmQ5MmZhMWUyNThjNmEyYTk0ZTUyOTgxMjYyOSJ9fSx7Im5hbWUiOiJ1di1pbnN0YWxsZXIucHMxIiwiZGlnZXN0Ijp7InNoYTI1NiI6ImQwMDU4MDA1MTY5OWJlZTU4YTJlMmQ5YjkyNWY0NTc4OGIzNWY3ZTBmZmY3OTY2ZmQ1YmVkZmMzYzU0N2RjN2QifX0seyJuYW1lIjoidXYtaW5zdGFsbGVyLnNoIiwiZGlnZXN0Ijp7InNoYTI1NiI6ImY3YTU2ZTdhZmFlYzQ5YTNiY2JjM2FjM2NkYmU5MTdlN2M2Y2UzMGUwZDgxNTMwOGExMmU1MzFhYTVlZjIzYjcifX0seyJuYW1lIjoidXYtcG93ZXJwYzY0bGUtdW5rbm93bi1saW51eC1nbnUudGFyLmd6IiwiZGlnZXN0Ijp7InNoYTI1NiI6IjNiMjg0NzE4NWVjMjk0NmZjYzkyODUwMGVmZWY3ZTNlNzRjMjU1ZDNiZTZmMmNiNDM1MDgxYTg0ODg1ZDhjNmMifX0seyJuYW1lIjoidXYtcmlzY3Y2NGdjLXVua25vd24tbGludXgtZ251LnRhci5neiIsImRpZ2VzdCI6eyJzaGEyNTYiOiI1MTcwNTI2MDNlZDQ2NDgzYjU5ZDg4NzVjOWM2MWE1Nzc5NDZiOGY0OTNjNzNlNDMzOWU0NWU3YjExOTE3MWQwIn19LHsibmFtZSI6InV2LXJpc2N2NjRnYy11bmtub3duLWxpbnV4LW11c2wudGFyLmd6IiwiZGlnZXN0Ijp7InNoYTI1NiI6IjY1OWE5NWRjNmFlZTNlY2Y4Njc0MmEwOGQ4Y2E2YzYyNjE0MjIxMjFjYmQ0MWRlY2MwMDQyZjVhNzFhZjYzMDUifX0seyJuYW1lIjoidXYtczM5MHgtdW5rbm93bi1saW51eC1nbnUudGFyLmd6IiwiZGlnZXN0Ijp7InNoYTI1NiI6ImZiNTZkNzMxMzA4YWE1Yzc3MTgwNTlkNzFhZmNiMTRkOWE5MzNhOTc0OTk3Y2VlM2Q0ZWI1ZDA2ZmM0NjJhNGIifX0seyJuYW1lIjoidXYteDg2XzY0LWFwcGxlLWRhcndpbi50YXIuZ3oiLCJkaWdlc3QiOnsic2hhMjU2IjoiNjNiYTE3ZmRkYTU1YWQxYTIzMGQ0YjM3ZmM2MDE3ZmZhMTBjMjM2ODhjODM4MThiZDNiZmRmOTE0YzY1MmEwMiJ9fSx7Im5hbWUiOiJ1di14ODZfNjQtcGMtd2luZG93cy1tc3ZjLnppcCIsImRpZ2VzdCI6eyJzaGEyNTYiOiJhMmE4ODIwZDVlZWEzMmYwOGVmNDY3ZWYzYjdmNjY4ODNhOWIwNzZkNTczN2EyZmMyZmJiNWY0NThkMWJiNTBmIn19LHsibmFtZSI6InV2LXg4Nl82NC11bmtub3duLWxpbnV4LWdudS50YXIuZ3oiLCJkaWdlc3QiOnsic2hhMjU2IjoiZjI5YzNlOTRmZmFiZDdlNmYzYmE1MjFkYmExNjlhOWI3ZWMxMjEwMGJmMzI1NzA4N2YwZjI0ZjYxMjNmMzY0NSJ9fSx7Im5hbWUiOiJ1di14ODZfNjQtdW5rbm93bi1saW51eC1tdXNsLnRhci5neiIsImRpZ2VzdCI6eyJzaGEyNTYiOiI1ZDYxZjI2YjZjNzIxMzc3OWVkYTkwZWRjNjk3MWVhNmIwYjc5NmJmYjhkNDlhNTc0YzhhZDY5YjU5ODM4NTQ4In19XSwicHJlZGljYXRlVHlwZSI6Imh0dHBzOi8vc2xzYS5kZXYvcHJvdmVuYW5jZS92MSIsInByZWRpY2F0ZSI6eyJidWlsZERlZmluaXRpb24iOnsiYnVpbGRUeXBlIjoiaHR0cHM6Ly9hY3Rpb25zLmdpdGh1Yi5pby9idWlsZHR5cGVzL3dvcmtmbG93L3YxIiwiZXh0ZXJuYWxQYXJhbWV0ZXJzIjp7IndvcmtmbG93Ijp7InJlZiI6InJlZnMvaGVhZHMvbWFpbiIsInJlcG9zaXRvcnkiOiJodHRwczovL2dpdGh1Yi5jb20vYXN0cmFsLXNoL3V2IiwicGF0aCI6Ii5naXRodWIvd29ya2Zsb3dzL3JlbGVhc2UueW1sIn19LCJpbnRlcm5hbFBhcmFtZXRlcnMiOnsiZ2l0aHViIjp7ImV2ZW50X25hbWUiOiJ3b3JrZmxvd19kaXNwYXRjaCIsInJlcG9zaXRvcnlfaWQiOiI2OTk1MzI2NDUiLCJyZXBvc2l0b3J5X293bmVyX2lkIjoiMTE1OTYyODM5IiwicnVubmVyX2Vudmlyb25tZW50IjoiZ2l0aHViLWhvc3RlZCJ9fSwicmVzb2x2ZWREZXBlbmRlbmNpZXMiOlt7InVyaSI6ImdpdCtodHRwczovL2dpdGh1Yi5jb20vYXN0cmFsLXNoL3V2QHJlZnMvaGVhZHMvbWFpbiIsImRpZ2VzdCI6eyJnaXRDb21taXQiOiIzMjliZjk0NDhhZmQ5ZjkwODhkMTRhNGQ1N2ZiYzYxZWI0NTRmMTE3In19XX0sInJ1bkRldGFpbHMiOnsiYnVpbGRlciI6eyJpZCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9hc3RyYWwtc2gvdXYvLmdpdGh1Yi93b3JrZmxvd3MvcmVsZWFzZS55bWxAcmVmcy9oZWFkcy9tYWluIn0sIm1ldGFkYXRhIjp7Imludm9jYXRpb25JZCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9hc3RyYWwtc2gvdXYvYWN0aW9ucy9ydW5zLzQwNDI1MTQxNTk4L2F0dGVtcHRzLzEifX19fQ==","payloadType":"application/vnd.in-toto+json","signatures":[{"sig":"MEUCIQDXYGFAMucuO/iE0nIhJjBjKhV3iqdmoRqftzpiqunCSgIgP8uLs43FejdSgeoY1hCaCNaYY5lV9Uu34TmqHNf9imY="}]}} diff --git a/src/recovery/nitro/release.test.ts b/src/recovery/nitro/release.test.ts new file mode 100644 index 0000000..d6c1e4f --- /dev/null +++ b/src/recovery/nitro/release.test.ts @@ -0,0 +1,252 @@ +import { createHash } from "crypto"; +import { readFileSync } from "fs"; +import { join } from "path"; +import { SigstoreVerifier } from "@freedomofpress/sigstore-browser"; +import { + assertReleaseMatchesPcrs, + parseReleaseStatement, + verifyEnclaveRelease, + verifyReleaseSignature, + type ReleaseSignerIdentity, +} from "./release"; +import { DEFAULT_SOCIAL_RECOVERY_ATTESTATION } from "../attestationDefaults"; +import trustedRoot from "./sigstoreTrustedRoot.json"; + +/** + * The Sigstore path runs against a real public-good bundle: astral-sh/uv's + * GitHub release attestation (Fulcio certificate, Rekor v1 dsse entry with an + * inclusion proof). It is not a Cavos release, so the Cavos policy must refuse + * it; pointed at uv's own workflow identity the signature must verify, and the + * statement must then be refused for not being an enclave release. + */ + +const uvBundle = () => + JSON.parse(readFileSync(join(__dirname, "fixtures/uvReleaseBundle.json"), "utf8")); + +const uvIdentity: ReleaseSignerIdentity = { + repository: "astral-sh/uv", + repositoryId: "699532645", + ownerId: "115962839", + workflow: ".github/workflows/release.yml", + trigger: "workflow_dispatch", + ref: /^refs\/heads\/main$/, +}; + +const pcr = (byte: number) => new Uint8Array(48).fill(byte); +const pcrs = new Map([ + [0, pcr(0xaa)], + [1, pcr(0xbb)], + [2, pcr(0xcc)], +]); +const released = { pcr0: "aa".repeat(48), pcr1: "bb".repeat(48), pcr2: "cc".repeat(48) }; + +let tufRefresh: jest.SpyInstance; + +beforeEach(() => { + tufRefresh = jest + .spyOn(SigstoreVerifier.prototype, "loadSigstoreRootWithTUF") + .mockImplementation(function (this: SigstoreVerifier) { + return this.loadSigstoreRoot(trustedRoot as any); + }); +}); + +afterEach(() => tufRefresh.mockRestore()); + +describe("bundled Sigstore trusted root", () => { + it("is the pinned public-good trusted_root.json", () => { + const bytes = readFileSync(join(__dirname, "sigstoreTrustedRoot.json")); + expect(createHash("sha256").update(bytes).digest("hex")).toBe( + "6494e21ea73fa7ee769f85f57d5a3e6a08725eae1e38c755fc3517c9e6bc0b66", + ); + }); +}); + +describe("verifyReleaseSignature", () => { + it("verifies a real public-good bundle under its own workflow identity", async () => { + const { payload, ref } = await verifyReleaseSignature(uvBundle(), uvIdentity); + expect(ref).toBe("refs/heads/main"); + expect(() => parseReleaseStatement(payload)).toThrow(/unexpected predicate type/); + expect(tufRefresh).not.toHaveBeenCalled(); + }); + + it("refuses the same bundle when any identity claim differs, without refreshing the root", async () => { + const variants: Partial[] = [ + { repositoryId: "1" }, + { ownerId: "1" }, + { trigger: "push" }, + { ref: /^refs\/tags\/v([1-9][0-9]*)$/ }, + { workflow: ".github/workflows/other.yml" }, + ]; + for (const variant of variants) { + await expect( + verifyReleaseSignature(uvBundle(), { ...uvIdentity, ...variant }), + ).rejects.toThrow(/signature is not valid/); + } + expect(tufRefresh).not.toHaveBeenCalled(); + }); + + it("retries a non-identity failure exactly once against the refreshed root", async () => { + const bundle = uvBundle(); + const statement = JSON.parse(Buffer.from(bundle.dsseEnvelope.payload, "base64").toString()); + statement.predicateType = "https://cavos.xyz/attestation/enclave-release/v1"; + bundle.dsseEnvelope.payload = Buffer.from(JSON.stringify(statement)).toString("base64"); + + await expect(verifyReleaseSignature(bundle, uvIdentity)).rejects.toThrow( + /signature is not valid/, + ); + expect(tufRefresh).toHaveBeenCalledTimes(1); + }); + + it("fails closed when the refreshed root cannot be loaded", async () => { + tufRefresh.mockRejectedValue(new Error("offline")); + const bundle = uvBundle(); + bundle.dsseEnvelope.signatures[0].sig = Buffer.alloc(70).toString("base64"); + await expect(verifyReleaseSignature(bundle, uvIdentity)).rejects.toThrow(/offline/); + }); + + it("rejects a bundle carrying a certificate chain", async () => { + const bundle = uvBundle(); + bundle.verificationMaterial.x509CertificateChain = { + certificates: [bundle.verificationMaterial.certificate], + }; + await expect(verifyReleaseSignature(bundle, uvIdentity)).rejects.toThrow(/single certificate/); + }); + + it("rejects anything but a v0.3 bundle", async () => { + const bundle = uvBundle(); + bundle.mediaType = "application/vnd.dev.sigstore.bundle+json;version=0.2"; + await expect(verifyReleaseSignature(bundle, uvIdentity)).rejects.toThrow(/v0.3 bundle/); + await expect(verifyReleaseSignature(null, uvIdentity)).rejects.toThrow(/v0.3 bundle/); + }); +}); + +describe("verifyEnclaveRelease", () => { + it("refuses a real bundle that was not signed by the Cavos release workflow", async () => { + await expect( + verifyEnclaveRelease(uvBundle(), pcrs, DEFAULT_SOCIAL_RECOVERY_ATTESTATION.release!, new Date()), + ).rejects.toThrow(/OIDCSourceRepositoryURI does not match/); + expect(tufRefresh).not.toHaveBeenCalled(); + }); +}); + +function statement(overrides: Record = {}, predicate: Record = {}) { + return new TextEncoder().encode( + JSON.stringify({ + _type: "https://in-toto.io/Statement/v1", + subject: [{ name: "enclave.eif", digest: { sha256: "ab".repeat(32) } }], + predicateType: "https://cavos.xyz/attestation/enclave-release/v1", + predicate: { ...released, version: 3, expiresAt: "2027-01-01T00:00:00Z", ...predicate }, + ...overrides, + }), + ); +} + +describe("parseReleaseStatement", () => { + it("reads a well-formed release", () => { + expect(parseReleaseStatement(statement())).toEqual({ + imageDigest: "ab".repeat(32), + ...released, + version: 3, + expiresAt: new Date("2027-01-01T00:00:00Z"), + }); + }); + + it("rejects payloads that are not a JSON object", () => { + expect(() => parseReleaseStatement(new TextEncoder().encode("nope"))).toThrow(/not valid JSON/); + expect(() => parseReleaseStatement(new TextEncoder().encode("[]"))).toThrow(/not an object/); + expect(() => parseReleaseStatement(Uint8Array.of(0xff))).toThrow(/not valid JSON/); + }); + + it("rejects the wrong statement or predicate type", () => { + expect(() => + parseReleaseStatement(statement({ _type: "https://in-toto.io/Statement/v0.1" })), + ).toThrow(/in-toto v1/); + expect(() => + parseReleaseStatement(statement({ predicateType: "https://slsa.dev/provenance/v1" })), + ).toThrow(/unexpected predicate type/); + }); + + it("rejects unknown statement, subject and predicate fields", () => { + expect(() => parseReleaseStatement(statement({ extra: 1 }))).toThrow(/statement has unknown/); + expect(() => + parseReleaseStatement( + statement({ subject: [{ name: "x", digest: { sha256: "ab".repeat(32) }, uri: "y" }] }), + ), + ).toThrow(/subject has unknown/); + expect(() => + parseReleaseStatement( + statement({ subject: [{ digest: { sha256: "ab".repeat(32), sha512: "cd" } }] }), + ), + ).toThrow(/digest has unknown/); + expect(() => parseReleaseStatement(statement({}, { pcr8: "00" }))).toThrow( + /predicate has unknown/, + ); + }); + + it("requires exactly one image subject with a sha256 digest", () => { + const subject = { digest: { sha256: "ab".repeat(32) } }; + expect(() => parseReleaseStatement(statement({ subject: [] }))).toThrow(/exactly one/); + expect(() => parseReleaseStatement(statement({ subject: [subject, subject] }))).toThrow( + /exactly one/, + ); + expect(() => + parseReleaseStatement(statement({ subject: [{ digest: { sha256: "AB".repeat(32) } }] })), + ).toThrow(/digest is malformed/); + }); + + it("requires lowercase 96-character measurements", () => { + for (const value of ["AA".repeat(48), "aa".repeat(47), 7, undefined]) { + expect(() => parseReleaseStatement(statement({}, { pcr1: value }))).toThrow( + /measurements are malformed/, + ); + } + }); + + it("requires a positive integer version", () => { + for (const version of [0, -1, 1.5, "3", Number.MAX_SAFE_INTEGER + 1]) { + expect(() => parseReleaseStatement(statement({}, { version }))).toThrow( + /version is malformed/, + ); + } + }); + + it("requires an RFC 3339 UTC expiry", () => { + for (const expiresAt of [ + "2027-01-01T00:00:00+01:00", + "2027-01-01", + "2027-13-01T00:00:00Z", + 1798761600, + ]) { + expect(() => parseReleaseStatement(statement({}, { expiresAt }))).toThrow( + /expiry is malformed/, + ); + } + }); +}); + +describe("assertReleaseMatchesPcrs", () => { + it("accepts exactly the released measurements", () => { + expect(() => assertReleaseMatchesPcrs(released, pcrs)).not.toThrow(); + }); + + it("rejects any differing measurement", () => { + for (const key of ["pcr0", "pcr1", "pcr2"] as const) { + expect(() => + assertReleaseMatchesPcrs({ ...released, [key]: "dd".repeat(48) }, pcrs), + ).toThrow(/do not match/); + } + }); + + it("rejects a debug-mode enclave even when the release names it", () => { + const debug = new Map(pcrs).set(0, new Uint8Array(48)); + expect(() => + assertReleaseMatchesPcrs({ ...released, pcr0: "00".repeat(48) }, debug), + ).toThrow(/debug mode/); + }); + + it("rejects an attestation missing a measurement", () => { + const missing = new Map(pcrs); + missing.delete(2); + expect(() => assertReleaseMatchesPcrs(released, missing)).toThrow(/no PCR2/); + }); +}); diff --git a/src/recovery/nitro/release.ts b/src/recovery/nitro/release.ts new file mode 100644 index 0000000..3210d14 --- /dev/null +++ b/src/recovery/nitro/release.ts @@ -0,0 +1,328 @@ +/** + * Verification of published enclave releases. + * + * Pinning PCR0 in this package means every enclave redeploy breaks every kit + * already shipped. Instead, each release of the enclave is built by GitHub + * Actions in the public `cavos-recovery` repository and attested there with + * Sigstore: a DSSE-signed in-toto statement naming the image's PCR0, PCR1 and + * PCR2, signed with a short-lived Fulcio certificate bound to that workflow + * run, and logged in Rekor. The browser accepts an enclave whose measurements + * such a statement vouches for, so a new release needs no new SDK. + * + * What is trusted is fixed here, not by the host that serves the bundle: the + * Sigstore public-good root, and the workflow identity in the certificate — + * repository, its numeric id and owner id (which survive renames and cannot be + * re-registered), GitHub-hosted runner, a push of a `v` tag, and the release + * workflow at that tag. The bundle arrives from an untrusted host and verifies + * itself; a host can withhold a release but cannot forge one. + */ + +import type { SigstoreBundle, TrustedRoot } from "@freedomofpress/sigstore-browser"; +import trustedRoot from "./sigstoreTrustedRoot.json"; + +/** + * The GitHub Actions workflow whose signed releases this build accepts. + */ +export interface EnclaveReleasePolicy { + /** `owner/name` of the public repository that builds the enclave image. */ + repository: string; + /** Numeric GitHub id of that repository. */ + repositoryId: string; + /** Numeric GitHub id of the repository's owner. */ + ownerId: string; + /** Path of the release workflow inside the repository. */ + workflow: string; + /** Lowest release version accepted; raising it revokes every older release. */ + minVersion: number; +} + +/** + * The workflow identity a release certificate must carry. + * + * `trigger` and `ref` are fixed for Cavos releases; they are parameters only + * so the signature path can be exercised against a real third-party bundle. + */ +export interface ReleaseSignerIdentity { + repository: string; + repositoryId: string; + ownerId: string; + workflow: string; + trigger: string; + ref: RegExp; +} + +/** The contents of a verified release statement. */ +export interface EnclaveRelease { + imageDigest: string; + pcr0: string; + pcr1: string; + pcr2: string; + version: number; + expiresAt: Date; +} + +const BUNDLE_MEDIA_TYPE = "application/vnd.dev.sigstore.bundle.v0.3+json"; +const IN_TOTO_PAYLOAD_TYPE = "application/vnd.in-toto+json"; +const STATEMENT_TYPE = "https://in-toto.io/Statement/v1"; +const RELEASE_PREDICATE_TYPE = "https://cavos.xyz/attestation/enclave-release/v1"; +const GITHUB_OIDC_ISSUER = "https://token.actions.githubusercontent.com"; +const RELEASE_TAG = /^refs\/tags\/v([1-9][0-9]*)$/; +const PCR_HEX = /^[0-9a-f]{96}$/; +const SHA256_HEX = /^[0-9a-f]{64}$/; +const RFC3339_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/; +const STATEMENT_FIELDS = ["_type", "subject", "predicateType", "predicate"]; +const PREDICATE_FIELDS = ["pcr0", "pcr1", "pcr2", "version", "expiresAt"]; + +/** + * Verify that `bundle` is a Cavos enclave release for exactly the attested + * `pcrs`, and return its version. + * + * Throws on any failure; there is no partial success. + */ +export async function verifyEnclaveRelease( + bundle: unknown, + pcrs: Map, + policy: EnclaveReleasePolicy, + now: Date, +): Promise { + if (!Number.isSafeInteger(policy.minVersion) || policy.minVersion < 1) { + throw new Error("kit/nitro: the release policy's minimum version is invalid"); + } + const { payload, ref } = await verifyReleaseSignature(bundle, { + repository: policy.repository, + repositoryId: policy.repositoryId, + ownerId: policy.ownerId, + workflow: policy.workflow, + trigger: "push", + ref: RELEASE_TAG, + }); + const release = parseReleaseStatement(payload); + + const tag = RELEASE_TAG.exec(ref); + if (!tag || tag[1] !== String(release.version)) { + throw new Error("kit/nitro: the release version does not match its signed tag"); + } + if (release.version < policy.minVersion) { + throw new Error("kit/nitro: the enclave release is older than this build accepts"); + } + if (!(release.expiresAt.getTime() > now.getTime())) { + throw new Error("kit/nitro: the enclave release has expired"); + } + assertReleaseMatchesPcrs(release, pcrs); + return release.version; +} + +/** + * Verify the bundle's Sigstore signature, transparency-log inclusion and + * certificate identity, and return the signed statement with the tag it was + * signed from. + * + * The bundled trusted root is tried first. If verification fails for any + * reason other than the certificate identity, it is retried exactly once + * against the root refreshed over TUF, then fails closed. A static root alone + * would break old kits the day Sigstore adds a key — a new Rekor shard or + * Fulcio intermediate — and new releases are logged under it. An identity + * mismatch does not depend on the root, so it is not retried. + */ +export async function verifyReleaseSignature( + bundle: unknown, + identity: ReleaseSignerIdentity, +): Promise<{ payload: Uint8Array; ref: string }> { + assertBundleShape(bundle); + const sigstore = await import("@freedomofpress/sigstore-browser"); + + let signedRef: string | undefined; + const repositoryUrl = `https://github.com/${identity.repository}`; + const policy = new sigstore.AllOf([ + new sigstore.OIDCIssuerV2(GITHUB_OIDC_ISSUER), + new sigstore.OIDCSourceRepositoryURI(repositoryUrl), + new sigstore.OIDCSourceRepositoryIdentifier(identity.repositoryId), + new sigstore.OIDCSourceRepositoryOwnerIdentifier(identity.ownerId), + new sigstore.OIDCRunnerEnvironment("github-hosted"), + new sigstore.OIDCBuildTrigger(identity.trigger), + { + verify(cert) { + const ref = cert.extSourceRepositoryRef?.sourceRepositoryRef; + if (ref === undefined || !identity.ref.test(ref)) { + throw new sigstore.PolicyError("the signing ref is not a release tag"); + } + const buildConfig = cert.extBuildConfigURI?.buildConfigURI; + if (buildConfig !== `${repositoryUrl}/${identity.workflow}@${ref}`) { + throw new sigstore.PolicyError("the signing workflow is not the release workflow"); + } + signedRef = ref; + }, + }, + ]); + + const verify = async (refresh: boolean) => { + const verifier = new sigstore.SigstoreVerifier({ + tlogThreshold: 1, + ctlogThreshold: 1, + tsaThreshold: 0, + }); + if (refresh) { + await verifier.loadSigstoreRootWithTUF(); + } else { + await verifier.loadSigstoreRoot(trustedRoot as unknown as TrustedRoot); + } + return verifier.verifyDsse(bundle as SigstoreBundle, policy); + }; + + let verified: { payloadType: string; payload: Uint8Array }; + try { + try { + verified = await verify(false); + } catch (error) { + if (error instanceof sigstore.PolicyError) throw error; + signedRef = undefined; + verified = await verify(true); + } + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`kit/nitro: the enclave release signature is not valid: ${reason}`); + } + + if (verified.payloadType !== IN_TOTO_PAYLOAD_TYPE) { + throw new Error("kit/nitro: the enclave release is not an in-toto statement"); + } + if (signedRef === undefined) { + throw new Error("kit/nitro: the enclave release identity was not checked"); + } + return { payload: verified.payload, ref: signedRef }; +} + +/** + * Parse a signed release statement, rejecting anything not exactly in the + * expected shape. Pure: it checks content, not signatures. + */ +export function parseReleaseStatement(payload: Uint8Array): EnclaveRelease { + let statement: unknown; + try { + statement = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(payload)); + } catch { + throw new Error("kit/nitro: the release statement is not valid JSON"); + } + if (!isPlainObject(statement)) { + throw new Error("kit/nitro: the release statement is not an object"); + } + if (statement._type !== STATEMENT_TYPE) { + throw new Error("kit/nitro: the release statement is not an in-toto v1 statement"); + } + assertOnlyFields(statement, STATEMENT_FIELDS, "release statement"); + if (statement.predicateType !== RELEASE_PREDICATE_TYPE) { + throw new Error("kit/nitro: the release statement has an unexpected predicate type"); + } + + const subjects = statement.subject; + if (!Array.isArray(subjects) || subjects.length !== 1) { + throw new Error("kit/nitro: the release statement must name exactly one image"); + } + const subject = subjects[0]; + if (!isPlainObject(subject) || !isPlainObject(subject.digest)) { + throw new Error("kit/nitro: the release subject is malformed"); + } + assertOnlyFields(subject, ["name", "digest"], "release subject"); + assertOnlyFields(subject.digest, ["sha256"], "release subject digest"); + if (subject.name !== undefined && typeof subject.name !== "string") { + throw new Error("kit/nitro: the release subject is malformed"); + } + const imageDigest = subject.digest.sha256; + if (typeof imageDigest !== "string" || !SHA256_HEX.test(imageDigest)) { + throw new Error("kit/nitro: the release subject digest is malformed"); + } + + const predicate = statement.predicate; + if (!isPlainObject(predicate)) { + throw new Error("kit/nitro: the release predicate is missing"); + } + assertOnlyFields(predicate, PREDICATE_FIELDS, "release predicate"); + const [pcr0, pcr1, pcr2] = [predicate.pcr0, predicate.pcr1, predicate.pcr2]; + for (const pcr of [pcr0, pcr1, pcr2]) { + if (typeof pcr !== "string" || !PCR_HEX.test(pcr)) { + throw new Error("kit/nitro: the release measurements are malformed"); + } + } + const version = predicate.version; + if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) { + throw new Error("kit/nitro: the release version is malformed"); + } + const expiresAtValue = predicate.expiresAt; + const expiresAt = typeof expiresAtValue === "string" ? new Date(expiresAtValue) : undefined; + if ( + typeof expiresAtValue !== "string" || + !RFC3339_UTC.test(expiresAtValue) || + !expiresAt || + Number.isNaN(expiresAt.getTime()) + ) { + throw new Error("kit/nitro: the release expiry is malformed"); + } + + return { + imageDigest, + pcr0: pcr0 as string, + pcr1: pcr1 as string, + pcr2: pcr2 as string, + version, + expiresAt, + }; +} + +/** + * Require the attested PCR0, PCR1 and PCR2 to be exactly the released ones. + * + * An all-zero PCR0 is what a debug-mode enclave reports; its memory is open to + * the parent instance, so it is never accepted whatever a release says. + */ +export function assertReleaseMatchesPcrs( + release: Pick, + pcrs: Map, +): void { + const attested = [0, 1, 2].map((index) => { + const value = pcrs.get(index); + if (!value || value.length === 0) { + throw new Error(`kit/nitro: the attestation carries no PCR${index}`); + } + return toHex(value); + }); + if (/^0+$/.test(attested[0])) { + throw new Error("kit/nitro: the enclave runs in debug mode"); + } + if ( + attested[0] !== release.pcr0 || + attested[1] !== release.pcr1 || + attested[2] !== release.pcr2 + ) { + throw new Error("kit/nitro: the enclave measurements do not match the release"); + } +} + +function assertBundleShape(bundle: unknown): void { + if (!isPlainObject(bundle) || bundle.mediaType !== BUNDLE_MEDIA_TYPE) { + throw new Error("kit/nitro: the enclave release is not a Sigstore v0.3 bundle"); + } + const material = bundle.verificationMaterial; + if (!isPlainObject(material) || "x509CertificateChain" in material) { + throw new Error("kit/nitro: the enclave release must carry a single certificate"); + } + if (!isPlainObject(bundle.dsseEnvelope)) { + throw new Error("kit/nitro: the enclave release carries no DSSE envelope"); + } +} + +function assertOnlyFields(value: Record, allowed: string[], what: string): void { + const unknown = Object.keys(value).filter((key) => !allowed.includes(key)); + if (unknown.length > 0) { + throw new Error(`kit/nitro: the ${what} has unknown fields: ${unknown.sort().join(", ")}`); + } +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function toHex(bytes: Uint8Array): string { + let out = ""; + for (const byte of bytes) out += byte.toString(16).padStart(2, "0"); + return out; +} diff --git a/src/recovery/nitro/releasePolicy.test.ts b/src/recovery/nitro/releasePolicy.test.ts new file mode 100644 index 0000000..8a7c019 --- /dev/null +++ b/src/recovery/nitro/releasePolicy.test.ts @@ -0,0 +1,154 @@ +import { verifyEnclaveRelease, type EnclaveReleasePolicy } from "./release"; + +/** + * The checks `verifyEnclaveRelease` makes on top of a valid signature, against + * the real identity policy classes. Only `SigstoreVerifier` is replaced: it + * runs the policy against a certificate carrying the claims below and returns + * the statement, standing in for a bundle Cavos has not published yet. + */ + +const signed: { claims: Record; statement: unknown } = { + claims: {}, + statement: undefined, +}; + +jest.mock("@freedomofpress/sigstore-browser", () => { + const actual = jest.requireActual("@freedomofpress/sigstore-browser"); + class FakeVerifier { + async loadSigstoreRoot() {} + async loadSigstoreRootWithTUF() {} + async verifyDsse(_bundle: unknown, policy: { verify(cert: unknown): Promise }) { + const c = signed.claims; + await policy.verify({ + extFulcioIssuerV2: { issuer: c.issuer }, + extSourceRepositoryURI: { sourceRepositoryURI: c.repositoryUri }, + extSourceRepositoryIdentifier: { sourceRepositoryIdentifier: c.repositoryId }, + extSourceRepositoryOwnerIdentifier: { sourceRepositoryOwnerIdentifier: c.ownerId }, + extRunnerEnvironment: { runnerEnvironment: c.runner }, + extBuildTrigger: { buildTrigger: c.trigger }, + extSourceRepositoryRef: { sourceRepositoryRef: c.ref }, + extBuildConfigURI: { buildConfigURI: c.buildConfig }, + }); + return { + payloadType: "application/vnd.in-toto+json", + payload: new TextEncoder().encode(JSON.stringify(signed.statement)), + }; + } + } + return { ...actual, SigstoreVerifier: FakeVerifier }; +}); + +const policy: EnclaveReleasePolicy = { + repository: "cavos-labs/cavos-recovery", + repositoryId: "1333734222", + ownerId: "216094156", + workflow: ".github/workflows/release.yml", + minVersion: 2, +}; + +const pcrs = new Map([ + [0, new Uint8Array(48).fill(0xaa)], + [1, new Uint8Array(48).fill(0xbb)], + [2, new Uint8Array(48).fill(0xcc)], +]); +const now = new Date("2026-10-28T00:00:00Z"); +const bundle = { + mediaType: "application/vnd.dev.sigstore.bundle.v0.3+json", + verificationMaterial: { certificate: { rawBytes: "" } }, + dsseEnvelope: {}, +}; + +function release(version: number, ref = `refs/tags/v${version}`, predicate: object = {}) { + signed.claims = { + issuer: "https://token.actions.githubusercontent.com", + repositoryUri: "https://github.com/cavos-labs/cavos-recovery", + repositoryId: "1333734222", + ownerId: "216094156", + runner: "github-hosted", + trigger: "push", + ref, + buildConfig: `https://github.com/cavos-labs/cavos-recovery/.github/workflows/release.yml@${ref}`, + }; + signed.statement = { + _type: "https://in-toto.io/Statement/v1", + subject: [{ name: "enclave.eif", digest: { sha256: "ab".repeat(32) } }], + predicateType: "https://cavos.xyz/attestation/enclave-release/v1", + predicate: { + pcr0: "aa".repeat(48), + pcr1: "bb".repeat(48), + pcr2: "cc".repeat(48), + version, + expiresAt: "2026-12-01T00:00:00Z", + ...predicate, + }, + }; +} + +describe("verifyEnclaveRelease policy", () => { + it("accepts a release signed from its own tag and returns its version", async () => { + release(3); + await expect(verifyEnclaveRelease(bundle, pcrs, policy, now)).resolves.toBe(3); + }); + + it("rejects each identity claim that differs from the Cavos release workflow", async () => { + const claims: Record = { + issuer: "https://accounts.google.com", + repositoryUri: "https://github.com/cavos-labs/other", + repositoryId: "1", + ownerId: "1", + runner: "self-hosted", + trigger: "workflow_dispatch", + buildConfig: + "https://github.com/cavos-labs/cavos-recovery/.github/workflows/other.yml@refs/tags/v3", + }; + for (const [claim, value] of Object.entries(claims)) { + release(3); + signed.claims[claim] = value; + await expect(verifyEnclaveRelease(bundle, pcrs, policy, now)).rejects.toThrow( + /signature is not valid/, + ); + } + }); + + it("rejects refs that are not a v release tag", async () => { + for (const ref of ["refs/heads/main", "refs/tags/v03", "refs/tags/v3-rc1", "refs/tags/3"]) { + release(3, ref); + await expect(verifyEnclaveRelease(bundle, pcrs, policy, now)).rejects.toThrow( + /not a release tag/, + ); + } + }); + + it("rejects a version that differs from the signed tag", async () => { + release(4, "refs/tags/v3"); + await expect(verifyEnclaveRelease(bundle, pcrs, policy, now)).rejects.toThrow( + /does not match its signed tag/, + ); + }); + + it("rejects a release older than the minimum version", async () => { + release(1); + await expect(verifyEnclaveRelease(bundle, pcrs, policy, now)).rejects.toThrow(/older/); + }); + + it("rejects an invalid minimum version in the policy", async () => { + release(3); + for (const minVersion of [0, NaN, 1.5]) { + await expect( + verifyEnclaveRelease(bundle, pcrs, { ...policy, minVersion }, now), + ).rejects.toThrow(/minimum version is invalid/); + } + }); + + it("rejects an expired release", async () => { + release(3, undefined, { expiresAt: "2026-10-28T00:00:00Z" }); + await expect(verifyEnclaveRelease(bundle, pcrs, policy, now)).rejects.toThrow(/expired/); + }); + + it("rejects a release for different measurements", async () => { + release(3, undefined, { pcr2: "dd".repeat(48) }); + await expect(verifyEnclaveRelease(bundle, pcrs, policy, now)).rejects.toThrow( + /do not match the release/, + ); + }); +}); diff --git a/src/recovery/nitro/sigstoreTrustedRoot.json b/src/recovery/nitro/sigstoreTrustedRoot.json new file mode 100644 index 0000000..effb0a1 --- /dev/null +++ b/src/recovery/nitro/sigstoreTrustedRoot.json @@ -0,0 +1,126 @@ +{ + "mediaType": "application/vnd.dev.sigstore.trustedroot+json;version=0.1", + "tlogs": [ + { + "baseUrl": "https://rekor.sigstore.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2021-01-12T11:53:27Z" + } + }, + "logId": { + "keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=" + } + }, + { + "baseUrl": "https://log2025-1.rekor.sigstore.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MCowBQYDK2VwAyEAt8rlp1knGwjfbcXAYPYAkn0XiLz1x8O4t0YkEhie244=", + "keyDetails": "PKIX_ED25519", + "validFor": { + "start": "2025-09-23T00:00:00Z" + } + }, + "logId": { + "keyId": "zxGZFVvd0FEmjR8WrFwMdcAJ9vtaY/QXf44Y1wUeP6A=" + } + } + ], + "certificateAuthorities": [ + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore" + }, + "uri": "https://fulcio.sigstore.dev", + "certChain": { + "certificates": [ + { + "rawBytes": "MIIB+DCCAX6gAwIBAgITNVkDZoCiofPDsy7dfm6geLbuhzAKBggqhkjOPQQDAzAqMRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxETAPBgNVBAMTCHNpZ3N0b3JlMB4XDTIxMDMwNzAzMjAyOVoXDTMxMDIyMzAzMjAyOVowKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLSyA7Ii5k+pNO8ZEWY0ylemWDowOkNa3kL+GZE5Z5GWehL9/A9bRNA3RbrsZ5i0JcastaRL7Sp5fp/jD5dxqc/UdTVnlvS16an+2Yfswe/QuLolRUCrcOE2+2iA5+tzd6NmMGQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYEFMjFHQBBmiQpMlEk6w2uSu1KBtPsMB8GA1UdIwQYMBaAFMjFHQBBmiQpMlEk6w2uSu1KBtPsMAoGCCqGSM49BAMDA2gAMGUCMH8liWJfMui6vXXBhjDgY4MwslmN/TJxVe/83WrFomwmNf056y1X48F9c4m3a3ozXAIxAKjRay5/aj/jsKKGIkmQatjI8uupHr/+CxFvaJWmpYqNkLDGRU+9orzh5hI2RrcuaQ==" + } + ] + }, + "validFor": { + "start": "2021-03-07T03:20:29Z", + "end": "2022-12-31T23:59:59.999Z" + } + }, + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore" + }, + "uri": "https://fulcio.sigstore.dev", + "certChain": { + "certificates": [ + { + "rawBytes": "MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV77LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjpKFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZIzj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJRnZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsPmygUY7Ii2zbdCdliiow=" + }, + { + "rawBytes": "MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxexX69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92jYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRYwB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQKsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCMWP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ" + } + ] + }, + "validFor": { + "start": "2022-04-13T20:06:15Z" + } + } + ], + "ctlogs": [ + { + "baseUrl": "https://ctfe.sigstore.dev/test", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbfwR+RJudXscgRBRpKX1XFDy3PyudDxz/SfnRi1fT8ekpfBd2O1uoz7jr3Z8nKzxA69EUQ+eFCFI3zeubPWU7w==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2021-03-14T00:00:00Z", + "end": "2022-10-31T23:59:59.999Z" + } + }, + "logId": { + "keyId": "CGCS8ChS/2hF0dFrJ4ScRWcYrBY9wzjSbea8IgY2b3I=" + } + }, + { + "baseUrl": "https://ctfe.sigstore.dev/2022", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEiPSlFi0CmFTfEjCUqF9HuCEcYXNKAaYalIJmBZ8yyezPjTqhxrKBpMnaocVtLJBI1eM3uXnQzQGAJdJ4gs9Fyw==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2022-10-20T00:00:00Z" + } + }, + "logId": { + "keyId": "3T0wasbHETJjGR4cmWc3AqJKXrjePK3/h4pygC8p7o4=" + } + } + ], + "timestampAuthorities": [ + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore-tsa-selfsigned" + }, + "uri": "https://timestamp.sigstore.dev/api/v1/timestamp", + "certChain": { + "certificates": [ + { + "rawBytes": "MIICEDCCAZagAwIBAgIUOhNULwyQYe68wUMvy4qOiyojiwwwCgYIKoZIzj0EAwMwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZDAeFw0yNTA0MDgwNjU5NDNaFw0zNTA0MDYwNjU5NDNaMC4xFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEVMBMGA1UEAxMMc2lnc3RvcmUtdHNhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE4ra2Z8hKNig2T9kFjCAToGG30jky+WQv3BzL+mKvh1SKNR/UwuwsfNCg4sryoYAd8E6isovVA3M4aoNdm9QDi50Z8nTEyvqgfDPtTIwXItfiW/AFf1V7uwkbkAoj0xxco2owaDAOBgNVHQ8BAf8EBAMCB4AwHQYDVR0OBBYEFIn9eUOHz9BlRsMCRscsc1t9tOsDMB8GA1UdIwQYMBaAFJjsAe9/u1H/1JUeb4qImFMHic6/MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMIMAoGCCqGSM49BAMDA2gAMGUCMDtpsV/6KaO0qyF/UMsX2aSUXKQFdoGTptQGc0ftq1csulHPGG6dsmyMNd3JB+G3EQIxAOajvBcjpJmKb4Nv+2Taoj8Uc5+b6ih6FXCCKraSqupe07zqswMcXJTe1cExvHvvlw==" + }, + { + "rawBytes": "MIIB9zCCAXygAwIBAgIUV7f0GLDOoEzIh8LXSW80OJiUp14wCgYIKoZIzj0EAwMwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZDAeFw0yNTA0MDgwNjU5NDNaFw0zNTA0MDYwNjU5NDNaMDkxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEgMB4GA1UEAxMXc2lnc3RvcmUtdHNhLXNlbGZzaWduZWQwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQUQNtfRT/ou3YATa6wB/kKTe70cfJwyRIBovMnt8RcJph/COE82uyS6FmppLLL1VBPGcPfpQPYJNXzWwi8icwhKQ6W/Qe2h3oebBb2FHpwNJDqo+TMaC/tdfkv/ElJB72jRTBDMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSY7AHvf7tR/9SVHm+KiJhTB4nOvzAKBggqhkjOPQQDAwNpADBmAjEAwGEGrfGZR1cen1R8/DTVMI943LssZmJRtDp/i7SfGHmGRP6gRbuj9vOK3b67Z0QQAjEAuT2H673LQEaHTcyQSZrkp4mX7WwkmF+sVbkYY5mXN+RMH13KUEHHOqASaemYWK/E" + } + ] + }, + "validFor": { + "start": "2025-07-04T00:00:00Z" + } + } + ] +}