All checks were successful
NYX Security Scan / nyx-scan (pull_request) Successful in 5m49s
Memory protection (step 3 of the Python parity work):
NodeSecureMemory has implemented lockMemory/unlockMemory since the
native addon landed, but nothing ever called them — mlock was dead code
while getProtectionInfo() reported method: 'mlock' and canLock: true.
The library claimed a protection it was not applying.
- SecureByteContext now locks on entry and unlocks on exit, mirroring
Python's secure_bytearray(lock=True). Zeroing happens *before*
unlocking, so cleartext cannot reach swap in between.
- lockMemory/unlockMemory are part of the SecureMemory interface, so
the browser implementation must answer for them explicitly (false).
- Locking is best-effort throughout: a refused or throwing lock
degrades to zeroing only, which still has value.
getProtectionInfo() now probes rather than assumes. Having the addon
loaded is not the same as being allowed to lock: mlock is routinely
refused by RLIMIT_MEMLOCK, which is small by default and 0 in some
containers. canLock reflects a real mlock attempt, and 'mlock' is only
claimed when locking genuinely works — otherwise it reports zero-only
and says why. ProtectionInfo also carries platform, hasSecureZeroing
and pageSize, closer to Python's get_protection_info().
SAST (ts.code_exec.new_function, ERROR):
JwtQuoteVerifier loaded ESM-only jose via new Function('s', 'return
import(s)'). No user input reached it, so it was not code injection —
but new Function is blocked by any CSP without 'unsafe-eval', and this
package ships a browser bundle, so the failure would land in the
attestation path. Removed in favour of injection: pass the module as
options.jose when require('jose') cannot work. The error message says
so. No eval-equivalent remains in src/.
.nyx/triage.json records the two native/src/mlock.cc cfg-resource-leak
warnings as false positives, scoped to that file rather than the rule,
so a genuine leak in future C++ still surfaces.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
528 lines
21 KiB
TypeScript
528 lines
21 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/);
|
|
});
|
|
|
|
test('accepts an injected jose module (CommonJS + ESM-only jose)', async () => {
|
|
const claims = {
|
|
sgx_mrenclave: MRENCLAVE.toUpperCase(),
|
|
sgx_mrsigner: MRSIGNER,
|
|
sgx_isvprodid: 1,
|
|
sgx_isvsvn: '4',
|
|
tcb_status: 'UpToDate',
|
|
sgx_report_data: bytesToHex(new Uint8Array(64).fill(7)),
|
|
};
|
|
const jose = {
|
|
createRemoteJWKSet: jest.fn(() => ({ kid: 'stub' })),
|
|
jwtVerify: jest.fn(async () => ({ payload: claims })),
|
|
};
|
|
|
|
const verifier = new JwtQuoteVerifier(
|
|
'https://verify.test/quote',
|
|
'https://verify.test/jwks',
|
|
{ jose, issuer: 'https://verify.test', algorithms: ['ES256'] }
|
|
);
|
|
(verifier as unknown as { httpClient: unknown }).httpClient = {
|
|
post: jest.fn(async () => jsonResponse(200, { token: 'header.payload.sig' })),
|
|
get: jest.fn(),
|
|
};
|
|
|
|
const v = await verifier.verify(new Uint8Array([1, 2, 3]));
|
|
|
|
expect(v.mrenclave).toBe(MRENCLAVE); // normalised to lowercase
|
|
expect(v.isvSvn).toBe(4); // string claim coerced to int
|
|
expect(v.reportData).toHaveLength(64);
|
|
expect(jose.jwtVerify).toHaveBeenCalledWith(
|
|
'header.payload.sig',
|
|
expect.anything(),
|
|
expect.objectContaining({ issuer: 'https://verify.test', algorithms: ['ES256'] })
|
|
);
|
|
});
|
|
|
|
test('rejects a verifier response without a token', async () => {
|
|
const verifier = new JwtQuoteVerifier('https://verify.test/quote', 'https://verify.test/jwks', {
|
|
jose: { createRemoteJWKSet: jest.fn(), jwtVerify: jest.fn() },
|
|
});
|
|
(verifier as unknown as { httpClient: unknown }).httpClient = {
|
|
post: jest.fn(async () => jsonResponse(200, { nope: true })),
|
|
get: jest.fn(),
|
|
};
|
|
|
|
await expect(verifier.verify(new Uint8Array([1]))).rejects.toThrow("missing JWT 'token'");
|
|
});
|
|
|
|
test('surfaces a non-200 from the verification service', async () => {
|
|
const verifier = new JwtQuoteVerifier('https://verify.test/quote', 'https://verify.test/jwks', {
|
|
jose: { createRemoteJWKSet: jest.fn(), jwtVerify: jest.fn() },
|
|
});
|
|
(verifier as unknown as { httpClient: unknown }).httpClient = {
|
|
post: jest.fn(async () => jsonResponse(503, { detail: 'down' })),
|
|
get: jest.fn(),
|
|
};
|
|
|
|
await expect(verifier.verify(new Uint8Array([1]))).rejects.toThrow('returned HTTP 503');
|
|
});
|
|
});
|
|
|
|
// ---- 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();
|
|
});
|
|
});
|