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

@ -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)
*/