diff --git a/packages/cre-sdk/src/sdk/utils/encryption.test.ts b/packages/cre-sdk/src/sdk/utils/encryption.test.ts new file mode 100644 index 00000000..103f7c23 --- /dev/null +++ b/packages/cre-sdk/src/sdk/utils/encryption.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'bun:test' +import { + ENCRYPTION_KEY_SECRET_NAME, + createRequestForEncryptedResponse, + decryptResponseBody, + deriveEncryptionKey, +} from './encryption' + +// Cross-language test vector (must match Go output). +const TEST_PASSPHRASE = 'test-passphrase-for-ci' +const TEST_EXPECTED_HEX = + '521af99325c07c9bd0d224c5bf3ca25666c68b5fbb7fa7884019b4f60a8e6eb5' + +function toHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') +} + +describe('deriveEncryptionKey', () => { + it('produces deterministic output for the same passphrase', async () => { + const k1 = await deriveEncryptionKey('my-passphrase') + const k2 = await deriveEncryptionKey('my-passphrase') + expect(toHex(k1)).toBe(toHex(k2)) + }) + + it('produces different output for different passphrases', async () => { + const k1 = await deriveEncryptionKey('passphrase-a') + const k2 = await deriveEncryptionKey('passphrase-b') + expect(toHex(k1)).not.toBe(toHex(k2)) + }) + + it('matches the Go cross-language test vector', async () => { + const key = await deriveEncryptionKey(TEST_PASSPHRASE) + expect(toHex(key)).toBe(TEST_EXPECTED_HEX) + }) +}) + +describe('createRequestForEncryptedResponse', () => { + it('sets encryptOutput and injects the secret identifier (JSON input)', () => { + const req = createRequestForEncryptedResponse( + { url: 'https://example.com', method: 'GET' }, + '0xDeaDBeeF', + ) + + expect(req.request?.encryptOutput).toBe(true) + expect(req.vaultDonSecrets).toHaveLength(1) + expect(req.vaultDonSecrets[0].key).toBe(ENCRYPTION_KEY_SECRET_NAME) + expect(req.vaultDonSecrets[0].owner).toBe('0xDeaDBeeF') + }) +}) + +describe('decryptResponseBody', () => { + it('round-trips encrypt then decrypt', async () => { + const passphrase = 'round-trip-test' + const plaintext = new TextEncoder().encode('hello confidential http') + + const keyBytes = await deriveEncryptionKey(passphrase) + + // Encrypt using Web Crypto (simulates enclave behavior). + const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['encrypt']) + const nonce = crypto.getRandomValues(new Uint8Array(12)) + const ciphertextBuf = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: nonce }, + key, + plaintext, + ) + + // Wire format: [nonce][ciphertext+tag] + const wire = new Uint8Array(nonce.length + ciphertextBuf.byteLength) + wire.set(nonce, 0) + wire.set(new Uint8Array(ciphertextBuf), nonce.length) + + const decrypted = await decryptResponseBody(wire, passphrase) + expect(new TextDecoder().decode(decrypted)).toBe('hello confidential http') + }) + + it('fails with wrong passphrase', async () => { + const keyBytes = await deriveEncryptionKey('correct-passphrase') + const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['encrypt']) + const nonce = crypto.getRandomValues(new Uint8Array(12)) + const ciphertextBuf = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: nonce }, + key, + new TextEncoder().encode('secret'), + ) + + const wire = new Uint8Array(nonce.length + ciphertextBuf.byteLength) + wire.set(nonce, 0) + wire.set(new Uint8Array(ciphertextBuf), nonce.length) + + expect(decryptResponseBody(wire, 'wrong-passphrase')).rejects.toThrow() + }) +}) diff --git a/packages/cre-sdk/src/sdk/utils/encryption.ts b/packages/cre-sdk/src/sdk/utils/encryption.ts new file mode 100644 index 00000000..156f1c28 --- /dev/null +++ b/packages/cre-sdk/src/sdk/utils/encryption.ts @@ -0,0 +1,101 @@ +import { create } from '@bufbuild/protobuf' +import type { + ConfidentialHTTPRequest, + HTTPRequest, + HTTPRequestJson, +} from '@cre/generated/capabilities/networking/confidentialhttp/v1alpha/client_pb' +import { + ConfidentialHTTPRequestSchema, + HTTPRequestSchema, + SecretIdentifierSchema, +} from '@cre/generated/capabilities/networking/confidentialhttp/v1alpha/client_pb' + +/** VaultDON secret name used for AES-GCM encryption of confidential HTTP responses. */ +export const ENCRYPTION_KEY_SECRET_NAME = 'san_marino_aes_gcm_encryption_key' + +/** HKDF info parameter shared across all language implementations. */ +const HKDF_INFO = 'confidential-http-encryption-key-v1' + +const AES_KEY_LEN = 32 +const GCM_NONCE_LEN = 12 + +/** + * Derives a 32-byte AES-256 key from a passphrase using HKDF-SHA256. + * + * Parameters match the Go implementation: + * - Salt: empty + * - Info: "confidential-http-encryption-key-v1" + * - IKM: passphrase (UTF-8 bytes) + */ +export async function deriveEncryptionKey(passphrase: string): Promise { + const encoder = new TextEncoder() + const ikm = encoder.encode(passphrase) + const info = encoder.encode(HKDF_INFO) + + // Import the passphrase as raw key material for HKDF. + const baseKey = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']) + + const bits = await crypto.subtle.deriveBits( + { + name: 'HKDF', + hash: 'SHA-256', + salt: new Uint8Array(0), + info, + }, + baseKey, + AES_KEY_LEN * 8, + ) + + return new Uint8Array(bits) +} + +/** + * Builds a ConfidentialHTTPRequest with encryptOutput=true and the AES key + * SecretIdentifier auto-injected. + * + * Accepts either a protobuf HTTPRequest message or a plain HTTPRequestJson object. + */ +export function createRequestForEncryptedResponse( + req: HTTPRequest | HTTPRequestJson, + owner: string, +): ConfidentialHTTPRequest { + // If req is a plain JSON object (no $typeName), convert to protobuf message. + const httpReq = isHTTPRequestMessage(req) ? req : create(HTTPRequestSchema, req) + httpReq.encryptOutput = true + + return create(ConfidentialHTTPRequestSchema, { + request: httpReq, + vaultDonSecrets: [ + create(SecretIdentifierSchema, { + key: ENCRYPTION_KEY_SECRET_NAME, + owner, + }), + ], + }) +} + +/** + * Decrypts an AES-GCM encrypted response body using the same passphrase that + * was used to store the encryption key. + * + * Wire format: [12-byte nonce][ciphertext+GCM tag] + */ +export async function decryptResponseBody( + ciphertext: Uint8Array, + passphrase: string, +): Promise { + const keyBytes = await deriveEncryptionKey(passphrase) + + const nonce = ciphertext.slice(0, GCM_NONCE_LEN) + const encrypted = ciphertext.slice(GCM_NONCE_LEN) + + const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['decrypt']) + + const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, key, encrypted) + + return new Uint8Array(plaintext) +} + +function isHTTPRequestMessage(req: HTTPRequest | HTTPRequestJson): req is HTTPRequest { + return '$typeName' in req +} diff --git a/packages/cre-sdk/src/sdk/utils/index.ts b/packages/cre-sdk/src/sdk/utils/index.ts index 9272593f..4f984d7b 100644 --- a/packages/cre-sdk/src/sdk/utils/index.ts +++ b/packages/cre-sdk/src/sdk/utils/index.ts @@ -7,3 +7,4 @@ export * from './safe-json-stringify' export * from './values/consensus_aggregators' export * from './values/serializer_types' export * from './values/value' +export * from './encryption'