468 lines
18 KiB
TypeScript
468 lines
18 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|