POSA_LEAKSMS/raspberry_pi4_sht30/wire.py
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

98 lines
3.9 KiB
Python

# =============================================================================
# wire.py - 서버 전송용 wire 포맷 (본문 JSON + raw-body HMAC 서명)
#
# [왜 별도 모듈인가]
# 서버(php/api/sensor_data.php)는 받은 "본문 바이트 그대로"에 대해
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=raw_body)) 를 검증한다.
# 따라서 "서명한 바이트"와 "실제 전송한 바이트"가 100% 동일해야 한다.
# 본문을 직접 문자열로 만들고 그 바이트에 서명한 뒤, 그 바이트를 그대로 POST 한다.
# (requests 의 json= 직렬화에 맡기면 부동소수/공백/순서가 달라져 서명이 깨질 수 있다.)
#
# [바이트 단위 호환 대상] — 아래 넷과 출력이 동일해야 한다.
# - firmware/common/jsonbody.c::jb_sht30_event (STM32 본문 생성)
# - firmware/common/sig.c::sig_raw_body (STM32 HMAC 서명)
# - firmware/test/host/reference.py (검증된 Python 레퍼런스)
# - php/config.php::verify_signature_raw (서버 검증부)
#
# fixed2() <-> jb_fixed2() / reference.fixed2()
# sht30_event_body() <-> jb_sht30_event() / reference.sht30_event_body()
# sign_raw() <-> sig_raw_body() / reference.sign_raw()
# =============================================================================
from __future__ import annotations
import hashlib
import hmac
def fixed2(v: float) -> str:
"""소수 2자리 고정 포맷 (round-half-up, locale 무관).
jb_fixed2() / reference.fixed2() 와 바이트 동일.
24.0 -> "24.00"
48.5 -> "48.50"
-3.245 -> "-3.25"
"""
neg = v < 0
if neg:
v = -v
scaled = int(v * 100.0 + 0.5) # round half up (C: (long long)(v*100+0.5))
ip = scaled // 100
fp = scaled % 100
sign = "-" if (neg and scaled != 0) else ""
return f"{sign}{ip}.{fp:02d}"
def _esc(s: str) -> str:
r"""JSON 문자열 값 이스케이프 (jb_estr / reference._esc 와 동일).
'"' 와 '\\' 와 제어문자(<0x20)만 이스케이프한다. '/' 는 그대로 둔다.
멀티바이트 UTF-8(한글 등)은 그대로 둔다(서버가 raw 바이트로 검증).
"""
out = []
for ch in s:
o = ord(ch)
if ch == '"':
out.append('\\"')
elif ch == '\\':
out.append('\\\\')
elif o < 0x20:
out.append('\\u%04x' % o)
else:
out.append(ch)
return "".join(out)
def sht30_event_body(device_id: str, device_location: str, sensor_id: int,
sensor_name: str, event_type: str, timestamp: int,
temperature_c: float, humidity_percent: float,
metric_status: str, app_version: str) -> str:
"""SHT30 이벤트 본문 JSON 문자열 생성.
필드 순서는 펌웨어 jb_sht30_event() 와 동일하게 고정한다(서명 호환).
metric_type 은 항상 "sht30" 고정. 온도/습도는 fixed2 로 2자리 직렬화.
"""
return (
'{'
f'"device_id":"{_esc(device_id)}",'
f'"device_location":"{_esc(device_location)}",'
f'"sensor_id":{int(sensor_id)},'
f'"sensor_name":"{_esc(sensor_name)}",'
f'"event_type":"{_esc(event_type)}",'
f'"timestamp":{int(timestamp)},'
f'"metric_type":"sht30",'
f'"temperature_c":{fixed2(temperature_c)},'
f'"humidity_percent":{fixed2(humidity_percent)},'
f'"metric_status":"{_esc(metric_status)}",'
f'"app_version":"{_esc(app_version)}"'
'}'
)
def sign_raw(api_key: str, body: str) -> str:
"""raw-body 서명: lowercase_hex(HMAC-SHA256(key=api_key, msg=body)).
sig_raw_body() / reference.sign_raw() / php verify_signature_raw 와 동일.
본문은 UTF-8 로 인코딩한 뒤 서명한다(전송 바이트와 동일).
"""
return hmac.new(api_key.encode("utf-8"), body.encode("utf-8"),
hashlib.sha256).hexdigest()