← 中文资源

简体中文版本

信封加密

信封加密用每个对象的数据密钥加密数据,再由 KMS 或 HSM 中的主密钥包裹数据密钥,降低暴露并简化轮换。

阅读英文原文

两层密钥,两种职责

数据密钥创建便宜,可以按对象、租户或字段唯一。主密钥保护成本高,几乎不离开 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));