feat(server): 敏感字段加密与脱敏(F25)
- 新增敏感字段加密服务(AES-256-GCM,FIELD_ENCRYPTION_KEY) - users 详情返回银行卡/身份证脱敏字段;新增 /users/:id/sensitive 特权明文接口并写审计 - 权限新增 system.users_sensitive(hr/admin) - 更新 env/README 与 v3 CSV 状态推进
这个提交包含在:
@@ -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,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户