| 1 | import { b64, fromB64 } from "./util.js"; |
| 2 | |
| 3 | /** |
| 4 | * Envelope encryption for secrets. Each scope (repo or org) gets its own AES-GCM |
| 5 | * key derived from MASTER_KEY with HKDF, so a leaked ciphertext from one repo |
| 6 | * cannot be replayed into another. |
| 7 | */ |
| 8 | async function scopeKey(masterKeyB64: string, scope: string): Promise<CryptoKey> { |
| 9 | const raw = await crypto.subtle.importKey("raw", fromB64(masterKeyB64) as BufferSource, "HKDF", false, ["deriveKey"]); |
| 10 | return crypto.subtle.deriveKey( |
| 11 | { name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode("diffinery-secrets-v1"), info: new TextEncoder().encode(scope) }, |
| 12 | raw, |
| 13 | { name: "AES-GCM", length: 256 }, |
| 14 | false, |
| 15 | ["encrypt", "decrypt"], |
| 16 | ); |
| 17 | } |
| 18 | |
| 19 | export async function encryptSecret(masterKey: string | undefined, scope: string, plaintext: string): Promise<string> { |
| 20 | if (!masterKey) throw new Error("MASTER_KEY is not configured"); |
| 21 | const iv = crypto.getRandomValues(new Uint8Array(12)); |
| 22 | const key = await scopeKey(masterKey, scope); |
| 23 | const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: new TextEncoder().encode(scope) }, key, new TextEncoder().encode(plaintext))); |
| 24 | return `v1.${b64(iv)}.${b64(ct)}`; |
| 25 | } |
| 26 | |
| 27 | export async function decryptSecret(masterKey: string | undefined, scope: string, envelope: string): Promise<string> { |
| 28 | if (!masterKey) throw new Error("MASTER_KEY is not configured"); |
| 29 | const [v, ivB, ctB] = envelope.split("."); |
| 30 | if (v !== "v1" || !ivB || !ctB) throw new Error("bad secret envelope"); |
| 31 | const key = await scopeKey(masterKey, scope); |
| 32 | const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromB64(ivB) as BufferSource, additionalData: new TextEncoder().encode(scope) }, key, fromB64(ctB) as BufferSource); |
| 33 | return new TextDecoder().decode(pt); |
| 34 | } |