From 6b6f29c44739abfe0abf4d96046d881719c1fb97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?= Date: Mon, 21 Sep 2026 10:47:06 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=82=A9=ED=92=88=20=EC=9E=94=EC=97=AC?= =?UTF-8?q?=203=EA=B1=B4=20=ED=95=B4=EC=86=8C=20(Pi=20=EB=B2=88=EB=93=A4?= =?UTF-8?q?=20=C2=B7=20MoveNet=20=ED=8F=B4=EB=B0=B1=20=C2=B7=20=EC=9E=90?= =?UTF-8?q?=EA=B2=A9=EC=A6=9D=EB=AA=85=20=ED=9A=8C=EC=A0=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pi 번들 — arm64 빌드 머신이 필요하다고 적어 둔 것은 과한 가정이었다. --cross 모드에서 pip 이 태그로 aarch64/cp311 휠을 받으므로 네트워크만 있으면 된다. 휠 11개 전부 aarch64. "Pi 번들 미빌드"로 스킵되던 테스트가 이제 실제로 돈다. MoveNet — P3-3 의 잔여였던 죽은 원본을 유효한 tf2onnx 변환본으로 교체하고 sha256+size 로 고정했다. 고정 전에 어댑터 계약과 대조했다: 입력 [1,192,192,3] int32 NHWC, 출력 [1,1,17,3]. 레이아웃이 다른 변환본은 onnxruntime 에 그냥 로드되고 좌표만 틀리는 실패라, 파일이 바뀌었는지가 아니라 계약이 맞는지를 테스트로 박았다. 자격증명 회전 — 손으로 적던 SQL 절차를 명령 하나로 바꿨다. DB 에 접속하지 않고 붙여넣을 SQL 만 낸다(운영자의 창구가 phpMyAdmin 이므로 접속 정보 없이 어느 머신에서나 돌아야 한다). 비밀번호는 STDIN 으로만 받는다 — 명령행은 ps 와 셸 히스토리에 남는다. 테스트가 잠그는 것은 서버 키와 Pi 키의 일치, 그리고 앱이 그 해시를 검증하는지 두 가지다. 회전 실행 자체는 운영 계정 조작이라 저장소에서 수행하지 않는다. 검증: phpunit 276 passed(스킵 0, 신규 7) · pytest 스킵 2(환경) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU --- .gitignore | 4 + docs/INDEX.md | 18 +++ docs/PROJECT_MAP.md | 5 +- docs/delivery-audit.md | 14 ++- edge/tools/fetch_movenet.py | 28 ++--- server/tests/RotateCredentialsTest.php | 111 +++++++++++++++++ server/tools/rotate_credentials.php | 157 +++++++++++++++++++++++++ tests/hardware/test_movenet_smoke.py | 19 +++ 8 files changed, 338 insertions(+), 18 deletions(-) create mode 100644 server/tests/RotateCredentialsTest.php create mode 100644 server/tools/rotate_credentials.php diff --git a/.gitignore b/.gitignore index b7026a7..b64fdd5 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,11 @@ dist/ # Edge runtime artifacts *.db +# 모델 가중치는 커밋하지 않는다. 둘 다 고정된 fetch 스크립트로 받는다 +# (edge/tools/fetch_hailo_hef.py · fetch_movenet.py) — sha256 가 저장소보다 확실한 보증이고, +# 9~10MB 바이너리를 히스토리에 쌓을 이유가 없다. *.hef +*.onnx # Root-anchored on purpose. A bare `snapshots/` matches at EVERY depth, which swallowed # cloud/storage/snapshots/ — and since git cannot re-include a file whose parent directory # is excluded, it silently killed cloud/.gitignore's `!/storage/snapshots/.gitkeep`. The diff --git a/docs/INDEX.md b/docs/INDEX.md index 74e821f..051bc64 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -173,3 +173,21 @@ 시스템 구성 · 낙상 시퀀스 · 낙상 상태기계. 시퀀스를 그리다 확인한 사실 — **경광등(3초)과 메일(최대 63초)은 같은 "통보"가 아니다.** 상태기계 쪽은 확정 창 3초와 추락 연결 창 3초가 겹쳐 있음을 한 장으로 보여준다 (4차 감사의 CRITICAL, 시간을 그렸다면 코드보다 먼저 보였을 충돌). HTML 은 gitignore, 소스 JSON 만 커밋. + +## 2026-09-21 — 납품 정비 (잔여 3건 해소) + +- **Pi 번들 재빌드** `move-detect-pi-1.2.0-20260921-aarch64-cp311-hailo8.tar.gz` (82MB). + arm64 빌드 머신이 필요하다고 봤던 것은 과한 가정이었다 — `--cross` 로 pip 이 태그를 보고 + aarch64/cp311 휠을 받으므로 네트워크만 있으면 된다. 휠 11개 전부 aarch64. 이걸로 + "Pi 번들 미빌드" 로 스킵되던 테스트가 실제로 돌아 스킵이 5 → 2로 줄었다. +- **MoveNet CPU 폴백 복구** — P3-3 의 잔여였던 죽은 원본을 유효한 tf2onnx 변환본으로 교체·고정 + (sha256 + size). 고정 **전에** 어댑터 계약과 대조했다: 입력 `[1,192,192,3] int32` NHWC, + 출력 `[1,1,17,3]`. 레이아웃이 다른 변환본은 로드는 되고 좌표만 틀리므로 그 계약을 테스트로 + 박았다. 실사진 2장으로 코<어깨<엉덩이<발목 순서까지 확인. `edge/tools/fetch_movenet.py` +- **자격증명 회전 도구** `server/tools/rotate_credentials.php` — 손으로 적던 SQL 절차를 명령 + 하나로. DB 에 접속하지 않고 붙여넣을 SQL 만 낸다(운영자의 창구가 phpMyAdmin 이므로). + 비밀번호는 STDIN 으로만 받는다 — 명령행은 `ps` 와 셸 히스토리에 남는다. 테스트가 잠그는 + 것은 "서버 SQL 의 키와 Pi 에 넣으라는 키가 같은가"와 "앱이 그 해시를 검증하는가" 두 가지다. + **교체 실행 자체는 운영 계정 조작이라 사용자 몫으로 남긴다.** +- 검증: pytest 스킵 2(shellcheck 미설치·Windows AF_UNIX) · phpunit 276 passed, 스킵 0 + diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 333851c..29ef966 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -53,7 +53,8 @@ - `src/Api/ThresholdCatalog.php`: 편집 가능한 튜너블의 화이트리스트 + 범위 + 한국어 설명. 목록에 없는 키는 API 로 저장 불가 (장비가 조용히 무시하는 값을 "설정됨"으로 보이지 않게) - `tools/`: `dispatch_alerts.php`(크론 1분) · `maintain.php`(크론 일 1회, 보존기간) · - `backup.php`(DB 덤프+스냅샷) · `verify_audit_chain.php`(감사 무결성) · `seed_demo.php`(시연·QA). + `backup.php`(DB 덤프+스냅샷) · `verify_audit_chain.php`(감사 무결성) · `seed_demo.php`(시연·QA) · + `rotate_credentials.php`(장치 HMAC 키·관리자 비번 회전 — DB 미접속, 붙여넣을 SQL 만 출력). 전부 CLI 전용(`bootstrap.php` 가 SAPI 검사), 납품 시 `move_detect_app/tools/` 로 www 밖에 배치 - 라우트: `POST /api/events`(ingest, HMAC) · `POST /api/heartbeat` · `GET /api/config` · `GET /api/events` · `GET /api/devices` · `GET /api/heatmap/{camera}`(spatial 집계) · @@ -98,7 +99,7 @@ ## docs/ -`delivery-audit.md`(감사 43건, 전부 완료 — OFF-HW 실장비 검증만 잔여) · `pilot-runbook.md` · +`delivery-audit.md`(감사 43건 + 재감사 4회, 전부 완료 — OFF-HW 실장비 검증만 잔여) · `pilot-runbook.md` · `hardware-bringup.md` · `INDEX.md`(작업 기록) 도해 3종. 소스는 `*.archify.json` 이고 HTML 은 생성물이라 커밋하지 않는다 diff --git a/docs/delivery-audit.md b/docs/delivery-audit.md index 3b45799..ffc37f0 100644 --- a/docs/delivery-audit.md +++ b/docs/delivery-audit.md @@ -1,9 +1,10 @@ # Delivery Audit — move-detect (납품 전 결함 목록 · 순차 처리) -> **판정 (2026-09-05, 1.2.0): 적합 — 오프하드웨어 항목 전량 해소.** +> **판정 (2026-09-21, 1.2.0): 적합 — 오프하드웨어 항목 전량 해소.** > 1차 43/43 + 2차 12/12 + 3차(1.1.0 장치 건전성) 14/14 + > **4차 재감사(1.2.0 추락·동시 다발·프리셋 표면) 23/23** 해소. -> 잔여는 실장비(OFF-HW) 검증뿐 — 아래 "OFF-HW 잔여" 절 참조. +> 2026-09-21 납품 정비로 Pi 번들 재빌드·MoveNet 원본·자격증명 회전 도구까지 닫혔다. +> 잔여는 실장비(OFF-HW) 검증과 **운영자가 직접 해야 하는 자격증명 교체** 뿐. Source: 62-agent adversarial audit (2026-07-16) + skill `iot-airgap-delivery` §3 + manual verification of every blocker against real code. Ordered for sequential fixing. Each item is @@ -163,6 +164,11 @@ misconfig/degradation fail LOUD. — PINTO hosts models off-repo), so it was silently saving garbage. No known-good sha256 to pin until a valid MOVENET_ONNX_URL is set. NON-BLOCKING for the Hailo delivery path (MoveNet is the optional CPU fallback); flag for whoever needs CPU MoveNet. + CLOSED 2026-09-21 — 유효한 출처를 찾아 고정했다(tf2onnx 변환본, sha256+size). 고정 전에 + 어댑터 계약과 대조했다: 입력 `[1,192,192,3] int32` NHWC, 출력 `[1,1,17,3]`. 레이아웃이 다른 + 변환본은 **로드는 되고 좌표만 틀리는** 실패라, 그 계약을 테스트로 박았다 + (`tests/hardware/test_movenet_smoke.py::test_model_signature_is_what_the_adapter_feeds_and_reads`). + 실사진 2장으로 코<어깨<엉덩이<발목 세로 순서와 bbox 프레임 내 포함까지 확인. ## Phase 4 — Server hardening (shared hosting) @@ -423,9 +429,13 @@ config 템플릿 2종. `edge/` 는 불변. 4개 관점 병렬 감사 후 각 발 - **자격증명 교체** — `secrets/PROVISION_SECRETS.md` 의 대시보드 비밀번호와 장치 HMAC 키는 6주간 납품 폴더 안에 있었다. 유출 정황은 없으나(git 미추적·zip 미포함) 교체를 권고한다. 운영 계정 조작이라 이 저장소에서 수행하지 않는다. + 2026-09-21 — 손으로 적던 절차를 도구로 바꿨다(`server/tools/rotate_credentials.php`). + **실행은 여전히 운영자 몫이다.** - **Pi 번들 재빌드** — 1.1.0 은 서버 전용이라 `edge/` 가 불변이고 내용은 1.0.0 과 동일하지만, 파일명·MANIFEST 의 버전 표기가 1.0.0 이다. arm64 + 네트워크가 있는 빌드 머신에서만 재빌드 가능하므로 Pi 납품 시 그 머신에서 수행한다. + RESOLVED 2026-09-21 — `--cross` 모드로 해소했다. arm64 머신은 필요 없었다: pip 이 태그로 + aarch64/cp311 휠을 받으므로 네트워크만 있으면 된다. 휠 11개 전부 aarch64. - **매뉴얼에 신규 2종 설명 없음** — `manual.json` 에 `장치 이상`·`감시 성능 저하` 항목이 없다 (1.0.0 의 `미확인 경보`도 마찬가지라 이번 회귀는 아니다). 캡처는 실화면이고 UI 골격은 바뀌지 않았다. 다음 매뉴얼 개정에서 함께 넣는다. diff --git a/edge/tools/fetch_movenet.py b/edge/tools/fetch_movenet.py index 4c3249b..7708a1c 100644 --- a/edge/tools/fetch_movenet.py +++ b/edge/tools/fetch_movenet.py @@ -5,14 +5,13 @@ to a temp file, validated, and only then moved into place — a truncated / HTML tampered download must never be saved as the model (onnxruntime would then fail to load it, and the operator would see a cryptic runtime error instead of a clear fetch failure). -STATUS: the historical PINTO_model_zoo raw URL below is DEAD (that repo hosts its models on -external storage, not as repo blobs — the raw path now returns a GitHub HTML 404). So there is -no known-good sha256 to pin here yet. Until a working source is set: - * this tool FAILS LOUD if the download is not a real ONNX (rejects the HTML it currently gets), - instead of silently saving garbage; - * set MOVENET_ONNX_URL to a valid source and pin PINNED_SHA256/PINNED_SIZE from its bytes. -For the Hailo delivery path this model is optional (POSE_BACKEND=hailo), so a missing MoveNet is -non-blocking; the CPU fallback needs this resolved. +SOURCE: the historical PINTO_model_zoo raw URL is DEAD (that repo hosts its models on external +storage, not as repo blobs — the raw path returns a GitHub HTML 404). The pinned source below is +a tf2onnx export of Google's MoveNet SinglePose Lightning v4, checked to match what +edge/adapters/movenet_pose.py feeds and reads: input [1,192,192,3] int32 NHWC, output +[1,1,17,3] float (y, x, score). A conversion with a different layout would load fine and then +produce silently wrong keypoints, so the digest below is what makes this safe to fetch — not the +hostname. For the Hailo delivery path this model is optional (POSE_BACKEND=hailo). """ import hashlib import os @@ -22,15 +21,16 @@ import urllib.request URL = os.environ.get( "MOVENET_ONNX_URL", - "https://github.com/PINTO0309/PINTO_model_zoo/raw/main/115_MoveNet/resources/" - "movenet_singlepose_lightning_4.onnx", + "https://huggingface.co/Xenova/movenet-singlepose-lightning/resolve/main/onnx/model.onnx", ) DEST = os.path.join(os.path.dirname(__file__), "..", "..", "models", "movenet_lightning.onnx") -# Pin from the bytes of a known-good source once MOVENET_ONNX_URL points at one. Empty = unpinned -# (still validated as a real ONNX below, but not byte-identical-verified). -PINNED_SHA256 = os.environ.get("MOVENET_ONNX_SHA256", "") -PINNED_SIZE = int(os.environ.get("MOVENET_ONNX_SIZE", "0") or "0") +# Pinned from the bytes of the source above. An override must bring its own digest — pointing +# MOVENET_ONNX_URL elsewhere while keeping this pin just makes the download fail, which is the +# intended direction of failure. +PINNED_SHA256 = os.environ.get( + "MOVENET_ONNX_SHA256", "1ad4f8d6c2f776a9967db3993c9ca740bc350104f9d37c151dc183fc29a464ad") +PINNED_SIZE = int(os.environ.get("MOVENET_ONNX_SIZE", "9413268") or "0") def _looks_like_onnx(head: bytes) -> bool: diff --git a/server/tests/RotateCredentialsTest.php b/server/tests/RotateCredentialsTest.php new file mode 100644 index 0000000..f118438 --- /dev/null +++ b/server/tests/RotateCredentialsTest.php @@ -0,0 +1,111 @@ + ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; + $p = proc_open($cmd, $spec, $pipes); + $this->assertIsResource($p, 'rotate_credentials.php 를 실행하지 못했습니다'); + fwrite($pipes[0], $stdin); + fclose($pipes[0]); + $out = (string)stream_get_contents($pipes[1]); + $err = (string)stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + return [proc_close($p), $out, $err]; + } + + public function test_device_rotation_puts_the_same_key_on_both_sides(): void + { + [$code, $out] = $this->tool(['--device=pi-01']); + $this->assertSame(0, $code); + + $this->assertSame(1, preg_match( + "/UPDATE move_devices SET secret = '([0-9a-f]{64})' WHERE device_id = 'pi-01';/", $out, $sql)); + $this->assertSame(1, preg_match('/DEVICE_SECRET=([0-9a-f]{64})\|/', $out, $env)); + + $this->assertSame($sql[1], $env[1], + '서버에 넣는 키와 Pi 에 넣는 키가 다릅니다 — 그 장치의 이벤트는 전부 거부됩니다.'); + } + + public function test_each_run_mints_a_fresh_key(): void + { + [, $a] = $this->tool(['--device=pi-01']); + [, $b] = $this->tool(['--device=pi-01']); + $this->assertNotSame($a, $b, '회전인데 같은 값이 나오면 회전이 아닙니다.'); + } + + public function test_device_id_must_be_an_identifier(): void + { + // device_id 는 운영자가 타이핑하는 값이고 SQL 문자열로 들어간다. + foreach (["pi-01'; DROP TABLE move_devices; --", "pi 01", str_repeat('a', 65), ""] as $bad) { + [$code, $out] = $this->tool(["--device={$bad}"]); + $this->assertNotSame(0, $code, "거부해야 할 device_id 를 통과시켰습니다: {$bad}"); + $this->assertStringNotContainsString('UPDATE', $out); + } + } + + public function test_admin_hash_is_what_the_app_will_verify(): void + { + $plain = 'correct-horse-battery-staple'; + [$code, $out] = $this->tool(['--admin=admin'], "{$plain}\n{$plain}\n"); + $this->assertSame(0, $code); + + $this->assertSame(1, preg_match( + "/SET password_hash = '([^']+)', failed_attempts = 0, locked_until = 0/", $out, $m)); + $this->assertTrue(Password::verify($plain, $m[1]), + '앱이 검증하지 못하는 해시입니다 — 운영자가 대시보드에서 잠깁니다.'); + + // 잠금 해제까지 같이 하지 않으면, 비번을 잊어 잠긴 계정은 새 비번으로도 못 들어온다. + $this->assertStringContainsString('failed_attempts = 0', $out); + } + + public function test_the_plaintext_password_is_never_printed(): void + { + $plain = 'correct-horse-battery-staple'; + [, $out, $err] = $this->tool(['--admin=admin'], "{$plain}\n{$plain}\n"); + $this->assertStringNotContainsString($plain, $out); + $this->assertStringNotContainsString($plain, $err); + } + + public function test_mismatch_and_short_passwords_are_refused(): void + { + foreach ([["aaaaaaaaaaaa\nbbbbbbbbbbbb\n", '다릅니다'], ["short\nshort\n", '미만']] as [$in, $why]) { + [$code, $out, $err] = $this->tool(['--admin=admin'], $in); + $this->assertNotSame(0, $code, "거부해야 할 입력을 통과시켰습니다: {$why}"); + $this->assertStringNotContainsString('UPDATE', $out); + $this->assertStringContainsString($why, $err); + } + } + + public function test_it_refuses_to_guess_which_credential_to_rotate(): void + { + foreach ([[], ['--device=pi-01', '--admin=admin']] as $args) { + [$code, $out] = $this->tool($args); + $this->assertNotSame(0, $code); + $this->assertStringNotContainsString('UPDATE', $out); + } + } +} diff --git a/server/tools/rotate_credentials.php b/server/tools/rotate_credentials.php new file mode 100644 index 0000000..b9d7925 --- /dev/null +++ b/server/tools/rotate_credentials.php @@ -0,0 +1,157 @@ + | --admin= (둘 중 하나) + + --device=pi-01 장치 HMAC 키를 새로 만들고, 서버 UPDATE 문과 Pi 쪽 절차를 출력 + --admin=admin 새 비밀번호를 STDIN 으로 받아 Argon2id 해시와 UPDATE 문을 출력 + + 출력된 SQL 을 phpMyAdmin 에서 실행하십시오. 이 도구는 DB 를 건드리지 않습니다. + + TXT); + exit(2); +} + +/** MySQL 문자열 리터럴. 여기서 만드는 값은 hex/해시뿐이지만, 식별자는 운영자 입력이다. */ +function sql_quote(string $s): string +{ + return "'" . str_replace(["\\", "'"], ["\\\\", "''"], $s) . "'"; +} + +if ($device !== null) { + if (!is_string($device) || !preg_match('/^[A-Za-z0-9._-]{1,64}$/', $device)) { + fwrite(STDERR, "device_id 형식이 올바르지 않습니다(영숫자 . _ - , 64자 이내): {$device}\n"); + exit(1); + } + $secret = bin2hex(random_bytes(DEVICE_SECRET_BYTES)); + $secret_q = sql_quote($secret); + $q = sql_quote($device); + + echo << 190) { + fwrite(STDERR, "username 이 비었거나 너무 깁니다.\n"); + exit(1); +} + +fwrite(STDERR, "새 비밀번호 (화면에 표시되지 않음, " . MIN_PASSWORD_LEN . "자 이상): "); +$plain = read_secret_line(); +fwrite(STDERR, "\n한 번 더: "); +$again = read_secret_line(); +fwrite(STDERR, "\n"); + +if ($plain !== $again) { + fwrite(STDERR, "두 입력이 다릅니다. 중단합니다.\n"); + exit(1); +} +if (strlen($plain) < MIN_PASSWORD_LEN) { + fwrite(STDERR, "비밀번호가 " . MIN_PASSWORD_LEN . "자 미만입니다. 중단합니다.\n"); + exit(1); +} + +$hash = Password::hash($plain); +$plain = $again = ''; // 이 프로세스가 더 들고 있을 이유가 없다 +$q = sql_quote($admin); +$h = sql_quote($hash); + +echo <</dev/null && echo ok')) === 'ok'; + if (!$hidden) { + fwrite(STDERR, "\n (경고: 이 터미널에서는 입력을 숨길 수 없었습니다. 화면과 히스토리를 확인하십시오)\n"); + } + } + $line = fgets(STDIN); + if ($hidden) { + @shell_exec('stty echo 2>/dev/null'); + } + if ($line === false) { + fwrite(STDERR, "입력을 읽지 못했습니다.\n"); + exit(1); + } + return rtrim($line, "\r\n"); +} diff --git a/tests/hardware/test_movenet_smoke.py b/tests/hardware/test_movenet_smoke.py index f7ec467..0443d18 100644 --- a/tests/hardware/test_movenet_smoke.py +++ b/tests/hardware/test_movenet_smoke.py @@ -26,3 +26,22 @@ def test_real_movenet_runs(): assert isinstance(persons, list) for person in persons: assert len(person.keypoints) == 17 + + +@pytest.mark.skipif(not os.path.exists(MODEL), reason="MoveNet model not bundled") +def test_model_signature_is_what_the_adapter_feeds_and_reads(): + """모델을 갈아끼웠을 때 조용히 틀리는 경우를 막는다. + + 레이아웃이 다른 변환본(NCHW·float32·[1,17,3])도 onnxruntime 에는 그냥 로드된다. 그러면 + 어댑터는 예외 없이 돌면서 엉뚱한 좌표를 내놓고, 낙상 판정은 "동작하는데 안 맞는" 상태가 + 된다. 파일이 바뀌었는지가 아니라 **계약이 맞는지**를 본다. + """ + ort = pytest.importorskip("onnxruntime") + session = ort.InferenceSession(MODEL, providers=["CPUExecutionProvider"]) + + inp = session.get_inputs()[0] + assert inp.shape == [1, 192, 192, 3], f"어댑터는 192x192 NHWC 를 넣는다: {inp.shape}" + assert inp.type == "tensor(int32)", f"어댑터는 int32 로 넣는다: {inp.type}" + + out = session.get_outputs()[0] + assert out.shape == [1, 1, 17, 3], f"어댑터는 [1,1,17,3] (y,x,score) 를 읽는다: {out.shape}"