feat(server): 敏感字段加密与脱敏(F25)

- 新增敏感字段加密服务(AES-256-GCM,FIELD_ENCRYPTION_KEY)
- users 详情返回银行卡/身份证脱敏字段;新增 /users/:id/sensitive 特权明文接口并写审计
- 权限新增 system.users_sensitive(hr/admin)
- 更新 env/README 与 v3 CSV 状态推进
这个提交包含在:
2026-02-09 20:51:34 +08:00
父节点 c791e67b20
当前提交 1855a82011
共修改 9 个文件,包含 428 行新增和 7 行删除
+147
查看文件
@@ -0,0 +1,147 @@
const crypto = require("node:crypto");
const ENCRYPTION_VERSION_PREFIX = "v1:";
const AES_ALGO = "aes-256-gcm";
const IV_LENGTH_BYTES = 12;
const TAG_LENGTH_BYTES = 16;
function resolveFieldEncryptionKey(config) {
const raw = config?.fieldEncryptionKey;
if (!raw || typeof raw !== "string" || !raw.trim()) {
return null;
}
let key;
try {
key = Buffer.from(raw.trim(), "base64");
} catch {
return null;
}
if (key.length !== 32) {
return null;
}
return key;
}
function encryptString(plaintext, key) {
const input = typeof plaintext === "string" ? plaintext : "";
if (!input) {
return "";
}
if (!Buffer.isBuffer(key) || key.length !== 32) {
throw new Error("Invalid encryption key");
}
const iv = crypto.randomBytes(IV_LENGTH_BYTES);
const cipher = crypto.createCipheriv(AES_ALGO, key, iv);
const cipherText = Buffer.concat([cipher.update(input, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
const payload = `${ENCRYPTION_VERSION_PREFIX}${iv.toString("base64url")}:${tag.toString(
"base64url",
)}:${cipherText.toString("base64url")}`;
return payload;
}
function decryptString(value, key) {
const input = typeof value === "string" ? value : "";
if (!input) {
return null;
}
if (!input.startsWith(ENCRYPTION_VERSION_PREFIX)) {
return null;
}
if (!Buffer.isBuffer(key) || key.length !== 32) {
throw new Error("Invalid encryption key");
}
const body = input.slice(ENCRYPTION_VERSION_PREFIX.length);
const parts = body.split(":");
if (parts.length !== 3) {
return null;
}
const [ivB64, tagB64, cipherB64] = parts;
const iv = Buffer.from(ivB64, "base64url");
const tag = Buffer.from(tagB64, "base64url");
const cipherText = Buffer.from(cipherB64, "base64url");
if (iv.length !== IV_LENGTH_BYTES || tag.length !== TAG_LENGTH_BYTES || !cipherText.length) {
return null;
}
const decipher = crypto.createDecipheriv(AES_ALGO, key, iv);
decipher.setAuthTag(tag);
const plain = Buffer.concat([decipher.update(cipherText), decipher.final()]).toString("utf8");
return plain;
}
function decryptStringSafe(config, encrypted, logger) {
const key = resolveFieldEncryptionKey(config);
if (!key) {
return null;
}
try {
return decryptString(encrypted, key);
} catch (error) {
logger?.warn?.({ err: error }, "Sensitive field decrypt failed");
return null;
}
}
function encryptStringOrThrow(config, plaintext) {
const key = resolveFieldEncryptionKey(config);
if (!key) {
throw new Error("Missing FIELD_ENCRYPTION_KEY");
}
return encryptString(plaintext, key);
}
function decryptStringOrThrow(config, encrypted) {
const key = resolveFieldEncryptionKey(config);
if (!key) {
throw new Error("Missing FIELD_ENCRYPTION_KEY");
}
return decryptString(encrypted, key);
}
function maskKeepEnds(value, options = {}) {
const input = typeof value === "string" ? value.trim() : "";
if (!input) {
return null;
}
const keepStart = Math.max(0, Number(options.keepStart ?? 0));
const keepEnd = Math.max(0, Number(options.keepEnd ?? 0));
if (input.length <= keepStart + keepEnd) {
return "*".repeat(input.length);
}
const start = keepStart ? input.slice(0, keepStart) : "";
const end = keepEnd ? input.slice(-keepEnd) : "";
return `${start}${"*".repeat(input.length - keepStart - keepEnd)}${end}`;
}
function maskBankAccount(account) {
return maskKeepEnds(account, { keepStart: 4, keepEnd: 4 });
}
function maskIdNumber(idNumber) {
return maskKeepEnds(idNumber, { keepStart: 6, keepEnd: 4 });
}
module.exports = {
decryptStringSafe,
decryptStringOrThrow,
encryptStringOrThrow,
maskBankAccount,
maskIdNumber,
resolveFieldEncryptionKey,
};