From 1855a82011dd4d652c54cf34db0260d0c4ef6e9c Mon Sep 17 00:00:00 2001 From: shunli Date: Mon, 9 Feb 2026 20:51:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E6=95=8F=E6=84=9F=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E5=8A=A0=E5=AF=86=E4=B8=8E=E8=84=B1=E6=95=8F=EF=BC=88?= =?UTF-8?q?F25=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增敏感字段加密服务(AES-256-GCM,FIELD_ENCRYPTION_KEY) - users 详情返回银行卡/身份证脱敏字段;新增 /users/:id/sensitive 特权明文接口并写审计 - 权限新增 system.users_sensitive(hr/admin) - 更新 env/README 与 v3 CSV 状态推进 --- deploy/trialrun/.env.example | 3 + deploy/trialrun/docker-compose.yml | 3 + .../2026-02-09_13-39-02-p5-blueprint-v3.csv | 2 +- server/.env.example | 4 + server/README.md | 5 + server/src/config.js | 1 + server/src/constants/permissions.js | 2 +- server/src/routes/users.js | 268 +++++++++++++++++- server/src/services/crypto/index.js | 147 ++++++++++ 9 files changed, 428 insertions(+), 7 deletions(-) create mode 100644 server/src/services/crypto/index.js diff --git a/deploy/trialrun/.env.example b/deploy/trialrun/.env.example index 4f63e2ed..507dab6d 100644 --- a/deploy/trialrun/.env.example +++ b/deploy/trialrun/.env.example @@ -11,6 +11,9 @@ APP_ORIGIN="http://127.0.0.1:3000" # ===== 服务端密钥(必须修改)===== JWT_SECRET="change_me_in_trialrun" +# ===== 敏感字段加密(F25)===== +# 生成方式示例:openssl rand -base64 32 +FIELD_ENCRYPTION_KEY="" # ===== 数据库(Compose 内部连接)===== POSTGRES_DB="hyb_oa" diff --git a/deploy/trialrun/docker-compose.yml b/deploy/trialrun/docker-compose.yml index bf827c54..feb42557 100644 --- a/deploy/trialrun/docker-compose.yml +++ b/deploy/trialrun/docker-compose.yml @@ -63,6 +63,7 @@ services: METRICS_ENABLED: ${METRICS_ENABLED} APP_ORIGIN: ${APP_ORIGIN} JWT_SECRET: ${JWT_SECRET} + FIELD_ENCRYPTION_KEY: ${FIELD_ENCRYPTION_KEY} DATABASE_URL: ${DATABASE_URL} STORAGE_DIR: ${STORAGE_DIR} STORAGE_DRIVER: ${STORAGE_DRIVER} @@ -118,6 +119,7 @@ services: METRICS_ENABLED: ${METRICS_ENABLED} APP_ORIGIN: ${APP_ORIGIN} JWT_SECRET: ${JWT_SECRET} + FIELD_ENCRYPTION_KEY: ${FIELD_ENCRYPTION_KEY} DATABASE_URL: ${DATABASE_URL} STORAGE_DIR: ${STORAGE_DIR} STORAGE_DRIVER: ${STORAGE_DRIVER} @@ -161,6 +163,7 @@ services: METRICS_ENABLED: ${METRICS_ENABLED} APP_ORIGIN: ${APP_ORIGIN} JWT_SECRET: ${JWT_SECRET} + FIELD_ENCRYPTION_KEY: ${FIELD_ENCRYPTION_KEY} DATABASE_URL: ${DATABASE_URL} STORAGE_DIR: ${STORAGE_DIR} STORAGE_DRIVER: ${STORAGE_DRIVER} diff --git a/issues/2026-02-09_13-39-02-p5-blueprint-v3.csv b/issues/2026-02-09_13-39-02-p5-blueprint-v3.csv index a909dfa1..e67eafca 100644 --- a/issues/2026-02-09_13-39-02-p5-blueprint-v3.csv +++ b/issues/2026-02-09_13-39-02-p5-blueprint-v3.csv @@ -26,7 +26,7 @@ F21,对象存储(MinIO/S3兼容),"存储从本地目录演进到 S3 兼容 F22,可观测性增强(metrics/日志),"新增 /metrics(Prometheus)与更完整的结构化日志字段规范(不落敏感)","metrics 可抓取关键指标(HTTP/作业/DB);日志具备 requestId/actorId/targetId 等字段","manual: curl /metrics 并核对字段;触发作业观察指标变化",manual,DONE,DONE,DONE,"server/src/app.js | server/src/observability/* | deploy/trialrun/*","[""F19""]","优先用 prom-client;OTel 放更后" F23,SSO/OIDC(可选,P7+),"支持 OIDC 登录(企业 SSO),保留现有密码登录作为 fallback","可通过 OIDC 登录并绑定员工;权限模型不变;可配置开关","manual: 配置 OIDC provider 后走一次登录回调",manual,TODO,TODO,TODO,"server/src/routes/auth.js | server/src/services/oidc/* | server/README.md","[""F1""]","优先复用 openid-client/passport 策略" F24,工作流扩展到其他实体(P7+),"抽象 entity_type 扩展到请假/采购等(先骨架:definition+instance+task 复用)","新实体可创建 instance 并走同审批动作;不影响报销既有链路","manual: seed 一个 leave 定义并跑一条最小链路",manual,TODO,TODO,TODO,"server/src/routes/workflow.js | server/src/routes/leave.js | server/prisma/schema.prisma","[""F19""]","只做骨架与复用点,不做完整业务表单" -F25,敏感字段加密与脱敏(P6+),"落实员工银行卡/身份证等敏感字段的可逆加密与脱敏输出,并对查看/变更写审计","对外响应仅脱敏;解密仅限特权权限并写 audit;不在日志/错误中泄露明文","manual: 创建员工并查看返回脱敏;用特权接口取明文并在 audit 检索记录",manual,TODO,TODO,TODO,"server/src/routes/users.js | server/src/services/crypto/* | server/src/routes/audit.js","[""F1""]","优先用成熟库(libsodium/crypto),并明确密钥轮换策略" +F25,敏感字段加密与脱敏(P6+),"落实员工银行卡/身份证等敏感字段的可逆加密与脱敏输出,并对查看/变更写审计","对外响应仅脱敏;解密仅限特权权限并写 audit;不在日志/错误中泄露明文","manual: 创建员工并查看返回脱敏;用特权接口取明文并在 audit 检索记录",manual,DONE,DONE,DONE,"server/src/routes/users.js | server/src/services/crypto/* | server/src/routes/audit.js","[""F1""]","优先用成熟库(libsodium/crypto),并明确密钥轮换策略" F26,部门API(departments),"补齐部门树/列表与管理接口:GET /api/departments(默认隐藏归档,可 query 开启);POST/PATCH;POST /:id/archive(无子部门/无在职员工约束);写 audit_logs","部门树可用且过滤正确;归档约束正确;system.users 才可写;审计可追踪","manual: 启动 server;以 admin@hyinda.com/AdminPassword1234 登录拿 accessToken;curl -sS -H 'authorization: Bearer ' 'http://127.0.0.1:3000/api/departments' | jq;curl -sS -X POST -H 'authorization: Bearer ' -H 'content-type: application/json' 'http://127.0.0.1:3000/api/departments' -d '{name:...}';curl -sS -X PATCH -H 'authorization: Bearer ' -H 'content-type: application/json' 'http://127.0.0.1:3000/api/departments/' -d '{name:...}';curl -sS -X POST -H 'authorization: Bearer ' 'http://127.0.0.1:3000/api/departments//archive';可选 curl -sS -H 'authorization: Bearer ' 'http://127.0.0.1:3000/api/audit-logs?action=DEPARTMENT_*' | jq",manual,DONE,DONE,DONE,"server/src/routes/departments.js | server/src/app.js | server/README.md","[""F1""]","单机试运行优先;树构造按 parent_id 组装" F27,员工API对齐(新增别名 /api/employees),"对齐规格书:在保留 /api/users 的同时新增 /api/employees 别名路由;补齐 GET详情/PATCH更新/POST archive;并保证本人可读自己的详情","/api/employees 与 /api/users 行为一致;system.users 可增改归档;本人可 GET 自己;审计完整","manual: 启动 server;以 admin@hyinda.com/AdminPassword1234 登录拿 accessToken;curl -sS -H 'authorization: Bearer ' 'http://127.0.0.1:3000/api/employees?page=1&pageSize=1' | jq;取 employeeId 后 curl -sS -H 'authorization: Bearer ' 'http://127.0.0.1:3000/api/employees/' | jq;再用普通 employee 登录拿 验证仅可 GET 自己且 GET 他人返回 403 PERM_001",manual,DONE,DONE,DONE,"server/src/routes/users.js | server/src/routes/employees.js | server/src/app.js | server/README.md","[""F26""]","优先复用既有 usersRoutes 实现,避免重复逻辑" F28,票据人工修正与手工归档(invoice-pool patch/archive),"补齐 PATCH /api/invoice-pool/:id(remarks/人工修正关键字段,允许 ocr_failed -> available);补齐 POST /api/invoice-pool/:id/archive(manual,未绑定才允许);写 audit_logs(INVOICE_POOL_EDITED/INVOICE_POOL_ARCHIVED)","服务端鉴权正确(owner/uploader/invoice.manage);人工修正后可参与 bind/判重;手工归档后不影响历史判重口径;审计可检索","cd server && ./scripts/regression-audit.sh",manual,DONE,DONE,DONE,"server/src/routes/invoicePool.js | server/scripts/regression-audit.sh | server/README.md","[""F1""]","extra.changes 仅记录必要字段的前后快照(避免敏感/大字段)" diff --git a/server/.env.example b/server/.env.example index 02ee6ec6..466d9970 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,5 +1,9 @@ DATABASE_URL="postgresql://hyboa_user:hyboa_password@localhost:5432/hyb_oa?schema=public" JWT_SECRET="change_me" +# ===== 敏感字段加密(F25)===== +# 用于员工银行卡/身份证等字段的可逆加密(AES-256-GCM)。 +# 生成方式示例:openssl rand -base64 32 +# FIELD_ENCRYPTION_KEY="" # APP_ORIGIN="https://oa.hyinda.com" # COOKIE_SECURE="true" # DEV_SHOW_PASSWORD_RESET_CODE="true" diff --git a/server/README.md b/server/README.md index d4898e00..bed1a3fe 100644 --- a/server/README.md +++ b/server/README.md @@ -366,19 +366,24 @@ npm run job:ocr-once ### 5.9 账号与角色管理(E17,试运行运营) - `GET /api/users`:员工列表(分页/搜索) - `POST /api/users`:创建员工(含初始密码) +- `GET /api/users/:id`:员工详情(管理员可看全部;员工可看自己;敏感字段仅返回脱敏) - `POST /api/users/:id/disable`:禁用员工(tokenVersion+1 + 撤销 refresh) - `POST /api/users/:id/enable`:启用员工(tokenVersion+1 + 撤销 refresh) - `POST /api/users/:id/reset-password`:重置密码(返回一次性口令,tokenVersion+1) - `POST /api/users/:id/roles`:设置角色(tokenVersion+1 + 撤销 refresh) +- `GET /api/users/:id/sensitive`:敏感字段明文查询(F25) +- `PATCH /api/users/:id/sensitive`:敏感字段更新(F25) 权限: - `system.users`(hr/admin;admin 具备 `*`) +- `system.users_sensitive`(仅特权账号;用于解密查看/更新银行卡、身份证等) --- ## 6. 关键环境变量 - `DATABASE_URL`: PostgreSQL 连接串 - `JWT_SECRET`: JWT & signed-url token 签名密钥 +- `FIELD_ENCRYPTION_KEY`: 敏感字段加密密钥(AES-256-GCM;base64 32 bytes;用于银行卡/身份证等) - `APP_TIMEZONE`: 默认 `Asia/Shanghai` - `STORAGE_DIR`: 默认 `generated/storage`(相对 `server/`) - `SERVE_CLIENT_DIST`: 是否由 server 托管 `client/dist`(默认 `false`) diff --git a/server/src/config.js b/server/src/config.js index 31f2bdd6..52fd91f4 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -26,6 +26,7 @@ function getConfig() { appOrigin: process.env.APP_ORIGIN ?? "", jwtSecret: requiredEnv("JWT_SECRET"), + fieldEncryptionKey: process.env.FIELD_ENCRYPTION_KEY ?? "", accessTokenTtl: "15m", refreshTtlSeconds: 7 * ONE_DAY_SECONDS, diff --git a/server/src/constants/permissions.js b/server/src/constants/permissions.js index ec4fcd5f..042bf1f7 100644 --- a/server/src/constants/permissions.js +++ b/server/src/constants/permissions.js @@ -42,7 +42,7 @@ const ROLE_PERMISSIONS = { "invoice.upload", "invoice.view_own", ], - hr: ["system.users", "expense.view_all"], + hr: ["system.users", "system.users_sensitive", "expense.view_all"], admin: ["*"], }; diff --git a/server/src/routes/users.js b/server/src/routes/users.js index f895cb60..492c1d4f 100644 --- a/server/src/routes/users.js +++ b/server/src/routes/users.js @@ -7,6 +7,13 @@ const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); const { hashPassword } = require("../utils/crypto"); const { validatePassword } = require("../utils/passwordPolicy"); +const { + decryptStringOrThrow, + decryptStringSafe, + encryptStringOrThrow, + maskBankAccount, + maskIdNumber, +} = require("../services/crypto"); const roleCodeSchema = z.enum([ "employee", @@ -58,6 +65,14 @@ const resetPasswordBodySchema = z.object({ newPassword: z.string().min(1).optional(), }); +const sensitiveBodySchema = z + .object({ + bankName: z.string().trim().max(100).nullable().optional(), + bankAccount: z.string().trim().max(100).nullable().optional(), + idNumber: z.string().trim().max(64).nullable().optional(), + }) + .strict(); + function parseUserId(params) { const parsed = z.string().uuid().safeParse(params?.id); if (!parsed.success) { @@ -156,12 +171,21 @@ function mapEmployeeSummary(row) { }; } -function mapEmployeeDetail(row) { +function mapEmployeeDetail(row, options = {}) { + const config = options.config ?? null; + const logger = options.logger ?? null; + + const bankAccountPlain = decryptStringSafe(config, row.bank_account_encrypted, logger); + const idNumberPlain = decryptStringSafe(config, row.id_number_encrypted, logger); + return { ...mapEmployeeSummary(row), directManagerId: row.direct_manager_id ?? null, hireDate: row.hire_date?.toISOString?.()?.slice(0, 10) ?? null, archivedAt: row.archived_at?.toISOString?.() ?? null, + bankName: row.bank_name ? String(row.bank_name) : null, + bankAccountMasked: bankAccountPlain ? maskBankAccount(bankAccountPlain) : null, + idNumberMasked: idNumberPlain ? maskIdNumber(idNumberPlain) : null, }; } @@ -180,6 +204,29 @@ function normalizeUniqueErrorDetails(error) { return Object.keys(details).length ? details : { unique: "duplicate" }; } +function parseSensitiveBody(body) { + const parsed = sensitiveBodySchema.safeParse(body ?? {}); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsed.error.flatten(), + }); + } + + if (!Object.keys(parsed.data).length) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { body: "empty" }, + }); + } + + return parsed.data; +} + async function usersRoutes(fastify, opts = {}) { const basePathRaw = typeof opts.basePath === "string" && opts.basePath.trim() ? opts.basePath.trim() : "/users"; @@ -276,6 +323,9 @@ async function usersRoutes(fastify, opts = {}) { title: true, direct_manager_id: true, hire_date: true, + bank_account_encrypted: true, + bank_name: true, + id_number_encrypted: true, archived_at: true, created_at: true, updated_at: true, @@ -291,7 +341,11 @@ async function usersRoutes(fastify, opts = {}) { }); } - return sendData(reply, request, mapEmployeeDetail(employee)); + return sendData( + reply, + request, + mapEmployeeDetail(employee, { config: fastify.config, logger: request.log ?? fastify.log }), + ); }, ); @@ -368,8 +422,8 @@ async function usersRoutes(fastify, opts = {}) { direct_manager_id: body.directManagerId ?? null, hire_date: hireDate, status: "active", - bank_account_encrypted: "ENC_TBD", - bank_name: "TBD", + bank_account_encrypted: "", + bank_name: "", password_hash: passwordHash, must_change_password: true, created_at: now, @@ -535,6 +589,9 @@ async function usersRoutes(fastify, opts = {}) { title: true, direct_manager_id: true, hire_date: true, + bank_account_encrypted: true, + bank_name: true, + id_number_encrypted: true, archived_at: true, created_at: true, updated_at: true, @@ -605,6 +662,9 @@ async function usersRoutes(fastify, opts = {}) { title: true, direct_manager_id: true, hire_date: true, + bank_account_encrypted: true, + bank_name: true, + id_number_encrypted: true, archived_at: true, created_at: true, updated_at: true, @@ -638,7 +698,205 @@ async function usersRoutes(fastify, opts = {}) { throw error; } - return sendData(reply, request, mapEmployeeDetail(updated)); + return sendData( + reply, + request, + mapEmployeeDetail(updated, { config: fastify.config, logger: request.log ?? fastify.log }), + ); + }, + ); + + fastify.get( + `${routeBasePath}/:id/sensitive`, + { + preHandler: fastify.requirePermission("system.users_sensitive"), + }, + async (request, reply) => { + const userId = parseUserId(request.params); + + const employee = await fastify.prisma.employee.findUnique({ + where: { id: userId }, + select: { + id: true, + bank_name: true, + bank_account_encrypted: true, + id_number_encrypted: true, + }, + }); + + if (!employee) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + let bankAccount = null; + let idNumber = null; + try { + bankAccount = decryptStringOrThrow(fastify.config, employee.bank_account_encrypted); + idNumber = decryptStringOrThrow(fastify.config, employee.id_number_encrypted); + } catch (error) { + fastify.log.error({ err: error, requestId: request.id }, "FIELD_ENCRYPTION_KEY misconfigured"); + throw new AppError({ + code: "SYS_001", + message: "服务器内部错误", + statusCode: 500, + details: {}, + }); + } + + await fastify.prisma.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "SYSTEM_USER_SENSITIVE_VIEWED", + targetType: "employee", + targetId: userId, + extra: {}, + }), + }); + + return sendData(reply, request, { + bankName: employee.bank_name ? String(employee.bank_name) : null, + bankAccount, + idNumber, + }); + }, + ); + + fastify.patch( + `${routeBasePath}/:id/sensitive`, + { + preHandler: fastify.requirePermission("system.users_sensitive"), + }, + async (request, reply) => { + const userId = parseUserId(request.params); + const body = parseSensitiveBody(request.body); + const now = new Date(); + + let updated = null; + try { + updated = await fastify.prisma.$transaction(async (tx) => { + const current = await tx.employee.findUnique({ + where: { id: userId }, + select: { + id: true, + bank_name: true, + bank_account_encrypted: true, + id_number_encrypted: true, + employee_no: true, + name: true, + phone: true, + email: true, + status: true, + department_id: true, + title: true, + direct_manager_id: true, + hire_date: true, + archived_at: true, + created_at: true, + updated_at: true, + roles: { select: { role_code: true } }, + }, + }); + if (!current) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const changes = {}; + const data = { + updated_at: now, + updated_by: request.employee.id, + }; + + if (Object.prototype.hasOwnProperty.call(body, "bankName")) { + const nextBankName = + typeof body.bankName === "string" ? body.bankName.trim() : body.bankName; + data.bank_name = nextBankName ? String(nextBankName) : ""; + changes.bankName = { + before: current.bank_name ? String(current.bank_name) : null, + after: data.bank_name || null, + }; + } + + if (Object.prototype.hasOwnProperty.call(body, "bankAccount")) { + const nextBankAccount = + typeof body.bankAccount === "string" ? body.bankAccount.trim() : body.bankAccount; + data.bank_account_encrypted = nextBankAccount + ? encryptStringOrThrow(fastify.config, nextBankAccount) + : ""; + changes.bankAccountMasked = nextBankAccount ? maskBankAccount(nextBankAccount) : null; + } + + if (Object.prototype.hasOwnProperty.call(body, "idNumber")) { + const nextIdNumber = + typeof body.idNumber === "string" ? body.idNumber.trim() : body.idNumber; + data.id_number_encrypted = nextIdNumber + ? encryptStringOrThrow(fastify.config, nextIdNumber) + : null; + changes.idNumberMasked = nextIdNumber ? maskIdNumber(nextIdNumber) : null; + } + + const employee = await tx.employee.update({ + where: { id: userId }, + data, + select: { + id: true, + employee_no: true, + name: true, + phone: true, + email: true, + status: true, + department_id: true, + title: true, + direct_manager_id: true, + hire_date: true, + bank_account_encrypted: true, + bank_name: true, + id_number_encrypted: true, + archived_at: true, + created_at: true, + updated_at: true, + roles: { select: { role_code: true } }, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "SYSTEM_USER_SENSITIVE_UPDATED", + targetType: "employee", + targetId: userId, + extra: { changes }, + }), + }); + + return employee; + }); + } catch (error) { + if (String(error?.message ?? "").includes("FIELD_ENCRYPTION_KEY")) { + fastify.log.error({ err: error, requestId: request.id }, "FIELD_ENCRYPTION_KEY misconfigured"); + throw new AppError({ + code: "SYS_001", + message: "服务器内部错误", + statusCode: 500, + details: {}, + }); + } + + throw error; + } + + return sendData( + reply, + request, + mapEmployeeDetail(updated, { config: fastify.config, logger: request.log ?? fastify.log }), + ); }, ); diff --git a/server/src/services/crypto/index.js b/server/src/services/crypto/index.js new file mode 100644 index 00000000..9e9de87b --- /dev/null +++ b/server/src/services/crypto/index.js @@ -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, +};