// AES-256-GCM at-rest sealing, keyed by sessionKey. // // createSealer(keySpec) binds one session-secret-derived key (see // sessionKey.deriveKey) and offers two storage formats: // struct -- { ciphertext, iv, tag } as Buffers (seal/open), or as hex strings // for DB columns (sealHex/openHex: Saltcorn's sqlite layer // JSON-stringifies values, so raw Buffers must not be stored) // string -- "scs1:" + base64(iv | tag | ciphertext): one opaque value for a // plugin configuration field (sealString/openString) // Both pin the GCM tag to the full 16 bytes (authTagLength): without it Node // accepts a truncated tag (down to 4 bytes), which weakens forgery resistance. // // Threat model: protects DB-only exposure (dumps, backups, peer sync, an admin // reading config). It does NOT protect against an attacker who also holds the // host env / config file -- they hold the session secret. const crypto = require("crypto"); const { deriveKey } = require("./sessionKey"); const GCM_ALGORITHM = "aes-256-gcm"; const IV_BYTES = 12; const TAG_BYTES = 16; // saltcorn-secret, string format v1 const STRING_PREFIX = "scs1:"; const toBuffer = (plaintext) => { return Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(String(plaintext), "utf8"); }; const seal = (key, plaintext) => { const iv = crypto.randomBytes(IV_BYTES); const cipher = crypto.createCipheriv(GCM_ALGORITHM, key, iv, { authTagLength: TAG_BYTES }); const ciphertext = Buffer.concat([cipher.update(toBuffer(plaintext)), cipher.final()]); return { ciphertext: ciphertext, iv: iv, tag: cipher.getAuthTag() }; }; const open = (key, sealed) => { const decipher = crypto.createDecipheriv(GCM_ALGORITHM, key, sealed.iv, { authTagLength: TAG_BYTES }); decipher.setAuthTag(sealed.tag); return Buffer.concat([decipher.update(sealed.ciphertext), decipher.final()]); }; const sealHex = (key, plaintext) => { const sealed = seal(key, plaintext); return { ciphertext: sealed.ciphertext.toString("hex"), iv: sealed.iv.toString("hex"), tag: sealed.tag.toString("hex") }; }; const openHex = (key, sealedHex) => { return open(key, { ciphertext: Buffer.from(sealedHex.ciphertext, "hex"), iv: Buffer.from(sealedHex.iv, "hex"), tag: Buffer.from(sealedHex.tag, "hex") }); }; const isSealedString = (value) => { return typeof value === "string" && value.startsWith(STRING_PREFIX); }; const sealString = (key, plaintext) => { const sealed = seal(key, plaintext); return STRING_PREFIX + Buffer.concat([sealed.iv, sealed.tag, sealed.ciphertext]).toString("base64"); }; // The plaintext (utf8), or null when value is not a sealed string or cannot be // opened (wrong/rotated key, tampering, truncation, corruption). const openString = (key, value) => { if (!isSealedString(value)) { return null; } try { const raw = Buffer.from(value.slice(STRING_PREFIX.length), "base64"); return open(key, { iv: raw.subarray(0, IV_BYTES), tag: raw.subarray(IV_BYTES, IV_BYTES + TAG_BYTES), ciphertext: raw.subarray(IV_BYTES + TAG_BYTES) }).toString("utf8"); } catch (e) { return null; } }; // keySpec = { owner, salt, info } (see sessionKey.deriveKey). The key is derived // on each use (cached there), so a missing session secret surfaces at use time. const createSealer = (keySpec) => { const key = () => deriveKey(keySpec); return { seal: (plaintext) => seal(key(), plaintext), open: (sealed) => open(key(), sealed), sealHex: (plaintext) => sealHex(key(), plaintext), openHex: (sealedHex) => openHex(key(), sealedHex), sealString: (plaintext) => sealString(key(), plaintext), // null also when no acceptable session secret is configured: the caller // treats every null as "re-enter the secret". openString: (value) => { try { return openString(key(), value); } catch (e) { return null; } } }; }; module.exports = { STRING_PREFIX, isSealedString, createSealer };