feat: 지정판매소 개인정보(PII) 보호 — 마스킹·암호화·열람 게이트 (기본 OFF)

종량제3 세션 작업. 마스터 플래그(Config\Pii.designatedShopProtection)가 OFF면
현재 동작(원문 그대로) 유지, ON일 때만 마스킹·게이트 적용.

- 표시 계층 단일 지점 마스킹: 대표자명/전화/이메일/계좌/가상계좌 (목록·상세·엑셀·판매대장·전화주문)
- 원문 열람 게이트: 지자체 허용 IP 자동열람 or 2단계 인증(TOTP) step-up + 감사기록(revealPii)
- 정확일치 검색: 블라인드 인덱스(HMAC) 컬럼 있으면 사용, 없으면 평문 폴백
- 저장계층 암호화(encryptAtRest, 기본 OFF): 모델 콜백 + spark 명령 pii:protect-designated-shops
- SQL(추가·멱등): designated_shop_add_pii_bidx / local_government_add_allow_ips
- Auth: TOTP 실제 통과 시에만 세션 auth_2fa_verified=true

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
taekyoungc
2026-07-16 15:45:28 +09:00
parent c5df784f91
commit 5e8e81f404
12 changed files with 726 additions and 19 deletions

View File

@@ -70,3 +70,246 @@ if (! function_exists('pii_decrypt')) {
if (! defined('PII_ENCRYPTED_FIELDS')) {
define('PII_ENCRYPTED_FIELDS', ['mb_phone', 'mb_email']);
}
/* =========================================================================
* 개인정보 마스킹(비식별화) 헬퍼 — 표시 계층에서만 사용(원본 데이터는 그대로).
* ======================================================================= */
if (! function_exists('pii_mask_name')) {
/** 이름 마스킹: 홍길동→홍*동, 홍길→홍*, 홍→홍 */
function pii_mask_name(?string $v): string
{
$v = trim((string) $v);
if ($v === '') {
return '';
}
$len = mb_strlen($v, 'UTF-8');
if ($len === 1) {
return $v;
}
if ($len === 2) {
return mb_substr($v, 0, 1, 'UTF-8') . '*';
}
return mb_substr($v, 0, 1, 'UTF-8') . str_repeat('*', $len - 2) . mb_substr($v, $len - 1, 1, 'UTF-8');
}
}
if (! function_exists('pii_mask_phone')) {
/** 전화 마스킹: 앞 국번 + 마지막 4자리만 노출, 가운데 마스킹. 010-1234-5678→010-****-5678 */
function pii_mask_phone(?string $v): string
{
$v = trim((string) $v);
if ($v === '') {
return '';
}
// 하이픈 형식이면 가운데 그룹만 마스킹
if (preg_match('/^(\d{2,4})-(\d{3,4})-(\d{4})$/', $v, $m) === 1) {
return $m[1] . '-' . str_repeat('*', strlen($m[2])) . '-' . $m[3];
}
// 그 외: 숫자만 추출해 앞3·뒤4 노출
$digits = preg_replace('/\D/', '', $v);
$n = strlen($digits);
if ($n <= 4) {
return str_repeat('*', $n);
}
$head = substr($digits, 0, 3);
$tail = substr($digits, -4);
return $head . str_repeat('*', max(1, $n - 7)) . $tail;
}
}
if (! function_exists('pii_mask_email')) {
/** 이메일 마스킹: 앞부분 첫 글자만 노출. hong@a.com→h***@a.com */
function pii_mask_email(?string $v): string
{
$v = trim((string) $v);
if ($v === '' || strpos($v, '@') === false) {
return $v === '' ? '' : pii_mask_name($v);
}
[$local, $domain] = explode('@', $v, 2);
$llen = mb_strlen($local, 'UTF-8');
$maskedLocal = $llen <= 1 ? '*' : mb_substr($local, 0, 1, 'UTF-8') . str_repeat('*', $llen - 1);
return $maskedLocal . '@' . $domain;
}
}
if (! function_exists('pii_mask_account')) {
/** 계좌/번호 마스킹: 마지막 4자리만 노출 */
function pii_mask_account(?string $v): string
{
$v = trim((string) $v);
if ($v === '') {
return '';
}
$len = mb_strlen($v, 'UTF-8');
if ($len <= 4) {
return str_repeat('*', $len);
}
return str_repeat('*', $len - 4) . mb_substr($v, $len - 4, 4, 'UTF-8');
}
}
if (! function_exists('pii_blind_index')) {
/**
* 블라인드 인덱스(정확일치 검색용): HMAC-SHA256(정규화값, 별도 인덱스키).
* 키(pii.blindIndexKey)가 없으면 '' 반환(검색 인덱스 비활성).
*/
function pii_blind_index(?string $value): string
{
$value = trim((string) $value);
if ($value === '') {
return '';
}
try {
$key = (string) (config('Pii')->blindIndexKey ?? '');
} catch (Throwable $e) {
$key = '';
}
if ($key === '') {
return '';
}
// 정규화: 공백 제거 + 소문자 (전화는 숫자만)
$norm = preg_replace('/\s+/u', '', $value);
if (preg_match('/^[\d\-()+ ]+$/', $value) === 1) {
$norm = preg_replace('/\D/', '', $value);
} else {
$norm = mb_strtolower($norm, 'UTF-8');
}
return hash_hmac('sha256', $norm, $key);
}
}
/* =========================================================================
* 지정판매소 PII 열람 게이트 — canViewShopPii (단일 판별 지점).
* ======================================================================= */
if (! function_exists('pii_shop_protection_enabled')) {
/** 지정판매소 PII 보호 마스터 플래그 (OFF면 현재 동작=항상 원문) */
function pii_shop_protection_enabled(): bool
{
try {
return (bool) (config('Pii')->designatedShopProtection ?? false);
} catch (Throwable $e) {
return false;
}
}
}
if (! function_exists('pii_ip_in_cidr')) {
/** IP가 CIDR/단일IP 목록(쉼표·공백 구분)에 포함되는지 (IPv4) */
function pii_ip_in_cidr(string $ip, string $list): bool
{
$ip = trim($ip);
if ($ip === '' || $list === '') {
return false;
}
$ipLong = ip2long($ip);
if ($ipLong === false) {
return false;
}
foreach (preg_split('/[\s,]+/', $list, -1, PREG_SPLIT_NO_EMPTY) as $entry) {
if (strpos($entry, '/') !== false) {
[$subnet, $bits] = explode('/', $entry, 2);
$subnetLong = ip2long(trim($subnet));
$bits = (int) $bits;
if ($subnetLong === false || $bits < 0 || $bits > 32) {
continue;
}
$mask = $bits === 0 ? 0 : (-1 << (32 - $bits)) & 0xFFFFFFFF;
if (($ipLong & $mask) === ($subnetLong & $mask)) {
return true;
}
} elseif (ip2long(trim($entry)) === $ipLong) {
return true;
}
}
return false;
}
}
if (! function_exists('pii_request_ip_in_lg_range')) {
/** 현재 요청 IP가 해당 지자체 허용 IP 대역에 속하는지 (local_government.lg_allow_ips) */
function pii_request_ip_in_lg_range(int $lgIdx): bool
{
if ($lgIdx <= 0) {
return false;
}
try {
$db = \Config\Database::connect();
if (! $db->fieldExists('lg_allow_ips', 'local_government')) {
return false; // 컬럼 없으면 대역 미설정 → 자동 원문 불가(마스킹+step-up)
}
$row = $db->table('local_government')->select('lg_allow_ips')->where('lg_idx', $lgIdx)->get()->getRowArray();
$list = (string) ($row['lg_allow_ips'] ?? '');
if ($list === '') {
return false;
}
return pii_ip_in_cidr((string) service('request')->getIPAddress(), $list);
} catch (Throwable $e) {
return false;
}
}
}
if (! function_exists('pii_stepup_granted')) {
/** 이 세션에서 step-up 재인증으로 원문 열람이 승인됐는지 (P2에서 세팅) */
function pii_stepup_granted(int $dsLgIdx): bool
{
$g = session('pii_stepup_grant');
if (! is_array($g)) {
return false;
}
$until = (int) ($g['until'] ?? 0);
$lg = (int) ($g['lg_idx'] ?? -1);
return $until > time() && ($lg === 0 || $lg === $dsLgIdx);
}
}
if (! function_exists('can_view_shop_pii')) {
/**
* 지정판매소 개인정보 원문 열람 가능 여부(단일 판별 지점).
* 보호 OFF → 항상 true(현재 동작). 보호 ON → 조건 판별.
*/
function can_view_shop_pii(int $dsLgIdx): bool
{
if (! pii_shop_protection_enabled()) {
return true;
}
$level = (int) session('mb_level');
$adminLg = (int) session('admin_lg_idx');
$twofa = (bool) session('auth_2fa_verified');
// 정상(자동 원문): 지자체관리자 + 본인 지자체 + 2FA + 지자체 IP대역
if ($level === \Config\Roles::LEVEL_LOCAL_ADMIN
&& $adminLg === $dsLgIdx && $adminLg > 0
&& $twofa
&& pii_request_ip_in_lg_range($dsLgIdx)) {
return true;
}
// 그 외(Super Admin·본부 포함, IP 밖 등): step-up 재인증으로 승인된 경우만
return pii_stepup_granted($dsLgIdx);
}
}
if (! function_exists('mask_shop_field')) {
/** 필드명 기준 마스킹 라우팅 (원문 표시 불가일 때 사용) */
function mask_shop_field(string $field, ?string $value): string
{
$value = (string) $value;
switch ($field) {
case 'ds_rep_name':
return pii_mask_name($value);
case 'ds_tel':
case 'ds_rep_phone':
return pii_mask_phone($value);
case 'ds_email':
return pii_mask_email($value);
case 'ds_va_account':
case 'ds_va_number':
return pii_mask_account($value);
default:
return $value === '' ? '' : pii_mask_name($value);
}
}
}