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

server-targets-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.

server-targets-crypto.tsBlame140 lines · 1 contributor
783dd46Claude1/**
2 * Server-target crypto primitives (no I/O, no DB).
3 *
4 * Mirrors `workflow-secrets-crypto.ts` — AES-256-GCM under a 32-byte master
5 * key sourced from `SERVER_TARGETS_KEY` (hex). Used for two ciphertext
6 * columns:
7 * - `server_targets.encrypted_private_key` (the SSH private key)
8 * - `server_target_env.encrypted_value` (per-target env var values)
9 *
10 * Both are addressed through the same primitives because they share the
11 * same threat model: an attacker who reads the DB without the master key
12 * cannot connect to or impersonate the target.
13 *
14 * Every fn returns `{ok:true,...}` / `{ok:false,error}` — never throws.
15 */
16
17import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
18
19const ALGO = "aes-256-gcm";
20const IV_LEN = 12;
21const TAG_LEN = 16;
22const KEY_LEN = 32;
23
24export function getMasterKey(): Buffer | null {
25 const hex = process.env.SERVER_TARGETS_KEY;
26 if (!hex) return null;
27 const trimmed = hex.trim();
28 if (!/^[0-9a-fA-F]+$/.test(trimmed)) return null;
29 let buf: Buffer;
30 try {
31 buf = Buffer.from(trimmed, "hex");
32 } catch {
33 return null;
34 }
35 if (buf.length !== KEY_LEN) return null;
36 return buf;
37}
38
39export function encryptValue(
40 plaintext: string
41): { ok: true; ciphertext: string } | { ok: false; error: string } {
42 const key = getMasterKey();
43 if (!key) {
44 return {
45 ok: false,
46 error:
47 "SERVER_TARGETS_KEY missing or not a 32-byte hex value (64 hex chars)",
48 };
49 }
50 if (typeof plaintext !== "string") {
51 return { ok: false, error: "plaintext must be a string" };
52 }
53 try {
54 const iv = randomBytes(IV_LEN);
55 const cipher = createCipheriv(ALGO, key, iv);
56 const enc = Buffer.concat([
57 cipher.update(plaintext, "utf8"),
58 cipher.final(),
59 ]);
60 const tag = cipher.getAuthTag();
61 if (tag.length !== TAG_LEN) {
62 return { ok: false, error: "unexpected auth tag length" };
63 }
64 const blob = Buffer.concat([iv, tag, enc]);
65 return { ok: true, ciphertext: blob.toString("base64") };
66 } catch (err) {
67 return {
68 ok: false,
69 error: `encrypt failed: ${err instanceof Error ? err.message : String(err)}`,
70 };
71 }
72}
73
74export function decryptValue(
75 ciphertext: string
76): { ok: true; plaintext: string } | { ok: false; error: string } {
77 const key = getMasterKey();
78 if (!key) {
79 return {
80 ok: false,
81 error:
82 "SERVER_TARGETS_KEY missing or not a 32-byte hex value (64 hex chars)",
83 };
84 }
85 if (typeof ciphertext !== "string" || ciphertext.length === 0) {
86 return { ok: false, error: "ciphertext must be a non-empty string" };
87 }
88 let blob: Buffer;
89 try {
90 blob = Buffer.from(ciphertext, "base64");
91 } catch {
92 return { ok: false, error: "ciphertext is not valid base64" };
93 }
94 if (blob.length < IV_LEN + TAG_LEN) {
95 return { ok: false, error: "ciphertext blob too short" };
96 }
97 const iv = blob.subarray(0, IV_LEN);
98 const tag = blob.subarray(IV_LEN, IV_LEN + TAG_LEN);
99 const enc = blob.subarray(IV_LEN + TAG_LEN);
100 try {
101 const decipher = createDecipheriv(ALGO, key, iv);
102 decipher.setAuthTag(tag);
103 const dec = Buffer.concat([decipher.update(enc), decipher.final()]);
104 return { ok: true, plaintext: dec.toString("utf8") };
105 } catch (err) {
106 return {
107 ok: false,
108 error: `decrypt failed: ${err instanceof Error ? err.message : String(err)}`,
109 };
110 }
111}
112
113/**
114 * Validate an env var name. Same rule as POSIX-ish env: leading
115 * letter/underscore, then letters/digits/underscores. We're a little
116 * stricter than POSIX in that we require uppercase + digits + underscore
117 * so KEY=value lines in the materialised .env file are predictable.
118 */
119export function isValidEnvName(name: unknown): name is string {
120 return typeof name === "string" && /^[A-Z_][A-Z0-9_]*$/.test(name);
121}
122
123/**
124 * Render an env-vars map as a `.env` file body — `KEY=value\n` lines with
125 * values single-quoted and embedded single quotes escaped. This matches the
126 * common `set -a; source /path/to/file; set +a` deploy-script pattern and
127 * is what `materializeEnv` produces before scp'ing it to the box.
128 */
129export function renderDotenv(env: Record<string, string>): string {
130 const keys = Object.keys(env).sort();
131 return (
132 keys
133 .map((k) => {
134 const v = env[k] ?? "";
135 const quoted = "'" + v.replace(/'/g, "'\\''") + "'";
136 return `${k}=${quoted}`;
137 })
138 .join("\n") + (keys.length ? "\n" : "")
139 );
140}