POSA_LEAKSMS/docs/stm32f407_migration_plan.md
유창욱 90f121e14c chore: import codebase with security hardening
SHT30 온습도 모니터링 시스템 전체 소스(서버 PHP, STM32 펌웨어, SQL, 테스트).
전체 코드리뷰에서 도출된 보안 하드닝 10건 반영:
- 요청 서명 HMAC-SHA256 전환(펌웨어 sig.c/서버 config.php/호스트 패리티 동시)
- 재전송 방어 + 기본 API_KEY fail-closed + 디바이스 문자열 정제(api/sensor_data.php)
- 오프라인 SMS 중복 발송 경합 제거(cron_heartbeat.php, 원자적 선점)
- CSV 수식 주입 방지(monthly_report.php), 감사로그 회전 락(retention_cleanup.php)
- 브루트포스 카운터 원자화(login.php), 예시 TOTP 비밀키 무효화, 마이그레이션 멱등화

_backup/(하드코딩 실 비밀값 포함)·config.local.php·런타임 상태는 .gitignore 제외.
2026-06-20 09:37:40 +09:00

257 lines
15 KiB
Markdown
Raw 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.

# 메인 MCU 마이그레이션 계획: Raspberry Pi Zero 2 W → STM32F407VGT6 (Cortex-M4)
> 대상: SHT30 온습도 모니터링 시스템 펌웨어 이식 (SHT30 단일 보드)
> 작성일: 2026-06-09 · 구현 완료: 2026-06-09 · 단일 보드 전환: 2026-06
## 0. 구현 현황 (✅ 완료)
펌웨어가 `firmware/` 에 구현되었고 레거시 RPi 펌웨어(`raspberry_pi/`, `systemd/`)는 제거되었습니다.
시스템은 **SHT30 온습도 단일 보드**로 전환되었습니다(누수 보드/누수 펌웨어는 제거됨).
**산출물**: 검증된 이식 코어(`common/{sha256_sw,hexutil,sig,jsonbody,sht30_convert,httpapi}.c`),
타깃 모듈(`net/ethernetif/tls_mbedtls/timesync/sht30/reporter/applog/watchdog/bsp/rtos_hooks`),
보드 앱(`board_sht30/`), 설정(`config/{FreeRTOSConfig,lwipopts,mbedtls_config,stm32f4xx_hal_conf}.h`),
빌드(`CMakeLists.txt`, `cmake/`, `ld/`, `scripts/`), 인증서(`certs/`), 문서(`README.md`, `docs/`).
**검증 (이 환경에서 실행 가능한 범위)**:
- 이식 코어 ARM 크로스컴파일 게이트: `arm-none-eabi-gcc -mcpu=cortex-m4 ... -Werror -Wconversion` 무경고 통과.
- wire 계약 패리티: Python 레퍼런스(=C와 동일 알고리즘) → 서버 PHP `verify_signature_raw` 수용 통과(`firmware/test/host/parity_test.py`).
- PHP 서명/보안 회귀: `tests/raw_body_signature_test.php`, `tests/security_hardening_test.php` 통과.
- ⚠️ **전체 펌웨어 링크/실행은 미검증**: HAL/FreeRTOS/lwip/mbedtls 는 폐쇄망 빌드 머신에서 벤더링되므로(`firmware/third_party`), 실제 보드 빌드·플래시·HIL 시험은 운영 측에서 수행해야 함.
**다중 에이전트 코드리뷰 후 수정된 결함**:
- (CRITICAL) TLS 인증서 유효기간(notBefore/notAfter) 미검증 → `MBEDTLS_HAVE_TIME_DATE` + RTC/SNTP 시간 공급(`tls_platform_time`), 시간 미동기 시 connect 차단.
- (CRITICAL) FreeRTOS 정적메모리 콜백 중복정의(커널 제공과 충돌) → `rtos_hooks.c` 에서 제거(커널 소유).
- (HIGH) LwIP 코어락 위반: PHY 링크 통지 raw API → `netifapi_*` + 콜백 내부 raw `netif_set_up`(중첩락 데드락 회피).
- (CRITICAL/통합) 로그 UART PA2(=RMII MDIO) 충돌 → USART3(PD8/PD9), FreeRTOS 훅 단일 소유, `-u _printf_float`.
- (MEDIUM/LOW) `close_notify` 무한루프 상한, `dns_setserver` 코어락, HW RNG `bsp_rand32`, SHT30 태스크 스택 12KB, CA 자리표시자 동작 주석.
**배포 전 하드웨어 확정 필요 — TODO(hw)** (상세: `firmware/docs/HARDWARE.md`, `PORTING_NOTES.md`):
LAN8720 PHY 주소·RMII 50MHz REF_CLK 소스, HSE 주파수(PLLM), RTC 클럭원(LSE 32.768k vs LSI),
상태 LED 핀, MAC 주소(노드별 고유), SHT30 I2C 핀(PB6/PB7),
실제 Cafe24 루트 CA(`certs/server_ca.c`), `secrets.h`(APP_API_KEY).
---
## 1. 결정 사항 (확정)
| 항목 | 현재 (RPi) | 신규 (STM32F407VGT6) |
|---|---|---|
| 메인 MCU | RPi Zero 2 W (Linux/Python) | STM32F407VGT6 (Cortex-M4F, bare HW) |
| 네트워크 | WiFi | **유선 Ethernet (LAN8720 RMII PHY)** |
| TLS/HTTPS | OpenSSL (`requests`) | **온칩 mbedTLS + LwIP** |
| 보드 구성 | RPi SHT30 노드 | **STM32 SHT30 단일 보드** (SHT30=sensor_id 2) |
| 임계 판정/SMS | 서버측 처리 | **서버 `config.php` METRIC_* 에서 판정** (펌웨어는 원값만 보고) |
| 동시성 | Python threads | **FreeRTOS tasks** |
| 시간원 | NTP (OS) | **SNTP (LwIP) → RTC** |
| 설정 | env / systemd | **컴파일 타임 `config.h` + (옵션) flash 설정** |
| 로깅 | rotating file log | **UART 콘솔 + (옵션) 서버 로그** |
| 프로세스 관리 | systemd 재시작 | **IWDG 워치독 + 재연결 로직** |
핵심 원칙: **서버측 PHP API는 변경하지 않는다(가능한 한).** 펌웨어가 기존
서명 규약·요청 포맷을 바이트 단위로 그대로 재현하여 무중단으로 전환한다.
---
## 2. 하드웨어 사양 / 제약 (STM32F407VGT6)
- Cortex-M4F @ 168 MHz, **Flash 1 MB**, **RAM 192 KB** (112+16 KB SRAM + 64 KB CCM)
- Ethernet MAC 내장 → 외부 PHY 필요 (**LAN8720, RMII**)
- 하드웨어 **RNG 있음** (TLS 엔트로피에 사용) — 중요
- 하드웨어 해시/암호 가속기 **없음** (CRYP/HASH는 F415/417 전용) → **SHA-256/AES는 소프트웨어(mbedTLS sw)**
- I²C 3채널, USART 다수, EXTI, RTC, IWDG
### 메모리 예산 (가장 큰 리스크)
- mbedTLS TLS1.2 클라이언트(ECDHE-RSA + AES-GCM) 기본 record buffer 2×16 KB = 32 KB.
**`MBEDTLS_SSL_IN/OUT_CONTENT_LEN`을 4~6 KB로 축소** (서버 응답이 작으므로 가능)
→ LwIP pbuf pool과 합산 시 SRAM 빠듯 → **FreeRTOS task stack을 CCM(64 KB)에 배치**
- RSA-2048 핸드셰이크는 소프트웨어로 ~12 s 소요(허용 가능, 보고 주기가 길다)
---
## 3. 현재 코드 → 펌웨어 모듈 매핑
| 현재 (Python) | 신규 (C 펌웨어 모듈) | 비고 |
|---|---|---|
| `sht30_monitor.py` | `app_sht30.c` (Task) | SHT30 보드 메인 |
| `make_signature()` | `sig.c : sig_raw_body()` | mbedtls_sha256 (raw-body) |
| 표준 JSON 직렬화 | `jsonbody.c : jb_sht30_event()` | raw-body 서명 → 정규화 불필요 |
| `post_to_server()` | `api.c : api_post_json()` | TLS + 재시도 루프 |
| `post_reading()` | `reporter.c : report_sht30_event()` | 본문→서명→POST→재시도 |
| `run_loop()` / `periodic_reporter()` | `Task_PeriodicReport` | vTaskDelay 기반 |
| `read_sht30()` | `sht30.c : sht30_read()` | I²C HAL |
| `crc8_sht3x()` | `sht30.c : sht30_crc8()` | 동일 알고리즘 |
| `classify_reading()` | reporter 호출부 `metric_status` | plausibility 만. 임계는 서버 |
| `config.py` | `config.h` (+ flash 설정 옵션) | |
| `setup_logger()` | `log.c` (UART) | |
| `systemd/*.service` | IWDG + 메인 재연결 | |
---
## 4. 펌웨어 아키텍처 (FreeRTOS)
```
main()
├─ HAL/Clock/RNG/IWDG init
├─ Ethernet/LwIP init (RMII LAN8720)
├─ Task_Net : 링크 업 대기 → DHCP(or static) → SNTP로 RTC 설정 → "online" 플래그
├─ Task_Sensor : SHT30 주기(300s) I2C 측정 → temperature_c/humidity_percent + metric_status
├─ Task_Report : SHT30_REPORT_INTERVAL(300s) 로 api_post_json() (startup → periodic)
└─ Task_Watchdog: 각 task 헬스 비트 확인 후 IWDG refresh
```
- SHT30 측정값(원값 + plausibility `metric_status`)을 서버로 POST → **서버가 `config.php` METRIC_* 임계와 비교해 고온/저온/고습/저습 시 SMS 발송**.
- TLS 컨텍스트는 매 요청마다 connect→handshake→POST→close (메모리 절약). 보고 주기가 길어 성능 무관.
---
## 5. 단계별 마이그레이션 계획
### Phase 0 — 환경/툴체인 (폐쇄망 대응)
- STM32CubeIDE 또는 `arm-none-eabi-gcc` + CMake + OpenOCD/ST-Link.
- **오프라인 번들**: 툴체인 설치본, CubeF4 HAL 팩, FreeRTOS·LwIP·mbedTLS 소스를
벤더링(`third_party/`에 고정 버전 포함). 빌드 시 외부 다운로드 0건.
- ST-Link/J-Link로 플래시 + UART 콘솔 확보.
### Phase 1 — 프로젝트 스켈레톤
- CubeMX로 클럭(HSE 8 MHz → 168 MHz), RMII, I²C, USART, RNG, IWDG, RTC 핀맵 생성.
- FreeRTOS(CMSIS-RTOS2), LwIP, mbedTLS 통합. CCM RAM에 task stack 배치(linker script).
- "Hello UART" + LED blink로 부팅 검증.
### Phase 2 — 네트워크 브링업
- LAN8720 RMII 드라이버 + LwIP. DHCP 우선, 실패 시 static fallback(`config.h`).
- **SNTP 클라이언트로 시간 동기 → RTC 설정** (TLS 인증서 유효기간 검증·`timestamp` 필드에 필수).
순서 주의: SNTP는 UDP라 TLS 불필요 → TLS보다 먼저 수행(닭-달걀 회피).
- 링크 다운/DHCP 실패 시 백오프 재시도.
### Phase 3 — TLS + HTTPS 클라이언트
- mbedTLS config 축소(`mbedtls_config.h`): TLS1.2만, ECDHE-RSA + AES-GCM,
`MBEDTLS_SSL_IN/OUT_CONTENT_LEN ≈ 4096`, MFL extension.
- 엔트로피: 하드웨어 RNG 연결.
- **서버 루트 CA PEM을 flash에 임베드**(Cafe24 인증서 체인 확인 필요).
- `api_post_json(url_path, body, out_resp)` 구현: connect→handshake(SNI)→
`POST` 헤더 작성→본문 전송→상태코드 파싱.
- 검증: `https://your-domain/.../sensor_data.php`로 200 수신.
### Phase 4 — 서명 (raw-body 방식, R1 해결로 단순화)
서버가 **raw-body 서명**을 지원하도록 변경 완료 (PHP `verify_signature_raw`, 본 저장소 적용됨):
- 서버 검증식: `sha256(API_KEY . 요청_본문_바이트_그대로)`, 값은 HTTP 헤더 **`X-Signature`**로 전송.
- 펌웨어는 **자신이 전송하는 본문 바이트 그대로**를 SHA-256으로 서명만 하면 된다.
→ 키 정렬·부동소수 포맷·슬래시 이스케이프 **재현 불필요** (SHT30 float 문제 원천 제거).
- 클라이언트 절차:
1. `body` 문자열 1회 생성(필드 순서 자유). 2. `sig = sha256_hex(API_KEY || body)`.
3. `POST``Content-Type: application/json` + `X-Signature: <sig>` 헤더, 본문=`body`.
4. 본문의 `signature` 필드는 불필요(있어도 서버가 무시).
- 서버는 `X-Signature` 헤더가 없으면 레거시 본문 필드 서명으로 폴백 → **RPi 무중단 공존**.
- 회귀 테스트: `tests/raw_body_signature_test.php` (통과 확인됨).
```c
// 펌웨어: 전송 바이트 그대로 서명
sig_sha256_hex(body, sig_hex); // sha256(API_KEY || body)
// HTTP 헤더에 "X-Signature: <sig_hex>" 추가, 본문은 body 그대로 전송
```
### Phase 5 — SHT30 보드 앱 (sensor_id=2)
- I²C: 예 `I2C1`(PB6/PB7), addr `0x44`, cmd `0x2C06`(high-repeatability), 20 ms 대기, 6 byte read.
- `sht30_crc8`(poly 0x31, init 0xFF) 온도/습도 각각 검증 — Python과 동일.
- 변환: `T=-45+175*raw/65535`, `RH=100*raw/65535`, **소수 2자리 반올림**.
- 필드: `metric_type="sht30"`, `temperature_c`, `humidity_percent`, `metric_status`, `app_version` (`is_leak` 필드 없음).
- 이벤트: `startup`(부팅 시 1회) → `periodic`(300 s) 주기 측정 보고.
- ⚠️ **float 서명 패리티**(아래 리스크 R1) 반드시 처리.
### Phase 6 — 서버 임계 판정 / SMS (펌웨어 외부)
- 펌웨어는 `temperature_c`/`humidity_percent` **원값** + `metric_status`(plausibility) 만 보고한다.
- 고온/저온/고습/저습 **운영 임계는 서버 `php/config.php``METRIC_*` 상수**가 판정하고, 초과 시 `sensor_data.php` 가 SMS 게이트웨이로 문자를 발송한다.
- 폐쇄망 재플래시 비용을 피하려 임계를 서버에 두므로, 임계 변경 시 펌웨어 재빌드/재배포가 불필요하다.
### Phase 7 — 신뢰성/운영
- IWDG 워치독, TLS/네트워크 실패 시 지수 백오프 재연결.
- 브라운아웃 리셋(BOR) 설정, 부팅 카운터.
- 로그: UART(개발) + (옵션) 실패 통계 서버 보고.
- 설정 변경: flash 마지막 섹터에 `device_id/location/static IP/API_KEY` 저장 + UART 명령으로 갱신(옵션). 미구현 시 `config.h` 재빌드.
### Phase 8 — 검증
- §7 호스트 서명 패리티 단위 테스트 통과.
- 실서버(또는 스테이징 PHP)로 `startup/periodic`(SHT30) E2E + 서버 임계 초과 시 SMS 트리거 확인.
- 24 h 안정성 + 망 단절 복구 + 워치독 동작 시험.
### Phase 9 — 빌드/배포 (폐쇄망)
- 단일 `.bin`/`.hex` 산출, 오프라인 플래시 절차 문서화.
- 단일 빌드 타깃 `sht30_fw`(컴파일 플래그 `BOARD_SHT30`).
- 펌웨어 버전 = `app_version` 필드로 보고(`v2606-sht30`, `firmware/VERSION`=`fw-2.0.0`).
---
## 6. 핵심 코드 스케치
### 6.1 본문 생성 + raw-body 서명 (키 정렬·정규화 불필요)
```c
// 필드 순서/포맷 자유 — 서버는 보낸 바이트 그대로 서명을 검증한다.
// SHT30 본문에는 is_leak 필드가 없다(온습도 원값 + metric_status 만 보고).
int build_body_sht30(char *out, size_t n, const Sht30Msg *m) {
return snprintf(out, n,
"{\"device_id\":\"%s\",\"device_location\":\"%s\","
"\"sensor_id\":%d,\"sensor_name\":\"%s\",\"event_type\":\"%s\","
"\"metric_type\":\"sht30\",\"temperature_c\":%.2f,\"humidity_percent\":%.2f,"
"\"metric_status\":\"%s\",\"app_version\":\"%s\",\"timestamp\":%ld}",
m->device_id, m->device_location, m->sensor_id, m->sensor_name,
m->event_type, m->temperature_c, m->humidity_percent,
m->metric_status, m->app_version, (long)m->timestamp);
}
// sha256(API_KEY || body) → hex. body는 전송 바이트와 100% 동일해야 한다.
void sig_sha256_hex(const char *body, char hex[65]) {
uint8_t d[32]; mbedtls_sha256_context c;
mbedtls_sha256_init(&c); mbedtls_sha256_starts(&c, 0);
mbedtls_sha256_update(&c, (const uint8_t*)API_KEY, strlen(API_KEY));
mbedtls_sha256_update(&c, (const uint8_t*)body, strlen(body));
mbedtls_sha256_finish(&c, d); mbedtls_sha256_free(&c);
for (int i=0;i<32;i++) sprintf(hex+i*2, "%02x", d[i]);
}
// POST 시 헤더에 "X-Signature: <hex>" 추가, 본문은 위 body 그대로 전송.
```
주의(유일): 서명한 `body` 바이트와 실제 전송 바이트가 **반드시 동일**해야 한다.
문자열 필드에 `"``\`가 들어갈 수 있으면 JSON 이스케이프만 적용(서버는 그 바이트로 검증하므로 PHP 포맷 재현 불요).
### 6.2 SHT30 읽기 (HAL)
```c
HAL_I2C_Master_Transmit(&hi2c1, 0x44<<1, (uint8_t[]){0x2C,0x06}, 2, 100);
HAL_Delay(20);
uint8_t b[6]; HAL_I2C_Master_Receive(&hi2c1, 0x44<<1, b, 6, 100);
if (sht30_crc8(b,2)!=b[2] || sht30_crc8(b+3,2)!=b[5]) return ERR_CRC;
float t = -45.0f + 175.0f*((b[0]<<8|b[1])/65535.0f);
float rh = 100.0f*((b[3]<<8|b[4])/65535.0f);
```
---
## 7. 리스크 & 대응
| ID | 리스크 | 영향 | 대응 |
|---|---|---|---|
| **R1** | ~~float 서명 패리티 (SHT30)~~ | ~~SHT30 보고 전건 401 가능~~ | ✅ **해결됨**: PHP를 **raw-body 서명**으로 전환(`verify_signature_raw`, `X-Signature` 헤더). 본문 바이트 그대로 서명 → JSON 재직렬화/float 포맷/슬래시 이스케이프 의존 제거. 레거시 본문 필드 서명은 RPi 호환을 위해 이중 모드로 유지. 상세 §5 Phase 4 참조 |
| **R2** | TLS RAM 부족(192 KB) | 핸드셰이크 실패/리셋 | content_len 4 KB 축소, CCM에 stack, LwIP pbuf 튜닝 |
| **R3** | Cafe24 인증서 cipher/체인/만료 | TLS 연결 불가 | 사전 `openssl s_client`로 체인·cipher 확인, 루트 CA 임베드, 만료 시 펌웨어 업데이트 절차 |
| **R4** | SNTP 실패 → TLS 시간검증·timestamp 오류 | 인증/중복판정 오류 | SNTP 재시도, 실패 시 RTC 보존값 사용 + 경보 |
| **R5** | `/` 이스케이프 누락 | 서명 불일치(값에 `/` 포함 시) | 정규화기에서 `\/` 처리 |
| **R6** | 임계 판정을 서버에 위임(폐쇄망 재플래시 회피) | 임계 변경이 서버 운영에 종속 | 경보 임계는 서버 `config.php` METRIC_* 가 소유. 펌웨어는 원값+`metric_status` 만 보고(§5 Phase 6) |
---
## 8. 산출물 / 디렉터리(제안)
```
firmware/
├─ third_party/ {CMSIS, STM32F4_HAL, FreeRTOS, lwip, mbedtls} # 벤더링(폐쇄망)
├─ common/ {api.c, sig.c, jsonbody.c, net.c, sntp.c, log.c, config.h}
├─ board_sht30/ {app_sht30.c, sht30.c, main.c}
├─ certs/ server_ca.pem.h
├─ test/ test_signature_parity.c # 호스트 빌드, PHP 기대값과 대조
└─ CMakeLists.txt
```
## 9. 다음 작업
1. **R1/R3 사전조사**: Cafe24 TLS 체인 확인, float 서명 정책 결정(PHP raw-body 서명으로 전환 권장).
2. Phase 01 스켈레톤 + UART/LED 부팅.
3. Phase 23 네트워크/TLS로 `sensor_data.php` 200 확보.
4. Phase 4 서명 패리티 테스트 그린.
5. Phase 5 SHT30 앱 완성 → E2E(+ 서버 임계 SMS 확인).