From 45cd7db199c4202870ba46420100ff234663274b Mon Sep 17 00:00:00 2001 From: chpark Date: Fri, 15 May 2026 16:12:22 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B2=AC=EC=A0=81=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EA=B2=B0=EC=9E=AC=EC=83=81=EC=8B=A0=20=E2=80=94=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=20=EC=BB=A4=EB=84=A5=EC=85=98=20'Amaranth=20-=20?= =?UTF-8?q?=EA=B2=B0=EC=9E=AC'=20auth=5Fconfig=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EB=B3=B5=ED=98=B8=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 증상: - 견적관리에서 결재상신 클릭 시 SSO URL 발급 실패. external_rest_api_connections 테이블의 accessToken/hashKey 가 AES-256-GCM 으로 암호화되어 저장되는데 amaranthApprovalClient.loadApprovalConnection() 가 평문 그대로 사용함. 수정: - ExternalRestApiConnectionService.encryptSensitiveData/decryptSensitiveData 와 동일한 알고리즘(aes-256-gcm, scrypt(DB_PASSWORD_SECRET,'salt',32)) 으로 복호화 하는 tryDecrypt 헬퍼를 amaranthApprovalClient 내부에 추가. - loadApprovalConnection 에서 accessToken/hashKey 만 자동 복호화 (callerName/groupSeq 는 평문 저장이므로 그대로). - 'iv:authTag:cipher' 3-part 형식이 아니면 평문으로 간주(마이그레이션 호환). 검증: - 복호화 후 accessToken='MN5KzKBWRAa92BPxDlRLl3GcsxeZXc' / hashKey='2251910...' 로 wace_plm AmaranthApprovalApiClient.java 의 하드코딩 값과 일치 확인. --- .../src/services/amaranthApprovalClient.ts | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/backend-node/src/services/amaranthApprovalClient.ts b/backend-node/src/services/amaranthApprovalClient.ts index 7628a3fb..750a6364 100644 --- a/backend-node/src/services/amaranthApprovalClient.ts +++ b/backend-node/src/services/amaranthApprovalClient.ts @@ -22,6 +22,33 @@ import { logger } from "../utils/logger"; const CONNECTION_NAME = "Amaranth - 결재"; +// external_rest_api_connections.auth_config 의 accessToken/hashKey 는 +// ExternalRestApiConnectionService 에서 AES-256-GCM 으로 암호화되어 저장됨 +// (`iv:authTag:ciphertext` 3-part hex). 같은 키/알고리즘으로 복호화한다. +const ENC_ALGORITHM = "aes-256-gcm"; +const ENC_KEY = crypto.scryptSync( + process.env.DB_PASSWORD_SECRET || "default-secret-key-change-in-production", + "salt", + 32, +); + +function tryDecrypt(value: string): string { + if (!value || typeof value !== "string") return value; + const parts = value.split(":"); + if (parts.length !== 3) return value; // 평문 그대로 (마이그레이션 호환) + try { + const iv = Buffer.from(parts[0], "hex"); + const authTag = Buffer.from(parts[1], "hex"); + const decipher = crypto.createDecipheriv(ENC_ALGORITHM, ENC_KEY, iv) as crypto.DecipherGCM; + decipher.setAuthTag(authTag); + let out = decipher.update(parts[2], "hex", "utf8"); + out += decipher.final("utf8"); + return out; + } catch { + return value; // 복호화 실패 시 원본 (평문 가정) + } +} + interface AmaranthAuth { baseUrl: string; callerName: string; @@ -31,7 +58,7 @@ interface AmaranthAuth { aesKey: string; } -/** DB 의 외부 커넥션 'Amaranth - 결재' 에서 인증 정보를 로드 */ +/** DB 의 외부 커넥션 'Amaranth - 결재' 에서 인증 정보를 로드 (민감 필드 자동 복호화) */ export async function loadApprovalConnection(): Promise { const row = await queryOne( `SELECT base_url, auth_config FROM external_rest_api_connections @@ -48,10 +75,10 @@ export async function loadApprovalConnection(): Promise { } return { baseUrl: String(row.base_url || "").replace(/\/+$/, ""), - callerName: cfg.callerName, - accessToken: cfg.accessToken, - hashKey: cfg.hashKey, - groupSeq: cfg.groupSeq, + callerName: cfg.callerName, // 평문 (암호화 대상 아님) + accessToken: tryDecrypt(cfg.accessToken), + hashKey: tryDecrypt(cfg.hashKey), + groupSeq: cfg.groupSeq, // 평문 aesKey: cfg.aesKey || "8441e27489d402cd", }; }