BetterFrame/server/src/shared/secrets.ts

159 lines
4.6 KiB
TypeScript
Raw Normal View History

/**
* Symmetric crypto and cluster key shared module (not a BSB plugin).
*
* initSecrets(config, log) SecretsApi
*/
import {
chmodSync,
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
import {
createCipheriv,
createDecipheriv,
randomBytes,
hkdfSync,
} from "node:crypto";
// ---- Public interface -------------------------------------------------------
export interface SecretsConfig {
dataDir: string;
systemdCredsName?: string;
}
export interface SecretsLog {
info(msg: string): void;
warn(msg: string): void;
}
export interface SecretsApi {
encryptString(plaintext: string, info?: string): string;
decryptString(ciphertext: string, info?: string): string;
generateClusterKey(): string;
encryptForCluster(plaintext: string, clusterKeyB64u: string): string;
}
// ---- Init -------------------------------------------------------------------
export function initSecrets(config: SecretsConfig, log: SecretsLog): SecretsApi {
const serverKey = loadServerKey(config, log);
function deriveSubkey(info: string): Buffer {
const out = hkdfSync(
"sha256",
serverKey,
Buffer.alloc(0),
Buffer.from(`betterframe.${info}`, "utf8"),
32,
);
return Buffer.from(out);
}
return {
encryptString(plaintext: string, info: string = "field"): string {
const subkey = deriveSubkey(info);
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", subkey, iv);
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return `v1.${b64u(iv)}.${b64u(tag)}.${b64u(ct)}`;
},
decryptString(ciphertext: string, info: string = "field"): string {
const parts = ciphertext.split(".");
if (parts.length !== 4 || parts[0] !== "v1") {
throw new Error("ciphertext: bad format");
}
const iv = b64uDecode(parts[1]!);
const tag = b64uDecode(parts[2]!);
const ct = b64uDecode(parts[3]!);
const subkey = deriveSubkey(info);
const decipher = createDecipheriv("aes-256-gcm", subkey, iv);
decipher.setAuthTag(tag);
const pt = Buffer.concat([decipher.update(ct), decipher.final()]);
return pt.toString("utf8");
},
generateClusterKey(): string {
return b64u(randomBytes(32));
},
encryptForCluster(plaintext: string, clusterKeyB64u: string): string {
const key = b64uDecode(clusterKeyB64u);
if (key.length !== 32) throw new Error("cluster key must be 32 bytes");
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return `v1.${b64u(iv)}.${b64u(tag)}.${b64u(ct)}`;
},
};
}
// ---- Key loading ------------------------------------------------------------
function loadServerKey(config: SecretsConfig, log: SecretsLog): Buffer {
const credsName = config.systemdCredsName ?? "betterframe-secret";
// 1. systemd-creds
const credsDir = process.env["CREDENTIALS_DIRECTORY"];
if (credsDir) {
const p = join(credsDir, credsName);
if (existsSync(p)) {
const buf = readFileSync(p);
if (buf.length >= 32) {
log.info("server key loaded from systemd-creds");
return buf.subarray(0, 32);
}
log.warn("systemd-creds file too short; falling back to dev key");
}
}
// 2. Dev fallback
const keyPath = join(config.dataDir, "secret.key");
if (existsSync(keyPath)) {
const buf = readFileSync(keyPath);
if (buf.length >= 32) {
log.info(`server key loaded from ${keyPath}`);
return buf.subarray(0, 32);
}
}
// 3. Generate new dev key
log.warn(
`GENERATING DEV SERVER KEY at ${keyPath} — production should use systemd-creds`,
);
try {
mkdirSync(dirname(keyPath), { recursive: true });
} catch {
/* exists or insufficient perms */
}
const fresh = randomBytes(32);
writeFileSync(keyPath, fresh, { mode: 0o600 });
try {
chmodSync(keyPath, 0o600);
} catch {
/* not POSIX; fine on dev */
}
return fresh;
}
// ---- base64url helpers ------------------------------------------------------
function b64u(buf: Buffer): string {
return buf
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
function b64uDecode(s: string): Buffer {
const padded = s + "=".repeat((4 - (s.length % 4)) % 4);
return Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64");
}