'...', 'API_KEY' => '...']; // ============================================================================= define('APP_VERSION', 'v2606'); $local_config_file = __DIR__ . '/config.local.php'; $local_config = is_file($local_config_file) ? require $local_config_file : []; function cfg(string $key, $default = '') { global $local_config; if (array_key_exists($key, $local_config)) { return $local_config[$key]; } $env = getenv($key); return $env !== false ? $env : $default; } // ─── DB 설정 ─────────────────────────────────────────────── define('DB_HOST', cfg('DB_HOST', 'localhost')); define('DB_PORT', (int)cfg('DB_PORT', 3306)); define('DB_NAME', cfg('DB_NAME', 'your_db_name')); define('DB_USER', cfg('DB_USER', 'your_db_user')); define('DB_PASS', cfg('DB_PASS', 'change-me')); define('DB_CHARSET', cfg('DB_CHARSET', 'utf8mb4')); // ─── API 보안 키 (STM32 펌웨어 secrets.h의 API_KEY와 동일하게 설정) ─── define('API_KEY', cfg('API_KEY', 'change-this-api-key')); // 운영 비밀값이 예시 기본값 그대로인지(=교체되지 않았는지) 판정. 네트워크 경계 // (api/sensor_data.php)가 부팅/요청 시 기본 API_KEY 로는 수신을 거부하도록 한다. define('API_KEY_DEFAULT', 'change-this-api-key'); function api_key_is_default(): bool { return !defined('API_KEY') || API_KEY === '' || API_KEY === API_KEY_DEFAULT; } // 수신 본문 timestamp 허용 시계 오차(초). 이 범위를 벗어난(과거/미래) 서명 요청은 // 재전송(replay) 방어를 위해 거부한다. SHT30 보고 주기(5분) 대비 넉넉히 둔다. define('INGEST_MAX_CLOCK_SKEW_SEC', (int)cfg('INGEST_MAX_CLOCK_SKEW_SEC', 600)); // 수신 본문 최대 크기(바이트). 정상 디바이스 본문은 ~1KB 미만. 인증 전 대용량 // 본문을 json_decode/HMAC 에 태우는 증폭형 DoS 를 차단한다. define('INGEST_MAX_BODY_BYTES', (int)cfg('INGEST_MAX_BODY_BYTES', 8192)); // 재전송(replay) 멱등 방어. 서명(X-Signature)을 nonce 로 기록해, 시계오차 창 // (INGEST_MAX_CLOCK_SKEW_SEC) 안에서 동일 서명 본문이 재수신되면 멱등 처리(재저장/ // 재발송 생략)한다. 펌웨어 재시도(같은 본문)·캡처 재생 모두 한 번만 반영된다. // leak_ingest_nonce 테이블이 없는 기존 배포에서는 자동으로 비활성(graceful degrade). define('INGEST_NONCE_ENABLED', (bool)cfg('INGEST_NONCE_ENABLED', true)); // 레거시 본문 필드 서명(verify_signature: 키-접두 sha256) 수용 여부. 기본 비활성. // 운영 클라이언트(STM32)는 HMAC raw-body 서명만 쓰므로, 약한 경로를 닫아 둔다. // 과거 RPi 클라이언트가 남아 있을 때만 1 로 켠다. define('ALLOW_LEGACY_BODY_SIGNATURE', (bool)cfg('ALLOW_LEGACY_BODY_SIGNATURE', false)); // ─── SMS 설정 (Cafe24 SMS API) ────────────────────────────── define('SMS_USER_ID', cfg('SMS_USER_ID', 'your-cafe24-sms-user-id')); define('SMS_SECURE', cfg('SMS_SECURE', 'your-cafe24-sms-secure-key')); define('SMS_SENDER', cfg('SMS_SENDER', '01000000000')); define('SMS_ENDPOINT', cfg('SMS_ENDPOINT', 'https://sslsms.cafe24.com/sms_sender.php')); // SMS 수신자 목록 (복수 가능) $sms_recipients = cfg('SMS_RECIPIENTS', ['01000000000']); if (is_string($sms_recipients)) { $sms_recipients = array_filter(array_map('trim', explode(',', $sms_recipients))); } define('SMS_RECIPIENTS', $sms_recipients); // Cafe24 SMS('S' 타입)는 EUC-KR 기준 약 90바이트까지만 단문으로 발송된다. 초과하면 // 게이트웨이가 잘라/거부하므로, 발송 직전에 EUC-KR 바이트 기준으로 안전하게 자른다 // (멀티바이트 경계 보존; sms_truncate_euckr). 위치/센서명이 길어도 무성 절단을 막는다. define('SMS_MAX_EUCKR_BYTES', (int)cfg('SMS_MAX_EUCKR_BYTES', 90)); // ─── SMS 메시지 태그 (경보 상태머신의 단일 출처) ────────────────────────────── // leak_sms_log.message 의 접두 태그로 쿨다운/복구/대시보드/월간보고서가 경보 상태를 // 재구성한다. 별도 상태 테이블을 두지 않으므로 이 태그 문자열이 사실상의 상태 키다. // 여러 파일(sms_send/dashboard/monthly_report)의 LIKE 문자열이 서로 어긋나 무성 // 장애가 나는 것을 막기 위해, 태그는 반드시 여기서만 정의하고 상수로 참조한다. const SMS_TAG_HIGH_TEMP = '[고온경보]'; const SMS_TAG_LOW_TEMP = '[저온경보]'; const SMS_TAG_HIGH_HUMIDITY = '[고습경보]'; const SMS_TAG_LOW_HUMIDITY = '[저습경보]'; const SMS_TAG_RECOVERY = '[정상복귀]'; const SMS_TAG_DEVICE_OFFLINE = '[장비 오프라인]'; const SMS_TAG_DEVICE_ONLINE = '[장비 복구]'; const SMS_TAG_CONDENSATION = '[결로주의]'; // 이슬점 근접(결로 위험) const SMS_TAG_BALANCE_LOW = '[SMS 잔액부족]'; // 발송 잔여 건수 부족 사전경보 const SMS_TAG_HEALTH = '[정기점검]'; // 일일 생존신호(데드맨) const SMS_TAG_SENSOR_STUCK = '[센서고착]'; // 측정값 고착(live-but-stuck) 의심 const SMS_TAG_ESCALATION = '[에스컬레이션]'; // 미복귀 경보 2차(야간) 통지 const SMS_TAG_LEAK_ALERT = '[누수감지]'; // 1번 모듈(누수) 누수 발생 const SMS_TAG_LEAK_RECOVERY = '[누수복구]'; // 1번 모듈(누수) 누수 해소(복구) // 경보 종류(metric kind) → 태그 매핑(단일 출처). evaluate_metric_thresholds 의 // breach 종류와 1:1 대응한다. const SMS_METRIC_TAGS = [ 'high_temp' => SMS_TAG_HIGH_TEMP, 'low_temp' => SMS_TAG_LOW_TEMP, 'high_humidity' => SMS_TAG_HIGH_HUMIDITY, 'low_humidity' => SMS_TAG_LOW_HUMIDITY, ]; /** * leak_sms_log 태그별 건수 집계 SELECT 절(단일 출처). 대시보드/월간보고서가 공유한다. * 태그 문자열은 위 SMS_TAG_* 상수에서만 가져오므로, 태그를 바꿔도 모든 집계가 한 * 곳에서 따라온다(여러 파일의 LIKE 문자열이 어긋나 무성 장애 나는 것 방지). * 반환 컬럼: high_temp, low_temp, high_humidity, low_humidity, recovery, * device_offline, device_online, leak_alert, leak_recovery, sms_fail */ function sms_summary_select_sql(): string { return "SUM(message LIKE '" . SMS_TAG_HIGH_TEMP . "%') AS high_temp,\n" . " SUM(message LIKE '" . SMS_TAG_LOW_TEMP . "%') AS low_temp,\n" . " SUM(message LIKE '" . SMS_TAG_HIGH_HUMIDITY . "%') AS high_humidity,\n" . " SUM(message LIKE '" . SMS_TAG_LOW_HUMIDITY . "%') AS low_humidity,\n" . " SUM(message LIKE '" . SMS_TAG_RECOVERY . "%') AS recovery,\n" . " SUM(message LIKE '" . SMS_TAG_DEVICE_OFFLINE . "%') AS device_offline,\n" . " SUM(message LIKE '" . SMS_TAG_DEVICE_ONLINE . "%') AS device_online,\n" . " SUM(message LIKE '" . SMS_TAG_LEAK_ALERT . "%') AS leak_alert,\n" . " SUM(message LIKE '" . SMS_TAG_LEAK_RECOVERY . "%') AS leak_recovery,\n" . " SUM(status = 'fail') AS sms_fail"; } // ─── 온습도 임계 경보 (고온/저온/고습/저습 → SMS) ────────────────────────── // 측정값이 아래 운영 임계를 벗어나면 api/sensor_data.php 가 종류별 SMS를 보냅니다. // 임계 판정은 펌웨어가 아니라 서버에서 하므로, 폐쇄망에서도 재플래시 없이 // config.local.php 로 값을 조정할 수 있습니다. define('METRIC_TEMP_HIGH_C', (float)cfg('METRIC_TEMP_HIGH_C', 30)); // 고온 임계(℃) define('METRIC_TEMP_LOW_C', (float)cfg('METRIC_TEMP_LOW_C', 10)); // 저온 임계(℃) define('METRIC_RH_HIGH', (float)cfg('METRIC_RH_HIGH', 70)); // 고습 임계(%) define('METRIC_RH_LOW', (float)cfg('METRIC_RH_LOW', 20)); // 저습 임계(%) // 복구(정상 회복) 히스테리시스: 임계 안쪽으로 이 여유만큼 들어와야 복구로 간주(경계 채터링 방지) define('METRIC_TEMP_HYSTERESIS_C', (float)cfg('METRIC_TEMP_HYSTERESIS_C', 1.0)); define('METRIC_RH_HYSTERESIS', (float)cfg('METRIC_RH_HYSTERESIS', 3.0)); // 동일 종류 경보 재발송 억제 쿨다운(초). 기본 30분. define('METRIC_ALERT_COOLDOWN_SEC', (int)cfg('METRIC_ALERT_COOLDOWN_SEC', 1800)); // 누수(leak) 경보 재발송 억제 쿨다운(초). 1번 모듈은 누수 지속 중 주기(periodic) 보고를 // 계속 보내므로, 같은 누수 에피소드에서 [누수감지] SMS 가 반복 발송되지 않도록 한다. 기본 30분. define('LEAK_ALERT_COOLDOWN_SEC', (int)cfg('LEAK_ALERT_COOLDOWN_SEC', 1800)); // ─── 결로(이슬점) 경보 ──────────────────────────────────────────────────────── // 온도·습도가 각각 정상이어도 (현재온도 − 이슬점)=결로마진 이 좁으면 차가운 표면에 // 결로가 생긴다(장비 손상). 마진이 이 임계 이하이면 결로주의 경보를 보낸다. // 단일 SHT30 측정만으로 산출(이슬점=Magnus 근사). 0 이하로 두면 결로 경보 비활성. define('METRIC_DEWPOINT_MARGIN_C', (float)cfg('METRIC_DEWPOINT_MARGIN_C', 3.0)); define('METRIC_DEWPOINT_HYSTERESIS_C', (float)cfg('METRIC_DEWPOINT_HYSTERESIS_C', 1.0)); // ─── SMS 잔액(잔여 건수) 사전경보 ───────────────────────────────────────────── // Cafe24 응답의 잔여 건수가 이 값 이하로 내려가면 1회 '[SMS 잔액부족]' 경고를 보낸다 // (경보 채널이 잔액 소진으로 조용히 죽는 것을 사전 방지). 쿨다운으로 반복을 억제. define('SMS_BALANCE_WARN_THRESHOLD', (int)cfg('SMS_BALANCE_WARN_THRESHOLD', 30)); define('SMS_BALANCE_WARN_COOLDOWN_SEC',(int)cfg('SMS_BALANCE_WARN_COOLDOWN_SEC', 21600)); // 6시간 // ─── 일일 헬스(생존신호) SMS ────────────────────────────────────────────────── // 하루 1통 '[정기점검]' 요약(정상 센서 수/최근 수신/SMS 잔여)을 보낸다. '무소식=정상' // 과 '무소식=경보경로 전체 고장'의 모호성을 제거하는 데드맨(관리자는 '안 오면 점검'만 // 기억하면 된다). HEALTH_SMS_HOUR 시(0~23) 이후의 첫 체크에서 하루 1회 발송. define('HEALTH_SMS_ENABLED', (bool)cfg('HEALTH_SMS_ENABLED', true)); define('HEALTH_SMS_HOUR', (int)cfg('HEALTH_SMS_HOUR', 9)); // ─── 경보 신뢰성 (재시도 / 에스컬레이션 / 고착 검출 / 드릴) ──────────────────── // 오프라인 SMS 발송 실패 시 offline_alerted 를 되돌려 다음 주기에 재시도한다(critical // 오프라인 미통지 방지 — 일회성 발송이 실패하면 영영 못 알리던 갭). define('SMS_RETRY_OFFLINE', (bool)cfg('SMS_RETRY_OFFLINE', true)); // 미복귀 경보 에스컬레이션: 임계 경보가 ESCALATE_AFTER_SEC 이상 미복귀로 지속되면 2차 // (야간) 수신자(SMS_RECIPIENTS_TIER2)에게 1회 통지. TIER2 가 비면 비활성(opt-in). $sms_tier2 = cfg('SMS_RECIPIENTS_TIER2', []); if (is_string($sms_tier2)) { $sms_tier2 = array_filter(array_map('trim', explode(',', $sms_tier2))); } define('SMS_RECIPIENTS_TIER2', $sms_tier2); define('ESCALATE_AFTER_SEC', (int)cfg('ESCALATE_AFTER_SEC', 1800)); // 센서 고착(live-but-stuck) 검출: 최근 STUCK_DETECT_SAMPLES 측정의 표준편차가 // STUCK_DETECT_EPSILON 미만(완전 평탄)이면 소자 고장(같은 값 반복)을 의심해 알린다. // (하트비트는 '수신 끊김'만 본다 — I2C 는 살아있는데 값이 고착된 무성실패를 놓침.) define('STUCK_DETECT_ENABLED', (bool)cfg('STUCK_DETECT_ENABLED', true)); define('STUCK_DETECT_SAMPLES', (int)cfg('STUCK_DETECT_SAMPLES', 6)); // 6 × 5분 = 30분 define('STUCK_DETECT_EPSILON', (float)cfg('STUCK_DETECT_EPSILON', 0.01)); // 분해능 이하 define('STUCK_ALERT_COOLDOWN_SEC', (int)cfg('STUCK_ALERT_COOLDOWN_SEC', 21600)); // 6시간 // 카오스 드릴: 켜면 send_sms_cafe24 가 실제 전송 없이 실패를 반환한다(실패 경로 시험). // 운영에서는 반드시 false. tests/chaos_drill.php 가 일시적으로 켜서 검증한다. define('SMS_FORCE_FAIL', (bool)cfg('SMS_FORCE_FAIL', false)); // ─── Heartbeat (장비 오프라인 감지) ──────────────────────── // SHT30 측정 보고 주기는 5분(펌웨어 APP_SHT30_REPORT_INTERVAL_SEC=300), 누수 모듈 // 정상 보고 주기는 10분(raspberry_pi4/config.py REPORT_INTERVAL=600). 기본 오프라인 // 판정 20분은 SHT30 4회 / 누수 2회 누락에 해당한다. config.local.php 로 조정 가능. // ⚠️ 이 타임아웃은 전 센서 공통 — 어떤 모듈의 보고 주기보다도 길어야 오탐이 없다. define('HEARTBEAT_TIMEOUT_SEC', (int)cfg('HEARTBEAT_TIMEOUT_SEC', 1200)); define('HEARTBEAT_CHECK_INTERVAL_SEC', (int)cfg('HEARTBEAT_CHECK_INTERVAL_SEC', 300)); // 오프라인 체크 실행 주기 define('CRON_SECRET', hash_hmac('sha256', 'cron', API_KEY)); // cron HTTP 접근용 시크릿(API_KEY 파생, HMAC) // ─── 누수 모듈 sensor_id 목록 ──────────────────────────── // 대시보드 누수 상태 카드 표시 대상. 신규 누수 지점 추가 시 config.local.php 로 // 오버라이드한다. 예: 'LEAK_SENSOR_IDS' => [1, 3] (문자열 "1,3" 도 허용) // 유효 범위는 1..255 정수(api/sensor_data.php 의 sensor_id 검증과 동일). 범위 밖· // 비숫자 항목은 버리고, 결과가 비면 기본 [1] 로 되돌린다(오설정으로 누수 카드 // 전체가 사라지는 것 방지). $leak_sensor_ids = cfg('LEAK_SENSOR_IDS', [1]); if (is_string($leak_sensor_ids)) { $leak_sensor_ids = explode(',', $leak_sensor_ids); } $leak_sensor_ids = array_values(array_filter( array_map('intval', (array)$leak_sensor_ids), static fn($id) => $id >= 1 && $id <= 255 )); define('LEAK_SENSOR_IDS', $leak_sensor_ids !== [] ? $leak_sensor_ids : [1]); // ─── 대시보드 세션 인증 ────────────────────────────────── define('ADMIN_USER', cfg('ADMIN_USER', 'admin')); define('ADMIN_PASSWORD_HASH', cfg('ADMIN_PASSWORD_HASH', '')); // bcrypt 해시 define('ADMIN_TOTP_SECRET', cfg('ADMIN_TOTP_SECRET', '')); // TOTP base32 secret for authenticator apps define('MFA_SETUP_TOKEN', cfg('MFA_SETUP_TOKEN', '')); // temporary setup token for setup_mfa.php bootstrap define('SESSION_TIMEOUT', 3600); // 유휴(idle) 타임아웃: 마지막 활동 후 1시간 // 절대 세션 수명(초). 연속 활동 중이어도 로그인 시각 기준 이 시간이 지나면 만료한다 // (유휴 타임아웃만 있으면 활동 중 세션이 무한정 유지되는 문제 보완). 기본 12시간. define('SESSION_ABSOLUTE_TIMEOUT', (int)cfg('SESSION_ABSOLUTE_TIMEOUT', 43200)); // ─── Retention policy ──────────────────────────────────────────────────────── define('SMS_LOG_RETENTION_DAYS', (int)cfg('SMS_LOG_RETENTION_DAYS', 365)); define('SENSOR_LOG_RETENTION_DAYS', (int)cfg('SENSOR_LOG_RETENTION_DAYS', 365)); define('SENSOR_METRIC_RETENTION_DAYS', (int)cfg('SENSOR_METRIC_RETENTION_DAYS', 365)); define('ADMIN_AUDIT_RETENTION_DAYS', (int)cfg('ADMIN_AUDIT_RETENTION_DAYS', 365)); // ─── 타임존 ─────────────────────────────────────────────── date_default_timezone_set('Asia/Seoul'); // ─── DB 연결 함수 ────────────────────────────────────────── function get_db(): PDO { static $pdo = null; if ($pdo === null) { $dsn = sprintf( 'mysql:host=%s;port=%d;dbname=%s;charset=%s', DB_HOST, DB_PORT, DB_NAME, DB_CHARSET ); $pdo = new PDO($dsn, DB_USER, DB_PASS, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]); } return $pdo; } // ─── JSON 응답 헬퍼 ─────────────────────────────────────── function json_response(array $data, int $http_code = 200): void { http_response_code($http_code); header('Content-Type: application/json; charset=utf-8'); echo json_encode($data, JSON_UNESCAPED_UNICODE); exit; } // ─── 개인정보(수신번호) 마스킹 ───────────────────────────── // 화면/보고서에 수신번호를 노출할 때 가운데를 가린다(010-****-1234). 발송 기록 // 자체(leak_sms_log)는 보존하되 표시 단계에서만 최소화한다(개인정보 최소표시 원칙). // 쉼표로 여러 번호가 들어오면 각각 마스킹한다. function mask_phone(string $value): string { if (strpos($value, ',') !== false) { $parts = array_map('trim', explode(',', $value)); return implode(', ', array_map('mask_phone', $parts)); } $d = preg_replace('/\D+/', '', $value); $len = strlen($d); if ($len < 4) { return str_repeat('*', $len); } $last4 = substr($d, -4); $prefix = $len >= 7 ? substr($d, 0, 3) : ''; return ($prefix !== '' ? $prefix . '-' : '') . '****-' . $last4; } // 최근 성공 발송의 잔여 SMS 건수(result_message "발송 성공 (잔여 N건)" 에서 파싱). // 별도 컬럼 없이 기존 기록에서 읽는다. 알 수 없으면 null. (잔액 점검/헬스 요약 공용) function latest_sms_balance(PDO $db): ?int { try { $rm = $db->query(" SELECT result_message FROM leak_sms_log WHERE status = 'success' AND result_message LIKE '%잔여%' ORDER BY id DESC LIMIT 1 ")->fetchColumn(); } catch (Throwable $e) { return null; } if ($rm !== false && preg_match('/잔여\s*(\d+)\s*건/u', (string)$rm, $m)) { return (int)$m[1]; } return null; } // ─── 수집 거부 사유 카운터 (파일 기반, DoS 안전) ────────────────────────────── // 인증 전 거부(403/400/413/503/범위)를 사유별 일자 버킷으로 집계한다. DB 를 건드리지 // 않아(파일 flock) 위조요청 폭주에도 부담이 작다. 대시보드/ops 가 읽어 '왜 데이터가 // 안 들어오는가'(시계오차/키불일치/위조시도)를 빠르게 분별한다. function ingest_rejection_path(): string { return __DIR__ . '/var/ingest_rejections.json'; } function record_ingest_rejection(string $reason): void { $fh = @fopen(ingest_rejection_path(), 'c+'); if ($fh === false) { return; // 카운터 실패가 수집 처리를 막아선 안 됨(best-effort) } if (flock($fh, LOCK_EX)) { $today = date('Y-m-d'); $raw = stream_get_contents($fh); $data = ($raw !== false && $raw !== '') ? json_decode($raw, true) : []; if (!is_array($data)) { $data = []; } if (!isset($data[$today]) || !is_array($data[$today])) { $data[$today] = []; } $data[$today][$reason] = ($data[$today][$reason] ?? 0) + 1; if (count($data) > 14) { // 최근 14일만 보존(파일 무한증가 방지) ksort($data); $data = array_slice($data, -14, null, true); } ftruncate($fh, 0); rewind($fh); fwrite($fh, json_encode($data)); fflush($fh); flock($fh, LOCK_UN); } fclose($fh); } // 최근 $days 일의 거부 사유별 합계(내림차순). 반환: ['reason' => count, ...]. function read_ingest_rejections(int $days = 1): array { $file = ingest_rejection_path(); if (!is_file($file)) { return []; } $data = json_decode((string)@file_get_contents($file), true); if (!is_array($data)) { return []; } $cutoff = date('Y-m-d', time() - max(0, $days - 1) * 86400); $totals = []; foreach ($data as $day => $reasons) { if ((string)$day < $cutoff || !is_array($reasons)) { continue; } foreach ($reasons as $reason => $cnt) { $totals[$reason] = ($totals[$reason] ?? 0) + (int)$cnt; } } arsort($totals); return $totals; } // ─── 서명 검증 (레거시: 본문 필드 기반, DEPRECATED) ─────── // 디코드된 데이터를 ksort 후 재직렬화하여 해시한다. 과거 RPi Python 클라이언트 // 전용의 하위 호환 경로이며, 현재 운영 클라이언트(STM32 펌웨어)는 사용하지 않는다. // 키 접두 sha256 이라 길이확장에 약하므로 신규 클라이언트는 절대 사용하지 말 것. // (남은 레거시 클라이언트가 없으면 차후 제거 가능.) function verify_signature(array $data): bool { if (empty($data['signature'])) return false; $received_sig = $data['signature']; unset($data['signature']); ksort($data); $payload = API_KEY . json_encode($data, JSON_UNESCAPED_UNICODE); $expected = hash('sha256', $payload); return hash_equals($expected, $received_sig); } // ─── 서명 검증 (신규: raw-body 기반, HMAC-SHA256) ────────── // 요청 본문 바이트 전체에 대해 HMAC-SHA256(key=API_KEY, msg=raw) 를 계산하고 // X-Signature 헤더 값과 상수시간 비교한다. // // JSON 재직렬화(키 정렬·부동소수 포맷·슬래시 이스케이프)에 의존하지 않으므로 // MCU(STM32 등)에서 부동소수 표기 차이로 서명이 불일치하던 문제를 제거한다. // 키 접두(sha256(key.body)) 대신 HMAC 을 써서 길이확장 공격에 견딘다. // 펌웨어 sig.c(HMAC) / reference.py(hmac.new) 와 바이트 단위로 일치한다. // 클라이언트는 전송하는 본문 바이트 그대로를 서명하기만 하면 된다. function verify_signature_raw(string $raw_body): bool { $received_sig = $_SERVER['HTTP_X_SIGNATURE'] ?? ''; if ($received_sig === '') return false; $expected = hash_hmac('sha256', $raw_body, API_KEY); return hash_equals($expected, $received_sig); }