주민번호 복호화를 붙인다 — 이 DB 는 실제로 암호화되어 있었다
문서(docs/RESNUM-DECRYPT.md)의 1→2→3 을 실행. 태그 7종 → 11종. ## 이 DB 가 암호화 병원이다 진단이 읽은 설정: 전체=Y 주민번호=Y. 앞 커밋에서 PAT_주민번호 를 뺀 판단이 맞았다 — PatResNum 을 그대로 썼으면 <b>암호문이 종이에 찍혔다</b>. ## 앱은 키를 갖지 않는다 복호화는 오라클 패키지가 한다. SELECT SUBSTR(:e,1,7) || CRYPTO_AES256.DEC_AES(SUBSTR(:e,8,256)) FROM DUAL 바인드 변수이고 접근 통제는 DB 권한이다. SCHILDREN 갈래(UDF_GETDECRESNUM_damo)도 함께 옮겼다. 앞 7자리가 평문이라 SUBSTR(...,1,7) 을 이어 붙이는 것을 빠뜨리면 앞자리가 사라진다. ## 설정을 못 읽으면 값을 만들지 않는다 "암호화 아님"으로 가정하면 PatResNum 을 평문처럼 써서 암호문을 찍는다. PatientIdentity 는 예외 시 빈 문자열을 돌려주고 사유만 로그에 남긴다 — 값은 남기지 않는다. 주민번호를 만드는 자리를 한 곳으로 좁혀 뒀다(만드는 경로가 여럿이면 어디서 새는지 추적할 수 없다). ## 순수 계산은 표로 고정했다 (테스트 13건) 보정 ①(기관기호, SRCH·JINJU): 앞 6자리를 PatBthDay 로 갈아 끼운다. 원본은 If Not DateValidCheck(Substring(1,6)) 으로 감싸 있지만 <b>그 조건은 항상 참이다</b> — DateValidCheck 는 길이가 8·12·14·17 이 아니면 무조건 False 인데 6자리를 넘긴다. 여기서 "고쳐서" 진짜 날짜 검사를 넣으면 레거시와 다른 번호가 나온다(이 DB 가 SRCH 다). 이식은 동작을 옮기는 일이지 바로잡는 일이 아니다 — 그 사실을 테스트로 못 박았다. 보정 ②(외국인): 7번째 자리 0/9 를 PatBthDay 세기로 6/8·5/7 로. 국적코드가 있어야 걸린다. 성별: 7번째 자리 mod 2. PAT_성별 은 CoiCalCod 가 "24" 로 시작하면 PatSexTyp 을 먼저 쓴다. ## 추측하면 걸린다 M_DtsMst 컬럼을 DtsTabCod·DtlCod 로 추측해 ORA-00904 를 맞았다. 실제는 DtsTblCod·DtsDtlCod·DtsCod 다(dtCommonLib.vb:1136). ## 확인한 것과 못 한 것을 갈라 적는다 진단은 HIS 사용자 문맥 없이 도므로 병원코드가 비고, SRCH 기관기호 보정 갈래는 이 경로로 확인되지 않는다. 통과로 세지 않고 [미확인] 줄로 적는다 — 그 갈래는 순수 함수 테스트가 덮는다. 진단 리포트에 <b>값을 쓰지 않는다</b> — 길이와 "전부 숫자인가"만 적는다. 리포트는 파일로 남는다. ## 아직 안 한 것 나이(AgeCheck)는 병원별 규칙과 의료급여 전산관리번호 규약이 붙어 별도 작업이다. 개인정보 접근 로그(C_PdrInf)에 쓸지는 운영 DB 쓰기라 승인 전까지 하지 않는다. ## 게이트 - dotnet test 336/336 (주민번호 판정 13건 신규) - --db-patient ①~㉑ 전건 통과 (⑲ 설정 · ⑳ 13자리 · ㉑ 성별) - --edit-smoke 실패 0 (이름 검사가 파생 태그 4종까지 본다) - --dialog-shots FAIL 0 - --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
34c53e3d69
commit
4bdd583adb
@@ -0,0 +1,135 @@
|
||||
using Oracle.ManagedDataAccess.Client;
|
||||
|
||||
namespace SheetMe.Data.Stores;
|
||||
|
||||
/// <summary>
|
||||
/// 이 병원의 개인정보 암호화 설정 — 주민번호를 어느 컬럼에서 읽어야 하는지 결정한다.
|
||||
/// </summary>
|
||||
/// <param name="Enabled">HSPCFG/PersonalDataEnc 가 Y 인가</param>
|
||||
/// <param name="ResidentNumber">그 아래 ResNum 이 Y 인가 — 이것이 켜져야 PatResEnc 를 읽는다</param>
|
||||
public readonly record struct PersonalDataEncryption(bool Enabled, bool ResidentNumber)
|
||||
{
|
||||
/// <summary>주민번호가 암호화되어 있는가 — 두 단이 모두 Y 여야 한다</summary>
|
||||
public bool ResidentNumberEncrypted => Enabled && ResidentNumber;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 주민번호 복호화 — 레거시 <c>dtCommonLib.GetDecResNum</c> (:6960) 을 옮긴 것.
|
||||
///
|
||||
/// <b>앱은 키를 갖지 않는다.</b> 복호화는 오라클 패키지 <c>CRYPTO_AES256</c> 가 하고
|
||||
/// 접근 통제는 DB 권한이다 — 그것이 있어야 할 자리다. 키를 상수로 옮기는 이식은 하지 않는다.
|
||||
/// 자세한 것은 <c>docs/RESNUM-DECRYPT.md</c>.
|
||||
///
|
||||
/// <b>앞 7자리는 평문이다.</b> 8자리부터만 암호화되어 있어서 <c>SUBSTR(...,1,7)</c> 을
|
||||
/// 그대로 이어 붙인다. 이걸 빠뜨리면 앞자리가 사라진 번호가 나온다.
|
||||
///
|
||||
/// <b>값을 캐시하지 않는다.</b> 설정(플래그)은 병원당 고정이라 한 번 읽고 들고 있지만,
|
||||
/// 복호화 결과는 들고 있지 않는다 — 가장 민감한 값을 메모리에 오래 두지 않는다.
|
||||
/// </summary>
|
||||
public sealed class PersonalDataStore
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>이 병원만 DAMO 로 바뀌었다(2023-10-14) — dtCommonLib.vb:6979</summary>
|
||||
private const string DamoHospital = "SCHILDREN";
|
||||
|
||||
private readonly string connectionString;
|
||||
private PersonalDataEncryption? cachedFlags;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public PersonalDataStore(string connectionString) => this.connectionString = connectionString;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>
|
||||
/// 암호화 설정 — 두 단으로 읽는다(clsCommonLib.vb:5835-5841).
|
||||
///
|
||||
/// ① <c>M_DtlMst</c>(HSPCFG/PersonalDataEnc) 의 <c>DtlCodVal</c> 에서 <c>=</c> 앞 조각
|
||||
/// ② 그것이 Y 일 때만 <c>M_DtsMst</c>(HSPCFG/PersonalDataEnc/ResNum) 의 <c>DtsCodVal</c>
|
||||
///
|
||||
/// ② 를 무조건 읽지 않는 것이 중요하다 — 레거시가 ① 이 Y 일 때만 읽으므로
|
||||
/// ① 이 N 인 병원에서 ② 에 남아 있는 옛 값에 걸리지 않는다.
|
||||
/// </summary>
|
||||
public PersonalDataEncryption Flags()
|
||||
{
|
||||
if (cachedFlags is { } cached)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
var result = new PersonalDataEncryption(false, false);
|
||||
try
|
||||
{
|
||||
using var connection = new OracleConnection(connectionString);
|
||||
connection.Open();
|
||||
var head = Scalar(connection,
|
||||
"SELECT DtlCodVal FROM M_DtlMst WHERE DtlTblCod = 'HSPCFG' AND DtlCod = 'PersonalDataEnc'");
|
||||
// DtlCodVal 은 "Y=..." 형태일 수 있다 — 레거시 Piece(...,"=",1) 과 같이 앞 조각만 본다
|
||||
var enabled = Piece(head).Equals("Y", StringComparison.OrdinalIgnoreCase);
|
||||
var resident = false;
|
||||
if (enabled)
|
||||
{
|
||||
// 컬럼 이름은 DtsTblCod · DtsDtlCod · DtsCod 다(dtCommonLib.vb:1136).
|
||||
// 처음에 DtsTabCod·DtlCod 로 추측해서 ORA-00904 를 맞았다 — 추측하면 여기서 걸린다.
|
||||
var detail = Scalar(connection,
|
||||
"SELECT DtsCodVal FROM M_DtsMst WHERE DtsTblCod = 'HSPCFG'"
|
||||
+ " AND DtsDtlCod = 'PersonalDataEnc' AND DtsCod = 'ResNum'");
|
||||
resident = Piece(detail).Equals("Y", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
result = new PersonalDataEncryption(enabled, resident);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 설정을 못 읽으면 <b>암호화 아님</b>으로 두지 않는다 — 그러면 암호문을 평문처럼 쓴다.
|
||||
// Enabled=false 는 "PatResNum 을 쓰라"는 뜻이 되므로, 여기서는 호출부가
|
||||
// 값을 만들지 못하도록 두 단 다 false 로 두고 복호화도 시도하지 않게 한다.
|
||||
// (호출부는 Flags 를 못 믿을 때 태그를 비우고 사유를 말해야 한다.)
|
||||
cachedFlags = result;
|
||||
throw;
|
||||
}
|
||||
cachedFlags = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 암호문을 평문으로. 빈 값이면 빈 값(레거시 :6966-6968).
|
||||
///
|
||||
/// 병원 갈래를 함께 옮긴다 — SCHILDREN 은 UDF 하나로 끝내고 나머지는
|
||||
/// 앞 7자리 + <c>CRYPTO_AES256.DEC_AES</c> 조립이다.
|
||||
/// </summary>
|
||||
public string Decrypt(string? encrypted, string hospitalCode)
|
||||
{
|
||||
var value = (encrypted ?? string.Empty).Trim();
|
||||
if (value.Length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
var sql = string.Equals(hospitalCode, DamoHospital, StringComparison.OrdinalIgnoreCase)
|
||||
? "SELECT UDF_GETDECRESNUM_damo(:e) FROM DUAL"
|
||||
: "SELECT SUBSTR(:e,1,7) || CRYPTO_AES256.DEC_AES(SUBSTR(:e,8,256)) FROM DUAL";
|
||||
|
||||
using var connection = new OracleConnection(connectionString);
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.BindByName = true;
|
||||
command.CommandText = sql;
|
||||
command.Parameters.Add(new OracleParameter("e", value));
|
||||
var decrypted = command.ExecuteScalar();
|
||||
return decrypted is null or DBNull ? string.Empty : decrypted.ToString()!.Trim();
|
||||
}
|
||||
|
||||
private static string Scalar(OracleConnection connection, string sql)
|
||||
{
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
var value = command.ExecuteScalar();
|
||||
return value is null or DBNull ? string.Empty : value.ToString()!.Trim();
|
||||
}
|
||||
|
||||
/// <summary>레거시 <c>Piece(값, "=", 1)</c> — 첫 등호 앞 조각</summary>
|
||||
private static string Piece(string value)
|
||||
{
|
||||
var at = value.IndexOf('=');
|
||||
return (at < 0 ? value : value[..at]).Trim();
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user