nomyo-js/src/core/attestation/verifiers.ts
2026-07-19 10:49:59 +02:00

269 lines
9.7 KiB
TypeScript

/**
* 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<VerifiedQuote>;
/**
* Adapt a (sync or async) callable into a QuoteVerifier.
*/
export class CallableQuoteVerifier implements QuoteVerifier {
constructor(private readonly func: VerifierCallable) {}
async verify(quote: Uint8Array): Promise<VerifiedQuote> {
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<string, unknown>;
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<keyof VerifiedQuote, string> = {
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<Record<keyof VerifiedQuote, string>>;
/** 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<string, unknown> }>;
}
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<JoseModule> {
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>;
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<keyof VerifiedQuote, string>;
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<VerifiedQuote> {
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<string, unknown>;
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<string, unknown>;
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<string, unknown>): 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,
};
}
}