diff --git a/.env.example b/.env.example index 3811cd0..dd2280f 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,6 @@ POSTGRES_USER=lyra POSTGRES_PASSWORD=lyra POSTGRES_DB=lyra DATABASE_URL=postgresql://lyra:lyra@db:5432/lyra + +# 32 random bytes, base64. Generate with: openssl rand -base64 32 +LYRA_SECRET_KEY=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY= diff --git a/docker-compose.yml b/docker-compose.yml index 1e98b97..8af4498 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,7 @@ services: restart: unless-stopped environment: DATABASE_URL: ${DATABASE_URL} + LYRA_SECRET_KEY: ${LYRA_SECRET_KEY} ports: # host:container — host 8770 avoids the Quill app on 3000 (and Lidarr on 8686) - "8770:3000" @@ -33,6 +34,7 @@ services: restart: unless-stopped environment: DATABASE_URL: ${DATABASE_URL} + LYRA_SECRET_KEY: ${LYRA_SECRET_KEY} depends_on: db: condition: service_healthy diff --git a/web/src/lib/crypto.test.ts b/web/src/lib/crypto.test.ts new file mode 100644 index 0000000..b4ed54e --- /dev/null +++ b/web/src/lib/crypto.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { encryptSecret, decryptSecret } from "./crypto"; + +beforeAll(() => { + // base64 of the 32-byte ASCII string "0123456789abcdef0123456789abcdef" + process.env.LYRA_SECRET_KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="; +}); + +describe("crypto", () => { + it("round-trips a secret", () => { + const env = encryptSecret("hunter2"); + expect(env).not.toContain("hunter2"); + expect(decryptSecret(env)).toBe("hunter2"); + }); + + it("produces a well-formed envelope", () => { + const env = JSON.parse(encryptSecret("x")); + expect(typeof env.iv).toBe("string"); + expect(typeof env.ct).toBe("string"); + expect(typeof env.tag).toBe("string"); + }); + + it("rejects a tampered envelope", () => { + const env = JSON.parse(encryptSecret("secret")); + env.ct = Buffer.from("garbage").toString("base64"); + expect(() => decryptSecret(JSON.stringify(env))).toThrow(); + }); +}); diff --git a/web/src/lib/crypto.ts b/web/src/lib/crypto.ts new file mode 100644 index 0000000..1670ff4 --- /dev/null +++ b/web/src/lib/crypto.ts @@ -0,0 +1,29 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; + +function key(): Buffer { + const b64 = process.env.LYRA_SECRET_KEY; + if (!b64) throw new Error("LYRA_SECRET_KEY is not set"); + const k = Buffer.from(b64, "base64"); + if (k.length !== 32) throw new Error("LYRA_SECRET_KEY must decode to 32 bytes"); + return k; +} + +export function encryptSecret(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()]); + const tag = cipher.getAuthTag(); + return JSON.stringify({ + iv: iv.toString("base64"), + ct: ct.toString("base64"), + tag: tag.toString("base64"), + }); +} + +export function decryptSecret(envelope: string): string { + const { iv, ct, tag } = JSON.parse(envelope) as { iv: string; ct: string; tag: string }; + const decipher = createDecipheriv("aes-256-gcm", key(), Buffer.from(iv, "base64")); + decipher.setAuthTag(Buffer.from(tag, "base64")); + const pt = Buffer.concat([decipher.update(Buffer.from(ct, "base64")), decipher.final()]); + return pt.toString("utf8"); +}