key === '') { return $value; } $encrypter = service('encrypter'); $encrypted = $encrypter->encrypt($value); return 'ENC:' . base64_encode($encrypted); } catch (Throwable $e) { return $value; } } } if (! function_exists('pii_decrypt')) { function pii_decrypt(?string $value): string { if ($value === null || $value === '') { return ''; } if (strpos($value, 'ENC:') !== 0) { return $value; } try { $config = config('Encryption'); if ($config->key === '') { return $value; } $encrypter = service('encrypter'); $payload = substr($value, 4); // 현재 포맷: ENC: + base64(raw ciphertext) $raw = base64_decode($payload, true); if ($raw !== false) { try { return $encrypter->decrypt($raw); } catch (Throwable $e) { // legacy 포맷 재시도 } } // 레거시 포맷 호환: // - ENC: + encrypter 반환값(rawData=false 환경 등) 또는 // - ENC: + 기타 문자열 포맷 return $encrypter->decrypt($payload); } catch (Throwable $e) { return $value; } } } /** 암호화 대상 개인정보 필드 (member 테이블) */ 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); } } }