POSA_LEAKSMS/php/config.php
cw2007 73e49fd528 feat: RPi4 누수+온습도 2-모듈 단일 서버 통합 + 보안 하드닝
2번 모듈 신규(raspberry_pi4_sht30/): SHT30 온습도 → cafe24, raw-body X-Signature HMAC, 폐쇄망 오프라인 설치(vendor/install), systemd, CRC/포맷 자가점검. firmware sht30_fw wire 포맷과 byte-parity 검증.

1번 모듈 통합(raspberry_pi4/): 누수+카메라를 동일 서버로. 레거시 sha256(key+json) body-sig → X-Signature HMAC 전환, 사진 canonical=device_id|sensor_id|log_id|timestamp.

서버 통합(php/): api/sensor_data.php 누수 이벤트 수신, api/photo_upload.php 신규(HMAC·finfo·서버생성 파일명·nonce 멱등·sensor 결속), photo.php 인증 사진서빙, dashboard.php 누수 패널+사진, sms_send.php 누수 SMS, config.php 태그/요약.

DB: sql/migration_add_leak.sql(가산·멱등), schema_sht30.sql 통합 스키마.

보안 하드닝(외부 펜테스트 대비, 확정 CRITICAL/HIGH 0): cron_heartbeat api_key fail-closed, setup_mfa 부트스트랩 토큰 post-enrollment 잠금, var/ PHP 실행차단, HSTS.

문서/테스트: SYSTEM_OVERVIEW.md, security_hardening_test.php 갱신. 참고: 사용자 요청으로 세션 외 기존 미커밋 변경도 함께 포함.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:21:11 +09:00

369 lines
22 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
// =============================================================================
// config.php - 데이터베이스 및 공통 설정 (v2605)
//
// 운영 비밀값은 config.local.php 또는 환경 변수로 주입합니다.
// config.local.php는 이 파일과 같은 디렉터리에 두며, 배열을 return 합니다.
// 예: return ['DB_PASS' => '...', '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) 안에서 동일 서명 본문이 재수신되면 멱등 처리(재저장/
// 재발송 생략)한다. 펌웨어 재시도(같은 본문)·캡처 재생 모두 한 번만 반영된다.
// 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 메시지 태그 (경보 상태머신의 단일 출처) ──────────────────────────────
// 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,
];
/**
* 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). 기본
// 오프라인 판정은 약 4회 누락(20분)으로 둔다. 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)
// ─── 대시보드 세션 인증 ──────────────────────────────────
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). 발송 기록
// 자체(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 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);
}