CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
workflow-secrets-crypto.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| abfa9ad | 1 | /** |
| 2 | * Workflow secrets — crypto primitives (no I/O, no DB). | |
| 3 | * | |
| 4 | * This module encapsulates the at-rest encryption used by `workflow_secrets` | |
| 5 | * rows and the `${{ secrets.NAME }}` template substitution applied at | |
| 6 | * step-execution time. The runner (Agent 5) calls `substituteSecrets` for | |
| 7 | * every step; the UI layer (Agent 7) calls `encryptSecret` when persisting a | |
| 8 | * new value and `decryptSecret` only via the DB loader (Agent 2, this file's | |
| 9 | * sibling `workflow-secrets.ts`). | |
| 10 | * | |
| 11 | * Crypto: AES-256-GCM with a single 32-byte master key sourced from | |
| 12 | * `process.env.WORKFLOW_SECRETS_KEY` (hex-encoded). The IV is a fresh random | |
| 13 | * 12 bytes per encryption; the GCM auth tag is the canonical 16 bytes. The | |
| 14 | * ciphertext blob on disk is base64("iv(12) || tag(16) || ciphertext"). | |
| 15 | * | |
| 16 | * Every public fn in this file is synchronous and returns `{ok:true,...}` / | |
| 17 | * `{ok:false,error}` — never throws, even on malformed input. That's a hard | |
| 18 | * contract the rest of the workflow engine depends on (the runner cannot | |
| 19 | * afford to panic mid-step on a mis-encoded blob). | |
| 20 | */ | |
| 21 | ||
| 22 | import { createCipheriv, createDecipheriv, randomBytes } from "crypto"; | |
| 23 | ||
| 24 | const ALGO = "aes-256-gcm"; | |
| 25 | const IV_LEN = 12; // GCM standard | |
| 26 | const TAG_LEN = 16; // GCM default auth tag length | |
| 27 | const KEY_LEN = 32; // AES-256 | |
| 28 | ||
| 29 | /** | |
| 30 | * Read the master key at call time (not module load). Returns `null` if the | |
| 31 | * env var is unset, not hex, or doesn't decode to exactly 32 bytes. Callers | |
| 32 | * must treat `null` as a soft failure — the caller's public surface should | |
| 33 | * return `{ok:false}`, never throw. | |
| 34 | * | |
| 35 | * We deliberately re-read the env on every call so tests can set the var, | |
| 36 | * run a case, then clear it, without needing to reload the module graph. | |
| 37 | */ | |
| 38 | export function getMasterKey(): Buffer | null { | |
| 39 | const hex = process.env.WORKFLOW_SECRETS_KEY; | |
| 40 | if (!hex) return null; | |
| 41 | // Tolerate leading/trailing whitespace from .env files and only hex chars. | |
| 42 | const trimmed = hex.trim(); | |
| 43 | if (!/^[0-9a-fA-F]+$/.test(trimmed)) return null; | |
| 44 | let buf: Buffer; | |
| 45 | try { | |
| 46 | buf = Buffer.from(trimmed, "hex"); | |
| 47 | } catch { | |
| 48 | return null; | |
| 49 | } | |
| 50 | if (buf.length !== KEY_LEN) return null; | |
| 51 | return buf; | |
| 52 | } | |
| 53 | ||
| 54 | /** | |
| 55 | * Encrypt a plaintext secret under the master key. | |
| 56 | * | |
| 57 | * Returns a single base64 string on success. Layout: | |
| 58 | * [0..12) IV (12 bytes, random) | |
| 59 | * [12..28) auth tag (16 bytes, GCM) | |
| 60 | * [28..) ciphertext (same length as plaintext, since GCM is stream-y) | |
| 61 | * | |
| 62 | * Failure modes: master key missing / wrong length / node crypto throws | |
| 63 | * (extremely unlikely for in-memory AES). All collapse to `{ok:false,error}`. | |
| 64 | */ | |
| 65 | export function encryptSecret( | |
| 66 | plaintext: string | |
| 67 | ): { ok: true; ciphertext: string } | { ok: false; error: string } { | |
| 68 | const key = getMasterKey(); | |
| 69 | if (!key) { | |
| 70 | return { | |
| 71 | ok: false, | |
| 72 | error: | |
| 73 | "WORKFLOW_SECRETS_KEY missing or not a 32-byte hex value (64 hex chars)", | |
| 74 | }; | |
| 75 | } | |
| 76 | if (typeof plaintext !== "string") { | |
| 77 | return { ok: false, error: "plaintext must be a string" }; | |
| 78 | } | |
| 79 | try { | |
| 80 | const iv = randomBytes(IV_LEN); | |
| 81 | const cipher = createCipheriv(ALGO, key, iv); | |
| 82 | const enc = Buffer.concat([ | |
| 83 | cipher.update(plaintext, "utf8"), | |
| 84 | cipher.final(), | |
| 85 | ]); | |
| 86 | const tag = cipher.getAuthTag(); | |
| 87 | if (tag.length !== TAG_LEN) { | |
| 88 | return { ok: false, error: "unexpected auth tag length" }; | |
| 89 | } | |
| 90 | const blob = Buffer.concat([iv, tag, enc]); | |
| 91 | return { ok: true, ciphertext: blob.toString("base64") }; | |
| 92 | } catch (err) { | |
| 93 | return { | |
| 94 | ok: false, | |
| 95 | error: `encrypt failed: ${err instanceof Error ? err.message : String(err)}`, | |
| 96 | }; | |
| 97 | } | |
| 98 | } | |
| 99 | ||
| 100 | /** | |
| 101 | * Decrypt a ciphertext produced by `encryptSecret`. | |
| 102 | * | |
| 103 | * Never throws. Returns `{ok:false}` on: | |
| 104 | * - master key missing / wrong length | |
| 105 | * - input not valid base64 | |
| 106 | * - blob shorter than IV+tag (28 bytes) | |
| 107 | * - GCM auth-tag mismatch (tampered ciphertext or wrong key) | |
| 108 | */ | |
| 109 | export function decryptSecret( | |
| 110 | ciphertext: string | |
| 111 | ): { ok: true; plaintext: string } | { ok: false; error: string } { | |
| 112 | const key = getMasterKey(); | |
| 113 | if (!key) { | |
| 114 | return { | |
| 115 | ok: false, | |
| 116 | error: | |
| 117 | "WORKFLOW_SECRETS_KEY missing or not a 32-byte hex value (64 hex chars)", | |
| 118 | }; | |
| 119 | } | |
| 120 | if (typeof ciphertext !== "string" || ciphertext.length === 0) { | |
| 121 | return { ok: false, error: "ciphertext must be a non-empty string" }; | |
| 122 | } | |
| 123 | let blob: Buffer; | |
| 124 | try { | |
| 125 | blob = Buffer.from(ciphertext, "base64"); | |
| 126 | } catch { | |
| 127 | return { ok: false, error: "ciphertext is not valid base64" }; | |
| 128 | } | |
| 129 | // Buffer.from with "base64" silently drops invalid chars rather than | |
| 130 | // throwing; also verify the decoded length is plausible. | |
| 131 | if (blob.length < IV_LEN + TAG_LEN) { | |
| 132 | return { ok: false, error: "ciphertext blob too short" }; | |
| 133 | } | |
| 134 | const iv = blob.subarray(0, IV_LEN); | |
| 135 | const tag = blob.subarray(IV_LEN, IV_LEN + TAG_LEN); | |
| 136 | const enc = blob.subarray(IV_LEN + TAG_LEN); | |
| 137 | try { | |
| 138 | const decipher = createDecipheriv(ALGO, key, iv); | |
| 139 | decipher.setAuthTag(tag); | |
| 140 | const dec = Buffer.concat([decipher.update(enc), decipher.final()]); | |
| 141 | return { ok: true, plaintext: dec.toString("utf8") }; | |
| 142 | } catch (err) { | |
| 143 | return { | |
| 144 | ok: false, | |
| 145 | error: `decrypt failed: ${err instanceof Error ? err.message : String(err)}`, | |
| 146 | }; | |
| 147 | } | |
| 148 | } | |
| 149 | ||
| 150 | /** | |
| 151 | * Substitute `${{ secrets.NAME }}` tokens in a template string. | |
| 152 | * | |
| 153 | * Matches (case-sensitive on NAME, flexible whitespace around the inner | |
| 154 | * `secrets.NAME` expression): | |
| 155 | * `${{ secrets.NAME }}` | |
| 156 | * `${{secrets.NAME}}` | |
| 157 | * `${{ secrets.NAME }}` | |
| 158 | * | |
| 159 | * Rules: | |
| 160 | * - If `NAME` is a key in `secrets`, the entire token is replaced with the | |
| 161 | * raw value (no quoting, no shell-escaping — that's the runner's job). | |
| 162 | * - Unknown names are **left untouched**. This is intentional: an empty | |
| 163 | * string would silently break commands (`curl -H "$TOKEN" ...`), and a | |
| 164 | * throw would abort the run. Leaving the literal placeholder makes the | |
| 165 | * failure visible in run logs. | |
| 166 | * - `$${{ secrets.X }}` (double-dollar) is an escape — it renders as the | |
| 167 | * literal `${{ secrets.X }}` with no lookup. Matches GitHub Actions / | |
| 168 | * docker-compose convention. | |
| 169 | * - Only `secrets.` is recognised here. `env.` / `matrix.` / `github.` are | |
| 170 | * expression-context references owned by Agent 4's if-evaluator and by | |
| 171 | * the runner step context; they pass through this fn untouched. | |
| 172 | */ | |
| 173 | export function substituteSecrets( | |
| 174 | template: string, | |
| 175 | secrets: Record<string, string> | |
| 176 | ): string { | |
| 177 | if (typeof template !== "string" || template.length === 0) return template; | |
| 178 | ||
| 179 | // Handle the escape form first by swapping it to a sentinel that cannot | |
| 180 | // appear in normal text (NUL bytes are stripped by most transports and | |
| 181 | // never legitimately appear in YAML we load). After the secrets pass we | |
| 182 | // swap the sentinel back to the literal token. | |
| 183 | const ESC_SENTINEL = " |