diff --git a/src/api/SecureChatCompletion.ts b/src/api/SecureChatCompletion.ts index 0f18018..2f2a57b 100644 --- a/src/api/SecureChatCompletion.ts +++ b/src/api/SecureChatCompletion.ts @@ -26,6 +26,8 @@ export class SecureChatCompletion { keyRotationPassword, maxRetries, keyDir, + attestationPolicy, + quoteVerifier, } = config; this._config = config; @@ -41,6 +43,8 @@ export class SecureChatCompletion { ...(keyRotationPassword !== undefined && { keyRotationPassword }), ...(maxRetries !== undefined && { maxRetries }), ...(keyDir !== undefined && { keyDir }), + ...(attestationPolicy !== undefined && { attestationPolicy }), + ...(quoteVerifier !== undefined && { quoteVerifier }), }); } diff --git a/src/core/SecureCompletionClient.ts b/src/core/SecureCompletionClient.ts index 1ed7872..6af5b8e 100644 --- a/src/core/SecureCompletionClient.ts +++ b/src/core/SecureCompletionClient.ts @@ -11,6 +11,7 @@ import { AESEncryption } from './crypto/encryption'; import { RSAOperations } from './crypto/rsa'; import { createHttpClient, HttpClient } from './http/client'; import { createSecureMemory, SecureByteContext } from './memory/secure'; +import { SgxAttestationVerifier } from './attestation/SgxAttestationVerifier'; import { SecurityError, APIConnectionError, @@ -76,6 +77,12 @@ export class SecureCompletionClient { private readonly keyDir: string; private _isHttps: boolean = true; + /** + * Only constructed when an attestation policy is supplied, so existing + * callers get byte-for-byte the previous behaviour. + */ + private readonly attestation?: SgxAttestationVerifier; + // Promise-based mutex: serialises concurrent ensureKeys() calls private ensureKeysLock: Promise = Promise.resolve(); @@ -92,6 +99,8 @@ export class SecureCompletionClient { keyRotationPassword, maxRetries = 2, keyDir = 'client_keys', + attestationPolicy, + quoteVerifier, } = config; this.debugMode = debug; @@ -127,6 +136,19 @@ export class SecureCompletionClient { } } + // Optional SGX attestation: only construct the verifier when a policy is + // provided, so existing callers see unchanged behaviour. + if (attestationPolicy !== undefined) { + this.attestation = new SgxAttestationVerifier({ + routerUrl: this.routerUrl, + policy: attestationPolicy, + verifier: quoteVerifier, + allowHttp, + timeout: this.requestTimeout, + debug: this.debugMode, + }); + } + // Initialize components this.keyManager = new KeyManager(this.debugMode); this.aes = new AESEncryption(); @@ -338,8 +360,13 @@ export class SecureCompletionClient { * - nonce: 12-byte GCM nonce * - tag: 16-byte GCM auth tag (split from Web Crypto output) * - encrypted_aes_key: AES key encrypted with server's RSA public key + * + * @param serverPublicKeyPem Optional pre-fetched server public key PEM. When + * provided the payload is encrypted to exactly this key (used after SGX + * attestation so we encrypt to the attested key, with no re-fetch); + * otherwise the key is fetched during encryption. */ - async encryptPayload(payload: object): Promise { + async encryptPayload(payload: object, serverPublicKeyPem?: string): Promise { this.assertNotDisposed(); if (!payload || typeof payload !== 'object') { @@ -361,10 +388,10 @@ export class SecureCompletionClient { if (this.secureMemory) { const context = new SecureByteContext(payloadBytes, true); return await context.use(async (protectedPayload) => { - return await this.performEncryption(protectedPayload); + return await this.performEncryption(protectedPayload, serverPublicKeyPem); }); } else { - return await this.performEncryption(payloadBytes); + return await this.performEncryption(payloadBytes, serverPublicKeyPem); } } @@ -375,7 +402,10 @@ export class SecureCompletionClient { * We split the tag out to match Python's cryptography library format * which sends ciphertext and tag as separate fields. */ - private async performEncryption(payloadBytes: ArrayBuffer): Promise { + private async performEncryption( + payloadBytes: ArrayBuffer, + serverPublicKeyPem?: string + ): Promise { const aesKey = await this.aes.generateKey(); const aesKeyBytes = await this.aes.exportKey(aesKey); @@ -390,8 +420,8 @@ export class SecureCompletionClient { const ciphertextOnly = ciphertextBytes.slice(0, ciphertextBytes.length - TAG_LENGTH); const tag = ciphertextBytes.slice(ciphertextBytes.length - TAG_LENGTH); - const serverPublicKeyPem = await this.fetchServerPublicKey(); - const serverPublicKey = await this.rsa.importPublicKey(serverPublicKeyPem); + const keyPem = serverPublicKeyPem ?? await this.fetchServerPublicKey(); + const serverPublicKey = await this.rsa.importPublicKey(keyPem); const encryptedAesKey = await this.rsa.encryptKey(protectedAesKey, serverPublicKey); const encryptedPackage = { @@ -570,6 +600,17 @@ export class SecureCompletionClient { await this.ensureKeys(); + // Step 0: SGX attestation handshake (when enabled). Fetch the server + // public key once, verify the enclave + key binding BEFORE building any + // plaintext, then encrypt to exactly that attested key. In enforce mode a + // failure throws here, so plaintext is never constructed or sent. + let serverPublicKeyPem: string | undefined; + if (this.attestation !== undefined) { + serverPublicKeyPem = await this.fetchServerPublicKey(); + const pubkeyDer = await this.spkiDerFromPem(serverPublicKeyPem); + await this.attestation.ensureVerified(pubkeyDer, securityTier); + } + const publicKeyPem = await this.keyManager.getPublicKeyPEM(); const headers: Record = { 'X-Payload-ID': payloadId, @@ -607,7 +648,7 @@ export class SecureCompletionClient { // Re-encrypt each attempt (throws non-retryable errors like SecurityError // or DisposedError — let those propagate immediately) - const encryptedPayload = await this.encryptPayload(payload); + const encryptedPayload = await this.encryptPayload(payload, serverPublicKeyPem); let response: { statusCode: number; body: ArrayBuffer }; try { @@ -722,6 +763,19 @@ export class SecureCompletionClient { } } + /** + * Return the X.509 SubjectPublicKeyInfo DER bytes for the server public key. + * + * This is exactly the value the SGX report_data binding is computed over + * (SHA-512 of these bytes). We re-serialize via the parsed key (rather than + * base64-decoding the PEM body directly) so the bytes are canonical SPKI DER + * regardless of PEM wrapping. + */ + private async spkiDerFromPem(pubkeyPem: string): Promise { + const key = await this.rsa.importPublicKey(pubkeyPem); + return await this.rsa.exportPublicKeyDer(key); + } + /** * Validate RSA key size (minimum 2048 bits) */ diff --git a/src/core/attestation/SgxAttestationVerifier.ts b/src/core/attestation/SgxAttestationVerifier.ts new file mode 100644 index 0000000..a2bae40 --- /dev/null +++ b/src/core/attestation/SgxAttestationVerifier.ts @@ -0,0 +1,269 @@ +/** + * SGX DCAP attestation orchestration. + * + * Before sending any plaintext to a `high`/`maximum` tier endpoint, prove that + * the RSA public key the client is about to encrypt to was generated *inside* a + * genuine Intel SGX enclave running our measured code. + * + * The single most important rule (spec §1): verify the quote and the key binding + * BEFORE transmitting plaintext. A failed verification must block the request in + * `enforce` mode, with no plaintext fallback. + * + * Port of Python's SgxAttestationVerifier. + */ + +import { QuoteVerifier, VerifiedQuote, REPORT_DATA_LEN } from '../../types/attestation'; +import { AttestationPolicy } from './policy'; +import { AttestationError } from '../../errors'; +import { createHttpClient, HttpClient } from '../http/client'; +import { + arrayBufferToString, + base64ToArrayBuffer, + bytesToHex, + constantTimeEqual, + generateRandomBytes, + sha256, + sha512, +} from '../crypto/utils'; + +export interface SgxAttestationVerifierOptions { + /** Base URL of the NOMYO router */ + routerUrl: string; + + /** Policy controlling what counts as an acceptable enclave */ + policy: AttestationPolicy; + + /** Step-4 DCAP quote verifier. Without one, quotes are unverifiable. */ + verifier?: QuoteVerifier; + + /** Allow fetching attestation over plain HTTP (local development only) */ + allowHttp?: boolean; + + /** Request timeout in milliseconds (default: 900000) */ + timeout?: number; + + /** Emit verbose progress logs */ + debug?: boolean; +} + +/** + * Runs the §3 verification algorithm around a QuoteVerifier. + * + * One instance per SecureCompletionClient. Caches the (pubkey -> verified) + * result per server for the session to avoid re-verifying on every call, and + * re-verifies if the server pubkey changes. Liveness mode bypasses the cache. + */ +export class SgxAttestationVerifier { + private readonly routerUrl: string; + private readonly policy: AttestationPolicy; + private readonly verifier?: QuoteVerifier; + private readonly allowHttp: boolean; + private readonly timeout: number; + private readonly debug: boolean; + private readonly httpClient: HttpClient; + + /** Hex sha256(pubkey_der) values that verified OK this session. */ + private readonly verifiedKeys = new Set(); + + constructor(options: SgxAttestationVerifierOptions) { + const { routerUrl, policy, verifier, allowHttp = false, timeout = 900000, debug = false } = options; + + this.routerUrl = routerUrl.replace(/\/$/, ''); + this.policy = policy; + this.verifier = verifier; + this.allowHttp = allowHttp; + this.timeout = timeout; + this.debug = debug; + this.httpClient = createHttpClient(); + } + + /** + * Run the §3 algorithm for the given server public key + tier. + * + * Returns the VerifiedQuote on success, or null when attestation is not + * required for the tier, when a cached verification covers this key, or when + * it failed but `enforce` is off. + * + * @throws AttestationError when verification fails and `policy.enforce` is + * true — in which case the caller MUST NOT send plaintext. + */ + async ensureVerified( + pubkeyDer: ArrayBuffer, + securityTier?: string + ): Promise { + const tier = securityTier ?? 'standard'; + if (!this.policy.requireAttestationForTier.has(tier)) { + // e.g. standard tier: attestation not expected — proceed (spec §6). + return null; + } + + const keyId = bytesToHex(await sha256(pubkeyDer)); + if (!this.policy.liveness && this.verifiedKeys.has(keyId)) { + if (this.debug) { + console.log(`SGX attestation: using cached verification for key ${keyId.slice(0, 16)}`); + } + return null; + } + + if (this.verifier === undefined) { + return this.fail( + 'no quote verifier configured (cannot perform step-4 DCAP verification)' + ); + } + + const challenge = this.policy.liveness + ? generateRandomBytes(32) + : new Uint8Array(0); + + const att = await this.fetchAttestation(challenge); + if (att === null) { + return this.fail('could not fetch attestation from server'); + } + if (!att.is_available) { + const reason = typeof att.reason === 'string' ? att.reason : 'unknown'; + return this.fail(`attestation unavailable: ${reason}`); + } + + const quoteB64 = att.quote_b64; + if (typeof quoteB64 !== 'string' || quoteB64.length === 0) { + return this.fail('attestation response missing quote_b64'); + } + let quote: Uint8Array; + try { + quote = new Uint8Array(base64ToArrayBuffer(quoteB64)); + } catch (_error) { + return this.fail('attestation quote_b64 is not valid base64'); + } + + // Step 4: delegate the cryptographic quote verification. + let v: VerifiedQuote; + try { + v = await this.verifier.verify(quote); + } catch (error) { + if (error instanceof AttestationError) { + return this.fail(`quote verification failed: ${error.message}`); + } + const name = error instanceof Error ? error.name : typeof error; + const message = error instanceof Error ? error.message : String(error); + return this.fail(`quote verification error: ${name}: ${message}`); + } + + // Step 5: policy checks. + const policyError = this.checkPolicy(v); + if (policyError !== null) { + return this.fail(policyError); + } + + // Step 6: key-binding check (constant-time). + const bound = new Uint8Array(pubkeyDer.byteLength + challenge.byteLength); + bound.set(new Uint8Array(pubkeyDer), 0); + bound.set(challenge, pubkeyDer.byteLength); + const expected = new Uint8Array(await sha512(bound)); + + if (v.reportData.length !== REPORT_DATA_LEN || !constantTimeEqual(expected, v.reportData)) { + return this.fail( + 'key binding mismatch: report_data does not match ' + + 'SHA-512(server_pubkey_der[ || challenge]) — the attested key is ' + + 'not the key being encrypted to' + ); + } + + // Step 7: success — cache (non-liveness only) and proceed. + if (!this.policy.liveness) { + this.verifiedKeys.add(keyId); + } + if (this.debug) { + console.log( + `SGX attestation verified: mrenclave=${v.mrenclave.slice(0, 16)} ` + + `tcb=${v.tcbStatus} tier=${tier}` + ); + } + return v; + } + + /** + * Return an error string if any policy check fails, else null. + */ + private checkPolicy(v: VerifiedQuote): string | null { + if (!this.policy.allowedTcbStatuses.has(v.tcbStatus)) { + const allowed = Array.from(this.policy.allowedTcbStatuses).sort(); + return `TCB status '${v.tcbStatus}' not in allowed ${JSON.stringify(allowed)}`; + } + if (this.policy.pinnedMrenclave.size > 0) { + if (!this.policy.pinnedMrenclave.has(v.mrenclave.toLowerCase())) { + return `MRENCLAVE ${v.mrenclave} not in pinned set`; + } + } else { + console.warn( + 'SGX attestation: no pinnedMrenclave configured — enclave identity ' + + 'is NOT verified (configure AttestationPolicy.pinnedMrenclave)' + ); + } + if (this.policy.pinnedMrsigner !== undefined) { + if (v.mrsigner.toLowerCase() !== this.policy.pinnedMrsigner) { + return `MRSIGNER ${v.mrsigner} != pinned ${this.policy.pinnedMrsigner}`; + } + } + if (this.policy.pinnedProdId !== undefined) { + if (v.isvProdId !== this.policy.pinnedProdId) { + return `isvProdId ${v.isvProdId} != pinned ${this.policy.pinnedProdId}`; + } + } + if (this.policy.minIsvSvn !== undefined) { + if (v.isvSvn < this.policy.minIsvSvn) { + return `isvSvn ${v.isvSvn} < minimum ${this.policy.minIsvSvn}`; + } + } + return null; + } + + /** + * GET /pki/attestation; return parsed JSON, or null on transport error. + * + * @throws AttestationError if the router URL is not HTTPS and HTTP was not + * explicitly allowed — regardless of `enforce`, since fetching attestation + * over a MITM-able channel defeats its purpose. + */ + private async fetchAttestation(challenge: Uint8Array): Promise | null> { + if (!this.routerUrl.startsWith('https://') && !this.allowHttp) { + throw new AttestationError( + 'Attestation must be fetched over HTTPS to prevent MITM attacks ' + + '(initialize with allowHttp=true only for local development).' + ); + } + + let url = `${this.routerUrl}/pki/attestation`; + if (challenge.length > 0) { + url += `?challenge=${encodeURIComponent(bytesToHex(challenge))}`; + } + + try { + const response = await this.httpClient.get(url, { timeout: this.timeout }); + if (response.statusCode !== 200) { + console.warn( + `SGX attestation: /pki/attestation returned HTTP ${response.statusCode}` + ); + return null; + } + return JSON.parse(arrayBufferToString(response.body)) as Record; + } catch (error) { + console.warn( + `SGX attestation: failed to fetch attestation: ${error instanceof Error ? error.message : 'unknown error'}` + ); + return null; + } + } + + /** + * Apply the §6 rollout policy: hard-fail in enforce mode, else warn + proceed. + */ + private fail(reason: string): null { + if (this.policy.enforce) { + throw new AttestationError(`SGX attestation failed: ${reason}`); + } + console.warn( + `SGX attestation: ${reason} — proceeding because enforce=false (rollout mode)` + ); + return null; + } +} diff --git a/src/core/attestation/index.ts b/src/core/attestation/index.ts new file mode 100644 index 0000000..aaaaee2 --- /dev/null +++ b/src/core/attestation/index.ts @@ -0,0 +1,13 @@ +/** + * SGX DCAP attestation — public surface. + */ + +export { AttestationPolicy } from './policy'; +export { SgxAttestationVerifier } from './SgxAttestationVerifier'; +export type { SgxAttestationVerifierOptions } from './SgxAttestationVerifier'; +export { + CallableQuoteVerifier, + JwtQuoteVerifier, + DEFAULT_CLAIM_MAP, +} from './verifiers'; +export type { VerifierCallable, JwtQuoteVerifierOptions } from './verifiers'; diff --git a/src/core/attestation/policy.ts b/src/core/attestation/policy.ts new file mode 100644 index 0000000..1590169 --- /dev/null +++ b/src/core/attestation/policy.ts @@ -0,0 +1,42 @@ +/** + * Client-side SGX attestation policy. + * + * Port of Python's `AttestationPolicy` dataclass, including the `__post_init__` + * normalisation: pins are lowercased so comparisons against verifier output are + * case-stable, and the set-typed fields accept any iterable. + */ + +import { AttestationPolicyOptions } from '../../types/attestation'; + +export class AttestationPolicy { + readonly pinnedMrenclave: ReadonlySet; + readonly pinnedMrsigner?: string; + readonly pinnedProdId?: number; + readonly minIsvSvn?: number; + readonly allowedTcbStatuses: ReadonlySet; + readonly enforce: boolean; + readonly requireAttestationForTier: ReadonlySet; + readonly liveness: boolean; + + constructor(options: AttestationPolicyOptions = {}) { + const { + pinnedMrenclave = [], + pinnedMrsigner, + pinnedProdId, + minIsvSvn, + allowedTcbStatuses = ['UpToDate'], + enforce = false, + requireAttestationForTier = ['high', 'maximum'], + liveness = false, + } = options; + + this.pinnedMrenclave = new Set(Array.from(pinnedMrenclave, m => m.toLowerCase())); + this.pinnedMrsigner = pinnedMrsigner?.toLowerCase(); + this.pinnedProdId = pinnedProdId; + this.minIsvSvn = minIsvSvn; + this.allowedTcbStatuses = new Set(allowedTcbStatuses); + this.enforce = enforce; + this.requireAttestationForTier = new Set(requireAttestationForTier); + this.liveness = liveness; + } +} diff --git a/src/core/attestation/verifiers.ts b/src/core/attestation/verifiers.ts new file mode 100644 index 0000000..f6c8e25 --- /dev/null +++ b/src/core/attestation/verifiers.ts @@ -0,0 +1,269 @@ +/** + * 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, + }; + } +} diff --git a/src/core/crypto/rsa.ts b/src/core/crypto/rsa.ts index e521f7d..a4feb12 100644 --- a/src/core/crypto/rsa.ts +++ b/src/core/crypto/rsa.ts @@ -76,6 +76,17 @@ export class RSAOperations { return arrayBufferToPem(exported, 'PUBLIC'); } + /** + * Export public key to canonical SPKI DER bytes. + * + * Used for the SGX attestation key binding, which is computed over exactly + * these bytes. + * @param publicKey RSA public key + */ + async exportPublicKeyDer(publicKey: CryptoKey): Promise { + return await this.subtle.exportKey('spki', publicKey); + } + /** * Import public key from PEM format * @param pem PEM-encoded public key diff --git a/src/core/crypto/utils.ts b/src/core/crypto/utils.ts index 4abee7f..fee28fe 100644 --- a/src/core/crypto/utils.ts +++ b/src/core/crypto/utils.ts @@ -109,6 +109,69 @@ export function arrayBufferToPem(buffer: ArrayBuffer, type: 'PUBLIC' | 'PRIVATE' return `${header}\n${formatted}\n${footer}`; } +/** + * Convert bytes to a lowercase hex string + */ +export function bytesToHex(buffer: ArrayBuffer | Uint8Array): string { + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); + let hex = ''; + for (let i = 0; i < bytes.length; i++) { + hex += bytes[i].toString(16).padStart(2, '0'); + } + return hex; +} + +/** + * Parse a hex string into bytes. + * @throws Error if the string is not valid, even-length hex + */ +export function hexToBytes(hex: string): Uint8Array { + if (hex.length % 2 !== 0) { + throw new Error('Invalid hex string: odd length'); + } + if (hex.length > 0 && !/^[0-9a-fA-F]+$/.test(hex)) { + throw new Error('Invalid hex string: non-hex characters'); + } + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.substr(i * 2, 2), 16); + } + return bytes; +} + +/** + * Compare two byte sequences in constant time (no early exit). + * + * Equivalent to Python's `hmac.compare_digest`. Used for the SGX report_data + * key-binding check so a mismatch leaks no positional information via timing. + */ +export function constantTimeEqual(a: ArrayBuffer | Uint8Array, b: ArrayBuffer | Uint8Array): boolean { + const x = a instanceof Uint8Array ? a : new Uint8Array(a); + const y = b instanceof Uint8Array ? b : new Uint8Array(b); + if (x.length !== y.length) { + return false; + } + let diff = 0; + for (let i = 0; i < x.length; i++) { + diff |= x[i] ^ y[i]; + } + return diff === 0; +} + +/** + * SHA-256 digest + */ +export async function sha256(data: ArrayBuffer | Uint8Array): Promise { + return await getCrypto().digest('SHA-256', data as BufferSource); +} + +/** + * SHA-512 digest + */ +export async function sha512(data: ArrayBuffer | Uint8Array): Promise { + return await getCrypto().digest('SHA-512', data as BufferSource); +} + /** * Get the Web Crypto API (works in both browser and Node.js) */ diff --git a/src/errors/index.ts b/src/errors/index.ts index f8ecbb9..5f35dc1 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -85,6 +85,23 @@ export class SecurityError extends Error { } } +/** + * Raised when SGX attestation verification fails in `enforce` mode. + * + * Extends SecurityError so existing `instanceof SecurityError` checks treat a + * failed attestation as the security failure it is. + */ +export class AttestationError extends SecurityError { + constructor(message: string) { + super(message); + this.name = 'AttestationError'; + + if (captureStackTrace) { + captureStackTrace(this, this.constructor); + } + } +} + export class DisposedError extends Error { constructor(message = 'This client instance has been disposed and can no longer be used') { super(message); diff --git a/src/index.ts b/src/index.ts index 7baa07a..3ca0347 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,12 @@ export { SecureChatCompletion } from './api/SecureChatCompletion'; export { SecureCompletionClient } from './core/SecureCompletionClient'; +// SGX DCAP attestation — mirrors Python's AttestationPolicy, QuoteVerifier, +// CallableQuoteVerifier, JwtQuoteVerifier and SgxAttestationVerifier +export * from './core/attestation'; + // Export types +export * from './types/attestation'; export * from './types/api'; export * from './types/client'; export * from './types/crypto'; diff --git a/src/types/attestation.ts b/src/types/attestation.ts new file mode 100644 index 0000000..76d6be1 --- /dev/null +++ b/src/types/attestation.ts @@ -0,0 +1,89 @@ +/** + * SGX DCAP attestation types. + * + * Port of Python's SgxAttestation dataclasses and QuoteVerifier interface. + * See doc/attestation.md for the protocol this implements. + */ + +/** + * SGX report_data is a fixed 64-byte field; the binding is the full SHA-512 + * digest of the server public key DER (optionally concatenated with a client + * challenge), with no truncation or padding (spec §2.3). + */ +export const REPORT_DATA_LEN = 64; + +/** + * Trusted result of verifying a DCAP quote. + * + * These values come from *inside* the hardware-signed quote (as returned by a + * QuoteVerifier) — never from the untrusted `/pki/attestation` JSON. + */ +export interface VerifiedQuote { + /** Enclave measurement, lowercase hex */ + mrenclave: string; + + /** Enclave signer measurement, lowercase hex */ + mrsigner: string; + + /** ISV product ID */ + isvProdId: number; + + /** ISV security version number */ + isvSvn: number; + + /** TCB status string, e.g. "UpToDate" */ + tcbStatus: string; + + /** The 64-byte SGX report_data field */ + reportData: Uint8Array; +} + +/** + * Client-side attestation policy options (spec §5). + * + * Ship the pinned values as configuration, not hard-coded constants: MRENCLAVE + * changes whenever the enclave build or its file paths change, so pin a *set* + * to allow rolling new builds. + */ +export interface AttestationPolicyOptions { + /** Accepted MRENCLAVE values (hex, case-insensitive). Empty = not verified. */ + pinnedMrenclave?: Iterable; + + /** Required MRSIGNER (hex, case-insensitive) */ + pinnedMrsigner?: string; + + /** Required ISV product ID */ + pinnedProdId?: number; + + /** Minimum acceptable ISV SVN */ + minIsvSvn?: number; + + /** Accepted TCB statuses (default: {"UpToDate"}) */ + allowedTcbStatuses?: Iterable; + + /** + * When true, a failed verification throws AttestationError and no plaintext + * is sent. When false (default), failures warn and proceed (rollout mode). + */ + enforce?: boolean; + + /** Tiers that require attestation (default: {"high", "maximum"}) */ + requireAttestationForTier?: Iterable; + + /** + * When true, send a fresh random challenge with every request and bypass + * the session cache, proving the quote is live rather than replayed. + */ + liveness?: boolean; +} + +/** + * Interface for step-4 DCAP quote verification. + * + * Implementations must verify the ECDSA signature, the PCK certificate chain to + * the Intel root, and revocation/TCB, then return the trusted parsed values. + * Throw on any verification failure. + */ +export interface QuoteVerifier { + verify(quote: Uint8Array): Promise; +} diff --git a/src/types/client.ts b/src/types/client.ts index 5422ea7..a5ea25b 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -2,7 +2,28 @@ * Client configuration types */ -export interface ClientConfig { +import { AttestationPolicy } from '../core/attestation/policy'; +import { QuoteVerifier } from './attestation'; + +/** Options shared by ClientConfig and ChatCompletionConfig for SGX attestation. */ +export interface AttestationConfig { + /** + * Optional AttestationPolicy enabling SGX DCAP attestation verification + * before any plaintext is sent (see doc/attestation.md). + * When omitted (default) attestation is disabled. + */ + attestationPolicy?: AttestationPolicy; + + /** + * Optional QuoteVerifier performing the step-4 DCAP quote verification + * (e.g. JwtQuoteVerifier). Required to actually verify; without it a set + * policy treats attestation as unverifiable (warn-and-proceed unless + * `policy.enforce`). + */ + quoteVerifier?: QuoteVerifier; +} + +export interface ClientConfig extends AttestationConfig { /** Base URL of the NOMYO router (e.g., https://api.nomyo.ai) */ routerUrl: string; @@ -70,7 +91,7 @@ export interface KeyPaths { publicKeyPath?: string; } -export interface ChatCompletionConfig { +export interface ChatCompletionConfig extends AttestationConfig { /** Base URL of the NOMYO router */ baseUrl?: string; diff --git a/tests/unit/attestation.test.ts b/tests/unit/attestation.test.ts new file mode 100644 index 0000000..fc37987 --- /dev/null +++ b/tests/unit/attestation.test.ts @@ -0,0 +1,468 @@ +/** + * Unit tests for SGX DCAP attestation. + * + * Mirrors the Python suite in ../nomyo/test/test_attestation.py: a mock router + * serves /pki/attestation with a quote whose report_data is the real + * SHA-512(server_pubkey_der [|| challenge]) binding, and a "faithful" verifier + * decodes that quote back into a VerifiedQuote. + */ + +import { + AttestationPolicy, + SgxAttestationVerifier, + CallableQuoteVerifier, + JwtQuoteVerifier, +} from '../../src/core/attestation'; +import { VerifiedQuote } from '../../src/types/attestation'; +import { AttestationError, SecurityError } from '../../src/errors'; +import { SecureCompletionClient } from '../../src/core/SecureCompletionClient'; +import { RSAOperations } from '../../src/core/crypto/rsa'; +import { + bytesToHex, + hexToBytes, + constantTimeEqual, + sha512, + stringToArrayBuffer, + arrayBufferToBase64, + arrayBufferToString, +} from '../../src/core/crypto/utils'; + +// ---- fixtures -------------------------------------------------------------- + +const MRENCLAVE = 'a'.repeat(64); +const MRSIGNER = 'b'.repeat(64); + +interface HttpResponseLike { + statusCode: number; + headers: Record; + body: ArrayBuffer; +} + +function jsonResponse(statusCode: number, body: object): HttpResponseLike { + return { + statusCode, + headers: { 'content-type': 'application/json' }, + body: stringToArrayBuffer(JSON.stringify(body)), + }; +} + +/** + * Mock router: serves the server public key and an attestation quote binding + * that key (plus any challenge) via SHA-512, exactly as the real enclave does. + */ +class MockServer { + pubkeyPem!: string; + pubkeyDer!: ArrayBuffer; + + /** Overrides for negative-path tests */ + isAvailable = true; + reason = 'sgx not supported'; + bindToWrongKey = false; + mrenclave = MRENCLAVE; + mrsigner = MRSIGNER; + isvProdId = 1; + isvSvn = 3; + tcbStatus = 'UpToDate'; + + /** Challenges observed on /pki/attestation */ + seenChallenges: (string | null)[] = []; + attestationCalls = 0; + + async init(): Promise { + const rsa = new RSAOperations(); + const pair = await rsa.generateKeyPair(2048); + this.pubkeyPem = await rsa.exportPublicKey(pair.publicKey); + this.pubkeyDer = await rsa.exportPublicKeyDer(pair.publicKey); + } + + /** + * The quote is opaque to the orchestrator; we encode the claims a real DCAP + * verifier would recover from it, so `faithfulVerify` can play that role. + */ + private async quoteB64(challenge: Uint8Array): Promise { + const keyBytes = this.bindToWrongKey + ? new Uint8Array(this.pubkeyDer.byteLength).fill(0x41) + : new Uint8Array(this.pubkeyDer); + + const bound = new Uint8Array(keyBytes.length + challenge.length); + bound.set(keyBytes, 0); + bound.set(challenge, keyBytes.length); + const reportData = new Uint8Array(await sha512(bound)); + + const claims = { + mrenclave: this.mrenclave, + mrsigner: this.mrsigner, + isv_prod_id: this.isvProdId, + isv_svn: this.isvSvn, + tcb_status: this.tcbStatus, + report_data: bytesToHex(reportData), + }; + return arrayBufferToBase64(stringToArrayBuffer(JSON.stringify(claims))); + } + + /** Drop-in replacement for the HttpClient interface. */ + client() { + const handler = async (url: string): Promise => { + const parsed = new URL(url); + + if (parsed.pathname === '/pki/public_key') { + return { + statusCode: 200, + headers: {}, + body: stringToArrayBuffer(this.pubkeyPem), + }; + } + + if (parsed.pathname === '/pki/attestation') { + this.attestationCalls += 1; + const challengeHex = parsed.searchParams.get('challenge'); + this.seenChallenges.push(challengeHex); + + if (!this.isAvailable) { + return jsonResponse(200, { is_available: false, reason: this.reason }); + } + const challenge = challengeHex ? hexToBytes(challengeHex) : new Uint8Array(0); + return jsonResponse(200, { + is_available: true, + quote_b64: await this.quoteB64(challenge), + }); + } + + return jsonResponse(404, { detail: 'not found' }); + }; + + return { + get: jest.fn((url: string) => handler(url)), + post: jest.fn((url: string) => handler(url)), + }; + } +} + +/** Plays the role of a real DCAP verifier: recovers claims from the quote. */ +async function faithfulVerify(quote: Uint8Array): Promise { + const claims = JSON.parse(arrayBufferToString(quote.buffer as ArrayBuffer)) as Record; + return { + mrenclave: String(claims.mrenclave), + mrsigner: String(claims.mrsigner), + isvProdId: Number(claims.isv_prod_id), + isvSvn: Number(claims.isv_svn), + tcbStatus: String(claims.tcb_status), + reportData: hexToBytes(String(claims.report_data)), + }; +} + +function makeOrchestrator(server: MockServer, policy: AttestationPolicy): SgxAttestationVerifier { + const orchestrator = new SgxAttestationVerifier({ + routerUrl: 'https://router.test', + policy, + verifier: new CallableQuoteVerifier(faithfulVerify), + }); + (orchestrator as unknown as { httpClient: unknown }).httpClient = server.client(); + return orchestrator; +} + +/** Run the §3 algorithm against the mock server at the "high" tier. */ +async function ensure( + server: MockServer, + policy: AttestationPolicy, + tier = 'high' +): Promise { + return makeOrchestrator(server, policy).ensureVerified(server.pubkeyDer, tier); +} + +let server: MockServer; +let warnSpy: jest.SpyInstance; + +beforeEach(async () => { + server = new MockServer(); + await server.init(); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); +}); + +afterEach(() => { + warnSpy.mockRestore(); +}); + +// ---- crypto helpers -------------------------------------------------------- + +describe('crypto helpers', () => { + test('hex round-trips', () => { + const bytes = new Uint8Array([0x00, 0x0f, 0xff, 0xa5]); + expect(bytesToHex(bytes)).toBe('000fffa5'); + expect(Array.from(hexToBytes('000fffa5'))).toEqual(Array.from(bytes)); + }); + + test('hexToBytes rejects malformed input', () => { + expect(() => hexToBytes('abc')).toThrow('odd length'); + expect(() => hexToBytes('zzzz')).toThrow('non-hex'); + }); + + test('constantTimeEqual compares correctly', () => { + expect(constantTimeEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 3]))).toBe(true); + expect(constantTimeEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 4]))).toBe(false); + expect(constantTimeEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2, 3]))).toBe(false); + }); +}); + +// ---- policy ---------------------------------------------------------------- + +describe('AttestationPolicy', () => { + test('normalises pins to lowercase and applies defaults', () => { + const policy = new AttestationPolicy({ + pinnedMrenclave: ['ABCDEF'], + pinnedMrsigner: 'FEDCBA', + }); + expect(policy.pinnedMrenclave.has('abcdef')).toBe(true); + expect(policy.pinnedMrsigner).toBe('fedcba'); + expect(Array.from(policy.allowedTcbStatuses)).toEqual(['UpToDate']); + expect(policy.requireAttestationForTier.has('high')).toBe(true); + expect(policy.requireAttestationForTier.has('maximum')).toBe(true); + expect(policy.enforce).toBe(false); + expect(policy.liveness).toBe(false); + }); +}); + +// ---- orchestrator §3 algorithm --------------------------------------------- + +describe('SgxAttestationVerifier', () => { + test('happy path returns the verified quote', async () => { + const policy = new AttestationPolicy({ + pinnedMrenclave: [MRENCLAVE], + enforce: true, + }); + const v = await ensure(server, policy); + expect(v).not.toBeNull(); + expect(v!.mrenclave).toBe(MRENCLAVE); + expect(v!.tcbStatus).toBe('UpToDate'); + }); + + test('standard tier skips attestation entirely', async () => { + const policy = new AttestationPolicy({ pinnedMrenclave: [MRENCLAVE], enforce: true }); + const v = await ensure(server, policy, 'standard'); + expect(v).toBeNull(); + expect(server.attestationCalls).toBe(0); + }); + + test('wrong key binding throws in enforce mode', async () => { + server.bindToWrongKey = true; + const policy = new AttestationPolicy({ pinnedMrenclave: [MRENCLAVE], enforce: true }); + await expect(ensure(server, policy)).rejects.toThrow(AttestationError); + await expect(ensure(server, policy)).rejects.toThrow('key binding mismatch'); + }); + + test('wrong key binding warns and proceeds when enforce is off', async () => { + server.bindToWrongKey = true; + const policy = new AttestationPolicy({ pinnedMrenclave: [MRENCLAVE], enforce: false }); + await expect(ensure(server, policy)).resolves.toBeNull(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('key binding mismatch')); + }); + + test('MRENCLAVE outside the pinned set fails', async () => { + server.mrenclave = 'c'.repeat(64); + const policy = new AttestationPolicy({ pinnedMrenclave: [MRENCLAVE], enforce: true }); + await expect(ensure(server, policy)).rejects.toThrow('not in pinned set'); + }); + + test('disallowed TCB status fails', async () => { + server.tcbStatus = 'OutOfDate'; + const policy = new AttestationPolicy({ pinnedMrenclave: [MRENCLAVE], enforce: true }); + await expect(ensure(server, policy)).rejects.toThrow("TCB status 'OutOfDate' not in allowed"); + }); + + test('MRSIGNER, prod id and SVN checks are enforced', async () => { + const base = { pinnedMrenclave: [MRENCLAVE], enforce: true }; + + await expect( + ensure(server, new AttestationPolicy({ ...base, pinnedMrsigner: 'd'.repeat(64) })) + ).rejects.toThrow('MRSIGNER'); + + await expect( + ensure(server, new AttestationPolicy({ ...base, pinnedProdId: 99 })) + ).rejects.toThrow('isvProdId'); + + await expect( + ensure(server, new AttestationPolicy({ ...base, minIsvSvn: 10 })) + ).rejects.toThrow('isvSvn 3 < minimum 10'); + + // All satisfied + await expect( + ensure(server, new AttestationPolicy({ + ...base, + pinnedMrsigner: MRSIGNER.toUpperCase(), + pinnedProdId: 1, + minIsvSvn: 3, + })) + ).resolves.not.toBeNull(); + }); + + test('warns when no MRENCLAVE is pinned', async () => { + await ensure(server, new AttestationPolicy({ enforce: true })); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('enclave identity is NOT verified')); + }); + + test('unavailable attestation throws in enforce mode', async () => { + server.isAvailable = false; + const policy = new AttestationPolicy({ pinnedMrenclave: [MRENCLAVE], enforce: true }); + await expect(ensure(server, policy)).rejects.toThrow('attestation unavailable: sgx not supported'); + }); + + test('unavailable attestation warns and proceeds when enforce is off', async () => { + server.isAvailable = false; + const policy = new AttestationPolicy({ pinnedMrenclave: [MRENCLAVE] }); + await expect(ensure(server, policy)).resolves.toBeNull(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('attestation unavailable')); + }); + + test('missing quote verifier throws in enforce mode', async () => { + const orchestrator = new SgxAttestationVerifier({ + routerUrl: 'https://router.test', + policy: new AttestationPolicy({ pinnedMrenclave: [MRENCLAVE], enforce: true }), + }); + (orchestrator as unknown as { httpClient: unknown }).httpClient = server.client(); + + await expect(orchestrator.ensureVerified(server.pubkeyDer, 'high')) + .rejects.toThrow('no quote verifier configured'); + }); + + test('liveness sends a fresh challenge and binds to it', async () => { + const policy = new AttestationPolicy({ + pinnedMrenclave: [MRENCLAVE], + enforce: true, + liveness: true, + }); + const orchestrator = makeOrchestrator(server, policy); + + await expect(orchestrator.ensureVerified(server.pubkeyDer, 'high')).resolves.not.toBeNull(); + await expect(orchestrator.ensureVerified(server.pubkeyDer, 'high')).resolves.not.toBeNull(); + + // Cache bypassed: both calls hit the server with distinct 32-byte challenges + expect(server.attestationCalls).toBe(2); + expect(server.seenChallenges).toHaveLength(2); + expect(server.seenChallenges[0]).toHaveLength(64); + expect(server.seenChallenges[0]).not.toBe(server.seenChallenges[1]); + }); + + test('session cache skips the second verification', async () => { + const policy = new AttestationPolicy({ pinnedMrenclave: [MRENCLAVE], enforce: true }); + const orchestrator = makeOrchestrator(server, policy); + + await expect(orchestrator.ensureVerified(server.pubkeyDer, 'high')).resolves.not.toBeNull(); + // Second call is served from the session cache (null, no network) + await expect(orchestrator.ensureVerified(server.pubkeyDer, 'high')).resolves.toBeNull(); + expect(server.attestationCalls).toBe(1); + expect(server.seenChallenges[0]).toBeNull(); + }); + + test('refuses to fetch attestation over plain HTTP', async () => { + const orchestrator = new SgxAttestationVerifier({ + routerUrl: 'http://router.test', + policy: new AttestationPolicy({ pinnedMrenclave: [MRENCLAVE] }), + verifier: new CallableQuoteVerifier(faithfulVerify), + }); + (orchestrator as unknown as { httpClient: unknown }).httpClient = server.client(); + + // Throws regardless of enforce — a MITM-able channel defeats attestation + await expect(orchestrator.ensureVerified(server.pubkeyDer, 'high')) + .rejects.toThrow('must be fetched over HTTPS'); + }); + + test('allows plain HTTP when explicitly opted in', async () => { + const orchestrator = new SgxAttestationVerifier({ + routerUrl: 'http://router.test', + policy: new AttestationPolicy({ pinnedMrenclave: [MRENCLAVE], enforce: true }), + verifier: new CallableQuoteVerifier(faithfulVerify), + allowHttp: true, + }); + (orchestrator as unknown as { httpClient: unknown }).httpClient = server.client(); + + await expect(orchestrator.ensureVerified(server.pubkeyDer, 'high')).resolves.not.toBeNull(); + }); + + test('AttestationError is a SecurityError', async () => { + server.isAvailable = false; + const policy = new AttestationPolicy({ pinnedMrenclave: [MRENCLAVE], enforce: true }); + await expect(ensure(server, policy)).rejects.toBeInstanceOf(SecurityError); + }); +}); + +// ---- verifiers ------------------------------------------------------------- + +describe('CallableQuoteVerifier', () => { + test('accepts a sync callable', async () => { + const quote: VerifiedQuote = { + mrenclave: MRENCLAVE, + mrsigner: MRSIGNER, + isvProdId: 1, + isvSvn: 1, + tcbStatus: 'UpToDate', + reportData: new Uint8Array(64), + }; + await expect(new CallableQuoteVerifier(() => quote).verify(new Uint8Array(0))) + .resolves.toEqual(quote); + }); + + test('rejects a callable that returns the wrong shape', async () => { + const bad = new CallableQuoteVerifier(() => ({ mrenclave: 'x' } as unknown as VerifiedQuote)); + await expect(bad.verify(new Uint8Array(0))) + .rejects.toThrow('must return a VerifiedQuote'); + }); +}); + +describe('JwtQuoteVerifier', () => { + test('throws a helpful AttestationError when jose is not installed', async () => { + const verifier = new JwtQuoteVerifier('https://verify.test/quote', 'https://verify.test/jwks'); + await expect(verifier.verify(new Uint8Array([1, 2, 3]))) + .rejects.toThrow(/requires the 'jose' package/); + }); +}); + +// ---- end-to-end through SecureCompletionClient ------------------------------ + +describe('SecureCompletionClient with attestation', () => { + test('enforce failure never sends plaintext', async () => { + server.bindToWrongKey = true; + + const client = new SecureCompletionClient({ + routerUrl: 'https://router.test', + keyRotationInterval: 0, + keySize: 2048, + attestationPolicy: new AttestationPolicy({ + pinnedMrenclave: [MRENCLAVE], + enforce: true, + }), + quoteVerifier: new CallableQuoteVerifier(faithfulVerify), + }); + + const http = server.client(); + (client as unknown as { httpClient: unknown }).httpClient = http; + (client as unknown as { attestation: { httpClient: unknown } }).attestation.httpClient = http; + + await expect( + client.sendSecureRequest({ model: 'm', messages: [] }, 'pid', undefined, 'high') + ).rejects.toThrow(AttestationError); + + // The completion endpoint was never POSTed to + expect(http.post).not.toHaveBeenCalled(); + client.dispose(); + }); + + test('no attestation configured leaves behaviour unchanged', async () => { + const client = new SecureCompletionClient({ + routerUrl: 'https://router.test', + keyRotationInterval: 0, + keySize: 2048, + }); + expect((client as unknown as { attestation?: unknown }).attestation).toBeUndefined(); + + const http = server.client(); + (client as unknown as { httpClient: unknown }).httpClient = http; + + // Reaches the router (mock returns 404 for the completion endpoint) — + // i.e. no attestation step ran and no attestation request was made. + await expect( + client.sendSecureRequest({ model: 'm', messages: [] }, 'pid', undefined, 'high') + ).rejects.toThrow('Endpoint not found'); + expect(server.attestationCalls).toBe(0); + client.dispose(); + }); +});