Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions packages/cre-sdk/src/sdk/utils/encryption.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
101 changes: 101 additions & 0 deletions packages/cre-sdk/src/sdk/utils/encryption.ts
Original file line number Diff line number Diff line change
@@ -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<Uint8Array> {
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<Uint8Array> {
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
}
1 change: 1 addition & 0 deletions packages/cre-sdk/src/sdk/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Loading