sc-site-sync/lib/common/aesGcm.js
2026-10-19 01:25:16 -05:00

124 lines
4.2 KiB
JavaScript

// 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
};