269 lines
9.7 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|