/** * Step-4 DCAP quote verifiers. * * Verifying a DCAP quote (ECDSA signature + PCK certificate chain + TCB / * revocation) is intentionally *not* done here — it must not be hand-rolled. * These adapters delegate that step to a remote appraisal service or to a * user-supplied callable, and return the trusted parsed values. * * Port of Python's CallableQuoteVerifier / JwtQuoteVerifier. */ import { QuoteVerifier, VerifiedQuote } from '../../types/attestation'; import { AttestationError } from '../../errors'; import { createHttpClient, HttpClient } from '../http/client'; import { arrayBufferToBase64, arrayBufferToString, hexToBytes } from '../crypto/utils'; /** A user callable may be sync or async and returns a VerifiedQuote. */ export type VerifierCallable = (quote: Uint8Array) => VerifiedQuote | Promise; /** * Adapt a (sync or async) callable into a QuoteVerifier. */ export class CallableQuoteVerifier implements QuoteVerifier { constructor(private readonly func: VerifierCallable) {} async verify(quote: Uint8Array): Promise { const result = await this.func(quote); if (!isVerifiedQuote(result)) { throw new AttestationError('quote verifier callable must return a VerifiedQuote'); } return result; } } /** * Structural check — the JS port has no dataclass to `instanceof` against, so * validate the shape the rest of the pipeline depends on. */ function isVerifiedQuote(value: unknown): value is VerifiedQuote { if (typeof value !== 'object' || value === null) return false; const v = value as Record; return ( typeof v.mrenclave === 'string' && typeof v.mrsigner === 'string' && typeof v.isvProdId === 'number' && typeof v.isvSvn === 'number' && typeof v.tcbStatus === 'string' && v.reportData instanceof Uint8Array ); } /** * Default mapping from VerifiedQuote field to JWT claim name. Concrete services * differ (Intel Trust Authority vs Veraison vs a custom QVL wrapper), so this is * overridable per JwtQuoteVerifier instance. */ export const DEFAULT_CLAIM_MAP: Record = { mrenclave: 'sgx_mrenclave', mrsigner: 'sgx_mrsigner', isvProdId: 'sgx_isvprodid', isvSvn: 'sgx_isvsvn', tcbStatus: 'tcb_status', reportData: 'sgx_report_data', }; export interface JwtQuoteVerifierOptions { /** Expected JWT issuer */ issuer?: string; /** Expected JWT audience */ audience?: string; /** Override the claim-name mapping for a non-standard service */ claimMap?: Partial>; /** Accepted JWS algorithms (default: RS256, ES256, ES384) */ algorithms?: string[]; /** Request timeout in milliseconds (default: 30000) */ timeout?: number; } /** Minimal surface of the optional `jose` dependency that we rely on. */ interface JoseModule { createRemoteJWKSet(url: URL): unknown; jwtVerify( token: string, key: unknown, options: { issuer?: string; audience?: string; algorithms?: string[] } ): Promise<{ payload: Record }>; } let joseModule: JoseModule | null = null; const JOSE_MISSING = "JwtQuoteVerifier requires the 'jose' package. Install it alongside nomyo-js: npm install jose"; /** * Load the optional `jose` package. * * Resolution is checked first so a genuinely missing package reports cleanly. * Only when `jose` IS installed but cannot be require()d — v5+ is ESM-only — do * we fall back to a dynamic import. The `new Function` wrapper stops TypeScript * rewriting `import()` back into `require()` for CommonJS builds. */ async function requireJose(): Promise { if (joseModule) return joseModule; if (typeof require === 'undefined' || typeof require.resolve !== 'function') { throw new AttestationError(JOSE_MISSING); } try { require.resolve('jose'); } catch (_notInstalled) { throw new AttestationError(JOSE_MISSING); } try { // eslint-disable-next-line @typescript-eslint/no-var-requires joseModule = require('jose') as JoseModule; return joseModule; } catch (_cjsError) { try { const dynamicImport = new Function('s', 'return import(s)') as (s: string) => Promise; joseModule = await dynamicImport('jose'); return joseModule; } catch (esmError) { throw new AttestationError( `could not load the 'jose' package: ${esmError instanceof Error ? esmError.message : 'unknown error'}` ); } } } /** * Verify a quote via a remote service that returns a signed JWT (spec §4A). * * POSTs the base64 quote to `verifyUrl`; the service replies with a JWT whose * signature is validated against `jwksUrl` (and optional issuer/audience). The * validated claims are mapped to a VerifiedQuote. * * Requires the optional `jose` dependency: `npm install jose` * * Note: unlike the Python client there is no `verify_ssl` escape hatch — TLS * certificate verification against the appraisal service is always enforced. */ export class JwtQuoteVerifier implements QuoteVerifier { private readonly issuer?: string; private readonly audience?: string; private readonly claimMap: Record; private readonly algorithms: string[]; private readonly timeout: number; private readonly httpClient: HttpClient; constructor( private readonly verifyUrl: string, private readonly jwksUrl: string, options: JwtQuoteVerifierOptions = {} ) { const { issuer, audience, claimMap, algorithms, timeout = 30000 } = options; this.issuer = issuer; this.audience = audience; this.claimMap = { ...DEFAULT_CLAIM_MAP, ...claimMap }; this.algorithms = algorithms ?? ['RS256', 'ES256', 'ES384']; this.timeout = timeout; this.httpClient = createHttpClient(); } async verify(quote: Uint8Array): Promise { const jose = await requireJose(); let response: { statusCode: number; body: ArrayBuffer }; try { response = await this.httpClient.post(this.verifyUrl, { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ quote: arrayBufferToBase64(quote.buffer.slice( quote.byteOffset, quote.byteOffset + quote.byteLength ) as ArrayBuffer), }), timeout: this.timeout, }); } catch (error) { throw new AttestationError( `could not reach quote verification service: ${error instanceof Error ? error.message : 'unknown error'}` ); } if (response.statusCode !== 200) { throw new AttestationError( `quote verification service returned HTTP ${response.statusCode}` ); } let token: unknown; try { const parsed = JSON.parse(arrayBufferToString(response.body)) as Record; token = parsed.token; } catch (_error) { throw new AttestationError("verifier response missing JWT 'token'"); } if (typeof token !== 'string' || token.length === 0) { throw new AttestationError("verifier response missing JWT 'token'"); } // Validate the JWT signature against the service's published JWKS. let claims: Record; try { const jwks = jose.createRemoteJWKSet(new URL(this.jwksUrl)); const result = await jose.jwtVerify(token, jwks, { ...(this.issuer !== undefined && { issuer: this.issuer }), ...(this.audience !== undefined && { audience: this.audience }), algorithms: this.algorithms, }); claims = result.payload; } catch (error) { throw new AttestationError( `JWT validation failed: ${error instanceof Error ? error.message : 'unknown error'}` ); } return this.claimsToVerifiedQuote(claims); } private claimsToVerifiedQuote(claims: Record): VerifiedQuote { const cm = this.claimMap; const required = (field: keyof VerifiedQuote): unknown => { const value = claims[cm[field]]; if (value === undefined || value === null) { throw new AttestationError( `verifier JWT missing/invalid expected claim: '${cm[field]}'` ); } return value; }; const toInt = (field: keyof VerifiedQuote): number => { const value = required(field); const parsed = typeof value === 'number' ? value : Number(value); if (!Number.isInteger(parsed)) { throw new AttestationError( `verifier JWT missing/invalid expected claim: '${cm[field]}' is not an integer` ); } return parsed; }; let reportData: Uint8Array; try { reportData = hexToBytes(String(required('reportData'))); } catch (error) { throw new AttestationError( `verifier JWT missing/invalid expected claim: '${cm.reportData}' is not valid hex` ); } return { mrenclave: String(required('mrenclave')).toLowerCase(), mrsigner: String(required('mrsigner')).toLowerCase(), isvProdId: toInt('isvProdId'), isvSvn: toInt('isvSvn'), tcbStatus: String(required('tcbStatus')), reportData, }; } }