124 lines
4.2 KiB
JavaScript
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
|
|
};
|