两层密钥,两种职责
数据密钥创建便宜,可以按对象、租户或字段唯一。主密钥保护成本高,几乎不离开 KMS 或 HSM。信封加密各取所长:数据密钥在本地做批量加密,主密钥保护数据密钥而不接触负载。
工作机制
应用让 KMS 生成数据密钥,响应包含明文密钥材料,以及用主密钥加密后的同一密钥。应用用明文密钥加密负载,随后从内存丢弃,把密文与包裹后的密钥、初始化向量、认证标签和主密钥标识一起保存。
解密时读取包裹密钥,调用 KMS 解包,再本地解密。轮换主密钥只需重新包裹已存的数据密钥,不必重新加密数 TB 负载;轮换数据密钥才需要重新加密该对象。
何时使用与取舍
数据静态存储需要按对象密钥、跨区域可移植或租户间密码学隔离时使用,这是对象存储、数据库字段加密和备份的标准做法。能放进 KMS 负载上限的小秘密,直接加密更简单。
KMS 成为读路径上的关键依赖,应短暂缓存解包后的数据密钥并为 KMS 故障做预案。进程内存中的密钥材料可能通过核心转储泄露,应尽量锁定内存。层次越多,元数据越容易出错:缺少密钥 ID 会让密文即使有正确主密钥也无法解密。
示例与常见误解
某 SaaS 为每个租户的文档使用独立数据密钥,由区域主密钥包裹。监管要求对某租户做密码学删除时,只需删除该租户的包裹密钥,密文即不可读,其他租户不受影响。
常见误解是 KMS 加密了文档。它只加密小密钥,批量工作由应用完成,这也是该模式能扩展的原因。相关概念:静态加密、密钥管理、密钥轮换。
代码示例
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
const ALGORITHM = "aes-256-gcm";
export interface EncryptedPayload {
salt: string;
iv: string;
tag: string;
data: string;
}
export function deriveAesKey(passphrase: string, salt: Buffer): Buffer {
if (typeof passphrase !== "string" || passphrase.length < 12) {
throw new RangeError("passphrase must be at least 12 characters");
}
return scryptSync(passphrase.normalize("NFKC"), salt, 32);
}
export function encryptString(plaintext: string, passphrase: string): EncryptedPayload {
const salt = randomBytes(16);
const iv = randomBytes(12);
const cipher = createCipheriv(ALGORITHM, deriveAesKey(passphrase, salt), iv);
const data = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
return {
salt: salt.toString("base64"),
iv: iv.toString("base64"),
tag: cipher.getAuthTag().toString("base64"),
data: data.toString("base64"),
};
}
export function decryptString(payload: EncryptedPayload, passphrase: string): string {
const salt = Buffer.from(payload.salt, "base64");
const iv = Buffer.from(payload.iv, "base64");
const tag = Buffer.from(payload.tag, "base64");
const decipher = createDecipheriv(ALGORITHM, deriveAesKey(passphrase, salt), iv);
decipher.setAuthTag(tag);
return Buffer.concat([
decipher.update(Buffer.from(payload.data, "base64")),
decipher.final(),
]).toString("utf8");
}
type PathTree = Map<string, PathTree>;
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
export function omitDeep<Value>(value: Value, paths: readonly string[]): Value {
if (!Array.isArray(paths)) throw new TypeError("paths must be an array");
if (paths.length === 0) return value;
const tree: PathTree = new Map();
for (const path of paths) {
if (typeof path !== "string" || path.trim() === "") {
throw new TypeError("paths must be non-empty strings");
}
const segments = path.split(".").map((segment) => segment.trim());
if (segments.some((segment) => segment === "")) throw new Error(`invalid path "${path}"`);
for (const segment of segments) {
if (FORBIDDEN_KEYS.has(segment)) throw new Error(`path "${path}" contains a forbidden key`);
}
insert(tree, segments);
}
return clone(value, tree) as Value;
}
function insert(tree: PathTree, segments: readonly string[]): void {
let node = tree;
for (const segment of segments) {
let child = node.get(segment);
if (child === undefined) {
child = new Map();
node.set(segment, child);
}
node = child;
}
}
function clone(value: unknown, tree: PathTree): unknown {
if (Array.isArray(value)) {
return value.map((item, index) => {
const child = tree.get(String(index));
if (child !== undefined && child.size === 0) return undefined;
return clone(item, child ?? tree);
});
}
if (value !== null && typeof value === "object") {
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
throw new TypeError("omitDeep only supports plain objects and arrays");
}
const output: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
const child = tree.get(key);
if (child !== undefined && child.size === 0) continue;
output[key] = clone(item, child ?? new Map());
}
return output;
}
return value;
}
const source = {
id: 1,
password: "secret",
profile: { email: "[email protected]", token: "abc", nested: { keep: true } },
items: [{ id: 1, token: "a" }, { id: 2, token: "b" }],
};
const sanitized = omitDeep(source, ["password", "profile.token", "items.token"]);
console.log(JSON.stringify(sanitized));
console.log(JSON.stringify(source));