feat(crypto): boot-generated key + AES-256-GCM sealed values
This commit is contained in:
31
server/src/lib/crypto.ts
Normal file
31
server/src/lib/crypto.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
export function loadOrCreateKey(dataDir: string): Buffer {
|
||||||
|
mkdirSync(dataDir, { recursive: true });
|
||||||
|
const path = join(dataDir, "secret.key");
|
||||||
|
if (!existsSync(path)) writeFileSync(path, randomBytes(32), { mode: 0o600 });
|
||||||
|
const key = readFileSync(path);
|
||||||
|
if (key.length !== 32) throw new Error(`secret.key must be 32 bytes, got ${key.length}`);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encrypt(key: Buffer, plaintext: string): string {
|
||||||
|
const iv = randomBytes(12);
|
||||||
|
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||||
|
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||||
|
return "enc:" + Buffer.concat([iv, cipher.getAuthTag(), ct]).toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decrypt(key: Buffer, sealed: string): string {
|
||||||
|
if (!sealed.startsWith("enc:")) throw new Error("not an encrypted value");
|
||||||
|
const buf = Buffer.from(sealed.slice(4), "base64");
|
||||||
|
const decipher = createDecipheriv("aes-256-gcm", key, buf.subarray(0, 12));
|
||||||
|
decipher.setAuthTag(buf.subarray(12, 28));
|
||||||
|
return Buffer.concat([decipher.update(buf.subarray(28)), decipher.final()]).toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mask(secret: string): string {
|
||||||
|
return secret.length >= 5 ? "…" + secret.slice(-4) : "…";
|
||||||
|
}
|
||||||
35
server/test/crypto.test.ts
Normal file
35
server/test/crypto.test.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { mkdtempSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { decrypt, encrypt, loadOrCreateKey, mask } from "../src/lib/crypto";
|
||||||
|
|
||||||
|
describe("crypto", () => {
|
||||||
|
test("key is created once and reused", () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "helios-"));
|
||||||
|
const a = loadOrCreateKey(dir);
|
||||||
|
const b = loadOrCreateKey(dir);
|
||||||
|
expect(a.length).toBe(32);
|
||||||
|
expect(a.equals(b)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("encrypt/decrypt round-trip; ciphertexts differ per call", () => {
|
||||||
|
const key = loadOrCreateKey(mkdtempSync(join(tmpdir(), "helios-")));
|
||||||
|
const sealed = encrypt(key, "sk-or-v1-secret");
|
||||||
|
expect(sealed.startsWith("enc:")).toBe(true);
|
||||||
|
expect(decrypt(key, sealed)).toBe("sk-or-v1-secret");
|
||||||
|
expect(encrypt(key, "sk-or-v1-secret")).not.toBe(sealed);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tampering fails closed", () => {
|
||||||
|
const key = loadOrCreateKey(mkdtempSync(join(tmpdir(), "helios-")));
|
||||||
|
const sealed = encrypt(key, "x");
|
||||||
|
const bad = sealed.slice(0, -2) + (sealed.endsWith("A") ? "BB" : "AA");
|
||||||
|
expect(() => decrypt(key, bad)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mask keeps only tail", () => {
|
||||||
|
expect(mask("sk-or-v1-abcdef")).toBe("…cdef");
|
||||||
|
expect(mask("abc")).toBe("…");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user