Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

workflow-secrets-crypto.tsBlame208 lines · 1 contributor
abfa9adClaude1/**
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
22import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
23
24const ALGO = "aes-256-gcm";
25const IV_LEN = 12; // GCM standard
26const TAG_LEN = 16; // GCM default auth tag length
27const 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 */
38export 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 */
65export 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 */
109export 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 */
173export 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 = "GLUECRON_SECRETS_ESCAPE";
184 // $${{ <anything-but-newline> }} — we only restore the literal form; we
185 // don't actually care about the inner contents for the escape case.
186 const escapeRe = /\$\$\{\{\s*secrets\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
187 const escaped = template.replace(escapeRe, (_m, name: string) => {
188 return `${ESC_SENTINEL}${name}${ESC_SENTINEL}`;
189 });
190
191 const tokenRe = /\$\{\{\s*secrets\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
192 const substituted = escaped.replace(tokenRe, (match, name: string) => {
193 if (Object.prototype.hasOwnProperty.call(secrets, name)) {
194 return secrets[name]!;
195 }
196 // Unknown name — leave the original token intact.
197 return match;
198 });
199
200 // Restore escaped tokens as literal `${{ secrets.NAME }}`.
201 const restoreRe = new RegExp(
202 `${ESC_SENTINEL}([A-Za-z_][A-Za-z0-9_]*)${ESC_SENTINEL}`,
203 "g"
204 );
205 return substituted.replace(restoreRe, (_m, name: string) => {
206 return `\${{ secrets.${name} }}`;
207 });
208}