Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

187 lines · 8.6 KBRaw History
1import { 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 */
9export 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 */
20export 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 */
31export 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 */
44export 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`. */
59export 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 */
81async 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
92const kids = new Map<string, Promise<string>>();
93
94/** The key id that v2 envelopes sealed with this master key carry. */
95export 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
109export 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
118export 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. */
125export 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
152export 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 */
163export 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 */
180export 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}