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.
| 783dd46 | 1 | /** |
| 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 | ||
| 17 | import { createCipheriv, createDecipheriv, randomBytes } from "crypto"; | |
| 18 | ||
| 19 | const ALGO = "aes-256-gcm"; | |
| 20 | const IV_LEN = 12; | |
| 21 | const TAG_LEN = 16; | |
| 22 | const KEY_LEN = 32; | |
| 23 | ||
| 24 | export 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 | ||
| 39 | export 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 | ||
| 74 | export 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 | */ | |
| 119 | export 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 | */ | |
| 129 | export 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 | } |