feat: SGX attestation
This commit is contained in:
parent
b055df5b87
commit
75867ef85a
13 changed files with 1334 additions and 9 deletions
|
|
@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
*/
|
||||
|
|
|
|||
269
src/core/attestation/SgxAttestationVerifier.ts
Normal file
269
src/core/attestation/SgxAttestationVerifier.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
13
src/core/attestation/index.ts
Normal file
13
src/core/attestation/index.ts
Normal 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';
|
||||
42
src/core/attestation/policy.ts
Normal file
42
src/core/attestation/policy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
269
src/core/attestation/verifiers.ts
Normal file
269
src/core/attestation/verifiers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
89
src/types/attestation.ts
Normal 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>;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
468
tests/unit/attestation.test.ts
Normal file
468
tests/unit/attestation.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue