| 1 | import { b64, fromB64, safeEqual } from "./util.js"; |
| 2 | |
| 3 | /** |
| 4 | * Everything Diffinery encrypts or signs with its own key starts from MASTER_KEY. To rotate it, an |
| 5 | * operator moves the old value to MASTER_KEY_PREV and sets a new MASTER_KEY: from then on, new |
| 6 | * ciphertexts and signatures use MASTER_KEY, and what was made with the old key still decrypts or |
| 7 | * verifies until MASTER_KEY_PREV is removed (docs/self-hosting/configuration.md, "Rotating MASTER_KEY"). |
| 8 | */ |
| 9 | export interface MasterKeyEnv { |
| 10 | /** Secret: base64 32 bytes; the current root key. */ |
| 11 | MASTER_KEY?: string; |
| 12 | /** Secret: the root key MASTER_KEY replaced, still accepted while a rotation is under way. */ |
| 13 | MASTER_KEY_PREV?: string; |
| 14 | } |
| 15 | |
| 16 | /** |
| 17 | * The configured master keys, the current one first and then the previous one while a rotation is |
| 18 | * under way. Empty without MASTER_KEY: MASTER_KEY_PREV alone never makes a key current. |
| 19 | */ |
| 20 | export function masterKeys(env: MasterKeyEnv): string[] { |
| 21 | const current = env.MASTER_KEY; |
| 22 | if (!current) return []; |
| 23 | const prev = env.MASTER_KEY_PREV; |
| 24 | return prev && prev !== current ? [current, prev] : [current]; |
| 25 | } |
| 26 | |
| 27 | /** |
| 28 | * The secrets a signer derives its key from: the master keys, or `devFallback` when no MASTER_KEY is |
| 29 | * set (local development only; callers pass null outside it, and then this throws `missing`). |
| 30 | */ |
| 31 | export function signingSecrets(env: MasterKeyEnv, devFallback: string | null, missing: string): string[] { |
| 32 | const keys = masterKeys(env); |
| 33 | if (keys.length) return keys; |
| 34 | if (devFallback !== null) return [devFallback]; |
| 35 | throw new Error(missing); |
| 36 | } |
| 37 | |
| 38 | /** |
| 39 | * Whether `tag` is what `mac` computes under any of `secrets`. New tags are always made with the |
| 40 | * first (current) secret; this is the matching check that also takes a tag made with the previous |
| 41 | * one. An error computing the current tag propagates as before rotation existed; one under the |
| 42 | * previous secret (say a MASTER_KEY_PREV that isn't valid base64) only means "no match". |
| 43 | */ |
| 44 | export async function matchesAnyKey(secrets: readonly string[], tag: string, mac: (secret: string) => Promise<string>): Promise<boolean> { |
| 45 | for (const [i, s] of secrets.entries()) { |
| 46 | let expected: string; |
| 47 | try { |
| 48 | expected = await mac(s); |
| 49 | } catch (e) { |
| 50 | if (i === 0) throw e; |
| 51 | continue; |
| 52 | } |
| 53 | if (safeEqual(expected, tag)) return true; |
| 54 | } |
| 55 | return false; |
| 56 | } |
| 57 | |
| 58 | /** HMAC-SHA256 of `data` under the UTF-8 bytes of `secret`. */ |
| 59 | export async function hmacSha256(secret: string, data: string): Promise<Uint8Array> { |
| 60 | const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); |
| 61 | return new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data))); |
| 62 | } |
| 63 | |
| 64 | // ---------------------------------------------------------------- envelope encryption |
| 65 | |
| 66 | /** |
| 67 | * Envelope encryption for secrets. Each scope (repo or org) gets its own AES-GCM |
| 68 | * key derived from MASTER_KEY with HKDF, so a leaked ciphertext from one repo |
| 69 | * cannot be replayed into another. |
| 70 | * |
| 71 | * Envelopes: |
| 72 | * - `v2.<kid>.<iv>.<ct>`, written now. `kid` names the master key that sealed it: the first 8 hex |
| 73 | * characters of SHA-256 over the key's raw (base64-decoded) bytes, which an operator can compute with |
| 74 | * `printf %s "$KEY" | base64 -d | sha256sum | cut -c1-8`. It identifies the key, it isn't secret, |
| 75 | * and it says nothing useful about a 256-bit random key. |
| 76 | * - `v1.<iv>.<ct>`, written before keys could be rotated; it says nothing about its key, so both |
| 77 | * configured keys are tried. |
| 78 | * The AES-GCM key, IV size and additional data (the scope) are the same in both, so a v1 envelope's |
| 79 | * ciphertext is exactly what v2 would carry. |
| 80 | */ |
| 81 | async function scopeKey(masterKeyB64: string, scope: string): Promise<CryptoKey> { |
| 82 | const raw = await crypto.subtle.importKey("raw", fromB64(masterKeyB64) as BufferSource, "HKDF", false, ["deriveKey"]); |
| 83 | return crypto.subtle.deriveKey( |
| 84 | { name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode("diffinery-secrets-v1"), info: new TextEncoder().encode(scope) }, |
| 85 | raw, |
| 86 | { name: "AES-GCM", length: 256 }, |
| 87 | false, |
| 88 | ["encrypt", "decrypt"], |
| 89 | ); |
| 90 | } |
| 91 | |
| 92 | const kids = new Map<string, Promise<string>>(); |
| 93 | |
| 94 | /** The key id that v2 envelopes sealed with this master key carry. */ |
| 95 | export function keyId(masterKeyB64: string): Promise<string> { |
| 96 | let id = kids.get(masterKeyB64); |
| 97 | if (!id) { |
| 98 | id = (async () => { |
| 99 | const d = new Uint8Array(await crypto.subtle.digest("SHA-256", fromB64(masterKeyB64) as BufferSource)); |
| 100 | return [...d.slice(0, 4)].map((x) => x.toString(16).padStart(2, "0")).join(""); |
| 101 | })(); |
| 102 | // A key that isn't base64 rejects; don't keep that around. |
| 103 | id.catch(() => kids.delete(masterKeyB64)); |
| 104 | kids.set(masterKeyB64, id); |
| 105 | } |
| 106 | return id; |
| 107 | } |
| 108 | |
| 109 | export async function encryptSecret(env: MasterKeyEnv, scope: string, plaintext: string): Promise<string> { |
| 110 | const [masterKey] = masterKeys(env); |
| 111 | if (!masterKey) throw new Error("MASTER_KEY is not configured"); |
| 112 | const iv = crypto.getRandomValues(new Uint8Array(12)); |
| 113 | const key = await scopeKey(masterKey, scope); |
| 114 | const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: new TextEncoder().encode(scope) }, key, new TextEncoder().encode(plaintext))); |
| 115 | return `v2.${await keyId(masterKey)}.${b64(iv)}.${b64(ct)}`; |
| 116 | } |
| 117 | |
| 118 | export interface OpenedSecret { |
| 119 | plaintext: string; |
| 120 | /** Sealed with the current MASTER_KEY in the current format; false means it is worth sealing again. */ |
| 121 | current: boolean; |
| 122 | } |
| 123 | |
| 124 | /** Decrypts an envelope with whichever configured master key sealed it, and says whether it is current. */ |
| 125 | export async function openSecret(env: MasterKeyEnv, scope: string, envelope: string): Promise<OpenedSecret> { |
| 126 | const keys = masterKeys(env); |
| 127 | if (!keys.length) throw new Error("MASTER_KEY is not configured"); |
| 128 | const parts = envelope.split("."); |
| 129 | const v1 = parts[0] === "v1" && parts.length === 3; |
| 130 | const v2 = parts[0] === "v2" && parts.length === 4 && /^[0-9a-f]{8}$/.test(parts[1]!); |
| 131 | const kid = v2 ? parts[1]! : null; |
| 132 | const [ivB, ctB] = v2 ? parts.slice(2) : parts.slice(1); |
| 133 | if ((!v1 && !v2) || !ivB || !ctB) throw new Error("bad secret envelope"); |
| 134 | // The key the kid names first; the others after it, in case two keys ever share a kid. |
| 135 | const ids = await Promise.all(keys.map((k) => keyId(k).catch(() => null))); |
| 136 | const order = kid === null ? keys.map((_, i) => i) : [...keys.keys()].sort((a, b) => Number(ids[b] === kid) - Number(ids[a] === kid)); |
| 137 | const iv = fromB64(ivB); |
| 138 | const ct = fromB64(ctB); |
| 139 | let failure: unknown = null; |
| 140 | for (const i of order) { |
| 141 | try { |
| 142 | const key = await scopeKey(keys[i]!, scope); |
| 143 | const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: iv as BufferSource, additionalData: new TextEncoder().encode(scope) }, key, ct as BufferSource); |
| 144 | return { plaintext: new TextDecoder().decode(pt), current: i === 0 && kid !== null && kid === ids[0] }; |
| 145 | } catch (e) { |
| 146 | failure ??= e; |
| 147 | } |
| 148 | } |
| 149 | throw new Error(`the secret doesn't decrypt with ${keys.length > 1 ? "MASTER_KEY or MASTER_KEY_PREV" : "MASTER_KEY"}`, { cause: failure }); |
| 150 | } |
| 151 | |
| 152 | export async function decryptSecret(env: MasterKeyEnv, scope: string, envelope: string): Promise<string> { |
| 153 | return (await openSecret(env, scope, envelope)).plaintext; |
| 154 | } |
| 155 | |
| 156 | /** |
| 157 | * Decrypts a stored secret for use and, when it was sealed with the previous key or as v1, stores it |
| 158 | * again sealed with the current MASTER_KEY through `store`, so secrets in use move to the current key |
| 159 | * by themselves during a rotation. `store` must only replace the row while it still holds `envelope` |
| 160 | * (compare and set), so a value saved meanwhile is never overwritten. A failed store is logged and |
| 161 | * otherwise ignored: using the secret never depends on it. |
| 162 | */ |
| 163 | export async function decryptAndReseal(env: MasterKeyEnv, scope: string, envelope: string, store: (sealed: string) => unknown): Promise<string> { |
| 164 | const s = await openSecret(env, scope, envelope); |
| 165 | if (!s.current) { |
| 166 | try { |
| 167 | await store(await encryptSecret(env, scope, s.plaintext)); |
| 168 | } catch (e) { |
| 169 | console.error("re-encrypting a secret with the current MASTER_KEY failed", scope, (e as Error).message); |
| 170 | } |
| 171 | } |
| 172 | return s.plaintext; |
| 173 | } |
| 174 | |
| 175 | /** |
| 176 | * The envelope to store in place of `envelope` when a row is written anyway: the same secret sealed |
| 177 | * with the current MASTER_KEY when it was sealed with the previous key or as v1, otherwise `envelope` |
| 178 | * itself. One that doesn't decrypt is kept as it is, so writing a row never loses what it held. |
| 179 | */ |
| 180 | export async function resealSecret(env: MasterKeyEnv, scope: string, envelope: string): Promise<string> { |
| 181 | try { |
| 182 | const s = await openSecret(env, scope, envelope); |
| 183 | return s.current ? envelope : await encryptSecret(env, scope, s.plaintext); |
| 184 | } catch { |
| 185 | return envelope; |
| 186 | } |
| 187 | } |