feat: SGX attestation
This commit is contained in:
parent
b055df5b87
commit
75867ef85a
13 changed files with 1334 additions and 9 deletions
|
|
@ -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)
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue