주민번호 복호화를 붙인다 — 이 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:
Msystech
2026-08-19 11:15:24 +09:00
co-authored by Claude Opus 5
parent 34c53e3d69
commit 4bdd583adb
7 changed files with 555 additions and 4 deletions
@@ -2106,6 +2106,43 @@ public static class DbSmoke
$"Ok={blind.Ok} {blind.Rows.Count}행 — 치환이 실제로 기여하지 않았다는 뜻이다");
lines.Add($" 대조군 SQL: {blind.Sql.Replace("\r", " ").Replace("\n", " ")}");
lines.Add($" 치환 못 한 변수 {blind.Unknown.Count}개");
// ── 주민번호 복호화 ──
// 값은 <b>절대 리포트에 쓰지 않는다</b> — 길이와 형식만 적는다.
// 이 앱이 다루는 것 중 가장 민감한 값이고, 리포트는 파일로 남는다.
var personal = new PersonalDataStore(conn);
var flags = personal.Flags();
var hospital = Services.UserSession.Current.HspCod;
lines.Add($" 암호화 설정: 전체={(flags.Enabled ? "Y" : "N")}"
+ $" 주민번호={(flags.ResidentNumber ? "Y" : "N")}"
+ $" / 병원코드='{hospital}'");
Check("⑲ 암호화 설정을 예외 없이 읽는다", true);
// 병원코드가 비면 SRCH·JINJU 기관기호 보정이 걸리지 않는다 —
// 그 갈래는 이 진단으로 확인되지 않는다. 대신 <b>복호화 자체</b>는 확인한다.
// 그것이 이 단계에서 실패하면 나머지가 전부 무의미하고, DB 권한·패키지 문제라
// 코드로는 알 수 없다. 확인한 것과 못 한 것을 갈라서 적는다.
if (hospital.Length == 0)
{
lines.Add(" [미확인] 병원코드가 없어 SRCH·JINJU 기관기호 보정은"
+ " 이 진단으로 확인되지 않았다(순수 함수 테스트가 그 갈래를 덮는다)");
}
{
var raw = flags.ResidentNumberEncrypted
? personal.Decrypt(PatientContext.Value(ctx.PatInf, "PatResEnc"), hospital)
: PatientContext.Value(ctx.PatInf, "PatResNum");
var normalized = SheetMe.Core.Catalog.ResidentNumber.Normalize(
raw,
PatientContext.Value(ctx.PatInf, "PatBthDay"),
PatientContext.Value(ctx.PatInf, "PatNatCod"),
hospital);
var sex = SheetMe.Core.Catalog.ResidentNumber.SexOf(normalized);
lines.Add($" 주민번호: 원본 {raw.Length}자 → 보정 {normalized.Length}자,"
+ $" 전부 숫자={normalized.All(char.IsAsciiDigit)} (값은 기록하지 않는다)");
Check("⑳ 주민번호가 13자리 숫자로 만들어진다",
normalized.Length == 13 && normalized.All(char.IsAsciiDigit),
$"{normalized.Length}자 — 복호화 실패나 컬럼 부재를 의심할 것");
Check("㉑ 그 번호에서 성별이 나온다", sex is "M" or "F", $"'{sex}'");
}
}
}
}
@@ -0,0 +1,58 @@
using SheetMe.Core.Catalog;
using SheetMe.Data.Stores;
namespace SheetMe.Designer.Services;
/// <summary>
/// 보정을 마친 주민번호를 만드는 <b>유일한 자리</b>.
///
/// 왜 한 곳으로 좁히는가: 이 값은 이 앱이 다루는 것 중 가장 민감하다.
/// 만드는 경로가 여러 곳이면 어디서 새는지 추적할 수 없고, 보정 순서가 어긋난 사본이 생긴다.
///
/// <b>값을 저장하지 않는다.</b> 필요할 때 만들어 <see cref="PatientTagResolver"/> 에 넘기고 끝낸다.
/// 로그에도 남기지 않는다 — 어떤 진단 리포트에도 값이 아니라 <b>길이</b>만 적는다.
///
/// 복호화는 DB 가 한다(<see cref="PersonalDataStore"/>). 앱은 키를 갖지 않는다.
/// 자세한 것은 <c>docs/RESNUM-DECRYPT.md</c>.
/// </summary>
public static class PatientIdentity
{
#region Methods
/// <summary>
/// 이 환자의 주민번호 — 못 만들면 빈 문자열.
///
/// <b>암호화 설정을 못 읽으면 만들지 않는다.</b> 설정을 "암호화 아님"으로 가정하면
/// <c>PatResNum</c> 을 평문처럼 써서 암호문을 종이에 찍는다. 빈칸이 훨씬 낫다.
/// </summary>
public static string ResidentNumberOf(PatientContext context)
{
var connection = ConfigService.Current.ConnectionString;
if (connection.Length == 0)
{
return string.Empty;
}
var hospital = UserSession.Current.HspCod;
try
{
var store = new PersonalDataStore(connection);
var flags = store.Flags();
var raw = flags.ResidentNumberEncrypted
// 암호화 병원 — PatResNum 은 쓰지 않는다. PatResEnc 를 DB 가 풀어 준다
? store.Decrypt(PatientContext.Value(context.PatInf, "PatResEnc"), hospital)
: PatientContext.Value(context.PatInf, "PatResNum");
return ResidentNumber.Normalize(
raw,
PatientContext.Value(context.PatInf, "PatBthDay"),
PatientContext.Value(context.PatInf, "PatNatCod"),
hospital);
}
catch (Exception ex)
{
// 사유는 남기지만 값은 남기지 않는다 — 예외 메시지에 SQL 이 섞여도 값은 바인드였다
AppLog.Audit($"[주민번호] 만들지 못했습니다: {ex.GetType().Name}");
return string.Empty;
}
}
#endregion
}
@@ -61,19 +61,35 @@ public sealed class PatientTagResolver : ITagValueResolver
private readonly ITagValueResolver next;
private readonly PatientContext context;
/// <summary>
/// 보정을 마친 주민번호 — 못 만들었으면 빈 문자열.
///
/// <b>미리 만들어 받는다.</b> 복호화는 DB 왕복이라 태그마다 하면
/// 주민번호를 쓰는 태그 수만큼 왕복이 생긴다. 그리고 이 값은 가장 민감하므로
/// 만드는 자리를 한 곳으로 좁혀 둔다(<see cref="PatientIdentity"/>).
/// </summary>
private readonly string residentNumber;
#endregion
#region Constructors
public PatientTagResolver(PatientContext context, ITagValueResolver next)
public PatientTagResolver(PatientContext context, ITagValueResolver next,
string residentNumber = "")
{
this.context = context;
this.next = next;
this.residentNumber = residentNumber;
}
#endregion
#region Methods
public TagValue Resolve(string tag)
{
// 주민번호에서 나오는 값들은 표로 다룰 수 없다 — 컬럼이 아니라 계산 결과다
if (FromResidentNumber(tag) is { } derived)
{
return derived;
}
if (!Direct.TryGetValue(tag, out var map))
{
// 환자 문맥이 <b>있는데도</b> 못 만든 것과, 문맥이 없어서 못 만든 것은 다른 일이다.
@@ -103,13 +119,61 @@ public sealed class PatientTagResolver : ITagValueResolver
return new TagValue(true, value);
}
/// <summary>
/// 주민번호에서 파생되는 태그 — 해당 태그가 아니면 null.
///
/// <c>PAT_성별</c> 에는 선행 분기가 있다: <c>CoiCalCod</c> 가 <c>"24"</c> 로 시작하면
/// 주민번호를 보지 않고 <c>PatSexTyp</c> 을 쓴다(bzDataInterface.vb:283-291).
/// 이 분기를 빼면 해당 산정코드 환자에서 성별이 조용히 달라진다.
/// </summary>
private TagValue? FromResidentNumber(string tag)
{
switch (tag)
{
case "PAT_주민번호":
return Wrap(residentNumber);
case "PAT_주민번호_Dash":
return Wrap(ResidentNumber.WithDash(residentNumber));
case "PAT_성별":
case "PAT_한글성별":
{
// 산정코드 24* 는 주민번호를 쓰지 않는다 — 이쪽이 먼저다
var byCode = PatientContext.Value(context.CoiInf, "CoiCalCod")
.StartsWith("24", StringComparison.Ordinal)
? PatientContext.Value(context.PatInf, "PatSexTyp")
: ResidentNumber.SexOf(residentNumber);
var value = tag == "PAT_한글성별"
? byCode switch { "F" => "여", "M" => "남", _ => string.Empty }
: byCode;
return value.Length > 0
? new TagValue(true, value)
: new TagValue(false, residentNumber.Length == 0
? "주민번호를 읽지 못해 성별을 만들 수 없습니다"
: "이 환자의 성별을 판정할 수 없습니다");
}
default:
return null;
}
}
/// <summary>주민번호에서 나온 값 — 비면 <b>왜</b> 비었는지 말한다</summary>
private TagValue Wrap(string value) => value.Length > 0
? new TagValue(true, value)
: new TagValue(false, "주민번호를 읽지 못했습니다(암호화 설정 또는 권한을 확인하세요)");
/// <summary>지금 옮겨진 환자 태그 수 — 화면이 커버리지를 정직하게 말할 때 쓴다</summary>
public static int MappedCount => Direct.Count;
public static int MappedCount => Direct.Count + DerivedTags.Length;
/// <summary>주민번호에서 파생되는 태그들 — 이름 검사가 이것도 봐야 한다</summary>
public static readonly string[] DerivedTags =
{
"PAT_주민번호", "PAT_주민번호_Dash", "PAT_성별", "PAT_한글성별",
};
/// <summary>
/// 옮긴 태그 이름들 — 진단이 <b>실존하는 태그인지</b> 검사한다.
/// 이 검사가 없어서 지어낸 이름 셋이 조용히 아무것도 안 하고 있었다.
/// </summary>
public static IReadOnlyCollection<string> MappedTags => Direct.Keys;
public static IReadOnlyCollection<string> MappedTags => Direct.Keys.Concat(DerivedTags).ToList();
#endregion
}
@@ -217,7 +217,11 @@ public partial class PreviewWindow : Window
internal void UsePatient(SheetMe.Data.Stores.PatientContext? picked, string label)
{
patient = picked;
tags = picked is null ? session : new PatientTagResolver(picked, session);
// 주민번호는 환자를 붙일 때 한 번만 만든다 — 태그마다 만들면 DB 왕복이 그만큼 늘고
// 가장 민감한 값을 여러 번 꺼내게 된다.
tags = picked is null
? session
: new PatientTagResolver(picked, session, PatientIdentity.ResidentNumberOf(picked));
// 러너를 <b>새로</b> 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아
// 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다.
fields = new MDataTableRunner(designer.Document,