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

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