mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add AES-256-GCM encryption at rest for sensitive settings
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
randomBytes,
|
||||
hkdf as hkdfCb,
|
||||
} from "node:crypto";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const hkdf = promisify(hkdfCb);
|
||||
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
const IV_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
const KEY_VERSION = 1;
|
||||
const PREFIX = "$ENC$";
|
||||
|
||||
async function deriveKey(masterKeyHex: string, context: string): Promise<Buffer> {
|
||||
const keyBytes = Buffer.from(masterKeyHex, "hex");
|
||||
const derived = await hkdf("sha256", keyBytes, Buffer.alloc(0), context, 32);
|
||||
return Buffer.from(derived);
|
||||
}
|
||||
|
||||
export async function encrypt(plaintext: string, masterKeyHex: string): Promise<string> {
|
||||
const key = await deriveKey(masterKeyHex, "snapotter-settings-encryption");
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
const blob = Buffer.concat([Buffer.from([KEY_VERSION]), iv, authTag, encrypted]);
|
||||
return `${PREFIX}${blob.toString("base64")}`;
|
||||
}
|
||||
|
||||
export async function decrypt(
|
||||
ciphertext: string,
|
||||
masterKeyHex: string,
|
||||
previousKeyHex?: string,
|
||||
): Promise<string | null> {
|
||||
if (!isEncrypted(ciphertext)) return ciphertext;
|
||||
|
||||
const blob = Buffer.from(ciphertext.slice(PREFIX.length), "base64");
|
||||
const _version = blob[0];
|
||||
const iv = blob.subarray(1, 1 + IV_LENGTH);
|
||||
const authTag = blob.subarray(1 + IV_LENGTH, 1 + IV_LENGTH + AUTH_TAG_LENGTH);
|
||||
const encrypted = blob.subarray(1 + IV_LENGTH + AUTH_TAG_LENGTH);
|
||||
|
||||
const tryDecrypt = async (keyHex: string): Promise<string | null> => {
|
||||
try {
|
||||
const key = await deriveKey(keyHex, "snapotter-settings-encryption");
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||
decipher.setAuthTag(authTag);
|
||||
return decipher.update(encrypted) + decipher.final("utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const result = await tryDecrypt(masterKeyHex);
|
||||
if (result !== null) return result;
|
||||
if (previousKeyHex) return tryDecrypt(previousKeyHex);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isEncrypted(value: string): boolean {
|
||||
return value.startsWith(PREFIX);
|
||||
}
|
||||
|
||||
export async function deriveAuditHmacKey(masterKeyHex: string): Promise<Buffer> {
|
||||
return deriveKey(masterKeyHex, "snapotter-audit-hmac");
|
||||
}
|
||||
@@ -97,6 +97,8 @@ const envSchema = z
|
||||
POSTHOG_API_KEY: z.string().default(""),
|
||||
POSTHOG_HOST: z.string().default("https://us.i.posthog.com"),
|
||||
SENTRY_DSN: z.string().default(""),
|
||||
DATA_ENCRYPTION_KEY: z.string().default(""),
|
||||
DATA_ENCRYPTION_KEY_PREVIOUS: z.string().default(""),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.STORAGE_MODE === "s3") {
|
||||
|
||||
@@ -11,6 +11,8 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { auditLog } from "../lib/audit.js";
|
||||
import { env } from "../config.js";
|
||||
import { encrypt, decrypt, isEncrypted } from "../lib/encryption.js";
|
||||
import { requirePermission } from "../permissions.js";
|
||||
import { requireAuth } from "../plugins/auth.js";
|
||||
|
||||
@@ -18,7 +20,27 @@ const settingsBodySchema = z.record(z.string().min(1), z.unknown());
|
||||
|
||||
const HTML_TAG_PATTERN = /<[a-z/!?][^>]*>/i;
|
||||
|
||||
const SENSITIVE_KEYS = new Set(["cookie_secret", "instance_id"]);
|
||||
const SENSITIVE_KEYS = new Set([
|
||||
"cookie_secret",
|
||||
"instance_id",
|
||||
"oidc_client_secret",
|
||||
"saml_idp_certificate",
|
||||
"siem_webhook_auth",
|
||||
]);
|
||||
|
||||
async function encryptIfSensitive(key: string, value: string): Promise<string> {
|
||||
if (!env.DATA_ENCRYPTION_KEY || !SENSITIVE_KEYS.has(key)) return value;
|
||||
return encrypt(value, env.DATA_ENCRYPTION_KEY);
|
||||
}
|
||||
|
||||
async function decryptIfNeeded(value: string): Promise<string> {
|
||||
if (!isEncrypted(value)) return value;
|
||||
if (!env.DATA_ENCRYPTION_KEY) return value;
|
||||
return (
|
||||
(await decrypt(value, env.DATA_ENCRYPTION_KEY, env.DATA_ENCRYPTION_KEY_PREVIOUS || undefined)) ??
|
||||
value
|
||||
);
|
||||
}
|
||||
|
||||
export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
||||
// GET /api/v1/settings — Get all settings as a key-value object
|
||||
@@ -32,7 +54,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
||||
const settings: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
if (!isAdmin && SENSITIVE_KEYS.has(row.key)) continue;
|
||||
settings[row.key] = row.value;
|
||||
settings[row.key] = await decryptIfNeeded(row.value);
|
||||
}
|
||||
|
||||
return reply.send({ settings });
|
||||
@@ -74,6 +96,8 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
||||
const now = new Date();
|
||||
|
||||
for (const { key, strValue } of entries) {
|
||||
const storedValue = await encryptIfSensitive(key, strValue);
|
||||
|
||||
// Upsert: insert or update on conflict
|
||||
const [existing] = await db
|
||||
.select()
|
||||
@@ -83,10 +107,10 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
||||
if (existing) {
|
||||
await db
|
||||
.update(schema.settings)
|
||||
.set({ value: strValue, updatedAt: now })
|
||||
.set({ value: storedValue, updatedAt: now })
|
||||
.where(eq(schema.settings.key, key));
|
||||
} else {
|
||||
await db.insert(schema.settings).values({ key, value: strValue });
|
||||
await db.insert(schema.settings).values({ key, value: storedValue });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +149,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
return reply.send({
|
||||
key: row.key,
|
||||
value: row.value,
|
||||
value: await decryptIfNeeded(row.value),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
encrypt,
|
||||
decrypt,
|
||||
isEncrypted,
|
||||
deriveAuditHmacKey,
|
||||
} from "../../../apps/api/src/lib/encryption.js";
|
||||
|
||||
describe("encryption", () => {
|
||||
const testKey = "a".repeat(64); // 32 bytes hex-encoded
|
||||
|
||||
it("encrypts and decrypts a value", async () => {
|
||||
const plaintext = "my-secret-oidc-client-secret";
|
||||
const encrypted = await encrypt(plaintext, testKey);
|
||||
expect(encrypted).not.toBe(plaintext);
|
||||
expect(isEncrypted(encrypted)).toBe(true);
|
||||
const decrypted = await decrypt(encrypted, testKey);
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
it("produces different ciphertext for same plaintext (random IV)", async () => {
|
||||
const plaintext = "same-value";
|
||||
const a = await encrypt(plaintext, testKey);
|
||||
const b = await encrypt(plaintext, testKey);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("isEncrypted returns false for plaintext", () => {
|
||||
expect(isEncrypted("just-a-normal-value")).toBe(false);
|
||||
expect(isEncrypted("")).toBe(false);
|
||||
});
|
||||
|
||||
it("decrypt returns null for wrong key", async () => {
|
||||
const encrypted = await encrypt("secret", testKey);
|
||||
const wrongKey = "b".repeat(64);
|
||||
const result = await decrypt(encrypted, wrongKey);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("decrypt tries previous key on failure", async () => {
|
||||
const oldKey = "c".repeat(64);
|
||||
const newKey = "d".repeat(64);
|
||||
const encrypted = await encrypt("secret", oldKey);
|
||||
const result = await decrypt(encrypted, newKey, oldKey);
|
||||
expect(result).toBe("secret");
|
||||
});
|
||||
|
||||
it("decrypt passes through non-encrypted values", async () => {
|
||||
const result = await decrypt("plain-text-value", testKey);
|
||||
expect(result).toBe("plain-text-value");
|
||||
});
|
||||
|
||||
it("deriveAuditHmacKey produces a 32-byte buffer", async () => {
|
||||
const key = await deriveAuditHmacKey(testKey);
|
||||
expect(key).toBeInstanceOf(Buffer);
|
||||
expect(key.length).toBe(32);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user