/** * Client-side encryption for secrets sent to the control plane. * * Uses hybrid RSA-OAEP + AES-GCM so secrets of any size can be encrypted. * The control plane holds the matching RSA private key as a worker secret. */ export interface EncryptedEnvelope { __encrypted: true; kid: string; wrappedKey: string; // base64url iv: string; // base64url ciphertext: string; // base64url } const KEY_ID = "v1"; /** RSA-OAEP-256 public key for secrets encryption (generated by scripts/generate-encryption-keypair.ts) */ const PUBLIC_KEY_JWK = { alg: "RSA-OAEP-256", e: "AQAB", ext: true, key_ops: ["encrypt"], kty: "RSA", n: "q2Y4K6heGkv_ABFOYokNXcwHFLAG3JScxEhjnZQTi7K8JEdCM9inqcy3gGhtT4lP6YWqhF4IHRMFU4qhPuByLASNp3bMWzDDKlckyDeWyPRnJqjb6IvwPYLw0ky1WumjjypAX_OSpNKhuYHx1X1hu7KQq9oa3f6sHFM5XbofMM2f__HvcEHnBVgkJvjTL2dn94DPgnsmtTLSRUAde34DQnXAKjVJ2jDuoC_sDAUmcmsEZKt3AUaCTkLBtbfW-ZI6_4VD2yNw-ySuOEprhhsNi6UpbjPY1ncduB5nkNhb276kVsjWo8w89KvDlhNCRyyZ_c0QRYSxn-nYEIE3vtS_h9FC9keMcDnH_fE4VPn14cjPV_G-eiUAoow8q5qBnFEp9DaaOswZ8IwEhpaxN6jvgk1WikZIBd58WB4HHSFWQ-W-096_5FA4cltQE7Qgwy86AgPnhpuCLLTqwpx8XF3GLbWtt9h4QYpfjrLyGuj4gJWCI4AJSDY1bvqiZtTfO1LdhyiZteEH0XhSBvXjXb1dJHbNXIcrIa_owtfEKqb53AxxwTvPaZazkigT0MqZ-141e7x6kuDkG_gSSFyCGrESaAyGYRh2K4wcGuV4jyZlQ6dzbQd0DPn8uRW3kC_vpToyZxZVqWGXFD6TtMYQwo_zWK3IaYCYMB-TYBFJj8a41z8", }; function base64urlEncode(bytes: ArrayBuffer | Uint8Array): string { const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); let bin = ""; for (const byte of u8) { bin += String.fromCharCode(byte); } return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } let _cachedPublicKey: CryptoKey | null = null; async function getPublicKey(): Promise { if (_cachedPublicKey) return _cachedPublicKey; _cachedPublicKey = await crypto.subtle.importKey( "jwk", PUBLIC_KEY_JWK, { name: "RSA-OAEP", hash: "SHA-256" }, false, ["encrypt"], ); return _cachedPublicKey; } /** Encrypt a single plaintext string into an encrypted envelope. */ export async function encryptSecretValue(plaintext: string): Promise { const rsaKey = await getPublicKey(); // Generate ephemeral AES-256-GCM key const aesKey = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, [ "encrypt", ]); // Encrypt plaintext with AES-GCM const iv = crypto.getRandomValues(new Uint8Array(12)); const plainBytes = new TextEncoder().encode(plaintext); const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, aesKey, plainBytes); // Wrap the AES key with RSA-OAEP const rawAesKey = await crypto.subtle.exportKey("raw", aesKey); const wrappedKey = await crypto.subtle.encrypt({ name: "RSA-OAEP" }, rsaKey, rawAesKey); return { __encrypted: true, kid: KEY_ID, wrappedKey: base64urlEncode(wrappedKey), iv: base64urlEncode(iv), ciphertext: base64urlEncode(ciphertext), }; } /** * Encrypt a full secrets map (Record) as a single envelope. * The entire JSON object is serialized then encrypted as one blob. */ export async function encryptSecrets(secrets: Record): Promise { return encryptSecretValue(JSON.stringify(secrets)); }