From a7e08af9b5c6760bc053ac25590356b4c0e0c39e Mon Sep 17 00:00:00 2001 From: Brian DeHamer Date: Thu, 8 Aug 2024 15:10:57 -0700 Subject: [PATCH] remove hard-coded issuer from JWT verification Signed-off-by: Brian DeHamer --- packages/attest/__tests__/oidc.test.ts | 14 ++++----- packages/attest/__tests__/provenance.test.ts | 2 +- packages/attest/src/oidc.ts | 32 ++++++++++++++------ packages/attest/src/provenance.ts | 2 +- 4 files changed, 31 insertions(+), 19 deletions(-) diff --git a/packages/attest/__tests__/oidc.test.ts b/packages/attest/__tests__/oidc.test.ts index 69ffa340..4bbf2ab8 100644 --- a/packages/attest/__tests__/oidc.test.ts +++ b/packages/attest/__tests__/oidc.test.ts @@ -4,7 +4,7 @@ import {getIDTokenClaims} from '../src/oidc' describe('getIDTokenClaims', () => { const originalEnv = process.env - const issuer = 'https://example.com' + const issuer = 'https://token.actions.example.ghe.com' const audience = 'nobody' const requestToken = 'token' const openidConfigPath = '/.well-known/openid-configuration' @@ -63,7 +63,7 @@ describe('getIDTokenClaims', () => { }) it('returns the ID token claims', async () => { - const result = await getIDTokenClaims(issuer) + const result = await getIDTokenClaims() expect(result).toEqual(claims) }) }) @@ -83,7 +83,7 @@ describe('getIDTokenClaims', () => { }) it('throws an error', async () => { - await expect(getIDTokenClaims(issuer)).rejects.toThrow(/missing claims/i) + await expect(getIDTokenClaims()).rejects.toThrow(/missing claims/i) }) }) @@ -99,7 +99,7 @@ describe('getIDTokenClaims', () => { }) it('throws an error', async () => { - await expect(getIDTokenClaims(issuer)).rejects.toThrow(/unexpected "iss"/) + await expect(getIDTokenClaims()).rejects.toThrow(/issuer mismatch/i) }) }) @@ -115,7 +115,7 @@ describe('getIDTokenClaims', () => { }) it('throw an error', async () => { - await expect(getIDTokenClaims(issuer)).rejects.toThrow(/unexpected "aud"/) + await expect(getIDTokenClaims()).rejects.toThrow(/verification failed/i) }) }) @@ -140,9 +140,7 @@ describe('getIDTokenClaims', () => { }) it('throws an error', async () => { - await expect(getIDTokenClaims(issuer)).rejects.toThrow( - /failed to get id/i - ) + await expect(getIDTokenClaims()).rejects.toThrow(/failed to get id/i) }) }) }) diff --git a/packages/attest/__tests__/provenance.test.ts b/packages/attest/__tests__/provenance.test.ts index 3d61fff9..22e79ca1 100644 --- a/packages/attest/__tests__/provenance.test.ts +++ b/packages/attest/__tests__/provenance.test.ts @@ -8,7 +8,7 @@ import {attestProvenance, buildSLSAProvenancePredicate} from '../src/provenance' describe('provenance functions', () => { const originalEnv = process.env - const issuer = 'https://example.com' + const issuer = 'https://token.actions.githubusercontent.com' const audience = 'nobody' const jwksPath = '/.well-known/jwks.json' const tokenPath = '/token' diff --git a/packages/attest/src/oidc.ts b/packages/attest/src/oidc.ts index 69e18d97..31374674 100644 --- a/packages/attest/src/oidc.ts +++ b/packages/attest/src/oidc.ts @@ -2,6 +2,11 @@ import {getIDToken} from '@actions/core' import {HttpClient} from '@actions/http-client' import * as jose from 'jose' +const VALID_ISSUERS = [ + 'https://token.actions.githubusercontent.com', + new RegExp('^https://token\\.actions\\.[a-z0-9-]+\\.ghe\\.com$') +] as const + const OIDC_AUDIENCE = 'nobody' const REQUIRED_CLAIMS = [ @@ -25,10 +30,10 @@ type OIDCConfig = { jwks_uri: string } -export const getIDTokenClaims = async (issuer: string): Promise => { +export const getIDTokenClaims = async (): Promise => { try { const token = await getIDToken(OIDC_AUDIENCE) - const claims = await decodeOIDCToken(token, issuer) + const claims = await decodeOIDCToken(token) assertClaimSet(claims) return claims } catch (error) { @@ -36,15 +41,24 @@ export const getIDTokenClaims = async (issuer: string): Promise => { } } -const decodeOIDCToken = async ( - token: string, - issuer: string -): Promise => { +const decodeOIDCToken = async (token: string): Promise => { + // Decode is an unsafe operation (no signature verification) but we're + // verifying the signature below after retrieving the issuer. + const {iss} = jose.decodeJwt(token) + + if (!iss) { + throw new Error('No issuer found') + } + + // Issuer must match at least one of the valid issuers + if (!VALID_ISSUERS.some(allowed => iss.match(allowed))) { + throw new Error('Issuer mismatch') + } + // Verify and decode token - const jwks = jose.createLocalJWKSet(await getJWKS(issuer)) + const jwks = jose.createLocalJWKSet(await getJWKS(iss)) const {payload} = await jose.jwtVerify(token, jwks, { - audience: OIDC_AUDIENCE, - issuer + audience: OIDC_AUDIENCE }) return payload diff --git a/packages/attest/src/provenance.ts b/packages/attest/src/provenance.ts index 0ef89e01..035cab3c 100644 --- a/packages/attest/src/provenance.ts +++ b/packages/attest/src/provenance.ts @@ -27,7 +27,7 @@ export const buildSLSAProvenancePredicate = async ( issuer: string = DEFAULT_ISSUER ): Promise => { const serverURL = process.env.GITHUB_SERVER_URL - const claims = await getIDTokenClaims(issuer) + const claims = await getIDTokenClaims() // Split just the path and ref from the workflow string. // owner/repo/.github/workflows/main.yml@main =>