feat: SGX attestation

This commit is contained in:
Alpha Nerd 2026-07-19 10:48:34 +02:00
parent b055df5b87
commit 75867ef85a
Signed by: alpha-nerd
SSH key fingerprint: SHA256:QkkAgVoYi9TQ0UKPkiKSfnerZy2h4qhi3SVPXJmBN+M
13 changed files with 1334 additions and 9 deletions

View file

@ -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 }),
});
}

View file

@ -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<void> = 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<ArrayBuffer> {
async encryptPayload(payload: object, serverPublicKeyPem?: string): Promise<ArrayBuffer> {
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<ArrayBuffer> {
private async performEncryption(
payloadBytes: ArrayBuffer,
serverPublicKeyPem?: string
): Promise<ArrayBuffer> {
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<string, string> = {
'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<ArrayBuffer> {
const key = await this.rsa.importPublicKey(pubkeyPem);
return await this.rsa.exportPublicKeyDer(key);
}
/**
* Validate RSA key size (minimum 2048 bits)
*/

View file

@ -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<string>();
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<VerifiedQuote | null> {
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<Record<string, unknown> | 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<string, unknown>;
} 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;
}
}

View file

@ -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';

View file

@ -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<string>;
readonly pinnedMrsigner?: string;
readonly pinnedProdId?: number;
readonly minIsvSvn?: number;
readonly allowedTcbStatuses: ReadonlySet<string>;
readonly enforce: boolean;
readonly requireAttestationForTier: ReadonlySet<string>;
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;
}
}

View file

@ -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<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,
};
}
}

View file

@ -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<ArrayBuffer> {
return await this.subtle.exportKey('spki', publicKey);
}
/**
* Import public key from PEM format
* @param pem PEM-encoded public key

View file

@ -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<ArrayBuffer> {
return await getCrypto().digest('SHA-256', data as BufferSource);
}
/**
* SHA-512 digest
*/
export async function sha512(data: ArrayBuffer | Uint8Array): Promise<ArrayBuffer> {
return await getCrypto().digest('SHA-512', data as BufferSource);
}
/**
* Get the Web Crypto API (works in both browser and Node.js)
*/

View file

@ -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);

View file

@ -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';

89
src/types/attestation.ts Normal file
View file

@ -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<string>;
/** 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<string>;
/**
* 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<string>;
/**
* 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<VerifiedQuote>;
}

View file

@ -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;

View file

@ -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<string, string>;
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<void> {
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<string> {
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<HttpResponseLike> => {
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<VerifiedQuote> {
const claims = JSON.parse(arrayBufferToString(quote.buffer as ArrayBuffer)) as Record<string, unknown>;
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<VerifiedQuote | null> {
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();
});
});