diff --git a/.github/workflows/ota.yml b/.github/workflows/ota.yml index 019d445..055c447 100644 --- a/.github/workflows/ota.yml +++ b/.github/workflows/ota.yml @@ -19,9 +19,13 @@ jobs: - run: python3 tests/test_updates.py - run: python3 tests/test_legacy_mtd.py - run: python3 tests/test_startup_recovery.py + - run: python3 tests/test_boot_guard.py - run: node tests/test_update_crypto.js + - run: node tests/test_password_crypto.js - run: node tests/test_monitor.js - run: python3 tests/test_update_feed.py + - run: python3 tests/test_setup_control.py + - run: python3 tests/test_public_management.py - run: c++ -std=c++11 -Wall -Wextra -Werror firmware/matter/tests/energy_counter_tests.cpp -o /tmp/ck-energy-tests && /tmp/ck-energy-tests - run: c++ -std=c++11 -Wall -Wextra -Werror firmware/matter/src/relay/RelayEngine.cpp firmware/matter/tests/relay_engine_tests.cpp -o /tmp/ck-relay-tests && /tmp/ck-relay-tests release: @@ -71,7 +75,7 @@ jobs: run: | python3 tools/generate_update.py docker run --rm -e CK_TESTING=1 -v "$PWD/firmware/matter:/work" incipio-public-build:v0.1.0-preview sh tools/build_updates.sh - docker run --rm -e CK_TEST_RUNNER=qemu-mips -e CK_TEST_BIN=/repo/firmware/matter/artifacts/update-test -v "$PWD:/repo" -w /repo incipio-public-build:v0.1.0-preview sh -c 'python3 -m venv /tmp/ck-tests && /tmp/ck-tests/bin/pip -q install -r tools/requirements.txt && /tmp/ck-tests/bin/python tests/test_updates.py' + docker run --rm -e CK_TEST_RUNNER=qemu-mips -e CK_TEST_BIN=/repo/firmware/matter/artifacts/update-test -v "$PWD:/repo" -w /repo incipio-public-build:v0.1.0-preview sh -c 'python3 -m venv /tmp/ck-tests && /tmp/ck-tests/bin/pip -q install -r tools/requirements.txt && /tmp/ck-tests/bin/python tests/test_updates.py && CK_BOOT_RUNNER=qemu-mips CK_BOOT_BIN=/repo/firmware/matter/artifacts/update-test/ck-boot-guard /tmp/ck-tests/bin/python tests/test_boot_guard.py' python3 tests/test_update_pid1.py - name: Build, verify and sign public artifacts run: python3 tools/release.py --private private/ota-signing.key --output artifacts/ota-release diff --git a/.github/workflows/update-feed.yml b/.github/workflows/update-feed.yml index 7c50586..f17d025 100644 --- a/.github/workflows/update-feed.yml +++ b/.github/workflows/update-feed.yml @@ -44,10 +44,13 @@ jobs: assert not release['isDraft'], 'Publish the reviewed release before deploying the update feed' PY mkdir -p site/updates - gh release download "$RELEASE_TAG" --pattern '*.ota' --pattern 'matter.json' --pattern 'exec-test.json' --dir site/updates + gh release download "$RELEASE_TAG" --pattern '*.ota' --pattern 'matter.json' --dir site/updates python3 tools/ota.py verify site/updates/matter.ota --public third_party/ota-signing.pub - python3 tools/ota.py verify site/updates/exec-test.ota --public third_party/ota-signing.pub - python3 tools/ota.py catalog site/updates/matter.ota site/updates/exec-test.ota --base-url "https://${GITHUB_REPOSITORY_OWNER}.github.io/${GITHUB_REPOSITORY#*/}/updates" --output site/updates/index.json + if [ -f site/updates/exec-test.ota ]; then + gh release download "$RELEASE_TAG" --pattern 'exec-test.json' --dir site/updates + python3 tools/ota.py verify site/updates/exec-test.ota --public third_party/ota-signing.pub + fi + python3 tools/ota.py catalog site/updates/*.ota --base-url "https://${GITHUB_REPOSITORY_OWNER}.github.io/${GITHUB_REPOSITORY#*/}/updates" --output site/updates/index.json python3 - <<'PY' import json,urllib.request,urllib.error url=json.load(open('version.json'))['catalog_url'] @@ -57,8 +60,10 @@ jobs: if error.code!=404:raise old={'updates':[]} incoming=json.load(open('site/updates/index.json')) - versions={item['variant']:item['software_version'] for item in incoming['updates']} - assert all(versions.get(item['variant'],0)>=item['software_version'] for item in old['updates']), 'Refusing to replace the feed with an older release' + import sys + sys.path.insert(0,'tools') + from ota import validate_catalog_upgrade + validate_catalog_upgrade(old,incoming) PY touch site/.nojekyll - uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 diff --git a/.gitignore b/.gitignore index 8c5c61e..e31187a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ __pycache__/ *.pyc .DS_Store *.key +**/commandkit-management*.json *.pseudo *.cpio *.sqsh diff --git a/README.md b/README.md index cd06c1a..100d13e 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # Incipio CommandKit 韌體研究與預覽韌體 +v0.2.10-preview(software version 12)保留[密碼管理後台](docs/admin-backend.md):訪客可看狀態,登入後才能切換 HomeKit/Matter、更新韌體、清除配對或將 kWh 歸零。兩種協議都保留管理入口,實體長按可恢復出廠。首次從無密碼舊版升級須重新進入設定熱點建立密碼及設定 Wi-Fi。HomeKit 模式暫停自訂網頁即時量測及 kWh 累計;原機驗收仍待完成。 + 自有 CMNDKT-004/RTL8196E 智慧插座的逆向紀錄,以及保留原廠 kernel/驅動的 Matter 和執行檔診斷預覽版。說明以繁體中文為主。 -公開來源位於 [GitHub repository](https://github.com/lee98064/incipio-commandkit);已發布的 v0.1 歷史去識別化 BIN、JSON 與校驗碼見 [v0.1.0-preview Release](https://github.com/lee98064/incipio-commandkit/releases/tag/v0.1.0-preview)。 +公開來源位於 [GitHub repository](https://github.com/lee98064/incipio-commandkit);目前的簽章更新包與去識別化附件見 [v0.2.3-preview Release](https://github.com/lee98064/incipio-commandkit/releases/tag/v0.2.3-preview)。[v0.1.0-preview](https://github.com/lee98064/incipio-commandkit/releases/tag/v0.1.0-preview) 保留為歷史基底。 **Release 的 8 MiB BIN 是位址對齊的「部分燒錄映像」,不是整片還原映像。**只有 Bank 1 rootfs 有內容;其餘區域為 `0xFF`。請用本專案工具個別化後限定區域寫入,保留每台插座自己的 MAC、MIB、校正、HomeKit/MFi 與 Bank 2。對空白晶片直接刷入公開 BIN 無法建立完整可開機裝置。 @@ -12,7 +14,9 @@ | 內容 | 入口 | |---|---| +| 密碼後台、kWh 歸零、完整恢復出廠 | [管理後台](docs/admin-backend.md) | | 網頁/Matter OTA 與發布新版 | [更新說明](docs/updates.md) | +| 原廠 10219/Bank 2 從網頁首次安裝 | [專用安裝包與限制](docs/factory-install.md) | | 手機網頁即時查看 V/A/W/kWh | [即時監測頁](docs/measurements.md#使用手機查看即時讀值) | | 標準電壓、RMS 電流、功率及累積 kWh | [Matter 電力量測](docs/measurements.md) | | 使用、配網、Matter、診斷 | [使用說明](docs/usage.md) | @@ -24,7 +28,7 @@ | 測試與發布交付狀態 | [驗證索引](verification/README.md) | | 上傳 GitHub 與建立 Release | [發布說明](docs/publishing.md) | -目前來源為 `v0.2.3-preview` 候選版;在既有管理網址新增手機即時監測頁,顯示 V/A/W/估算累積 kWh。v0.2.2 的配網、Flash 同步及 MCU 控制修復繼續保留,見[修復驗證](verification/recovery-results.json)。v0.1 Release 尚無永久 OTA,第一次升級仍須限定區域燒錄。 +目前來源為 `v0.2.10-preview`,加入[程式區域復原接續](docs/2026-10-20_firmware-programmer-recovery-report.md)。保留獨立 DHCP 回連與 PID1 監督;[協議切換](docs/protocol-switching.md)保留既有配對及資料,[遠端配對重設](docs/matter-reset.md)與完整恢復出廠分開處理。公開包在每顆插座首次開機自行產生裝置身分,不內建共用管理密碼。本版只交付 HomeKit/Matter 共用韌體,內部診斷版尚未遷移密碼初設,不發行新版診斷 OTA。 ## 兩種韌體 diff --git a/docs/2026-10-19_firmware-boa-first-install-report.md b/docs/2026-10-19_firmware-boa-first-install-report.md new file mode 100644 index 0000000..172eb74 --- /dev/null +++ b/docs/2026-10-19_firmware-boa-first-install-report.md @@ -0,0 +1,91 @@ +# CMNDKT-004 原廠網頁首次安裝包驗證 + +分析日期:2026-10-19,Asia/Taipei。工具:既有 Ghidra 反編譯/組語、ELF PT_DYNAMIC parser、Unicorn 2.1.4、qemu-mips、固定 uClibc/Matter SDK 工具鏈。報告 flavor 為 null。 + +## 結果與範圍 + +已製作一顆插座專用的 `v0.2.4-preview` 首次安裝 BIN,大小 3,997,728 bytes。原廠 Boa 的格式驗證與寫入規劃、bootloader 的選槽/checksum,以及首次匯入後的下一版簽章 OTA,皆已離線通過。原 kernel 載入器與壓縮內容保持相同,解壓 hash 亦一致。實際原廠插座首次寫入、MTD driver、RF 與開機驗收尚未執行。 + +授權與邊界見 [scope.md](factory-install/scope.md),操作順序見 [timeline.md](factory-install/timeline.md)。使用者回報另一顆為 `10219-1531`、Active Bank 2、雙韌體已勾選;本次未對該裝置發送網路請求或接觸實體 Flash。實際操作入口與限制見[安裝指南](factory-install.md)。 + +## 格式與呼叫路徑 + +`/boafrm/formDualFirmware` 在 handler table 對應 `0x00444B88`,處理 `active=save`/`active=reboot` 和 `dualFw`,沒有韌體檔案解析分支。更新頁上方的 multipart 表單提交至 `/boafrm/formUpload`,欄位 `binary`;handler 是 `0x00442F14`。 + +網頁 handler 逐段檢查 Realtek 16-byte header、長度與 additive checksum,最後 `0x0044275C` 僅比較四個 `dual` bytes。它與原廠 `upgfw` 的 RSA/SHA-1 線上更新流程不同。Boa 的 157 個 imports 及 `libapmib.so`、`libc.so.0`、`libgcc_s.so.1` 依賴已核對;沒有 RSA/SHA/OpenSSL imports。這個結論也由實際函式呼叫鏈與原 MIPS 程式執行支持,並非僅依賴 imports 缺席。 + +| 段落 | 外層 header | Payload 與行為 | +|---|---|---| +| Kernel | `cr6c`、entry `0x80500000`、burn offset `0x10000`、length `0x11FFF0` | 完整 0x120000 區域;Boa seek 後將 +8 改成新 bank mark | +| Rootfs | `r6cr`、burn offset 0、length `0x2B0000` | 外層 header 不寫入 rootfs;含新的 SquashFS 與空白 journal | +| Trailer | `dual` + 12 個零 bytes | 原廠網頁的結尾格式檢查 | + +Rootfs 的 bootloader checksum 仍依 superblock 的 `BE32(+8)+0x282` 計算;上傳外層 checksum 涵蓋整個 `0x2B0000`,校正 word 放在 SquashFS 外的 padding,使 journal 保持全 FF。 + +在 Active Bank 2 且雙韌體啟用時,原 `FirmwareUpgrade` 產生以下 fwd 計畫: + +| 裝置 | seek | 長度 | 對應 Flash 範圍 | +|---|---:|---:|---| +| `/dev/mtdblock0` | `0x10000` | `0x120000` | `[0x010000,0x130000)` | +| `/dev/mtdblock1` | 0 | `0x2B0000` | `[0x130000,0x3E0000)` | + +`[0,0x10000)` 與 `[0x3E0000,0x800000)` 在記憶體套用計畫後逐 byte 保留,包括整個第二槽。首次安裝會清空新 journal;此檔不適用於已有 Matter 配對/電量的裝置升級。 + +## 後續 OTA 相容性 + +v0.2.4 的更新器先保留原本完整 kernel/Bank 2 hash 檢查。若 kernel 不符合舊安裝方式,只有在簽章包指定已知 legacy 平台時,才允許嚴格的 factory kernel hash。Factory hash 僅將 +8 的非程式碼 bank mark 正規化為 `0x8000004D`;entry、長度、載入器、壓縮資料、padding 與 checksum 仍全部被 hash 覆蓋。第二槽仍要求原平台完整 hash,沒有通用略過相容性介面。 + +| 識別 | SHA-256 | +|---|---| +| Legacy Bank 1 kernel | `f081b4e0aafad65b071bcff0e0ca475fe2aa6ead608f5c7afe3a731a378b8ae8` | +| Factory kernel(mark 正規化後) | `6c72f554c78d0e71e9dfbd1c1b466eef4700f965b8a21e6958567750be933308` | +| 不變的解壓 kernel | `f160b5df1bac550b4703ee030db147c08406e740e3171c04c688f7dddfa14f8f` | + +## Evidence + +| E-id | source_ref/artifact_path | content_hash(SHA-256) | repro_command | +|---|---|---|---| +| E-001 | 私人原廠 Bank 2 `boa` 與 imports;ELF32 MSB MIPS-I、動態連結 | `175c66efa68eabda38c8ca6990e824ddcc41dfec0219bc739bce7b8ffe0a587a`(boa) | 使用自己的備份執行 `reverse/run.py`,檢查其 `reverse/evidence/elf/530000_bin__boa.imports.json` | +| E-002 | `reverse/decompiled/130000/boa/functions/00444b88_FUN_00444b88.c` | `4635224b43205d72aa9157069e91a8fb7fa69cb01313982e9b55e9a4c18698c7` | `cat reverse/decompiled/130000/boa/functions/00444b88_FUN_00444b88.c`;另對照同目錄 `.s` | +| E-003 | `reverse/decompiled/130000/boa/functions/0044275c_FUN_0044275c.c` | `f988b07a785fab2e6e3fe34bff8f52aa4f17d601903c9d64486f671eb0829c59` | `cat reverse/decompiled/130000/boa/functions/0044275c_FUN_0044275c.c`;另對照 `.s` | +| E-004 | `verification/factory-boa-results.json` | `7856dcd45e908c09b5dad692614dae6d6fd614735e750757ed0add71245bc9a7` | 下方原程式執行命令;原始 binary/映像只留本機 | +| E-005 | `verification/factory-ota-results.json` | `e305bc806a4a524451585b4c58863c6dea0013ff8140281679c8803175be944c` | 下方實際 MIPS 更新器測試命令 | + +公開反編譯文字經去識別化,E-002/E-003 的 hash 僅識別此版文字,不等同原 ELF hash。 + +## Findings + +| F-id | severity | evidence_ids | confidence | location | status/結論 | +|---|---|---|---|---|---| +| F-001 | n/a_re | E-001、E-002、E-004 | high | Boa table `0x00468758`/handler `0x00444B88` | validated:DualFirmware 是設定入口;Upload 才接收韌體 | +| F-002 | n/a_re | E-003、E-004 | high | `0x0044275C`、`0x00442F14` | validated:網頁接受 checksum 合格、帶 dual trailer 的專用映像 | +| F-003 | n/a_re | E-004 | high | `FirmwareUpgrade 0x004438F8`、boot selector `0x804086B0` | validated(模擬):僅規劃第一槽寫入,新 mark 選第一槽;破損 rootfs 可在模擬中選回第二槽 | +| F-004 | n/a_re | E-005 | high | `ck_ota.c`、`ck_common.c` | validated(fixture):首次匯入後可接受下一版簽章 OTA,保留身分並拒絕程式碼/第二槽變更 | + +## Path 與測試界線 + +P-001,`path_type=callflow`:原廠更新頁 → `formUpload`(E-001/F-001)→ checksum/dual(E-003、E-004/F-002)→ `FirmwareUpgrade` 產生 fwd 計畫(E-004/F-003)→ 記憶體套用計畫 → 原 bootloader 完整檢查及選第一槽(E-004/F-003)→ 新 journal 首次匯入 → 下一版簽章 OTA(E-005/F-004)。 + +Unicorn 中所有外部 I/O 都是 stub;它不執行實體 SPI driver,也未執行完整 Linux 冷開機。測試用 next-version OTA 使用即時產生的拋棄式金鑰,並非對外發布的新版本。測試涵蓋故障拒絕與 Flash fixture 保留範圍,不能當成實際斷電、寫入完成或硬體相容的證據。尤其有效 checksum 但應用啟動失敗時,原廠 bootloader 不保證自動切回第二槽。 + +## 重現 + +以下命令於 repository 根目錄執行。先用自己的備份和本機私人 image 準備 `private/stock-analysis/reverse` 與 `private/outlet-first-install.bin`;不公開原 Flash、keys 或完整診斷。 + +```bash +python3 -m pip install unicorn==2.1.4 +python3 reverse/tools/verify_boa_factory.py \ + --boa private/stock-analysis/reverse/binaries/530000/boa \ + --imports private/stock-analysis/reverse/evidence/elf/530000_bin__boa.imports.json \ + --reference private/stock-analysis/original_master.bin \ + --image private/outlet-first-install.bin \ + --bootloader private/stock-analysis/reverse/images/bootloader_unpacked.bin \ + --output private/boa-results.json +python3 tests/test_factory_upgrade.py \ + --image private/outlet-first-install.bin \ + --reference private/stock-analysis/original_master.bin \ + --release-dir artifacts/releases/v0.2.4-preview \ + --output private/factory-ota-results.json +``` + +另已完成 uClibc production Matter 建置與 ISA 稽核、native/MIPS 更新器測試、12 項部署測試、2 項配網控制工具測試及公開 rootfs 稽核;個別結果見 `verification/factory-results.json`。私人安裝包不屬於公開包稽核範圍,其用途與憑證包含情形另在指南標示。 diff --git a/docs/2026-10-20_firmware-admin-report.md b/docs/2026-10-20_firmware-admin-report.md new file mode 100644 index 0000000..1be33a1 --- /dev/null +++ b/docs/2026-10-20_firmware-admin-report.md @@ -0,0 +1,111 @@ +# CMNDKT-004 v0.2.9 管理後台與出廠重置驗證 + +## 概述 + +本版完成 HomeKit/Matter 共用管理入口、初次密碼設定、訪客/管理員後台、累積 kWh 歸零與完整實體重置。管理服務不再依賴身分匯入或協議程序;HomeKit 啟動失敗時仍可從後台選回 Matter。Matter 電量清零在唯一 MCU/SDK owner 執行,避免舊 RAM 計數器寫回。完整重置保存要求後重啟,依序清除 Wi-Fi、管理密碼、兩套配對與 kWh,保留原機身分、MAC、校正及 OTA 信任。簽章 OTA 已產生,本輪沒有寫入、重置或對外發布實機。 + +範圍與授權見 [scope](admin-backend-analysis/scope.md),事件見 [timeline](admin-backend-analysis/timeline.md)。報告 flavor = null;此為自有韌體實作/相容性驗證,不是外部漏洞、惡意程式或 ATT&CK 分析。從無密碼舊版首次升級會進入設定 AP,須建立密碼及重新輸入 Wi-Fi。內部 exec-test 未遷移密碼初設,因此不交付新版診斷 OTA。 + +## Evidence + +測試使用固定公開基底、原廠 uClibc/BusyBox、MIPS qemu-user、64 MiB Malta/Linux 4.19、固定 Matter SDK;SDK daemon 限制 8 MiB address space。MIB、MCU 串列、NOR、身分匯入與 RF 視個別測試使用 fixture,不能據此推定 32 MiB 原機、MFi、iPhone 配對或真實 Flash driver 行為。重現前依建置文件準備固定依賴;`.local` 的原始測試日誌留在本機,摘要記錄其 SHA-256。 + +### E-001 — production payload 與原生/MIPS 管理測試 + +- observed_at:2026-10-20,Asia/Taipei +- source_type:file +- source_ref/artifact_path:[verification/admin-release-results.json](../verification/admin-release-results.json) +- content_hash(SHA-256):`f5a077fae5fd31d4f019107b178b85b5e3ea49ceb0b42a56e7245b5323cb9c78` +- linked_workitem:五項管理修正;supersedes:none +- repro_command:`python3 reverse/tools/verify_protocol_release.py --release-dir artifacts/releases/v0.2.9-preview --test-logs .local --output verification/admin-release-results.json` +- raw_excerpt:`status: pass`。 + +### E-002 — 實際 SDK owner 配對、歸零及確認長按 + +- observed_at:2026-10-20,Asia/Taipei +- source_type:file +- source_ref/artifact_path:[verification/admin-matter-vm-results.json](../verification/admin-matter-vm-results.json) +- content_hash(SHA-256):`fa3f220bcb93e2c437a8455be25afc81c046e2bc7544151abfd473ae825f7851` +- linked_workitem:五項管理修正;supersedes:none +- repro_command:`CK_SDK_DIR="$PWD/firmware/matter/deps" python3 tests/test_matter_reset_vm.py` +- raw_excerpt:`status: pass`。 + +### E-003 — 原廠 HomeKit reset CLI 與合成 NOR + +- observed_at:2026-10-20,Asia/Taipei +- source_type:file +- source_ref/artifact_path:[verification/homekit-factory-v0.2.9-results.json](../verification/homekit-factory-v0.2.9-results.json) +- content_hash(SHA-256):`4a24331464bd39a5245652c70a89d6c9fcbdf5fb1dfc649b7a4c1bee1e00d246` +- linked_workitem:五項管理修正;supersedes:none +- repro_command:`python3 tests/test_homekit_factory.py --deps firmware/matter/deps --toolchains firmware/matter/toolchains --output verification/homekit-factory-v0.2.9-results.json` +- raw_excerpt:`status: pass`。 + +### E-004 — PID1/DHCP 與獨立管理轉址 + +- observed_at:2026-10-20,Asia/Taipei +- source_type:file +- source_ref/artifact_path:[verification/network-boot-v0.2.9-results.json](../verification/network-boot-v0.2.9-results.json) +- content_hash(SHA-256):`29a69e37e9e61fa8ee4fc3ec5ce485cb5105fa29bc16274befb8cdaa398d9207` +- linked_workitem:五項管理修正;supersedes:none +- repro_command:`python3 tests/test_network_boot_vm.py --deps firmware/matter/deps --toolchains firmware/matter/toolchains --vendor-ota artifacts/releases/v0.2.6-preview/matter.ota --output verification/network-boot-v0.2.9-results.json` +- raw_excerpt:`status: pass`。 + +### E-005 — 登入後設定頁與登出後公開資訊 + +- observed_at:2026-10-20,Asia/Taipei +- source_type:file +- source_ref/artifact_path:[verification/admin-ui/settings-fixture.png](../verification/admin-ui/settings-fixture.png) +- content_hash(SHA-256):`721a4accfba37a0e45e6a647b51c9d1f29586f474d5a2d0e9261e62e69eee102` +- linked_workitem:五項管理修正;supersedes:none +- repro_command:`python3 tests/serve_admin_fixture.py(Linux,需 tools/requirements.txt);以瀏覽器登入文件化的合成 fixture 帳號,僅接觸暫存 NOR` +- raw_excerpt:登入後顯示管理設定;登出後隱藏設定並持續顯示合成量測。 + +## Finding + +### F-001 — 管理入口與協議解耦 + +severity:n/a_re;category:design;status:validated;confidence:high(模擬範圍)。evidence_ids:E-001、E-004、E-005。location:`ck-management-start`、`ck_updated.c`、`setupd-device.c`。 + +獨立 PID1 respawn 啟動管理服務,port 80 在 station 狀態轉到數字 IP 的 18082。身分匯入持續失敗、Flash lock 被佔用時,公開 dashboard 仍回應,敏感操作拒絕而不放行。HomeKit owner 不再等待 DHCP 才接管 MCU。remediation:已實作;repro_steps:執行 E-004,再於 E-001 的 Flash lock fixture 讀取公開端點。原機記憶體/RF 仍需驗收。 + +### F-002 — 密碼控制所有管理變更 + +severity:n/a_re;category:design;status:validated;confidence:high。evidence_ids:E-001、E-005。location:`ck_web_auth.h`、`password.js`、`setupd-device.c`。 + +初設 credential 必須在 AP setup、有正確 per-boot token、帳號缺席時才可寫入;寫入失敗不允許後續 Wi-Fi 提交,也不能覆蓋既有帳號。PBKDF2-HMAC-SHA256 使用隨機 salt 與 600,000 次;登入 challenge 一次性,session 限時且綁 IP,變更需 CSRF。密碼輪替會撤銷所有 session;舊 HMAC POST 與金鑰匯出無法繞過。瀏覽器曾發現兩份 JS 共用 `hex` 的宣告衝突,已用閉包隔離並加入共同 global 的重現測試。remediation:已實作;repro_steps:執行 E-001 及 `node tests/test_password_crypto.js`,再登入 E-005 fixture。 + +這是本機 HTTP 管理,沒有 TLS/Secure cookie。Flash credential 具有登入等同權限,不宣稱抵抗中間人、root 或實體擷取。威脅邊界及 API 見[管理文件](admin-backend.md)。 + +### F-003 — 電量歸零不被舊 owner 覆蓋 + +severity:n/a_re;category:design;status:validated;confidence:high。evidence_ids:E-001、E-002。location:`ck_energy.c`、`ElectricalMeasurements.cpp`、`main.cpp`。 + +Matter 模式將 E action 交給 SDK 事件迴圈:先讀回確認零 checkpoint,再清 RAM counter、remainder、保存狀態及 cluster reset 資料。實際 SDK 測試暫停負載功率後歸零,等待超過 30 秒仍為零。HomeKit 模式沒有自訂計數 owner,後台直接清保存值;無可確認協議時拒絕。新 checkpoint 中斷仍保留原值,不做全片擦除。remediation:已實作;repro_steps:執行 E-001 的 torn reset/排程測試及 E-002。 + +### F-004 — 長按完整重置保留原機出廠身分 + +severity:n/a_re;category:reverse_algo;status:validated;confidence:high(軟體/binary fixture)。evidence_ids:E-001、E-002、E-003。location:`RelayEngine.cpp`、`ck_factory_reset.h`、`tools/build.py`。 + +Matter 前置 frame 不清除,確認 frame 才保存 `control/factory-reset` intent 並關閉 owner。開機在任一協議啟動前,執行 HAP pairing reset、Wi-Fi unconfigure、kWh reset,最後原子清理 journal。故意讓 Wi-Fi 或最後 journal commit 失敗,intent 仍保留,重試後才清除帳號與配對。 + +原廠 HAP 的 `-c` 原先將參數 1 傳給 `HMInitFlash`,會改寫 MIB MAC/配件識別。版本 11 只將 file offset `0x1b8e4` 的 `24040001` 改為 `24040000`;兩個固定長度命令字串改為 `/bin/ck-reset` 及 `true`。ELF 長度及其他 bytes 保持一致。實際原廠 ELF 在 qemu-user 執行 `-c`,MIB set/update/default 任一呼叫均使測試失敗,結果未發生;只清 pairing `PHRX` 後至 `0xa5d` 的資料,之後含原廠 PIN 的尾端不變。remediation:已實作;repro_steps:執行 E-003,再執行 E-001 的完整 boot retry fixture。原機按壓門檻及 reset 掉電仍待驗收。 + +## Path + +| 路徑 | 證據與結果 | +|---|---| +| P-001:AP → 密碼 enrollment → Wi-Fi → public dashboard → login → 設定 | E-001/E-005 → F-001/F-002;管理金鑰檔不再是一般使用流程 | +| P-002:登入 → energy E mailbox → 唯一 SDK owner → zero checkpoint → 新計數 | E-001/E-002 → F-003;下一次保存未還原舊值 | +| P-003:確認長按 → durable intent → 重啟 → HAP/Wi-Fi/電量 → journal 最後提交 | E-001/E-002/E-003 → F-004;失敗保留 intent,出廠身分保留 | + +## 驗證與交付 + +原生 C 與 MIPS 各 48 項通過;部署 12 項通過,未交付診斷版的 1 項明確略過;啟動復原 9 項通過。初設 MIPS HTTP/DNS、PBKDF2 的獨立 Node crypto 對照、實際 SDK 配對/重設與 kWh 歸零、原廠 HAP `-c` 均通過。更多測試紀錄與截圖雜湊見[執行結果](../verification/admin-runtime-results.json)。瀏覽器使用真正 C HTTP server、暫存 NOR 和合成帳號,驗證訪客、登入設定、單選保留與登出,並從 UI 保存 Matter→HomeKit 及 HomeKit→Matter(服務/RAM 重啟為 fixture,不執行 HAP 或 RF);未在 UI 更改實機密碼。Wi-Fi PHY、MFi、Apple 家庭顯示、32 MiB 原機及實機重置仍待驗收。 + +- 版本:`0.2.9-preview`,software version `11`。 +- `matter.ota` SHA-256:`c67b25b86533d3928799522fdf2bb284dd26cefb5edae1620d197eb3fc247f58`。 +- 原廠 HAP SHA-256:`2d50339436759f3d602c150fb4f1a42f0fdf122fc18e9a4f54f6e6b07d7a0c62`。 +- 修補 HAP SHA-256:`f27336a5aa672169b88d81e3a215a4a4dea59badf31c56e912aedd43ad870ee6`,長度 2,116,412 bytes。 +- rootfs checksum extent:2,461,698 bytes;距保留邊界剩 225,278 bytes。 + +HomeKit 模式的自訂 V/A/W 與 kWh 累計仍暫停,可查看/清零保存值,切回 Matter 後續計。這不是完整 A/B 自動回退或安全抹除。離線舊版仍需 CH341A 復原;本輪未執行寫入、實機重置、Git push 或發布。最終公開掃描結果見[去識別化稽核](../verification/public-audit-v0.2.9-preview.json)。 diff --git a/docs/2026-10-20_firmware-programmer-recovery-report.md b/docs/2026-10-20_firmware-programmer-recovery-report.md new file mode 100644 index 0000000..34f8361 --- /dev/null +++ b/docs/2026-10-20_firmware-programmer-recovery-report.md @@ -0,0 +1,59 @@ +# CMNDKT-004 v0.2.10 程式區域復原 + +## 概述 + +本版處理原廠網頁首次安裝後的 CH341A 復原相容性,並修正保留舊 OTA 待確認標記時,新版程式無法完成健康確認的問題。保留 v0.2.9 的密碼後台、HomeKit/Matter 單選、kWh 歸零及完整出廠重置功能。本次實機復原不執行出廠重置,只允許 Bank 1 rootfs 寫入;HomeKit、MFi、MAC、校正、裝置身分、配對及 kWh 保留。授權見 [scope](programmer-recovery-analysis/scope.md),事件見 [timeline](programmer-recovery-analysis/timeline.md)。flavor = null,此為自有韌體相容性/復原工作,不是外部漏洞或惡意程式分析。 + +## Evidence + +### E-001:known factory kernel wrapper + +source_type:file;observed_at:2026-10-20。content_hash(摘要 SHA-256):`6c27bf1d1a944c64e811ce7c244361dbebf512bff9db57cb56e0a65d9a1a1639`;artifact_path:`verification/host-factory-recovery-results.json`。來源為 [host 測試摘要](../verification/host-factory-recovery-results.json)。`ck_factory.h` 已固定 legacy 與 Boa wrapper 的兩個 hash;production OTA verifier 原已支援 wrapper,host `fwlib.compatible()` 則只有 legacy raw hash 比對。 + +重現:使用符合公開固定 platform 的原始完整備份作 `--reference`,執行 `python3 tests/test_factory_host_compat.py --reference private/reference.bin --output verification/host-factory-recovery-results.json`。此命令只接觸本地檔案,不存取燒錄器。完整輸入含私人資料,不隨報告公開。 + +### E-002:stale pending version + +source_type:file/command;observed_at:2026-10-20。content_hash(摘要 SHA-256):`a2dd72bf3b85651cbac6f5b414ba0636881b0d1520739691ce77b43fdd9b911c`;artifact_path:`verification/recovery-v0.2.10-release-results.json`。來源為 `tests/test_updates.py` 的兩項復原測試,以及當次私人 NOR 副本預演。舊狀態 pending=8、floor=6;較新程式 version=12 若直接沿用 pending=8,`ck_store_confirm(12)` 會因 pending 不符而失敗。 + +重現:Linux 安裝 `tools/requirements.txt` 後執行 `python3 tests/test_updates.py -v`;MIPS 使用 `CK_TEST_RUNNER=qemu-mips CK_TEST_BIN=firmware/matter/artifacts/update-test`。原生與 MIPS 各 50 項通過,細節與日誌 SHA-256 見產物驗證摘要。故障注入覆蓋接續標記提交失敗及下次重試,不要求實機斷電。 + +### E-003:私人備份與限定寫入 + +source_type:command/private file;observed_at:2026-10-20。完整備份、個別化目標與燒錄器 session 留在本機 private 目錄,含實機身分及 HomeKit 資料,不公開 bytes、配對碼或身分值。狀態與保留範圍的去識別化摘要記錄於本版驗證結果。初始不穩定讀取未用作寫入基礎,追加兩份一致後才準備目標。 + +## Finding + +### F-001:host 復原工具需辨識已支援的 wrapper + +severity:n/a_re;category:design;status:validated;confidence:high;evidence_ids:E-001。location:`tools/fwlib.py:compatible`。 + +原廠 Boa 首次安裝把相同 kernel 包在 full-span 長度與 checksum 內,因此整段 hash 與 legacy 容器不同。host 現在與 C OTA verifier 採相同嚴格條件:legacy expected hash、固定 magic/entry/full-span length、合法 boot mark、canonical factory 全段 hash,以及完全相同 Bank 2。只正規化 boot mark,沒有遮罩程式、padding 或 checksum,也不改寫 kernel。3 種已知 mark 接受,11 種程式/header/padding/Bank 2/無效 mark 變更拒絕,包含重新計算 checksum 的惡意程式變更。 + +### F-002:較新程式接續舊待確認版本 + +severity:n/a_re;category:design;status:validated;confidence:high;evidence_ids:E-002。location:`firmware/matter/src/update/ck_store.c:ck_store_boot`。 + +當 executing metadata 的版本比 pending 新,且未低於已確認 floor,啟動流程先刪除舊 attempts,再保存新的 pending,然後照常計算首次嘗試。刪除 attempts 在前,使兩步之間中斷時能於下次啟動重試;不會直接提高 floor 或宣稱健康。新程式仍需完成原有健康檢查才確認。身分、配對、管理資料與獨立電量紀錄不被清除。pending 與執行版本相同時,原三次未確認保護維持。 + +### F-003:保留專屬資料的復原邊界 + +severity:n/a_re;category:design;status:validated(寫入與讀回);evidence_ids:E-003;confidence:high;location:`tools/flash.py`。本次已完成限定區域寫入、內建校驗與獨立全片讀回,狀態為 `write_verified_not_boot_tested`;實機開機仍另行驗收。 + +目標以當次一致備份補回全部非 rootfs bytes,沿用 journal 中既有身分;不產生替代金鑰,不執行 factory reset。寫入工具再次取得兩份備份並比對完整原 hash,固定 layout 只包含 `[0x130000,0x3C0000)`;內建校驗後另做全片讀回,比較完整目標與全部保留區。HomeKit 配對/PIN 區域 `[0x3E0000,0x400000)`、journal/電量 `[0x3C0000,0x3E0000)`、MAC/MIB/kernel、Bank 2 與 factory/校正都在保留範圍。 + +## Path + +| 路徑 | 結果 | +|---|---| +| P-001:穩定完整備份 → 已知 wrapper 驗證 → 沿用身分的私人目標 | E-001/E-003 → F-001/F-003 | +| P-002:新版 import → running > pending → 保留資料接續 → 待正常健康確認 | E-002 → F-002 | +| P-003:再次雙份備份 → rootfs layout 寫入 → 獨立全片讀回 → 移除接線後開機 | E-003 → F-003;開機結果不可由離線結果推定 | + +## 使用與限制 + +首次從無管理密碼的舊版升級,會進入設定熱點,須建立密碼並重新輸入 Wi-Fi。HomeKit 模式仍暫停自訂即時量測與 kWh 累計,切回 Matter 後續計。新版長按為完整恢復出廠,本次為保留資料不測試長按。已辨識的 factory wrapper 不代表可忽略其他 kernel 或 Bank 2 差異。 + +本版沒有變更 GitHub 或 UniFi 設定,也未公開完整私人映像。實機健康、Wi-Fi、短按、HomeKit/MFi 與長期穩定性須各自驗收;全片讀回成功只證明寫入內容一致。 + +本次[硬體寫入摘要](../verification/recovery-v0.2.10-hardware-results.json)確認完整目標一致、兩份當次備份一致、所有保留區逐 byte 未變,未執行重置。私人 `preservation-verification.json` 另記錄各區域前後 hash。公開報告不列實機配對碼、身分值、原始映像或 per-device hash。 diff --git a/docs/2026-10-20_firmware-protocol-switch-report.md b/docs/2026-10-20_firmware-protocol-switch-report.md new file mode 100644 index 0000000..cf9bda3 --- /dev/null +++ b/docs/2026-10-20_firmware-protocol-switch-report.md @@ -0,0 +1,82 @@ +# CMNDKT-004 HomeKit/Matter 互斥切換報告 + +日期:2026-10-20;flavor = null;範圍為使用者自有插座韌體、既有原廠 HomeKit ELF 與離線測試。使用者要求先完成協議選擇面板,CH341A 復原暫緩。未連線燒錄器、未重設實機、未發布版本。 + +## 結論與界線 + +v0.2.8-preview(software version 10)完成經管理金鑰驗證的 HomeKit/Matter 單選面板。偏好存在既有雙快照 journal,成功讀回才回覆,重啟後套用;不清除 Wi-Fi、Matter fabrics、原廠 HAP/MFi 區或已保存的 kWh。單一開機擁有者及繼承鎖排除重複服務;HomeKit 啟動失敗不自動轉開 Matter。 + +HomeKit 使用每顆自己的原廠服務與 MFi/配對資料。**此版尚未完成原機 HomeKit、MFi、RF、iPhone 配對或 32 MiB 記憶體驗收**。HomeKit 模式暫停自訂網頁量測與 kWh 累計,舊紀錄保留;不能據此宣稱 Apple「家庭」能顯示電力資料。原廠 HomeKit 重設會改寫 MIB 識別值,故 HomeKit 長按改成只重新啟動;Matter 長按保留原配對重設功能。 + +操作見[協議選擇](protocol-switching.md)。既有離線插座仍須先經 CH341A 限定區域復原,不能直接收到這份 OTA。 + +## Evidence + +### E-imports:原廠程式身分與相依性 + +- observed_at: 2026-10-20 +- source_type: file / command +- source_ref: v0.2.7 payload 的 `/bin/homekitserver`,先做 ELF/imports 調查,再閱讀既有 Ghidra 530000 輸出。 +- content_hash: `2d50339436759f3d602c150fb4f1a42f0fdf122fc18e9a4f54f6e6b07d7a0c62` +- artifact_path: 原始輸出留在忽略版控的私人分析目錄;不在公開文件附上使用者映像。 +- raw_excerpt: 2,116,412 bytes;ELF32 big-endian MIPS-I/o32,stripped,動態連結;207 imports;uClibc loader 與 apmib/pthread/dl/rt/m/gcc/libc 相依性均保留。 +- repro_command: 在專案根目錄以下列 Python 擷取,再對輸出執行 `rabin2 -I -i -l`(需先準備對應 OTA)。 + +```python +from pathlib import Path +import sys +sys.path.insert(0, 'tools') +from fwlib import read_file +root = Path('artifacts/releases/v0.2.7-preview/matter.ota').read_bytes()[256:] +Path('/tmp/ck-homekitserver-analysis.elf').write_bytes(read_file(root, 'bin/homekitserver')) +``` + +### E-boot:原廠控制與重設呼叫 + +- observed_at: 2026-10-20 +- source_type: file / decompile +- source_ref: `main @ 0x0041b3f8`、serial open `0x004335e8`、`Phytrex_HMInitFlash @ 0x00425904`、`FUN_00425670 @ 0x00425670`。 +- content_hash: 同 E-imports。 +- artifact_path: 同 E-imports。 +- raw_excerpt: 主程式 fork,父程序寫 `/var/run/homekitserver.pid`;由 MIB 688 判斷已設定狀態;原廠 reset 路徑執行 `flash default-sw`、`homekitserver -c`、`reboot`。後者觸發 MIB 23 寫入及原廠配對區初始化。 +- repro_command: 在相同 ELF 的反編譯中查看上述四個位址;成品差異另由 E-test 的還原 hash 檢查。 + +### E-test:成品及故障注入 + +- observed_at: 2026-10-20 +- source_type: log / file +- source_ref: [成品驗證](../verification/protocol-switch-results.json)、[網路開機 VM](../verification/network-boot-v0.2.8-results.json)。 +- content_hash: `2e745bfad3caad8afdc5632795016a8bae61ca31048681c4bed056180b0bc965` +- artifact_path: `verification/protocol-switch-results.json` +- raw_excerpt: C 儲存/OTA/HTTP/協議套件 native 39、MIPS 39;部署及 HAP 佈局 13。包含錯誤金鑰、body/origin/nonce/重放、journal 中斷、模式雙向切換保留資料、無變更不擦寫、OTA/重設互斥、HomeKit 失敗不轉開 Matter、supervisor 死亡後背景 daemon 繼承鎖。 +- repro_command: Linux 建置容器安裝 `tools/requirements.txt`,先建置 production 與 `CK_TESTING=1` 目標,再執行下列命令。Mac 主機可直接執行部署和成品檢查。 + +```bash +python3 tests/test_updates.py -v +CK_TEST_RUNNER=qemu-mips CK_TEST_BIN=/repo/firmware/matter/artifacts/update-test python3 tests/test_updates.py -v +python3 tests/test_deployment.py --release-dir artifacts/releases/v0.2.8-preview -v +python3 reverse/tools/verify_protocol_release.py --release-dir artifacts/releases/v0.2.8-preview --output verification/protocol-switch-results.json +``` + +網路 VM 使用真正的原廠 BusyBox init、DHCP 與 production guard/setupd/backend,MIB 與 importer 為 fixture;64 MiB/Linux 4.19 不能等同原機驗收。HAP 背景程序生命週期測試使用 daemon fixture,沒有模擬 MFi 晶片。瀏覽器以本機 fixture 另驗證 radio 互斥、未提供管理檔時不送出切換。 + +## Findings + +| id | 結論 | severity / category | status / confidence | evidence_ids | +|---|---|---|---|---| +| F-1 | 原廠 HAP fork 成背景服務,單靠前景程序退出不能證明 MCU 已釋放;需跨 fork/exec 繼承擁有者鎖及開機快照 | n/a_re / design | validated(離線) / high | E-imports, E-boot, E-test | +| F-2 | 切換必須綁定所選協議的驗證碼,讀回成功後才重啟,且和 OTA/Matter reset 互斥 | n/a_re / design | validated(fixture) / high | E-test | +| F-3 | 原廠長按重設會清 Wi-Fi 並改寫識別值;兩個命令以等長 no-op 取代,保留最後的 reboot | n/a_re / reverse_algo | validated(靜態差異) / high | E-boot, E-test | +| F-4 | 原廠 HomeKit 在這顆實機的重啟/MFi/手機配對相容性 | n/a_re / other | candidate / medium | E-imports, E-test | + +F-3 的最終 ELF 可逆還原後 SHA-256 必須完全等於 E-imports,且長度不變。測試固定兩個字串 offset:`0x1ae5b8`、`0x1ae5e0`,避免以模糊字串匹配誤改其他程式內容。初次候選包曾因替換長度少一 byte 未通過檢查;已隔離在忽略版控的本機候選目錄,未交付或寫入。 + +## Path 與驗收 + +P-1(callflow):面板單選 → 一次性 nonce + HMAC 綁定協議 → 取得 transfer 與 Matter operation 排他權 → journal 原子保存/讀回 → 回覆並重啟 → rcS 在 importer 前保存本次開機快照 → PID1 啟動唯一擁有者 → Matter 或原廠 HomeKit。每一步對應 E-test/F-1/F-2;原廠 HAP 身分及 fork 行為對應 E-imports/E-boot/F-3。 + +HomeKit 健康確認要求連續約 30 秒 PID 與 TCP listener 存活且 Wi-Fi online,沒有宣稱已驗證 MCU ACK;Matter 維持既有新鮮 MCU 狀態確認。HomeKit 異常時保留管理頁,不建立另一個 MCU 程序。下一項實機驗收為安全接線後取得兩次一致的 8 MiB 讀取,限定更新 rootfs;再確認按鍵、Wi-Fi、兩種模式與手機配對。 + +Timeline:先完成 ELF/imports 與原廠重設行為調查;實作經驗證面板、journal 偏好與單一啟動擁有者;執行 native/MIPS 故障注入及網路 VM;修正候選包 ELF 長度並停用改寫識別值的 HomeKit reset;最後核對成品內 binary、保留區與簽章。未進行硬體寫入。 + +成品 `matter.ota` SHA-256:`37cb27447b4c0345dcd3c0ba52f32a7da1033c6f0f30f822644ab6eac094ad21`。rootfs checksum extent 2,446,850 bytes,距既有範圍上限尚有 240,126 bytes;沒有擴大 journal 邊界。原廠 Boa/路由器 GUI 已移除,後續共用 `:18082/` 網頁更新。 diff --git a/docs/2026-10-20_firmware-wifi-recovery-report.md b/docs/2026-10-20_firmware-wifi-recovery-report.md new file mode 100644 index 0000000..e92ee29 --- /dev/null +++ b/docs/2026-10-20_firmware-wifi-recovery-report.md @@ -0,0 +1,87 @@ +# CMNDKT-004 v0.2.7 網路開機修復 + +日期:2026-10-20,Asia/Taipei。報告 flavor:null。授權與邊界沿用 [scope](wifi-startup-analysis/scope.md),早期調查見[啟動分析](2026-10-20_firmware-wifi-startup-report.md)。本輪完成來源修改、離線開機測試及簽章包,沒有對實體插座寫入韌體或變更 UniFi 設定。 + +## 已確認的直接錯誤 + +實際 v0.2.6 OTA 的 `ck-ota-start` 在 `set -eu` 後,第 5 行執行 `touch /var/run/ck-identity-pending`。原廠 BusyBox 1.13.4 未編入 `touch`,該 rootfs 的 `/bin/touch`、`/usr/bin/touch` 也不存在。在原廠 BusyBox PID1 的完整開機測試中,原腳本輸出: + +```text +/bin/ck-ota-start-v6: line 5: touch: not found +V6_STARTUP_EXIT=127 +``` + +因此金鑰尚未從 journal 匯入 RAM,初始化腳本就已中止;隨後舊 setup wrapper 找不到 key 而退出,DHCP 不會由該流程啟動。這條確定失敗路徑符合按鍵可用、AP 仍見到無線關聯、網頁不可達的觀察。**實體插座仍沒有更新後開機日誌,不能宣告已取得其現場 crash 證據。** + +原廠 BusyBox 也沒有 `pidof`,rootfs 沒有提供外部同名程式。Matter MCU 衝突檢查及診斷版健康檢查原先使用了它,已改用映像實際提供的 `ps`/`grep`。 + +前一次故障注入雖用了原廠 ash,PATH 仍包含主機工具,因此漏掉 `touch` 不存在的相容性錯誤。本次測試改用原廠 BusyBox 作 PID1,搭配映像真正的 applet/外部程式,不使用主機工具替代這些命令。 + +## 修正後行為 + +| 修正 | 實作與目的 | +|---|---| +| 移除缺少的命令 | RAM marker 改用 shell builtin `: > file`;移除 `pidof` 依賴 | +| 網路先啟動 | setup 控制器先處理保存的 station Wi-Fi/DHCP,身分、QR 與管理 key 由另一服務準備 | +| 身分失敗不開放操作 | 匯入完成且 onboarding helper 完整成功後才載入管理身分;此前 API 回覆 503,沒有共用 key 或免驗證操作 | +| PID1 監督 | identity、onboarding、setup、diagnostic 各有一個 respawn action;rcS 不重複啟動它們 | +| 初始化期限 | 固定工作 guard:MIB status 10 秒、一般 import/helper 30 秒、等待首次亂數 300 秒;失敗重試,保留原 journal,launcher 死亡會終止其工作程序 | +| 設定控制器恢復 | 每 2 秒 RAM 心跳,保留最後一次有效值以容忍讀取中的短暫空檔;超過 40 秒未更新時 guard 終止卡住的程序,由 PID1 重啟;單一控制器鎖防重複 owner | +| DHCP 恢復 | 定期檢查/重啟 DHCP;控制器重啟會接手既有 client,避免不必要地清 IP。DHCP 輸出保留最後 8192 bytes 於 RAM | +| 保留已存 Wi-Fi | 已設定 station 的重連不寫 MIB/onboarded;逾時持續重試,不自動覆寫 SSID/PSK 切換 AP。明確要求重新設定或首次 onboarding 仍使用 AP 流程 | +| 更新計數 | RAM boot marker 確保更新 HTTP daemon 重啟時,不重複計算同一次上電的 OTA attempts | + +診斷快照增加身分階段、匯入/helper/controller 錯誤與 DHCP RAM 日誌;不輸出 key 或配對碼。Matter reset、長按重設及 kWh 演算法未在本輪變更。OTA 寫入範圍仍是 Bank 1 rootfs `[0x130000,0x3C0000)`,不包含 Wi-Fi MIB、身分 journal 或累積電量區。 + +## Evidence → Finding → Path + +| E-id | source_ref/artifact_path | content_hash/重現 | +|---|---|---| +| E-001 | 實際 v0.2.6 `matter.ota` | SHA-256 `6dfe222d5d93586cda39fcc7234ab84aa75f1eb1a5b5c762b9023ee3a5bb2ba9`;原始腳本與 BusyBox 直接提取 | +| E-002 | 原廠 BusyBox metadata/applet 清單與 rootfs inventory | BusyBox SHA-256 `3cd0ec8270214a74879ee4b67e592be285e23d6a9cca964b64a86b88c821e619`;原始 ELF 留本機 | +| E-003 | [完整開機 VM 結果](../verification/network-boot-vm-results.json) | `tests/test_network_boot_vm.py`;記錄實際 guard/setupd hash,原始 console 留在私人目錄 | +| E-004 | [修復測試及包摘要](../verification/wifi-recovery-results.json) | native/MIPS 儲存、啟動、guard、部署測試 log hash 及包內檔案一致性 | +| E-005 | 新簽章 `matter.ota` | SHA-256 `3959e7b97bede402e1c15ae5eaa23c712cff78336c9181387a5882f517d99eaa`;沿用原 Ed25519 信任公鑰,software version 9 | + +| F-id | severity | evidence_ids | confidence | location | status/結論 | +|---|---|---|---|---|---| +| F-001 | n/a_re | E-001、E-002、E-003 | high | v0.2.6 `ck-ota-start:5` | validated:缺少 touch 造成匯入前退出 127 | +| F-002 | n/a_re | E-003、E-004 | high | setup controller/PID1 actions | fixed,離線驗證:無身分仍完成 DHCP;crash/hang 可恢復 | +| F-003 | n/a_re | E-003、E-004 | high | identity tick/authenticated API | validated:未就緒回覆 503;稍後正常啟用既有 HMAC 管理流程 | +| F-004 | n/a_re | E-003、E-004、E-005 | high | backend resume/OTA 範圍 | validated(fixture):station 重連不寫 MIB;既有 key、kWh 與 journal 保留測試通過 | +| F-005 | n/a_re | E-004 | high | 實體插座 | pending:舊 IP 的 80/18081/18082 無回應,新版未送入或驗收 | + +P-001,`path_type=callflow`:原廠 BusyBox 無 touch(E-002)→ 舊啟動腳本退出 127(F-001)→ 金鑰缺失 → 舊 setup wrapper 中止 → 未啟動 DHCP。新版本以 builtin marker、分離的 PID1 服務及網路先啟動切斷此依賴(F-002);管理操作維持身分驗證(F-003)。 + +## 測試與界線 + +已通過 native/MIPS 儲存及 OTA 各 33 項、啟動 9 項、guard native/MIPS 各 6 項、部署 12 項、實際 MIPS HTTP/DNS/HMAC/Matter 管理路由,以及公開包稽核。另逐檔確認 OTA 內的腳本與 production binaries 對應本次修改,四個 PID1 service 各只有一個啟動位置。 + +完整開機 VM 使用真正的原廠 BusyBox init、vendor DHCP client、production guard/setupd、backend、callback 與 lease publisher,驗證: + +1. 舊 v0.2.6 原腳本退出 127。 +2. 修正版在持續匯入失敗、沒有身分檔案時完成實際 DHCP,管理 API 回覆 503。 +3. setup 控制器 SIGSEGV 後由 PID1 恢復,沿用 DHCP client。 +4. setup 控制器 SIGSTOP 後由 40 秒心跳期限清除,PID1 恢復服務。 +5. 身分稍後完成,無需重開即可透過 HMAC 取得 online 狀態。 +6. 終止 DHCP client 後,新 client 再次完成取址;未觸發 MIB/onboarded 寫入。 + +VM 的 MIB、identity importer/helper 是 fixture,沒有 RF 或 MTD 裝置。Debian Linux 4.19 測試 kernel 在 `-m 32`、甚至不帶 initrd 時都無法開機,因此這項測試使用 64 MiB Malta;它不能當成原插座 32 MiB/Linux 2.6.30 的 RAM 或 driver 驗收。實體安裝後仍須確認開機版本、回連、按鍵、配對視窗、kWh 及長期穩定性。 + +重現前先依建置說明準備相同工具鏈、kernel/modules、已建置 production 服務,並保留原 v0.2.6 OTA: + +```bash +python3 tests/test_startup_recovery.py +python3 tests/test_boot_guard.py +python3 tests/test_network_boot_vm.py \ + --deps firmware/matter/deps \ + --toolchains firmware/matter/toolchains \ + --vendor-ota artifacts/releases/v0.2.6-preview/matter.ota \ + --output verification/network-boot-vm-results.json +``` + +Guard 單元測試需 Linux;MIPS runner 可透過 `CK_BOOT_RUNNER=qemu-mips` 與 `CK_BOOT_BIN` 指定測試版 guard。實際 firmware 不接受測試環境變數或任意 executable 路徑。 + +## 實體復原狀態 + +新版包可供仍有更新入口的裝置安裝;目前離線的插座無法只靠該 OTA URL 接收更新。需要先找回可用管理入口,或讀取目前 Flash 後,使用限定 rootfs 區域的復原寫入。已有自製韌體的插座不得拿「首次安裝 BIN」直接重裝,因為首次安裝包會初始化 journal,與保留身分/fabrics/kWh 的修復目標不同。 diff --git a/docs/2026-10-20_firmware-wifi-startup-report.md b/docs/2026-10-20_firmware-wifi-startup-report.md new file mode 100644 index 0000000..e8b024d --- /dev/null +++ b/docs/2026-10-20_firmware-wifi-startup-report.md @@ -0,0 +1,118 @@ +# CMNDKT-004 升級後 Wi-Fi 回連失敗分析 + +日期:2026-10-20,Asia/Taipei。報告 flavor:null。範圍見 [scope](wifi-startup-analysis/scope.md),順序見 [timeline](wifi-startup-analysis/timeline.md)。 + +後續修復的完整開機測試已找到更早的確定失敗:原廠 BusyBox 沒有 `touch` applet,v0.2.6 rootfs 也沒有外部 `touch`;其 `ck-ota-start` 第 5 行會在匯入金鑰之前退出 127。本報告原先的故障注入雖使用原廠 ash,仍借用主機 PATH 工具,漏掉了這項差異。原有依賴與恢復缺口仍成立;補充證據、v0.2.7 修正及完整開機驗證以[修復報告](2026-10-20_firmware-wifi-recovery-report.md)為準。 + +## 結論 + +目前韌體確實有「身分初始化失敗會連帶阻止 DHCP 啟動」的缺口,已用實際送出 OTA 內的啟動腳本重現。它可以造成無線仍關聯、按鍵仍可切換,卻沒有可用 IPv4、管理網頁或設定熱點。這是已驗證的失敗路徑;**尚無更新後實體開機日誌,不能確定這顆插座此次是否走到該路徑,也不能宣告特定程序已崩潰。** + +實體觀察較符合「Wi-Fi 關聯之後、DHCP 啟動或完成之前」出問題。UniFi 的 SSID/VLAN 對應一致;租約表未查到目標 MAC,因此介面顯示的舊 IP 並不足以證明更新後已取得新租約。這些是控制器觀察,沒有 DHCP 封包擷取,仍可能存在控制器資料延遲或其他網路問題。 + +## 實際映像比較 + +對照的是原廠網頁通道使用的 v0.2.4 私人首次安裝 BIN,以及之後提交的 v0.2.6 `matter.ota`,不是只比較 Git HEAD。兩份 rootfs 均通過格式/checksum 檢查。 + +| 項目 | v0.2.4 → v0.2.6 | +|---|---| +| rcS、inittab、vendor init/startup | 逐 byte 相同 | +| BusyBox、libapmib、ck-wificfg、ck-lease、DHCP callback | 逐 byte 相同 | +| 身分匯入/setup/diagnostic 啟動腳本 | 改為公開版自動身分匯入與 pending 等待 | +| rootfs 內 bootstrap device/diag key | v0.2.4 私人版具備;v0.2.6 公開版沒有,必須由 journal 匯入 | +| Matter、updater、setup 控制器 | 執行檔有變更 | + +因此目前沒有證據顯示升級替換了 Wi-Fi 驅動或 WPA 設定程式。正常 OTA 的 rootfs 範圍是 `[0x130000,0x3C0000)`,Wi-Fi 設定保存在 vendor MIB,與 RAM 裡的 Matter 身分檔案不同。這不等於已在實體上排除 MIB 損壞、寫入故障或 RF 問題。 + +## 啟動依賴與已重現的情況 + +![Startup dependency](wifi-startup-analysis/startup.svg) + +圖中的三條開機支線表示程序依賴,不表示 rcS 同時執行所有命令;可編輯來源見 [startup.dot](wifi-startup-analysis/startup.dot)。 + +`ck-setup-start` 先等 `ck-identity-pending` 消失,再執行 `matterd-test --init-identity wlan0`。只有成功後才啟動 `ck-setupd`。控制器又先檢查配對碼、管理金鑰、亂數和監聽 socket,通過後才呼叫 `ck-setup-backend resume` 啟動 `udhcpc`。Wi-Fi 關聯可由先前的 vendor 初始化完成,所以不能用「AP 看得到它」推論 DHCP/應用服務已起來。 + +| 情況 | 驗證結果 | 與本次症狀的關係 | +|---|---|---| +| 公開版匯入回傳失敗,或 importer 子程序崩潰 | rescue 會建立、pending 會清除,但沒有 bootstrap device key;身分 helper 失敗,setup 控制器與 DHCP 都未啟動 | 高度相符;實體是否有匯入錯誤仍待日誌 | +| importer 卡住,或其背景初始化 shell 被終止 | pending 留存;setup 與診斷腳本無期限等待 | 高度相符;實體是否發生卡住/OOM 未證實 | +| `--init-identity` 失敗或崩潰 | setup wrapper 直接退出,沒有重啟;DHCP 未啟動 | 可重現;缺 key、無有效 wlan0 MAC、寫 RAM 失敗等也會回傳失敗,未必是 crash | +| setup 控制器啟動檢查失敗或 MIB status 失敗 | 在啟動 resume 前退出;沒有外部 supervisor 重啟 | 可重現/來源碼確認;實體原因未定 | +| DHCP child 未啟動或取得租約失敗,但 setup 控制器仍存活 | 正常應在約 120 秒後嘗試 AP;child 的 stderr 目前被丟棄 | 單獨此情況通常還應看到 AP 嘗試;若沒有,需再查前述 gate 或 AP 啟動失敗 | + +故障注入使用實際 OTA 的 BusyBox ash 與腳本,只將絕對路徑改到暫存 fixture;updater、identity helper、控制器及網路命令是 stub。`dhcp_launch_reached` 只表示走到 DHCP 啟動命令,不表示真實網路完成 DHCP。共 9 案例:公開版正常、匯入錯誤、匯入 crash、匯入 hang、初始化 shell 被 kill、身分 helper crash、控制器拒絕、MIB 錯誤,以及 v0.2.4 有 bootstrap 的匯入錯誤對照。全部符合預期;舊私人版在匯入失敗時仍能走到 DHCP 啟動,公開版同條件下不能。 + +此外,OTA 健康監看在 `state=online` 之後才開始 180 秒計時。若網路啟動已卡住,它會一直等待,不能修復 pending gate 或重啟 setup 控制器。有效 rootfs checksum 也不代表應用健康,更不能據此宣告 bootloader 會自動回退。 + +## 已排查與尚未排除 + +提取的 production MIPS 身分 helper 已在 qemu-user 執行 5 案例:新舊版本以同一測試 key/MAC 成功,產生相同配對資料;v0.2.6 缺 key、key 長度錯誤、介面不存在則正常以 5 退出。沒有在此環境重現 helper 自發 crash;測試用現代容器 kernel 與合成身分,不能排除 RTL8196E/Linux 2.6.30 的 ABI、記憶體或硬體差異。 + +已重跑 native 與 MIPS 的 `test_auto_policy_keeps_existing_legacy_identity_without_entropy`:有效 v0.2.4 journal 可由公開版匯入,不改 key、不寫 Flash、不產生 first-setup flag,也不需新亂數。只有允許建立全新身分且 journal 尚無有效身分時才走 `/dev/random`;「這顆正在重新產生 key」目前沒有證據支持。 + +若只是 Matter 伺服器在已上線之後崩潰,既有啟動架構有 MCU fallback,且 setup/DHCP 為其他程序;這不足以單獨解釋 DHCP 完全沒啟動。OOM 若同時殺掉初始化或控制器則可能造成上述問題,但目前沒有 kernel OOM 訊息或故障時 RAM 數據,列為未驗證候選。 + +先前 Matter reset VM 驗證的是配對與重設行為,測試直接啟動相關服務,沒有覆蓋完整 rcS 的前置失敗鏈。既有啟動測試雖檢查背景亂數等待與私人 bootstrap fallback,沒有涵蓋「公開 rootfs 匯入失敗後沒有 bootstrap」及「初始化 shell 死亡留下 pending」;本次補上這些可重現證據。 + +## Evidence → Finding → Path + +| E-id | source_ref/artifact_path | content_hash/重現 | +|---|---|---| +| E-001 | 實際 v0.2.4 私人首次安裝 BIN | SHA-256 `f9cb04be891a3ea725aa9628cfb4d22e9da37f5826b9fdeaea827c3550d343d9`;私人映像不發布 | +| E-002 | `artifacts/releases/v0.2.6-preview/matter.ota` | SHA-256 `6dfe222d5d93586cda39fcc7234ab84aa75f1eb1a5b5c762b9023ee3a5bb2ba9` | +| E-003 | [去識別化比較與測試結果](../verification/wifi-startup-analysis-results.json) | SHA-256 `913fc054e713369276c826e0da0b88cfdfc5bf12e260009f4771bfcf4ebc3aaf`;包含逐檔 hash、ELF 摘要、9 案例、5 production helper 案例及兩種 legacy 測試 | +| E-004 | [重放工具](../reverse/tools/audit_wifi_startup.py) | hash 存於 E-003 `reproducer_sha256`;命令見下方 | +| E-005 | 本機私人觀察:使用者按鍵測試、UniFi client/VLAN/租約表 | 無封包或更新後開機日誌;觀察不能當成實體 root cause 證據 | + +rabin2 6.2.2 的 ELF survey:BusyBox 為動態 ELF32 MSB MIPS-I,244 imports;production Matter/updater/setupd 為靜態 MIPS ELF,imports 空集合。空 imports 不代表沒有 I/O,呼叫路徑另由腳本、來源碼與執行結果核對。 + +| F-id | severity | evidence_ids | confidence | location | status/結論 | +|---|---|---|---|---|---| +| F-001 | n/a_re | E-001、E-002、E-003 | high | `ck-setup-device-start:4–17`、`setupd-device.c:960–993` | validated:DHCP 依賴 Matter 身分與控制器前置成功 | +| F-002 | n/a_re | E-001、E-002、E-003、E-004 | high | `ck-ota-start:20–35` | validated(離線):公開版匯入錯誤時 bootstrap fallback 無 device/diag key 可用,後續不啟動 DHCP | +| F-003 | n/a_re | E-002、E-003、E-004 | high | `ck-ota-start:5,54–55`、setup/diag pending wait | validated(離線):hang 或 initializer 死亡留下無期限 gate | +| F-004 | n/a_re | E-002、E-003 | high | `ck-ota-start:63–82`、inittab | validated(來源碼):online 前沒有恢復期限,setup 控制器沒有外部 supervisor | +| F-005 | n/a_re | E-003 | high | `ck_store.c:311–420` | validated(fixture):有效舊身分不需新亂數、不應自動改 key | +| F-006 | n/a_re | E-003、E-005 | medium | 實體開機鏈 | candidate:身分/控制器啟動失敗造成未啟動 DHCP;需開機日誌驗證 | + +P-001,`path_type=callflow`:已保存 Wi-Fi 的 vendor 初始化可先關聯 → 公開版身分匯入(E-002)→ 匯入失敗或 pending 不解除(F-002/F-003)→ 身分 helper 退出或 setup 持續等待(F-001)→ DHCP/AP 控制器未啟動 → online 前的健康監看也持續等待(F-004)。MCU worker 是另一條支線,因此按鍵正常與此路徑並不矛盾。F-006 只是假設實體走到 P-001,尚未驗證。 + +## 修正方向與實體確認資料 + +修正應先把「已有 Wi-Fi 的回連/DHCP」從 Matter 配對身分生成拆開,讓身分服務失敗時仍可完成網路回連;管理寫入仍須有效裝置金鑰,不能加入共用密碼或任意重建身分。為 initializer 與 setup 加入可觀察的階段、存活檢查及有上限的重試,避免 pending 孤兒檔;錯誤恢復不要反覆寫 MIB。DHCP 日誌應保留在有大小上限的 RAM 檔案,健康監看需涵蓋 online 前。 + +確認實體原因需要早期開機日誌與程序狀態,重點為 `/var/log/ck-store-init.log`、`/var/log/matter-identity.log`、`/var/run/ck-identity-pending`、`/var/run/ck-setup-state`、`/var/run/ck-wifi-bound`、br0/wlan0 狀態、程序清單及 kernel OOM/fault 訊息。只記錄階段、errno 與檔案是否存在,不輸出管理 key 或配對碼。 + +這輪產出分析與重現工具,未改動 firmware source、重建簽章版本或再寫入實體插座。原本要求的實體 Matter 清除/重新開窗仍未驗收完成。 + +## 重現公開版故障鏈 + +在 repository 根目錄執行,需既有 build image、SquashFS 工具及本機 v0.2.6 OTA。只會建立 `private/wifi-startup-repro`;該目錄若已有資料,請先另選新目錄。 + +```bash +python3 - <<'PY' +from pathlib import Path +import sys +sys.path.insert(0, 'tools') +from fwlib import read_file, rootfs_check +root = Path('artifacts/releases/v0.2.6-preview/matter.ota').read_bytes()[256:] +rootfs_check(root) +output = Path('private/wifi-startup-repro') +output.mkdir(parents=True, exist_ok=False) +for name in ['bin/busybox', 'bin/ck-ota-start', 'bin/ck-setup-start', + 'bin/ck-setup-backend', 'lib/ld-uClibc.so.0', + 'lib/libc.so.0', 'lib/libgcc_s.so.1']: + path = output / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(read_file(root, name)) + path.chmod(0o700) +PY +docker run --rm --network none \ + -v "$PWD:/repo:ro" \ + -v "$PWD/private/wifi-startup-repro:/audit" \ + incipio-public-build:v0.1.0-preview \ + python3 /repo/reverse/tools/audit_wifi_startup.py \ + --tree /audit --output /audit/results.json +``` + +上述命令可重現公開版 8 案例;第 9 個私人版對照需自行持有 v0.2.4 映像提取的三份腳本,並以 `--old-tree` 指定容器可見的提取目錄。重現工具不包含任何實體 key 或私人映像。 diff --git a/docs/2026-10-21_firmware-led-report.md b/docs/2026-10-21_firmware-led-report.md new file mode 100644 index 0000000..3539dc2 --- /dev/null +++ b/docs/2026-10-21_firmware-led-report.md @@ -0,0 +1,51 @@ +# LED 控制與夜間排程實作驗證 + +日期:2026-10-21。版本:v0.2.11-preview,software version 13。報告類型:一般韌體整合,flavor = null。 + +## 結果 + +已實作六種指示燈模式、15 秒尋找、家庭協議辨識、夜間熄燈與 NTP/手機校時。HomeKit、Matter 共用登入後的面板及持久設定。LED 改由獨立程序處理,原協議仍各自獨占 MCU。原生與 MIPS 更新/管理測試各 55 項通過;部署測試 12 項通過、1 項歷史診斷版跳過。原機尚待使用者以 OTA 安裝驗收。 + +授權及時間線見 [scope](led-analysis/scope.md)、[timeline](led-analysis/timeline.md)。使用方式、API 及燈號順序見[操作文件](led-control.md)。 + +## Evidence + +| ID | 來源與觀察 | 可重現方式 | +|---|---|---| +| E-imports | [原廠 ELF 證據](../verification/led-imports-v0.2.11.json),MIPS-I BE o32、207 個 imports;read、GPIO/Identify 函式與既有反組譯相符 | 從固定公開基底提取 `bin/homekitserver`,以 `rabin2 -I`、`rabin2 -ij` 檢查 | +| E-hook | [HomeKit hook](../verification/homekit-led-v0.2.11-results.json),在原廠 loader/libc 執行讀取橋接、回應觀察與辨識信箱;可完整還原原 ELF | `python3 tests/test_homekit_led.py --output verification/homekit-led-v0.2.11-results.json` | +| E-tests | [映像驗證](../verification/led-release-v0.2.11-results.json),原生/MIPS 各 55 項,包括輸入邊界、CSRF、跨午夜、提示順序、保存與慢校時子程序 | 於 Linux 執行 `python3 tests/test_updates.py -v`;MIPS 使用 `CK_TEST_RUNNER=qemu-mips`、`CK_TEST_BIN` 指向測試產物 | +| E-reset | [原廠重置](../verification/homekit-factory-v0.2.11-results.json),實際交付 ELF 的 `-c` 僅清除合成 pairing 範圍;禁止 MIB 寫入,保留出廠尾段 | `tests/test_homekit_factory.py --release-dir` 指向本版,另指定已建置 toolchains/deps 路徑 | +| E-mcu | [完整系統模擬](../verification/led-mcu-vm-v0.2.11-results.json),10 次模擬短按、超過 60 秒缺身分時仍可控制;daemon AS 上限 8 MiB | `python3 tests/test_hardware_fallback.py --deps firmware/matter/deps --toolchains firmware/matter/toolchains` | +| E-ui | [瀏覽器操作紀錄](../verification/led-ui-v0.2.11-results.json),訪客隔離、儲存、手機校時及切協議後設定保留 | `CK_FIXTURE_LED=1 python3 tests/serve_admin_fixture.py`,使用合成測試帳號操作本機面板 | + +E-imports SHA-256:`c752370150ac6dba768f075607b31b1ac20dfc4dccfb7ca568e7024e3ddaed3f`。E-hook SHA-256:`6477f25288a914cd17ddee8d634830fd1f04081ec1724693aaa539ae1b84f874`。其餘映像與測試日誌的雜湊保存在對應 JSON;不包含私人 Flash 或裝置設定碼。 + +## Findings 與 Path + +- F-LED(validated,confidence high,evidence_ids E-imports/E-hook/E-tests):Matter 原有直接 GPIO 寫入已移除;原廠 Identify/GPIO6 初始化寫入由限定雜湊補丁停用,既有 GPIO0/5 與重置流程保留。Path:協議的既有 MCU 回應 → RAM 狀態信箱 → `ck-updater --run-led` → GPIO6。 +- F-clock(validated,confidence high,evidence_ids E-tests/E-ui):排程使用 UTC 錨點及 monotonic 推進,校時不變更系統時間。DNS/UDP 位於期限 12 秒的子程序,失敗重試不阻塞 LED/管理事件迴圈。NTP 測試使用本機 UDP 回覆,驗證來源、nonce、stratum 與失敗結果;慢子程序另驗證繼電器狀態觀察仍繼續。Path:NTP 或登入後手機 UTC → RAM 錨點 → 本地時差與跨午夜判斷 → 平常燈光抑制。 +- F-preserve(validated,confidence high,evidence_ids E-tests/E-reset):設定只新增 `config/led`,一般儲存/協議切換/OTA 保留身分、配對、Wi-Fi 及 kWh。恢復出廠依既有契約清除 LED 偏好。Path:已驗證管理操作 → 現有 journal 原子保存 → 開機載入。 + +## 驗證範圍 + +| 檢查 | 結果 | +|---|---| +| 原生 Linux 管理/儲存/OTA | 55 通過 | +| 實際 MIPS updater/HTTP | 55 通過 | +| 部署/保留範圍/ELF 復原 | 12 通過,1 個未交付診斷變體跳過 | +| MCU 引擎 | 574 檢查、100 個命令通過 | +| 啟動 guard/回復檢查 | 6/9 項通過 | +| HomeKit 讀取 hook/重置 | 通過,使用原廠 binary 與合成資料 | +| 瀏覽器管理面板 | 通過;合成裝置,非原機 | +| 原機 RF/MFi/LED 實際表現 | 待 OTA 安裝後驗收 | + +整機模擬使用 Malta/Linux 4.19、64 MiB,application AS 上限 8 MiB;目標實機為 RTL8196E、32 MiB、Linux 2.6.30。補跑時 32 MiB 的測試 kernel 在進入 userspace 前沒有主控台輸出,改用既有 64 MiB 測試環境;這不作為實機記憶體驗收。模擬未提供實體 Flash 或網路介面。 + +映像只能寫入既有 Bank1 rootfs 範圍;公開組裝及簽章核對、保留區全 FF 與憑證排除由部署/公開稽核再次檢查。交付保留既有 Ed25519 信任來源。HomeKit 自訂網頁量測及 kWh 累計仍沿用先前的暫停行為;此次 hook 僅供燈號判斷。 + +## 後台畫面 + +以下為隔離測試環境,在 HomeKit 模式保留恆滅與夜間排程的畫面。 + +![指示燈與夜間熄燈](../verification/led-ui/homekit-led-panel.png) diff --git a/docs/admin-backend-analysis/scope.md b/docs/admin-backend-analysis/scope.md new file mode 100644 index 0000000..00e36bf --- /dev/null +++ b/docs/admin-backend-analysis/scope.md @@ -0,0 +1,7 @@ +# v0.2.9 工作範圍 + +日期:2026-10-20。目標為使用者自有 CMNDKT-004 的 HomeKit/Matter 共用預覽韌體。 + +授權工作:修正切換後管理入口、初次設定密碼、訪客/登入後台、kWh 歸零、實體重置。使用者明確選擇恢復出廠包含清除 Wi-Fi 及 kWh;連同管理密碼、兩套配對一起清除,保留出廠校正與硬體身分。可修改來源、建置、執行離線及模擬測試、產生簽章附件。 + +本輪未執行:實機 LAN 設定/重置、CH341A/SPI 寫入、市電操作、無線干擾、Git push 或對外發布。內部 exec-test 未遷移密碼初設,不交付新版附件。實機恢復需另確認本次斷電、3.3 V、同顆晶片及穩定讀取條件。 diff --git a/docs/admin-backend-analysis/timeline.md b/docs/admin-backend-analysis/timeline.md new file mode 100644 index 0000000..87879c7 --- /dev/null +++ b/docs/admin-backend-analysis/timeline.md @@ -0,0 +1,10 @@ +# 實作與驗證紀錄 + +2026-10-20(Asia/Taipei),此紀錄只列本輪事件,不推定實機結果。 + +1. 確認五項管理需求;使用者選擇長按完整恢復出廠,包含 Wi-Fi 與 kWh。 +2. 將 HTTP 管理服務改為獨立 PID1 service,加入密碼 enrollment、challenge/session/CSRF 及登入後設定。 +3. 增加 owner 內電量清零、確認長按事件與 durable factory intent,修補原廠 HAP reset CLI 的 MIB 身分改寫。 +4. 原生/MIPS 各 48 項、SDK 配對/清零/重置、原廠 HAP 模擬重置、BusyBox PID1/DHCP/port 80 轉址驗證通過。 +5. 真正 C HTTP backend 的瀏覽器測試找到並修復全域 JavaScript 名稱衝突;重測登入、設定可見性與登出。 +6. 產生 v0.2.9 簽章 OTA,核對實際 payload 及部署保存範圍。原機驗收待完成。 diff --git a/docs/admin-backend.md b/docs/admin-backend.md new file mode 100644 index 0000000..fc8927b --- /dev/null +++ b/docs/admin-backend.md @@ -0,0 +1,60 @@ +# 使用插座管理後台 + +v0.2.11-preview 新增兩種協議共用的[LED 模式、尋找插座與夜間熄燈](led-control.md),設定與校時操作需登入。 + +v0.2.9-preview(software version 11)在 HomeKit 與 Matter 模式共用同一個管理後台。**首次從沒有管理密碼的舊版升級,會進入設定熱點,需要建立管理密碼並重新輸入 Wi-Fi。**已有管理密碼後,後續 OTA 保留密碼及 Wi-Fi。 + +## 初次設定與登入 + +1. 連接 `CommandKit-Setup-…`,開啟 `http://192.168.1.254/`。 +2. 保存這顆插座的 Matter 設定碼,輸入並確認管理密碼,再設定 2.4 GHz WPA2 Wi-Fi。密碼至少 8 個字元,最多 128 UTF-8 bytes;沒有預設密碼,也不需要下載管理金鑰檔。 +3. 從路由器取得插座 IP。開啟 `http://插座IP/` 會轉到 `http://插座IP:18082/`。 +4. 未登入可看即時資訊、韌體版本及目前協議。按「管理員登入」後才顯示管理設定;登入有效約 15 分鐘,逾時需重新登入。 + +HomeKit 使用原廠標籤上的配對碼。Matter 使用首次設定產生的裝置專屬碼。管理密碼只用來登入本機後台,與這兩種家庭配對碼不同。 + +忘記管理密碼時,可實體長按至裝置重啟,恢復出廠後重設;**這也會清除 Wi-Fi、家庭配對及累積 kWh**。沒有保留資料的免密碼遠端重設入口。 + +## 管理設定 + +| 操作 | 行為 | +|---|---| +| 協議切換 | Matter/HomeKit 單選,保存後重啟;管理網址保持相同,DHCP 換 IP 時使用新位址 | +| Wi-Fi | 輸入新的名稱與密碼,重新連線;HomeKit 首次配網完成會重啟,使原廠服務讀取新狀態 | +| 開啟 Matter 配對視窗 | 保留已完成配對,清理未完成流程並重新開啟約 15 分鐘 | +| 清除目前協議的配對 | 只清除該協議的家庭配對,保留 Wi-Fi、密碼、裝置身分與 kWh | +| 更新韌體 | 登入後上傳/套用簽章 OTA,仍核對版本、平台及簽章 | +| 變更密碼 | 新密碼保存成功後,所有管理工作階段失效,需重新登入 | +| 累積用電歸零 | 確認後立即保存零值,之後重新累計;保留家庭配對及其他設定 | +| 恢復出廠 | 清除管理密碼、Wi-Fi、兩套協議配對、累積用電及協議偏好,重新進入設定熱點 | + +短按仍切換繼電器。實體長按門檻由原廠 MCU 決定;Matter 收到確認事件 `AA FF FF 55` 才要求恢復出廠,前置事件 `AA FF 00 55` 不清除設定。HomeKit 長按使用原廠事件處理,再呼叫共同重置工具。完整重置保留 MAC、校正、出廠 HomeKit/MFi、每顆 Matter 裝置身分及 OTA 信任來源,不複製別顆插座的憑證。 + +HomeKit 模式仍由原廠程式獨占 MCU,**自訂網頁的 V/A/W 與 kWh 累計暫停**;頁面可顯示最後保存的 kWh,也能將其歸零。切回 Matter 後從保存值繼續,不回補 HomeKit 使用期間的電量。 + +## 本機登入與 API + +後台使用 HTTP 區網連線,沒有 TLS;請在受信任區網與自己的設定環境使用。密碼不寫入 URL、日誌或使用者下載檔。首次設定與變更時,瀏覽器推導並提交 60-byte credential;資料具有登入等同權限,0600 權限與 Flash 保護仍有必要,不能宣稱可防止區網中間人或實體 Flash 擷取。 + +密碼推導為 PBKDF2-HMAC-SHA256、600,000 次、每顆隨機 16-byte salt。參考 [OWASP 密碼儲存說明](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)及 [RFC 8018](https://www.rfc-editor.org/rfc/rfc8018.html)。LAN HTTP 沒有 SubtleCrypto 時,在 Web Worker 執行相同推導。登入使用一次性 120 秒 challenge、HMAC proof、來源 IP 綁定、有限次數節流;cookie 為 HttpOnly/SameSite=Strict,變更操作另需 CSRF header。純 HTTP 的 cookie 沒有 Secure 屬性。 + +公開 GET 為 `/`、`/password.js`、`/status`、`/measurements`、`/protocol`、`/auth/session`、`/auth/challenge`。`POST /auth/login` 完成驗證。其他 POST(包含 `/upload`、`/apply`)都需有效 session 與 `X-CK-CSRF`。`GET /admin/state` 及 `/admin/onboarding` 需登入。舊診斷 HMAC 不能繞過密碼修改設定;舊金鑰檔匯出已停用。 + +管理服務由 PID1 獨立監督,不等待身分匯入、Matter/HomeKit 或配對完成。Flash 忙碌時公開頁仍可回應,管理操作拒絕而不降級為免登入。OTA、協議交接、Wi-Fi 改動與重置互斥。 + +完整恢復出廠先保存 durable intent,重啟後依序清除 HomeKit pairing、Wi-Fi、電量,最後原子清除設定 journal;每一步失敗都保留 intent,下次開機繼續。重置完成前不啟動另一個協議。這是可重試的邏輯清除,並非全片安全抹除。 + +## 電腦工具 + +以下新介面會在終端提示管理密碼,不使用 `--profile`: + +```bash +python3 tools/setup_control.py matter-status --host 192.168.1.50 +python3 tools/setup_control.py open-commissioning --host 192.168.1.50 --wait +python3 tools/setup_control.py reset-matter --host 192.168.1.50 --wait +python3 tools/update.py 192.168.1.50 --package artifacts/releases/v0.2.9-preview/matter.ota +``` + +`setup_control.py --profile` 僅保留給舊版 HMAC 介面。新版請使用後台修改 Wi-Fi。`update.py --check-only` 不登入、不上傳、不重啟。 + +本版只交付 `matter` 種類的 HomeKit/Matter 共用韌體,未交付尚未遷移初次密碼設定的內部 `exec-test` 診斷版。原廠 HomeKit/MFi、實際 RF、按鍵門檻及斷電恢復仍須原機驗收;軟體、MIPS 與 SDK 測試見[驗證報告](2026-10-20_firmware-admin-report.md)。 diff --git a/docs/automatic-identity.md b/docs/automatic-identity.md new file mode 100644 index 0000000..e410459 --- /dev/null +++ b/docs/automatic-identity.md @@ -0,0 +1,39 @@ +# 公開韌體的自動身分 + +v0.2.5/software_version 7 的公開首次安裝包不含裝置配對金鑰、診斷金鑰或既有配對碼。同一份 BIN 可安裝到符合條件的插座,各台第一次開機自行產生並保存身分。此功能沒有改變 SDK 範例 attestation 的性質,不等同產品認證或生產 DAC 憑證。 + +## 第一次設定 + +1. 依[原廠網頁安裝指南](factory-install.md)刷入公開首次安裝檔。 +2. 等待插座收集隨機資料、保存身分後,連接 `CommandKit-Setup-…`。等待期間實體按鍵保持可用。 +3. 開啟 `http://192.168.1.254/`,保存 Matter QR/設定碼,建立管理密碼,再完成 Wi-Fi 設定;v0.2.9 不需管理金鑰檔。 +4. 重開機與標準 OTA 都沿用原金鑰、設定碼及配對。舊版透過 OTA 升級保留原身分;首次升級至 v0.2.9 密碼版例外,需要建立管理密碼並重新輸入 Wi-Fi。 + +v0.2.9 的管理方式改為[管理密碼與後台](admin-backend.md),舊管理金鑰匯出已停用。裝置內仍保留自己的診斷身分,供既有唯讀診斷相容用途;一般使用者不用下載或保管金鑰檔。管理密碼與 Matter 裝置身分分開保存,沒有通用預設密碼。 + + +## 隨機來源及提交 + +初始化器從 `/dev/random` 累積讀滿 32 bytes,再以 HMAC-SHA256 與不同用途字串衍生兩組金鑰。production 必須使用 major 1/minor 8 的 character device,拒絕 symlink、一般檔案和測試種子環境變數。MAC 只參與後續 Matter 資料推導,不用作秘密種子。 + +此作法依賴原廠舊內核的 entropy accounting。Linux 2.6.30 的 `/dev/random` 限制輸出為內核估算可用的 entropy,`/dev/urandom` 沒有相同限制,見[該版本原始碼](https://github.com/torvalds/linux/blob/v2.6.30/drivers/char/random.c)。這不是對原廠硬體亂數品質的獨立認證;原機收集時間仍待驗收。資料不足時在背景等待,不退回固定、MAC、時間種子或過早的 urandom。 + +部分已讀資料只保留在初始化行程的 RAM。等待不阻塞原廠網路初始化或 MCU worker;設定頁及診斷服務等身分提交完成才啟動。初始化仍進行時,原有 60 秒身分等待限制不會讓 MCU worker 退出。 + +## 斷電與損壞 + +公開安裝容器在第二個 journal slot 放入非秘密的 `CKAUTOINIT-V1` 記號,表示這次首次安裝明確初始化了該區;它不是金鑰。沒有此記號時,不會清除未知/損壞資料來建立新身分。 + +配對金鑰、診斷金鑰、OTA 公鑰及自動初始化標記一起提交到第一個 snapshot。完成 checksum、readback 與 commit,再撤銷第二槽的初始化記號並核對,才輸出 0600 RAM 金鑰檔、產生 QR 及啟動設定頁。 + +- commit 前中斷:尚未對外提供身分,下次可重新初始化。 +- commit 後、撤銷記號前中斷:下次沿用已提交的同一組金鑰。 +- 身分可用之後:初始化權限已撤銷;journal 損壞會拒絕網路身分啟動,保留 MCU 控制,不會悄悄換碼。 + +雙 snapshot、配對與 kWh 保存方式繼續沿用。第一次配網成功才保存 `identity/onboarded`;之前若斷電,重開仍進設定模式。 + +## 公開與更新 + +可公開 `*-public-first-install.bin`、簽章 `.ota` 和 OTA 公鑰;簽章私鑰由維護者保存,不放入映像。已使用的 Matter 插座請用較新的 `.ota` 更新,因為重刷首次安裝檔會清除 journal 中的配對與累積電量。 + +驗證見[自動身分結果](../verification/automatic-identity-results.json)、[公開包 OTA](../verification/public-factory-ota-results.json)及[原廠 Boa/bootloader](../verification/public-factory-boa-results.json)。離線/VM 結果不能代替原機首次寫入、亂數品質、RF 與斷電驗收。 diff --git a/docs/factory-install.md b/docs/factory-install.md new file mode 100644 index 0000000..2b3b1fb --- /dev/null +++ b/docs/factory-install.md @@ -0,0 +1,53 @@ +# 從原廠網頁首次安裝 Matter + +本流程限定 CMNDKT-004、原廠 `10219-1531`、Active Bank **2**,且 **Enable Dual Firmware 已勾選**。v0.2.5 的 `*-public-first-install.bin` 不含預置裝置金鑰,同一份檔案可供符合條件的插座使用。每顆首次開機自行產生並保存身分;v0.2.4 私人安裝檔仍僅供原指定插座使用。 + +已通過原廠 Boa/bootloader MIPS 離線執行與後續 OTA fixture 驗證;**尚未在另一顆原廠插座完成實際首次寫入、driver/重開機驗收**。保留第二槽不能保證應用當機時自動回退。 + +## 上傳檔案 + +1. 插座裝回外殼、沒有燒錄器/UART/外接線,先不接負載。手機/電腦與插座在同一網路。 +2. 開啟原廠的 `/upload.htm` 更新頁,再核對上述版本、Active Bank 2 和勾選狀態。保留雙韌體設定。 +3. 在頁面上方 **Select File → Upload** 選擇公開的 `*-public-first-install.bin`。實際 multipart 上傳端點是 `/boafrm/formUpload`,欄位為 `binary`。 +4. 等頁面完成倒數/重開;完成寫入與重新連線前保持供電。不要另外按下「Reboot Form Backup Bank Now」。 + +`/boafrm/formDualFirmware` 處理雙槽設定/切換,沒有接收韌體檔案的分支。`matter.ota`、`matter-matter-ota.bin`、`matter-8m-region.bin` 都不能代替本次首次安裝檔。 + +## 開機後設定與更新 + +公開版首次開機會等待內核提供足夠隨機資料,再原子保存整組身分。等待期間 MCU 保持按鍵控制,設定熱點可能延後出現;原機收集時間尚待驗收。之後正常重開不會重新產生身分。 + +首次初始化完成後會進入 `CommandKit-Setup-…`,即使原廠曾存過 Wi-Fi。連接後開啟 `http://192.168.1.254/`,先保存新的 Matter QR/設定碼,並建立管理密碼(v0.2.9 起不需管理金鑰檔),再完成 Wi-Fi 設定。原廠 HomeKit 碼不適用。 + +連上家中網路後,從路由器查詢 IP,開啟 `http://插座IP/` 查看狀態,再以管理密碼登入變更 Wi-Fi、重設配對或更新。需要完整重新設定時,實體長按恢復出廠會同時清除密碼、Wi-Fi、配對及 kWh,見[管理後台](admin-backend.md)。 + +後續請到 `:18082/` 上傳比目前版本更新的簽章 `matter.ota`。線上來源目前若只有舊版,顯示沒有新版本屬正常;不會自動降版。首次安裝檔會初始化持久資料區,**不要拿來更新已使用中的 Matter 插座**。 + +## 寫入與保留範圍 + +| Flash 範圍(結尾不含) | 行為 | +|---|---| +| `[0x000000,0x010000)` | 保留 bootloader、MIB、MAC/無線設定 | +| `[0x010000,0x130000)` | 寫入已驗證的 10219 kernel,更新外層長度、checksum 與開機標記 | +| `[0x130000,0x3C0000)` | 寫入公開 Matter rootfs | +| `[0x3C0000,0x3E0000)` | 首次初始化 journal 與非秘密的初始化記號,供身分、配對、OTA 與 kWh 使用 | +| `[0x3E0000,0x800000)` | 保留原廠資料、完整 Bank 2 與尾端 factory/校正區 | + +kernel 的原載入器與壓縮資料保持一致,解壓結果逐 byte 相同。首次安裝採完整 kernel 範圍寫入,使舊版 kernel 的殘留 padding 不影響後續平台核對。 + +## 產生另一台的安裝檔 + +先建置 v0.2.5 以上來源。`--factory-template` 使用已驗證的公開首次安裝 BIN,只取固定 hash 的 kernel,不複製模板 rootfs 或任何裝置設定;不需要私人 Flash 備份。維護者也可改用 `--reference` 提供符合 `third_party/platform.json` 的本機參考映像。工具不會讀取或寫入目標插座,目標版本與槽位仍須另行核對。 + +```bash +python3 tools/factory_image.py \ + --public \ + --factory-template artifacts/previous-public-first-install.bin \ + --release artifacts/releases/v0.2.5-preview/matter-8m-region.bin \ + --manifest artifacts/releases/v0.2.5-preview/matter-8m-region.json \ + --output artifacts/commandkit-public-first-install.bin +``` + +公開模式不需要 profile。若使用私人模式,將 `--public` 改成 `--profile private/new-outlet-factory`;私人輸出仍含單台金鑰,不能當公開檔。簽章私鑰在兩種模式都不會包含於安裝檔。 + +自動產生與保存細節見[自動身分說明](automatic-identity.md);原廠格式分析見[首次安裝報告](2026-10-19_firmware-boa-first-install-report.md)。 diff --git a/docs/factory-install/scope.md b/docs/factory-install/scope.md new file mode 100644 index 0000000..d8be1fa --- /dev/null +++ b/docs/factory-install/scope.md @@ -0,0 +1,10 @@ +# 首次安裝包範圍 + +- auth:使用者於 2026-10-19 要求「包一版」可透過原廠網頁安裝的韌體;指定另一顆仍為原廠韌體的 CMNDKT-004。 +- in_scope:本機原廠映像/反編譯分析、離線 MIPS 執行、建立個別安裝檔與驗證、提供使用說明。 +- target_state:使用者回報 `10219-1531`、Active Bank 2、Enable Dual Firmware 已勾選;未透過網路讀取目標。 +- network_profile:測試容器使用 `--network none`;本次未對插座送出設定或寫入請求。 +- out_of_scope:實體燒錄、目標插座 Flash 寫入、遠端重開機、變更目前另一顆已使用插座、完整自動回退保證。 +- flavor:null,普通韌體格式/更新流程逆向,非漏洞或惡意程式報告。 + +2026-10-20 使用者另外要求實作首次開機自動產生金鑰,範圍擴充為公開共用安裝檔、持久身分初始化、管理金鑰下載及離線/MIPS/VM 驗證;仍未執行原機更新/重開。 diff --git a/docs/factory-install/timeline.md b/docs/factory-install/timeline.md new file mode 100644 index 0000000..6b8b663 --- /dev/null +++ b/docs/factory-install/timeline.md @@ -0,0 +1,8 @@ +# 2026-10-19 工作紀錄(Asia/Taipei) + +1. 讀取原廠 upload.htm、Boa handler table、更新/雙槽函式及 imports,確認兩個端點用途不同。 +2. 使用者指定另一顆原廠插座,回報 10219-1531、Active Bank 2 及雙韌體已啟用。 +3. 建立 v0.2.4/software_version 6;新增專用安裝檔封裝及嚴格 factory kernel 平台核對。 +4. 以原廠 MIPS Boa 執行檔驗證格式與 fwd 寫入規劃,再以原廠 bootloader 執行選槽/checksum。 +5. 用實際 MIPS 更新器、regular-file NOR fixture 驗證首次匯入、版本確認、下一版簽章 OTA、身分保存及破損拒絕。 +6. 產生一顆插座專用的私人 BIN、checksum、指南;未連線或寫入插座。 diff --git a/docs/flashing.md b/docs/flashing.md index ea882d8..5cc968c 100644 --- a/docs/flashing.md +++ b/docs/flashing.md @@ -4,6 +4,8 @@ v0.2 首次安裝後可使用[簽章 OTA](updates.md),後續更新不需再次拆機。v0.1 沒有永久 OTA 入口。 +原廠 `10219-1531`/Active Bank 2/雙韌體已啟用的 CMNDKT-004,另有[網頁首次安裝包](factory-install.md);目前通過離線驗證,原廠機首次寫入驗收尚未完成。 + ## 接線與前提 1. 插座與市電完全斷開。不得一邊接市電一邊接 USB 燒錄器/UART。 @@ -40,7 +42,7 @@ python3 tools/flash.py prepare \ --profile private/outlet-a --output private/matter-target.bin ``` -此步不接觸硬體。它產生新的私人診斷金鑰、Matter 身分金鑰,重打包 rootfs、保留 special device nodes/symlink metadata,重算 Realtek checksum,再用當次備份補回其餘區域。輸出完整私人目標映像及 `.bin.json`。 +此步不接觸硬體。已有 journal 身分時沿用原診斷與 Matter 金鑰,只有沒有既有身分的新安裝才產生新金鑰;接著重打包 rootfs、保留 special device nodes/symlink metadata,重算 Realtek checksum,再用當次備份補回其餘區域。輸出完整私人目標映像及 `.bin.json`。 同一插座後續更新使用相同 `--profile`,以保留其配對碼來源。另一台使用另一個 profile。不要公開 profile 或合成映像;更換/遺失身分金鑰會改變設定碼。診斷版把檔名改成 `exec-test-8m-region.bin`/`.json`,其設定熱點帳密會另存在 profile 的 `setup-ap.json`。 @@ -73,3 +75,5 @@ python3 tools/flash.py restore \ ``` 復原同樣取得兩份當次備份,要求原備份的 kernel/Bank 2 相容,只取回原備份中的 Bank 1 rootfs。當次 MAC、校正、MIB 與其他保留區維持當次值。這不是空白晶片初始化或 bootloader 修復工具。 + +v0.2.10 的 host 工具另支援已固定 hash 的 Boa 首次安裝 kernel wrapper,與 production OTA verifier 採相同嚴格條件。只正規化其 boot mark;程式、長度、padding、checksum 及 Bank 2 仍完整核對,kernel 不會被寫入。較新程式可接續保留的舊 OTA pending 狀態,仍需正常健康確認,見[復原報告](2026-10-20_firmware-programmer-recovery-report.md)。 diff --git a/docs/led-analysis/scope.md b/docs/led-analysis/scope.md new file mode 100644 index 0000000..d144b78 --- /dev/null +++ b/docs/led-analysis/scope.md @@ -0,0 +1,7 @@ +# LED 控制實作範圍 + +使用者於 2026-10-21 明確要求實作已確認的 LED/夜間排程計畫,並產生 v0.2.11-preview 簽章 OTA。授權範圍是本機來源、公開原廠 10219 基底、隔離 VM/NOR fixture、編譯及封裝。實機升級與 LED/MFi 驗收待完成目前 v0.2.10 開機確認後進行。 + +保留原裝置的身分、HomeKit 配對碼、Wi-Fi、配對與 kWh;測試僅使用合成資料。不公開私人映像、金鑰、真實設定碼。 + +使用者同日追加授權:將已驗證韌體上傳 GitHub,部署線上更新索引,之後由使用者在原機後台執行 OTA。發布目標為 `lee98064/incipio-commandkit` 的 v0.2.11-preview。 diff --git a/docs/led-analysis/timeline.md b/docs/led-analysis/timeline.md new file mode 100644 index 0000000..0ed2947 --- /dev/null +++ b/docs/led-analysis/timeline.md @@ -0,0 +1,7 @@ +# 實作時間線 + +- 2026-10-21:確認預設智慧模式、恆滅保留必要提示、加入夜間排程及網路校時/手機備援。 +- 2026-10-21:檢查公開 10219 HAP ELF/207 個 imports;read、GPIO、Identify 入口與現有反組譯相符。 +- 2026-10-21:建立統一 LED 程序、共享 RAM 狀態、HomeKit 觀察 hook、管理 API 與面板。 +- 2026-10-21:新增原生/MIPS 模式、保存、校時、過期讀值與 OTA 燈態測試;實機尚未操作。 +- 2026-10-21:公開 v0.2.11-preview、核對 GitHub 附件雜湊與 Pages 更新索引;工作流程 39460447519 成功,線上 OTA 簽章及 CORS 驗證通過。由使用者接續原機 OTA。 diff --git a/docs/led-control.md b/docs/led-control.md new file mode 100644 index 0000000..d785d63 --- /dev/null +++ b/docs/led-control.md @@ -0,0 +1,65 @@ +# 設定插座指示燈 + +v0.2.11-preview 在 `http://插座IP:18082/` 的「管理員登入 → 管理設定 → 指示燈」提供控制。HomeKit 與 Matter 使用同一份設定,儲存後約一秒內套用。 + +| 模式 | 平常表現 | +|---|---| +| 智慧狀態(預設) | 跟隨插座開關,提示設定、連線、配對及異常 | +| 跟隨開關 | 插座開啟時亮、關閉時滅 | +| 反向指示 | 插座關閉時亮、開啟時滅 | +| 耗電指示 | 連續兩次有效功率達 5 W 時閃爍;兩次降至 2 W 以下停止 | +| 恆亮 | 持續亮燈 | +| 恆滅 | 平常熄燈,保留設定、連線、配對與異常提示 | + +3–4 W 保持先前負載判定;關閉插座或資料過期會停止耗電閃燈。讀值未知時,跟隨/反向模式不會假裝已知插座狀態。 + +「尋找插座」會閃燈 15 秒,再恢復模式。HomeKit 辨識要求也閃燈 15 秒;Matter 辨識依控制器指定的持續時間執行。各模式都接受主動尋找。 + +## 辨認燈號 + +智慧及恆滅模式顯示以下提示,其餘模式只接受主動尋找。由上往下優先顯示: + +| 優先狀態 | 表現 | +|---|---| +| 主動尋找/家庭 App 辨識 | 每四分之一秒亮滅 | +| 服務/MCU 通訊異常 | 每三秒短閃三次 | +| 等待設定/設定熱點開啟 | 每半秒亮滅 | +| Wi-Fi 正在連線或重新連線 | 每兩秒短閃一次 | +| 可進行家庭配對 | 每兩秒短閃兩次 | + +開機後保留 60 秒啟動時間,再將缺少/過期的 MCU 回應視為異常。Matter 根據實際配對視窗;HomeKit 根據原廠配對紀錄及 MCU 回應。 + +## 夜間熄燈與校時 + +勾選「每天固定時段熄燈」,設定開始與結束時間,再按「儲存燈號設定」。支援跨午夜,預填 23:00–07:00;首次安裝預設關閉排程。夜間抑制平常燈光,保留目前模式原本允許的必要提示與主動尋找。 + +展開「排程時間與校時」: + +- 台灣使用 UTC +8;可調整為 -12 至 +14 小時,每 15 分鐘一格。夏令時間由使用者調整。 +- 預設 NTP 伺服器為 `pool.ntp.org`,可改為區網主機名稱或 IPv4 位址。 +- 排程啟用且 Wi-Fi 上線後自動校時,成功後每六小時重試校準,失敗隔一分鐘重試。 +- IoT 網路不能連外時,登入後按「使用此手機時間校準」。 +- 每次開機等有效時間才執行排程;等待時仍依平常模式與提示運作。成功校準後,本次開機可離線繼續計時。校時不改變系統時鐘。 + +設定與累積 kWh 分別保存;切換協議及一般 OTA 會保留。恢復出廠會清除燈號偏好,回到智慧模式、排程關閉。 + +## 管理 API + +以下介面需有效管理 session;POST 另需 `X-CK-CSRF`,沿用既有同來源檢查與操作互斥。 + +| 方法/路徑 | 用途與內容 | +|---|---| +| `GET /admin/led` | 讀取模式、排程、校時狀態、服務存活、當前燈號原因 | +| `POST /admin/led` | 文字格式 `CKLED1 MODE ENABLED START END UTC_OFFSET NTP_HOST`;時間與時差單位為分鐘 | +| `POST /admin/led-find` | 空內容,尋找 15 秒 | +| `POST /admin/led-time` | 十進位 Unix UTC 秒數,僅校準本次開機的 LED 排程 | + +例如 `CKLED1 smart 1 1380 420 480 pool.ntp.org`。MODE 為 `smart`、`relay`、`inverse`、`load`、`on`、`off`。成功回傳 200;輸入無效 400、未登入或 CSRF 無效 401、其他操作進行中 409、儲存未就緒 503。`GET` 回傳 `service_ready=false` 代表尚未收到近期工作程序狀態,不能當作實體燈號已驗證。 + +## 實作與驗證 + +PID1 監督 `ck-led-start`,執行既有靜態 updater 的 `--run-led`。只有此程序設定 GPIO6;GPIO6 為低電位亮燈。Matter 發佈已確認的 MCU 狀態,HomeKit 的限定雜湊 hook 觀察原廠既有讀取,透過 RAM 信箱發佈。串列埠保持單一協議擁有者。 + +NTP 在具 12 秒期限的獨立子程序處理 DNS/UDP。主程序持續運作,校時失敗不會等待在按鍵、MCU、Wi-Fi 或 HTTP 事件迴圈。簽章 OTA 排入後保持最後 GPIO 電位,經 RAM updater 寫入、讀回與重啟後,由新版重新接手;最後電位不代表更新成功。 + +詳見[實作驗證報告](2026-10-21_firmware-led-report.md)。目前交付為預覽版;實際 LED、原廠 MFi 與 RF 操作另需原機驗收。 diff --git a/docs/matter-reset.md b/docs/matter-reset.md new file mode 100644 index 0000000..181fd52 --- /dev/null +++ b/docs/matter-reset.md @@ -0,0 +1,19 @@ +# 重新開啟配對與重設 + +v0.2.9 的操作均從[管理後台](admin-backend.md)登入,不需管理金鑰檔。 + +「重新開啟 Matter 配對視窗」保留已完成的 fabrics,清理未完成的 PASE/fail-safe,開啟約 900 秒配對視窗。「清除目前協議的配對」在 Matter 模式移除 fabrics、ACL、操作金鑰、session resumption 與持久屬性,再重啟 Matter 並開啟視窗。Wi-Fi、管理密碼、裝置身分與累積 kWh 保留;原家庭平台的舊配件項目需自行移除後重新加入。 + +```bash +python3 tools/setup_control.py matter-status --host 192.168.1.50 +python3 tools/setup_control.py open-commissioning --host 192.168.1.50 --wait +python3 tools/setup_control.py reset-matter --host 192.168.1.50 --wait +``` + +工具提示管理密碼。HTTP 202 只表示排入佇列;`--wait` 比對此次 request ID 與完成狀態。HomeKit 模式的配對清除經原廠 CLI 執行,再重啟。 + +**實體長按與遠端清除配對不同:長按恢復出廠,連同管理密碼、Wi-Fi、兩套配對和 kWh 一併清除。**短按只切換繼電器。MCU 的前置 `AA FF 00 55` 不重置;確認 `AA FF FF 55` 才保存出廠重置要求並重啟。按壓秒數由原廠 MCU 決定,尚待原機驗收,不能由模擬事件推定。 + +完整出廠重置在開機時執行,可在中斷後繼續;保留 MAC、校正、原廠 MFi/身分與 OTA 信任來源。Matter owner 即使正在等候身分或網路,也會處理確認長按事件。新版遠端變更入口移至 TCP 18082,舊 TCP 18081 的 HMAC POST 不能繞過密碼。 + +驗證見 [SDK/MIPS 結果](../verification/admin-matter-vm-results.json)、[原廠 HAP 重置結果](../verification/homekit-factory-v0.2.9-results.json)與[完整報告](2026-10-20_firmware-admin-report.md)。v0.2.6 的舊行為及原機紀錄留在歷史驗證檔,不能視為本版實機通過。 diff --git a/docs/ota-release-notes.md b/docs/ota-release-notes.md index 515bf48..e77d93e 100644 --- a/docs/ota-release-notes.md +++ b/docs/ota-release-notes.md @@ -1,10 +1,46 @@ -v0.2.3 在原有 `http://插座IP:18082/` 管理頁新增即時電壓、電流、功率與估算累積 kWh;每 2 秒更新,無效或過期讀值顯示「—」,有效零值保留為 0。累積電量顯示已保存的總量,約每 30 秒更新。頁面下方保留 OTA 更新入口,無需先配對 Apple 家庭即可查看。 +## v0.3.0-preview(software version 14)— 全新管理介面與設定精靈 + +- **管理後台改版**:即時功率量表與近 2 分鐘走勢、電壓/電流/累積用電卡片,分頁導覽(手機為底部分頁列),支援深色模式。 +- **自動檢查更新**:開啟 `http://插座IP:18082/` 會自動向更新索引查詢新版;有新版時總覽頁顯示提示,登入後一鍵下載並更新,過程分步顯示下載、驗證、安裝、重新啟動。仍可手動選擇或拖放 `.ota`。 +- **燈號與配對**:六種 LED 模式改為卡片並附燈號預覽,夜間熄燈加上 24 小時時間軸;Matter 設定碼分組顯示,可一鍵複製。 +- **設定熱點精靈**:首次設定改為三步驟——選擇 Wi-Fi、建立管理密碼、加入家庭;附近網路以清單顯示訊號強度與加密方式,送出後直接顯示配對 QR Code 與後續步驟。 + +裝置 API、OTA 簽章、更新索引網址與保留範圍都沒有變。一般 OTA 保留裝置身分、原廠 HomeKit 配對碼、Wi-Fi、管理密碼、家庭配對與累積 kWh。本版屬預覽版,原機安裝驗收由使用者執行。 + +## v0.2.10-preview — CH341A 復原接續 + +## v0.2.11-preview(software version 13) + +新增 HomeKit/Matter 共用的六種 LED 模式、尋找插座、夜間熄燈,以及 NTP/手機校時備援。LED 由獨立程序統一控制;HomeKit hook 保留原 MCU 擁有者與既有重置修補。操作見[指示燈設定](led-control.md),測試與原機驗收界線見[驗證報告](2026-10-21_firmware-led-report.md)。 + +較新程式在啟動時可接續舊 OTA 待確認版本,保留原有健康確認、重試及版本 floor,不清除配對、金鑰或 kWh。host 復原工具加入與 production OTA verifier 相同的已知 Boa kernel wrapper 檢查;kernel 本身仍不寫入。原生/MIPS 各 50 項與指定 wrapper 測試通過。保留 v0.2.9 的五項管理功能,詳見[復原報告](2026-10-20_firmware-programmer-recovery-report.md)。 + +## v0.2.9-preview — 密碼管理後台與完整重置 + +兩種協議都保留獨立管理服務,連接 `http://插座IP/` 會轉到後台。訪客可看狀態,登入才顯示管理設定;首次設定建立密碼,停止管理金鑰檔下載。OTA、協議/Wi-Fi 變更、清除配對、kWh 歸零和恢復出廠都需 session 與 CSRF。實體長按會清除 Wi-Fi、管理密碼、兩套配對與 kWh,保留出廠身分/校正;確認事件以外不清除。重置先保存 intent,開機失敗可重試。 + +首次從無管理密碼的舊版升級需重新進入設定熱點建立密碼及設定 Wi-Fi。HomeKit 模式的自訂即時量測與 kWh 累計仍暫停,可查看及歸零保存值。本版只交付 HomeKit/Matter 共用韌體,不發布尚未遷移密碼初設的內部診斷版。MIPS/SDK/原廠 HAP fixture 測試通過範圍見[管理報告](2026-10-20_firmware-admin-report.md),原機驗收尚待完成。 + +以下為歷史版本紀錄,涉及金鑰檔及長按的舊行為已由 v0.2.9 取代。 + +v0.2.7 修復原廠 BusyBox 缺少 `touch` 導致身分匯入前中止的開機錯誤,移除 `pidof` 依賴,並將 Wi-Fi/DHCP 回連與 Matter 身分初始化拆開。PID1 分別監督金鑰匯入、配對資料產生、網路控制器及診斷服務;初始化有期限與重試,控制器有 40 秒心跳監看。已保存的 Wi-Fi 短暫中斷時重試 DHCP,不自動覆寫帳密切換 AP;管理金鑰及完整配對資料尚未就緒時,管理 API 回覆 503。原廠 BusyBox PID1/DHCP 與 production MIPS 控制器的開機、crash/hang/DHCP 重啟測試通過;實體安裝驗收待完成,見 `docs/2026-10-20_firmware-wifi-recovery-report.md`。 + +## v0.2.8-preview — 協議選擇 + +新增 HomeKit/Matter 單選面板、經管理金鑰驗證的切換 API,以及開機時的單一 MCU 擁有者。持久設定讀回成功後才回覆成功,重啟時才交接;切換不清除配對、Wi-Fi、金鑰或已保存的 kWh。HomeKit 沿用原廠服務及每顆自己的原廠身分,暫停自訂即時量測和 kWh 累計。移除原廠 Boa/路由器 GUI,所有後續更新共用 `:18082/`。保留 v0.2.7 Wi-Fi 啟動修復。詳見[操作及限制](protocol-switching.md)。原機 HomeKit/MFi/RF 驗收未完成;未自動發布或寫入裝置。 + + +保留 v0.2.6 的經管理金鑰驗證的 Matter 清除/開啟配對視窗,以及 MCU 長按事件的 Matter 重設。清除保留 Wi-Fi、裝置身分、管理金鑰、OTA 信任來源與累積估算 kWh;完成後開啟 15 分鐘配對視窗。重設重啟 Matter 程式並確認插座輸出 OFF。使用方式及驗證範圍見 `docs/matter-reset.md`。 + +v0.2.5 的公開 `*-public-first-install.bin` 不含預置裝置金鑰;每顆首次開機自行產生、原子保存並顯示自己的 Matter 碼。設定頁可下載管理金鑰,重開及 OTA 沿用原身分。隨機來源不足時在背景等待,實體按鍵仍可操作。首次安裝仍限定原廠 `10219-1531`/Active Bank 2/雙韌體啟用,原機首次寫入及開機待驗收,詳見 `docs/factory-install.md` 與 `docs/automatic-identity.md`。 + +保留 v0.2.3 在 `http://插座IP:18082/` 的即時電壓、電流、功率與估算累積 kWh;每 2 秒更新,無效或過期讀值顯示「—」,有效零值保留為 0。累積電量顯示已保存的總量,約每 30 秒更新。頁面下方保留 OTA 更新入口,無需先配對 Apple 家庭即可查看。 量測快照由同一個 MCU owner/Matter 程式在 RAM 發布,網頁不直接開啟串列或寫入 Flash。原有 v0.2.2 的 `wlan0`、啟動順序、舊 MTD 相容性與 MCU 控制退路修復繼續保留。 此系列提供簽章 OTA、區網管理頁、線上版本檢查、Matter OTA Requestor,以及設定/fabrics 持久儲存。 -首次安裝仍需以原燒錄工具將支援 OTA 的韌體寫入 Bank 1;後續使用 `.ota` 更新包。`matter-8m-region.bin` 是部分燒錄映像,必須本機個別化,禁止整片覆蓋。`matter-matter-ota.bin` 是提供 Matter OTA Provider 的標準外層容器。 +符合條件的原廠機可另外準備網頁首次安裝檔,其餘情況使用原限定區域燒錄流程;後續使用 `.ota` 更新包。`matter-8m-region.bin` 是部分燒錄映像,必須本機個別化,禁止整片覆蓋。`matter-matter-ota.bin` 是提供 Matter OTA Provider 的標準外層容器。 保留原廠 Bank 2、bootloader、MAC、校正、HomeKit/MFi 與出廠資料。自訂 journal 使用 Bank 1 rootfs 尾端預留 128 KiB;遇到未辨識的既有資料會拒絕初始化。 diff --git a/docs/privacy.md b/docs/privacy.md index cef5d74..c847589 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -4,7 +4,7 @@ 公開 BIN 大小為 8 MiB,只有 `[0x130000,0x3C0000)` 是 rootfs。其餘 bytes 是 `0xFF`,包含低位 bootloader/硬體設定、default/current MIB、journal、HomeKit、Bank 2、factory/校正/production identity。這些區域不是公共部署時的寫入內容。 -rootfs 中的 `matter-diag.key`、`matter-device.key`、設定 AP 憑證、既有 QR/manual code 均移除。新服務從自己的本機 profile 取得部署時新生成的資料,沒有原裝置金鑰或共享配對碼 fallback。缺少 Matter 金鑰/有效身分時拒絕啟動。 +rootfs 中的 `matter-diag.key`、`matter-device.key`、設定 AP 憑證、既有 QR/manual code 均移除。v0.2.5 公開首次安裝包會在各台第一次開機時產生並保存金鑰,見[自動身分](automatic-identity.md)。私人 profile 個別化方式繼續支援;沒有共享配對碼 fallback。缺少有效身分或隨機資料時不會啟動 Matter 配對。 部署後的完整目標映像含使用者自己的保留資料及新金鑰,必須留在私人目錄。部分燒錄不是把 MAC 改成另一個通用 MAC,而是保留該台原值。 @@ -23,3 +23,7 @@ rootfs 中的 `matter-diag.key`、`matter-device.key`、設定 AP 憑證、既 `check_public.py` 檢查公開容器格式、checksum、保留區填充值、解壓後憑證檔案缺席、公開檔案列表及常見可識別字串。發布者另外對原私人憑證做精確 bytes 比對;私人值不寫入公開掃描設定或報告。 之後新增截圖、診斷或測試證據仍需單獨檢查。公開包掃描通過只對本次已記錄 hash 的檔案成立。 + +## v0.2.9 管理密碼 + +公開 rootfs 不含 `admin/credential`、預設管理密碼或實機 session。密碼設定及 salt 在各顆裝置本地產生,credential 保存於私人 journal,屬登入等同憑證;不進入 OTA 或公開 profile。測試的 `FixtureAdmin42`、模擬設定碼與 localhost 截圖是合成資料,從未寫入實機。恢復出廠為邏輯清除,不宣稱安全抹除 Flash 歷史頁。對外發布前仍需掃描 source 與解壓附件。 diff --git a/docs/programmer-recovery-analysis/scope.md b/docs/programmer-recovery-analysis/scope.md new file mode 100644 index 0000000..e9d9151 --- /dev/null +++ b/docs/programmer-recovery-analysis/scope.md @@ -0,0 +1,7 @@ +# v0.2.10 CH341A 復原範圍 + +日期:2026-10-20,Asia/Taipei。使用者授權以 CH341A 復原自有、目前無法從網路存取的 CMNDKT-004,並明確要求保留這顆的 HomeKit 配對碼等專屬資訊。使用者已確認本次斷開市電、移除其他供電、3.3 V 與夾具接回同顆 GD25Q64;接觸異常後依回覆重試。 + +可執行:唯讀 JEDEC 辨識、完整備份、離線檢查、修正復原相容性、產生簽章版本、限定 Bank 1 rootfs 寫入與全片讀回。寫入範圍固定 `[0x130000,0x3C0000)`。禁止整片覆蓋、清除配對或 kWh、生成替代裝置身分、實機恢復出廠。不得在燒錄器接線仍存在時接市電。 + +本次不執行長按重置驗收,因新版長按會清除使用者要求保留的資料。網路、短按及開機驗收需在完整移除外接線、裝回外殼後進行;軟體預演不能取代原機結果。 diff --git a/docs/programmer-recovery-analysis/timeline.md b/docs/programmer-recovery-analysis/timeline.md new file mode 100644 index 0000000..7e310eb --- /dev/null +++ b/docs/programmer-recovery-analysis/timeline.md @@ -0,0 +1,14 @@ +# 復原紀錄 + +2026-10-20,Asia/Taipei。 + +1. 使用者確認本次接線條件,要求保留 HomeKit 配對碼等專屬資訊。 +2. 初始 JEDEC 回傳 FF,工具停止,未擦除或寫入。重夾後一度可辨識,但第一組兩份備份不一致。 +3. 差異集中第一份 kernel;下一份通過校驗。追加兩份完整備份一致,並與前一份有效讀取相同。確認裝置身分吻合且無待執行重置。 +4. 確認原廠 OTA 首次安裝 kernel wrapper 已由 production OTA verifier 支援,但 host 復原工具尚未支援相同嚴格替代格式。補上 host 檢查。 +5. 確認舊版 OTA 保留 pending version 8、floor 6,會使較新版程式的健康確認失敗。v0.2.10(version 12)加入新版接續狀態,沿用健康確認與 rollback floor。 +6. 原生/MIPS 各 50 項、host wrapper 3 項接受與 11 項拒絕、部署 12 項(診斷版未交付,1 項略過)通過。 +7. 用實際備份的私人副本執行 MIPS 更新器預演:簽章/kernel 相容、匯入、協議 boot、pending 接續成功;其他 journal key、電量與出廠範圍不變。 +8. 實體寫入與開機結果另由當次私人 session 及公開驗證摘要記錄。完整備份、目標映像、金鑰與 HomeKit 資料不公開。 + +9. Bank 1 rootfs 寫入及內建 verify 通過,再獨立讀回 8 MiB 與完整目標一致;HomeKit pairing/PIN、MIB/MAC、kernel、journal/kWh、Bank 2、factory/校正逐 byte 未變。已請使用者移除接線、裝回外殼後進行短按與設定熱點驗收,未執行長按。 diff --git a/docs/protocol-switching.md b/docs/protocol-switching.md new file mode 100644 index 0000000..959fc93 --- /dev/null +++ b/docs/protocol-switching.md @@ -0,0 +1,23 @@ +# 選擇 HomeKit 或 Matter + +v0.2.9-preview(software version 11)在兩種模式都保留獨立管理服務。首次密碼設定、管理登入及完整重置方式見[管理後台](admin-backend.md)。原廠 HomeKit 的 MFi、RF 及 iPhone 配對尚待實機驗收。 + +## 切換協議 + +1. 開啟 `http://插座IP/` 或 `http://插座IP:18082/`,按「管理員登入」。 +2. 到「管理設定 → 智慧家庭協議」,選擇 Matter 或 HomeKit,按「儲存並重新啟動」。 +3. 保持供電。重啟後重新登入同一網址;DHCP 更換位址時,使用路由器顯示的新 IP。 + +每次只啟動一套協議。切換保留管理密碼、Wi-Fi、各自配對、Matter 身分與已保存 kWh。HomeKit 使用這顆插座自己的原廠標籤配對碼與 MFi 資料;Matter 使用裝置專屬碼。另一協議停用期間,家庭 App 可能顯示裝置沒有回應。 + +管理服務、配網及 DHCP 不依賴協議服務,HomeKit 模式也能回到後台選擇 Matter。HomeKit 啟動失敗不自動啟動 Matter,避免兩個背景程序搶用 MCU;偏好或 journal 無法驗證時,進入維修/實體控制模式。 + +## 量測、重置與更新 + +Matter 模式提供網頁 V/A/W/kWh 和標準 Matter 電力量測。HomeKit 模式使用原廠程式獨占 MCU,自訂即時量測及 kWh 累計暫停,顯示保存值;切回 Matter 後續計,不回補中間電量。不能因此保證 Apple「家庭」會顯示電壓等資訊。 + +兩種模式均可登入後清除目前協議配對或將 kWh 歸零。**實體長按是完整恢復出廠,會清除 Wi-Fi、密碼、兩套配對與 kWh**,保留出廠身分及校正。這取代 v0.2.8 的 HomeKit 長按只重啟行為。 + +後續更新共用簽章 `matter.ota` 與後台;HomeKit 模式沒有 Matter OTA Requestor,使用網頁更新。原廠 Boa/路由器網頁已移除。OTA、Wi-Fi 重連、重置與協議切換互斥;同一協議重複保存不重新啟動或擦寫 Flash。 + +已離線的舊版無法自行取得這份 OTA。CH341A 復原仍需當次斷電及 3.3 V 接線確認、兩次一致完整讀取,再只更新 Bank 1 rootfs `[0x130000,0x3C0000)`。公開 8 MiB 映像不能全片寫入;已有 journal 的插座不可使用會初始化 journal 的首次安裝包。 diff --git a/docs/updates.md b/docs/updates.md index 1b31ff0..a07a4c1 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -1,12 +1,20 @@ # 安裝更新與發布新版 +v0.2.11-preview(software version 13)加入[六種 LED 模式及夜間熄燈](led-control.md),使用相同管理後台與 OTA 簽章來源,保留既有裝置身分、配對與 kWh。 + +v0.2.9-preview(software version 11)改為[密碼管理後台](admin-backend.md),HomeKit/Matter 兩種模式共用管理入口。訪客能看狀態,更新、切換協議及重置必須登入。實體長按完整恢復出廠,會清除 Wi-Fi、管理密碼、兩套配對與 kWh。**從沒有管理密碼的舊版首次升級,會進入設定熱點,需建立密碼並重新輸入 Wi-Fi**;已有密碼後的更新會保留資料。本版只產生 HomeKit/Matter 共用的 `matter` 附件,不發行尚未遷移密碼初設的 `exec-test`。 + +v0.2.7-preview(software version 9)修復原廠 BusyBox 不提供 `touch`/`pidof` 的啟動相容性,並讓已保存 Wi-Fi 的 DHCP 回連獨立於 Matter 身分初始化。服務由 PID1 監督,具備初始化期限、崩潰重啟、控制器心跳及 DHCP 存活檢查,見[修復驗證](2026-10-20_firmware-wifi-recovery-report.md)。金鑰與配對資料就緒前,管理 API 回覆 503;既有 station 回連不寫 Wi-Fi MIB。若舊版已無法啟動網路/更新服務,必須先恢復可用的管理或限定區域寫入入口,無法只靠離線裝置上的 OTA URL 自行安裝。 + +保留 v0.2.6 的[保留 Wi-Fi/金鑰/累積 kWh 的 Matter 重設及重新開啟配對視窗](matter-reset.md),可由登入後的管理頁或電腦工具使用;實體長按改為完整出廠重置。使用本機簽章 `matter.ota` 更新;新版本的原機安裝仍須驗收。 + v0.2.3-preview 在管理頁新增即時 V/A/W/估算累積 kWh,並保留區網網頁更新、線上更新索引、Ed25519 簽章與 Matter OTA Requestor。v0.2.2 的舊 MTD 同步、`wlan0` 設定介面及身分產生的啟動順序修復繼續保留。Matter fabrics、平台設定及裝置身分存入 Flash journal。**第一次仍須以 CH341A 安裝支援 OTA 的版本;v0.1.0-preview 沒有永久更新入口。**原機斷電、RF 及家庭平台完整驗收尚待完成;修復檢查見[修復驗證](../verification/recovery-results.json),電量檢查見 [v0.2.1 驗證](../verification/energy-results.json)。 ## 在插座網頁更新 1. 首次依 [燒錄說明](flashing.md) 安裝新的 `matter-8m-region.bin` 或 `exec-test-8m-region.bin`,沿用同一台插座的 profile。 2. 配網後,從路由器取得插座 IPv4 位址,在瀏覽器開啟 `http://插座IP:18082/`;即時監測與更新共用此頁。 -3. 按「檢查新版本」及「下載並更新」,或選擇已下載的 `.ota`,按「驗證並更新」。 +3. 按「管理員登入」,進入管理設定後,按「檢查新版本」及「下載並更新」,或選擇已下載的 `.ota`,按「驗證並更新」。 4. 等候簽章核對、安裝、重開及健康確認。保持供電;更新暫停 Matter 控制,現有啟動邏輯會要求 MCU 確認 OFF。 相同或較舊版本、不同韌體種類、錯誤機型、平台 hash 不符、截斷及簽章錯誤都會拒絕。不能把 8 MiB BIN 當成 `.ota`,也不能透過 OTA 在 Matter 與診斷版之間切換。 @@ -30,7 +38,7 @@ python3 tools/update.py "$OUTLET_IP" \ --package artifacts/releases/v0.2.1-preview/matter.ota --check-only ``` -安裝 v0.2.3-preview(`software_version=5`)後,後續更新的 `software_version` 必須大於 5 才能安裝。`--check-only` 不傳送更新,也不重新啟動。 +後續更新的 `software_version` 必須大於目前確認版本;例如 v0.2.9 的數值是 11。工具在實際上傳前提示管理密碼,密碼不放入 argv 或下載檔。`--check-only` 不傳送更新,也不重新啟動。 ## 更新包與保留範圍 @@ -52,6 +60,8 @@ journal 為兩個 64 KiB slots;各 slot 前 60 KiB 為設定 snapshot,最後 | key 前綴 | 資料 | |---|---| +| `admin/` | 隨機 salt、密碼推導參數及登入等同 credential | +| `config/` | 協議偏好、需要重新設定標記 | | `identity/` | 裝置身分、診斷金鑰、更新信任公鑰、診斷版 AP 憑證 | | `matter/` | fabrics、ACL、金鑰、session resumption、持久屬性 | | `platform/` | SDK factory/config/counters/KVS 的 INI 內容,單筆上限 16 KiB | @@ -63,7 +73,7 @@ SDK 原本寫入 `/tmp/*.ini` 的平台設定也會同步保存至 journal。此 ## 安裝與復原流程 -區網內可直接上傳及安裝,不需要 diag.key、HMAC、nonce 或額外操作授權。上傳完成後才驗證整包;管理頁及電腦工具會附上所選 package 的 checksum,避免套用不同的暫存檔。BusyBox PID 1 的固定 restart action 執行 RAM 中的靜態更新器;它停止其他使用者行程、切換 RAM root 並卸離舊 rootfs,才開寫。先使 rootfs magic 無效,寫入 body,最後補回第一個 sector,讀回 hash 與 journal 後重開。 +v0.2.9 的上傳及安裝均需管理密碼登入後取得 session 與 CSRF token;不需要 diag.key 或管理金鑰檔。上傳完成後才驗證整包;管理頁及電腦工具會附上所選 package 的 checksum,避免套用不同的暫存檔。BusyBox PID 1 的固定 restart action 執行 RAM 中的靜態更新器;它停止其他使用者行程、切換 RAM root 並卸離舊 rootfs,才開寫。先使 rootfs magic 無效,寫入 body,最後補回第一個 sector,讀回 hash 與 journal 後重開。 Matter 必須連續 30 秒取得新鮮 MCU 狀態才確認新版本;診斷版確認網路及兩個服務已啟動。應用未通過健康檢查時,獨立管理頁保留維修入口;連續三次未確認啟動後不再啟動 Matter。可以安裝較新的修正版。 @@ -75,7 +85,7 @@ Matter 必須連續 30 秒取得新鮮 MCU 狀態才確認新版本;診斷版 維護者的私鑰保存在自己的 `private/ota-signing.key`,公鑰是 `third_party/ota-signing.pub`。發布既有裝置更新時必須沿用原金鑰;不要重新執行 keygen。fork 的維護者須自行產生金鑰,替換其來源公鑰,並首次安裝自己的信任來源。 -1. 修改 `version.json`,增加 `software_version` 並更新版本名稱;例如數值 6、名稱 `0.3.0-preview`。 +1. 修改 `version.json`,增加 `software_version` 並更新版本名稱;例如數值 8、名稱 `0.3.0-preview`。 2. 執行完整建置;工具也會產生 Matter 執行檔的版本紀錄,拒絕混用舊執行檔或測試 backend: ```bash @@ -95,7 +105,7 @@ python3 tools/release.py --private private/ota-signing.key \ - PR 執行 native C/HTTP/儲存測試與瀏覽器 SHA 對照。 - 發布 tag 後,再建置工具鏈、測試 MIPS 更新器與 Linux PID 1 路徑、產生簽章附件並建立 **草稿 pre-release**。 -- 人工發布 Release 後,另一個 workflow 驗證簽章,把兩種 `.ota` 與索引部署至 Pages。較舊版本不能覆蓋較新的索引。 +- 人工發布 Release 後,另一個 workflow 驗證簽章,把此次 Release 的 `.ota` 與索引部署至 Pages。較舊版本不能覆蓋較新的索引。 預設索引為 `https://lee98064.github.io/incipio-commandkit/updates/index.json`。尚未發布更新時,網頁會說明來源未就緒,手動上傳仍可使用。首次設定 Pages 不代表附件已經發布。 @@ -117,9 +127,11 @@ TCP 18082,只綁定 `br0`,Host 必須為該連線的 IPv4 與 port;有 Ori |---|---| | `GET /` | 自包含管理頁,不載入第三方 JavaScript | | `GET /status` | 版本、種類、維修及健康狀態,不含金鑰 | -| `POST /upload` | 接收完整 `.ota`,驗證並暫存 RAM,不寫 rootfs | -| `POST /apply` | 排程 PID 1 安裝,可附所選 package hash 核對 | +| `POST /upload` | 登入與 CSRF 驗證後接收完整 `.ota`,驗證並暫存 RAM,不寫 rootfs | +| `POST /apply` | 登入與 CSRF 驗證後排程 PID 1 安裝,可附所選 package hash 核對 | -不需要更新憑證。`X-CK-SHA256` 是可選的傳輸 checksum;管理頁與電腦工具自動附上,上傳時對應 body,apply 時對應先前核對的完整 package。未提供 checksum 的區網用戶端仍可直接更新,但更新包簽章、版本、機型、平台與長度檢查不會省略。服務另檢查來源 IP、Host、Origin、重複 header 及固定長度。原有診斷和 Wi-Fi 設定服務仍使用其原認證。 +需要有效管理 session 及 `X-CK-CSRF`;`X-CK-SHA256` 是可選的傳輸 checksum;管理頁與電腦工具自動附上,上傳時對應 body,apply 時對應先前核對的完整 package。已登入但未提供 checksum 的用戶端仍可更新,但更新包簽章、版本、機型、平台與長度檢查不會省略。服務另檢查來源 IP、Host、Origin、重複 header 及固定長度。舊診斷 HMAC 的變更 POST 已停用,不能繞過管理登入。 `.ota` 的前 256 bytes 為 big-endian header:magic 0、format 8、model 12、variant 16、版本 20、payload 長度 24、保留 flags 28、payload SHA-256 32、kernel SHA-256 64、Bank 2 SHA-256 96、公鑰 128、NUL 結尾版本名稱 160、Ed25519 signature 192。簽章涵蓋前 192 bytes,payload hash 因而受到簽章保護;後面接固定 2,686,976-byte rootfs。 + +HomeKit 模式的健康確認使用原廠 daemon PID、listener 與網路狀態連續約 30 秒存活;不等同 MCU 確認或實機配對驗收。切換實作及證據見[協議切換報告](2026-10-20_firmware-protocol-switch-report.md)。 diff --git a/docs/wifi-startup-analysis/scope.md b/docs/wifi-startup-analysis/scope.md new file mode 100644 index 0000000..09b5052 --- /dev/null +++ b/docs/wifi-startup-analysis/scope.md @@ -0,0 +1,9 @@ +# Wi-Fi 回連分析範圍 + +日期:2026-10-20,Asia/Taipei。 + +授權來源:使用者要求研究目前由原廠通道安裝、再升級的自製韌體,哪些情況會造成開機後無法回連 Wi-Fi。 + +分析對象為本專案 v0.2.4 私人首次安裝映像、已提交的 v0.2.6 簽章 OTA,以及對應來源碼。只在本機離線映像與隔離的暫存 fixture 執行故障注入、MIPS 模擬及比較;不對其他用戶端探測、不寫入插座 Flash、不更改 UniFi 設定。本報告不公開真實 SSID、裝置金鑰、配對碼或 Flash 備份。 + +成功條件是指出可驗證的啟動失敗路徑,並分清「可重現的設計缺口」與「實體插座此次故障原因」。沒有更新後的開機日誌,不能宣告後者已確定。 diff --git a/docs/wifi-startup-analysis/startup.dot b/docs/wifi-startup-analysis/startup.dot new file mode 100644 index 0000000..ce9faeb --- /dev/null +++ b/docs/wifi-startup-analysis/startup.dot @@ -0,0 +1,22 @@ +digraph Startup { + graph [rankdir=LR, bgcolor="white", pad="0.2", nodesep="0.35", ranksep="0.5"]; + node [shape=box, style="rounded,filled", fillcolor="#eef3fa", color="#536780", fontname="Helvetica", fontsize=11]; + edge [color="#536780", fontname="Helvetica", fontsize=10]; + boot [label="rcS boot"]; + wifi [label="Vendor Wi-Fi association"]; + mcu [label="Independent MCU worker\nLocal button can remain active"]; + import [label="Import persistent identity"]; + helper [label="Matter identity helper"]; + setup [label="Setup controller"]; + dhcp [label="Resume backend\nDHCP client launch", fillcolor="#e4f4e9"]; + stalled [label="Pending forever / early exit\nNo DHCP launch or AP recovery", fillcolor="#fce5e5", color="#b74848"]; + boot -> wifi; + boot -> import; + boot -> mcu; + import -> helper [label="success"]; + helper -> setup [label="success"]; + setup -> dhcp [label="startup checks pass"]; + import -> stalled [label="hang / import failure", color="#b74848"]; + helper -> stalled [label="failure / crash", color="#b74848"]; + setup -> stalled [label="startup failure", color="#b74848"]; +} diff --git a/docs/wifi-startup-analysis/startup.svg b/docs/wifi-startup-analysis/startup.svg new file mode 100644 index 0000000..19f59de --- /dev/null +++ b/docs/wifi-startup-analysis/startup.svg @@ -0,0 +1,124 @@ + + + + + + +Startup + + + +boot + +rcS boot + + + +wifi + +Vendor Wi-Fi association + + + +boot->wifi + + + + + +mcu + +Independent MCU worker +Local button can remain active + + + +boot->mcu + + + + + +import + +Import persistent identity + + + +boot->import + + + + + +helper + +Matter identity helper + + + +import->helper + + +success + + + +stalled + +Pending forever / early exit +No DHCP launch or AP recovery + + + +import->stalled + + +hang / import failure + + + +setup + +Setup controller + + + +helper->setup + + +success + + + +helper->stalled + + +failure / crash + + + +dhcp + +Resume backend +DHCP client launch + + + +setup->dhcp + + +startup checks pass + + + +setup->stalled + + +startup failure + + + diff --git a/docs/wifi-startup-analysis/timeline.md b/docs/wifi-startup-analysis/timeline.md new file mode 100644 index 0000000..c27d7f1 --- /dev/null +++ b/docs/wifi-startup-analysis/timeline.md @@ -0,0 +1,10 @@ +# 分析順序 + +以下均為 2026-10-20;順序號只表示證據取得順序,不代表精確時刻。 + +1. 綜合先前實體觀察:按鍵仍可切換;UniFi 顯示 Wi-Fi 關聯,SSID 與 VLAN 對應一致,但租約表未找到對應 MAC。顯示的舊 IP 不能視為更新後成功 DHCP 的證據。 +2. 從實際 v0.2.4 首次安裝 BIN 與 v0.2.6 OTA 提取啟動腳本及執行檔,驗證 rootfs checksum,逐檔比較 SHA-256。 +3. 使用 rabin2 6.2.2 核對 MIPS ELF metadata/imports,追蹤 rcS、身分匯入、設定控制器與 DHCP 呼叫順序。 +4. 使用映像內的 BusyBox ash,在隔離 Linux 容器中重放實際腳本,完成 9 個正常/故障對照案例。 +5. 執行提取的 production MIPS 身分初始化程式,完成 5 個正常/缺檔/介面失敗案例;相同測試身分的新舊配對資料一致。 +6. 重跑舊身分不需亂數的 native/MIPS 測試及既有 7 個啟動測試,保存去識別化結果與報告。未對實體插座再提交更新或配對清除。 diff --git a/firmware/homekit-led/src/bridge.S b/firmware/homekit-led/src/bridge.S new file mode 100644 index 0000000..1331550 --- /dev/null +++ b/firmware/homekit-led/src/bridge.S @@ -0,0 +1,21 @@ +.set noreorder +.set mips1 +.text +.globl ck_hap_read_bridge +.ent ck_hap_read_bridge +ck_hap_read_bridge: + addiu $sp,$sp,-24 + sw $ra,20($sp) + sw $gp,16($sp) + lui $gp,0x62 + addiu $gp,$gp,-0x5cf0 + lw $t9,-0x6588($gp) + nop + jalr $t9 + nop + lw $gp,16($sp) + lw $ra,20($sp) + nop + jr $ra + addiu $sp,$sp,24 +.end ck_hap_read_bridge diff --git a/firmware/homekit-led/src/hook.c b/firmware/homekit-led/src/hook.c new file mode 100644 index 0000000..d3cfddc --- /dev/null +++ b/firmware/homekit-led/src/hook.c @@ -0,0 +1,60 @@ +/* Hash-bound 10219 HAP observer. All UART reads remain in the original MCU + * thread. No UART opens, relay writes, GPIO access or Flash access here. */ +typedef unsigned int u32; +typedef unsigned long long u64; +extern long linux_call(long,long,long,long,long,long,long); +extern int ck_hap_read_bridge(int,void *,unsigned); +#define P(x) ((long)(x)) +#define S3(n,a,b,c) linux_call(n,P(a),P(b),P(c),0,0,0) +static u64 relay_at,power_at; +static u32 flags,power_mw,sequence; +static unsigned char window[2][4],count[2]; +static void put32(volatile unsigned char *b,u32 n){b[0]=n>>24;b[1]=n>>16;b[2]=n>>8;b[3]=n;} +static void put64(volatile unsigned char *b,u64 n){put32(b,(u32)(n>>32));put32(b+4,(u32)n);} +static u64 now_ms(void){long t[2];if(S3(4263,1,t,0)<0)return 0;return (u64)(u32)t[0]*1000+(u32)t[1]/1000000;} +static int atomic_ram(const char *tmp,const char *path,const volatile void *b,unsigned n){ + int fd=(int)S3(4005,tmp,0x101|0x200|0x80|0x20000,0600);if(fd<0)return -1; + long r=S3(4004,fd,b,n);S3(4006,fd,0,0);if(r!=(long)n)return -1;return (int)S3(4038,tmp,path,0); +} +static void publish(u64 now){ + volatile unsigned char b[56];unsigned j;for(j=0;j<56;j++)b[j]=0; + const char *magic="CKLEDHW1";for(j=0;j<8;j++)b[j]=(unsigned char)magic[j]; + u32 f=flags|16;volatile unsigned char *pairs=(volatile unsigned char *)0x00614c58u; + for(j=0;j<16;j++)if(pairs[j*0xa1]){f|=8;break;} + put64(b+8,now);put64(b+16,relay_at);put64(b+24,power_at);put32(b+40,power_mw);put32(b+44,f);put32(b+48,sequence); + atomic_ram("/var/run/ck-led-homekit.bin.tmp","/var/run/ck-led-homekit.bin",b,56); +} +/* Existing poll boundaries identify power responses; unsolicited reads can + * update confirmed relay state, including the physical short-press button. */ +static int observe_read(int fd,void *bytes,unsigned size,unsigned kind){ + int n=ck_hap_read_bridge(fd,bytes,size);if(n<=0 || n>(int)size || n>50)return n; + unsigned char *p=bytes;unsigned k=kind?1:0;int changed=0;u64 now=now_ms(); + for(int i=0;i(EEM::Feature::kImportedEnergy,EEM::Feature::kCumulativeEnergy), - {},mEnergyAccuracy,*this,timer}) { + EEM::ElectricalEnergyMeasurementCluster::OptionalAttributesSet().Set(),mEnergyAccuracy,*this,timer}) { // Nominal vendor bounds; no unverified calibration or accuracy percentage. const int64_t limits[] = {300000, 300000, 15000, 2000000}; for (unsigned i=0; i<4; ++i) { mRanges[i].rangeMin=0; mRanges[i].rangeMax=limits[i]; } @@ -94,3 +94,11 @@ CHIP_ERROR ElectricalMeasurements::SaveEnergy(bool force) { if(mPersistent && ck_energy_save(mCounter.TotalMwh(),mCounter.Remainder())) return CHIP_ERROR_PERSISTED_STORAGE_FAILED; mSavedMwh=mCounter.TotalMwh();mSavedRemainder=mCounter.Remainder();return CHIP_NO_ERROR; } +CHIP_ERROR ElectricalMeasurements::ResetEnergy() { + if(!mEnergyReady || (mPersistent && ck_energy_reset())) return CHIP_ERROR_PERSISTED_STORAGE_FAILED; + mCounter.Restore(0,0);mSavedMwh=0;mSavedRemainder=0;mFirstEnergyReport=true;mNextPublish=0; + mLastSaveAttempt=incipio::McuController::Now(); + EEM::Structs::CumulativeEnergyResetStruct::Type reset; + reset.importedResetSystime.SetValue(chip::app::DataModel::MakeNullable(mLastSaveAttempt)); + return mEnergyCluster.SetCumulativeEnergyReset(chip::app::DataModel::MakeNullable(reset)); +} diff --git a/firmware/matter/src/matter-app-device/ElectricalMeasurements.h b/firmware/matter/src/matter-app-device/ElectricalMeasurements.h index 4f21924..e2a2a1c 100644 --- a/firmware/matter/src/matter-app-device/ElectricalMeasurements.h +++ b/firmware/matter/src/matter-app-device/ElectricalMeasurements.h @@ -41,6 +41,7 @@ class ElectricalMeasurements final : public chip::app::Clusters::ElectricalPower explicit ElectricalMeasurements(chip::TimerDelegate & timer); CHIP_ERROR InitEnergy(bool persistent); CHIP_ERROR SaveEnergy(bool force=false); + CHIP_ERROR ResetEnergy(); Value GetCumulativeEnergyImported() override { return mEnergyReady ? chip::app::DataModel::MakeNullable(int64_t(mSavedMwh)) : Value{}; } Value GetCumulativeEnergyExported() override { return {}; } Value GetPeriodicEnergyImported() override { return {}; } diff --git a/firmware/matter/src/matter-app-device/Version.h b/firmware/matter/src/matter-app-device/Version.h index bd058d5..5f63dc3 100644 --- a/firmware/matter/src/matter-app-device/Version.h +++ b/firmware/matter/src/matter-app-device/Version.h @@ -1,3 +1,3 @@ #pragma once -#define CK_SOFTWARE_VERSION 5 -#define CK_VERSION_STRING "0.2.3-preview" +#define CK_SOFTWARE_VERSION 14 +#define CK_VERSION_STRING "0.3.0-preview" diff --git a/firmware/matter/src/matter-app-device/main.cpp b/firmware/matter/src/matter-app-device/main.cpp index b16b524..6fa97aa 100644 --- a/firmware/matter/src/matter-app-device/main.cpp +++ b/firmware/matter/src/matter-app-device/main.cpp @@ -10,6 +10,8 @@ #include #include #include +#include +#include #include #include #include "../relay/McuController.h" @@ -36,6 +38,9 @@ #include "ElectricalMeasurements.h" #include "MatterOta.h" #include "Version.h" +#include "../update/ck_common.h" +#include "../update/ck_matter_control.h" +#include "../update/ck_admin.h" static DeviceIdentityData deviceIdentity; using namespace chip; using namespace chip::app; @@ -156,7 +161,8 @@ class TestPlug final : public SingleEndpoint { public: TestPlug():SingleEndpoint(Span(plugTypes)){} CHIP_ERROR InitEnergy(bool persistent) { return measurements.InitEnergy(persistent); } - void SaveEnergy() { LogErrorOnFailure(measurements.SaveEnergy(true)); } + CHIP_ERROR SaveEnergy() { return measurements.SaveEnergy(true); } + CHIP_ERROR ResetEnergy() { return measurements.ResetEnergy(); } void Poll() { onoff.Poll(); measurements.Poll(hardware.Snapshot(),incipio::McuController::Now()); } CHIP_ERROR Register(EndpointId id,CodeDrivenDataModelProvider&model,EndpointComposition c={}) override { ReturnErrorOnFailure(RegisterDescriptor(id,model,c)); @@ -184,10 +190,105 @@ static DefaultSafeAttributePersistenceProvider safeAttributes; static std::unique_ptr model; static std::unique_ptr root; static TestPlug plug; +static char **restartArguments; +static char controlId[65] = "", controlAction = 0; +static const char *controlResult = "idle"; +static uint64_t controlDue = 0, controlDeadline = 0, windowDeadline = 0; +static uint64_t nextControlReport = 0, lastPhysicalReset = 0, physicalDue = 0; +static uint32_t seenResetEvents = 0; +static bool resetOnBoot = false; +static void ControlPublish() { + auto &s = Server::GetInstance(); + uint64_t now = incipio::McuController::Now(); + bool open = s.GetCommissioningWindowManager().IsCommissioningWindowOpen(); + char text[512]; + int n = std::snprintf(text,sizeof(text), + "{\"updated_s\":%llu,\"request_id\":\"%s\",\"action\":\"%s\",\"result\":\"%s\"," + "\"fabrics\":%u,\"window_open\":%s,\"window_seconds\":%u,\"failsafe_armed\":%s}\n", + static_cast(now/1000),controlId, + controlAction == 'R' ? "reset-matter" : controlAction == 'O' ? "open-commissioning" : controlAction == 'E' ? "reset-energy" : "none", + controlResult,unsigned(s.GetFabricTable().FabricCount()),open ? "true" : "false", + unsigned(open && windowDeadline > now ? (windowDeadline-now+999)/1000 : 0), + s.GetFailSafeContext().IsFailSafeArmed() ? "true" : "false"); + if (n > 0 && n < int(sizeof(text))) LogErrorOnFailure( + ck_atomic(CK_MATTER_STATE,text,n,0600) ? CHIP_ERROR_WRITE_FAILED : CHIP_NO_ERROR); +} +static void ControlFinish(const char *result) { + controlResult = result; controlDue = controlDeadline = 0; + unlink(CK_MATTER_REQUEST); rmdir(CK_MATTER_LOCK); + ControlPublish(); +} +static void PhysicalResetPoll() { + uint64_t now=incipio::McuController::Now();auto snapshot=hardware.Snapshot(); + if(snapshot.resetEvents!=seenResetEvents) { + seenResetEvents=snapshot.resetEvents;if(!physicalDue)physicalDue=now+500; + } + if(!physicalDue || now='0' && request[i]<='9') || (request[i]>='a' && request[i]<='f'); + unlink(CK_MATTER_REQUEST); + if (valid) { + controlAction=request[0];memcpy(controlId,request+2,64);controlId[64]=0; + controlResult="running";controlDue=now;controlDeadline=now+10000; + } else { rmdir(CK_MATTER_LOCK); } + } + } + if(controlDue && controlAction=='E') ControlFinish(plug.ResetEnergy()==CHIP_NO_ERROR?"complete":"storage-error"); + if (controlDue && controlAction=='R') { + // No SDK callbacks run between the durable purge and exec. This avoids + // restoring removed fabrics from live SDK caches, including fail-safe + // state from an interrupted PASE commissioning attempt. + if (plug.SaveEnergy()!=CHIP_NO_ERROR || ck_store_reset_matter(controlId)) { ControlFinish("storage-error"); } + else { + uint8_t stamp[8];ck_put64(stamp,now); + ck_atomic("/var/run/ck-matter-reset-cooldown",stamp,sizeof(stamp),0600); + controlResult="restarting";ControlPublish(); + std::puts("MATTER_ONLY_RESET_COMMITTED: Wi-Fi, device identity, OTA trust and kWh retained"); + hardware.Stop(); + for (int fd=3;fd<1024;++fd) close(fd); + execv("/bin/matterd-test",restartArguments); + std::fputs("MATTER_ONLY_RESET_EXEC_FAILED; receipt retained for next boot\n",stderr);_exit(1); + } + } + if (controlDue && controlAction=='O') { + auto &s=Server::GetInstance();auto &failsafe=s.GetFailSafeContext(); + if (strcmp(controlResult,"running")==0) { + s.GetCommissioningWindowManager().CloseCommissioningWindow(); + failsafe.ForceFailSafeTimerExpiry(); + controlResult="waiting-for-failsafe"; + } + if (failsafe.IsFailSafeFullyDisarmed()) { + CHIP_ERROR err=s.GetCommissioningWindowManager().OpenBasicCommissioningWindow( + System::Clock::Seconds32(CK_MATTER_WINDOW_SECONDS),CommissioningWindowAdvertisement::kDnssdOnly); + if (err==CHIP_NO_ERROR) { windowDeadline=now+CK_MATTER_WINDOW_SECONDS*1000;ControlFinish("complete"); } + else { LogErrorOnFailure(err);ControlFinish("window-error"); } + } else if (now>=controlDeadline) { ControlFinish("failsafe-timeout"); } + } + if (now>=nextControlReport) { ControlPublish();nextControlReport=now+1000; } +} static void PollHardware(System::Layer * layer, void *) { - if (stopping) { plug.SaveEnergy(); hardware.Stop(); _exit(0); } - plug.Poll(); + if (stopping) { LogErrorOnFailure(plug.SaveEnergy()); hardware.Stop(); _exit(0); } + PhysicalResetPoll();plug.Poll(); auto snapshot=hardware.Snapshot();uint64_t now=incipio::McuController::Now(); + ControlPoll(now,snapshot); if(!isVm&&!healthConfirmed){ if(!snapshot.known||now-snapshot.relayAt>10000)healthySince=0; else if(!healthySince)healthySince=now; @@ -204,6 +305,7 @@ static int Fail(CHIP_ERROR err,const char*where){ } #define TRY(e) do{CHIP_ERROR er=(e);if(er!=CHIP_NO_ERROR)return Fail(er,#e);}while(0) int main(int argc,char**argv){ + restartArguments=argv; setvbuf(stdout,nullptr,_IONBF,0);setvbuf(stderr,nullptr,_IONBF,0); if(argc==3&&strcmp(argv[1],"--probe")==0)return platform_probe_main(argc-1,argv+1); if(argc==3&&strcmp(argv[1],"--init-identity")==0) { @@ -226,8 +328,10 @@ int main(int argc,char**argv){ // Setup generates identity after its 15-second network startup delay. // Keep the MCU worker active while waiting; no pairing key fallback. bool ready = false; - for (unsigned attempt = 0; attempt < 240 && !stopping; ++attempt) { + for (unsigned attempt = 0; !stopping; ++attempt) { + PhysicalResetPoll(); if (LoadDeviceIdentity(deviceIdentity)) { ready = true; break; } + if (attempt >= 240 && access("/var/run/ck-identity-pending",F_OK) != 0) break; if (attempt == 0) std::puts("MCU_ACTIVE_WAITING_FOR_DEVICE_IDENTITY"); usleep(250000); } @@ -238,6 +342,7 @@ int main(int argc,char**argv){ } if (!vm) { for (;;) { + PhysicalResetPoll(); char state[64] = {}; int fd = open("/var/run/ck-setup-state", O_RDONLY); ssize_t n = fd < 0 ? -1 : read(fd, state, sizeof(state)-1); if (fd >= 0) { close(fd); } if (stopping) { hardware.Stop(); return 0; } @@ -246,6 +351,16 @@ int main(int argc,char**argv){ } } unsigned index=if_nametoindex(iface);if(index==0){std::fprintf(stderr,"interface unavailable\n");hardware.Stop();return 2;} + size_t resetSize=64; + int resetRead=ck_store_get(CK_MATTER_RESET_KEY,controlId,&resetSize); + if (resetRead==0 && resetSize==64) { + controlId[64]=0;resetOnBoot=true;controlAction='R';controlResult="restarting"; + // Platform KVS also has a RAM copy. Never fall back to its old contents + // after the journal transaction removed the operational namespace. + if (unlink("/tmp/chip_kvs") && errno!=ENOENT) { hardware.Stop();_exit(6); } + } else if (resetRead!=1 && (!vm || persistentVm)) { hardware.Stop();_exit(6); } + uint8_t cooldown[8]; + if (!ck_exact_file("/var/run/ck-matter-reset-cooldown",cooldown,sizeof(cooldown))) lastPhysicalReset=ck_be64(cooldown); TRY(Platform::MemoryInit());TRY(PlatformMgr().InitChipStack());TRY(identity.Init()); SetCommissionableDataProvider(&identity); Credentials::SetDeviceAttestationCredentialsProvider(Credentials::Examples::GetExampleDACProvider()); @@ -268,6 +383,14 @@ int main(int argc,char**argv){ params.dataModelProvider=model.get();params.interfaceId=Inet::InterfaceId(index); TRY(s.Init(params)); TRY(StartMatterOta()); + windowDeadline=incipio::McuController::Now()+CK_MATTER_WINDOW_SECONDS*1000; + if (resetOnBoot) { + auto &window=s.GetCommissioningWindowManager(); + window.CloseCommissioningWindow(); + TRY(window.OpenBasicCommissioningWindow(System::Clock::Seconds32(CK_MATTER_WINDOW_SECONDS),CommissioningWindowAdvertisement::kDnssdOnly)); + if (s.GetFabricTable().FabricCount()!=0 || ck_store_delete(CK_MATTER_RESET_KEY)) { hardware.Stop();_exit(6); } + ControlFinish("complete"); + } else { ControlPublish(); } SetupPayload payload;payload.version=0;payload.vendorID=0xFFF1;payload.productID=0x8000; payload.setUpPINCode=deviceIdentity.pin;payload.discriminator.SetLongValue(deviceIdentity.discriminator); payload.rendezvousInformation.SetValue(RendezvousInformationFlag::kOnNetwork); diff --git a/firmware/matter/src/matter_device_init.c b/firmware/matter/src/matter_device_init.c index ce1bdd2..6a323b4 100644 --- a/firmware/matter/src/matter_device_init.c +++ b/firmware/matter/src/matter_device_init.c @@ -35,6 +35,11 @@ int main(void) { mkdir("/var/lock",0700);mkdir("/var/lib",0700);mkdir("/var/lib/ck-state",0700); setenv("CK_FLASH_FILE","/flash.bin",1); if(ck_store_import()){puts("PERSISTENT_VM_IMPORT_FAIL");return 6;} + uint8_t admin[60];int credential=open("/test-admin.bin",O_RDONLY); + if(credential<0 || read(credential,admin,sizeof(admin))!=sizeof(admin) || ck_store_set("admin/credential",admin,sizeof(admin)))return 7; + close(credential);memset(admin,0,sizeof(admin)); + int active=open("/var/run/ck-protocol-active",O_WRONLY|O_CREAT,0600); + if(active>=0){write(active,"matter",6);close(active);} #endif if (module("/mii.ko") || module("/pcnet32.ko")) { perror("network module"); return 3; } int fd = socket(AF_INET, SOCK_DGRAM, 0); struct ifreq r; @@ -66,12 +71,13 @@ int main(void) { restart_application: #ifdef CK_TESTING if (!fork()) { execl("/bin/ck-updated","ck-updated",NULL); _exit(127); } + if (!fork()) { execl("/bin/ck-setupd","ck-setupd","--fixture","18083","18081","no-flash","/var/lib/ck-state/diag.key",NULL); _exit(127); } #endif int child = fork(); if (!child) { struct rlimit limit = {8*1024*1024,8*1024*1024}; if (setrlimit(RLIMIT_AS,&limit)) _exit(126); - alarm(300); + alarm(600); char *args[] = {"/bin/matterd-test", #ifdef CK_TESTING "--vm-persistent", @@ -100,6 +106,10 @@ int main(void) { sleep(1); } printf("MATTER_TLV_VM_WAIT=%d\n",status); +#ifdef CK_TESTING + uint8_t intent[8];size_t intent_size=sizeof(intent); + if(!ck_store_get("control/factory-reset",intent,&intent_size) && intent_size==8 && !memcmp(intent,"CKRESET1",8))puts("FACTORY_INTENT_DURABLE=1"); +#endif sync(); reboot(RB_POWER_OFF); for (;;) pause(); } diff --git a/firmware/matter/src/matter_diag.c b/firmware/matter/src/matter_diag.c index b8f84cd..3df7e93 100644 --- a/firmware/matter/src/matter_diag.c +++ b/firmware/matter/src/matter_diag.c @@ -71,7 +71,7 @@ static int selftest(void){Sha s;u8 b[32],k[20];char t[65];sha_init(&s);sha_add(& static void isa_probe(int which){int pid,st=0,limit[2]={0,0};volatile u32 x=0;pid=S0(4002);if(pid==0){S2(4075,4,P(limit));S1(4027,2);if(which==0){probe_llsc(&x);S1(4001,x==1?0:1);}else if(which==1){probe_tls();S1(4001,0);}else{probe_sync();S1(4001,0);}for(;;){} }if(pid<0){number(pid);return;}if(S3(4007,pid,P(&st),0)<0){append("wait_failed");return;}append("wait_status=");number(st);append(st==0?" supported":" NOT_proven");} static void probes(void){int t[2],r,fd,one=1,index=0,req[2]={0,0};u8 b[32],ifr[40],group[20];unsigned i;append("\n[ platform probes: read-only; child-only ISA checks ]\nclock_monotonic=");number(S2(4263,1,P(t)));append("\nclock_realtime=");number(S2(4263,0,P(t)));r=random_bytes(b,32);append("\nurandom_read_bytes=");number(r);zero(b,32);append(" (entropy quality not certified)\nsha256_hmac_vectors=");number(selftest());append("\nllsc: ");isa_probe(0);append("\nrdhwr_tls: ");isa_probe(1);append("\nsync: ");isa_probe(2);append("\nipv6_socket=");fd=S3(4183,10,1,0);number(fd<0?fd:0);if(fd>=0){append("\nipv6_pktinfo=");number(S5(4181,fd,41,49,P(&one),4));zero(ifr,40);copy(ifr,"br0",4);r=S3(4054,fd,0x8933,P(ifr));if(r==0)copy(&index,ifr+16,4);append("\nbr0_ifindex=");number(r<0?r:index);if(r==0&&index>0){zero(group,20);group[0]=0xff;group[1]=2;group[15]=0xfb;copy(group+16,&index,4);append("\njoin_ff02_fb_br0=");number(S5(4181,fd,41,20,P(group),20));}close_fd(fd);}append("\n");(void)req;(void)i;} static void processes(void){int fd,n,off,count=0;char path[64];append("\n[ process status: max 64, credentials/command lines excluded ]\n");fd=open_ro("/proc");if(fd<0)return;while((n=S3(4219,fd,P(scratch),sizeof(scratch)))>0){for(off=0;off+20<=n;){unsigned len=(unsigned)(u8)scratch[off+16]*256+(u8)scratch[off+17];char *name=scratch+off+19;int i=0,good=1;if(len<20||off+(int)len>n)break;while(i<(int)len-19&&name[i]){if(name[i]<'0'||name[i]>'9')good=0;i++;}if(i==0||i==(int)len-19)good=0;if(good&&count<64){char data[2048];int got;copy(path,"/proc/",6);copy(path+6,name,i);copy(path+6+i,"/status",8);got=read_file(path,data,sizeof(data));append("\n");append(path);append("\n");if(got>0)appendn(data,got);count++;}off+=(int)len;}}close_fd(fd);if(count==64)append("\n[process list capped]\n");} -static void snapshot(void){static const char *files[]={"/proc/version","/proc/cpuinfo","/proc/meminfo","/proc/uptime","/proc/mtd","/proc/partitions","/proc/mounts","/proc/net/dev","/proc/net/route","/proc/net/if_inet6","/proc/net/ipv6_route","/proc/net/udp6","/proc/net/igmp6","/proc/sys/net/ipv6/conf/all/disable_ipv6","/proc/sys/kernel/random/entropy_avail","/proc/self/status","/var/run/ck-setup-state","/var/log/ck-setup.txt","/var/log/ck-ram-probe.txt","/var/log/matter-platform-cpp.txt","/var/log/matter-test.log",0};int i,n;used=0;append("incipio-matter-diag/2\nread_only=1; writes_flash=0; owns_mcu=0\nSDK pin=v1.6.1.0 3bcdd56ba54fb88b2afb4bfef575014671df7aa7\nserved=");number((int)served);append(" rejected=");number((int)rejected);append("\n");for(i=0;files[i];i++)file_section(files[i]);probes();processes();append("\n[ kernel log: non-consuming snapshot, max 8192 bytes ]\n");n=S3(4103,3,P(scratch),sizeof(scratch));if(n>0)appendn(scratch,n);else{append("unavailable errno=");number(-n);}append("\n[end]\n");} +static void snapshot(void){static const char *files[]={"/proc/version","/proc/cpuinfo","/proc/meminfo","/proc/uptime","/proc/mtd","/proc/partitions","/proc/mounts","/proc/net/dev","/proc/net/route","/proc/net/if_inet6","/proc/net/ipv6_route","/proc/net/udp6","/proc/net/igmp6","/proc/sys/net/ipv6/conf/all/disable_ipv6","/proc/sys/kernel/random/entropy_avail","/proc/self/status","/var/run/ck-setup-state","/var/run/ck-identity-state","/var/log/ck-store-init.log","/var/log/matter-identity.log","/var/log/ck-setupd.log","/var/log/ck-dhcp.log","/var/log/ck-setup.txt","/var/log/ck-ram-probe.txt","/var/log/matter-platform-cpp.txt","/var/log/matter-test.log",0};int i,n;used=0;append("incipio-matter-diag/2\nread_only=1; writes_flash=0; owns_mcu=0\nSDK pin=v1.6.1.0 3bcdd56ba54fb88b2afb4bfef575014671df7aa7\nserved=");number((int)served);append(" rejected=");number((int)rejected);append("\n");for(i=0;files[i];i++)file_section(files[i]);probes();processes();append("\n[ kernel log: non-consuming snapshot, max 8192 bytes ]\n");n=S3(4103,3,P(scratch),sizeof(scratch));if(n>0)appendn(scratch,n);else{append("unavailable errno=");number(-n);}append("\n[end]\n");} static int send_all(int fd,const char *p,int n){int r;while(n>0){r=S4(4178,fd,P(p),n,0x4000);if(r==-4)continue;if(r<=0)return -1;p+=r;n-=r;}return 0;} static void reply(int fd,const char *status,const char *body,int n){char header[256],num[12];int i=0,j=0,x=n;const char*parts[]={"HTTP/1.1 ",status,"\r\nContent-Type: text/plain; charset=utf-8\r\nCache-Control: no-store\r\nConnection: close\r\nX-Content-Type-Options: nosniff\r\nContent-Length: ",0};while(j<3){int k=slen(parts[j]);copy(header+i,parts[j],k);i+=k;j++;}j=0;do{num[j++]=(char)('0'+x%10);x/=10;}while(x);while(j)header[i++]=num[--j];copy(header+i,"\r\n\r\n",4);i+=4;if(send_all(fd,header,i)==0)send_all(fd,body,n);} static int prefix(const char*s,const char*p){while(*p)if(*s++!=*p++)return 0;return 1;} diff --git a/firmware/matter/src/matter_diag_relay.c b/firmware/matter/src/matter_diag_relay.c index 4b28f24..3b7b95c 100644 --- a/firmware/matter/src/matter_diag_relay.c +++ b/firmware/matter/src/matter_diag_relay.c @@ -71,7 +71,7 @@ static int selftest(void){Sha s;u8 b[32],k[20];char t[65];sha_init(&s);sha_add(& static void isa_probe(int which){int pid,st=0,limit[2]={0,0};volatile u32 x=0;pid=S0(4002);if(pid==0){S2(4075,4,P(limit));S1(4027,2);if(which==0){probe_llsc(&x);S1(4001,x==1?0:1);}else if(which==1){probe_tls();S1(4001,0);}else{probe_sync();S1(4001,0);}for(;;){} }if(pid<0){number(pid);return;}if(S3(4007,pid,P(&st),0)<0){append("wait_failed");return;}append("wait_status=");number(st);append(st==0?" supported":" NOT_proven");} static void probes(void){int t[2],r,fd,one=1,index=0,req[2]={0,0};u8 b[32],ifr[40],group[20];unsigned i;append("\n[ platform probes: read-only; child-only ISA checks ]\nclock_monotonic=");number(S2(4263,1,P(t)));append("\nclock_realtime=");number(S2(4263,0,P(t)));r=random_bytes(b,32);append("\nurandom_read_bytes=");number(r);zero(b,32);append(" (entropy quality not certified)\nsha256_hmac_vectors=");number(selftest());append("\nllsc: ");isa_probe(0);append("\nrdhwr_tls: ");isa_probe(1);append("\nsync: ");isa_probe(2);append("\nipv6_socket=");fd=S3(4183,10,1,0);number(fd<0?fd:0);if(fd>=0){append("\nipv6_pktinfo=");number(S5(4181,fd,41,49,P(&one),4));zero(ifr,40);copy(ifr,"br0",4);r=S3(4054,fd,0x8933,P(ifr));if(r==0)copy(&index,ifr+16,4);append("\nbr0_ifindex=");number(r<0?r:index);if(r==0&&index>0){zero(group,20);group[0]=0xff;group[1]=2;group[15]=0xfb;copy(group+16,&index,4);append("\njoin_ff02_fb_br0=");number(S5(4181,fd,41,20,P(group),20));}close_fd(fd);}append("\n");(void)req;(void)i;} static void processes(void){int fd,n,off,count=0;char path[64];append("\n[ process status: max 64, credentials/command lines excluded ]\n");fd=open_ro("/proc");if(fd<0)return;while((n=S3(4219,fd,P(scratch),sizeof(scratch)))>0){for(off=0;off+20<=n;){unsigned len=(unsigned)(u8)scratch[off+16]*256+(u8)scratch[off+17];char *name=scratch+off+19;int i=0,good=1;if(len<20||off+(int)len>n)break;while(i<(int)len-19&&name[i]){if(name[i]<'0'||name[i]>'9')good=0;i++;}if(i==0||i==(int)len-19)good=0;if(good&&count<64){char data[2048];int got;copy(path,"/proc/",6);copy(path+6,name,i);copy(path+6+i,"/status",8);got=read_file(path,data,sizeof(data));append("\n");append(path);append("\n");if(got>0)appendn(data,got);count++;}off+=(int)len;}}close_fd(fd);if(count==64)append("\n[process list capped]\n");} -static void snapshot(void){static const char *files[]={"/proc/version","/proc/cpuinfo","/proc/meminfo","/proc/uptime","/proc/mtd","/proc/bootbank","/proc/partitions","/proc/mounts","/proc/net/dev","/proc/net/route","/proc/net/if_inet6","/proc/net/ipv6_route","/proc/net/udp6","/proc/net/igmp6","/proc/sys/net/ipv6/conf/all/disable_ipv6","/proc/sys/kernel/random/entropy_avail","/proc/self/status","/var/run/ck-setup-state","/var/log/ck-store-init.log","/var/log/ck-update.log","/var/log/matter-identity.log","/var/log/ck-setup.txt","/var/log/ck-ram-probe.txt","/var/log/matter-platform-cpp.txt","/var/log/matter-test.log","/var/log/matter-hardware.txt",0};int i,n;used=0;append("incipio-matter-diag/2\nread_only=1; writes_flash=0; owns_mcu=0\nSDK pin=v1.6.1.0 3bcdd56ba54fb88b2afb4bfef575014671df7aa7\nserved=");number((int)served);append(" rejected=");number((int)rejected);append("\n");for(i=0;files[i];i++)file_section(files[i]);probes();processes();append("\n[ kernel log: non-consuming snapshot, max 8192 bytes ]\n");n=S3(4103,3,P(scratch),sizeof(scratch));if(n>0)appendn(scratch,n);else{append("unavailable errno=");number(-n);}append("\n[end]\n");} +static void snapshot(void){static const char *files[]={"/proc/version","/proc/cpuinfo","/proc/meminfo","/proc/uptime","/proc/mtd","/proc/bootbank","/proc/partitions","/proc/mounts","/proc/net/dev","/proc/net/route","/proc/net/if_inet6","/proc/net/ipv6_route","/proc/net/udp6","/proc/net/igmp6","/proc/sys/net/ipv6/conf/all/disable_ipv6","/proc/sys/kernel/random/entropy_avail","/proc/self/status","/var/run/ck-setup-state","/var/run/ck-identity-state","/var/log/ck-store-init.log","/var/log/matter-identity.log","/var/log/ck-setupd.log","/var/log/ck-dhcp.log","/var/log/ck-store-init.log","/var/log/ck-update.log","/var/log/matter-identity.log","/var/log/ck-setup.txt","/var/log/ck-ram-probe.txt","/var/log/matter-platform-cpp.txt","/var/log/matter-test.log","/var/log/matter-hardware.txt",0};int i,n;used=0;append("incipio-matter-diag/2\nread_only=1; writes_flash=0; owns_mcu=0\nSDK pin=v1.6.1.0 3bcdd56ba54fb88b2afb4bfef575014671df7aa7\nserved=");number((int)served);append(" rejected=");number((int)rejected);append("\n");for(i=0;files[i];i++)file_section(files[i]);probes();processes();append("\n[ kernel log: non-consuming snapshot, max 8192 bytes ]\n");n=S3(4103,3,P(scratch),sizeof(scratch));if(n>0)appendn(scratch,n);else{append("unavailable errno=");number(-n);}append("\n[end]\n");} static int send_all(int fd,const char *p,int n){int r;while(n>0){r=S4(4178,fd,P(p),n,0x4000);if(r==-4)continue;if(r<=0)return -1;p+=r;n-=r;}return 0;} static void reply(int fd,const char *status,const char *body,int n){char header[256],num[12];int i=0,j=0,x=n;const char*parts[]={"HTTP/1.1 ",status,"\r\nContent-Type: text/plain; charset=utf-8\r\nCache-Control: no-store\r\nConnection: close\r\nX-Content-Type-Options: nosniff\r\nContent-Length: ",0};while(j<3){int k=slen(parts[j]);copy(header+i,parts[j],k);i+=k;j++;}j=0;do{num[j++]=(char)('0'+x%10);x/=10;}while(x);while(j)header[i++]=num[--j];copy(header+i,"\r\n\r\n",4);i+=4;if(send_all(fd,header,i)==0)send_all(fd,body,n);} static int prefix(const char*s,const char*p){while(*p)if(*s++!=*p++)return 0;return 1;} diff --git a/firmware/matter/src/relay/McuController.cpp b/firmware/matter/src/relay/McuController.cpp index 006278e..fe40ed1 100644 --- a/firmware/matter/src/relay/McuController.cpp +++ b/firmware/matter/src/relay/McuController.cpp @@ -1,4 +1,5 @@ #include "McuController.h" +#include "../update/ck_led.h" #include #include #include @@ -78,23 +79,19 @@ bool McuController::Send(const McuTx & tx) } return sent == sizeof(tx.bytes); } -void McuController::Gpio(const char * command) -{ - int fd = open(mGpio, O_WRONLY | O_NOFOLLOW); - if (fd < 0) { return; } - ssize_t unused = write(fd, command, std::strlen(command)); (void) unused; close(fd); -} -void McuController::Report(uint64_t now, const RelaySnapshot & s, bool led) +void McuController::Report(uint64_t now, const RelaySnapshot & s) { + (void)ck_led_hw_publish("matter",now,s.relayAt,s.powerAt,mIdentify?now+2000:0,s.powerRaw*1000u, + (s.known?CK_LED_KNOWN:0)|(s.on?CK_LED_RELAY_ON:0)|(s.powerValid?CK_LED_POWER:0)|CK_LED_READY,s.powerFrames); char text[1024]; int n = std::snprintf(text, sizeof(text), "matter-hardware/1\nconnected=%d baud=9600 cold_off_confirmed=%d\nrelay_known=%d relay_on=%d relay_age_ms=%llu\n" - "completed_id=%u result=%u relay_frames=%u\npower_raw=%u power_frames=%u power_age_ms=%llu load_blink=%d led=%d\n" + "completed_id=%u result=%u relay_frames=%u\npower_raw=%u power_frames=%u power_age_ms=%llu load_blink=%d identify=%d\n" "voltage_raw=%u voltage_valid=%d voltage_age_ms=%llu current_raw=%u current_valid=%d current_age_ms=%llu power_valid=%d\n" - "reset_events=%u reset_action=record_only\n", + "reset_events=%u reset_action=dispatched_by_matter_event_loop\n", int(mFd >= 0), int(s.bootOffConfirmed), int(s.known && now-s.relayAt <= 10000), int(s.on), static_cast(now-s.relayAt), s.completedId, unsigned(s.result), s.relayFrames, - s.powerRaw, s.powerFrames, static_cast(now-s.powerAt), int(mEngine.LoadBlink(now)), int(led), s.voltageRaw, int(s.voltageValid && now-s.voltageAt<=15000), static_cast(now-s.voltageAt), + s.powerRaw, s.powerFrames, static_cast(now-s.powerAt), int(mEngine.LoadBlink(now)), int(mIdentify), s.voltageRaw, int(s.voltageValid && now-s.voltageAt<=15000), static_cast(now-s.voltageAt), s.currentRaw, int(s.currentValid && now-s.currentAt<=15000), static_cast(now-s.currentAt), int(s.powerValid && now-s.powerAt<=15000), s.resetEvents); int fd = open("/var/log/matter-hardware.txt.tmp", O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW, 0600); @@ -105,8 +102,7 @@ void McuController::Report(uint64_t now, const RelaySnapshot & s, bool led) } void McuController::Worker() { - uint64_t start = Now(), nextOpen = 0, nextReport = 0; bool lastLed = false; - Gpio("config 6 w"); Gpio("set 6 1"); + uint64_t nextOpen = 0, nextReport = 0; for (;;) { uint64_t now = Now(); pthread_mutex_lock(&mMutex); @@ -127,13 +123,9 @@ void McuController::Worker() ioctl(mFd, TIOCNXCL); close(mFd); mFd = -1; mEngine.Disconnect(); } } - bool led = mIdentify ? ((now / 250) & 1) : (mEngine.LoadBlink(now) && ((now / 500) & 1)); - if (!mIdentify && now >= start + 10000 && now < start + 11500) { led = ((now-start-10000)/250)%2 == 0; } - if (led != lastLed) { Gpio(led ? "set 6 0" : "set 6 1"); lastLed = led; } - if (now >= nextReport) { Report(now, mEngine.Snapshot(), led); nextReport = now + 1000; } + if (now >= nextReport) { Report(now, mEngine.Snapshot()); nextReport = now + 1000; } pthread_mutex_unlock(&mMutex); usleep(10000); } if (mFd >= 0) { ioctl(mFd, TIOCNXCL); close(mFd); mFd = -1; } - Gpio("set 6 1"); } } // namespace incipio diff --git a/firmware/matter/src/relay/McuController.h b/firmware/matter/src/relay/McuController.h index 9b1023c..97fd51d 100644 --- a/firmware/matter/src/relay/McuController.h +++ b/firmware/matter/src/relay/McuController.h @@ -24,7 +24,6 @@ class McuController { void Worker(); bool Open(); bool Send(const McuTx & tx); - void Gpio(const char * command); - void Report(uint64_t now, const RelaySnapshot & state, bool led); + void Report(uint64_t now, const RelaySnapshot & state); }; } // namespace incipio diff --git a/firmware/matter/src/relay/RelayEngine.cpp b/firmware/matter/src/relay/RelayEngine.cpp index f52a722..7a125be 100644 --- a/firmware/matter/src/relay/RelayEngine.cpp +++ b/firmware/matter/src/relay/RelayEngine.cpp @@ -93,7 +93,9 @@ void RelayEngine::Feed(uint8_t byte, uint64_t now) mQuery = Query::None; mCount = 0; } else if (mWindow[0] == 0xaa && mWindow[1] == 0xff && mWindow[3] == 0x55 && (mWindow[2] == 0 || mWindow[2] == 0xff)) { - ++mState.resetEvents; mCount = 0; // Record only; never execute vendor flash-default/reboot commands. + // 00 arms the vendor reset flow; FF is the confirmed long-press frame. + if(mWindow[2]==0xff) ++mState.resetEvents; + mCount = 0; } } void RelayEngine::Disconnect() diff --git a/firmware/matter/src/setup/boot_guard.c b/firmware/matter/src/setup/boot_guard.c new file mode 100644 index 0000000..f46fd7d --- /dev/null +++ b/firmware/matter/src/setup/boot_guard.c @@ -0,0 +1,121 @@ +/* Fixed boot jobs only. No shell or caller-selected executable in production. + * Deadlines use CLOCK_MONOTONIC; a dead launcher cannot orphan its child. + */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static volatile sig_atomic_t stopping; +static void stop(int sig) { (void)sig; stopping = 1; } +static long seconds_now(void) { + struct timespec t; + return clock_gettime(CLOCK_MONOTONIC, &t) ? -1 : t.tv_sec; +} +static const char *path(const char *p) { +#ifdef CK_TESTING + static char names[8][1024]; static unsigned at; + const char *root = getenv("CK_BOOT_TEST_ROOT"); + if (root) { + char *out = names[at++ % 8]; + if (snprintf(out,1024,"%s%s",root,p) >= 1024) _exit(2); + return out; + } +#endif + return p; +} +int main(int argc, char **argv) { + const char *binary, *arg, *extra = NULL; + int deadline = 30, watch = 0; + if (argc != 2) return 2; + if (!strcmp(argv[1],"--dhcp-log")) { + char buffer[8192], chunk[512]; size_t total=0; ssize_t got; + while ((got=read(STDIN_FILENO,chunk,sizeof(chunk)))>0) { + size_t n=(size_t)got; + if (total+n>sizeof(buffer)) { + size_t trim=total+n-sizeof(buffer); + memmove(buffer,buffer+trim,total-trim); total-=trim; + } + memcpy(buffer+total,chunk,n); total+=n; + int fd=open(path("/var/log/ck-dhcp.log"),O_WRONLY|O_CREAT|O_TRUNC|O_NOFOLLOW,0600); + if (fd<0) return 1; + size_t at=0; + while (at 0) deadline = atoi(limit); +#endif + signal(SIGTERM,stop); signal(SIGINT,stop); signal(SIGHUP,stop); + pid_t launcher=getppid(); + if (prctl(PR_SET_PDEATHSIG,SIGTERM) || getppid() != launcher) return 125; + long started = seconds_now(); + if (started < 0) return 125; + pid_t child = fork(); + if (child < 0) return 125; + if (!child) { + pid_t parent = getppid(); + if (setsid() < 0 || prctl(PR_SET_PDEATHSIG,SIGKILL) || getppid() != parent) _exit(125); + char *args[] = {(char *)path(binary),(char *)arg,(char *)extra,NULL}; + execv(args[0],args); _exit(127); + } + int status = 0, expired = 0; + long last = started; + for (;;) { + pid_t result = waitpid(child,&status,WNOHANG); + if (result == child) break; + if (result < 0 && errno != EINTR) return 125; + long now = seconds_now(); + if (watch) { + FILE *f = fopen(path("/var/run/ck-setup-heartbeat"),"r"); + long beat; + if (f) { + if (fscanf(f,"%ld",&beat) == 1 && beat >= last && beat <= now) last = beat; + fclose(f); + } + } + if (stopping || now < 0 || now - last >= deadline) { + expired = !stopping; + /* A process group also contains a stuck backend child. */ + kill(-child,SIGTERM); kill(child,SIGTERM); + struct timespec grace = {1,0}; nanosleep(&grace,NULL); + kill(-child,SIGKILL); kill(child,SIGKILL); + while (waitpid(child,&status,0) < 0 && errno == EINTR) {} + break; + } + struct timespec pause = {0,250000000}; nanosleep(&pause,NULL); + } + if (expired || stopping) { + fprintf(stderr,"CK_BOOT job=%s result=%s\n",argv[1],expired?"timeout":"stopped"); + return expired ? 124 : 143; + } + return WIFEXITED(status) ? WEXITSTATUS(status) : 128 + WTERMSIG(status); +} diff --git a/firmware/matter/src/setup/ck-led-start b/firmware/matter/src/setup/ck-led-start new file mode 100755 index 0000000..bcde1d9 --- /dev/null +++ b/firmware/matter/src/setup/ck-led-start @@ -0,0 +1,4 @@ +#!/bin/sh +umask 077 +mkdir -p /var/run /var/lock /var/log +exec /bin/ck-updater --run-led > /var/log/ck-led.log 2>&1 diff --git a/firmware/matter/src/setup/ck-management-start b/firmware/matter/src/setup/ck-management-start new file mode 100644 index 0000000..d143f93 --- /dev/null +++ b/firmware/matter/src/setup/ck-management-start @@ -0,0 +1,4 @@ +#!/bin/sh +umask 077 +mkdir -p /var/run/ck-ota /var/lock /var/log +exec /bin/ck-updated > /var/log/ck-update.log 2>&1 diff --git a/firmware/matter/src/setup/ck-onboarding-start b/firmware/matter/src/setup/ck-onboarding-start new file mode 100644 index 0000000..80adc97 --- /dev/null +++ b/firmware/matter/src/setup/ck-onboarding-start @@ -0,0 +1,12 @@ +#!/bin/sh +umask 077 +# Independent from DHCP and identity import. PID1 restarts this supervisor. +while [ ! -f /var/run/ck-onboarding-ready ]; do + if [ -f /var/lib/ck-state/device.key ] && + /bin/ck-boot-guard --identity > /var/log/matter-identity.log 2>&1; then + : > /var/run/ck-onboarding-ready + break + fi + sleep 5 +done +while :; do sleep 3600; done diff --git a/firmware/matter/src/setup/ck-protocol-start b/firmware/matter/src/setup/ck-protocol-start new file mode 100644 index 0000000..35115ed --- /dev/null +++ b/firmware/matter/src/setup/ck-protocol-start @@ -0,0 +1,4 @@ +#!/bin/sh +umask 077 +mkdir -p /var/run /var/lock /var/log +exec /bin/ck-updater --run-protocol > /var/log/ck-protocol.log 2>&1 diff --git a/firmware/matter/src/setup/ck-reset b/firmware/matter/src/setup/ck-reset new file mode 100644 index 0000000..1536cda --- /dev/null +++ b/firmware/matter/src/setup/ck-reset @@ -0,0 +1,2 @@ +#!/bin/sh +exec /bin/ck-updater --factory-reset diff --git a/firmware/matter/src/setup/ck-setup-device-backend b/firmware/matter/src/setup/ck-setup-device-backend index b04a731..d6918a6 100644 --- a/firmware/matter/src/setup/ck-setup-device-backend +++ b/firmware/matter/src/setup/ck-setup-device-backend @@ -4,7 +4,25 @@ set -eu umask 077 op=${1:-} case "$op" in ap|join|resume|commit) ;; *) exit 2 ;; esac -if [ "$op" = commit ]; then exec /bin/ck-wificfg commit; fi +if [ "$op" = commit ]; then + /bin/ck-wificfg commit + exec /bin/ck-updater --onboarded +fi +dhcp_running=0 +if [ -f /var/run/ck-udhcpc.pid ]; then + read -r pid < /var/run/ck-udhcpc.pid || true + case "$pid" in ''|*[!0-9]*) ;; *) + if proc_name=$( + while read -r field value rest; do + if [ "$field" = Name: ]; then printf '%s' "$value"; break; fi + done 2>/dev/null < "/proc/$pid/status" + ) && [ "$proc_name" = udhcpc ] && kill -0 "$pid" 2>/dev/null; then + dhcp_running=1 + fi + ;; esac +fi +# Controller restarts and periodic checks adopt the existing client/lease. +if [ "$op" = resume ] && [ "$dhcp_running" = 1 ]; then exit 0; fi /bin/killall wfaudio WACServer 2>/dev/null || true sleep 2 /bin/killall -9 wfaudio WACServer 2>/dev/null || true @@ -43,5 +61,5 @@ if [ "$op" = join ]; then /bin/killall boa dnrd udhcpd 2>/dev/null || true fi /bin/ifconfig br0 0.0.0.0 up -/bin/udhcpc -f -i br0 -p /var/run/ck-udhcpc.pid -s /bin/ck-udhcpc >/dev/null 2>&1 & +/bin/udhcpc -f -i br0 -p /var/run/ck-udhcpc.pid -s /bin/ck-udhcpc 2>&1 | /bin/ck-boot-guard --dhcp-log & exit 0 diff --git a/firmware/matter/src/setup/ck-setup-device-start b/firmware/matter/src/setup/ck-setup-device-start index 5131843..71452d5 100644 --- a/firmware/matter/src/setup/ck-setup-device-start +++ b/firmware/matter/src/setup/ck-setup-device-start @@ -1,12 +1,18 @@ #!/bin/sh umask 077 -sleep 15 -# Generate all onboarding data together before changing the network mode. -/bin/matterd-test --init-identity wlan0 > /var/log/matter-identity.log 2>&1 || exit 1 -status=$(/bin/ck-wificfg status 2>/dev/null) || exit 1 +if [ ! -f /var/run/ck-setup-boot ]; then sleep 15; fi +if [ -f /var/run/ck-first-setup ]; then + status=configured=0 +else + # A failed read must not replace saved station credentials with an AP. + status=$(/bin/ck-boot-guard --mib 2>/dev/null) || status=configured=1 +fi case "$status" in *configured=1*) printf configured > /var/run/ck-setup-boot ;; *) printf unconfigured > /var/run/ck-setup-boot ;; esac -# A single controller. Never restart failed MIB writes in a tight loop. -exec /bin/ck-setupd --serve /var/lib/ck-state/diag.key +# Starts networking before it has any Matter identity or management key. +# The guard restarts hung children; PID1 restarts a crashed supervisor. +/bin/ck-boot-guard --setup > /var/log/ck-setupd.log 2>&1 +sleep 5 +exit 1 diff --git a/firmware/matter/src/setup/matter-relay-start b/firmware/matter/src/setup/matter-relay-start index f9b055e..51be1ae 100644 --- a/firmware/matter/src/setup/matter-relay-start +++ b/firmware/matter/src/setup/matter-relay-start @@ -1,6 +1,6 @@ #!/bin/sh # Firmware starts only this MCU owner; the V2 executable is retained for recovery. -if [ -n "$(/bin/pidof homekitserver 2>/dev/null)" ]; then +if /bin/ps | /bin/grep -q '[h]omekitserver'; then echo 'MCU_OWNER_CONFLICT: HomeKit is running; Matter relay not started' > /var/log/matter-test.log exit 1 fi @@ -12,6 +12,6 @@ fi # Keep one MCU owner even when persistent identity or Matter initialization # fails. This mode needs no pairing identity, Flash writes, or networking. echo 'MCU_ONLY: network application unavailable; physical button remains active' - touch /var/run/ck-mcu-only + : > /var/run/ck-mcu-only exec /bin/matter-diag --run-hardware ) 2>&1 | /bin/matter-diag --log diff --git a/firmware/matter/src/setup/portal-device-page.h b/firmware/matter/src/setup/portal-device-page.h index 91fde80..d3339ba 100644 --- a/firmware/matter/src/setup/portal-device-page.h +++ b/firmware/matter/src/setup/portal-device-page.h @@ -1,4 +1,4 @@ /* Generated: public template, no credential values. */ -static const char portal_before[] = "設定 CommandKit
CommandKit · 設定

讓插座連上
家中的 Wi-Fi。

選擇家中的 2.4 GHz Wi-Fi,再輸入密碼。連線完成後,手機需要切回同一個家中網路,才能接續 Matter 配對。

01 設定 Wi-Fi → 02 加入家庭

正在尋找附近的 2.4 GHz Wi-Fi…

手動輸入/隱藏網路
支援 2.4 GHz WPA2 個人網路與開放網路。

插座的設定熱點不需要密碼,暫時沒有網際網路是正常的。請保持連線,直到送出設定。

Matter 配對

先保存 QR Code 或設定碼。Wi-Fi 設定完成後,手機切回同一個網路,再用 Apple 家庭或 Home Assistant 加入插座。

\"Matter"; -static const char portal_after[] = "此設定碼屬於這台插座,重開機後維持相同。請保存設定碼或 QR Code。請不要使用插座原本的 HomeKit 碼。開發版配對資料目前只存在 RAM,斷電後需重新配對。
本機設定 · 不需要雲端帳號
開發測試版 · 配對後由 Matter 控制插座開關。
\n"; +static const char portal_before[] = "設定 CommandKit\n\n\n\n\n\n\n\n\n\n\n
CommandKit 設定CMNDKT-004 智慧插座 · 本機設定
\n
  1. 步驟 1連上 Wi-Fi
  2. 步驟 2管理密碼
  3. 步驟 3加入家庭
\n
\n

讓插座連上
家中的 Wi-Fi

選擇家中的 2.4 GHz Wi-Fi 並輸入密碼。設定熱點暫時沒有網際網路是正常的,請保持連線。

\n
附近的 Wi-Fi
\n

正在尋找附近的 2.4 GHz Wi-Fi…

\n
\n
手動輸入或隱藏網路\n
\n

支援 2.4 GHz WPA2 個人網路與開放網路。

\n
\n\n

正在讀取管理設定…

\n
\n\n
Matter 配對碼\"MatterMatter 設定碼"; +static const char portal_after[] = "這組碼屬於這台插座,重開機與更新後都相同,請先拍照保存。請不要使用插座原本的 HomeKit 碼;若之後改用 HomeKit,可在管理後台切換。
\n\n
本機設定,不需要雲端帳號
開發測試版 · 依所選的 HomeKit 或 Matter 協議控制插座
\n"; diff --git a/firmware/matter/src/setup/portal-device.html b/firmware/matter/src/setup/portal-device.html index 25ffa0e..bdefd63 100644 --- a/firmware/matter/src/setup/portal-device.html +++ b/firmware/matter/src/setup/portal-device.html @@ -1,4 +1,98 @@ -設定 CommandKit
CommandKit · 設定

讓插座連上
家中的 Wi-Fi。

選擇家中的 2.4 GHz Wi-Fi,再輸入密碼。連線完成後,手機需要切回同一個家中網路,才能接續 Matter 配對。

01 設定 Wi-Fi → 02 加入家庭

正在尋找附近的 2.4 GHz Wi-Fi…

手動輸入/隱藏網路
支援 2.4 GHz WPA2 個人網路與開放網路。

插座的設定熱點不需要密碼,暫時沒有網際網路是正常的。請保持連線,直到送出設定。

Matter 配對

先保存 QR Code 或設定碼。Wi-Fi 設定完成後,手機切回同一個網路,再用 Apple 家庭或 Home Assistant 加入插座。

Matter 配對 QR Code{{MATTER_MANUAL}}此設定碼屬於這台插座,重開機後維持相同。請保存設定碼或 QR Code。請不要使用插座原本的 HomeKit 碼。開發版配對資料目前只存在 RAM,斷電後需重新配對。
本機設定 · 不需要雲端帳號
開發測試版 · 配對後由 Matter 控制插座開關。
+設定 CommandKit + +
CommandKit 設定CMNDKT-004 智慧插座 · 本機設定
+
  1. 步驟 1連上 Wi-Fi
  2. 步驟 2管理密碼
  3. 步驟 3加入家庭
+
+

讓插座連上
家中的 Wi-Fi

選擇家中的 2.4 GHz Wi-Fi 並輸入密碼。設定熱點暫時沒有網際網路是正常的,請保持連線。

+
附近的 Wi-Fi
+

正在尋找附近的 2.4 GHz Wi-Fi…

+
+
手動輸入或隱藏網路 +
+

支援 2.4 GHz WPA2 個人網路與開放網路。

+
+ +

正在讀取管理設定…

+
+ +
Matter 配對碼Matter 配對 QR CodeMatter 設定碼{{MATTER_MANUAL}}這組碼屬於這台插座,重開機與更新後都相同,請先拍照保存。請不要使用插座原本的 HomeKit 碼;若之後改用 HomeKit,可在管理後台切換。
+ +
本機設定,不需要雲端帳號
開發測試版 · 依所選的 HomeKit 或 Matter 協議控制插座
diff --git a/firmware/matter/src/setup/portal-scan-script.h b/firmware/matter/src/setup/portal-scan-script.h index c488253..7942a1e 100644 --- a/firmware/matter/src/setup/portal-scan-script.h +++ b/firmware/matter/src/setup/portal-scan-script.h @@ -1,2 +1,2 @@ /* Generated public script. */ -static const char portal_scan_script[] = "'use strict';\n(function () {\n var form = document.getElementById('wifi-form');\n var list = document.getElementById('network');\n var manual = document.getElementById('manual-network');\n var ssid = document.getElementById('ssid');\n var security = document.getElementById('security');\n var password = document.getElementById('password');\n var passwordArea = document.getElementById('password-area');\n var status = document.getElementById('scan-status');\n var retry = document.getElementById('scan-again');\n var save = document.getElementById('save');\n var networks = [], scanning = false;\n function passwordMode() {\n var open = security.value === 'open';\n password.required = !open;\n password.minLength = open ? 0 : 8;\n passwordArea.hidden = open;\n if (open) password.value = '';\n }\n function choose() {\n if (list.value === 'manual') {\n manual.open = true;\n ssid.value = '';\n security.value = 'wpa2';\n save.disabled = scanning;\n ssid.focus();\n } else if (list.value !== '') {\n var item = networks[Number(list.value)];\n if (!item || item.security === 'unsupported') return;\n ssid.value = item.ssid;\n security.value = item.security;\n manual.open = false;\n save.disabled = scanning;\n if (item.security !== 'open') password.focus();\n } else save.disabled = true;\n passwordMode();\n }\n function option(text, value, disabled) {\n var item = document.createElement('option');\n item.textContent = text; item.value = value; item.disabled = !!disabled;\n list.appendChild(item);\n }\n function show(items) {\n networks = items;\n list.textContent = '';\n option('請選擇家中的 Wi-Fi', '');\n items.forEach(function (item, index) {\n var signal = item.signal >= 70 ? '訊號良好' : item.signal >= 40 ? '訊號普通' : '訊號較弱';\n var suffix = item.security === 'open' ? '免密碼' : item.security === 'unsupported' ? '不支援此加密方式' : signal;\n option(item.ssid + ' · ' + suffix, String(index), item.security === 'unsupported');\n });\n option('手動輸入/隱藏網路', 'manual');\n list.disabled = false;\n list.required = true;\n save.disabled = true;\n if (!items.some(function (item) { return item.security !== 'unsupported'; })) {\n list.value = 'manual'; choose();\n }\n }\n function request(fresh) {\n var controller = new AbortController();\n var timeout = setTimeout(function () { controller.abort(); }, 6000);\n var body = 'token=' + encodeURIComponent(form.elements.token.value) + (fresh ? '&refresh=1' : '');\n return fetch('/wifi-scan', {method: 'POST', credentials: 'same-origin',\n headers: {'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'},\n body: body, signal: controller.signal, cache: 'no-store'})\n .then(function (response) {\n clearTimeout(timeout);\n if (!response.ok) throw new Error('scan failed');\n return response.json();\n }, function (error) { clearTimeout(timeout); throw error; });\n }\n function scan() {\n if (scanning) return;\n scanning = true;\n retry.disabled = true; save.disabled = true;\n status.textContent = '正在尋找附近的 2.4 GHz Wi-Fi…';\n var tries = 0;\n function poll(fresh) {\n request(fresh).then(function (result) {\n if (result.status === 'scanning' && tries++ < 28) {\n setTimeout(function () { poll(false); }, 1000); return;\n }\n scanning = false; retry.disabled = false;\n if (result.status !== 'ready' || !Array.isArray(result.networks)) throw new Error('scan failed');\n show(result.networks);\n status.textContent = result.networks.length ? '選擇網路,再輸入密碼即可。' : '沒有找到網路。可重新掃描,或手動輸入名稱。';\n }).catch(function () {\n scanning = false; retry.disabled = false;\n show([]);\n status.textContent = '掃描未完成。請重新掃描,或手動輸入網路名稱。';\n });\n }\n poll(true);\n }\n list.addEventListener('change', choose);\n security.addEventListener('change', passwordMode);\n ssid.addEventListener('input', function () { list.value = 'manual'; save.disabled = scanning; });\n retry.addEventListener('click', scan);\n document.getElementById('show-password').addEventListener('change', function (event) {\n password.type = event.target.checked ? 'text' : 'password';\n });\n passwordMode(); manual.open = false; scan();\n}());\n"; +static const char portal_scan_script[] = "'use strict';\n(function () {\n function $(id) { return document.getElementById(id); }\n var form = $('wifi-form'), list = $('network'), manual = $('manual-network'), ssid = $('ssid'), security = $('security');\n var password = $('password'), passwordArea = $('password-area'), status = $('scan-status'), retry = $('scan-again');\n var next = $('next'), save = $('save'), adminMessage = $('admin-setup-status');\n var networks = [], scanning = false, adminState = null, setting = false, chosen = -1;\n form.noValidate = true;\n $('protocol-management').href = 'http://' + location.hostname + ':18082/';\n var code = $('matter-manual'), raw = code.textContent.trim();\n if (/^\\d{11}$/.test(raw)) code.textContent = raw.slice(0, 4) + '-' + raw.slice(4, 7) + '-' + raw.slice(7);\n\n function step(n) {\n [1, 2, 3].forEach(function (i) {\n $('step-' + i).hidden = i !== n;\n $('p' + i).className = i === n ? 'on' : i < n ? 'done' : '';\n });\n adminMessage.hidden = n === 3;\n window.scrollTo(0, 0);\n }\n function say(text, error) { adminMessage.textContent = text; adminMessage.dataset.tone = error ? 'error' : ''; }\n function wifiReady() {\n var open = security.value === 'open', name = ssid.value;\n return name.length > 0 && new TextEncoder().encode(name).length <= 32 && (open || password.value.length >= 8);\n }\n function refreshButtons() {\n next.disabled = setting || !wifiReady() || !adminState || !adminState.ready;\n next.textContent = adminState && adminState.configured ? '儲存並連線' : '下一步:建立管理密碼';\n }\n async function adminStatus() {\n try {\n var response = await fetch('/admin/status', {cache: 'no-store'});\n adminState = await response.json();\n if (!adminState.ready) { setTimeout(adminStatus, 1500); return; }\n $('admin-setup').hidden = adminState.configured;\n say(adminState.configured ? '管理密碼已設定,送出後直接連線。' : '');\n if (!adminState.wifi_allowed) {\n form.hidden = true; $('protocol-note').hidden = false;\n $('p1').className = $('p2').className = 'done'; $('p3').className = 'on';\n }\n refreshButtons();\n } catch (error) { say('管理服務尚未就緒,正在重試…'); setTimeout(adminStatus, 1500); }\n }\n async function connect() {\n if (setting) return;\n try {\n if (!adminState || !adminState.ready) throw Error('請稍候管理服務準備完成');\n if (!wifiReady()) { step(1); throw Error('請確認 Wi-Fi 名稱與密碼'); }\n setting = true; save.disabled = next.disabled = true; save.classList.add('loading'); next.classList.add('loading');\n if (!adminState.configured) {\n var admin = $('admin-password').value;\n if (admin.length < 8) throw Error('管理密碼至少需要 8 個字元');\n if (admin !== $('admin-confirm').value) throw Error('兩次管理密碼不相同');\n say('正在保護管理密碼,約需數秒…');\n var salt = CKPassword.salt(), key = await CKPassword.derive(admin, salt);\n $('admin-password').value = ''; $('admin-confirm').value = ''; admin = '';\n var enroll = await fetch('/admin/enroll', {method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},\n body: 'token=' + form.elements.token.value + '&credential=' + CKPassword.record(salt, key)});\n key = '';\n if (!enroll.ok) throw Error('管理密碼未保存,請重新開啟設定頁再試');\n adminState.configured = true;\n }\n say('密碼已保存,正在送出 Wi-Fi 設定…');\n var body = new URLSearchParams({token: form.elements.token.value, ssid: ssid.value, security: security.value,\n password: security.value === 'open' ? '' : password.value});\n var reply = await fetch('/wifi', {method: 'POST', credentials: 'same-origin', cache: 'no-store',\n headers: {'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'}, body: body.toString()});\n if (!reply.ok) {\n var text = (await reply.text()).trim();\n throw Error(reply.status === 409 ? '插座正在處理其他操作,請稍候再試' : text || '設定未送出,請重新開啟設定頁');\n }\n password.value = ''; $('done-ssid').textContent = '「' + ssid.value + '」'; step(3);\n } catch (error) {\n say(error.message, true); setting = false; save.disabled = false; refreshButtons();\n } finally { save.classList.remove('loading'); next.classList.remove('loading'); }\n }\n form.addEventListener('submit', function (event) { event.preventDefault(); connect(); });\n next.addEventListener('click', function () {\n if (!wifiReady()) return;\n if (adminState && adminState.configured) { connect(); return; }\n $('chosen-ssid').textContent = ssid.value; say(''); step(2); $('admin-password').focus();\n });\n $('back').addEventListener('click', function () { say(''); step(1); });\n\n function passwordMode() {\n var open = security.value === 'open';\n password.required = !open; password.minLength = open ? 0 : 8;\n passwordArea.hidden = open; if (open) password.value = '';\n refreshButtons();\n }\n function select(index) {\n var item = networks[index];\n if (!item || item.security === 'unsupported') return;\n chosen = index;\n Array.prototype.forEach.call(list.children, function (row) { row.setAttribute('aria-checked', String(Number(row.dataset.index) === index)); });\n ssid.value = item.ssid; security.value = item.security; manual.open = false;\n passwordMode();\n if (item.security !== 'open') password.focus();\n }\n function row(item, index) {\n var button = document.createElement('button'), bars = document.createElement('span'), name = document.createElement('span'), meta = document.createElement('small');\n button.type = 'button'; button.className = 'net'; button.dataset.index = String(index);\n button.setAttribute('role', 'radio'); button.setAttribute('aria-checked', 'false');\n bars.className = 'bars'; bars.dataset.level = item.signal >= 70 ? '3' : item.signal >= 40 ? '2' : '1';\n for (var i = 0; i < 3; i++) bars.appendChild(document.createElement('i'));\n name.className = 'ssid'; name.textContent = item.ssid;\n meta.textContent = item.security === 'open' ? '免密碼' : item.security === 'unsupported' ? '不支援此加密方式' :\n item.signal >= 70 ? '訊號良好' : item.signal >= 40 ? '訊號普通' : '訊號較弱';\n button.appendChild(bars); button.appendChild(name); button.appendChild(meta);\n if (item.security === 'wpa2') {\n var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'), use = document.createElementNS('http://www.w3.org/2000/svg', 'use');\n svg.setAttribute('class', 'i lock'); svg.setAttribute('aria-hidden', 'true'); use.setAttribute('href', '#i-lock'); svg.appendChild(use); button.appendChild(svg);\n }\n button.disabled = item.security === 'unsupported';\n button.addEventListener('click', function () { select(index); });\n return button;\n }\n function show(items) {\n networks = items; chosen = -1; list.textContent = '';\n items.forEach(function (item, index) { list.appendChild(row(item, index)); });\n if (!items.some(function (item) { return item.security !== 'unsupported'; })) { manual.open = true; }\n refreshButtons();\n }\n function request(fresh) {\n var controller = new AbortController();\n var timeout = setTimeout(function () { controller.abort(); }, 6000);\n var body = 'token=' + encodeURIComponent(form.elements.token.value) + (fresh ? '&refresh=1' : '');\n return fetch('/wifi-scan', {method: 'POST', credentials: 'same-origin',\n headers: {'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'},\n body: body, signal: controller.signal, cache: 'no-store'})\n .then(function (response) {\n clearTimeout(timeout);\n if (!response.ok) throw new Error('scan failed');\n return response.json();\n }, function (error) { clearTimeout(timeout); throw error; });\n }\n function scan() {\n if (scanning) return;\n scanning = true; retry.disabled = true;\n status.textContent = '正在尋找附近的 2.4 GHz Wi-Fi…';\n var tries = 0;\n function poll(fresh) {\n request(fresh).then(function (result) {\n if (result.status === 'scanning' && tries++ < 28) {\n setTimeout(function () { poll(false); }, 1000); return;\n }\n scanning = false; retry.disabled = false;\n if (result.status !== 'ready' || !Array.isArray(result.networks)) throw new Error('scan failed');\n show(result.networks);\n status.textContent = result.networks.length ? '找到 ' + result.networks.length + ' 個網路,點選家中的 Wi-Fi。' : '沒有找到網路。可重新掃描,或手動輸入名稱。';\n }).catch(function () {\n scanning = false; retry.disabled = false;\n show([]);\n status.textContent = '掃描未完成。請重新掃描,或手動輸入網路名稱。';\n });\n }\n poll(true);\n }\n security.addEventListener('change', passwordMode);\n ssid.addEventListener('input', function () {\n chosen = -1;\n Array.prototype.forEach.call(list.children, function (item) { if (item.dataset.index) item.setAttribute('aria-checked', 'false'); });\n refreshButtons();\n });\n password.addEventListener('input', refreshButtons);\n manual.addEventListener('toggle', function () { if (manual.open) ssid.focus(); refreshButtons(); });\n retry.addEventListener('click', scan);\n Array.prototype.forEach.call(document.querySelectorAll('.eye'), function (button) {\n button.addEventListener('click', function () {\n var input = $(button.dataset.for), reveal = input.type === 'password';\n input.type = reveal ? 'text' : 'password'; button.setAttribute('aria-label', reveal ? '隱藏密碼' : '顯示密碼');\n });\n });\n adminStatus(); passwordMode(); manual.open = false; scan();\n}());\n"; diff --git a/firmware/matter/src/setup/portal-scan.js b/firmware/matter/src/setup/portal-scan.js index 2b26626..c2fcdcf 100644 --- a/firmware/matter/src/setup/portal-scan.js +++ b/firmware/matter/src/setup/portal-scan.js @@ -1,62 +1,125 @@ 'use strict'; (function () { - var form = document.getElementById('wifi-form'); - var list = document.getElementById('network'); - var manual = document.getElementById('manual-network'); - var ssid = document.getElementById('ssid'); - var security = document.getElementById('security'); - var password = document.getElementById('password'); - var passwordArea = document.getElementById('password-area'); - var status = document.getElementById('scan-status'); - var retry = document.getElementById('scan-again'); - var save = document.getElementById('save'); - var networks = [], scanning = false; + function $(id) { return document.getElementById(id); } + var form = $('wifi-form'), list = $('network'), manual = $('manual-network'), ssid = $('ssid'), security = $('security'); + var password = $('password'), passwordArea = $('password-area'), status = $('scan-status'), retry = $('scan-again'); + var next = $('next'), save = $('save'), adminMessage = $('admin-setup-status'); + var networks = [], scanning = false, adminState = null, setting = false, chosen = -1; + form.noValidate = true; + $('protocol-management').href = 'http://' + location.hostname + ':18082/'; + var code = $('matter-manual'), raw = code.textContent.trim(); + if (/^\d{11}$/.test(raw)) code.textContent = raw.slice(0, 4) + '-' + raw.slice(4, 7) + '-' + raw.slice(7); + + function step(n) { + [1, 2, 3].forEach(function (i) { + $('step-' + i).hidden = i !== n; + $('p' + i).className = i === n ? 'on' : i < n ? 'done' : ''; + }); + adminMessage.hidden = n === 3; + window.scrollTo(0, 0); + } + function say(text, error) { adminMessage.textContent = text; adminMessage.dataset.tone = error ? 'error' : ''; } + function wifiReady() { + var open = security.value === 'open', name = ssid.value; + return name.length > 0 && new TextEncoder().encode(name).length <= 32 && (open || password.value.length >= 8); + } + function refreshButtons() { + next.disabled = setting || !wifiReady() || !adminState || !adminState.ready; + next.textContent = adminState && adminState.configured ? '儲存並連線' : '下一步:建立管理密碼'; + } + async function adminStatus() { + try { + var response = await fetch('/admin/status', {cache: 'no-store'}); + adminState = await response.json(); + if (!adminState.ready) { setTimeout(adminStatus, 1500); return; } + $('admin-setup').hidden = adminState.configured; + say(adminState.configured ? '管理密碼已設定,送出後直接連線。' : ''); + if (!adminState.wifi_allowed) { + form.hidden = true; $('protocol-note').hidden = false; + $('p1').className = $('p2').className = 'done'; $('p3').className = 'on'; + } + refreshButtons(); + } catch (error) { say('管理服務尚未就緒,正在重試…'); setTimeout(adminStatus, 1500); } + } + async function connect() { + if (setting) return; + try { + if (!adminState || !adminState.ready) throw Error('請稍候管理服務準備完成'); + if (!wifiReady()) { step(1); throw Error('請確認 Wi-Fi 名稱與密碼'); } + setting = true; save.disabled = next.disabled = true; save.classList.add('loading'); next.classList.add('loading'); + if (!adminState.configured) { + var admin = $('admin-password').value; + if (admin.length < 8) throw Error('管理密碼至少需要 8 個字元'); + if (admin !== $('admin-confirm').value) throw Error('兩次管理密碼不相同'); + say('正在保護管理密碼,約需數秒…'); + var salt = CKPassword.salt(), key = await CKPassword.derive(admin, salt); + $('admin-password').value = ''; $('admin-confirm').value = ''; admin = ''; + var enroll = await fetch('/admin/enroll', {method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: 'token=' + form.elements.token.value + '&credential=' + CKPassword.record(salt, key)}); + key = ''; + if (!enroll.ok) throw Error('管理密碼未保存,請重新開啟設定頁再試'); + adminState.configured = true; + } + say('密碼已保存,正在送出 Wi-Fi 設定…'); + var body = new URLSearchParams({token: form.elements.token.value, ssid: ssid.value, security: security.value, + password: security.value === 'open' ? '' : password.value}); + var reply = await fetch('/wifi', {method: 'POST', credentials: 'same-origin', cache: 'no-store', + headers: {'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'}, body: body.toString()}); + if (!reply.ok) { + var text = (await reply.text()).trim(); + throw Error(reply.status === 409 ? '插座正在處理其他操作,請稍候再試' : text || '設定未送出,請重新開啟設定頁'); + } + password.value = ''; $('done-ssid').textContent = '「' + ssid.value + '」'; step(3); + } catch (error) { + say(error.message, true); setting = false; save.disabled = false; refreshButtons(); + } finally { save.classList.remove('loading'); next.classList.remove('loading'); } + } + form.addEventListener('submit', function (event) { event.preventDefault(); connect(); }); + next.addEventListener('click', function () { + if (!wifiReady()) return; + if (adminState && adminState.configured) { connect(); return; } + $('chosen-ssid').textContent = ssid.value; say(''); step(2); $('admin-password').focus(); + }); + $('back').addEventListener('click', function () { say(''); step(1); }); + function passwordMode() { var open = security.value === 'open'; - password.required = !open; - password.minLength = open ? 0 : 8; - passwordArea.hidden = open; - if (open) password.value = ''; + password.required = !open; password.minLength = open ? 0 : 8; + passwordArea.hidden = open; if (open) password.value = ''; + refreshButtons(); } - function choose() { - if (list.value === 'manual') { - manual.open = true; - ssid.value = ''; - security.value = 'wpa2'; - save.disabled = scanning; - ssid.focus(); - } else if (list.value !== '') { - var item = networks[Number(list.value)]; - if (!item || item.security === 'unsupported') return; - ssid.value = item.ssid; - security.value = item.security; - manual.open = false; - save.disabled = scanning; - if (item.security !== 'open') password.focus(); - } else save.disabled = true; + function select(index) { + var item = networks[index]; + if (!item || item.security === 'unsupported') return; + chosen = index; + Array.prototype.forEach.call(list.children, function (row) { row.setAttribute('aria-checked', String(Number(row.dataset.index) === index)); }); + ssid.value = item.ssid; security.value = item.security; manual.open = false; passwordMode(); + if (item.security !== 'open') password.focus(); } - function option(text, value, disabled) { - var item = document.createElement('option'); - item.textContent = text; item.value = value; item.disabled = !!disabled; - list.appendChild(item); + function row(item, index) { + var button = document.createElement('button'), bars = document.createElement('span'), name = document.createElement('span'), meta = document.createElement('small'); + button.type = 'button'; button.className = 'net'; button.dataset.index = String(index); + button.setAttribute('role', 'radio'); button.setAttribute('aria-checked', 'false'); + bars.className = 'bars'; bars.dataset.level = item.signal >= 70 ? '3' : item.signal >= 40 ? '2' : '1'; + for (var i = 0; i < 3; i++) bars.appendChild(document.createElement('i')); + name.className = 'ssid'; name.textContent = item.ssid; + meta.textContent = item.security === 'open' ? '免密碼' : item.security === 'unsupported' ? '不支援此加密方式' : + item.signal >= 70 ? '訊號良好' : item.signal >= 40 ? '訊號普通' : '訊號較弱'; + button.appendChild(bars); button.appendChild(name); button.appendChild(meta); + if (item.security === 'wpa2') { + var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'), use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); + svg.setAttribute('class', 'i lock'); svg.setAttribute('aria-hidden', 'true'); use.setAttribute('href', '#i-lock'); svg.appendChild(use); button.appendChild(svg); + } + button.disabled = item.security === 'unsupported'; + button.addEventListener('click', function () { select(index); }); + return button; } function show(items) { - networks = items; - list.textContent = ''; - option('請選擇家中的 Wi-Fi', ''); - items.forEach(function (item, index) { - var signal = item.signal >= 70 ? '訊號良好' : item.signal >= 40 ? '訊號普通' : '訊號較弱'; - var suffix = item.security === 'open' ? '免密碼' : item.security === 'unsupported' ? '不支援此加密方式' : signal; - option(item.ssid + ' · ' + suffix, String(index), item.security === 'unsupported'); - }); - option('手動輸入/隱藏網路', 'manual'); - list.disabled = false; - list.required = true; - save.disabled = true; - if (!items.some(function (item) { return item.security !== 'unsupported'; })) { - list.value = 'manual'; choose(); - } + networks = items; chosen = -1; list.textContent = ''; + items.forEach(function (item, index) { list.appendChild(row(item, index)); }); + if (!items.some(function (item) { return item.security !== 'unsupported'; })) { manual.open = true; } + refreshButtons(); } function request(fresh) { var controller = new AbortController(); @@ -73,8 +136,7 @@ } function scan() { if (scanning) return; - scanning = true; - retry.disabled = true; save.disabled = true; + scanning = true; retry.disabled = true; status.textContent = '正在尋找附近的 2.4 GHz Wi-Fi…'; var tries = 0; function poll(fresh) { @@ -85,7 +147,7 @@ scanning = false; retry.disabled = false; if (result.status !== 'ready' || !Array.isArray(result.networks)) throw new Error('scan failed'); show(result.networks); - status.textContent = result.networks.length ? '選擇網路,再輸入密碼即可。' : '沒有找到網路。可重新掃描,或手動輸入名稱。'; + status.textContent = result.networks.length ? '找到 ' + result.networks.length + ' 個網路,點選家中的 Wi-Fi。' : '沒有找到網路。可重新掃描,或手動輸入名稱。'; }).catch(function () { scanning = false; retry.disabled = false; show([]); @@ -94,12 +156,20 @@ } poll(true); } - list.addEventListener('change', choose); security.addEventListener('change', passwordMode); - ssid.addEventListener('input', function () { list.value = 'manual'; save.disabled = scanning; }); + ssid.addEventListener('input', function () { + chosen = -1; + Array.prototype.forEach.call(list.children, function (item) { if (item.dataset.index) item.setAttribute('aria-checked', 'false'); }); + refreshButtons(); + }); + password.addEventListener('input', refreshButtons); + manual.addEventListener('toggle', function () { if (manual.open) ssid.focus(); refreshButtons(); }); retry.addEventListener('click', scan); - document.getElementById('show-password').addEventListener('change', function (event) { - password.type = event.target.checked ? 'text' : 'password'; + Array.prototype.forEach.call(document.querySelectorAll('.eye'), function (button) { + button.addEventListener('click', function () { + var input = $(button.dataset.for), reveal = input.type === 'password'; + input.type = reveal ? 'text' : 'password'; button.setAttribute('aria-label', reveal ? '隱藏密碼' : '顯示密碼'); + }); }); - passwordMode(); manual.open = false; scan(); + adminStatus(); passwordMode(); manual.open = false; scan(); }()); diff --git a/firmware/matter/src/setup/setupd-device.c b/firmware/matter/src/setup/setupd-device.c index d773c78..4ef8b44 100644 --- a/firmware/matter/src/setup/setupd-device.c +++ b/firmware/matter/src/setup/setupd-device.c @@ -6,6 +6,8 @@ #undef diag_main #include "portal-device-page.h" #include "portal-scan-script.h" +#include "../update/ck_matter_control.h" +#include "../update/password_script.h" static char kMatterManualCode[12]; #include "ram_probe.h" static int ram_worker = -1, ram_time, received_body, content_len; @@ -18,6 +20,33 @@ static const char *mode = "starting"; static char csrf[65], portal_ip[32] = "192.168.1.254", iface_name[16] = "br0"; static char host_allowed[48]; static u8 portal_addr[4]; +static const char *identity_keypath; +static int identity_ready, identity_checked = -5, first_setup_handled; +static int boot_reconnect, network_checked, heartbeat_written = -2; +static void ap_begin(void); +static void identity_tick(void) { + if (identity_ready || seconds() - identity_checked < 5) return; + identity_checked = seconds(); + if (!fixture) { + int imported=open_ro("/var/run/ck-identity-ready"); + if (imported<0) return; + close_fd(imported); + int generated=open_ro("/var/run/ck-onboarding-ready"); + if (generated<0) return; + close_fd(generated); + } + char manual[12], text[66]; u8 candidate[32], random[32]; + int n = read_file("/var/run/matter-manual-code", manual, sizeof(manual)); + if (n != 11) return; + for (int i = 0; i < 11; ++i) if (manual[i] < '0' || manual[i] > '9') return; + n = read_file(identity_keypath, text, sizeof(text)); + if ((n != 64 && n != 65) || unhex(text, candidate, 32)) { zero(text,sizeof(text)); return; } + zero(text,sizeof(text)); + if (random_bytes(random,32) != 32 || selftest()) { zero(candidate,32); zero(random,32); return; } + copy(key,candidate,32); zero(candidate,32); + copy(kMatterManualCode,manual,11); kMatterManualCode[11]=0; + hex(random,32,csrf); zero(random,32); identity_ready=1; +} static int scan_worker = -1, scan_running, scan_started, scan_finished, scan_error; static int fixture_scan_error; static char scan_json[16384]; @@ -83,6 +112,8 @@ static void status_file(void) { } } static void state(const char *s) { + if(equal(s,"online") || equal(s,"setup") || equal(s,"error")) + S1(4010,P("/var/run/ck-network-changing")); mode = s; status_file(); } @@ -140,6 +171,8 @@ static void start_worker(const char *op) { } static void ap_begin(void) { close_portal(); + boot_reconnect = 0; + if (!identity_ready) { state("waiting-identity"); return; } state("ap-starting"); if (fixture) { state("setup"); @@ -149,7 +182,7 @@ static void ap_begin(void) { } static void online(void) { close_portal(); - if (fixture) { + if (fixture || boot_reconnect) { state("online"); } else { state("committing"); @@ -158,10 +191,31 @@ static void online(void) { } static void tick(void) { int now = seconds(), st = 0; + if (!fixture && now - heartbeat_written >= 2) { + int f = ram_open("/var/run/ck-setup-heartbeat"); + used=0; number(now); append("\n"); + if (f >= 0) { write_all(f,report,(int)used); close_fd(f); } + heartbeat_written=now; + } + identity_tick(); + if (!fixture && identity_ready && !first_setup_handled) { + first_setup_handled=1; + int flag=open_ro("/var/run/ck-first-setup"); + if (flag>=0) { close_fd(flag); if (worker<0) ap_begin(); else first_setup_handled=0; } + else if (equal(mode,"waiting-identity")) ap_begin(); + } scan_tick(); char lease[64]; if (control_fd < 0) control_fd = socket_at(2, control_port, 1); + if(portal_fd<0) portal_fd=socket_at(2,http_port,1); + if(!fixture && worker<0 && !action_time) { + int pending=open_ro("/var/run/ck-admin-wifi"); + if(pending>=0) { + close_fd(pending);S1(4010,P("/var/run/ck-admin-wifi")); + boot_reconnect=0;close_portal();join_time=now;state("joining");start_worker("join"); + } + } if (ram_worker > 0) { int r = S3(4007, ram_worker, P(&st), 1); if (r == ram_worker) { @@ -179,13 +233,15 @@ static void tick(void) { if (r == worker) { worker = -1; /* sysconf may replace br0. SO_BINDTODEVICE stores its old ifindex. */ - if (!equal(mode, "committing")) { + if (equal(mode, "ap-starting") || equal(mode,"joining")) { close_fd(control_fd); control_fd = socket_at(2, control_port, 1); } if (st) { error_code = 2; - if (equal(mode, "joining") || equal(mode, "reconnecting")) + if (equal(mode,"reconnecting") && boot_reconnect) { + network_checked=now; + } else if (equal(mode, "joining") || equal(mode, "reconnecting")) ap_begin(); else state("error"); @@ -199,14 +255,16 @@ static void tick(void) { S3(4007, worker, P(&st), 0); worker = -1; error_code = 3; - if (equal(mode, "joining") || equal(mode, "reconnecting")) + if (equal(mode,"reconnecting") && boot_reconnect) { + network_checked=now; + } else if (equal(mode, "joining") || equal(mode, "reconnecting")) ap_begin(); else state("error"); } } - if (equal(mode, "setup") && portal_fd < 0) { - portal_fd = socket_at(2, http_port, 0); + if (equal(mode, "setup") && dns_fd < 0) { + if(portal_fd<0)portal_fd = socket_at(2, http_port, 1); dns_fd = socket_at(1, dns_port, 0); if (portal_fd < 0 || dns_fd < 0) { close_portal(); @@ -235,9 +293,17 @@ static void tick(void) { online(); } else if (now - join_time > 120) { error_code = 5; - ap_begin(); + if (boot_reconnect) { + // A transient outage must not overwrite the saved station SSID/PSK. + join_time=now; + } else ap_begin(); } } + if (!fixture && boot_reconnect && worker<0 && now-network_checked>=30) { + network_checked=now; + if (equal(mode,"error")) state("reconnecting"); + start_worker("resume"); + } if (fixture && equal(mode, "joining")) online(); } @@ -452,6 +518,39 @@ static int proof(const char *scope) { nonce_valid = 0; return 1; } +static int matter_state(void) { + int n=read_file(CK_MATTER_STATE,scratch,512); + if (n<=0 || n>=512 || !prefix(scratch,"{\"updated_s\":")) return -1; + int stamp=0, at=13; + while (at='0' && scratch[at]<='9') { + if (stamp>200000000) return -1; + stamp=stamp*10+scratch[at++]-'0'; + } + int now=seconds(); + return at==13 || scratch[at]!=',' || now5 ? -1 : n; +} +static void matter_request(int fd, char action) { + if (S2(4033,P(CK_PROTOCOL_PENDING),0)==0) { + response(fd,"409 Conflict","Protocol switch busy\n",21,0);return; + } + if ((!fixture && !equal(mode,"online")) || worker>0 || ram_worker>0 || action_time || matter_state()<0) { + response(fd,"503 Unavailable","Matter not ready\n",17,0);return; + } + if (S2(4039,P(CK_MATTER_LOCK),0700)<0) { + response(fd,"409 Conflict","Matter operation busy\n",22,0);return; + } + char command[67];command[0]=action;command[1]=' ';copy(command+2,nonce,64);command[66]='\n'; + const char *temporary=CK_MATTER_REQUEST ".tmp"; + int f=S3(4005,P(temporary),0x20301,0600); + int ok=f>=0 && write_all(f,command,sizeof(command))==0; + if (f>=0) close_fd(f); + if (!ok || S2(4038,P(temporary),P(CK_MATTER_REQUEST))<0) { + S1(4010,P(temporary));S1(4040,P(CK_MATTER_LOCK)); + response(fd,"503 Unavailable","Mailbox unavailable\n",20,0);return; + } + used=0;append("{\"request_id\":\"");append(nonce);append("\",\"result\":\"queued\"}\n"); + response(fd,"202 Accepted",report,(int)used,0); +} static int make_jail(void) { const char *dirs[] = {RAM_JAIL, RAM_JAIL "/dev", 0}; for (int i = 0; dirs[i]; i++) { @@ -674,7 +773,22 @@ static void handle_setup(int fd, int portal) { response(fd, "400 Bad Request", "Invalid request\n", 16, 0); return; } + if(portal && !equal(mode,"setup")) { + if(prefix(request,"GET ")) { + u8 address[16];int size=16; + if(S3(4172,fd,P(address),P(&size))<0){response(fd,"503 Unavailable","Unavailable\n",12,0);return;} + used=0;append("HTTP/1.1 302 Found\r\nLocation: http://"); + for(int i=4;i<8;i++){if(i>4)append(".");number(address[i]);} + append(":18082/\r\nCache-Control: no-store\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");send_all(fd,report,used); + }else response(fd,"403 Forbidden","Use management login\n",21,0); + return; + } + if (!identity_ready) { + response(fd,"503 Unavailable","Device identity not ready\n",26,0); + return; + } if (!portal) { + if(prefix(request,"POST ")) {response(fd,"403 Forbidden","Use management login\n",21,0);return;} if (prefix(request, "POST /ram-probe HTTP/")) { ram_upload(fd, base); return; @@ -713,6 +827,18 @@ static void handle_setup(int fd, int portal) { response(fd, "200 OK", report, (int)used, 0); return; } + if (prefix(request,"GET /matter-state HTTP/") && proof("GET /matter-state\n")) { + int n=matter_state(); + if(n<0) response(fd,"503 Unavailable","Matter not ready\n",17,0); + else response(fd,"200 OK",scratch,n,0); + return; + } + if (prefix(request,"POST /reset-matter HTTP/") && request[base]==0 && proof("POST /reset-matter\n")) { + matter_request(fd,'R');return; + } + if (prefix(request,"POST /open-commissioning HTTP/") && request[base]==0 && proof("POST /open-commissioning\n")) { + matter_request(fd,'O');return; + } if (prefix(request, "POST /enter-setup HTTP/") && request[base] == 0 && proof("POST /enter-setup\n")) { if (worker > 0 || ram_worker > 0 || equal(mode, "joining") || @@ -757,6 +883,63 @@ static void handle_setup(int fd, int portal) { response_typed(fd, "200 OK", portal_scan_script, slen(portal_scan_script), "application/javascript; charset=utf-8"); return; } + if(prefix(request,"GET /password.js HTTP/")) { + response_typed(fd,"200 OK",ck_password_script,slen(ck_password_script),"application/javascript; charset=utf-8");return; + } + if(prefix(request,"GET /admin/status HTTP/")) { + char value[2];int n=read_file("/var/run/ck-admin-state",value,sizeof(value)); + int ok=open_ro("/var/run/ck-admin-enroll-ok");if(ok>=0)close_fd(ok); + const char *body=n!=1 || (value[0]!='0' && value[0]!='1')?"{\"ready\":false}": + value[0]=='0'?"{\"ready\":true,\"configured\":false,\"wifi_allowed\":true}": + ok>=0?"{\"ready\":true,\"configured\":true,\"wifi_allowed\":true}":"{\"ready\":true,\"configured\":true,\"wifi_allowed\":false}"; + response_typed(fd,"200 OK",body,slen(body),"application/json");return; + } + if(prefix(request,"POST /admin/enroll HTTP/")) { + const char *body=request+base; + if(!origin_ok() || !form_type() || content_len!=202 || slen(body)!=202 || !prefix(body,"token=") || + !prefix(body+70,"&credential=") || worker>0 || action_time) { + response(fd,"400 Bad Request","Invalid enrollment\n",19,0);return; + } + int diff=0;for(int i=0;i<64;i++)diff|=(u8)body[6+i]^(u8)csrf[i]; + u8 credential[60]; + if(diff || unhex(body+82,credential,60) || !prefix((char *)credential,"CKAUTH1\n") || + credential[8] || credential[9]!=9 || credential[10]!=0x27 || credential[11]!=0xc0) { + response(fd,"403 Forbidden","Invalid enrollment\n",19,0);return; + } + int out=ram_open("/var/run/ck-admin-enroll");int error=out<0 || write_all(out,credential,60); + close_fd(out);zero(credential,60); + if(error){response(fd,"503 Unavailable","Storage unavailable\n",20,0);return;} + int child=S0(4002); + if(!child){for(int i=3;i<128;i++)close_fd(i);char *args[]={"/bin/ck-updater","--enroll-admin",0},*env[]={0};S3(4011,P(args[0]),P(args),P(env));S1(4001,127);for(;;){}} + int status=0,done=0,until=seconds()+10; + while(child>0 && seconds()0){S2(4037,child,9);S3(4007,child,P(&status),0);} + S1(4010,P("/var/run/ck-admin-enroll")); + const char *result=done && !status?"{\"configured\":true}":"{\"error\":\"enrollment\"}"; + response_typed(fd,done&&!status?"200 OK":"409 Conflict",result,slen(result),"application/json");return; + } + if (prefix(request, "POST /management-key HTTP/")) { + response(fd,"403 Forbidden","Use management password\n",24,0);return; + const char *body = request + base; + int diff = 0; + if (!origin_ok() || !form_type() || content_len != 70 || slen(body) != 70 || + !prefix(body,"token=") || !equal(mode,"setup") || action_time || worker > 0) { + response(fd,"403 Forbidden","Setup page required\n",20,0); return; + } + for (int i=0;i<64;++i) diff |= (u8)body[6+i] ^ (u8)csrf[i]; + if (diff) { response(fd,"403 Forbidden","Expired setup page\n",19,0); return; } + char secret[65]; hex(key,32,secret); + used=0; + append("{\"format\":\"commandkit-management-v1\",\"diag_key\":\""); + append(secret); append("\"}\n"); + zero(secret,sizeof(secret)); + response_typed(fd,"200 OK",report,(int)used,"application/json; charset=utf-8"); + zero(report,used); used=0; + return; + } if (prefix(request, "POST /wifi-scan HTTP/")) { const char *body = request + base; if (!origin_ok() || !form_type()) { @@ -787,6 +970,9 @@ static void handle_setup(int fd, int portal) { return; } if (prefix(request, "POST /wifi HTTP/")) { + int enrolled=open_ro("/var/run/ck-admin-enroll-ok"); + if(enrolled<0){response(fd,"403 Forbidden","Set management password first\n",30,0);return;} + close_fd(enrolled); if (!origin_ok() || !form_type()) { response(fd, "403 Forbidden", "請重新開啟設定頁再試一次。", slen("請重新開啟設定頁再試一次。"), 0); return; @@ -814,7 +1000,7 @@ static void handle_setup(int fd, int portal) { state("join-queued"); action_time = seconds() + 3; used = 0; append(p); append(kMatterManualCode); - append("

開發版配對資料只存在 RAM;斷電後需重新配對。

"); + append("

配對資料已保存,管理頁可使用剛設定的密碼登入。

"); response(fd, "202 Accepted", report, (int)used, 1); return; } @@ -860,8 +1046,16 @@ int diag_main(int argc, char **argv) { if (argc != 3 && argc != 6) return 2; fixture = argc == 6; + if (!fixture) { + int owner=S3(4005,P("/var/lock/ck-setup-owner.lock"),0x102,0600); + if (owner<0 || S2(4143,owner,6)<0) return 76; /* LOCK_EX|LOCK_NB */ + if (S3(4055,owner,2,1)<0) return 76; /* FD_CLOEXEC for backend children */ + int pidfile=ram_open("/var/run/ck-setup.pid"); + used=0; number(S0(4020)); append("\n"); + if (pidfile>=0) { write_all(pidfile,report,(int)used); close_fd(pidfile); } + } if (fixture) fixture_scan_error = equal(argv[4], "scan-error"); - const char *keypath = fixture ? argv[5] : argv[2]; + identity_keypath = fixture ? argv[5] : argv[2]; if (!fixture && !equal(argv[1], "--serve")) return 2; if (fixture && !equal(argv[1], "--fixture")) @@ -896,28 +1090,16 @@ int diag_main(int argc, char **argv) { number(http_port); copy(host_allowed, report, used + 1); } - if (read_file("/var/run/matter-manual-code", kMatterManualCode, sizeof(kMatterManualCode)) != 11) - return 11; - for (int i = 0; i < 11; ++i) - if (kMatterManualCode[i] < '0' || kMatterManualCode[i] > '9') return 11; - kMatterManualCode[11] = 0; - char k[66]; - int n = read_file(keypath, k, sizeof(k)); - if ((n != 64 && n != 65) || unhex(k, key, 32)) - return 3; - zero(k, sizeof(k)); - u8 b[32]; - if (random_bytes(b, 32) != 32 || selftest()) - return 4; - hex(b, 32, csrf); - zero(b, 32); + // Identity is retried independently; it never gates station DHCP. + identity_tick(); + int n; if (fixture) { control_port = 0; for (const char *p = argv[3]; *p >= '0' && *p <= '9'; p++) control_port = control_port * 10 + *p - '0'; } control_fd = socket_at(2, control_port, 1); - if (control_fd < 0) + if (fixture && control_fd < 0) return 5; if (fixture) state("setup"); @@ -925,11 +1107,15 @@ int diag_main(int argc, char **argv) { char configured[32]; n = read_file("/var/run/ck-setup-boot", configured, 31); if (n > 0 && prefix(configured, "configured")) { + boot_reconnect=1; + network_checked=seconds(); join_time = seconds(); state("reconnecting"); start_worker("resume"); - } else + } else { + first_setup_handled=identity_ready; ap_begin(); + } } for (;;) { tick(); diff --git a/firmware/matter/src/setup/wificfg-device.c b/firmware/matter/src/setup/wificfg-device.c index 6d15fc8..925bc3b 100644 --- a/firmware/matter/src/setup/wificfg-device.c +++ b/firmware/matter/src/setup/wificfg-device.c @@ -57,6 +57,13 @@ int main(int argc, char **argv) { printf("mode=%d\nconfigured=%d\nencryption=%d\npsk_empty=%d\n", mode, c, encryption, psk[0] == 0); return 0; } + if (argc == 2 && strcmp(argv[1], "unconfigure") == 0) { + integer(688,0);text(1,"");text(30,"");integer(25,0);integer(36,0); + if(failures || !apmib_update(4)) return 5; + int configured=1;char check_ssid[33]={0},check_psk[65]={0}; + if(!apmib_get(688,&configured) || !apmib_get(1,check_ssid) || !apmib_get(30,check_psk)) return 5; + return configured || check_ssid[0] || check_psk[0] ? 5 : 0; + } if (argc == 2 && strcmp(argv[1], "commit") == 0) { int configured = 0; if (!apmib_get(688, &configured)) diff --git a/firmware/matter/src/setup/wificfg.c b/firmware/matter/src/setup/wificfg.c index 6535da9..77e1733 100644 --- a/firmware/matter/src/setup/wificfg.c +++ b/firmware/matter/src/setup/wificfg.c @@ -57,6 +57,13 @@ int main(int argc, char **argv) { printf("mode=%d\nconfigured=%d\n", mode, c); return 0; } + if (argc == 2 && strcmp(argv[1], "unconfigure") == 0) { + integer(688,0);text(1,"");text(30,"");integer(25,0);integer(36,0); + if(failures || !apmib_update(4)) return 5; + int configured=1;char check_ssid[33]={0},check_psk[65]={0}; + if(!apmib_get(688,&configured) || !apmib_get(1,check_ssid) || !apmib_get(30,check_psk)) return 5; + return configured || check_ssid[0] || check_psk[0] ? 5 : 0; + } if (argc == 2 && strcmp(argv[1], "commit") == 0) { int configured = 0; if (!apmib_get(688, &configured)) diff --git a/firmware/matter/src/update/ck-identity-start b/firmware/matter/src/update/ck-identity-start new file mode 100644 index 0000000..c138456 --- /dev/null +++ b/firmware/matter/src/update/ck-identity-start @@ -0,0 +1,32 @@ +#!/bin/sh +umask 077 +mkdir -p /var/lib/ck-state /var/run/ck-ota /var/lock +: > /var/run/ck-identity-pending +trap 'rm -f /var/run/ck-identity-pending; exit 1' 1 2 15 +while :; do + printf 'phase=importing\n' > /var/run/ck-identity-state + if /bin/ck-boot-guard --import > /var/log/ck-store-init.log 2>&1; then result=0; else result=$?; fi + if [ "$result" = 75 ]; then + printf 'phase=waiting-entropy\n' > /var/run/ck-identity-state + if /bin/ck-boot-guard --import-wait >> /var/log/ck-store-init.log 2>&1; then result=0; else result=$?; fi + fi + if [ "$result" = 0 ]; then break; fi + printf 'phase=retrying\nerror=%s\n' "$result" > /var/run/ck-identity-state + # Never format unknown journal data or substitute a shared identity. + sleep 10 +done +: > /var/run/ck-identity-ready +rm -f /var/run/ck-identity-pending +printf 'phase=ready\n' > /var/run/ck-identity-state +if [ ! -f /var/run/ck-ota-booted ]; then + # Count this power-on once, even if the update HTTP daemon later crashes. + : > /var/run/ck-ota-booted + /bin/ck-boot-guard --boot >> /var/log/ck-store-init.log 2>&1 || : > /var/run/ck-rescue +fi +if [ ! -f /var/run/ck-ota/queued ]; then + rm -f /var/run/ck-ota/upload.ota /var/run/ck-ota/staging.ota +fi +/bin/ck-updater --admin-state || true +trap - 1 2 15 +# Management is a separate PID1 service, even when import never succeeds. +exec /bin/sleep 2147483647 diff --git a/firmware/matter/src/update/ck-ota-start b/firmware/matter/src/update/ck-ota-start index ead979e..d07acf2 100644 --- a/firmware/matter/src/update/ck-ota-start +++ b/firmware/matter/src/update/ck-ota-start @@ -2,9 +2,25 @@ set -eu umask 077 mkdir -p /var/lib/ck-state /var/run/ck-ota /var/lock -if ! /bin/ck-updater --import > /var/log/ck-store-init.log 2>&1; then +: > /var/run/ck-identity-pending +initialize_identity() { +if /bin/ck-updater --import > /var/log/ck-store-init.log 2>&1; then + result=0 +else + result=$? +fi +if [ "$result" = 75 ]; then + echo 'Waiting for device randomness; local button remains active.' >> /var/log/ck-store-init.log + if /bin/ck-updater --import-wait >> /var/log/ck-store-init.log 2>&1; then + result=0 + else + result=1 + fi +fi +if [ "$result" != 0 ]; then echo 'Persistent identity unavailable; Matter and firmware writes disabled.' >> /var/log/ck-store-init.log - touch /var/run/ck-rescue + : > /var/run/ck-rescue + rm -f /var/run/ck-identity-pending # Expose diagnostics/setup with this device's original bootstrap identity. # This is RAM only; do not replace unknown journal data or claim fabrics # are persistent. The MCU-only startup path retains local communication. @@ -16,9 +32,13 @@ if ! /bin/ck-updater --import > /var/log/ck-store-init.log 2>&1; then chmod 600 "/var/lib/ck-state/$dest" fi done - exit 1 + return 1 fi -/bin/ck-updater --boot || touch /var/run/ck-rescue +: > /var/run/ck-identity-ready +rm -f /var/run/ck-identity-pending +} +start_services() { +/bin/ck-updater --boot || : > /var/run/ck-rescue ( while :; do if [ ! -f /var/run/ck-ota/queued ]; then @@ -28,6 +48,22 @@ fi sleep 5 done ) & +} +# A public image may need to collect entropy for its first identity. Continue +# vendor network/MCU startup while that one bounded initializer waits. +if [ -x /bin/ck-boot-guard ]; then + # Snapshot the boot choice before PID1 starts the entropy/journal importer. + # A later import must never delay the selected service's physical controls. + /bin/ck-boot-guard --protocol-boot || printf unavailable > /var/run/ck-protocol-active + # Matter identity/update services are separately supervised by PID1. + # rcS must reach vendor Wi-Fi initialization even with a broken journal. + : +elif [ -f /etc/ck-identity-policy ] && [ ! -f /etc/matter-device.key ]; then + ( initialize_identity && start_services ) & +else + initialize_identity || exit 1 + start_services +fi # A failed application leaves the independent maintenance page available. ( # Allow the owner unlimited time to complete first-time Wi-Fi setup. @@ -43,16 +79,16 @@ fi read -r variant < /etc/ck-variant || true if [ "$variant" = exec-test ]; then sleep 10 - if /bin/pidof exec-diag >/dev/null && /bin/pidof ck-setupd >/dev/null && /bin/ck-updater --confirm; then - touch /var/run/ck-update-healthy + if /bin/ps | /bin/grep -q '[e]xec-diag' && /bin/ps | /bin/grep -q '[c]k-setupd' && /bin/ck-updater --confirm; then + : > /var/run/ck-update-healthy else - touch /var/run/ck-rescue + : > /var/run/ck-rescue fi exit 0 fi sleep 180 if [ ! -f /var/run/ck-update-healthy ]; then - touch /var/run/ck-rescue + : > /var/run/ck-rescue if [ ! -f /var/run/ck-mcu-only ]; then /bin/killall matterd-test 2>/dev/null || true fi diff --git a/firmware/matter/src/update/ck_admin.h b/firmware/matter/src/update/ck_admin.h new file mode 100644 index 0000000..746ace5 --- /dev/null +++ b/firmware/matter/src/update/ck_admin.h @@ -0,0 +1,29 @@ +#ifndef CK_ADMIN_H +#define CK_ADMIN_H +#include "ck_common.h" +#include "ck_store.h" +#include +#include +#define CK_ADMIN_KEY "admin/credential" +#define CK_ADMIN_BYTES 60 +#define CK_ADMIN_ITERATIONS 600000u +#define CK_FACTORY_PENDING "control/factory-reset" +static int ck_admin_valid(const uint8_t b[CK_ADMIN_BYTES]) { + return !memcmp(b,"CKAUTH1\n",8) && ck_be32(b+8)==CK_ADMIN_ITERATIONS; +} +/* 0 configured, 1 unclaimed, -1 storage/format failure. */ +static int ck_admin_load(uint8_t b[CK_ADMIN_BYTES]) { + size_t n=CK_ADMIN_BYTES;int r=ck_store_try_get(CK_ADMIN_KEY,b,&n); + return r==1 ? 1 : !r && n==CK_ADMIN_BYTES && ck_admin_valid(b) ? 0 : -1; +} +static int ck_factory_pending(void) { + uint8_t b[8];size_t n=sizeof(b);int r=ck_store_get(CK_FACTORY_PENDING,b,&n); + return r==1 ? 0 : !r && n==8 && !memcmp(b,"CKRESET1",8) ? 1 : -1; +} +static int ck_factory_request(void) { + int r=ck_atomic(ck_path("/var/run/ck-factory-pending"),"1",1,0600); + if(!r) r=ck_store_set(CK_FACTORY_PENDING,"CKRESET1",8); + if(r) unlink(ck_path("/var/run/ck-factory-pending")); + return r; +} +#endif diff --git a/firmware/matter/src/update/ck_common.c b/firmware/matter/src/update/ck_common.c index b4cb755..22f21df 100644 --- a/firmware/matter/src/update/ck_common.c +++ b/firmware/matter/src/update/ck_common.c @@ -210,7 +210,10 @@ int ck_download_begin(void) { int fd = ck_transfer_lock(); if (fd < 0) return -1; - if (ck_ram_storage() || !access(ck_path("/var/run/ck-ota/queued"), F_OK)) { + if (ck_ram_storage() || !access(ck_path("/var/run/ck-ota/queued"), F_OK) || + !access(ck_path("/var/run/ck-protocol-pending"),F_OK) || !access(ck_path("/var/run/ck-factory-pending"),F_OK) || + !access(ck_path("/var/run/ck-network-changing"),F_OK) || + !access(ck_path("/var/run/ck-matter-request.lock"),F_OK)) { close(fd); return -1; } @@ -356,6 +359,30 @@ int ck_hash_fd(int fd, unsigned off, unsigned n, uint8_t out[32]) { sha_end(&s, out); return 0; } +int ck_hash_factory_kernel(int fd, unsigned off, uint8_t out[32]) { + /* The factory web uploader rewrites only the bank-selection mark. All + * executable bytes, the length and the padding/checksum remain hashed. */ + Sha s; + uint8_t b[4096]; + unsigned remaining = 0x120000; + if (lseek(fd, off, SEEK_SET) < 0 || ck_read_all(fd, b, 16) || + memcmp(b, "cr6c", 4) || ck_be32(b + 4) != 0x80500000 || + ck_be32(b + 8) < 0x80000002 || ck_be32(b + 12) != 0x11fff0) + return -1; + ck_put32(b + 8, 0x8000004d); + sha_init(&s); + sha_add(&s, b, 16); + remaining -= 16; + while (remaining) { + unsigned take = remaining > sizeof(b) ? sizeof(b) : remaining; + if (ck_read_all(fd, b, take)) + return -1; + sha_add(&s, b, take); + remaining -= take; + } + sha_end(&s, out); + return 0; +} void ck_hmac(const uint8_t k[32], const void *p, size_t n, uint8_t out[32]) { hmac(k, 32, p, n, out); } diff --git a/firmware/matter/src/update/ck_common.h b/firmware/matter/src/update/ck_common.h index 21afc54..83ad606 100644 --- a/firmware/matter/src/update/ck_common.h +++ b/firmware/matter/src/update/ck_common.h @@ -26,6 +26,7 @@ void ck_sha256(const void *data, size_t n, uint8_t out[32]); void ck_sha256_pair(const void *a, size_t an, const void *b, size_t bn, uint8_t out[32]); int ck_hash_fd(int fd, unsigned offset, unsigned size, uint8_t out[32]); +int ck_hash_factory_kernel(int fd, unsigned offset, uint8_t out[32]); int ck_hash_pair(int a, unsigned ao, unsigned an, int b, unsigned bo, unsigned bn, uint8_t out[32]); void ck_hmac(const uint8_t key[32], const void *data, size_t size, @@ -44,6 +45,7 @@ int ck_flash_write(struct ck_flash *f, unsigned at, const void *p, unsigned n); int ck_flash_erase(struct ck_flash *f, unsigned at, unsigned n); int ck_flash_sync(struct ck_flash *f); int ck_lock(void); +int ck_try_lock(void); void ck_unlock(int fd); int ck_download_begin(void); int ck_transfer_lock(void); diff --git a/firmware/matter/src/update/ck_energy.c b/firmware/matter/src/update/ck_energy.c index ab7c2a6..fb45de9 100644 --- a/firmware/matter/src/update/ck_energy.c +++ b/firmware/matter/src/update/ck_energy.c @@ -48,8 +48,8 @@ static int scan(struct ck_flash *f,uint8_t pages[2*CK_ERASE_SIZE],int *latest) { } return 0; } -int ck_energy_load(uint64_t *mwh,uint32_t *remainder) { - int lock=ck_lock(),result=-1,latest; +static int load_energy(uint64_t *mwh,uint32_t *remainder,int nonblocking) { + int lock=nonblocking?ck_try_lock():ck_lock(),result=-1,latest; struct ck_flash f;uint8_t pages[2*CK_ERASE_SIZE]; if(lock<0) return -1; if(ck_flash_open(&f,1,0)) goto done; @@ -60,7 +60,9 @@ int ck_energy_load(uint64_t *mwh,uint32_t *remainder) { closed:close(f.fd); done:ck_unlock(lock);return result; } -int ck_energy_save(uint64_t mwh,uint32_t remainder) { +int ck_energy_load(uint64_t *mwh,uint32_t *remainder) { return load_energy(mwh,remainder,0); } +int ck_energy_try_load(uint64_t *mwh,uint32_t *remainder) { return load_energy(mwh,remainder,1); } +static int save_energy(uint64_t mwh,uint32_t remainder,int reset) { if(mwh>ENERGY_MAX || remainder>=DENOMINATOR) return -1; int lock=ck_lock(),result=-1,latest; struct ck_flash f;uint8_t pages[2*CK_ERASE_SIZE],record[RECORD],verify[RECORD]; @@ -71,7 +73,7 @@ int ck_energy_save(uint64_t mwh,uint32_t remainder) { if(seq==UINT64_MAX) goto closed; if(latest>=0) { uint64_t old=ck_be64(pages+latest*RECORD+16);uint32_t fraction=ck_be32(pages+latest*RECORD+24); - if(mwh= 0 && flock(fd, LOCK_EX) == 0) + if (fd >= 0 && flock(fd, LOCK_EX | (nonblocking ? LOCK_NB : 0)) == 0) return fd; if (fd >= 0) close(fd); return -1; } +int ck_lock(void) { return lock_flash(0); } +int ck_try_lock(void) { return lock_flash(1); } void ck_unlock(int fd) { if (fd >= 0) { flock(fd, LOCK_UN); diff --git a/firmware/matter/src/update/ck_identity_init.h b/firmware/matter/src/update/ck_identity_init.h new file mode 100644 index 0000000..defce0b --- /dev/null +++ b/firmware/matter/src/update/ck_identity_init.h @@ -0,0 +1,8 @@ +#ifndef CK_IDENTITY_INIT_H +#define CK_IDENTITY_INIT_H +/* Public installation intent, never a seed or credential. The second + * snapshot slot carries this until the initial identity commit is verified. */ +#define CK_INIT_MARKER "CKAUTOINIT-V1\r\n" +#define CK_INIT_MARKER_SIZE 15u +#define CK_ENTROPY_PENDING 75 +#endif diff --git a/firmware/matter/src/update/ck_led.c b/firmware/matter/src/update/ck_led.c new file mode 100644 index 0000000..f57559f --- /dev/null +++ b/firmware/matter/src/update/ck_led.c @@ -0,0 +1,85 @@ +#define _GNU_SOURCE +#include "ck_led.h" +#include "ck_store.h" +#include +#include +#include +#include +#include +#include +#include +const char *ck_led_mode(int m) { static const char *names[]={"smart","relay","inverse","load","on","off"};return m>=0 && m<6?names[m]:"smart"; } +const char *ck_led_reason(int m) { static const char *names[]={"normal","night","pairing","joining","setup","error","identify"};return m>=0 && m<7?names[m]:"error"; } +uint64_t ck_led_now(void) { struct timespec t;if(clock_gettime(CLOCK_MONOTONIC,&t))return 0;return (uint64_t)t.tv_sec*1000+t.tv_nsec/1000000; } +void ck_led_default(struct ck_led_config *c) { memset(c,0,sizeof(*c));c->start=1380;c->end=420;c->offset=480;strcpy(c->ntp,"pool.ntp.org"); } +int ck_led_parse(const char *s,struct ck_led_config *c) { + char mode[16],host[128],numbers[4][24];int used=0;struct ck_led_config v; + if(strlen(s)>220 || strncmp(s,"CKLED1 ",7) || sscanf(s,"CKLED1 %15s %23s %23s %23s %23s %127s%n",mode,numbers[0],numbers[1],numbers[2],numbers[3],host,&used)!=6)return -1; + int *fields[]={&v.schedule,&v.start,&v.end,&v.offset}; + for(unsigned j=0;j<4;j++) { + char *end;errno=0;long n=strtol(numbers[j],&end,10); + if(errno || *end || n < -720 || n > 1440)return -1; + *fields[j]=(int)n; + } + if(s[used]=='\n')used++; + if(s[used])return -1; + for(v.mode=0;v.mode<6 && strcmp(mode,ck_led_mode(v.mode));v.mode++); + if(v.mode==6 || v.schedule<0 || v.schedule>1 || v.start<0 || v.start>=1440 || v.end<0 || v.end>=1440 || v.start==v.end || v.offset < -720 || v.offset>840 || v.offset%15)return -1; + size_t n=strlen(host);if(!n || host[0]=='-' || host[n-1]=='-' || host[0]=='.' || host[n-1]=='.')return -1; + for(size_t i=0;inow_ms;int hints=c->mode==CK_LED_SMART || c->mode==CK_LED_OFF; + if(c->schedule && i->time_valid) { + int minute=(int)((i->utc_s/60)%1440)+c->offset;minute=(minute+1440)%1440; + o.night=c->startend?(minute>=c->start && minuteend):(minute>=c->start || minuteend); + } + if(i->identify){o.reason=CK_LED_IDENTIFY;o.on=(t%500)<250;} + else if(hints && i->error){o.reason=CK_LED_ERROR;o.on=t%3000<500 && (t%200)<100;} + else if(hints && i->setup){o.reason=CK_LED_SETUP;o.on=t%1000<500;} + else if(hints && i->joining){o.reason=CK_LED_JOINING;o.on=t%2000<100;} + else if(hints && i->pairing){o.reason=CK_LED_PAIRING;o.on=t%2000<300 && t%200<100;} + else if(o.night)o.reason=CK_LED_NIGHT; + else switch(c->mode) { + case CK_LED_SMART:case CK_LED_RELAY:o.on=i->known && i->on;break; + case CK_LED_INVERSE:o.on=i->known && !i->on;break; + case CK_LED_LOAD:o.on=i->known && i->on && i->load && t%1000<500;break; + case CK_LED_ON:o.on=1;break; + default:break; + } + return o; +} +int ck_led_hw_publish(const char *protocol,uint64_t now,uint64_t relay,uint64_t power,uint64_t identify,uint32_t mw,uint32_t flags,uint32_t sequence) { + uint8_t b[CK_LED_HW_SIZE]={0};char path[80]; + if(strcmp(protocol,"matter") && strcmp(protocol,"homekit"))return -1; + memcpy(b,"CKLEDHW1",8);ck_put64(b+8,now);ck_put64(b+16,relay);ck_put64(b+24,power);ck_put64(b+32,identify);ck_put32(b+40,mw);ck_put32(b+44,flags);ck_put32(b+48,sequence); + snprintf(path,sizeof(path),"/var/run/ck-led-%s.bin",protocol);return ck_atomic(ck_path(path),b,sizeof(b),0600); +} +int ck_led_set_time(uint64_t utc,int network) { + uint8_t b[32]={0};if(utc<1577836800ULL || utc>4102444799ULL)return -2; + memcpy(b,"CKLEDT1\n",8);ck_put64(b+8,ck_led_now());ck_put64(b+16,utc);ck_put32(b+24,network?1:2); + return ck_atomic(ck_path(CK_LED_CLOCK),b,sizeof(b),0600); +} +int ck_led_find(void) { uint8_t b[8];ck_put64(b,ck_led_now()+15000);return ck_atomic(ck_path(CK_LED_FIND),b,sizeof(b),0600); } +int ck_led_status(char *out,size_t size) { + struct ck_led_config c;if(ck_led_load_config(&c))return -1; + uint8_t b[32];uint64_t now=ck_led_now();int live=0,on=0,reason=CK_LED_NORMAL,valid=0,night=0,source=0;uint64_t utc=0; + if(!ck_exact_file(ck_path(CK_LED_STATE),b,sizeof(b)) && !memcmp(b,"CKLEDS1\n",8) && ck_be64(b+8)<=now && now-ck_be64(b+8)<=3000) { + live=1;on=b[16];reason=b[17];valid=b[18];night=b[19];source=b[20];utc=ck_be64(b+24); + } + int n=snprintf(out,size,"{\"mode\":\"%s\",\"schedule\":%s,\"start\":%d,\"end\":%d,\"utc_offset\":%d,\"ntp\":\"%s\",\"service_ready\":%s,\"on\":%s,\"reason\":\"%s\",\"time_valid\":%s,\"night\":%s,\"time_source\":\"%s\",\"utc_s\":%llu}",ck_led_mode(c.mode),c.schedule?"true":"false",c.start,c.end,c.offset,c.ntp,live?"true":"false",on?"true":"false",ck_led_reason(reason),valid?"true":"false",night?"true":"false",source==1?"ntp":source==2?"phone":"none",(unsigned long long)utc); + return n<0 || (size_t)n>=size?-1:0; +} diff --git a/firmware/matter/src/update/ck_led.h b/firmware/matter/src/update/ck_led.h new file mode 100644 index 0000000..6ba7aab --- /dev/null +++ b/firmware/matter/src/update/ck_led.h @@ -0,0 +1,35 @@ +#ifndef CK_LED_H +#define CK_LED_H +#include "ck_common.h" +#ifdef __cplusplus +extern "C" { +#endif +#define CK_LED_KEY "config/led" +#define CK_LED_STATE "/var/run/ck-led-state" +#define CK_LED_CLOCK "/var/run/ck-led-clock" +#define CK_LED_FIND "/var/run/ck-led-find" +#define CK_LED_HW_SIZE 56 +enum { CK_LED_SMART, CK_LED_RELAY, CK_LED_INVERSE, CK_LED_LOAD, CK_LED_ON, CK_LED_OFF }; +enum { CK_LED_NORMAL, CK_LED_NIGHT, CK_LED_PAIRING, CK_LED_JOINING, CK_LED_SETUP, CK_LED_ERROR, CK_LED_IDENTIFY }; +enum { CK_LED_KNOWN=1, CK_LED_RELAY_ON=2, CK_LED_POWER=4, CK_LED_PAIRED=8, CK_LED_READY=16 }; +struct ck_led_config { int mode, schedule, start, end, offset; char ntp[128]; }; +struct ck_led_input { int known,on,load,setup,joining,pairing,error,identify,time_valid; uint64_t utc_s,now_ms; }; +struct ck_led_output { int on,reason,night; }; +const char *ck_led_mode(int mode); +const char *ck_led_reason(int reason); +uint64_t ck_led_now(void); +void ck_led_default(struct ck_led_config *c); +int ck_led_parse(const char *text,struct ck_led_config *c); +int ck_led_load_config(struct ck_led_config *c); +int ck_led_save_config(const char *text); +struct ck_led_output ck_led_evaluate(const struct ck_led_config *c,const struct ck_led_input *in); +int ck_led_hw_publish(const char *protocol,uint64_t now,uint64_t relay_at,uint64_t power_at,uint64_t identify_until,uint32_t power_mw,uint32_t flags,uint32_t sequence); +int ck_led_status(char *out,size_t capacity); +int ck_led_find(void); +int ck_led_set_time(uint64_t utc_s,int network); +int ck_led_run(void); +int ck_led_ntp(const char *server); +#ifdef __cplusplus +} +#endif +#endif diff --git a/firmware/matter/src/update/ck_led_service.h b/firmware/matter/src/update/ck_led_service.h new file mode 100644 index 0000000..992dff8 --- /dev/null +++ b/firmware/matter/src/update/ck_led_service.h @@ -0,0 +1,107 @@ +/* One GPIO6 owner, independent of protocol and HTTP event loops. Linked into + * the existing updater to share its static runtime and journal implementation. */ +#include "ck_led.h" +#include +#include +#include +#include +#include +static int led_gpio(const char *s) { + int f=open(ck_path("/proc/wlan0/gpio_ctrl"),O_WRONLY|O_NOFOLLOW); + if(f<0)return -1;int r=ck_write_all(f,s,strlen(s));close(f);return r; +} +/* A connected UDP socket validates the reply's peer. DNS and socket waits run + * only in this killable child; neither sets the system wall clock. */ +int ck_led_ntp(const char *host) { + alarm(12);struct addrinfo hint,*list=NULL;memset(&hint,0,sizeof(hint));hint.ai_family=AF_INET;hint.ai_socktype=SOCK_DGRAM; + if(getaddrinfo(host,"123",&hint,&list))return 1; + int result=1; + for(struct addrinfo *a=list;a;a=a->ai_next) { + int f=socket(a->ai_family,a->ai_socktype,a->ai_protocol);if(f<0)continue; + struct timeval timeout={2,0};setsockopt(f,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout)); + uint8_t q[48]={0},r[256];q[0]=0x23;int random=open("/dev/urandom",O_RDONLY|O_NOFOLLOW); + if(random<0){close(f);break;}int bad=ck_read_all(random,q+40,8);close(random); + uint64_t at=ck_led_now(); + if(!bad && !connect(f,a->ai_addr,a->ai_addrlen) && send(f,q,sizeof(q),0)==sizeof(q)) { + ssize_t n=recv(f,r,sizeof(r),0);uint64_t elapsed=ck_led_now()-at; + if(n>=48 && (r[0]&7)==4 && ((r[0]>>3)&7)>=3 && ((r[0]>>3)&7)<=4 && (r[0]>>6)!=3 && r[1]>=1 && r[1]<=15 && !memcmp(r+24,q+40,8) && elapsed<=2500) { + uint64_t seconds=ck_be32(r+40);if(seconds=0)close(lock);return 1;} + /* Preserve the last GPIO level when PID1 quiesces userspace for OTA. */ + uint64_t boot=ck_led_now(),tick=0,next_ntp=0,next_report=0;pid_t child=0; + struct ck_led_config config;ck_led_default(&config);int configured=0,last=-1,load=0,high=0,low=0;uint32_t sequence=0; + char owner[16]="",ntp_host[128]="";uint8_t hardware[CK_LED_HW_SIZE]={0}; + led_gpio("config 6 w"); + for(;;) { + uint64_t now=ck_led_now(); + if(child>0) {int status;pid_t done=waitpid(child,&status,WNOHANG);if(done==child){child=0;next_ntp=now+(!status?21600000:60000);} } + if(now>=tick) { + tick=now+1000;struct ck_led_config fresh; + if(!ck_led_load_config(&fresh)){config=fresh;configured=1;} + if(strcmp(config.ntp,ntp_host)){strcpy(ntp_host,config.ntp);next_ntp=now;if(child>0){kill(child,SIGKILL);waitpid(child,NULL,0);child=0;}} + } + /* Never replace an unreadable preference with a newly saved default. */ + if(!configured){usleep(100000);continue;} + struct ck_led_input in;memset(&in,0,sizeof(in));in.now_ms=now; + char mode[16]="",state[64]="";led_read_text(CK_PROTOCOL_ACTIVE,mode,sizeof(mode)); + if(strcmp(owner,mode)){strcpy(owner,mode);sequence=0;load=high=low=0;} + char path[96];snprintf(path,sizeof(path),"/var/run/ck-led-%s.bin",!strcmp(mode,"homekit")?"homekit":"matter"); + int hw_live=!ck_exact_file(ck_path(path),hardware,sizeof(hardware)) && !memcmp(hardware,"CKLEDHW1",8) && ck_be64(hardware+8)<=now && now-ck_be64(hardware+8)<=15000; + uint32_t flags=hw_live?ck_be32(hardware+44):0; + uint64_t relay=ck_be64(hardware+16),power=ck_be64(hardware+24); + in.known=hw_live && (flags&CK_LED_KNOWN) && relay<=now && now-relay<=10000; + in.on=!!(flags&CK_LED_RELAY_ON); + int pvalid=hw_live && (flags&CK_LED_POWER) && power<=now && now-power<15000 && ck_be32(hardware+40)<=2000000; + uint32_t seq=ck_be32(hardware+48),mw=ck_be32(hardware+40); + if(!in.known || !in.on || !pvalid){load=high=low=0;sequence=seq;} + else if(seq!=sequence){sequence=seq;if(mw>=5000){low=0;if(high<2)high++;if(high==2)load=1;}else if(mw<=2000){high=0;if(low<2)low++;if(low==2)load=0;}else high=low=0;} + in.load=load; + uint64_t until=hw_live?ck_be64(hardware+32):0;in.identify=until>now && until-now<=900000; + uint8_t find[8];if(!ck_exact_file(ck_path(CK_LED_FIND),find,8)){until=ck_be64(find);if(until>now && until-now<=15000)in.identify=1;} + if(!strcmp(mode,"homekit") && !ck_exact_file(ck_path("/var/run/ck-led-homekit-identify"),find,8)) {until=ck_be64(find);if(until>now && until-now<=15000)in.identify=1;} + led_read_text("/var/run/ck-setup-state",state,sizeof(state)); + in.setup=!strcmp(state,"state=setup\n") || !strcmp(state,"state=ap-starting\n") || !strcmp(state,"state=waiting-identity\n"); + in.joining=!strcmp(state,"state=joining\n") || !strcmp(state,"state=reconnecting\n") || !strcmp(state,"state=committing\n"); + int online=!strcmp(state,"state=online\n"); + in.error=!access(ck_path("/var/run/ck-rescue"),F_OK) || !strcmp(state,"state=error\n"); + /* Grace period covers USB/identity/protocol startup. A missing controller + * or stale confirmed relay then becomes a diagnostic, not a fake ON. */ + if(now-boot>=60000 && (!hw_live || !in.known))in.error=1; + if(!strcmp(mode,"homekit"))in.pairing=hw_live && (flags&CK_LED_READY) && !(flags&CK_LED_PAIRED); + else { + char m[512];long stamp=0;int window=0; + if(!led_read_text(CK_MATTER_STATE,m,sizeof(m)) && sscanf(m,"{\"updated_s\":%ld,",&stamp)==1 && stamp<=(long)(now/1000) && (long)(now/1000)-stamp<=5) { + char *p=strstr(m,"\"window_open\":");if(p && (!strncmp(p+14,"true",4) || p[14]=='1'))window=1; + } + in.pairing=window; + } + int source=0;uint8_t clock[32]; + if(!ck_exact_file(ck_path(CK_LED_CLOCK),clock,sizeof(clock)) && !memcmp(clock,"CKLEDT1\n",8) && ck_be64(clock+8)<=now) { + in.utc_s=ck_be64(clock+16)+(now-ck_be64(clock+8))/1000;source=(int)ck_be32(clock+24);in.time_valid=in.utc_s>=1577836800ULL && in.utc_s<=4102444799ULL && (source==1 || source==2); + } + if(config.schedule && online && child<=0 && now>=next_ntp) { + child=fork();if(!child){for(int f=3;f<128;f++)close(f);execl(ck_path("/bin/ck-updater"),"ck-updater","--led-ntp",config.ntp,(char *)NULL);_exit(127);} + next_ntp=now+60000; + } + struct ck_led_output out=ck_led_evaluate(&config,&in); + /* A staged update latches the output even during the TERM/KILL interval. */ + if(!access(ck_path("/var/run/ck-ota/queued"),F_OK)) {usleep(50000);continue;} + if(out.on!=last && !led_gpio(out.on?"set 6 0":"set 6 1"))last=out.on; + uint8_t report[32]={0};memcpy(report,"CKLEDS1\n",8);ck_put64(report+8,now);report[16]=out.on;report[17]=out.reason;report[18]=in.time_valid;report[19]=out.night;report[20]=source;ck_put64(report+24,in.utc_s); + if(now>=next_report){ck_atomic(ck_path(CK_LED_STATE),report,sizeof(report),0600);next_report=now+1000;} + usleep(50000); + } +} diff --git a/firmware/matter/src/update/ck_matter_control.h b/firmware/matter/src/update/ck_matter_control.h new file mode 100644 index 0000000..434f624 --- /dev/null +++ b/firmware/matter/src/update/ck_matter_control.h @@ -0,0 +1,11 @@ +#ifndef CK_MATTER_CONTROL_H +#define CK_MATTER_CONTROL_H +/* Fixed RAM mailbox. Only the authenticated setup service may enqueue LAN + * requests; the Matter event loop owns all SDK operations and Flash writes. */ +#define CK_MATTER_REQUEST "/var/run/ck-matter-request" +#define CK_MATTER_LOCK "/var/run/ck-matter-request.lock" +#define CK_MATTER_STATE "/var/run/ck-matter-state.json" +#define CK_MATTER_RESET_KEY "control/matter-reset" +#define CK_MATTER_WINDOW_SECONDS 900 +#define CK_PROTOCOL_PENDING "/var/run/ck-protocol-pending" +#endif diff --git a/firmware/matter/src/update/ck_meter.c b/firmware/matter/src/update/ck_meter.c index ba7048f..88187c8 100644 --- a/firmware/matter/src/update/ck_meter.c +++ b/firmware/matter/src/update/ck_meter.c @@ -1,5 +1,6 @@ #include "ck_meter.h" #include "ck_common.h" +#include "ck_energy.h" #include #include @@ -39,7 +40,7 @@ int ck_meter_json(char *out, size_t capacity, uint64_t now) { int available = 0; uint32_t flags = 0, voltage = 0, current = 0, power = 0; uint64_t published = 0, energy = 0; - char v[24], i[24], p[24], e[24], age[24]; + char v[24], i[24], p[24], e[24], age[24],saved[24]="null",mode[7]; const char *relay = "null"; if (!ck_exact_file(ck_path(METER_PATH), b, sizeof(b)) && !memcmp(b, "CKMETER1", 8)) { @@ -62,10 +63,14 @@ int ck_meter_json(char *out, size_t capacity, uint64_t now) { number(age, sizeof(age), available, now - published, 0); if (available && (flags & CK_METER_RELAY) && fresh(ck_be64(b + 64), published, now, 10000)) relay = flags & CK_METER_ON ? "true" : "false"; + if(!ck_exact_file(ck_path("/var/run/ck-protocol-active"),mode,sizeof(mode)) && !memcmp(mode,"homekit",7)) { + uint64_t total=0;uint32_t fraction=0; + if(ck_energy_try_load(&total,&fraction)>=0) number(saved,sizeof(saved),1,total,1); + } int n = snprintf(out, capacity, "{\"available\":%s,\"sample_age_ms\":%s,\"relay_on\":%s," "\"voltage_mv\":%s,\"current_ma\":%s,\"power_mw\":%s," - "\"energy_mwh\":%s,\"energy_estimated\":true,\"checkpoint_interval_s\":30}", - available ? "true" : "false", age, relay, v, i, p, e); + "\"energy_mwh\":%s,\"saved_energy_mwh\":%s,\"energy_estimated\":true,\"checkpoint_interval_s\":30}", + available ? "true" : "false", age, relay, v, i, p, e,saved); return n < 0 || (size_t)n >= capacity ? -1 : 0; } diff --git a/firmware/matter/src/update/ck_ota.c b/firmware/matter/src/update/ck_ota.c index 392f609..8329b89 100644 --- a/firmware/matter/src/update/ck_ota.c +++ b/firmware/matter/src/update/ck_ota.c @@ -3,6 +3,7 @@ #include "../vendor/monocypher/monocypher-ed25519.h" #include "ck_common.h" #include "ck_store.h" +#include "ck_factory.h" #include #include #include @@ -21,6 +22,15 @@ static int compatibility(const uint8_t *h) { if (!ck_hash_fd(a.fd, a.base + 0x10000, 0x120000, hash) && ck_equal(hash, h + 64, 32)) r = 0; + if (r) { + uint8_t legacy[32], factory[32]; + if (!ck_unhex(CK_LEGACY_KERNEL_SHA256, 32, legacy) && + !ck_unhex(CK_FACTORY_KERNEL_SHA256, 32, factory) && + ck_equal(h + 64, legacy, 32) && + !ck_hash_factory_kernel(a.fd, a.base + 0x10000, hash) && + ck_equal(hash, factory, 32)) + r = 0; + } close(a.fd); if (r) return -1; @@ -156,7 +166,10 @@ int ck_ota_stage(const char *path) { int lock = manager_lock(); if (lock < 0) return -1; - if (!access(ck_path("/var/run/ck-ota/queued"), F_OK)) { + if (!access(ck_path("/var/run/ck-ota/queued"), F_OK) || + !access(ck_path("/var/run/ck-protocol-pending"),F_OK) || !access(ck_path("/var/run/ck-factory-pending"),F_OK) || + !access(ck_path("/var/run/ck-network-changing"),F_OK) || + !access(ck_path("/var/run/ck-matter-request.lock"),F_OK)) { close(lock); return -1; } @@ -202,7 +215,10 @@ int ck_ota_queue(const char *expected_hash) { close(lock); return -1; } - if (!access(ck_path("/var/run/ck-ota/queued"), F_OK)) { + if (!access(ck_path("/var/run/ck-ota/queued"), F_OK) || + !access(ck_path("/var/run/ck-protocol-pending"),F_OK) || !access(ck_path("/var/run/ck-factory-pending"),F_OK) || + !access(ck_path("/var/run/ck-network-changing"),F_OK) || + !access(ck_path("/var/run/ck-matter-request.lock"),F_OK)) { close(transfer); close(lock); return -1; diff --git a/firmware/matter/src/update/ck_protocol.h b/firmware/matter/src/update/ck_protocol.h new file mode 100644 index 0000000..7195d1b --- /dev/null +++ b/firmware/matter/src/update/ck_protocol.h @@ -0,0 +1,29 @@ +#ifndef CK_PROTOCOL_H +#define CK_PROTOCOL_H +#include "ck_common.h" +#include "ck_store.h" +#include "ck_matter_control.h" +#include +#define CK_PROTOCOL_KEY "config/protocol" +#define CK_PROTOCOL_ACTIVE "/var/run/ck-protocol-active" +/* A missing preference is the existing Matter behavior. An unreadable or + * malformed journal never silently selects a different hardware owner. */ +static int ck_protocol_valid(const char *p) { + return !strcmp(p,"matter") || !strcmp(p,"homekit"); +} +static const char *ck_protocol_selected(void) { + char value[8] = {0}; size_t n = 7; + int r = ck_store_try_get(CK_PROTOCOL_KEY,value,&n); + if (r == 1) return "matter"; + if (!r && ((n == 6 && !memcmp(value,"matter",6)) || + (n == 7 && !memcmp(value,"homekit",7)))) + return n == 6 ? "matter" : "homekit"; + return "unavailable"; +} +static const char *ck_protocol_active(void) { + char value[8] = {0}; + if (!ck_exact_file(ck_path(CK_PROTOCOL_ACTIVE),value,6) && !memcmp(value,"matter",6)) return "matter"; + if (!ck_exact_file(ck_path(CK_PROTOCOL_ACTIVE),value,7) && !memcmp(value,"homekit",7)) return "homekit"; + return "unavailable"; +} +#endif diff --git a/firmware/matter/src/update/ck_protocol_owner.h b/firmware/matter/src/update/ck_protocol_owner.h new file mode 100644 index 0000000..6c3b661 --- /dev/null +++ b/firmware/matter/src/update/ck_protocol_owner.h @@ -0,0 +1,103 @@ +/* Included only in the existing updater executable, sharing its journal and + * crypto code instead of duplicating a static runtime in this small rootfs. */ +#include "ck_protocol.h" +#include +#include +static int protocol_online(void) { + char b[32]; FILE *f=fopen(ck_path("/var/run/ck-setup-state"),"r"); + int ready=f && fgets(b,sizeof(b),f) && !strcmp(b,"state=online\n"); + if(f) fclose(f); + return ready; +} +static int protocol_station_saved(void) { + char b[10]; + return !ck_exact_file(ck_path("/var/run/ck-setup-boot"),b,sizeof(b)) && !memcmp(b,"configured",10); +} +static int hap_alive(pid_t pid) { + char path[96], name[32]; + if(pid<=1 || kill(pid,0)) return 0; + snprintf(path,sizeof(path),"/proc/%ld/status",(long)pid); + FILE *f=fopen(ck_path(path),"r"); + int ok=f && fscanf(f,"Name: %31s",name)==1 && !strcmp(name,"homekitserver"); + if(f) fclose(f); + return ok; +} +static int hap_listening(void) { + struct sockaddr_in6 a; memset(&a,0,sizeof(a)); + a.sin6_family=AF_INET6; a.sin6_port=htons(52739); a.sin6_addr=in6addr_loopback; + int fd=socket(AF_INET6,SOCK_STREAM,0); + if(fd<0) return 0; + fcntl(fd,F_SETFL,O_NONBLOCK); + int result=connect(fd,(struct sockaddr *)&a,sizeof(a)); + if(result && errno==EINPROGRESS) { + struct pollfd p={fd,POLLOUT,0}; int error=1; socklen_t n=sizeof(error); + if(poll(&p,1,100)>0 && !getsockopt(fd,SOL_SOCKET,SO_ERROR,&error,&n)) result=error; + } + close(fd); return result==0; +} +static int run_protocol(void) { + /* Deliberately inherited through exec/fork, including the vendor daemon. + * If this supervisor dies, a surviving MCU owner retains the lock. A PID1 + * respawn cannot start another stack next to an orphaned daemon. */ + int lock=open(ck_path("/var/lock/ck-protocol-owner.lock"),O_RDWR|O_CREAT|O_NOFOLLOW,0600); + if(lock<0 || flock(lock,LOCK_EX|LOCK_NB)) { if(lock>=0) close(lock); sleep(5); return 1; } + const char *mode=ck_protocol_active(); + if(!strcmp(mode,"unavailable") && access(ck_path(CK_PROTOCOL_ACTIVE),F_OK)) { + mode=ck_protocol_selected(); + if(ck_atomic(ck_path(CK_PROTOCOL_ACTIVE),mode,strlen(mode),0600)) return 1; + } + fprintf(stderr,"PROTOCOL_BOOT: %s\n",mode); + if(strcmp(mode,"homekit")) { + if(strcmp(mode,"matter")) { + /* Preserve physical controls on invalid preferences, without advertising + * either network protocol or choosing a replacement identity. */ + ck_atomic(ck_path("/var/run/ck-rescue"),"",0,0600); + } + execl(ck_path("/bin/matter-test-start"),"matter-test-start",(char *)NULL); + return 127; + } + /* Station/DHCP service is independent of both protocols. Never start WAC, + * which could replace credentials or take over the MCU. */ + /* A saved station may be temporarily offline. HAP can bind to the wildcard + * interface and own the MCU before DHCP, keeping physical controls alive. */ + while(access(ck_path("/dev/ttyACM0"),F_OK)) sleep(2); + unlink(ck_path("/var/run/homekitserver.pid")); + pid_t launcher=fork(); + if(launcher<0) return 1; + if(!launcher) { + int out=open("/dev/null",O_WRONLY); + if(out>=0) { dup2(out,1);dup2(out,2);close(out); } + execl(ck_path("/bin/homekitserver"),"homekitserver",(char *)NULL); _exit(127); + } + int status; + while(waitpid(launcher,&status,0)<0) if(errno!=EINTR) return 1; + /* The stock parent writes its forked child's PID and exits. Never retry a + * daemon launch automatically: incomplete PID handoff must fail closed. */ + pid_t daemon=0; + FILE *f=fopen(ck_path("/var/run/homekitserver.pid"),"r"); + long id=0; if(f) { if(fscanf(f,"%ld",&id)!=1) id=0; fclose(f); } + if(id>1 && id<=2147483647) daemon=(pid_t)id; + unsigned ready=0; int confirmed=0; + for(;;) { + if(!WIFEXITED(status) || WEXITSTATUS(status) || !hap_alive(daemon)) { + ck_atomic(ck_path("/var/run/ck-rescue"),"",0,0600); + fprintf(stderr,"HOMEKIT_STOPPED: reboot required; no alternate MCU owner started\n"); + for(;;) pause(); + } + if(!confirmed && protocol_online() && hap_listening()) { + if(++ready>=15) { + uint8_t meta[12]; + if(!ck_exact_file(ck_path("/etc/ck-update.meta"),meta,12) && + !ck_store_confirm(ck_be32(meta+8)) && + !ck_atomic(ck_path("/var/run/ck-update-healthy"),"",0,0600)) confirmed=1; + } + } else if(!confirmed) ready=0; + sleep(2); + } +} +static int protocol_boot(void) { + if(!access(ck_path(CK_PROTOCOL_ACTIVE),F_OK)) return 0; + if(factory_finish()) return 1; + const char *mode=ck_protocol_selected(); + return ck_atomic(ck_path(CK_PROTOCOL_ACTIVE),mode,strlen(mode),0600) ? 1 : 0; +} diff --git a/firmware/matter/src/update/ck_store.c b/firmware/matter/src/update/ck_store.c index c5d59d0..3548c72 100644 --- a/firmware/matter/src/update/ck_store.c +++ b/firmware/matter/src/update/ck_store.c @@ -1,15 +1,27 @@ #define _GNU_SOURCE #include "ck_store.h" #include "ck_common.h" +#include "ck_identity_init.h" +#include "ck_matter_control.h" +#include #include #include #include #include #include +#include +#ifndef CK_TESTING +#include +#endif #define HEADER 64u #define PAYLOAD_MAX (CK_SLOT_SIZE - HEADER) static uint8_t snapshots[2][CK_SLOT_SIZE]; static uint8_t work[CK_SLOT_SIZE]; +static int auto_policy(void) { + char policy[8]; + return !ck_exact_file(ck_path("/etc/ck-identity-policy"), policy, sizeof(policy)) && + !memcmp(policy, "auto-v1\n", sizeof(policy)); +} static int erased(const uint8_t *p, size_t n) { while (n--) if (*p++ != 255) @@ -42,12 +54,17 @@ static int valid(const uint8_t *p) { } return at == n; } +static int initial_marker(const uint8_t *p) { + return !memcmp(p, CK_INIT_MARKER, CK_INIT_MARKER_SIZE) && + erased(p + CK_INIT_MARKER_SIZE, CK_SLOT_SIZE - CK_INIT_MARKER_SIZE); +} static int load(struct ck_flash *f, int *active, uint64_t *seq, unsigned *n) { if (ck_flash_read(f, CK_JOURNAL_OFFSET, snapshots, sizeof(snapshots))) return -1; int a = valid(snapshots[0]), b = valid(snapshots[1]); if (!a && !b) { - if (!erased(snapshots[0], sizeof(snapshots))) { + if (!(auto_policy() && initial_marker(snapshots[1])) && + !erased(snapshots[0], sizeof(snapshots))) { uint8_t bootstrap[65]; /* An interrupted very first import has no previous snapshot. Recover * only our own first-generation header with an erased second slot and @@ -128,8 +145,8 @@ static int save(struct ck_flash *f, int active, uint64_t seq, unsigned n) { return -1; return 0; } -int ck_store_get(const char *key, void *value, size_t *size) { - int lock = ck_lock(), result = -1, active; +static int get_value(const char *key,void *value,size_t *size,int nonblocking) { + int lock = nonblocking ? ck_try_lock() : ck_lock(), result = -1, active; unsigned n, at, v; uint64_t seq; struct ck_flash f; @@ -161,6 +178,8 @@ int ck_store_get(const char *key, void *value, size_t *size) { ck_unlock(lock); return result; } +int ck_store_get(const char *key,void *value,size_t *size) { return get_value(key,value,size,0); } +int ck_store_try_get(const char *key,void *value,size_t *size) { return get_value(key,value,size,1); } static int change(const char *key, const void *value, size_t size, int remove) { size_t k = strlen(key); if (!k || k > 96 || size > 65535 || (!value && size)) @@ -215,6 +234,150 @@ int ck_store_set(const char *k, const void *v, size_t n) { return change(k, v, n, 0); } int ck_store_delete(const char *k) { return change(k, NULL, 0, 1); } +int ck_store_factory_clear(void) { + int lock=ck_lock(),result=-1,active; unsigned n,out=0; uint64_t seq; struct ck_flash f; + if(lock<0) return -1; + if(ck_flash_open(&f,1,1)) goto done; + if(load(&f,&active,&seq,&n) || active<0) goto closed; + for(unsigned at=0;at=9 && !memcmp(key,"identity/",9) && !(k==18 && !memcmp(key,"identity/onboarded",18))) || + (k>=4 && !memcmp(key,"ota/",4)); + if(keep) { memmove(work+HEADER+out,work+HEADER+at,size);out+=size; } + at+=size; + } + const char *key="config/needs-setup";unsigned k=(unsigned)strlen(key); + if(out+5+k>CK_STORE_PAYLOAD_MAX) goto closed; + work[HEADER+out]=k;work[HEADER+out+1]=0;work[HEADER+out+2]=0;work[HEADER+out+3]=1; + memcpy(work+HEADER+out+4,key,k);work[HEADER+out+4+k]=1; + result=save(&f,active,seq,out+5+k); +closed:close(f.fd); +done:ck_unlock(lock);return result; +} +int ck_store_reset_matter(const char request_id[65]) { + if (!request_id || strlen(request_id) != 64) return -1; + for (unsigned i = 0; i < 64; ++i) + if (!((request_id[i] >= '0' && request_id[i] <= '9') || + (request_id[i] >= 'a' && request_id[i] <= 'f'))) return -1; + int lock = ck_lock(), result = -1, active; + unsigned n, out = 0; + uint64_t seq; + struct ck_flash f; + if (lock < 0) return -1; + if (ck_flash_open(&f, 1, 1)) goto done; + if (load(&f, &active, &seq, &n) || active < 0) goto closed; + for (unsigned at = 0; at < n;) { + unsigned k = work[HEADER+at], v = (unsigned)work[HEADER+at+2] << 8 | work[HEADER+at+3]; + unsigned size = 4+k+v; + const uint8_t *key = work+HEADER+at+4; + int remove = (k >= 7 && !memcmp(key,"matter/",7)) || + (k == 17 && !memcmp(key,"platform/chip_kvs",17)) || + (k == sizeof(CK_MATTER_RESET_KEY)-1 && !memcmp(key,CK_MATTER_RESET_KEY,k)); + if (!remove) { memmove(work+HEADER+out,work+HEADER+at,size); out += size; } + at += size; + } + const unsigned k = sizeof(CK_MATTER_RESET_KEY)-1; + if (out+4+k+64 > CK_STORE_PAYLOAD_MAX) goto closed; + work[HEADER+out] = k; work[HEADER+out+1] = 0; + work[HEADER+out+2] = 0; work[HEADER+out+3] = 64; + memcpy(work+HEADER+out+4,CK_MATTER_RESET_KEY,k); + memcpy(work+HEADER+out+4+k,request_id,64); + result = save(&f,active,seq,out+4+k+64); +closed: + close(f.fd); +done: + ck_unlock(lock); + return result; +} +static void wipe(void *p, size_t n) { + volatile uint8_t *v = p; + while (n--) *v++ = 0; +} +static int entropy(uint8_t seed[32], int wait) { + const char *path = "/dev/random"; +#ifdef CK_TESTING + const char *fixture = getenv("CK_TEST_SEED_FILE"); + if (fixture) path = fixture; +#endif + int fd = open(path, O_RDONLY | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC); + struct stat st; + if (fd < 0) return -1; + if (fstat(fd, &st) +#ifndef CK_TESTING + || !S_ISCHR(st.st_mode) || major(st.st_rdev) != 1 || minor(st.st_rdev) != 8 +#else + || (fixture ? !S_ISREG(st.st_mode) : !S_ISCHR(st.st_mode)) +#endif + ) { close(fd); return -1; } + size_t n = 0; + while (n < 32) { + ssize_t got = read(fd, seed + n, 32 - n); + if (got > 0) { n += (size_t)got; continue; } + if (got < 0 && errno == EINTR) continue; + if (got < 0 && errno != EAGAIN) { close(fd); wipe(seed,32); return -1; } + if (!wait) { close(fd); wipe(seed,32); return CK_ENTROPY_PENDING; } + /* Keep partial credited entropy in this process's RAM, without holding + * up rcS, networking or the independent MCU worker. No weak fallback. */ + usleep(250000); + } + close(fd); + return 0; +} +static unsigned append_entry(unsigned n, const char *key, const void *value, unsigned size) { + unsigned k = (unsigned)strlen(key); + work[HEADER+n] = (uint8_t)k; work[HEADER+n+1] = 0; + work[HEADER+n+2] = (uint8_t)(size >> 8); work[HEADER+n+3] = (uint8_t)size; + memcpy(work+HEADER+n+4,key,k); memcpy(work+HEADER+n+4+k,value,size); + return n+4+k+size; +} +static int initialize_auto(int wait) { + struct ck_flash f; + int lock = ck_lock(), result = -1, active; + uint64_t seq; + unsigned n; + uint8_t seed[32] = {0}, device[32], diag[32], trusted[32], flag = 1; + char diag_text[65]; + if (lock < 0) return -1; + if (ck_flash_open(&f,1,1)) goto done; + if (load(&f,&active,&seq,&n)) goto closed; + if (active < 0) { + /* Only the public first-install container can authorize formatting a + * new identity. Unknown journal bytes and erased OTA state are refused. */ + if (!initial_marker(snapshots[1]) || + ck_exact_file(ck_path("/etc/ck-update.pub"),trusted,32)) goto closed; + result = entropy(seed,wait); + if (result) goto closed; + ck_hmac(seed,"commandkit/device/v1",20,device); + ck_hmac(seed,"commandkit/diagnostics/v1",25,diag); + ck_hex(diag,32,diag_text); diag_text[64] = '\n'; + n = append_entry(0,"identity/device",device,32); + n = append_entry(n,"identity/diag",diag_text,65); + n = append_entry(n,"identity/update",trusted,32); + n = append_entry(n,"identity/auto",&flag,1); + result = save(&f,-1,0,n); + if (result) goto closed; + } + /* Retire the initialization permission before exporting any credential. + * A later corrupt journal must fail closed instead of changing identity. */ + if (initial_marker(snapshots[1])) { + uint8_t zero[8] = {0}, check[8]; +#ifdef CK_TESTING + if (getenv("CK_FAIL_INIT_RETIRE")) { result = -1; goto closed; } +#endif + if (ck_flash_write(&f,CK_JOURNAL_OFFSET+CK_SLOT_SIZE,zero,8) || ck_flash_sync(&f) || + ck_flash_read(&f,CK_JOURNAL_OFFSET+CK_SLOT_SIZE,check,8) || + !ck_equal(zero,check,8)) { result = -1; goto closed; } + } + result = 0; +closed: + close(f.fd); +done: + wipe(seed,sizeof(seed)); wipe(device,sizeof(device)); wipe(diag,sizeof(diag)); + wipe(diag_text,sizeof(diag_text)); + ck_unlock(lock); + return result; +} static int migrate(const char *name, const char *source, const char *dest, size_t expected) { uint8_t b[128]; @@ -237,12 +400,17 @@ static int migrate(const char *name, const char *source, const char *dest, memset(b, 0, sizeof(b)); return r; } -int ck_store_import(void) { +static int import_identity(int wait) { uint8_t meta[12]; if (ck_exact_file(ck_path("/etc/ck-update.meta"), meta, 12) || ck_be32(meta) != 0x434b0004) return -1; unsigned variant = ck_be32(meta + 4); + if (variant == 1 && auto_policy() && + access(ck_path("/etc/matter-device.key"),F_OK) != 0) { + int result = initialize_auto(wait); + if (result) return result; + } if (variant == 1) { if (migrate("identity/device", "/etc/matter-device.key", "/var/lib/ck-state/device.key", 32)) @@ -263,6 +431,39 @@ int ck_store_import(void) { ck_exact_file(ck_path("/etc/ck-update.pub"), current, 32) || !ck_equal(trusted, current, 32)) return -1; + uint8_t flag; + size_t size = 1; + if(!access(ck_path("/etc/ck-admin-policy"),F_OK)) { + uint8_t admin[60];size_t length=sizeof(admin); + int admin_state=ck_store_get("admin/credential",admin,&length); + if(admin_state==1 || !ck_store_get("config/needs-setup",&flag,&size)) { + if(ck_atomic(ck_path("/var/run/ck-first-setup"),"1",1,0600)) return -1; + } else if(admin_state) return -1; + memset(admin,0,sizeof(admin)); + } + size=1; + if (!ck_store_get("identity/auto",&flag,&size) && size == 1 && flag == 1) { + size = 1; + int r = ck_store_get("identity/onboarded",&flag,&size); + if (r == 1) { + if (ck_atomic(ck_path("/var/run/ck-first-setup"),"1",1,0600)) return -1; + } else if (r || size != 1 || flag != 1) return -1; + } + return 0; +} +int ck_store_import(void) { return import_identity(0); } +int ck_store_import_wait(void) { return import_identity(1); } +int ck_store_onboarded(void) { + if(ck_store_delete("config/needs-setup")) return -1; + unlink(ck_path("/var/run/ck-first-setup")); + unlink(ck_path("/var/run/ck-admin-enroll-ok")); + uint8_t flag; + size_t size = 1; + int r = ck_store_get("identity/auto",&flag,&size); + if (r == 1) return 0; + if (r || size != 1 || flag != 1) return -1; + if (ck_store_set("identity/onboarded",&flag,1)) return -1; + unlink(ck_path("/var/run/ck-first-setup")); return 0; } uint32_t ck_store_floor(void) { @@ -293,6 +494,25 @@ int ck_store_boot(void) { return 0; if (r || n != 4) return -1; + uint8_t meta[12]; + if (ck_exact_file(ck_path("/etc/ck-update.meta"), meta, sizeof(meta)) || + ck_be32(meta) != 0x434b0004 || + (ck_be32(meta + 4) != 1 && ck_be32(meta + 4) != 2)) + return -1; + uint32_t running = ck_be32(meta + 8); + if (running > ck_be32(b)) { + /* A region-only programmer recovery preserves an older pending OTA. + * Adopt the newer running image, keeping its usual health confirmation. + * Delete attempts first: interruption before updating pending retries + * this branch, rather than inheriting an exhausted old retry count. */ + uint32_t floor = ck_store_floor(); + if (floor == UINT32_MAX || running < floor || + ck_store_delete("ota/attempts")) + return -1; + ck_put32(b, running); + if (ck_store_set("ota/pending", b, sizeof(b))) + return -1; + } n = 4; uint32_t count = 0; r = ck_store_get("ota/attempts", b, &n); diff --git a/firmware/matter/src/update/ck_store.h b/firmware/matter/src/update/ck_store.h index d71d00f..2640ea4 100644 --- a/firmware/matter/src/update/ck_store.h +++ b/firmware/matter/src/update/ck_store.h @@ -8,9 +8,16 @@ extern "C" { /* Synchronous, bounded dual-snapshot journal. No success before flash readback. */ int ck_store_get(const char *key, void *value, size_t *size); +int ck_store_try_get(const char *key, void *value, size_t *size); int ck_store_set(const char *key, const void *value, size_t size); int ck_store_delete(const char *key); +/* Atomic removal of SDK operational data, with a restart/open-window receipt. + * Identity, platform configuration, OTA state and the energy ring are kept. */ +int ck_store_reset_matter(const char request_id[65]); +int ck_store_factory_clear(void); int ck_store_import(void); +int ck_store_import_wait(void); +int ck_store_onboarded(void); int ck_store_boot(void); int ck_store_confirm(uint32_t version); uint32_t ck_store_floor(void); diff --git a/firmware/matter/src/update/ck_updated.c b/firmware/matter/src/update/ck_updated.c index c4bb67c..a0136b0 100644 --- a/firmware/matter/src/update/ck_updated.c +++ b/firmware/matter/src/update/ck_updated.c @@ -3,6 +3,11 @@ #include "ck_ota.h" #include "ck_store.h" #include "ck_meter.h" +#include "ck_led.h" +#include "ck_protocol.h" +#include "ck_matter_control.h" +#include "ck_energy.h" +#include "password_script.h" #include "update_page.h" #include #include @@ -55,9 +60,101 @@ static void reply(int fd, int status, const char *type, const char *body) { static void json(int fd, int status, const char *body) { reply(fd, status, "application/json; charset=utf-8", body); } +#include "ck_web_auth.h" +static void protocol_get(int fd) { + char b[256];const char *selected=ck_protocol_selected(),*active=ck_protocol_active(); + snprintf(b,sizeof(b),"{\"selected\":\"%s\",\"active\":\"%s\",\"restart_pending\":%s}",selected,active,access(ck_path(CK_PROTOCOL_PENDING),F_OK)?"false":"true"); + json(fd,200,b); +} +static void management_reboot(void) { +#ifndef CK_TESTING + pid_t child=fork();if(!child) { for(int f=0;f<128;f++)close(f);sleep(2);execl("/bin/reboot","reboot",(char *)NULL);_exit(127); } +#endif +} +static int operation_begin(void) { + int lock=ck_transfer_lock();if(lock<0)return -1; + if(!access(ck_path("/var/run/ck-ota/queued"),F_OK) || !access(ck_path(CK_PROTOCOL_PENDING),F_OK) || + !access(ck_path("/var/run/ck-network-changing"),F_OK) || + !access(ck_path("/var/run/ck-factory-pending"),F_OK) || mkdir(ck_path(CK_MATTER_LOCK),0700)) { close(lock);return -1; } + return lock; +} +static void operation_end(int lock) { rmdir(ck_path(CK_MATTER_LOCK));close(lock); } +static void protocol_set(int fd,const char *mode) { + if(!ck_protocol_valid(mode)){json(fd,400,"{\"error\":\"protocol\"}");return;} + int lock=operation_begin();if(lock<0){json(fd,409,"{\"error\":\"busy\"}");return;} + if(!strcmp(mode,ck_protocol_active()) && !strcmp(mode,ck_protocol_selected())) { + operation_end(lock);json(fd,200,"{\"status\":\"unchanged\"}");return; + } + int error=ck_atomic(ck_path(CK_PROTOCOL_PENDING),mode,strlen(mode),0600); + if(!error)error=ck_store_set(CK_PROTOCOL_KEY,mode,strlen(mode)); + if(error){unlink(ck_path(CK_PROTOCOL_PENDING));operation_end(lock);json(fd,503,"{\"error\":\"storage\"}");return;} + close(lock);json(fd,200,"{\"status\":\"restarting\"}");management_reboot(); +} +static void matter_queue(int fd,char action,int lock) { + char id[65],command[67]; + if(web_random(id)){operation_end(lock);json(fd,503,"{}");return;} + command[0]=action;command[1]=' ';memcpy(command+2,id,64);command[66]='\n'; + if(ck_atomic(ck_path(CK_MATTER_REQUEST),command,sizeof(command),0600)) { operation_end(lock);json(fd,503,"{}");return; } + close(lock);char b[128];snprintf(b,sizeof(b),"{\"request_id\":\"%s\",\"result\":\"queued\"}",id);json(fd,202,b); +} +static int matter_ready(void) { + char b[512];FILE *f=fopen(ck_path(CK_MATTER_STATE),"r");long stamp=0;int ok=0; + if(f){if(fgets(b,sizeof(b),f) && sscanf(b,"{\"updated_s\":%ld,",&stamp)==1 && stamp<=web_now() && web_now()-stamp<=5)ok=1;fclose(f);} + return ok; +} +static void admin_action(int fd,const char *path,const char *body,unsigned length) { + int lock=operation_begin();if(lock<0){json(fd,409,"{\"error\":\"busy\"}");return;} + if(!strcmp(path,"/admin/led") || !strcmp(path,"/admin/led-find") || !strcmp(path,"/admin/led-time")) { + int r=-2; + if(!strcmp(path,"/admin/led"))r=ck_led_save_config(body); + else if(!strcmp(path,"/admin/led-find"))r=length?-2:ck_led_find(); + else { + char *end=NULL;errno=0;unsigned long long utc=strtoull(body,&end,10); + if(length>=10 && length<=11 && body[0]>='1' && body[0]<='9' && !errno && end && !*end)r=ck_led_set_time(utc,0); + } + operation_end(lock);json(fd,r==-2?400:r?503:200,r?"{\"error\":\"led_settings\"}":"{\"result\":\"complete\"}");return; + } + if(!strcmp(path,"/admin/energy-reset")) { + if(!strcmp(ck_protocol_active(),"matter")) { + if(!matter_ready()){operation_end(lock);json(fd,503,"{\"error\":\"protocol_not_ready\"}");return;} + matter_queue(fd,'E',lock);return; + } + if(strcmp(ck_protocol_active(),"homekit")){operation_end(lock);json(fd,503,"{\"error\":\"protocol_not_ready\"}");return;} + int r=ck_energy_reset();operation_end(lock);json(fd,r?503:200,r?"{\"error\":\"storage\"}":"{\"result\":\"complete\"}");return; + } + if(!strcmp(path,"/admin/factory-reset")) { + if(ck_factory_request()){operation_end(lock);json(fd,503,"{\"error\":\"storage\"}");return;} + close(lock);json(fd,200,"{\"status\":\"restarting\"}");management_reboot();return; + } + if(!strcmp(path,"/admin/reboot")) { + if(ck_atomic(ck_path(CK_PROTOCOL_PENDING),"reboot",6,0600)){operation_end(lock);json(fd,503,"{}");return;} + close(lock);json(fd,200,"{\"status\":\"restarting\"}");management_reboot();return; + } + if(!strcmp(path,"/admin/open-commissioning") || !strcmp(path,"/admin/pairing-reset")) { + if(!strcmp(ck_protocol_active(),"matter") && matter_ready()) { matter_queue(fd,!strcmp(path,"/admin/pairing-reset")?'R':'O',lock);return; } + if(!strcmp(path,"/admin/pairing-reset") && !strcmp(ck_protocol_active(),"homekit")) { + if(ck_store_set("control/homekit-reset","1",1) || ck_atomic(ck_path(CK_PROTOCOL_PENDING),"homekit",7,0600)) { + operation_end(lock);json(fd,503,"{}");return; + } + close(lock);json(fd,200,"{\"status\":\"restarting\"}");management_reboot();return; + } + operation_end(lock);json(fd,503,"{\"error\":\"protocol_not_ready\"}");return; + } + if(!strcmp(path,"/admin/wifi")) { + uint8_t request[104]; + if(length!=208 || ck_unhex(body,104,request) || memcmp(request,"CKW1",4) || request[4]<1 || request[4]>32 || request[5]>64 || request[6]>1 || request[7] || + (request[6] && request[5]<8) || (!request[6] && request[5])) {operation_end(lock);json(fd,400,"{}");return;} + int error=ck_atomic(ck_path("/var/run/ck-setup-request.bin"),request,sizeof(request),0600); + if(!error)error=ck_atomic(ck_path("/var/run/ck-network-changing"),"1",1,0600); + if(!error)error=ck_atomic(ck_path("/var/run/ck-admin-wifi"),"1",1,0600); + if(error)unlink(ck_path("/var/run/ck-network-changing")); + memset(request,0,sizeof(request));operation_end(lock);json(fd,error?503:202,error?"{}":"{\"result\":\"queued\"}");return; + } + operation_end(lock);json(fd,404,"{}"); +} static void handle(int fd, int server) { char request[4096], method[12], path[80], version[16], - host[128] = "", origin[160] = "", digest[80] = ""; + host[128] = "", origin[160] = "", digest[80] = "", proof[132] = "", cookie[512]="", csrf[80]="", login[132]=""; unsigned n = 0, base = 0, length = 0, seen = 0; time_t start = time(NULL); while (!base && n < sizeof(request) - 1 && time(NULL) - start < 10) { @@ -105,6 +202,11 @@ static void handle(int fd, int server) { bit = 8; out = digest; cap = sizeof(digest); + } else if(!strcasecmp(line,"Cookie")) { bit=64;out=cookie;cap=sizeof(cookie); + } else if(!strcasecmp(line,"X-CK-CSRF")) { bit=128;out=csrf;cap=sizeof(csrf); + } else if(!strcasecmp(line,"X-CK-Login")) { bit=256;out=login;cap=sizeof(login); + } else if (!strcasecmp(line,"X-CK-Proof")) { + bit=32;out=proof;cap=sizeof(proof); } else if (!strcasecmp(line, "Content-Length")) { bit = 16; char *tail; @@ -173,11 +275,65 @@ static void handle(int fd, int server) { return; } } + struct web_session *session=web_session(cookie,remote); + if(!strcmp(method,"POST") && strcmp(path,"/upload")) { + if(length>512){json(fd,400,"{}");return;} + while(n-basecsrf:"");json(fd,200,b);return; + } + if(!strcmp(method,"POST")) { + if(!web_storage_ready){json(fd,503,"{\"error\":\"storage\"}");return;} + if(!web_authorized(session,csrf)){json(fd,401,"{\"error\":\"login_required\"}");return;} + if(!strcmp(path,"/auth/logout")){memset(session,0,sizeof(*session));json(fd,200,"{\"authenticated\":false}");return;} + if(!strcmp(path,"/auth/password")) { + uint8_t record[CK_ADMIN_BYTES]; + if(length!=120 || ck_unhex(request+base,60,record) || !ck_admin_valid(record)){json(fd,400,"{}");return;} + int lock=operation_begin();if(lock<0){memset(record,0,sizeof(record));json(fd,409,"{}");return;} + int r=ck_store_set(CK_ADMIN_KEY,record,sizeof(record));memset(record,0,sizeof(record)); + operation_end(lock); + if(!r)memset(web_sessions,0,sizeof(web_sessions));json(fd,r?503:200,r?"{\"error\":\"storage\"}":"{\"result\":\"login_again\"}");return; + } + if(!strcmp(path,"/protocol")){protocol_set(fd,request+base);return;} + if(!strncmp(path,"/admin/",7)) { + if(strcmp(path,"/admin/wifi") && strcmp(path,"/admin/led") && strcmp(path,"/admin/led-time") && length){json(fd,400,"{}");return;} + admin_action(fd,path,request+base,length);return; + } + } if (!strcmp(method, "GET") && length == 0 && n == base) { if (!strcmp(path, "/")) { reply(fd, 200, "text/html; charset=utf-8", ck_update_page); return; } + if(!strcmp(path,"/password.js")){reply(fd,200,"application/javascript",ck_password_script);return;} + if(!strcmp(path,"/admin/led")) { + if(!session){json(fd,401,"{}");return;}char b[768]; + if(ck_led_status(b,sizeof(b))){json(fd,503,"{\"error\":\"storage\"}");return;}json(fd,200,b);return; + } + if(!strcmp(path,"/admin/onboarding")) { + if(!session){json(fd,401,"{}");return;} + char manual[12]={0},b[48]; + if(ck_exact_file(ck_path("/var/run/matter-manual-code"),manual,11)){json(fd,503,"{}");return;} + for(unsigned i=0;i<11;i++)if(manual[i]<'0'||manual[i]>'9'){json(fd,503,"{}");return;} + snprintf(b,sizeof(b),"{\"manual_code\":\"%s\"}",manual);json(fd,200,b);return; + } + if(!strcmp(path,"/protocol")){protocol_get(fd);return;} + if(!strcmp(path,"/admin/state")) { + if(!session){json(fd,401,"{}");return;} + char state[512]="null",b[768],energy[32]="null";FILE *f=fopen(ck_path(CK_MATTER_STATE),"r"); + if(f){if(!fgets(state,sizeof(state),f) || strncmp(state,"{\"updated_s\":",13))strcpy(state,"null");fclose(f);} + uint64_t total=0;uint32_t fraction=0; + if(ck_energy_load(&total,&fraction)>=0)snprintf(energy,sizeof(energy),"\"%llu\"",(unsigned long long)total); + snprintf(b,sizeof(b),"{\"matter\":%s,\"energy_mwh\":%s}",state,energy); + json(fd,200,b);return; + } if (!strcmp(path, "/status")) { char b[512], label[32] = {0}; uint8_t meta[12]; @@ -192,6 +348,8 @@ static void handle(int fd, int server) { fclose(f); } label[strcspn(label, "\r\n")] = 0; + uint8_t floor_bytes[4]={0};size_t floor_size=4; + if(ck_store_try_get("ota/floor",floor_bytes,&floor_size) || floor_size!=4)memset(floor_bytes,0,4); snprintf(b, sizeof(b), "{\"software_version\":%u,\"variant\":%u,\"version\":\"%s\"," "\"rescue\":%s,\"healthy\":%s,\"confirmed_version\":%u,\"max_" @@ -200,7 +358,7 @@ static void handle(int fd, int server) { access(ck_path("/var/run/ck-rescue"), F_OK) ? "false" : "true", access(ck_path("/var/run/ck-update-healthy"), F_OK) ? "false" : "true", - ck_store_floor(), CK_ROOTFS_SIZE + 256); + ck_be32(floor_bytes), CK_ROOTFS_SIZE + 256); json(fd, 200, b); return; } diff --git a/firmware/matter/src/update/ck_updater.c b/firmware/matter/src/update/ck_updater.c index e6b681d..c8e69ca 100644 --- a/firmware/matter/src/update/ck_updater.c +++ b/firmware/matter/src/update/ck_updater.c @@ -15,6 +15,10 @@ #include #include #include +#include +#include "ck_factory_reset.h" +#include "ck_protocol_owner.h" +#include "ck_led_service.h" static int other_processes(void) { DIR *dir = opendir("/proc"); if (!dir) @@ -179,6 +183,19 @@ static int apply(int simulation) { return result ? 12 : 0; } int main(int argc, char **argv) { + if(argc==2 && !strcmp(argv[1],"--run-led")) return ck_led_run(); + if(argc==3 && !strcmp(argv[1],"--led-ntp")) return ck_led_ntp(argv[2]); + if((argc==2 && !strcmp(argv[1],"--factory-reset")) || (argc==1 && strstr(argv[0],"ck-reset"))) + { + int lock=ck_transfer_lock();if(lock<0)return 1; + if(!access(ck_path("/var/run/ck-ota/queued"),F_OK) || !access(ck_path(CK_PROTOCOL_PENDING),F_OK) || + !access(ck_path("/var/run/ck-network-changing"),F_OK) || mkdir(ck_path(CK_MATTER_LOCK),0700)) {close(lock);return 1;} + int r=ck_factory_request();if(r)rmdir(ck_path(CK_MATTER_LOCK));close(lock);return r?1:0; + } + if(argc==2 && !strcmp(argv[1],"--enroll-admin")) return admin_enroll() ? 1 : 0; + if(argc==2 && !strcmp(argv[1],"--admin-state")) return admin_export_state() ? 1 : 0; + if(argc==2 && !strcmp(argv[1],"--run-protocol")) return run_protocol(); + if(argc==2 && !strcmp(argv[1],"--protocol-boot")) return protocol_boot(); if (argc == 3 && !strcmp(argv[1], "--verify")) { struct ck_image im; int r = ck_ota_verify(argv[2], &im); @@ -209,8 +226,19 @@ int main(int argc, char **argv) { if (argc == 2 && !strcmp(argv[1], "--simulate")) return apply(1); #endif - if (argc == 2 && !strcmp(argv[1], "--import")) - return ck_store_import() ? 1 : 0; + if (argc == 2 && (!strcmp(argv[1], "--import") || !strcmp(argv[1], "--import-wait"))) { + int r = !strcmp(argv[1], "--import-wait") ? ck_store_import_wait() : ck_store_import(); + return r == 75 ? 75 : r ? 1 : 0; + } + if (argc == 2 && !strcmp(argv[1], "--onboarded")) { + if(ck_store_onboarded())return 1; +#ifndef CK_TESTING + if(!strcmp(ck_protocol_active(),"homekit")) { + pid_t child=fork();if(!child){sleep(2);execl("/bin/reboot","reboot",(char *)NULL);_exit(127);} + } +#endif + return 0; + } if (argc == 2 && !strcmp(argv[1], "--boot")) return ck_store_boot(); if (argc == 2 && !strcmp(argv[1], "--confirm")) { diff --git a/firmware/matter/src/update/ck_web_auth.h b/firmware/matter/src/update/ck_web_auth.h new file mode 100644 index 0000000..5e74779 --- /dev/null +++ b/firmware/matter/src/update/ck_web_auth.h @@ -0,0 +1,98 @@ +#include "ck_admin.h" +/* Bounded RAM sessions; no password, bearer token or proof is logged. */ +struct web_session { char token[65],csrf[65]; uint32_t ip; time_t until; }; +struct web_challenge { char nonce[65];uint32_t ip;time_t until; }; +static struct web_session web_sessions[4]; +static struct web_challenge web_challenges[8]; +static unsigned web_next_challenge,web_next_session; +static uint8_t web_credential[CK_ADMIN_BYTES]; +static int web_credential_state=-1; +static int web_storage_ready; +struct web_limit { uint32_t ip;unsigned failures;time_t retry; }; +static struct web_limit web_limits[8]; +static time_t web_now(void) { struct timespec t;return clock_gettime(CLOCK_MONOTONIC,&t)?-1:t.tv_sec; } +static int web_random(char out[65]) { + uint8_t b[32];int f=open("/dev/urandom",O_RDONLY|O_CLOEXEC); + int r=f<0?-1:ck_read_all(f,b,sizeof(b));if(f>=0)close(f); + if(!r)ck_hex(b,32,out);memset(b,0,sizeof(b));return r; +} +static int web_load(void) { + uint8_t next[CK_ADMIN_BYTES]={0};int state=ck_admin_load(next); + web_storage_ready=state>=0; + if(state<0) return -1; + if(state!=web_credential_state || memcmp(next,web_credential,sizeof(next))) { + memset(web_sessions,0,sizeof(web_sessions));memset(web_challenges,0,sizeof(web_challenges)); + memcpy(web_credential,next,sizeof(next));web_credential_state=state; + } + const char *flag=state==0?"1":state==1?"0":"!"; + ck_atomic(ck_path("/var/run/ck-admin-state"),flag,1,0600); + return state; +} +static struct web_session *web_session(const char *cookie,uint32_t ip) { + if(web_load())return NULL; + const char *p=cookie;char token[65]={0};unsigned found=0; + while(*p) { + while(*p==' ' || *p==';')p++; + const char *end=strchr(p,';');if(!end)end=p+strlen(p); + if(end-p>=7 && !memcmp(p,"ck_sid=",7)) { + if(++found!=1 || end-p!=71)return NULL; + memcpy(token,p+7,64); + } + p=end; + } + if(found!=1)return NULL; + time_t now=web_now();if(now<0)return NULL; + for(unsigned i=0;i<4;i++) { + struct web_session *s=web_sessions+i; + if(s->until>now && s->ip==ip && ck_equal(token,s->token,64))return s; + } + return NULL; +} +static int web_authorized(struct web_session *s,const char *csrf) { + if(!s || strlen(csrf)!=64 || !ck_equal(csrf,s->csrf,64))return 0; + s->until=web_now()+900;return 1; +} +static void web_challenge(int fd,uint32_t ip) { + int r=web_load();if(r) { json(fd,r==1?409:503,r==1?"{\"error\":\"setup_required\"}":"{\"error\":\"storage\"}");return; } + struct web_challenge *c=&web_challenges[web_next_challenge++%8]; + c->until=0;if(web_random(c->nonce)) { json(fd,503,"{}");return; } + c->ip=ip;c->until=web_now()+120; + char b[240],salt[33];ck_hex(web_credential+12,16,salt); + snprintf(b,sizeof(b),"{\"nonce\":\"%s\",\"salt\":\"%s\",\"iterations\":%u}",c->nonce,salt,CK_ADMIN_ITERATIONS); + json(fd,200,b); +} +static struct web_limit *web_limit(uint32_t ip) { + struct web_limit *slot=&web_limits[ip%8]; + for(unsigned i=0;i<8;i++)if(web_limits[i].ip==ip)return web_limits+i; + for(unsigned i=0;i<8;i++)if(!web_limits[i].ip){slot=web_limits+i;break;} + memset(slot,0,sizeof(*slot));slot->ip=ip;return slot; +} +static void web_login(int fd,uint32_t ip,const char *proof) { + int loaded=web_load();if(loaded) { json(fd,loaded<0?503:409,loaded<0?"{\"error\":\"storage\"}":"{\"error\":\"setup_required\"}");return; } + struct web_limit *limit=web_limit(ip);time_t now=web_now(); + if(now<0 || limit->retry>now) { json(fd,429,"{\"error\":\"retry_later\"}");return; } + struct web_challenge *challenge=NULL; + if(strlen(proof)==129 && proof[64]==':') + for(unsigned i=0;i<8;i++)if(web_challenges[i].ip==ip && web_challenges[i].until>now && + ck_equal(proof,web_challenges[i].nonce,64)){challenge=web_challenges+i;break;} + int valid=0; + if(challenge) { + char message[96];uint8_t actual[32],given[32]; + int n=snprintf(message,sizeof(message),"ck-admin-login-v1\n%s",challenge->nonce); + challenge->until=0; + ck_hmac(web_credential+28,message,n,actual); + valid=!ck_unhex(proof+65,32,given) && ck_equal(actual,given,32); + } + if(!valid) { + if(limit->failures<8)limit->failures++; + limit->retry=now+(limit->failures<3?0:1u<<(limit->failures>7?5:limit->failures-3)); + json(fd,403,"{\"error\":\"login\"}");return; + } + memset(limit,0,sizeof(*limit)); + struct web_session *s=&web_sessions[web_next_session++%4];memset(s,0,sizeof(*s)); + if(web_random(s->token) || web_random(s->csrf)){json(fd,503,"{}");return;} + s->ip=ip;s->until=now+900; + char b[128],h[512];int n=snprintf(b,sizeof(b),"{\"authenticated\":true,\"csrf\":\"%s\"}",s->csrf); + int hn=snprintf(h,sizeof(h),"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\nCache-Control: no-store\r\nSet-Cookie: ck_sid=%s; HttpOnly; SameSite=Strict; Path=/; Max-Age=900\r\nX-Content-Type-Options: nosniff\r\n\r\n",n,s->token); + sendall(fd,h,hn);sendall(fd,b,n); +} diff --git a/firmware/matter/src/update/password.js b/firmware/matter/src/update/password.js new file mode 100644 index 0000000..8123643 --- /dev/null +++ b/firmware/matter/src/update/password.js @@ -0,0 +1,65 @@ +(function () { +"use strict"; +function sha256(input) { + const k = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2]; + const h = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]; + const data = new Uint8Array(Math.ceil((input.length + 9) / 64) * 64); + data.set(input); data[input.length] = 128; + const view = new DataView(data.buffer); view.setUint32(data.length - 4, input.length * 8); + const rr = (x,n) => (x >>> n) | (x << (32-n)); + for (let at=0; at>>3))+w[i-7]+(rr(w[i-2],17)^rr(w[i-2],19)^(w[i-2]>>>10)))>>>0; + let [a,b,c,d,e,f,g,j]=h; + for(let i=0;i<64;i++) { const t=(j+(rr(e,6)^rr(e,11)^rr(e,25))+((e&f)^(~e&g))+k[i]+w[i])>>>0; + const u=((rr(a,2)^rr(a,13)^rr(a,22))+((a&b)^(a&c)^(b&c)))>>>0; + j=g;g=f;f=e;e=(d+t)>>>0;d=c;c=b;b=a;a=(t+u)>>>0; } + [a,b,c,d,e,f,g,j].forEach((v,i)=>h[i]=(h[i]+v)>>>0); + } + const out=new Uint8Array(32), dv=new DataView(out.buffer);h.forEach((v,i)=>dv.setUint32(i*4,v));return out; +} +const hex = bytes => Array.from(bytes,b=>b.toString(16).padStart(2,'0')).join(''); +function hmac256(key,input) { + const inner=new Uint8Array(64+input.length),outer=new Uint8Array(96); + for(let i=0;i<64;i++){inner[i]=(key[i]||0)^0x36;outer[i]=(key[i]||0)^0x5c;} + inner.set(input,64);outer.set(sha256(inner),64);return sha256(outer); +} + +function pbkdf2(password,salt,iterations) { + if(!(iterations>=1&&iterations<=600000)||salt.length!==16)throw Error('Invalid password parameters'); + let key=password.length>64?sha256(password):password; + const inner=new Uint8Array(96),outer=new Uint8Array(96); + for(let i=0;i<64;i++){inner[i]=(key[i]||0)^0x36;outer[i]=(key[i]||0)^0x5c;} + function prf(bytes){const first=new Uint8Array(64+bytes.length);first.set(inner.subarray(0,64));first.set(bytes,64);outer.set(sha256(first),64);return sha256(outer);} + const first=new Uint8Array(20);first.set(salt);first[19]=1; + let u=prf(first),out=new Uint8Array(u); + for(let round=1;round{try{const {password,salt,iterations}=event.data; + const result=pbkdf2(new TextEncoder().encode(password),Uint8Array.from(salt.match(/../g),x=>parseInt(x,16)),iterations); + self.postMessage({key:hex(result)});result.fill(0); + }catch{self.postMessage({error:'密碼處理失敗,請重新輸入'});}}; +}else if(typeof window!=='undefined'){ + window.CKPassword={ + async derive(password,salt,iterations=600000){ + if(typeof password!=='string'||new TextEncoder().encode(password).length>128||password.length<8||!/^[a-f0-9]{32}$/.test(salt)||iterations!==600000)throw Error('管理密碼須至少 8 個字元,且不超過 128 bytes'); + if(window.crypto?.subtle){const p=await crypto.subtle.importKey('raw',new TextEncoder().encode(password),'PBKDF2',false,['deriveBits']); + const bytes=Uint8Array.from(salt.match(/../g),x=>parseInt(x,16));return hex(new Uint8Array(await crypto.subtle.deriveBits({name:'PBKDF2',salt:bytes,iterations,hash:'SHA-256'},p,256)));} + return new Promise((resolve,reject)=>{const worker=new Worker('/password.js'); + const timer=setTimeout(()=>{worker.terminate();reject(Error('密碼處理逾時,請重新嘗試'));},240000); + worker.onmessage=event=>{clearTimeout(timer);worker.terminate();event.data.error?reject(Error(event.data.error)):resolve(event.data.key);}; + worker.onerror=()=>{clearTimeout(timer);worker.terminate();reject(Error('密碼處理無法啟動,請重新開啟頁面'));}; + worker.postMessage({password,salt,iterations});}); + }, + salt(){const bytes=new Uint8Array(16);crypto.getRandomValues(bytes);return hex(bytes);}, + record(salt,key){return '434b41555448310a000927c0'+salt+key;} + }; +} + +})(); diff --git a/firmware/matter/src/update/password_script.h b/firmware/matter/src/update/password_script.h new file mode 100644 index 0000000..940d576 --- /dev/null +++ b/firmware/matter/src/update/password_script.h @@ -0,0 +1,2 @@ +/* Generated public password worker. */ +static const char ck_password_script[] = "(function () {\n\"use strict\";\nfunction sha256(input) {\n const k = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];\n const h = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19];\n const data = new Uint8Array(Math.ceil((input.length + 9) / 64) * 64);\n data.set(input); data[input.length] = 128;\n const view = new DataView(data.buffer); view.setUint32(data.length - 4, input.length * 8);\n const rr = (x,n) => (x >>> n) | (x << (32-n));\n for (let at=0; at>>3))+w[i-7]+(rr(w[i-2],17)^rr(w[i-2],19)^(w[i-2]>>>10)))>>>0;\n let [a,b,c,d,e,f,g,j]=h;\n for(let i=0;i<64;i++) { const t=(j+(rr(e,6)^rr(e,11)^rr(e,25))+((e&f)^(~e&g))+k[i]+w[i])>>>0;\n const u=((rr(a,2)^rr(a,13)^rr(a,22))+((a&b)^(a&c)^(b&c)))>>>0;\n j=g;g=f;f=e;e=(d+t)>>>0;d=c;c=b;b=a;a=(t+u)>>>0; }\n [a,b,c,d,e,f,g,j].forEach((v,i)=>h[i]=(h[i]+v)>>>0);\n }\n const out=new Uint8Array(32), dv=new DataView(out.buffer);h.forEach((v,i)=>dv.setUint32(i*4,v));return out;\n}\nconst hex = bytes => Array.from(bytes,b=>b.toString(16).padStart(2,'0')).join('');\nfunction hmac256(key,input) {\n const inner=new Uint8Array(64+input.length),outer=new Uint8Array(96);\n for(let i=0;i<64;i++){inner[i]=(key[i]||0)^0x36;outer[i]=(key[i]||0)^0x5c;}\n inner.set(input,64);outer.set(sha256(inner),64);return sha256(outer);\n}\n\nfunction pbkdf2(password,salt,iterations) {\n if(!(iterations>=1&&iterations<=600000)||salt.length!==16)throw Error('Invalid password parameters');\n let key=password.length>64?sha256(password):password;\n const inner=new Uint8Array(96),outer=new Uint8Array(96);\n for(let i=0;i<64;i++){inner[i]=(key[i]||0)^0x36;outer[i]=(key[i]||0)^0x5c;}\n function prf(bytes){const first=new Uint8Array(64+bytes.length);first.set(inner.subarray(0,64));first.set(bytes,64);outer.set(sha256(first),64);return sha256(outer);}\n const first=new Uint8Array(20);first.set(salt);first[19]=1;\n let u=prf(first),out=new Uint8Array(u);\n for(let round=1;round{try{const {password,salt,iterations}=event.data;\n const result=pbkdf2(new TextEncoder().encode(password),Uint8Array.from(salt.match(/../g),x=>parseInt(x,16)),iterations);\n self.postMessage({key:hex(result)});result.fill(0);\n }catch{self.postMessage({error:'密碼處理失敗,請重新輸入'});}};\n}else if(typeof window!=='undefined'){\n window.CKPassword={\n async derive(password,salt,iterations=600000){\n if(typeof password!=='string'||new TextEncoder().encode(password).length>128||password.length<8||!/^[a-f0-9]{32}$/.test(salt)||iterations!==600000)throw Error('管理密碼須至少 8 個字元,且不超過 128 bytes');\n if(window.crypto?.subtle){const p=await crypto.subtle.importKey('raw',new TextEncoder().encode(password),'PBKDF2',false,['deriveBits']);\n const bytes=Uint8Array.from(salt.match(/../g),x=>parseInt(x,16));return hex(new Uint8Array(await crypto.subtle.deriveBits({name:'PBKDF2',salt:bytes,iterations,hash:'SHA-256'},p,256)));}\n return new Promise((resolve,reject)=>{const worker=new Worker('/password.js');\n const timer=setTimeout(()=>{worker.terminate();reject(Error('密碼處理逾時,請重新嘗試'));},240000);\n worker.onmessage=event=>{clearTimeout(timer);worker.terminate();event.data.error?reject(Error(event.data.error)):resolve(event.data.key);};\n worker.onerror=()=>{clearTimeout(timer);worker.terminate();reject(Error('密碼處理無法啟動,請重新開啟頁面'));};\n worker.postMessage({password,salt,iterations});});\n },\n salt(){const bytes=new Uint8Array(16);crypto.getRandomValues(bytes);return hex(bytes);},\n record(salt,key){return '434b41555448310a000927c0'+salt+key;}\n };\n}\n\n})();\n"; diff --git a/firmware/matter/src/update/update.html b/firmware/matter/src/update/update.html index 6fd02ca..90c4618 100644 --- a/firmware/matter/src/update/update.html +++ b/firmware/matter/src/update/update.html @@ -1,26 +1,332 @@ - -CommandKit 插座用電與更新 + +CommandKit 插座管理 -
COMMANDKIT / CMNDKT-004

插座用電

韌體版本
讀取中…
-
-
-
正在連線…插座狀態讀取中
-
-
電壓
—V

交流有效值

-
電流
—A

交流有效值

-
即時功率
—W

目前消耗的功率

-
累積用電
—kWh

功率估算 · 約 30 秒保存

-
-
等待量測資料每 2 秒自動更新
+ +
CommandKitCMNDKT-004 智慧插座 · 本機管理
+
正在連線 + +
+
+ +
+ + + +
+

狀態總覽

即時電力與裝置狀態

+
+
正在讀取量測…插座狀態未知
+
+
即時功率—W
+
近 2 分鐘功率走勢
+收集讀值中… +
平均—
最高—
最低—
+
電壓

—V

交流有效值
+
電流

—A

交流有效值
+
累積用電

—kWh

功率估算 · 約 30 秒保存一次
+

等待量測資料每 2 秒更新

+

裝置資訊

+
韌體版本—
目前協議—
網路位址—
線上更新尚未檢查
+
訪客可以查看狀態。登入後可調整燈號、配對、更新韌體與重設設定。
-

韌體更新

讓插座保持更新,原有設定與配對會保留。

-
使用已下載的更新包

更新會暫停控制並重新啟動插座,請保持供電。

-

可檢查新版本,或使用已下載的更新包。

-
-
讀值無效或連線中斷時顯示「—」。累積用電從啟用電量紀錄起計算,不含先前的使用量。
- diff --git a/firmware/matter/src/update/update.js b/firmware/matter/src/update/update.js index d94a8ac..47ff187 100644 --- a/firmware/matter/src/update/update.js +++ b/firmware/matter/src/update/update.js @@ -19,10 +19,15 @@ function sha256(input) { const out=new Uint8Array(32), dv=new DataView(out.buffer);h.forEach((v,i)=>dv.setUint32(i*4,v));return out; } const hex = bytes => Array.from(bytes,b=>b.toString(16).padStart(2,'0')).join(''); +function hmac256(key,input) { + const inner=new Uint8Array(64+input.length),outer=new Uint8Array(96); + for(let i=0;i<64;i++){inner[i]=(key[i]||0)^0x36;outer[i]=(key[i]||0)^0x5c;} + inner.set(input,64);outer.set(sha256(inner),64);return sha256(outer); +} function meterView(data) { const reading=(n,max,digits)=>Number.isInteger(n)&&n>=0&&n<=max?(n/1000).toFixed(digits):'—'; const live=!!(data&&data.available===true&&Number.isInteger(data.sample_age_ms)&&data.sample_age_ms>=0&&data.sample_age_ms<=5000); - const d=live?data:{}; + const d=live?data:{energy_mwh:data?.saved_energy_mwh}; let energy='—'; if(typeof d.energy_mwh==='string'&&/^(0|[1-9][0-9]{0,18})$/.test(d.energy_mwh)&& (d.energy_mwh.length<19||d.energy_mwh<='4611686018427387904')){ @@ -34,45 +39,203 @@ function meterView(data) { state:complete?'量測資料正常':live?'部分讀值尚未就緒':'暫無即時資料', note:live?'讀值於 '+new Date().toLocaleTimeString('zh-TW',{hour12:false})+' 更新':'等待裝置提供新的量測資料'}; } -if (typeof module !== 'undefined') module.exports={sha256,hex,meterView}; -if (typeof document !== 'undefined') (async () => { - const $=id=>document.getElementById(id), message=text=>{$('message').textContent=text;}; - let status=null, latest=null, busy=false; - function drawMeter(data,offline=false){const view=meterView(data); - for(const [id,value] of [['voltage',view.voltage],['current-amps',view.current],['power',view.power],['energy',view.energy],['relay',view.relay],['meter-updated',view.note]])$(id).textContent=value; - $('meter-state').textContent=offline?'連線中斷,正在重試':view.state; - $('meter-state').dataset.state=offline?'offline':view.complete?'live':'waiting';} - async function monitor(){ - if(!busy&&!document.hidden){const controller=new AbortController(),timeout=setTimeout(()=>controller.abort(),4000); - try{const r=await fetch('/measurements',{cache:'no-store',signal:controller.signal});if(!r.ok)throw Error('measurements');const data=await r.json();if(!busy&&!document.hidden)drawMeter(data);} - catch{if(!busy&&!document.hidden)drawMeter(null,true);}finally{clearTimeout(timeout);}} - setTimeout(monitor,2000); +/* Watts for the gauge and trend; null whenever meterView would show a dash. */ +function powerWatts(data){const v=meterView(data);return v.live&&v.power!=='—'?Number(v.power):null;} +/* Logarithmic 0–2 kW arc so a phone charger and a heater are both visible. */ +const gaugeFraction=w=>Math.min(1,Math.max(0,Math.log10(1+w)/Math.log10(2001))); +/* Same compatibility rules every earlier release used to read the catalog. */ +function newestUpdate(updates,current){ + return updates.filter(x=>x&&x.model==='CMNDKT-004'&&x.variant==='matter'&&Number.isSafeInteger(x.software_version)&&x.software_version>current).sort((a,b)=>b.software_version-a.software_version)[0]||null; +} +/* SVG paths for the trend chart; samples are [ms, watts] pairs. */ +function trendPaths(samples,now,span=120000,width=300,height=120){ + const points=samples.filter(([t])=>now-t<=span);if(points.length<2)return null; + const values=points.map(p=>p[1]),max=Math.max(...values),min=Math.min(...values),top=Math.max(max*1.15,5); + const xy=points.map(([t,w])=>[(width*(1-(now-t)/span)).toFixed(1),(height-6-(height-12)*w/top).toFixed(1)]); + const line='M'+xy.map(p=>p.join(' ')).join('L'); + return {line,area:line+'L'+xy[xy.length-1][0]+' '+height+'L'+xy[0][0]+' '+height+'Z',min,max,top,avg:values.reduce((a,b)=>a+b,0)/values.length}; +} +if (typeof module !== 'undefined') module.exports={sha256,hex,hmac256,meterView,powerWatts,gaugeFraction,newestUpdate,trendPaths}; +if(typeof document!=='undefined')(async()=>{ + const $=id=>document.getElementById(id),enc=s=>new TextEncoder().encode(s),sleep=ms=>new Promise(r=>setTimeout(r,ms)); + let auth={authenticated:false},csrf='',status=null,protocol=null,busy=false,latest=null,catalog={state:'idle',error:''},installing=false, + protocolDirty=false,ledDirty=false,current='overview',pending=null,toastTimer=0; + const samples=[],fmtW=w=>w>=1000?(w/1000).toFixed(2)+' kW':w.toFixed(1)+' W'; + $('device-address').textContent=location.hostname; + document.querySelectorAll('input[name=protocol]').forEach(input=>input.onchange=()=>{protocolDirty=true;}); + function toast(text,kind='info'){const box=$('toast');$('toast-text').textContent=text;box.dataset.kind=kind; + $('toast-icon').setAttribute('href',kind==='error'?'#i-alert':kind==='success'?'#i-check':'#i-info'); + box.hidden=false;clearTimeout(toastTimer);toastTimer=setTimeout(()=>{box.hidden=true;},kind==='error'?8000:5000);} + function show(name){current=name;if(catalog.state==='available')$('update-banner').hidden=name==='update'||installing;document.querySelectorAll('.view').forEach(s=>s.hidden=s.id!=='view-'+name); + document.querySelectorAll('.tabs button').forEach(b=>b.setAttribute('aria-current',b.dataset.view===name?'page':'false'));} + function view(name){if(name!=='overview'&&!auth.authenticated){pending=name;openLogin();return;} + show(name);window.scrollTo(0,0);if(name!=='overview')settingsRefresh().catch(()=>{});} + document.querySelectorAll('.tabs button').forEach(b=>b.onclick=()=>view(b.dataset.view)); + $('update-banner').onclick=()=>view('update'); + function openLogin(note=''){if(auth.configured===false){toast('尚未設定管理密碼,請先完成初始設定','error');return;} + const n=$('login-note');n.textContent=note;n.dataset.tone='';if(!$('login-box').open)$('login-box').showModal();$('login-password').focus();} + $('login-open').onclick=()=>openLogin(); + $('login-cancel').onclick=()=>{pending=null;$('login-box').close();}; + $('login-box').addEventListener('close',()=>{$('login-password').value='';}); + function drawAuth(data){auth=data;csrf=data.csrf||'';const admin=!!data.authenticated; + document.body.classList.toggle('is-admin',admin);$('login-open').hidden=admin;$('account').hidden=!admin;$('guest-note').hidden=admin; + $('setup-notice').hidden=data.configured!==false;$('login-open').disabled=data.configured===false; + if(!admin&¤t!=='overview')show('overview');drawUpdate();} + async function authRefresh(){const r=await fetch('/auth/session',{cache:'no-store'});if(!r.ok)throw Error('管理服務尚未就緒');const data=await r.json(); + if(data.storage_ready===false){$('login-open').disabled=true;$('login-open').title='管理設定準備中';return;}$('login-open').title='';drawAuth(data);} + async function api(path,body='',digest=null){ + const headers={'Content-Type':'text/plain','X-CK-CSRF':csrf};if(digest)headers['X-CK-SHA256']=digest; + const r=await fetch(path,{method:'POST',credentials:'same-origin',headers,body}); + const data=await r.json().catch(()=>({})); + if(!r.ok){if(r.status===401){drawAuth({authenticated:false,configured:true});openLogin('登入已逾時,請重新登入後再操作');throw Error('請先登入,或重新登入後操作');} + throw Error(r.status===409?'裝置正在處理其他操作,請稍候再試':r.status===503?'裝置或儲存尚未準備好,請稍候再試':'操作遭拒絕,請確認輸入與更新檔案');}return data; } + function ask(text,title='確認操作',yes='確認'){return new Promise(resolve=>{const dialog=$('confirm-box');$('confirm-title').textContent=title;$('confirm-text').textContent=text;$('confirm-yes').textContent=yes; + $('confirm-yes').onclick=()=>{dialog.close();resolve(true);};$('confirm-no').onclick=()=>{dialog.close();resolve(false);}; + dialog.oncancel=()=>resolve(false);dialog.showModal();});} + function setBusy(value){busy=value;if(value)drawMeter(null);document.querySelectorAll('.admin-view button').forEach(b=>b.disabled=value);} + async function run(job,button){if(busy)return;setBusy(true);button?.classList.add('loading');try{await job();}catch(e){toast(e.message,'error');}finally{button?.classList.remove('loading');setBusy(false);}} + document.querySelectorAll('.pw-toggle').forEach(b=>b.onclick=()=>{const input=$(b.dataset.for),reveal=input.type==='password';input.type=reveal?'text':'password'; + b.setAttribute('aria-label',reveal?'隱藏密碼':'顯示密碼');b.setAttribute('aria-pressed',String(reveal));}); + $('logout').onclick=async()=>{try{await api('/auth/logout');drawAuth({authenticated:false,configured:true});toast('已登出');}catch(e){toast(e.message,'error');}}; + $('login-form').onsubmit=async event=>{event.preventDefault();const button=$('login-submit'),note=$('login-note');button.disabled=true;button.classList.add('loading'); + note.dataset.tone='';note.textContent='正在驗證密碼,約需數秒…'; + try{const r=await fetch('/auth/challenge',{cache:'no-store'});if(!r.ok)throw Error('請先完成初始管理密碼設定');const c=await r.json(); + if(!/^[a-f0-9]{64}$/.test(c.nonce))throw Error('驗證資料無效'); + const keyText=await CKPassword.derive($('login-password').value,c.salt,c.iterations);$('login-password').value=''; + const key=Uint8Array.from(keyText.match(/../g),x=>parseInt(x,16));const proof=c.nonce+':'+hex(hmac256(key,enc('ck-admin-login-v1\n'+c.nonce)));key.fill(0); + const login=await fetch('/auth/login',{method:'POST',headers:{'X-CK-Login':proof},body:''}); + if(!login.ok)throw Error(login.status===429?'嘗試次數較多,請稍候再登入':'密碼不正確,或驗證已逾時'); + drawAuth({...await login.json(),configured:true});note.textContent='';$('login-box').close();toast('已登入管理員','success'); + const next=pending;pending=null;if(next)view(next);else settingsRefresh().catch(()=>{}); + }catch(e){note.dataset.tone='error';note.textContent=e.message;}finally{button.disabled=false;button.classList.remove('loading');}}; + function drawTrend(){const t=trendPaths(samples,Date.now());$('trend-empty').style.display=t?'none':''; + $('trend-line').setAttribute('d',t?t.line:'');$('trend-area').setAttribute('d',t?t.area:''); + for(const [id,k]of[['trend-avg','avg'],['trend-max','max'],['trend-min','min']])$(id).textContent=t?fmtW(t[k]):'—'; + $('trend-range').textContent=t?'0 – '+fmtW(t.top):'';} + function drawMeter(data,offline=false){const v=meterView(data),pill=$('live-state'),fine=v.energy!=='—'; + for(const [id,value]of[['voltage',v.voltage],['current-amps',v.current],['power',v.power],['relay-text',v.relay],['meter-updated',v.note]])$(id).textContent=value; + $('energy').textContent=fine?v.energy.slice(0,-3):v.energy;$('energy-fine').textContent=fine?v.energy.slice(-3):''; + $('relay').dataset.on=v.relay==='插座已開啟'?'true':v.relay==='插座已關閉'?'false':''; + $('meter-state').textContent=offline?'連線中斷,正在重試':v.state; + const w=offline?null:powerWatts(data);$('gauge-arc').style.strokeDasharray=(w===null?0:395.84*gaugeFraction(w)).toFixed(2)+' 527.79'; + if(w!==null){const now=Date.now();samples.push([now,w]);while(samples.length&&now-samples[0][0]>120000)samples.shift();}drawTrend(); + pill.dataset.state=offline?'offline':v.complete?'live':'waiting';pill.lastElementChild.textContent=offline?'連線中斷':v.complete?'即時連線':'等待量測'; + if(protocol?.active==='homekit'){$('meter-state').textContent='HomeKit 模式';$('meter-updated').textContent='自訂即時量測及 kWh 累計暫停;顯示已保存的用電紀錄';if(!offline){pill.lastElementChild.textContent='管理連線正常';pill.dataset.state='live';}}} + const protocolName=p=>p==='homekit'?'HomeKit':p==='matter'?'Matter':'維修模式'; + async function refresh(){const r=await fetch('/status',{cache:'no-store'});if(!r.ok)throw Error('裝置尚未準備好');status=await r.json(); + $('current').textContent=status.version;$('version-big').textContent=status.version||'—';const health=$('health-chip'); + health.textContent=status.rescue?'維修模式':status.healthy?'運作正常 · 軟體版本 '+status.software_version:'正在確認啟動狀態';health.dataset.tone=status.rescue?'warn':status.healthy?'ok':''; + const p=await fetch('/protocol',{cache:'no-store'});if(p.ok){const previous=protocol?.selected;protocol=await p.json();const name=protocolName(protocol.active); + $('active-protocol').textContent=name;$('protocol-status').textContent='目前:'+name+(protocol.restart_pending?' · 等待重啟':''); + const choice=document.querySelector('input[name="protocol"][value="'+protocol.selected+'"]');if(choice&&!protocolDirty&&previous!==protocol.selected)choice.checked=true; + $('matter-onboarding').hidden=protocol.active!=='matter';$('commissioning-open').hidden=protocol.active!=='matter';}} + const ledReasons={normal:'依平常模式顯示',night:'夜間熄燈中',pairing:'等待家庭配對',joining:'正在連接 Wi-Fi',setup:'等待初次設定',error:'服務或 MCU 通訊異常',identify:'尋找插座中'}; + const ledMinutes=s=>{if(!/^\d{2}:\d{2}$/.test(s))return NaN;const [h,m]=s.split(':').map(Number);return h<24&&m<60?h*60+m:NaN;}; + const ledTime=m=>String(Math.floor(m/60)).padStart(2,'0')+':'+String(m%60).padStart(2,'0'); + function drawTimeline(){const start=ledMinutes($('led-start').value),end=ledMinutes($('led-end').value),on=$('led-schedule').checked; + const seg=(el,a,b)=>{el.hidden=!(on&&b>a);el.style.left=a/14.4+'%';el.style.width=(b-a)/14.4+'%';}; + const valid=Number.isInteger(start)&&Number.isInteger(end)&&start!==end; + seg($('night-a'),valid?start:0,valid?(startend?end:0);$('led-timeline').style.opacity=on?1:.5;} + async function ledRefresh(){if(!auth.authenticated)return;const r=await fetch('/admin/led',{cache:'no-store'});if(!r.ok){$('led-status').textContent='燈號設定尚未就緒,請稍候再試';return;}const d=await r.json(); + $('led-status').textContent=d.service_ready?(ledReasons[d.reason]||'正在讀取狀態'):'指示燈服務正在準備中';$('led-lamp').classList.toggle('lit',d.on===true); + $('led-clock').textContent=d.time_valid?'時間已校準 · '+(d.time_source==='ntp'?'時間伺服器':'手機時間')+(d.night?' · 目前為熄燈時段':''):'排程等待校時,可在「時區與校時」用手機時間校準'; + const now=$('night-now');now.hidden=!d.time_valid;if(d.time_valid)now.style.left=((Math.floor(d.utc_s/60)+d.utc_offset)%1440+1440)%1440/14.4+'%'; + if(!ledDirty){const mode=document.querySelector('input[name="led-mode"][value="'+d.mode+'"]');if(mode)mode.checked=true; + $('led-schedule').checked=d.schedule;$('led-start').value=ledTime(d.start);$('led-end').value=ledTime(d.end);$('led-offset').value=d.utc_offset/60;$('led-ntp').value=d.ntp;} + drawTimeline();} + $('led-settings').addEventListener('input',()=>{ledDirty=true;drawTimeline();}); + $('led-settings').onsubmit=e=>{e.preventDefault();run(async()=>{const mode=document.querySelector('input[name="led-mode"]:checked')?.value||'smart', + start=ledMinutes($('led-start').value),end=ledMinutes($('led-end').value),offset=Number($('led-offset').value)*60,ntp=$('led-ntp').value.trim(); + if(!Number.isInteger(start)||!Number.isInteger(end)||start===end||!Number.isInteger(offset)||offset<-720||offset>840||offset%15||!/^([A-Za-z0-9][A-Za-z0-9.-]{0,126})$/.test(ntp)||/[.-]$/.test(ntp))throw Error('請檢查熄燈時間、UTC 時差及時間伺服器'); + await api('/admin/led','CKLED1 '+mode+' '+Number($('led-schedule').checked)+' '+start+' '+end+' '+offset+' '+ntp+'\n');ledDirty=false;toast('燈號設定已保存,會立即套用。','success');await ledRefresh();},$('led-save'));}; + $('led-find').onclick=()=>run(async()=>{await api('/admin/led-find');toast('插座將閃燈 15 秒,之後恢復原本燈號。','success');},$('led-find')); + $('led-phone-time').onclick=()=>run(async()=>{await api('/admin/led-time',String(Math.floor(Date.now()/1000)));toast('已送出手機時間,本次開機的排程可離線繼續計時。','success');await ledRefresh();},$('led-phone-time')); + const formatCode=c=>/^\d{11}$/.test(c)?c.slice(0,4)+'-'+c.slice(4,7)+'-'+c.slice(7):c; + async function settingsRefresh(){if(!auth.authenticated)return;const r=await fetch('/admin/state',{cache:'no-store'});if(r.status===401){await authRefresh();return;}if(!r.ok)return; + const m=(await r.json()).matter,line=$('pairing-state'); + if(protocol?.active==='homekit'){$('pairing-text').textContent='HomeKit 模式:請使用插座原廠標籤上的配對碼。';line.dataset.tone='ok';} + else if(m){$('pairing-text').textContent='已配對 '+m.fabrics+' 個家庭 · '+(m.window_open?'配對視窗開啟中':'配對視窗已關閉');line.dataset.tone=m.window_open?'open':m.fabrics?'ok':'';} + else{$('pairing-text').textContent='Matter 正在準備中';line.dataset.tone='';} + const onboarding=await fetch('/admin/onboarding',{cache:'no-store'});if(onboarding.ok){const d=await onboarding.json();$('matter-code').textContent=d.manual_code?formatCode(d.manual_code):'尚未就緒';$('matter-code').dataset.raw=d.manual_code||'';} + await ledRefresh();} + $('code-copy').onclick=async()=>{const code=$('matter-code'),text=code.dataset.raw;if(!text)return; + try{await navigator.clipboard.writeText(text);}catch{const range=document.createRange(),selection=getSelection();range.selectNodeContents(code);selection.removeAllRanges();selection.addRange(range); + const copied=document.execCommand('copy');selection.removeAllRanges();if(!copied){toast('無法自動複製,請手動抄寫設定碼','error');return;}} + toast('已複製 Matter 設定碼','success');}; + async function waitReceipt(id){const until=Date.now()+60000;while(Date.now()run(async()=>{const mode=document.querySelector('input[name="protocol"]:checked').value;const data=await api('/protocol',mode);protocolDirty=false; + if(data.status==='unchanged'){toast('目前已使用此協議。');return;}restarting('協議選擇已保存,正在重新啟動');},$('protocol-save')); + $('energy-reset').onclick=()=>run(async()=>{if(!await ask('累積用電會歸零並立即保存,之後從零重新累計。此動作無法復原。','將累積用電歸零?','歸零'))return; + const result=await api('/admin/energy-reset');if(result.request_id)await waitReceipt(result.request_id);toast('累積用電已歸零並保存。','success');samples.length=0;drawMeter(null);await settingsRefresh();},$('energy-reset')); + $('commissioning-open').onclick=()=>run(async()=>{const r=await api('/admin/open-commissioning');await waitReceipt(r.request_id);toast('Matter 配對視窗已重新開啟。','success');await settingsRefresh();},$('commissioning-open')); + $('pairing-reset').onclick=()=>run(async()=>{if(!await ask('目前協議的家庭配對會被清除,Wi-Fi、管理密碼和累積用電會保留。','清除家庭配對?','清除配對'))return; + const r=await api('/admin/pairing-reset');if(r.status==='restarting')restarting('正在重設配對並重新啟動');else toast('正在重設配對,請稍候再查看狀態。');},$('pairing-reset')); + $('reboot').onclick=()=>run(async()=>{await api('/admin/reboot');restarting('正在重新啟動');},$('reboot')); + $('factory-reset').onclick=()=>run(async()=>{if(!await ask('會清除管理密碼、Wi-Fi、HomeKit/Matter 配對與全部累積用電,之後需要重新設定。','恢復出廠設定?','清除並恢復出廠'))return; + await api('/admin/factory-reset');toast('正在恢復出廠。重啟後請連接 CommandKit-Setup 設定熱點重新設定。','success');drawAuth({authenticated:false,configured:false});},$('factory-reset')); + $('wifi-settings').onsubmit=event=>{event.preventDefault();run(async()=>{const ssid=enc($('wifi-ssid').value),password=enc($('wifi-password').value); + if(ssid.length<1||ssid.length>32||password.length<8||password.length>64||password.some(v=>v<32||v>126))throw Error('請檢查 Wi-Fi 名稱與密碼長度'); + const b=new Uint8Array(104);b.set(enc('CKW1'));b[4]=ssid.length;b[5]=password.length;b[6]=1;b.set(ssid,8);b.set(password,40); + await api('/admin/wifi',hex(b));b.fill(0);$('wifi-password').value='';toast('已送出 Wi-Fi 設定。請切回相同網路,並用插座的新 IP 開啟管理頁。','success');},event.submitter);}; + $('password-settings').onsubmit=event=>{event.preventDefault();run(async()=>{const password=$('new-password').value;if(password!==$('confirm-password').value)throw Error('兩次密碼不相同'); + const salt=CKPassword.salt(),key=await CKPassword.derive(password,salt); + await api('/auth/password',CKPassword.record(salt,key));$('new-password').value='';$('confirm-password').value='';drawAuth({authenticated:false,configured:true});openLogin('密碼已更新,請使用新密碼登入。');},event.submitter);}; + function drawUpdate(){const s=catalog.state,newer=s==='available'&&latest,tone={checking:'busy',available:'new',current:'ok',error:'warn'}[s]||''; + $('update-state').dataset.tone=tone;$('update-icon').firstElementChild.setAttribute('href',newer?'#i-download':s==='current'?'#i-check':s==='error'?'#i-alert':'#i-update'); + $('update-title').textContent=s==='checking'?'正在檢查線上版本…':newer?'有新版本 '+latest.version:s==='current'?'目前已是最新版本':s==='error'?'無法檢查線上版本':'尚未檢查線上版本'; + $('message').textContent=s==='checking'?'正在連線到更新來源':newer?'軟體版本 '+latest.software_version+(Number.isFinite(latest.size)?' · '+(latest.size/1048576).toFixed(1)+' MB':'')+' · 驗證簽章後安裝': + s==='current'?'已安裝 '+(status?.version||'')+' · '+catalog.at.toLocaleTimeString('zh-TW',{hour12:false})+' 檢查':s==='error'?catalog.error+'。仍可使用手動更新。':'開啟頁面時會自動檢查一次'; + const chip=$('update-chip');chip.dataset.tone=newer?'new':tone==='busy'?'':tone;chip.textContent=s==='checking'?'檢查中…':newer?'有新版本':s==='current'?'已是最新':s==='error'?'無法檢查':'尚未檢查'; + $('update-banner').hidden=!newer||installing||current==='update';$('update-dot').hidden=!newer;$('online').hidden=!newer||!auth.authenticated; + if(newer){$('banner-title').textContent='有新版本 '+latest.version+' 可以安裝';$('banner-text').textContent=auth.authenticated?'前往更新頁面安裝,更新時請保持供電':'登入管理員後即可更新';$('online-label').textContent='下載並更新至 '+latest.version;}} + async function checkUpdates(manual=false){if(!status||catalog.state==='checking')return;catalog={state:'checking',error:''};drawUpdate(); + try{const r=await fetch(CK_CATALOG_URL,{cache:'no-store'});if(!r.ok)throw Error('更新來源尚未發布');const c=await r.json();if(c.format!=='commandkit-catalog-v1'||!Array.isArray(c.updates))throw Error('更新索引格式不符'); + latest=newestUpdate(c.updates,status.software_version);catalog={state:latest?'available':'current',error:'',at:new Date()}; + if(manual)toast(latest?'有新版本 '+latest.version:'目前已是最新版本',latest?'info':'success'); + }catch(e){latest=null;catalog={state:'error',error:e instanceof TypeError?'無法連線到更新來源':e.message};if(manual)toast('無法檢查線上版本:'+catalog.error,'error');} + drawUpdate();} + $('check').onclick=()=>run(()=>checkUpdates(true),$('check')); + function steps(active,online){const order=['download','upload','apply','reboot','done'],at=order.indexOf(active); + document.querySelectorAll('#install-steps li').forEach((li,i)=>{li.dataset.state=!online&&!i?'skip':active==='done'||isize)throw Error('下載不完整');out.set(value,at);at+=value.length; + const p=Math.floor(at*100/size);if(p!==shown){shown=p;progress(p);}} + return at===size?out:out.subarray(0,at);} + $('online').onclick=()=>run(()=>installJob(async()=>{const item=latest;if(!item?.url.startsWith('https://')||!Number.isSafeInteger(item.size)||item.size<256||item.size>16777216)throw Error('更新來源不符'); + $('install-note').textContent='正在下載 '+item.version+'…';const r=await fetch(item.url,{cache:'no-store'});if(!r.ok)throw Error('下載失敗'); + const b=await readAll(r,item.size,p=>{$('install-note').textContent='正在下載 '+item.version+' · '+p+'%';}); + if(b.length!==item.size||hex(sha256(b))!==item.sha256)throw Error('下載不完整');return [b,item.software_version];},true),$('online')); + function drawPackage(){const f=$('package').files[0];$('package-name').textContent=f?f.name:'點選或拖放 .ota 更新包'; + $('package-info').textContent=f?(f.size/1048576).toFixed(2)+' MB · 按「驗證並更新」開始':'只接受本專案簽章的 matter.ota';} + $('package').onchange=drawPackage;const drop=$('drop'); + ['dragenter','dragover'].forEach(n=>drop.addEventListener(n,e=>{e.preventDefault();drop.classList.add('over');})); + ['dragleave','drop'].forEach(n=>drop.addEventListener(n,e=>{e.preventDefault();drop.classList.remove('over');})); + drop.addEventListener('drop',e=>{if(e.dataTransfer?.files?.length){$('package').files=e.dataTransfer.files;drawPackage();}}); + $('manual').onclick=()=>{const file=$('package').files[0];if(!file){toast('請先選擇 .ota 更新包','error');return;} + run(()=>installJob(async()=>{const b=new Uint8Array(await file.arrayBuffer());if(b.length<256)throw Error('更新檔案不完整');return [b,new DataView(b.buffer).getUint32(20)];},false),$('manual'));}; + async function monitor(){if(!document.hidden&&!busy){const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),4000);try{ + const r=await fetch('/measurements',{cache:'no-store',signal:controller.signal});if(!r.ok)throw Error();drawMeter(await r.json()); + }catch{drawMeter(null,true);}finally{clearTimeout(timer);}} + setTimeout(monitor,2000);} + async function statePoll(){if(!document.hidden&&!busy){try{await refresh();await authRefresh();await settingsRefresh();if(catalog.state==='idle')checkUpdates();}catch{}}setTimeout(statePoll,10000);} document.addEventListener('visibilitychange',()=>{if(document.hidden)drawMeter(null);}); - monitor(); - async function refresh(){const r=await fetch('/status');if(!r.ok)throw Error('裝置尚未準備好');status=await r.json();$('current').textContent=status.version; - if(status.rescue)message('裝置目前在維修模式,可安裝較新的修正版。');} - async function post(path,bytes,imageHash=null){const digest=imageHash||hex(sha256(bytes)); - const response=await fetch(path,{method:'POST',headers:{'Content-Type':'application/octet-stream','X-CK-SHA256':digest},body:bytes}); - if(!response.ok){const problem=await response.json();throw Error(problem.error==='busy_or_low_memory'?'插座正在更新或記憶體不足,請稍候再試':'更新遭拒絕:請確認機型、版本與簽章,或下載完整檔案後重試');} - return response.json();} - async function install(bytes,expected){if(busy)return;busy=true;document.querySelectorAll('button').forEach(b=>b.disabled=true); - drawMeter(null);$('meter-state').textContent='更新期間暫停量測'; - try{if(bytes.length!==status.max_size)throw Error('請選擇 .ota 更新包;8 MiB 燒錄映像不適用'); - message('正在驗證並上傳,請保持此頁開啟…');await post('/upload',bytes); - message('簽章驗證完成,正在安裝。請保持插座供電…');await post('/apply',new Uint8Array(),hex(sha256(bytes))); - const until=Date.now()+240000;let found=false; - while(Date.now()setTimeout(r,4000));try{await refresh();if(status.software_version===expected){if(status.rescue||status.healthy){found=true;message(status.rescue?'新版已啟動,但進入維修模式,請安裝修正版。':'更新完成,Matter 與裝置控制已就緒。原有設定與配對已保留。');break;}message('新版已啟動,正在驗證網路與裝置控制…');}}catch{}} - if(!found)message('尚未收到新版回應。請稍候,並確認插座的網路位址;勿重複上傳。'); - }catch(e){message(e.message);}finally{busy=false;document.querySelectorAll('button').forEach(b=>b.disabled=false);}} - $('manual').onclick=async()=>{try{const f=$('package').files[0];if(!f)throw Error('請選擇 .ota 更新包');const b=new Uint8Array(await f.arrayBuffer());if(b.length<256)throw Error('檔案不完整');await install(b,new DataView(b.buffer).getUint32(20));}catch(e){message(e.message);}}; - $('check').onclick=async()=>{try{message('正在檢查新版本…');const response=await fetch(CK_CATALOG_URL,{cache:'no-store'});if(!response.ok)throw Error('更新來源尚未發布');const catalog=await response.json(); - if(catalog.format!=='commandkit-catalog-v1'||!Array.isArray(catalog.updates))throw Error('更新索引格式不符'); - const name=status.variant===1?'matter':'exec-test';latest=catalog.updates.filter(x=>x.model==='CMNDKT-004'&&x.variant===name&&Number.isSafeInteger(x.software_version)&&x.software_version>status.software_version).sort((a,b)=>b.software_version-a.software_version)[0]; - $('online').hidden=!latest;message(latest?'有新版本 '+latest.version+',可按「下載並更新」。':'目前已是最新版本。'); - }catch(e){message('無法檢查線上版本:'+e.message+'。仍可使用下方手動更新。');}}; - $('online').onclick=async()=>{try{if(!latest||!latest.url.startsWith('https://'))throw Error('更新來源不符');message('正在下載 '+latest.version+'…');const r=await fetch(latest.url,{cache:'no-store'});if(!r.ok)throw Error('下載失敗'); - const b=new Uint8Array(await r.arrayBuffer());if(b.length!==latest.size||hex(sha256(b))!==latest.sha256)throw Error('下載不完整');await install(b,latest.software_version); - }catch(e){message(e.message+'。可改用手動更新,或電腦上的 update.py。');}}; - try{await refresh();}catch(e){message(e.message);} + try{await refresh();await authRefresh();}catch{const pill=$('live-state');pill.dataset.state='offline';pill.lastElementChild.textContent='連線中斷';} + monitor();statePoll();checkUpdates();setInterval(()=>{if(!busy&&!installing)checkUpdates();},6*3600000); })(); diff --git a/firmware/matter/src/update/update_page.h b/firmware/matter/src/update/update_page.h index f18dd95..8e005fe 100644 --- a/firmware/matter/src/update/update_page.h +++ b/firmware/matter/src/update/update_page.h @@ -1,2 +1,2 @@ /* Generated by tools/generate_update.py. */ -static const char ck_update_page[] = "\n\nCommandKit 插座用電與更新\n\n
COMMANDKIT / CMNDKT-004

插座用電

韌體版本
讀取中…
\n
\n
\n
正在連線…插座狀態讀取中
\n
\n
電壓
—V

交流有效值

\n
電流
—A

交流有效值

\n
即時功率
—W

目前消耗的功率

\n
累積用電
—kWh

功率估算 · 約 30 秒保存

\n
\n
等待量測資料每 2 秒自動更新
\n
\n

韌體更新

讓插座保持更新,原有設定與配對會保留。

\n
使用已下載的更新包

更新會暫停控制並重新啟動插座,請保持供電。

\n

可檢查新版本,或使用已下載的更新包。

\n
\n
讀值無效或連線中斷時顯示「—」。累積用電從啟用電量紀錄起計算,不含先前的使用量。
\n\n"; +static const char ck_update_page[] = "\n\nCommandKit 插座管理\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
CommandKitCMNDKT-004 智慧插座 · 本機管理
\n
正在連線\n\n
\n
\n\n
\n\n\n\n
\n

狀態總覽

即時電力與裝置狀態

\n
\n
正在讀取量測…插座狀態未知
\n
\n
即時功率—W
02 kW
\n
近 2 分鐘功率走勢
\n收集讀值中…\n
平均—
最高—
最低—
\n
電壓

—V

交流有效值
\n
電流

—A

交流有效值
\n
累積用電

—kWh

功率估算 · 約 30 秒保存一次
\n

等待量測資料每 2 秒更新

\n

裝置資訊

\n
韌體版本—
目前協議—
網路位址—
線上更新尚未檢查
\n
訪客可以查看狀態。登入後可調整燈號、配對、更新韌體與重設設定。
\n
\n\n\n\n\n\n\n\n\n
CMNDKT-004 · 實體長按按鈕直到重新啟動,也可以恢復出廠設定。
\n
\n

管理員登入

輸入初始設定時建立的管理密碼。

\n
\n

\n

確認操作

\n\n\n"; diff --git a/firmware/matter/tests/relay_engine_tests.cpp b/firmware/matter/tests/relay_engine_tests.cpp index 7373ad8..eb89c16 100644 --- a/firmware/matter/tests/relay_engine_tests.cpp +++ b/firmware/matter/tests/relay_engine_tests.cpp @@ -58,7 +58,7 @@ int main() { power(e,6,65000); power(e,6,70000); check(e.LoadBlink(70003),"load reentry"); state(e,true,84000); check(!e.LoadBlink(85002),"stale metering stops blink"); uint8_t reset[]={0xaa,0xff,0,0x55,0xaa,0xff,0xff,0x55};for(auto b:reset)e.Feed(b,86000); - check(e.Snapshot().resetEvents==2,"reset events recorded without erasing"); + check(e.Snapshot().resetEvents==1,"only confirmed long press dispatches factory reset"); check(e.Request(203,false,87000),"disconnect command");e.Disconnect(); check(e.Snapshot().completedId==203&&e.Snapshot().result==RelayResult::TransportError&&!e.Snapshot().known,"transport failure never succeeds"); RelayEngine late; check(late.Request(1,false,0),"late fixture");late.NextTx(0,tx);late.NextTx(100,tx);state(late,false,3000);late.NextTx(3000,tx); diff --git a/firmware/matter/tests/test_device_portal.py b/firmware/matter/tests/test_device_portal.py index 5a5cb87..e38abbb 100644 --- a/firmware/matter/tests/test_device_portal.py +++ b/firmware/matter/tests/test_device_portal.py @@ -9,6 +9,7 @@ def call(port,method,path,body=None,headers=None): def main(): parser=argparse.ArgumentParser();parser.add_argument('--output-tag',choices=['softap-device-code'],default='softap-device-code');options=parser.parse_args();ART=OUT/'artifacts'/options.output_tag;EV=OUT/'evidence'/options.output_tag name='incipio-softap-http-test';checks=[] + ram=OUT/'private/device-test-A';(ram/'ck-admin-state').write_text('0');(ram/'ck-admin-enroll-ok').unlink(missing_ok=True) p=subprocess.Popen(['docker','run','--rm','--name',name,'-p','127.0.0.1:18082:18082', '-p','127.0.0.1:18083:18083/udp','-p','127.0.0.1:18084:18084','-v',f'{OUT}:/work:ro', '-v',f'{OUT}/private/device-test-A:/var/run', @@ -32,10 +33,29 @@ def main(): assert s==302 and hs['Location']=='http://127.0.0.1:18082/';checks.append('captive HTTP probe redirect') assert call(18084,'POST','/enter-setup')[0]==403;checks.append('unauthenticated setup rejected') key=bytes.fromhex((OUT/'private/diag.key').read_text().strip()) + download_headers={'Content-Type':'application/x-www-form-urlencoded'} + assert b'id="admin-password"' in page and b'download-management-key' not in page + assert call(18082,'POST','/management-key','token='+token,download_headers)[0]==403 + assert key.hex().encode() not in page + state=json.loads(call(18082,'GET','/admin/status')[1]);assert state['ready'] and not state['configured'] + valid_wifi=urllib.parse.urlencode({'ssid':'Lab Network','password':'ExamplePass42','security':'wpa2','token':token}) + assert call(18082,'POST','/wifi',valid_wifi,download_headers)[0]==403 + credential=(b'CKAUTH1\n'+struct.pack('>I',600000)+bytes(range(16))+bytes(range(32))).hex() + assert call(18082,'POST','/admin/enroll','token='+'0'*64+'&credential='+credential,download_headers)[0]==403 + # This qemu-user portal has no journal/backend helper. Actual enrollment + # commits are covered by test_updates; the portal must not claim success. + assert call(18082,'POST','/admin/enroll','token='+token+'&credential='+credential,download_headers)[0]==409 + assert not (ram/'ck-admin-enroll').exists() + (ram/'ck-admin-state').write_text('1');(ram/'ck-admin-enroll-ok').write_text('1') + checks.append('password inputs replace key export; valid setup token required; enrollment failure never permits Wi-Fi; journal success is a separate fixture') def proof(scope): _,nonce,_=call(18084,'GET','/challenge');return nonce.decode()+':'+hmac.new(key,scope.encode()+nonce,hashlib.sha256).hexdigest() header={'X-Setup-Proof':proof('GET /state\n')};s,b,_=call(18084,'GET','/state',headers=header) assert s==200 and b'state=setup' in b;assert call(18084,'GET','/state',headers=header)[0]==403;checks.append('HMAC state and replay rejection') + for action in ['reset-matter','open-commissioning','enter-setup','ram-probe']: + assert call(18084,'POST','/'+action,headers={'X-Setup-Proof':proof('POST /'+action+'\n')})[0]==403 + assert not (ram/'ck-matter-request').exists() + checks.append('legacy diagnostic HMAC cannot bypass password login for mutations') qname=b''.join(bytes([len(x)])+x.encode() for x in 'captive.apple.com'.split('.'))+b'\0' sock=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);sock.settimeout(3) for ty in [1,28]: @@ -94,7 +114,7 @@ def scan(h=None,body=scan_body): if options.output_tag in ('softap-device-code',): assert manual in confirmation and b"src='/matter-qr'" in confirmation checks.append('Wi-Fi submission confirmation retains same manual code and QR') - assert post(v)==409;time.sleep(4) + assert post(v)==403;time.sleep(4) s,b,_=call(18084,'GET','/state',headers={'X-Setup-Proof':proof('GET /state\n')}) assert s==200 and b'state=online' in b;checks.append('duplicate guard and fixture transition') r={'status':'pass','checks':checks,'binary_sha256':hashlib.sha256((ART/'ck-setupd').read_bytes()).hexdigest(), diff --git a/firmware/matter/tools/build_matter_device_fixture.py b/firmware/matter/tools/build_matter_device_fixture.py index 717b919..8de0204 100644 --- a/firmware/matter/tools/build_matter_device_fixture.py +++ b/firmware/matter/tools/build_matter_device_fixture.py @@ -23,6 +23,9 @@ def entry(name,data=b'',mode=stat.S_IFREG|0o755,rdev=(0,0)): entry('dev/ttyS1',mode=stat.S_IFCHR|0o600,rdev=(4,65));entry('tmp/gpio',b' '*32,mode=stat.S_IFREG|0o600) entry('etc/matter-device.key',(OUT/'private/device-identity.key').read_bytes(),mode=stat.S_IFREG|0o600) if persistent: + salt=bytes(range(16));key=hashlib.pbkdf2_hmac('sha256',b'FixtureAdmin42',salt,600000,32) + entry('test-admin.bin',b'CKAUTH1\n'+struct.pack('>I',600000)+salt+key,mode=stat.S_IFREG|0o600) + entry('bin/ck-setupd',(OUT/'artifacts/softap-device-code/ck-setupd').read_bytes()) entry('bin/ck-updated',(OUT/'artifacts/update-test/ck-updated').read_bytes()) entry('dev/ttyS0',mode=stat.S_IFCHR|0o600,rdev=(4,64)) entry('etc/ck-update.pub',(Path('/repo/third_party/ota-signing.pub')).read_bytes(),mode=stat.S_IFREG|0o444) diff --git a/firmware/matter/tools/build_updates.sh b/firmware/matter/tools/build_updates.sh index d93724c..b0dc2bc 100644 --- a/firmware/matter/tools/build_updates.sh +++ b/firmware/matter/tools/build_updates.sh @@ -8,15 +8,17 @@ if [ "${CK_TESTING:-0}" = 1 ]; then dest=artifacts/update-test; fi mkdir -p "$dest" flags='-static -Os -std=c11 -Wall -Wextra -Werror -Wno-misleading-indentation -Wno-unused-function -mno-llsc -mno-load-store-pairs -mmemcpy -fno-builtin-memcpy -fno-builtin-memmove -ffunction-sections -fdata-sections -Wl,--gc-sections' if [ "${CK_TESTING:-0}" = 1 ]; then flags="$flags -DCK_TESTING"; fi -common='src/update/ck_common.c src/update/ck_flash.c src/update/ck_store.c src/update/ck_ota.c src/vendor/monocypher/monocypher.c src/vendor/monocypher/monocypher-ed25519.c' +common='src/update/ck_common.c src/update/ck_flash.c src/update/ck_store.c src/update/ck_energy.c src/update/ck_led.c src/update/ck_ota.c src/vendor/monocypher/monocypher.c src/vendor/monocypher/monocypher-ed25519.c' ${cross}gcc $flags $common src/update/ck_updater.c -o "$dest/ck-updater" ${cross}gcc $flags $common src/update/ck_meter.c src/update/ck_updated.c -o "$dest/ck-updated" +${cross}gcc $flags src/setup/boot_guard.c -o "$dest/ck-boot-guard" if [ "${CK_TESTING:-0}" = 1 ]; then ${cross}gcc $flags -DCK_TEST_PID1 $common src/update/ck_updater.c -o "$dest/ck-updater-vm" ${cross}strip "$dest/ck-updater-vm" fi ${cross}strip "$dest/ck-updater" "$dest/ck-updated" -${cross}objdump -d "$dest/ck-updater" "$dest/ck-updated" > "$dest/disassembly.txt" +${cross}strip "$dest/ck-boot-guard" +${cross}objdump -d "$dest/ck-updater" "$dest/ck-updated" "$dest/ck-boot-guard" > "$dest/disassembly.txt" CK_BUILD_DEST="$dest" python3 - <<'PY' import re,os from pathlib import Path diff --git a/reverse/tools/audit_wifi_startup.py b/reverse/tools/audit_wifi_startup.py new file mode 100644 index 0000000..b3775fd --- /dev/null +++ b/reverse/tools/audit_wifi_startup.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Replay extracted startup scripts with injected faults; no device/network I/O. + +Run inside a disposable Linux container with --network none. The actual image's +BusyBox ash is emulated; updater, identity helper and network commands are mocks. +Only temporary regular files are modified. A DHCP marker means its launch was +reached, not that a real DHCP transaction succeeded. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import resource +import shlex +import signal +import subprocess +import tempfile +import time + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def wait_for(predicate, timeout=3): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(.01) + return bool(predicate()) + + +def run_case(tree, runner, scenario, public=True): + with tempfile.TemporaryDirectory(prefix='ck-wifi-audit-') as name: + root = Path(name) + for folder in ('etc', 'bin', 'var/log', 'var/run', 'var/lib/ck-state'): + (root / folder).mkdir(parents=True, exist_ok=True) + events = root / 'events' + processes = [] + logs = [] + + def executable(name, body): + path = root / 'bin' / name + path.write_text('#!/bin/sh\n' + body + '\n') + path.chmod(0o700) + return path + + def record(event): + return 'echo ' + shlex.quote(event) + ' >> ' + shlex.quote(str(events)) + '\n' + + def rewrite(path, output): + # Preserve shell logic; redirect absolute device paths into fixtures. + text = path.read_text() + text = re.sub(r'/(?:bin|var|etc)/', lambda m: str(root) + m[0], text) + output.write_text(text) + return output + + def launch(script, *args): + log = (root / ('process-%d.log' % len(processes))).open('wb') + logs.append(log) + p = subprocess.Popen(runner + [str(script), *args], stdout=log, + stderr=log, start_new_session=True, + env={**os.environ, 'PATH': str(root / 'bin') + ':/usr/bin:/bin'}) + processes.append(p) + return p + + if public: + (root / 'etc/ck-identity-policy').write_text('auto-v1\n') + else: + (root / 'etc/matter-device.key').write_bytes(b'D' * 32) + (root / 'etc/matter-diag.key').write_bytes(b'12' * 32 + b'\n') + (root / 'etc/ck-update.pub').write_bytes(b'P' * 32) + executable('sleep', 'exec /bin/sleep .02') + executable('ck-updated', 'exec /bin/sleep 30') + executable('killall', 'exit 0') + executable('ifconfig', record('ifconfig-called') + 'exit 0') + executable('udhcpc', record('dhcp-launch-reached') + 'exec /bin/sleep 30') + updater = record('updater-$1').replace("'updater-$1'", '"updater-$1"') + updater += 'if [ "$1" != --import ]; then exit 0; fi\n' + updater += 'echo "$PPID" > ' + shlex.quote(str(root / 'initializer.pid')) + '\n' + if scenario in ('import_hang', 'initializer_killed'): + updater += 'exec /bin/sleep 30\n' + elif scenario == 'import_crash': + updater += 'kill -SEGV $$\n' + elif scenario == 'import_error': + updater += 'exit 1\n' + else: + updater += 'printf %s DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD > ' + shlex.quote(str(root / 'var/lib/ck-state/device.key')) + '\n' + executable('ck-updater', updater) + identity = record('identity-helper-called') + if scenario == 'identity_crash': + identity += 'kill -SEGV $$\n' + else: + identity += 'test -f ' + shlex.quote(str(root / 'var/lib/ck-state/device.key')) + ' || exit 5\n' + identity += 'printf 12345678901 > ' + shlex.quote(str(root / 'var/run/matter-manual-code')) + '\n' + executable('matterd-test', identity) + executable('ck-wificfg', 'exit 9' if scenario == 'mib_error' else 'echo configured=1') + backend = rewrite(tree / 'bin/ck-setup-backend', root / 'backend.sh') + controller = record('setup-controller-called') + if scenario == 'controller_reject': + controller += 'exit 11\n' + else: + controller += '/bin/sh ' + shlex.quote(str(backend)) + ' resume\nexec /bin/sleep 30\n' + executable('ck-setupd', controller) + ota_script = rewrite(tree / 'bin/ck-ota-start', root / 'ota-start.sh') + setup_script = rewrite(tree / 'bin/ck-setup-start', root / 'setup-start.sh') + pending = root / 'var/run/ck-identity-pending' + + def reached(event): + return events.exists() and event in events.read_text().splitlines() + + try: + ota = launch(ota_script) + assert wait_for(lambda: (root / 'initializer.pid').exists()), 'import not started' + if scenario == 'initializer_killed': + initializer = int((root / 'initializer.pid').read_text()) + # In the public branch the initializer is a background ash. + assert initializer != ota.pid, 'expected independent background initializer' + os.kill(initializer, signal.SIGKILL) + setup = launch(setup_script) + expected_dhcp = scenario == 'success' or (not public and scenario == 'import_error') + blocked = scenario in ('import_hang', 'initializer_killed') + if expected_dhcp: + assert wait_for(lambda: reached('dhcp-launch-reached')), 'DHCP launch not reached' + elif blocked: + # Accelerated sleep allows many poll iterations, with no cleanup. + time.sleep(.4) + assert pending.exists() and setup.poll() is None, 'pending gate did not persist' + assert not reached('identity-helper-called'), 'identity unexpectedly started' + else: + assert wait_for(lambda: setup.poll() is not None), 'setup wrapper did not exit' + assert setup.returncode != 0, 'fault did not propagate' + assert reached('dhcp-launch-reached') == expected_dhcp + return { + 'scenario': scenario, 'public_image': public, + 'expected_observation_confirmed': True, + 'identity_pending': pending.exists(), + 'rescue_marker': (root / 'var/run/ck-rescue').exists(), + 'identity_helper_reached': reached('identity-helper-called'), + 'controller_reached': reached('setup-controller-called'), + 'dhcp_launch_reached': reached('dhcp-launch-reached'), + 'setup_exit_status': setup.poll(), + } + finally: + for p in processes: + try: + os.killpg(p.pid, signal.SIGKILL) + except ProcessLookupError: + pass + for p in processes: + p.wait(timeout=3) + for log in logs: + log.close() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--tree', required=True, type=Path) + parser.add_argument('--old-tree', type=Path) + parser.add_argument('--output', required=True, type=Path) + args = parser.parse_args() + resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) + runner = ['qemu-mips', '-L', str(args.tree), str(args.tree / 'bin/busybox'), 'sh'] + cases = [run_case(args.tree, runner, name) for name in ( + 'success', 'import_error', 'import_crash', 'import_hang', + 'initializer_killed', 'identity_crash', 'controller_reject', 'mib_error')] + if args.old_tree: + cases.append(run_case(args.old_tree, runner, 'import_error', public=False)) + report = { + 'status': 'pass', 'environment': 'actual v0.2.6 BusyBox ash under qemu-user in offline Linux container', + 'actual_scripts_with_fixture_path_rewrites': True, + 'mocks': ['updater', 'identity helper', 'Wi-Fi MIB helper', 'setup controller', 'network commands'], + 'no_physical_device_or_network_access': True, + 'limitation': 'Injected faults establish startup consequences, not which fault occurred on hardware. DHCP launch marker is not a DHCP exchange.', + 'sha256': {path: digest(args.tree / path) for path in ( + 'bin/busybox', 'bin/ck-ota-start', 'bin/ck-setup-start', 'bin/ck-setup-backend')}, + 'cases': cases, + } + args.output.write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps(report, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/reverse/tools/verify_boa_factory.py b/reverse/tools/verify_boa_factory.py new file mode 100644 index 0000000..739e44e --- /dev/null +++ b/reverse/tools/verify_boa_factory.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""Execute original Boa MIPS validation/planning in Unicorn, without an OS. + +Requires unicorn==2.1.4. Every external operation is a stub; MTD writes are +represented only by a plan, then applied to an in-memory bytearray. +""" +import argparse +import hashlib +import json +import lzma +import struct +import sys +from pathlib import Path +from unicorn import Uc, UC_ARCH_MIPS, UC_MODE_MIPS32, UC_MODE_BIG_ENDIAN, UC_HOOK_CODE +from unicorn.mips_const import (UC_MIPS_REG_A0, UC_MIPS_REG_A1, UC_MIPS_REG_A2, + UC_MIPS_REG_A3, UC_MIPS_REG_V0, UC_MIPS_REG_RA, UC_MIPS_REG_SP, UC_MIPS_REG_PC, + UC_MIPS_REG_T9) +from analyze_elf import dynsym + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT/'tools')) +from factory_image import inspect, CANONICAL_MARK, journal_initializer +from fwlib import sha + + +def boot_checks(loader, flash): + """Run the vendor selector/checksums with only SPI copy/console/poll stubbed.""" + u = Uc(UC_ARCH_MIPS, UC_MODE_MIPS32 | UC_MODE_BIG_ENDIAN) + u.mem_map(0,0x1000000) + u.mem_map(0x1d000000,0x800000) + u.mem_write(0x400000,loader) + u.mem_write(0x1d000000,bytes(flash)) + def stub(name): + def hook(u,address,size,data): + if name == 'spi_copy': + dest,offset,n=[u.reg_read(r) for r in (UC_MIPS_REG_A0,UC_MIPS_REG_A1,UC_MIPS_REG_A2)] + assert offset+n <= len(flash) + u.mem_write(dest & 0x1fffffff,bytes(flash[offset:offset+n])) + u.reg_write(UC_MIPS_REG_V0,0) + u.reg_write(UC_MIPS_REG_PC,u.reg_read(UC_MIPS_REG_RA)) + return hook + for address,name in [(0x80404a94,'spi_copy'),(0x804079cc,'console'), + (0x804077a4,'console'),(0x80407de0,'poll')]: + u.hook_add(UC_HOOK_CODE,stub(name),begin=address,end=address) + u.hook_add(UC_HOOK_CODE,lambda u,a,n,d:u.emu_stop(),begin=0x80900000,end=0x80900000) + u.reg_write(UC_MIPS_REG_SP,0x808ff000) + u.reg_write(UC_MIPS_REG_RA,0x80900000) + u.reg_write(UC_MIPS_REG_A0,1) + u.emu_start(0x804086b0,0x80900004,timeout=30_000_000,count=150_000_000) + assert u.reg_read(UC_MIPS_REG_V0) != 0 + return struct.unpack('>I',u.mem_read(0x40fdb0,4))[0] + + +class Boa: + def __init__(self, binary, flash, image, dual=True): + self.u = Uc(UC_ARCH_MIPS, UC_MODE_MIPS32 | UC_MODE_BIG_ENDIAN) + self.flash, self.image, self.dual = flash, image, dual + self.messages, self.lines, self.fds = [], [], {} + self.redirect, self.exited, self.next_fd = None, False, 20 + self.alloc_next = 0x1800000 + symbols, deps, ph, tags = dynsym(binary) + self.u.mem_map(0x400000, 0x400000) + for t, o, v, _, fs, ms, _, _ in ph: + if t == 1: + self.u.mem_write(v, binary[o:o+fs]) + self.u.mem_map(0x1000000, 0x1000000) + self.u.mem_map(0x3000000, 0x1000) + self.u.mem_map(0x70000000, 0x100000) + # Dynamic imports in the vendor image are pointers to its MIPS stubs. + imports = json.loads(args.imports.read_text())['imports'] + for row in imports: + if 'plt' in row and row.get('type') == 'FUNC': + self.hook(row['plt'], row['name']) + for address, name in [(0x4132b4, 'req_get_cstream_var'), + (0x412160, 'req_format_write'), (0x40e6f0, 'send_redirect_perm'), + (0x441988, 'page_header'), (0x41e0ec, 'write_line_to_file'), + (0x41f66c, 'kill_processes'), (0x40b4d0, 'clear_fwupload_shm')]: + self.hook(address, name) + self.u.hook_add(UC_HOOK_CODE, lambda u, a, n, d: u.emu_stop(), begin=0x3000000, end=0x3000000) + self.request, self.upload = 0x1000000, 0x1100000 + prefix = b'Content-Disposition: form-data; name="binary"; filename="first-install.bin"\r\n' + prefix += b'X-Padding: ' + b'a' * (256-len(prefix)-11-4-42) + prefix += b'\r\nContent-Type: application/octet-stream\r\n\r\n' + # Keep the uploaded kernel aligned for the original MIPS FirmwareUpgrade. + if len(prefix) % 4: + prefix = b'X:' + b'a' * ((-len(prefix)-4) % 4) + b'\r\n' + prefix + assert len(prefix) % 4 == 0 + self.body = prefix + image + b'\r\n--end--\r\n' + self.u.mem_write(self.upload, self.body) + self.put(self.request+0x98, self.upload) + self.put(self.request+0x9c, len(self.body)) + self.offset = len(prefix) + + def hook(self, address, name): + self.u.hook_add(UC_HOOK_CODE, lambda u, a, n, d: self.external(name), begin=address, end=address) + + def read(self, p, n): return bytes(self.u.mem_read(p, n)) + def string(self, p): + if not p: return '' + b = bytearray() + while len(b) < 20000: + c = self.read(p+len(b), 1) + if c == b'\0': return b.decode('latin1') + b += c + raise ValueError('unterminated string') + def put(self, p, n): self.u.mem_write(p, struct.pack('>I', n & 0xffffffff)) + def alloc(self, data): + p = self.alloc_next + self.u.mem_write(p, data+b'\0'); self.alloc_next += (len(data)+16) & ~7 + return p + def external(self, name): + u = self.u + a, b, c, d = [u.reg_read(r) for r in [UC_MIPS_REG_A0, UC_MIPS_REG_A1, UC_MIPS_REG_A2, UC_MIPS_REG_A3]] + result = 0 + if name == 'memcmp': result = (self.read(a,c) > self.read(b,c)) - (self.read(a,c) < self.read(b,c)) + elif name == 'strcmp': result = (self.string(a) > self.string(b)) - (self.string(a) < self.string(b)) + elif name in ('strcpy', 'memcpy', 'memmove'): + data = self.string(b).encode('latin1')+b'\0' if name == 'strcpy' else self.read(b,c) + u.mem_write(a,data); result = a + elif name == 'memset': u.mem_write(a, bytes([b & 255])*c); result = a + elif name == 'strlen': result = len(self.string(a)) + elif name == 'strstr': + pos = self.string(a).find(self.string(b)); result = a+pos if pos >= 0 else 0 + elif name == 'strchr': + pos = self.string(a).find(chr(b)); result = a+pos if pos >= 0 else 0 + elif name == 'req_get_cstream_var': result = self.alloc(b'/upload.htm') + elif name == 'req_format_write': self.messages.append(self.string(b)); result = 1 + elif name == 'send_redirect_perm': self.redirect = self.string(b) + elif name == 'sprintf': + fmt = self.string(b) + # The planner formats decimal offsets, lengths and shm IDs only. + if fmt == '%d': text = str(c if c < 2**31 else c-2**32) + elif fmt == '%d\n': text = str(c)+'\n' + else: text = fmt.replace('%d',str(c),1).replace('%x',format(c,'x'),1) + u.mem_write(a,text.encode()+b'\0'); result = len(text) + elif name in ('printf','puts','fprintf'): result = 1 + elif name == 'apmib_get': + assert a == 0x514 + self.put(b, int(self.dual)); result = 1 + elif name == 'fopen': + assert self.string(a) == '/proc/bootbank' + result = 9 + elif name == 'fgets': u.mem_write(a,b'2\0'); result = a + elif name in ('fclose','page_header','clear_fwupload_shm','kill_processes','system','sleep','usleep','sync'): pass + elif name == 'open': + path = self.string(a) + assert path in ('/dev/mtdblock0','/dev/mtdblock1','/dev/mtdblock3','/dev/mtdblock4'), path + result = self.next_fd; self.next_fd += 1; self.fds[result] = [path,0] + elif name == 'lseek': self.fds[a][1] = b; result = b + elif name == 'read': + path, offset = self.fds[a] + base = {'/dev/mtdblock0':0,'/dev/mtdblock1':0x130000,'/dev/mtdblock3':0x400000,'/dev/mtdblock4':0x530000}[path] + u.mem_write(b,self.flash[base+offset:base+offset+c]); self.fds[a][1] += c; result = c + elif name == 'close': pass + elif name == 'write_line_to_file': + path, text = self.string(a), self.string(c) + if path == '/var/fwd.conf': + if b == 1: self.lines = [] + self.lines.append(text) + else: assert path == '/var/fwd.ready', path + result = 1 + elif name == 'exit': self.exited = True; u.emu_stop(); return + else: raise RuntimeError('unstubbed external: '+name) + u.reg_write(UC_MIPS_REG_V0, result & 0xffffffff) + u.reg_write(UC_MIPS_REG_PC, u.reg_read(UC_MIPS_REG_RA)) + + def call(self, address, values): + self.u.reg_write(UC_MIPS_REG_SP, 0x700ff000) + self.u.reg_write(UC_MIPS_REG_RA, 0x3000000) + self.u.reg_write(UC_MIPS_REG_T9, address) + for r,v in zip([UC_MIPS_REG_A0,UC_MIPS_REG_A1,UC_MIPS_REG_A2,UC_MIPS_REG_A3],values): self.u.reg_write(r,v) + self.u.emu_start(address, 0x3000004, timeout=30_000_000, count=100_000_000) + + def validate(self): + self.call(0x442f14,[self.request]) + return self.redirect == '/countDownPage.htm' + + def plan(self): + self.call(0x4438f8,[self.upload,len(self.body),0,0x1700000]) + assert self.exited and len(self.lines) == 8, self.lines + plans = [] + for i in range(0,len(self.lines),4): + path, seek, length, offset = self.lines[i:i+4] + seek,length,offset = map(int,(seek,length,offset)) + plans.append({'device':path,'seek':seek,'length':length,'upload_offset':offset}) + return plans + + +def main(): + global args + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--boa',type=Path,required=True) + p.add_argument('--imports',type=Path,required=True) + p.add_argument('--reference',type=Path,required=True) + p.add_argument('--image',type=Path,required=True) + p.add_argument('--output',type=Path,required=True) + p.add_argument('--bootloader',type=Path) + args = p.parse_args() + binary, flash, image = args.boa.read_bytes(), args.reference.read_bytes(), args.image.read_bytes() + assert sha(binary) in ('2e4b024ca170468c58ace898807f0364c8b23adc6ef234ebc64f9589a464a65a', + '175c66efa68eabda38c8ca6990e824ddcc41dfec0219bc739bce7b8ffe0a587a') + inspect(image) + boa = Boa(binary,flash,image) + assert boa.validate(), boa.messages + plans = boa.plan() + assert [(p['device'],p['seek'],p['length']) for p in plans] == [ + ('/dev/mtdblock0',0x10000,0x120000),('/dev/mtdblock1',0,0x2b0000)], plans + result = bytearray(flash) + for item in plans: + base = 0 if item['device'] == '/dev/mtdblock0' else 0x130000 + n = item['length']; offset = item['upload_offset'] + result[base+item['seek']:base+item['seek']+n] = boa.read(boa.upload+offset,n) + assert result[:0x10000] == flash[:0x10000] and result[0x3e0000:] == flash[0x3e0000:] + assert result[0x3c0000:0x3e0000] in (journal_initializer(False),journal_initializer(True)) + # The compressed kernel and embedded loader stay byte-for-byte intact. + original_n=struct.unpack_from('>I',flash,0x41000c)[0] + assert result[0x10010:0x10010+original_n] == flash[0x410010:0x410010+original_n] + original_decoder=lzma.LZMADecompressor(format=lzma.FORMAT_ALONE) + original_kernel=original_decoder.decompress(flash[0x412818:0x410010+original_n]) + factory_decoder=lzma.LZMADecompressor(format=lzma.FORMAT_ALONE) + factory_kernel=factory_decoder.decompress(result[0x12818:0x130000]) + assert original_decoder.eof and factory_decoder.eof + assert original_kernel == factory_kernel + active_mark = struct.unpack_from('>I',flash,0x410008)[0] + mark = struct.unpack_from('>I',result,0x10008)[0] + assert mark == active_mark+1 and mark > active_mark + normalized = bytearray(result[0x10000:0x130000]);struct.pack_into('>I',normalized,8,CANONICAL_MARK) + for offset in (32,0x120000+16+100,-16): + damaged = bytearray(image);damaged[offset] ^= 1 + assert not Boa(binary,flash,bytes(damaged)).validate(), offset + report = {'status':'pass','method':'original MIPS Boa executed in Unicorn 2.1.4; every external operation stubbed', + 'boa_sha256':sha(binary),'image_sha256':sha(image),'physical_flash_access':False, + 'actual_mtd_driver_tested':False,'factory_boot_tested':False, + 'stock_bootbank':2,'dual_firmware':True,'writes':plans, + 'kernel_bankmark':hex(mark),'canonical_factory_kernel_sha256':sha(normalized), + 'preserved_ranges':[[0,0x10000],[0x3e0000,0x800000]], + 'journal_initializer':'auto-v1' if result[0x3d0000:0x3d000f] == b'CKAUTOINIT-V1\r\n' else 'erased', + 'damaged_kernel_rootfs_trailer_rejected':True} + report['decompressed_kernel_unchanged']=True + report['decompressed_kernel_sha256']=sha(factory_kernel) + if args.bootloader: + loader=args.bootloader.read_bytes() + assert boot_checks(loader,result) == 1 + broken=bytearray(result);broken[0x130100]^=1 + assert boot_checks(loader,broken) == 2 + report.update({'bootloader_sha256':sha(loader), + 'original_boot_selector_and_checksums_passed':True, + 'corrupt_bank1_rootfs_selects_bank2_in_emulator':True}) + args.output.parent.mkdir(parents=True,exist_ok=True) + args.output.write_text(json.dumps(report,indent=2)+'\n') + print(json.dumps(report,indent=2)) + + +if __name__ == '__main__': main() diff --git a/reverse/tools/verify_protocol_release.py b/reverse/tools/verify_protocol_release.py new file mode 100644 index 0000000..ea3a8cb --- /dev/null +++ b/reverse/tools/verify_protocol_release.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Inspect the actual signed-payload filesystem; never access an outlet.""" +import argparse +import hashlib +import json +import re +import sys +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[2] +sys.path.insert(0,str(ROOT/'tools')) +from fwlib import read_file,file_inventory,rootfs_check + +def digest(data):return hashlib.sha256(data).hexdigest() + +def main(): + p=argparse.ArgumentParser(description=__doc__) + p.add_argument('--release-dir',type=Path,required=True) + p.add_argument('--test-logs',type=Path) + p.add_argument('--output',type=Path,required=True) + a=p.parse_args() + package=(a.release_dir/'matter.ota').read_bytes();root=package[256:] + assert package[:8]==b'CKOTA1\r\n' and int.from_bytes(package[20:24],'big')>=10 + version=int.from_bytes(package[20:24],"big") + hap=read_file(root,'bin/homekitserver');original=bytearray(hap) + for offset,command in ((0x1ae5b8,b'flash default-sw\0'),(0x1ae5e0,b'homekitserver -c\0')): + action=b'/bin/ck-reset' if version>=11 and offset==0x1ae5b8 else b'true' + replacement=action.ljust(len(command)-1,b' ')+b'\0' + assert original[offset:offset+len(command)]==replacement + original[offset:offset+len(command)]=command + if version>=11: + assert original[0x1b8e4:0x1b8e8]==bytes.fromhex("24040000") + original[0x1b8e4:0x1b8e8]=bytes.fromhex("24040001") + if version>=13: + from homekit_led import restore + original=bytearray(restore(original,json.loads(read_file(root,"etc/ck-homekit-led.json")))) + else:assert len(hap)==2116412 + assert digest(original)=='2d50339436759f3d602c150fb4f1a42f0fdf122fc18e9a4f54f6e6b07d7a0c62' + inv=file_inventory(root) + for lib in ['libgcc_s.so.1','libapmib.so','libpthread.so.0','libdl.so.0','librt.so.0','libm.so.0','libc.so.0','ld-uClibc.so.0']: + assert 'lib/'+lib in inv + assert 'bin/boa' not in inv and not any(n.startswith('web/') for n in inv) + rc=read_file(root,'etc/init.d/rcS');inittab=read_file(root,'etc/inittab') + assert inittab.count(b'::respawn:/bin/ck-protocol-start\n')==1 + for old in (b'\nhomekitserver\n',b'/bin/matter-test-start &',b'\nboa\n'):assert old not in rc + sources={'bin/ck-updater':'firmware/matter/artifacts/update/ck-updater', + 'bin/ck-updated':'firmware/matter/artifacts/update/ck-updated', + 'bin/ck-boot-guard':'firmware/matter/artifacts/update/ck-boot-guard', + 'bin/matterd-test':'firmware/matter/artifacts/softap-device-code/matterd-test', + 'bin/ck-setupd':'firmware/matter/artifacts/softap-device-code/ck-setupd', + 'bin/ck-protocol-start':'firmware/matter/src/setup/ck-protocol-start', + 'bin/matter-test-start':'firmware/matter/src/setup/matter-relay-start', + 'bin/ck-ota-start':'firmware/matter/src/update/ck-ota-start'} + if version>=11: + assert inittab.count(b"::respawn:/bin/ck-management-start\n")==1 + assert read_file(root,"etc/ck-admin-policy")==b"password-v1\n" + for n in ("ck-management-start","ck-reset"):sources["bin/"+n]="firmware/matter/src/setup/"+n + hashes={} + for dest,source in sources.items(): + data=read_file(root,dest);assert data==(ROOT/source).read_bytes(),dest + hashes[dest]=digest(data) + report={'status':'pass','version':read_file(root,'etc/ck-version').decode().strip(), + 'software_version':int.from_bytes(package[20:24],'big'),'real_outlet':False, + 'checks':['exact production binaries and scripts match payload', + 'one supervised protocol owner; no direct rcS protocol launch', + 'HomeKit original segments restored byte-for-byte; reset patches and LED observer verified', + 'original HAP shared libraries retained; unused Boa/router GUI removed', + 'version 13: hash-bound LED observer segment and exact reversible patch manifest' if version>=13 else 'legacy HAP layout'], + 'rootfs':rootfs_check(root),'ota_sha256':digest(package), + 'original_homekit_sha256':digest(original),'homekit_sha256':digest(hap), + 'homekit_elf_bytes':len(hap),'production_sha256':hashes, + 'limitations':['No physical HomeKit/MFi, RF or iPhone pairing validation', + 'HomeKit pauses custom web measurements and kWh accumulation; saved checkpoints remain', + 'HomeKit daemon lifecycle tests use a fixture', + 'Network VM uses 64 MiB/Linux 4.19 and MIB/import fixtures, not actual 32 MiB hardware', + 'No CH341A write, live reset/switch, git push or publication performed']} + report['rootfs_free_bytes']=0x290000-report['rootfs']['checksum_extent'] + if a.test_logs: + if version>=11: + names=['led-v0.2.11/full-combined.log','led-v0.2.11/deployment.log'] if version>=13 else ['v0.2.10-recovery-update-tests.log','v0.2.10-recovery-deployment-tests.log'] if version>=12 else ['v0.2.9-update-tests.log','v0.2.9-deployment-tests.log','v0.2.9-portal-tests.log','v0.2.9-password-tests.log'] + count=55 if version>=13 else 50 if version>=12 else 48 + log=(a.test_logs/names[0]).read_text() + assert len(re.findall(r'Ran '+str(count)+r' tests[^\n]*\n\nOK',log))==2 + deploy=(a.test_logs/names[1]).read_text() + assert re.search(r'Ran 13 tests[^\n]*\n\nOK \(skipped=1\)',deploy) + report['tests']={'native_storage_ota_http_admin_protocol':count,'mips_storage_ota_http_admin_protocol':count, + 'deployment_and_hap_layout_passed':12,'diagnostic_variant_not_shipped_skipped':1} + else: + names=['v0.2.8-update-tests.log','v0.2.8-deployment-tests.log','v0.2.8-portal-tests.log'] + log=(a.test_logs/names[0]).read_text() + assert len(re.findall(r'Ran 39 tests[^\n]*\n\nOK',log))==2 + deploy=(a.test_logs/names[1]).read_text();assert re.search(r'Ran 13 tests[^\n]*\n\nOK',deploy) + report['tests']={'native_storage_ota_http_protocol':39,'mips_storage_ota_http_protocol':39,'deployment_and_hap_layout':13} + report['tests']['test_log_sha256']={name:digest((a.test_logs/name).read_bytes()) for name in names} + a.output.write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n') + print(json.dumps({k:report[k] for k in ('status','ota_sha256','rootfs_free_bytes')},indent=2)) + +if __name__=='__main__':main() diff --git a/tests/hardware_fallback_init.c b/tests/hardware_fallback_init.c index ddce6dd..a328726 100644 --- a/tests/hardware_fallback_init.c +++ b/tests/hardware_fallback_init.c @@ -16,6 +16,8 @@ int main(void) { mount("ramfs", "/var", "ramfs", 0, NULL); mkdir("/var/run", 0700); mkdir("/var/log", 0700); + int pending = open("/var/run/ck-identity-pending", O_WRONLY | O_CREAT, 0600); + if (pending >= 0) close(pending); puts("MCU_ONLY_VM_READY_NO_IDENTITY"); sleep(2); int phase = 0; @@ -32,7 +34,7 @@ int main(void) { _exit(127); } int control = open("/dev/ttyS0", O_RDONLY | O_NONBLOCK), status = -1; - for (unsigned tick = 0; tick < 200; tick++) { + for (unsigned tick = 0; tick < 400; tick++) { if (waitpid(child, &status, WNOHANG) == child) break; char command[64] = {0}; if (control >= 0 && read(control, command, sizeof(command)-1) > 0 && strstr(command, "stop")) { diff --git a/tests/network_boot_init.c b/tests/network_boot_init.c new file mode 100644 index 0000000..25d0b83 --- /dev/null +++ b/tests/network_boot_init.c @@ -0,0 +1,32 @@ +/* Offline Malta fixture: real vendor BusyBox PID1 + real DHCP, no MTD/RF. */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +static int module(const char *path) { + int fd=open(path,O_RDONLY); if(fd<0)return -1; + int n=lseek(fd,0,SEEK_END);lseek(fd,0,SEEK_SET); + void *data=malloc(n);if(!data)return -1; + int result=read(fd,data,n)==n && !syscall(SYS_init_module,data,n,""); + free(data);close(fd);return result?0:-1; +} +int main(void) { + mount("proc","/proc","proc",0,NULL);mount("ramfs","/var","ramfs",0,NULL); + for(const char **p=(const char*[]){"/var/run","/var/log","/var/lock","/var/lib","/var/lib/ck-state",NULL};*p;p++)mkdir(*p,0700); + if(module("/mii.ko")||module("/pcnet32.ko"))return 3; + int fd=socket(AF_INET,SOCK_DGRAM,0);struct ifreq r; + memset(&r,0,sizeof(r));strcpy(r.ifr_name,"eth0");strcpy(r.ifr_newname,"br0"); + if(ioctl(fd,SIOCSIFNAME,&r))return 4; + memset(&r,0,sizeof(r));strcpy(r.ifr_name,"br0"); + ioctl(fd,SIOCGIFFLAGS,&r);r.ifr_flags|=IFF_UP; + if(ioctl(fd,SIOCSIFFLAGS,&r))return 5; + close(fd);setenv("PATH","/bin",1); + execl("/bin/busybox","busybox","init",NULL);return 127; +} diff --git a/tests/serve_admin_fixture.py b/tests/serve_admin_fixture.py new file mode 100644 index 0000000..a714e3e --- /dev/null +++ b/tests/serve_admin_fixture.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Disposable browser fixture using the real C HTTP service and file-only NOR.""" +import hashlib,json,os,signal,socket,socketserver,struct,subprocess,sys,threading,time +from pathlib import Path +from test_updates import Updates +Updates.setUpClass();fixture=Updates('test_persistence_and_noop_wear');fixture.setUp() +salt=bytes(range(16));key=hashlib.pbkdf2_hmac('sha256',b'FixtureAdmin42',salt,600000,32) +record=b'CKAUTH1\n'+(600000).to_bytes(4,'big')+salt+key +assert fixture.put('admin/credential',record)==0 +assert fixture.lib.ck_energy_save(1234567,0)==0 +(fixture.work/'etc/ck-version').write_text('0.2.11-preview\n') +initial=os.environ.get('CK_FIXTURE_PROTOCOL','matter');assert initial in ('matter','homekit') +assert fixture.put('config/protocol',initial.encode())==0 +(fixture.work/'var/run/ck-protocol-active').write_text(initial) +(fixture.work/'var/run/matter-manual-code').write_text('12345678901') +fixture.port=18983 +def start_server():return subprocess.Popen([str(fixture.server_binary),'18983','lo'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) +server=start_server() +led=None +if os.environ.get('CK_FIXTURE_LED')=='1': + for path in ['proc/wlan0','var/log']:(fixture.work/path).mkdir(parents=True,exist_ok=True) + (fixture.work/'proc/wlan0/gpio_ctrl').write_bytes(b' '*32) + (fixture.work/'var/run/ck-setup-state').write_text('state=online\n') + led=subprocess.Popen([str(fixture.updater),'--run-led'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) + +class Proxy(socketserver.BaseRequestHandler): + def handle(self): + remote=socket.create_connection(('127.0.0.1',18983),timeout=5) + def transfer(source,dest): + try: + while True: + data=source.recv(65536) + if not data:break + dest.sendall(data) + except OSError:pass + try:dest.shutdown(socket.SHUT_WR) + except OSError:pass + thread=threading.Thread(target=transfer,args=(self.request,remote),daemon=True);thread.start();transfer(remote,self.request);remote.close() +class TCP(socketserver.ThreadingTCPServer):allow_reuse_address=True;daemon_threads=True +proxy=TCP((socket.gethostbyname(socket.gethostname()),18983),Proxy) +threading.Thread(target=proxy.serve_forever,daemon=True).start() +try: + print('ADMIN_FIXTURE_READY port=18983; synthetic credentials and NOR only',flush=True) + while True: + pending=fixture.work/'var/run/ck-protocol-pending' + if pending.exists(): + mode=pending.read_text() + if mode in ('matter','homekit'): + # Simulate only the RAM/service restart; no HAP, network or real reboot. + server.terminate();server.wait(timeout=5) + (fixture.work/'var/run/ck-protocol-active').write_text(mode) + pending.unlink();(fixture.work/'var/run/ck-matter-request.lock').rmdir() + server=start_server() + if led: + now=int(time.monotonic()*1000);mode=(fixture.work/'var/run/ck-protocol-active').read_text() + (fixture.work/('var/run/ck-led-'+mode+'.bin')).write_bytes(b'CKLEDHW1'+struct.pack('>QQQQIIII',now,now,now,0,6000,31,now//5000,0)) + if (fixture.work/'var/run/ck-protocol-active').read_text()=='matter':fixture.meter_snapshot() + else:(fixture.work/'var/run/ck-meter.bin').unlink(missing_ok=True) + (fixture.work/'var/run/ck-matter-state.json').write_text(json.dumps({'updated_s':int(time.monotonic()),'fabrics':1,'window_open':False,'result':'idle','request_id':''},separators=(',',':'))) + time.sleep(1) +finally: + if led:led.terminate();led.wait() + proxy.shutdown();server.terminate();server.wait();fixture.tearDown();Updates.tearDownClass() diff --git a/tests/test_boot_guard.py b/tests/test_boot_guard.py new file mode 100644 index 0000000..6fe5e12 --- /dev/null +++ b/tests/test_boot_guard.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Fixed-job watchdog faults in regular-file fixtures; no RF/MTD access.""" +import os +from pathlib import Path +import shlex +import signal +import subprocess +import sys +import tempfile +import time +import unittest + +if sys.platform.startswith('linux'): + import resource + resource.setrlimit(resource.RLIMIT_CORE,(0,0)) + +ROOT=Path(__file__).resolve().parents[1] + + +@unittest.skipUnless(sys.platform.startswith('linux'),'Linux prctl/process groups') +class BootGuard(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.build=tempfile.TemporaryDirectory(prefix='ck-boot-guard-build-') + cls.binary=Path(os.environ.get('CK_BOOT_BIN',str(Path(cls.build.name)/'guard'))) + cls.runner=shlex.split(os.environ.get('CK_BOOT_RUNNER','')) + if not cls.runner: + subprocess.run(['cc','-DCK_TESTING','-O2','-Wall','-Wextra','-Werror', + str(ROOT/'firmware/matter/src/setup/boot_guard.c'),'-o',str(cls.binary)],check=True) + + @classmethod + def tearDownClass(cls):cls.build.cleanup() + + def setUp(self): + self.temp=tempfile.TemporaryDirectory(prefix='ck-boot-guard-test-') + self.root=Path(self.temp.name) + for folder in ['bin','var/run','var/log','var/lib/ck-state']:(self.root/folder).mkdir(parents=True) + self.env={**os.environ,'CK_BOOT_TEST_ROOT':str(self.root),'CK_BOOT_TEST_SECONDS':'1'} + + def tearDown(self):self.temp.cleanup() + + def stub(self,name,body): + p=self.root/'bin'/name;p.write_text('#!/bin/sh\n'+body+'\n');p.chmod(0o700) + + def command(self,mode):return [*self.runner,str(self.binary),mode] + + def run_guard(self,mode,**kwargs): + return subprocess.run(self.command(mode),capture_output=True,env=self.env,timeout=6,**kwargs) + + def test_fixed_modes_and_child_exit(self): + self.stub('ck-wificfg','echo configured=1; exit 0') + self.assertEqual(self.run_guard('--mib').stdout,b'configured=1\n') + self.stub('matterd-test','test "$1" = --init-identity && test "$2" = wlan0; exit 7') + self.assertEqual(self.run_guard('--identity').returncode,7) + self.assertEqual(self.run_guard('/bin/sh').returncode,2) + + def test_crash_is_reported(self): + self.stub('ck-updater','kill -SEGV $$') + self.assertEqual(self.run_guard('--import').returncode,139) + + def test_hanging_import_is_killed_and_can_retry(self): + self.stub('ck-updater','exec /bin/sleep 30') + result=self.run_guard('--import') + self.assertEqual(result.returncode,124) + self.assertIn(b'timeout',result.stderr) + self.stub('ck-updater','exit 0') + self.assertEqual(self.run_guard('--import').returncode,0) + + def test_setup_requires_fresh_heartbeat(self): + self.stub('ck-setupd','exec /bin/sleep 30') + self.assertEqual(self.run_guard('--setup').returncode,124) + self.env['CK_BOOT_TEST_SECONDS']='2' + heartbeat=shlex.quote(str(self.root/'var/run/ck-setup-heartbeat')) + self.stub('ck-setupd','while :; do read up rest < /proc/uptime; echo "${up%%.*}" > '+heartbeat+'; sleep .1; done') + with tempfile.TemporaryFile() as output: + p=subprocess.Popen(self.command('--setup'),env=self.env,stdout=output,stderr=output) + try: + time.sleep(3) + self.assertIsNone(p.poll()) + p.terminate();self.assertEqual(p.wait(timeout=4),143) + finally: + if p.poll() is None:p.kill();p.wait() + + def test_dhcp_log_is_capped_and_retains_tail(self): + data=bytes(range(256))*100 + self.assertEqual(self.run_guard('--dhcp-log',input=data).returncode,0) + self.assertEqual((self.root/'var/log/ck-dhcp.log').read_bytes(),data[-8192:]) + + def test_partial_heartbeat_keeps_last_valid_sample(self): + self.env['CK_BOOT_TEST_SECONDS']='3' + heartbeat=shlex.quote(str(self.root/'var/run/ck-setup-heartbeat')) + self.stub('ck-setupd','i=0; while [ "$i" -lt 20 ]; do ' + 'read up rest < /proc/uptime; echo "${up%%.*}" > '+heartbeat+'; ' + 'sleep .1; i=$((i+1)); done; : > '+heartbeat+'; exec sleep 30') + with tempfile.TemporaryFile() as output: + p=subprocess.Popen(self.command('--setup'),env=self.env,stdout=output,stderr=output) + try: + time.sleep(3.5) + self.assertIsNone(p.poll()) + p.terminate();self.assertEqual(p.wait(timeout=4),143) + finally: + if p.poll() is None:p.kill();p.wait() + + +if __name__=='__main__':unittest.main(verbosity=2) diff --git a/tests/test_deployment.py b/tests/test_deployment.py index 4d064e9..85afc74 100644 --- a/tests/test_deployment.py +++ b/tests/test_deployment.py @@ -76,6 +76,37 @@ def test_protected_bytes_and_stable_keys(self): self.assertEqual((self.profile / "diag.key").stat().st_mode & 0o777, 0o600) self.assertEqual(self.output.stat().st_mode & 0o777, 0o600) + def test_homekit_patch_preserves_original_elf_and_one_boot_owner(self): + root=self.release.read_bytes()[fw.START:fw.END] + meta=fw.read_file(root,'etc/ck-update.meta') + if struct.unpack('>III',meta)[2]<10:self.skipTest('protocol selection requires software version 10') + hap=fw.read_file(root,'bin/homekitserver') + original=bytearray(hap) + for offset,command in ((0x1ae5b8,b'flash default-sw\0'),(0x1ae5e0,b'homekitserver -c\0')): + action=b'/bin/ck-reset' if struct.unpack('>III',meta)[2]>=11 and command.startswith(b'flash') else b'true' + replacement=action.ljust(len(command)-1,b' ')+b'\0' + self.assertEqual(original[offset:offset+len(command)],replacement) + original[offset:offset+len(command)]=command + if struct.unpack('>III',meta)[2]>=11: + self.assertEqual(original[0x1b8e4:0x1b8e8],bytes.fromhex('24040000')) + original[0x1b8e4:0x1b8e8]=bytes.fromhex('24040001') + if struct.unpack('>III',meta)[2]>=13: + from homekit_led import restore + record=json.loads(fw.read_file(root,'etc/ck-homekit-led.json')) + original=restore(original,record) + self.assertEqual(fw.read_file(root,'etc/inittab').count(b'::respawn:/bin/ck-led-start\n'),1) + self.assertEqual(hashlib.sha256(original).hexdigest(),'2d50339436759f3d602c150fb4f1a42f0fdf122fc18e9a4f54f6e6b07d7a0c62') + inventory=fw.file_inventory(root) + self.assertNotIn('bin/boa',inventory) + self.assertFalse(any(n.startswith('web/') for n in inventory)) + for name in ['libgcc_s.so.1','libapmib.so','libpthread.so.0','libdl.so.0','librt.so.0','libm.so.0','libc.so.0','ld-uClibc.so.0']: + self.assertIn('lib/'+name,inventory) + inittab=fw.read_file(root,'etc/inittab') + self.assertEqual(inittab.count(b'::respawn:/bin/ck-protocol-start\n'),1) + rc=fw.read_file(root,'etc/init.d/rcS') + for old in [b'\nhomekitserver\n',b'/bin/matter-test-start &',b'\nboa\n']: + self.assertNotIn(old,rc) + def test_different_devices_get_different_keys(self): self.prepare() other = bytearray(self.before) @@ -99,6 +130,7 @@ def test_lost_profile_key_not_silently_rotated(self): def test_diagnostic_profile_uses_private_wpa2_credentials(self): release = Path(os.environ["CK_RELEASE_DIR"]) image = release / "exec-test-8m-region.bin" + if not image.exists():self.skipTest("本版只交付 HomeKit/Matter 韌體") meta = json.loads((release / "exec-test-8m-region.json").read_text()) meta["compatibility"] = fw.compatibility(self.before) manifest = self.work / "exec.json" diff --git a/tests/test_factory_host_compat.py b/tests/test_factory_host_compat.py new file mode 100644 index 0000000..b8a6830 --- /dev/null +++ b/tests/test_factory_host_compat.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Strict host recovery checks for the known Boa kernel wrapper; no hardware.""" +import argparse,json,struct,sys +from pathlib import Path +ROOT=Path(__file__).resolve().parents[1] +sys.path.insert(0,str(ROOT/'tools')) +from fwlib import START,compatible,compatibility,image_checks,sha +from factory_image import factory_kernel,identities + +def main(): + p=argparse.ArgumentParser(description=__doc__) + p.add_argument('--reference',type=Path,required=True) + p.add_argument('--output',type=Path,required=True) + a=p.parse_args();legacy=a.reference.read_bytes() + expected=json.loads((ROOT/'third_party/platform.json').read_text());compatible(legacy,expected) + wrapped=bytearray(legacy);kernel=bytearray(factory_kernel(legacy,expected));struct.pack_into('>I',kernel,8,0x8000004d) + wrapped[0x10000:START]=kernel + assert compatibility(wrapped)['bank1_kernel_sha256']==identities()['FACTORY'] + accepted=0;rejected=0 + for mark in [0x8000004d,0x80000050,0xfffffff0]: + changed=bytearray(wrapped);struct.pack_into('>I',changed,0x10008,mark) + before=bytes(changed);compatible(changed,expected);assert changed==before;accepted+=1 + for offset in [0x10004,0x1000c,0x10010,0x120000,0x12ffff,0x410010,0x530100]: + changed=bytearray(wrapped);changed[offset]^=1 + before=bytes(changed) + try:compatible(changed,expected) + except ValueError:rejected+=1 + else:raise AssertionError('accepted a modified executable/header/padding') + assert changed==before + for mark in [0,0x10000,0x80000001]: + changed=bytearray(wrapped);struct.pack_into('>I',changed,0x10008,mark) + try:compatible(changed,expected) + except ValueError:rejected+=1 + else:raise AssertionError('accepted an invalid boot mark') + # A malicious kernel with a freshly repaired checksum must still fail hash matching. + changed=bytearray(wrapped);changed[0x10080]^=1;changed[START-2:START]=b'\0\0' + total=sum(x[0] for x in struct.iter_unpack('>H',changed[0x10010:START])) + struct.pack_into('>H',changed,START-2,(-total)&65535);image_checks(changed) + try:compatible(changed,expected) + except ValueError:rejected+=1 + else:raise AssertionError('accepted modified code with a valid checksum') + report={'status':'pass','physical_device_access':False,'accepted_known_wrapper_marks':accepted, + 'rejected_code_header_padding_bank2_or_invalid_mark':rejected, + 'legacy_kernel_sha256':identities()['LEGACY'],'factory_kernel_sha256':identities()['FACTORY'], + 'input_preserved':True,'checks':['same strict kernel alternative as production OTA verifier','only bank mark normalized; full code/padding/checksum hashed','Bank 2 identity retained','mutated code rejected even with repaired checksum']} + a.output.write_text(json.dumps(report,indent=2)+'\n');print(json.dumps(report,indent=2)) +if __name__=='__main__':main() diff --git a/tests/test_factory_upgrade.py b/tests/test_factory_upgrade.py new file mode 100644 index 0000000..0769997 --- /dev/null +++ b/tests/test_factory_upgrade.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Test a private factory image -> signed OTA using the actual MIPS updater. + +Only regular files inside a temporary Docker fixture are written. The next +version is a synthetic test package, signed with a fresh throwaway key. +""" +import argparse +import json +import os +import struct +import subprocess +import sys +import tempfile +from pathlib import Path +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives import serialization + +ROOT=Path(__file__).resolve().parents[1] +sys.path.insert(0,str(ROOT/'tools')) +from factory_image import inspect, CANONICAL_MARK, KERNEL_SIZE +from fwlib import read_file, sha, START, END, file_inventory + + +def main(): + p=argparse.ArgumentParser(description=__doc__) + p.add_argument('--image',type=Path,required=True) + p.add_argument('--reference',type=Path,required=True) + p.add_argument('--release-dir',type=Path,required=True) + p.add_argument('--output',type=Path,required=True) + a=p.parse_args() + image=a.image.read_bytes();root=inspect(image);reference=a.reference.read_bytes() + flash=bytearray(reference) + kernel=bytearray(image[:KERNEL_SIZE]);struct.pack_into('>I',kernel,8,CANONICAL_MARK) + flash[0x10000:START]=kernel;flash[START:0x3e0000]=image[KERNEL_SIZE+16:-16] + next_package=bytearray((a.release_dir/'matter.ota').read_bytes()) + with tempfile.TemporaryDirectory(prefix='ck-factory-ota-') as name: + work=Path(name);os.chmod(work,0o700) + for path in ['etc','var/lock','var/lib/ck-state','var/run/ck-ota']:(work/path).mkdir(parents=True,exist_ok=True) + inventory=file_inventory(root) + public_image='etc/matter-device.key' not in inventory + paths=['etc/ck-update.meta']+[path for path in ['etc/matter-device.key','etc/matter-diag.key','etc/ck-identity-policy'] if path in inventory] + for path in paths: + (work/path).write_bytes(read_file(root,path));(work/path).chmod(0o600) + # Test identity and trust are local; the production signing key is never read. + key=Ed25519PrivateKey.generate() + public=key.public_key().public_bytes(serialization.Encoding.Raw,serialization.PublicFormat.Raw) + (work/'etc/ck-update.pub').write_bytes(public) + next_version=struct.unpack_from('>I',next_package,20)[0]+1 + struct.pack_into('>I',next_package,20,next_version) + next_package[128:160]=public;next_package[192:256]=key.sign(bytes(next_package[:192])) + (work/'next.ota').write_bytes(next_package) + (work/'seed.bin').write_bytes(os.urandom(32)) + target=work/'flash.bin';target.write_bytes(flash);target.chmod(0o600) + prefix=['docker','run','--rm','--network','none','-e','CK_TEST_ROOT=/fixture', + '-e','CK_TEST_SEED_FILE=/fixture/seed.bin', + '-e','CK_FLASH_FILE=/fixture/flash.bin','-v',str(work)+':/fixture', + '-v',str(ROOT)+':/repo:ro','incipio-public-build:v0.1.0-preview', + 'qemu-mips','/repo/firmware/matter/artifacts/update-test/ck-updater'] + def run(*args,success=True): + cp=subprocess.run(prefix+list(args),capture_output=True,timeout=30) + assert (cp.returncode==0)==success,(args,cp.returncode,cp.stderr[-500:]) + run('--import');run('--boot');run('--confirm');run('--verify','/fixture/next.ota') + if public_image: + assert (work/'var/run/ck-first-setup').exists() + run('--onboarded');run('--import') + assert not (work/'var/run/ck-first-setup').exists() + (work/'seed.bin').unlink() + def onboarding(): + command=['docker','run','--rm','--mac-address','02:11:22:33:44:55', + '-v',str(work/'etc/ck-update.meta')+':/etc/ck-update.meta:ro', + '-v',str(work/'var/lib/ck-state')+':/var/lib/ck-state:ro', + '-v',str(work/'var/run')+':/var/run', + '-v',str(ROOT)+':/repo:ro','incipio-public-build:v0.1.0-preview', + 'qemu-mips','/repo/firmware/matter/artifacts/softap-device-code/matterd-test', + '--init-identity','eth0'] + subprocess.run(command,capture_output=True,check=True,timeout=30) + return {name:(work/'var/run'/name).read_bytes() for name in ['matter-device-identity','matter-manual-code','matter-qr.svg']} + codes=onboarding() + assert len(codes['matter-manual-code'])==11 and b'I',changed,0x10008,mark);target.write_bytes(changed) + run('--verify','/fixture/next.ota') + for offset in (0x10010,0x120000,0x12ffff,0x410010,0x530100): + changed=bytearray(first_state);changed[offset]^=1;target.write_bytes(changed) + run('--verify','/fixture/next.ota',success=False) + assert target.read_bytes()==changed + target.write_bytes(first_state) + run('--stage','/fixture/next.ota');run('--simulate') + after=target.read_bytes() + assert after[START:END]==bytes(next_package[256:]) + assert after[:START]==first_state[:START] and after[END:]==first_state[END:] + for path,data in state_files.items():assert (work/'var/lib/ck-state'/path).read_bytes()==data + run('--import');assert onboarding()==codes + if not public_image: + for name,path in [('device.key','etc/matter-device.key'),('diag.key','etc/matter-diag.key')]: + assert (work/'var/lib/ck-state'/name).read_bytes()==read_file(root,path) + report={'status':'pass','environment':'actual MIPS test updater under qemu-user; temporary regular-file NOR fixture', + 'image_sha256':sha(image),'updater_sha256':sha((ROOT/'firmware/matter/artifacts/update-test/ck-updater').read_bytes()), + 'checks':['first import into erased journal','boot and version confirmation', + 'signed next-version OTA accepted on factory kernel','vendor boot-mark changes accepted', + 'changed code/padding/Bank 2 rejected without writes','OTA rootfs write/readback', + 'persistent identity and trust preserved across OTA','all ranges outside OTA rootfs preserved'], + 'public_image_without_preset_keys':public_image, + 'actual_mips_manual_and_qr_stable_across_reboot_and_ota':True, + 'physical_device_access':False,'factory_boot_tested':False} + a.output.write_text(json.dumps(report,indent=2)+'\n');print(json.dumps(report,indent=2)) + + +if __name__=='__main__':main() diff --git a/tests/test_hardware_fallback.py b/tests/test_hardware_fallback.py index 6cb23df..c9a0959 100644 --- a/tests/test_hardware_fallback.py +++ b/tests/test_hardware_fallback.py @@ -20,6 +20,7 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--deps', type=Path, required=True) parser.add_argument('--toolchains', type=Path, required=True) + parser.add_argument('--memory', type=int, choices=(32,64), default=64, help='Linux 4.19 Malta fixture needs 64 MiB; daemon remains capped at 8 MiB') args = parser.parse_args() binary = ROOT / 'firmware/matter/artifacts/softap-device-code/matterd-test' with tempfile.TemporaryDirectory(prefix='ck-mcu-only-vm-') as name: @@ -59,7 +60,7 @@ def entry(path, data=b'', mode=stat.S_IFREG | 0o755, device=(0, 0)): '-p', '127.0.0.1:15551:15551', '-v', str(directory) + ':/fixture:ro', '-v', str(args.deps.resolve()) + ':/deps:ro', 'incipio-public-build:v0.1.0-preview', 'qemu-system-mips', - '-M', 'malta', '-cpu', '24Kc', '-m', '32', '-vga', 'none', '-display', 'none', + '-M', 'malta', '-cpu', '24Kc', '-m', str(args.memory), '-vga', 'none', '-display', 'none', '-monitor', 'none', '-no-reboot', '-serial', 'stdio', '-serial', 'tcp:0.0.0.0:15551,server=on,wait=off', '-kernel', '/deps/downloads/vmlinux-4.19.0-21-4kc-malta', @@ -86,6 +87,7 @@ def wait_for(predicate, start=0, timeout=20): worker.start() for phase in range(2): wait_for(lambda text: 'MCU_VM_PHASE=' + str(phase) in text) + phase_started=time.monotonic() text = console.read_text(errors='replace') start = text.index('MCU_VM_PHASE=' + str(phase)) wait_for(lambda text: 'cold_off_confirmed=1' in text, start=start) @@ -97,6 +99,14 @@ def wait_for(predicate, start=0, timeout=20): mcu.event(on) expected = 'relay_known=1 relay_on=' + str(int(on)) wait_for(lambda text: expected in text, start=at) + if phase == 0: + while time.monotonic()-phase_started < 62:time.sleep(.1) + self_start=console.stat().st_size + mcu.event(True) + wait_for(lambda text:'relay_known=1 relay_on=1' in text,start=self_start) + self_start=console.stat().st_size + mcu.event(False) + wait_for(lambda text:'relay_known=1 relay_on=0' in text,start=self_start) process.stdin.write(b'stop\n') process.stdin.flush() wait_for(lambda text: 'MCU_VM_PHASE_' + str(phase) + '_WAIT=0' in text) @@ -105,9 +115,10 @@ def wait_for(predicate, start=0, timeout=20): print(json.dumps({'status': 'pass', 'binary_sha256': hashlib.sha256(binary.read_bytes()).hexdigest(), 'identity_missing': True, 'cold_off_confirmed': True, 'startup_identity_wait_and_mcu_only_mode': True, - 'simulated_button_state_changes': 8, 'continued_mcu_polling': True, + 'simulated_button_state_changes': 10, 'continued_mcu_polling': True, + 'identity_pending_over_60_seconds_keeps_buttons_active': True, 'flash_devices_present': False, 'virtual_nic_present': False, - 'original_hardware_test': False, 'address_space_limit': 8*1024*1024})) + 'original_hardware_test': False, 'vm_memory_mib': args.memory, 'address_space_limit': 8*1024*1024})) finally: mcu.done = True subprocess.run(['docker', 'stop', '-t', '1', 'incipio-mcu-only-vm'], diff --git a/tests/test_homekit_factory.py b/tests/test_homekit_factory.py new file mode 100644 index 0000000..a1a9dd9 --- /dev/null +++ b/tests/test_homekit_factory.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Run patched stock HAP reset in qemu-user, with synthetic MIB/NOR only.""" +import argparse,hashlib,json,os,subprocess,sys,tempfile +from pathlib import Path +ROOT=Path(__file__).resolve().parents[1] +sys.path.insert(0,str(ROOT/'tools')) +from fwlib import read_file + +def main(): + p=argparse.ArgumentParser(description=__doc__) + p.add_argument('--toolchains',type=Path,required=True);p.add_argument('--deps',type=Path,required=True) + p.add_argument('--output',type=Path,required=True);p.add_argument('--release-dir',type=Path);a=p.parse_args() + rootfs=(ROOT/'artifacts/update-base/matter-8m-region.bin').read_bytes()[0x130000:0x3c0000] + # The pinned public template stores the vendor rootfs in Bank 1. + original=read_file(rootfs,'bin/homekitserver') + assert hashlib.sha256(original).hexdigest()=='2d50339436759f3d602c150fb4f1a42f0fdf122fc18e9a4f54f6e6b07d7a0c62' + patched=bytearray(original) + for off,old,new in [(0x1ae5b8,b'flash default-sw\0',b'/bin/ck-reset'),(0x1ae5e0,b'homekitserver -c\0',b'true')]: + assert patched[off:off+len(old)]==old;patched[off:off+len(old)]=new.ljust(len(old)-1,b' ')+b'\0' + assert patched[0x1b8e4:0x1b8e8]==bytes.fromhex('24040001');patched[0x1b8e4:0x1b8e8]=bytes.fromhex('24040000') + if a.release_dir: + package=(a.release_dir/'matter.ota').read_bytes();patched=read_file(package[256:],'bin/homekitserver') + with tempfile.TemporaryDirectory(prefix='ck-hap-reset-') as td: + fixture=Path(td);r=fixture/'root' + for name in ['lib','bin','dev','var/run','tmp']: (r/name).mkdir(parents=True,exist_ok=True) + for name in ['libgcc_s.so.1','libpthread.so.0','libdl.so.0','librt.so.0','libm.so.0','libc.so.0','ld-uClibc.so.0']: + (r/'lib'/name).write_bytes(read_file(rootfs,'lib/'+name)) + (r/'bin/homekitserver').write_bytes(patched);(r/'bin/homekitserver').chmod(0o755) + before=bytes((i*31+7)%256 for i in range(0x20000));flash=r/'dev/mtdblock2';flash.write_bytes(before) + # Any MIB write or default-reset call exits with 89: the CLI patch must avoid it. + (fixture/'mib.c').write_text(''' +static void forbidden(void){register long n __asm__("$2")=4001;register long v __asm__("$4")=89;__asm__ volatile("syscall" : "+r"(n):"r"(v):"memory");for(;;){}} +int apmib_init(void){return 1;} +int apmib_get(int id,void *out){if(id==23){unsigned char *p=out;for(int i=0;i<6;i++)p[i]=(unsigned char)(i+16);}else *(int *)out=id==688?1:0;return 1;} +int apmib_set(int id,void *p){forbidden();return 0;} +int apmib_update(int x){forbidden();return 0;} +int apmib_updateDef(void){forbidden();return 0;} +''') + docker=['docker','run','--rm','--network','none','-v',str(fixture)+':/fixture','-v',str(a.toolchains.resolve())+':/work/toolchains:ro','-v',str(a.deps.resolve())+':/work/deps:ro','incipio-public-build:v0.1.0-preview'] + subprocess.run([*docker,'/work/toolchains/uclibc/bin/mips-linux-uclibc-gcc','-shared','-nostdlib','-Os','-fPIC','-mno-llsc','-Wl,--hash-style=sysv','-Wl,-soname,libapmib.so','/fixture/mib.c','-o','/fixture/root/lib/libapmib.so'],check=True) + result=subprocess.run([*docker,'qemu-mips','-L','/fixture/root','/fixture/root/bin/homekitserver','-c'],capture_output=True,timeout=15) + assert result.returncode==0,('HAP reset failed',result.returncode,result.stderr[-1000:]) + after=flash.read_bytes();assert len(after)==len(before) + assert after[:4]==b'PHRX' and after[4:0xa5d]==b'\0'*(0xa5d-4),after[:20].hex() + assert after[0xa5d:]==before[0xa5d:],'factory/calibration tail changed' + report={'status':'pass','real_outlet':False,'environment':'original MIPS HAP ELF and vendor uClibc under qemu-user; synthetic apmib and regular NOR file; no RF/MFi/hardware', + 'checks':['patched stock -c resets HAP pairing header and entire pairing area','MIB set/update/default calls forbidden; none executed','all bytes after 0xa5d, including factory PIN/calibration tail, unchanged'], + 'original_sha256':hashlib.sha256(original).hexdigest(),'patched_sha256':hashlib.sha256(patched).hexdigest(),'length':len(patched),'changed_bytes':sum(x!=y for x,y in zip(before,after))} + a.output.write_text(json.dumps(report,indent=2)+'\n');print(json.dumps(report,indent=2)) +if __name__=='__main__':main() diff --git a/tests/test_homekit_led.py b/tests/test_homekit_led.py new file mode 100644 index 0000000..ea17675 --- /dev/null +++ b/tests/test_homekit_led.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Run the exact HAP observer under qemu, without UART, GPIO or physical NOR.""" +import argparse,hashlib,json,struct,subprocess,sys,tempfile +from pathlib import Path +ROOT=Path(__file__).resolve().parents[1];sys.path.insert(0,str(ROOT/'tools')) +from fwlib import read_file +from homekit_led import build_payload,patch,restore,ORIGINAL_SHA + +def main(): + p=argparse.ArgumentParser(description=__doc__);p.add_argument('--output',type=Path,required=True);a=p.parse_args() + base=(ROOT/'artifacts/update-base/matter-8m-region.bin').read_bytes()[0x130000:0x3c0000];original=read_file(base,'bin/homekitserver') + assert hashlib.sha256(original).hexdigest()==ORIGINAL_SHA + with tempfile.TemporaryDirectory(prefix='ck-hap-led-') as td: + t=Path(td);r=t/'root' + for name in ['lib','bin','tmp','var/run']:(r/name).mkdir(parents=True,exist_ok=True) + for name in ['libapmib.so','libgcc_s.so.1','libpthread.so.0','libdl.so.0','librt.so.0','libm.so.0','libc.so.0','ld-uClibc.so.0']:(r/'lib'/name).write_bytes(read_file(base,'lib/'+name)) + payload,symbols=build_payload(t/'build',test=True);patched,record=patch(original,payload,symbols,test=True) + assert restore(patched,record)==original + (r/'bin/homekitserver').write_bytes(patched);(r/'bin/homekitserver').chmod(0o755) + (r/'tmp/ck-led-test-input').write_bytes(bytes.fromhex('fe313156ff060055')) + # Run qemu inside an isolated chroot: both rename paths use actual RAM + # fixture files, with no qemu prefix rewriting or host device access. + for path in ['ck-led-homekit.bin','ck-led-homekit.bin.tmp','ck-led-homekit-identify','ck-led-homekit-identify.tmp']:(r/'var/run'/path).write_bytes(b'') + driver=""" +import os,pathlib,re,shutil,subprocess +root=pathlib.Path('/fixture/root') +paths=['/usr/bin/qemu-mips']+re.findall(r'/[^ ()\\n]+',subprocess.check_output(['ldd','/usr/bin/qemu-mips'],text=True)) +for name in paths: + dest=root/name.lstrip('/');dest.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(name,dest) +os.execvp('chroot',['chroot',str(root),'/usr/bin/qemu-mips','-strace','/bin/homekitserver']) +""" + run=subprocess.run(['docker','run','--rm','--network','none','-v',str(t)+':/fixture','incipio-public-build:v0.1.0-preview','python3','-c',driver],capture_output=True,timeout=15) + if run.returncode:a.output.with_suffix('.trace.log').write_bytes(run.stderr) + assert run.returncode==0,(run.returncode,run.stdout.decode(errors='replace'),run.stderr.decode(errors='replace')[-3000:]) + b=(r/'var/run/ck-led-homekit.bin').read_bytes();assert len(b)==56 and b[:8]==b'CKLEDHW1' + now,relay,power,identify,mw,flags,seq,reserved=struct.unpack('>QQQQIIII',b[8:]);assert relay>0 and power>=relay and now>=power and mw==6000 and flags&7==7 and seq==1 and reserved==0 + until=int.from_bytes((r/'var/run/ck-led-homekit-identify').read_bytes(),'big');assert 14900<=until-now<=15500 + report={'status':'pass','real_outlet':False,'original_sha256':ORIGINAL_SHA,'test_payload_sha256':hashlib.sha256(payload).hexdigest(),'checks':['original libc read bridge including lazy GP binding','confirmed relay response observed','power response observed and fresh timestamps published','bounded identify deadline and no GPIO writes','patch restoration exactly recovers original ELF'],'limitations':['qemu-user does not validate physical MCU timing, MFi or LED polarity']} + a.output.parent.mkdir(parents=True,exist_ok=True);a.output.write_text(json.dumps(report,indent=2)+'\n');print(json.dumps(report,indent=2)) +if __name__=='__main__':main() diff --git a/tests/test_matter_reset_vm.py b/tests/test_matter_reset_vm.py new file mode 100644 index 0000000..a83b21d --- /dev/null +++ b/tests/test_matter_reset_vm.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Actual SDK/MIPS commissioning reset with a serial MCU and NOR file fixture.""" +import hashlib +import hmac +import http.client +import json +import os +from pathlib import Path +import re +import socket +import struct +import subprocess +import sys +import threading +import time + +ROOT=Path(__file__).resolve().parents[1] +WORK=ROOT/'firmware/matter' +sys.path.insert(0,str(WORK/'tools')) +sys.path.insert(0,str(ROOT/'tools')) +from test_device_vm import Mcu +from setup_control import control + +def main(): + name='incipio-matter-reset-vm' + deps=Path(os.environ['CK_SDK_DIR']) + private=ROOT/'private'/('matter-reset-vm-'+str(int(time.time()))) + private.mkdir(mode=0o700) + console=private/'console.txt' + binary=Path(json.loads((WORK/'evidence/chip-tool-macos/build.json').read_text())['binary']) + pin=struct.unpack_from('>I',(WORK/'private/device-test-VM/matter-device-identity').read_bytes(),12)[0] + report={'status':'failed','environment':'actual MIPS SDK, 64 MiB Malta VM, daemon limited to 8 MiB AS, serial MCU and NOR file fixtures', + 'real_outlet':False,'binary_sha256':hashlib.sha256((WORK/'artifacts/update-matter-test/matterd-test').read_bytes()).hexdigest(),'checks':[]} + args=['docker','run','-i','--rm','--name',name,'-p','127.0.0.1:55640:55640/udp', + '-p','127.0.0.1:15551:15551','-p','127.0.0.1:18184:18184','-p','127.0.0.1:18185:18185', + '-p','127.0.0.1:18186:18186','-v',str(WORK)+':/work:ro','-v',str(deps)+':/work/deps:ro', + 'incipio-public-build:v0.1.0-preview','qemu-system-mips','-M','malta','-cpu','24Kc','-m','64', + '-vga','none','-display','none','-monitor','none','-no-reboot','-serial','stdio', + '-serial','tcp:0.0.0.0:15551,server=on,wait=off','-kernel','/work/deps/downloads/vmlinux-4.19.0-21-4kc-malta', + '-initrd','/work/artifacts/update-matter-test/vm.cpio','-append','console=ttyS0 rdinit=/init panic=1', + '-netdev','user,id=n,restrict=off,hostfwd=udp:0.0.0.0:55640-10.0.2.15:5540,' + 'hostfwd=tcp:0.0.0.0:18184-10.0.2.15:18082,hostfwd=tcp:0.0.0.0:18185-10.0.2.15:18081,' + 'hostfwd=tcp:0.0.0.0:18186-10.0.2.15:18083','-device','pcnet,netdev=n,romfile=,mac=52:54:00:12:34:56'] + mcu=Mcu();stop=threading.Event();proxies=[];nodes=set() + def proxy(): + while not stop.wait(.1): + for node in re.findall(r'(?:Advertising|Advertise) operational node ([0-9A-Fa-f]{16}-[0-9A-Fa-f]{16})',console.read_text(errors='replace')): + if node in nodes:continue + nodes.add(node);proxies.append(subprocess.Popen(['dns-sd','-P',node,'_matter._tcp','local.','55640', + 'incipio-reset-vm.local','127.0.0.1','SII=500','SAI=300','T=0'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)) + def wait(predicate,timeout=45): + end=time.monotonic()+timeout + while time.monotonic()850 + tool('existing-fabric-retained',['onoff','off','12344321','1']) + report['checks'].append('authenticated open-window retains completed fabric and encrypted CASE control') + pair('incomplete-pase','controller-pase',True) + wait(lambda:status()['failsafe_armed']) + opened=operate('open-commissioning') + assert opened['fabrics']==1 and not opened['failsafe_armed'] and opened['window_open'] + report['checks'].append('open-window expires incomplete PASE fail-safe and reopens while retaining completed fabric') + mcu.event(True);time.sleep(.3);mcu.event(False);time.sleep(.5) + assert status()['fabrics']==1 + wait(lambda:(lambda x:x['available'] and x['energy_mwh'] and int(x['energy_mwh'])>0)(json.loads(web())),40) + before=json.loads(web());assert int(before['energy_mwh'])>0 + mcu.power=0;wait(lambda:json.loads(web())['power_mw']==0) + operate('energy-reset') + wait(lambda:json.loads(web())['energy_mwh']=='0') + time.sleep(32) + assert json.loads(web())['energy_mwh']=='0','old RAM counter restored after reset' + report['checks'].append('password-authenticated kWh reset commits zero in actual SDK owner and stays zero after next 30-second save') + mcu.power=500 + before=json.loads(web());reset=operate('reset-matter') + assert reset['fabrics']==0 and reset['window_open'] and not reset['failsafe_armed'] + wait(lambda:json.loads(web())['available']) + assert int(json.loads(web())['energy_mwh'])>=int(before['energy_mwh']) + assert hashlib.sha256(web('/matter-qr',18186)).hexdigest()==qr + tool('old-controller-denied',['onoff','off','12344321','1'],expected=1) + pair('new-controller-pair','controller-b') + tool('new-controller-case',['onoff','off','12344321','1'],'controller-b') + report['checks'].append('remote reset removes old controller, opens 900-second window, retains QR/admin password/network/cumulative energy and permits fresh full commissioning') + mcu.socket.sendall(bytes.fromhex('aaff0055')) + time.sleep(2) + assert status()['fabrics']==1 and 'FACTORY_RESET_REQUESTED:' not in console.read_text(errors='replace') + mcu.socket.sendall(bytes.fromhex('aaffff55')) + wait(lambda:'FACTORY_RESET_REQUESTED:' in console.read_text(errors='replace')) + wait(lambda:'FACTORY_INTENT_DURABLE=1' in console.read_text(errors='replace')) + assert not mcu.bad + report['checks'].append('reset-arm frame alone keeps pairing; confirmed physical long press commits durable factory intent and shuts down protocol owner') + report['status']='pass' + except Exception as error:report['failure']=str(error) + finally: + stop.set();mcu.done=True + subprocess.run(['docker','stop','-t','1',name],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) + vm.communicate(timeout=10);thread.join(timeout=1);mdns.join(timeout=1) + for child in proxies:child.terminate();child.wait(timeout=5) + (ROOT/'verification/admin-matter-vm-results.json').write_text(json.dumps(report,indent=2)+'\n') + print(json.dumps(report,indent=2)) + return 0 if report['status']=='pass' else 1 + +if __name__=='__main__':raise SystemExit(main()) diff --git a/tests/test_monitor.js b/tests/test_monitor.js index 77b3f94..524347a 100644 --- a/tests/test_monitor.js +++ b/tests/test_monitor.js @@ -1,5 +1,10 @@ const assert=require('node:assert/strict'); -const {meterView}=require('../firmware/matter/src/update/update.js'); +const {meterView,hmac256,hex}=require('../firmware/matter/src/update/update.js'); +const crypto=require('node:crypto'); +for(const message of ['Hi There','commandkit/protocol/v1\n'+'ab'.repeat(32)+'\nhomekit']){ + const key=Uint8Array.from({length:32},(_,i)=>i),data=new TextEncoder().encode(message); + assert.equal(hex(hmac256(key,data)),crypto.createHmac('sha256',key).update(data).digest('hex')); +} const sample={available:true,sample_age_ms:100,voltage_mv:122313,current_ma:12,power_mw:0,energy_mwh:'1234567',relay_on:false}; let v=meterView(sample); assert.deepEqual([v.voltage,v.current,v.power,v.energy],['122.31','0.012','0.0','1.234567']); diff --git a/tests/test_network_boot_vm.py b/tests/test_network_boot_vm.py new file mode 100644 index 0000000..ed35219 --- /dev/null +++ b/tests/test_network_boot_vm.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Real BusyBox PID1/DHCP and production setup controller in a MIPS VM. + +MTD, Wi-Fi MIB and the Matter identity helper are synthetic fixtures. No real +outlet, Flash device or RF access. All credentials below are fresh throwaways. +""" +import argparse +import hashlib +import hmac +import http.client +import json +import os +from pathlib import Path +import re +import stat +import subprocess +import sys +import tempfile +import time + +ROOT=Path(__file__).resolve().parents[1] +SOURCE=ROOT/'firmware/matter/src' +sys.path.insert(0,str(ROOT/'tools')) +from fwlib import read_file + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--deps',type=Path,required=True) + parser.add_argument('--toolchains',type=Path,required=True) + parser.add_argument('--vendor-ota',type=Path,required=True) + parser.add_argument('--output',type=Path,required=True) + args=parser.parse_args() + run=ROOT/'private'/('network-boot-vm-'+str(int(time.time()))) + run.mkdir(mode=0o700);console=run/'console.log' + container='incipio-'+run.name + compile=['docker','run','--rm','--network','none','-v',str(ROOT)+':/repo:ro', + '-v',str(run)+':/fixture','-v',str(args.deps.resolve())+':/work/deps:ro', + '-v',str(args.toolchains.resolve())+':/work/toolchains:ro','incipio-public-build:v0.1.0-preview', + '/work/toolchains/uclibc/bin/mips-linux-uclibc-gcc','-static','-Os','-mno-llsc','-mno-load-store-pairs','-mmemcpy', + '/repo/tests/network_boot_init.c','-o','/fixture/init'] + subprocess.run(compile,check=True) + entries=[] + def entry(path,data=b'',mode=stat.S_IFREG|0o755,device=(0,0)): + name=path.encode()+b'\0';fields=[len(entries)+1,mode,0,0,1,0,len(data),0,0,*device,len(name),0] + blob=b'070701'+b''.join(f'{v:08x}'.encode() for v in fields)+name + blob+=b'\0'*(-len(blob)%4);blob+=data;blob+=b'\0'*(-len(data)%4);entries.append(blob) + for folder in ['bin','lib','dev','etc','proc','var','tmp']: + entry(folder,mode=stat.S_IFDIR|0o755) + for name,device in [('console',(5,1)),('null',(1,3)),('urandom',(1,9)),('ttyS0',(4,64))]: + entry('dev/'+name,mode=stat.S_IFCHR|0o600,device=device) + vendor=args.vendor_ota.read_bytes()[256:] + for path in ['bin/busybox','bin/udhcpd','lib/ld-uClibc.so.0','lib/libc.so.0','lib/libgcc_s.so.1']: + entry(path,read_file(vendor,path)) + entry('bin/udhcpc',b'udhcpd',mode=stat.S_IFLNK|0o777) + for applet in ['sh','sleep','mkdir','rm','cp','cat','killall','ifconfig','route','ps']: + entry('bin/'+applet,b'busybox',mode=stat.S_IFLNK|0o777) + for name in ['mii.ko','pcnet32.ko']:entry(name,(args.deps/'downloads'/name).read_bytes()) + entry('init',(run/'init').read_bytes()) + entry('bin/ck-boot-guard',(ROOT/'firmware/matter/artifacts/update/ck-boot-guard').read_bytes()) + for binary in ['ck-setupd','ck-lease']: + entry('bin/'+binary,(ROOT/'firmware/matter/artifacts/softap-device-code'/binary).read_bytes()) + for binary,source in [('ck-identity-start','update/ck-identity-start'),('ck-ota-start','update/ck-ota-start'), + ('ck-onboarding-start','setup/ck-onboarding-start'),('ck-management-start','setup/ck-management-start'),('ck-setup-start','setup/ck-setup-device-start'), + ('ck-setup-backend','setup/ck-setup-device-backend'),('ck-udhcpc','setup/ck-udhcpc-fixed')]: + entry('bin/'+binary,(SOURCE/source).read_bytes()) + entry('etc/ck-variant',b'matter\n');entry('test-device.key',os.urandom(32),mode=stat.S_IFREG|0o600) + key=os.urandom(32);entry('test-diag.key',key.hex().encode()+b'\n',mode=stat.S_IFREG|0o600) + def script(name,body):entry('bin/'+name,('#!/bin/sh\n'+body+'\n').encode()) + script('ck-wificfg','if [ "$1" = status ]; then echo configured=1; else : > /var/run/unexpected-mib-write; exit 1; fi') + script('ck-updater','''case "$1" in +--import|--import-wait) + [ -f /var/run/allow-identity ] || exit 1 + cp /test-device.key /var/lib/ck-state/device.key + cp /test-diag.key /var/lib/ck-state/diag.key ;; +--boot) echo boot >> /var/run/boot-count ;; +--protocol-boot) printf matter > /var/run/ck-protocol-active ;; +--admin-state) printf '!' > /var/run/ck-admin-state ;; +*) : > /var/run/unexpected-mib-write; exit 1 ;; +esac''') + entry('bin/ck-updated',(ROOT/'firmware/matter/artifacts/update/ck-updated').read_bytes()) + entry('etc/ck-version',b'0.2.9-preview\n',mode=stat.S_IFREG|0o444) + entry('etc/ck-update.meta',__import__('struct').pack('>III',0x434b0004,1,11),mode=stat.S_IFREG|0o444) + script('matterd-test','test "$1" = --init-identity && test "$2" = wlan0 || exit 2\n' + 'test -f /var/lib/ck-state/device.key || exit 5\nprintf 12345678901 > /var/run/matter-manual-code\n') + entry('bin/ck-ota-start-v6',read_file(vendor,'bin/ck-ota-start')) + script('test-rcS','/bin/ck-ota-start-v6\nprintf "V6_STARTUP_EXIT=%s\\n" "$?"\n/bin/ck-ota-start\necho NETWORK_BOOT_VM_READY') + inittab='::sysinit:/bin/test-rcS\n::respawn:/bin/sh\n' + for name in ['ck-identity-start','ck-onboarding-start','ck-setup-start','ck-management-start']: + inittab+='::respawn:/bin/'+name+'\n' + entry('etc/inittab',inittab.encode(),mode=stat.S_IFREG|0o644) + entry('TRAILER!!!',mode=0);(run/'vm.cpio').write_bytes(b''.join(entries)) + command=['docker','run','--rm','-i','--name',container, + '-p','127.0.0.1:18190:18190','-p','127.0.0.1:18191:18191','-p','127.0.0.1:18192:18192','-v',str(run)+':/fixture:ro', + '-v',str(args.deps.resolve())+':/deps:ro','incipio-public-build:v0.1.0-preview', + 'qemu-system-mips','-M','malta','-cpu','24Kc','-m','64','-vga','none','-display','none', + '-monitor','none','-serial','stdio','-no-reboot','-kernel','/deps/downloads/vmlinux-4.19.0-21-4kc-malta', + '-initrd','/fixture/vm.cpio','-append','console=ttyS0 rdinit=/init panic=1', + '-netdev','user,id=n,restrict=off,hostfwd=tcp:0.0.0.0:18190-10.0.2.15:18081,hostfwd=tcp:0.0.0.0:18191-10.0.2.15:18082,hostfwd=tcp:0.0.0.0:18192-10.0.2.15:80', + '-device','pcnet,netdev=n,romfile=,mac=52:54:00:12:34:56'] + report={'status':'failed','real_outlet':False,'environment':'64 MiB Malta/Linux 4.19; actual vendor BusyBox init, production guard/setupd/backend/DHCP/lease publisher', + 'memory_limitation':'This Debian test kernel does not boot with -m 32 even without an initrd. This is not a physical 32 MiB outlet memory validation.', + 'fixtures':['MIB status/write rejection','identity importer','Matter identity helper'], + 'checks':[],'sha256':{name:hashlib.sha256((ROOT/path).read_bytes()).hexdigest() for name,path in { + 'guard':'firmware/matter/artifacts/update/ck-boot-guard','setupd':'firmware/matter/artifacts/softap-device-code/ck-setupd'}.items()}} + def request(path,headers=None): + c=http.client.HTTPConnection('127.0.0.1',18190,timeout=2) + c.request('GET',path,headers=headers or {});r=c.getresponse();data=r.read();c.close();return r.status,data + def wait(check,timeout=55): + end=time.monotonic()+timeout + while time.monotonic() /var/run/allow-identity') + wait(lambda:request('/challenge')[0]==200,timeout=35) + assert state()[0]==200 and b'state=online' in state()[1] + report['checks'].append('identity/management become ready after network is already online, without reboot') + old_dhcp=pid('cat /var/run/ck-udhcpc.pid');assert old_dhcp + serial('kill '+str(old_dhcp)) + wait(lambda:(lambda current:current and current!=old_dhcp)(pid('cat /var/run/ck-udhcpc.pid')),timeout=50) + wait(lambda:state()[0]==200 and b'state=online' in state()[1]) + report['checks'].append('DHCP client death is detected and a new client completes DHCP') + final=serial('cat /var/run/ck-wifi-bound; cat /var/run/boot-count; ' + 'if [ -f /var/run/unexpected-mib-write ]; then echo MIB_WRITE_FAILURE; else echo MIB_UNCHANGED; fi; ' + 'echo FINAL_END') + assert not re.search(r'(?m)^MIB_WRITE_FAILURE\r?$',final) + assert re.search(r'(?m)^MIB_UNCHANGED\r?$',final) + report['checks'].append('saved station reconnection never writes MIB or onboarded state') + report['status']='pass' + finally: + subprocess.run(['docker','stop','-t','1',container],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) + vm.wait(timeout=10) + args.output.write_text(json.dumps(report,indent=2)+'\n') + print(json.dumps(report,indent=2)) + + +if __name__=='__main__':main() diff --git a/tests/test_password_crypto.js b/tests/test_password_crypto.js new file mode 100644 index 0000000..a3b1d64 --- /dev/null +++ b/tests/test_password_crypto.js @@ -0,0 +1,18 @@ +const assert=require('node:assert/strict'); +const crypto=require('node:crypto'); +const {pbkdf2,hex,hmac256}=require('../firmware/matter/src/update/password.js'); +for(const [password,iterations]of [['fixture password',1],['fixture password',2],['中文密碼123',4096],['x'.repeat(100),4096],['fixture password',600000]]){ + const salt=Buffer.from('00112233445566778899aabbccddeeff','hex'),bytes=Buffer.from(password); + assert.equal(hex(pbkdf2(bytes,salt,iterations)),crypto.pbkdf2Sync(bytes,salt,iterations,32,'sha256').toString('hex')); +} +for(const iterations of [0,600001,-1])assert.throws(()=>pbkdf2(Buffer.from('password'),Buffer.alloc(16),iterations)); +const key=crypto.randomBytes(32),message=Buffer.from('ck-admin-login-v1\n'+'a'.repeat(64)); +assert.equal(hex(hmac256(key,message)),crypto.createHmac('sha256',key).update(message).digest('hex')); +console.log('Password worker PBKDF2/HMAC agrees with independent Node crypto, including 600,000 rounds.'); + +// Load both scripts in one browser global: names must not collide. +const vm=require("node:vm"),fs=require("node:fs"),context=vm.createContext({window:{},console}); +vm.runInContext(fs.readFileSync(require.resolve("../firmware/matter/src/update/password.js"),"utf8"),context); +vm.runInContext(fs.readFileSync(require.resolve("../firmware/matter/src/update/update.js"),"utf8"),context); +assert.equal(typeof context.window.CKPassword.derive,"function"); +console.log("Password and dashboard scripts coexist in one browser global."); diff --git a/tests/test_public_management.py b/tests/test_public_management.py new file mode 100644 index 0000000..8b3d285 --- /dev/null +++ b/tests/test_public_management.py @@ -0,0 +1,24 @@ +import json +import sys +import tempfile +import unittest +from pathlib import Path +sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'tools')) +from check_public import public_paths, audit + + +class PublicManagement(unittest.TestCase): + def test_downloaded_profile_is_excluded_from_source_archive(self): + with tempfile.TemporaryDirectory() as name: + path=Path(name)/'commandkit-management.json' + path.write_text(json.dumps({'format':'commandkit-management-v1','diag_key':'01'*32})) + self.assertEqual(public_paths(name),[]) + def test_renamed_profile_is_rejected_before_publication(self): + with tempfile.TemporaryDirectory() as name: + path=Path(name)/'renamed.json' + path.write_text(json.dumps({'format':'commandkit-management-v1','diag_key':'01'*32})) + with self.assertRaisesRegex(ValueError,'公開包含管理金鑰'): + audit(name,Path(name)/'unused-release') + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_setup_control.py b/tests/test_setup_control.py new file mode 100644 index 0000000..a46da39 --- /dev/null +++ b/tests/test_setup_control.py @@ -0,0 +1,68 @@ +import hashlib +import hmac +import json +import io +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch +sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'tools')) +from setup_control import control,main +from fwlib import profile_diag_key + + +class SetupControl(unittest.TestCase): + def test_downloaded_management_profile(self): + with tempfile.TemporaryDirectory() as name: + path=Path(name)/'commandkit-management.json';key=bytes(range(32)) + path.write_text(json.dumps({'format':'commandkit-management-v1','diag_key':key.hex()})) + self.assertEqual(profile_diag_key(path),key) + path.write_text(json.dumps({'format':'other','diag_key':key.hex()})) + with self.assertRaises(ValueError):profile_diag_key(path) + path.write_text('[]') + with self.assertRaises(ValueError):profile_diag_key(path) + def test_scope_and_nonce_are_bound_and_key_not_sent(self): + for action,method,path in [('status','GET','/state'),('enter-setup','POST','/enter-setup'), + ('matter-status','GET','/matter-state'),('reset-matter','POST','/reset-matter'), + ('open-commissioning','POST','/open-commissioning')]: + with self.subTest(action=action),tempfile.TemporaryDirectory() as name: + key=bytes(range(32));nonce=b'1'*64 + (Path(name)/'diag.key').write_bytes(key.hex().encode()+b'\n') + with patch('setup_control.http.client.HTTPConnection') as constructor: + connection=constructor.return_value + challenge=unittest.mock.Mock(status=200);challenge.read.return_value=nonce + response=unittest.mock.Mock(status=202);response.read.return_value=b'accepted\n' + connection.getresponse.side_effect=[challenge,response] + self.assertEqual(control('192.168.1.20',name,action),'accepted\n') + call=connection.request.call_args_list[1] + self.assertEqual(call.args,(method,path)) + expect=hmac.new(key,(method+' '+path+'\n').encode()+nonce,hashlib.sha256).hexdigest() + self.assertEqual(call.kwargs['headers'],{'X-Setup-Proof':nonce.decode()+':'+expect}) + self.assertNotIn(key.hex(),str(connection.request.call_args_list)) + def test_invalid_challenge_sends_no_mutation(self): + with tempfile.TemporaryDirectory() as name: + (Path(name)/'diag.key').write_text('01'*32+'\n') + with patch('setup_control.http.client.HTTPConnection') as constructor: + response=constructor.return_value.getresponse.return_value + response.status=200;response.read.return_value=b'invalid' + with self.assertRaises(ValueError):control('192.168.1.20',name,'enter-setup') + constructor.return_value.request.assert_called_once_with('GET','/challenge',body=None,headers={}) + def test_wait_ignores_previous_completed_request(self): + previous={'request_id':'old','result':'complete','window_open':True,'fabrics':0} + completed={**previous,'request_id':'new'} + with patch('sys.argv',['setup_control.py','reset-matter','--host','192.168.1.20','--profile','profile','--wait']), \ + patch('setup_control.control',side_effect=[json.dumps({'request_id':'new'}),json.dumps(previous),json.dumps(completed)]) as api, \ + patch('setup_control.time.sleep'),patch('sys.stdout',new_callable=io.StringIO) as output: + main() + self.assertEqual(json.loads(output.getvalue()),completed) + self.assertEqual(api.call_count,3) + def test_wait_does_not_report_reset_success_with_fabrics_remaining(self): + state={'request_id':'new','result':'complete','window_open':True,'fabrics':1} + with patch('sys.argv',['setup_control.py','reset-matter','--host','192.168.1.20','--profile','profile','--wait']), \ + patch('setup_control.control',side_effect=[json.dumps({'request_id':'new'}),json.dumps(state)]), \ + patch('setup_control.time.sleep'): + with self.assertRaisesRegex(ValueError,'尚未清除'):main() + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_startup_recovery.py b/tests/test_startup_recovery.py index 450f26c..b06e5d6 100644 --- a/tests/test_startup_recovery.py +++ b/tests/test_startup_recovery.py @@ -6,6 +6,8 @@ import re import tempfile import unittest +import time +import signal ROOT = Path(__file__).resolve().parents[1] SOURCE = ROOT / 'firmware/matter/src' @@ -34,20 +36,21 @@ def run_script(self, source): environment = {**os.environ, 'PATH': str(self.work / 'bin') + os.pathsep + os.environ['PATH']} return subprocess.run(['sh', str(script)], capture_output=True, timeout=5, env=environment) - def test_setup_uses_interface_present_on_original_hardware(self): - # Captured original /proc/net/dev has wlan0, with no wl0 interface. - interfaces = self.work / 'interfaces' - (interfaces / 'wlan0').mkdir(parents=True) + def test_setup_starts_with_missing_identity_and_stale_pending(self): + (self.work/'var/run/ck-identity-pending').touch() self.executable('sleep', 'exit 0\n') - self.executable('matterd-test', - 'test "$1" = --init-identity || exit 2\n' - 'test -d "' + str(interfaces) + '/$2" || exit 5\n') - self.executable('ck-wificfg', 'echo configured=0\n') - self.executable('ck-setupd', 'echo setup_started\n') + self.executable('ck-boot-guard', 'case "$1" in --mib) echo configured=1 ;; --setup) echo setup_started ;; *) exit 9 ;; esac\n') result = self.run_script('setup/ck-setup-device-start') - self.assertEqual(result.returncode, 0) - self.assertIn(b'setup_started', result.stdout) - self.assertEqual((self.work / 'var/run/ck-setup-boot').read_text(), 'unconfigured') + self.assertEqual(result.returncode, 1) # PID1 respawns the supervisor. + self.assertIn('setup_started', (self.work/'var/log/ck-setupd.log').read_text()) + self.assertEqual((self.work / 'var/run/ck-setup-boot').read_text(), 'configured') + + def test_failed_mib_read_preserves_station_mode(self): + self.executable('sleep','exit 0\n') + self.executable('ck-boot-guard','case "$1" in --mib) exit 9 ;; --setup) echo setup_started ;; esac\n') + result=self.run_script('setup/ck-setup-device-start') + self.assertIn('setup_started',(self.work/'var/log/ck-setupd.log').read_text()) + self.assertEqual((self.work/'var/run/ck-setup-boot').read_text(),'configured') def test_failed_import_keeps_bootstrap_for_setup_and_diagnostics(self): self.executable('ck-updater', 'echo import_failed\nexit 1\n') @@ -66,8 +69,65 @@ def test_failed_import_keeps_bootstrap_for_setup_and_diagnostics(self): self.assertEqual(output.stat().st_mode & 0o777, 0o600) self.assertFalse((self.work / 'var/run/ck-update-healthy').exists()) + def test_first_generated_identity_forces_setup_even_with_old_wifi(self): + (self.work/'var/run/ck-first-setup').touch() + self.executable('sleep','exit 0\n') + self.executable('ck-boot-guard','case "$1" in --mib) exit 9 ;; --setup) echo setup_started ;; esac\n') + result=self.run_script('setup/ck-setup-device-start') + self.assertEqual(result.returncode,1) + self.assertIn('setup_started',(self.work/'var/log/ck-setupd.log').read_text()) + self.assertEqual((self.work/'var/run/ck-setup-boot').read_text(),'unconfigured') + + def test_public_entropy_wait_does_not_block_vendor_startup(self): + (self.work/'etc/ck-identity-policy').write_text('auto-v1\n') + ready=self.work/'entropy-ready' + self.executable('ck-updater', + 'case "$1" in --import) exit 75 ;; --import-wait)\n' + 'while [ ! -f "'+str(ready)+'" ]; do /bin/sleep 0.1; done ;; esac\nexit 0\n') + self.executable('ck-updated','exit 0\n') + text=(SOURCE/'update/ck-ota-start').read_text() + text=re.sub(r'/(?:bin|var|etc)/',lambda m:str(self.work)+m[0],text) + script=self.work/'startup.sh';script.write_text(text) + with (self.work/'output.log').open('wb') as log: + process=subprocess.Popen(['sh',str(script)],stdout=log,stderr=log,start_new_session=True) + try: + self.assertEqual(process.wait(timeout=2),0) + until=time.monotonic()+2 + pending=self.work/'var/run/ck-identity-pending' + while not pending.exists() and time.monotonic()> "'+str(self.work/'boots')+'" ;; esac\nexit 0\n') + self.executable('ck-updated','exit 0\n') + self.executable('ck-updater','exit 0\n') + self.executable('sleep','exit 0\n') + for _ in range(2): + result=self.run_script('update/ck-identity-start') + self.assertEqual(result.returncode,0) + self.assertEqual(key.read_bytes(),original) + self.assertEqual((self.work/'boots').read_text().splitlines(),['booted']) + self.assertFalse((self.work/'var/run/ck-identity-pending').exists()) + self.assertEqual((self.work/'var/run/ck-identity-state').read_text(),'phase=ready\n') + def relay_script(self, rescue=False, conflict=False): - self.executable('pidof', 'echo homekitserver\n' if conflict else 'exit 1\n') + self.executable('ps', 'echo homekitserver\n' if conflict else 'exit 0\n') + self.executable('grep','exec /usr/bin/grep "$@"\n') calls = self.work / 'calls' self.executable('matter-diag', 'if [ "$1" = --log ]; then exec cat; fi\n' diff --git a/tests/test_update_feed.py b/tests/test_update_feed.py index 4f161f1..47f137b 100644 --- a/tests/test_update_feed.py +++ b/tests/test_update_feed.py @@ -9,6 +9,7 @@ from unittest.mock import patch sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'tools')) import setup_github_updates as setup +from ota import validate_catalog_upgrade class PagesEnvironment(unittest.TestCase): def configure(self,policy,rules,missing=False): @@ -43,4 +44,15 @@ def test_new_environment_keeps_main_and_adds_release_tags(self): posts=[json.loads(k['input']) for a,k in calls if a[:3]==['api','--method','POST']] self.assertEqual(posts,[{'name':'main','type':'branch'},{'name':'v*','type':'tag'}]) +class CatalogUpgrade(unittest.TestCase): + def catalog(self,**versions):return {'updates':[{'variant':v,'software_version':n} for v,n in versions.items()]} + def test_legacy_diagnostic_can_retire_while_matter_advances(self): + validate_catalog_upgrade(self.catalog(matter=5,**{'exec-test':5}),self.catalog(matter=13)) + def test_supported_channel_cannot_disappear_or_downgrade(self): + for incoming in [self.catalog(matter=4),self.catalog(**{'exec-test':13}),self.catalog()]: + with self.assertRaises(ValueError):validate_catalog_upgrade(self.catalog(matter=5),incoming) + with self.assertRaises(ValueError):validate_catalog_upgrade(self.catalog(matter=5,other=5),self.catalog(matter=13)) + def test_retained_diagnostic_cannot_downgrade(self): + with self.assertRaises(ValueError):validate_catalog_upgrade(self.catalog(matter=5,**{'exec-test':5}),self.catalog(matter=13,**{'exec-test':4})) + if __name__=='__main__':unittest.main(verbosity=2) diff --git a/tests/test_update_pid1.py b/tests/test_update_pid1.py index 8f8afc1..28a951d 100644 --- a/tests/test_update_pid1.py +++ b/tests/test_update_pid1.py @@ -32,7 +32,7 @@ def main(): for args in [['--import'],['--stage','/fixture/update.ota']]: subprocess.run([*docker,'incipio-public-build:v0.1.0-preview','qemu-mips',binary,*args],check=True,timeout=30) before=(work/'flash.bin').read_bytes() - sources=['ck_common.c','ck_flash.c','ck_store.c','ck_ota.c','ck_updater.c'] + sources=['ck_common.c','ck_flash.c','ck_store.c','ck_energy.c','ck_led.c','ck_ota.c','ck_updater.c'] common=['/repo/firmware/matter/src/update/'+p for p in sources] common += ['/repo/firmware/matter/src/vendor/monocypher/'+p for p in ['monocypher.c','monocypher-ed25519.c']] subprocess.run([*docker,'incipio-public-build:v0.1.0-preview','cc','-static','-O2','-DCK_TESTING','-DCK_TEST_PID1',*common,'-o','/fixture/apply-native'],check=True,timeout=60) diff --git a/tests/test_updates.py b/tests/test_updates.py index b5d4927..311237e 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -6,15 +6,19 @@ import ctypes import hashlib import http.client +import hmac import json import os import socket +import signal +import fcntl import shlex import struct import subprocess import sys import tempfile import time +import threading import unittest from pathlib import Path from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey @@ -41,7 +45,7 @@ class Updates(unittest.TestCase): @classmethod def setUpClass(cls): cls.build=tempfile.TemporaryDirectory(prefix='ck-update-build-');cls.bin=Path(cls.build.name) - common=[str(SOURCE/'update'/p) for p in ['ck_common.c','ck_flash.c','ck_store.c','ck_energy.c','ck_ota.c','ck_meter.c']] + common=[str(SOURCE/'update'/p) for p in ['ck_common.c','ck_flash.c','ck_store.c','ck_energy.c','ck_ota.c','ck_meter.c','ck_led.c']] common += [str(SOURCE/'vendor/monocypher'/p) for p in ['monocypher.c','monocypher-ed25519.c']] flags=['cc','-DCK_TESTING','-std=c11','-O2','-g','-Wall','-Wextra','-Werror','-Wno-misleading-indentation','-Wno-unused-function'] subprocess.run(flags+['-shared','-fPIC',*common,'-o',str(cls.bin/'state.so')],check=True) @@ -71,13 +75,18 @@ def setUp(self): self.flash=self.work/'flash.bin';self.flash.write_bytes(self.image) os.environ['CK_FLASH_FILE']=str(self.flash);os.environ['CK_TEST_ROOT']=str(self.work) os.environ.pop('CK_FAIL_STORE',None);os.environ.pop('CK_FAIL_OTA_AFTER',None);os.environ.pop('CK_FAIL_ENERGY',None) + os.environ.pop('CK_TEST_SEED_FILE',None);os.environ.pop('CK_FAIL_INIT_RETIRE',None) self.assertEqual(self.lib.ck_store_import(),0) self.ota=self.work/'update.ota';self.ota.write_bytes(package(self.key,self.image)) - self.server=None + self.server=None;self.cookie='';self.csrf='' + self.admin_salt=os.urandom(16);self.admin_key=os.urandom(32) + self.admin_record=b'CKAUTH1\n'+struct.pack('>I',600000)+self.admin_salt+self.admin_key + self.assertEqual(self.put('admin/credential',self.admin_record),0) def tearDown(self): if self.server: self.server.terminate();self.server.wait(timeout=5);self.server.stdout.close();self.server.stderr.close() - os.environ.pop('CK_FAIL_STORE',None);os.environ.pop('CK_FAIL_OTA_AFTER',None);os.environ.pop('CK_FAIL_ENERGY',None);self.tmp.cleanup() + for key in ('CK_FAIL_STORE','CK_FAIL_OTA_AFTER','CK_FAIL_ENERGY','CK_TEST_SEED_FILE','CK_FAIL_INIT_RETIRE'):os.environ.pop(key,None) + self.tmp.cleanup() def get(self,key): b=ctypes.create_string_buffer(65536);n=ctypes.c_size_t(len(b));r=self.lib.ck_store_get(key.encode(),b,ctypes.byref(n));return r,b.raw[:n.value] def put(self,key,value):return self.lib.ck_store_set(key.encode(),value,len(value)) @@ -124,6 +133,29 @@ def test_persistence_and_noop_wear(self): code='import ctypes,os;from pathlib import Path;lib=ctypes.CDLL(os.environ["LIB"]);b=ctypes.create_string_buffer(100);n=ctypes.c_size_t(100);assert lib.ck_store_get(b"matter/fabric",b,ctypes.byref(n))==0;assert b.raw[:n.value]==b"paired fabric secret"' subprocess.run([sys.executable,'-c',code],check=True,env={**os.environ,'LIB':str(self.bin/'state.so')}) self.assertEqual(self.lib.ck_store_delete(b'matter/fabric'),0);self.assertEqual(self.get('matter/fabric')[0],1) + def test_matter_reset_is_atomic_and_keeps_identity_wifi_ota_and_energy(self): + kept={k:self.get(k) for k in ['identity/device','identity/diag','identity/update']} + for key,value in {'platform/chip_config.ini':b'[DEFAULT]\nunique-id=stable\ncountry-code=TW\n', + 'platform/chip_factory.ini':b'factory data', 'platform/chip_counters.ini':b'counters', + 'wifi/fixture':b'unchanged network', 'ota/floor':b'\0\0\0\x08'}.items(): + self.assertEqual(self.put(key,value),0);kept[key]=(0,value) + for key in ['matter/f/1/n','matter/g/fs/c','matter/acl','platform/chip_kvs']: + self.assertEqual(self.put(key,b'operational secret'),0) + self.assertEqual(self.lib.ck_energy_save(987654,123),0) + before=self.flash.read_bytes();request=b'a'*64 + for point in ['after-erase','before-commit']: + os.environ['CK_FAIL_STORE']=point + self.assertNotEqual(self.lib.ck_store_reset_matter(request),0) + os.environ.pop('CK_FAIL_STORE') + self.assertEqual(self.get('matter/f/1/n'),(0,b'operational secret')) + self.assertEqual(self.get('control/matter-reset')[0],1) + self.assertEqual(self.lib.ck_store_reset_matter(request),0) + for key in ['matter/f/1/n','matter/g/fs/c','matter/acl','platform/chip_kvs']:self.assertEqual(self.get(key)[0],1) + for key,value in kept.items():self.assertEqual(self.get(key),value) + self.assertEqual(self.get('control/matter-reset'),(0,request));self.assertEqual(self.energy(),(0,987654,123)) + after=self.flash.read_bytes() + for start,end in [(0,JOURNAL),(0x3cf000,0x3d0000),(0x3df000,0x800000)]:self.assertEqual(before[start:end],after[start:end]) + for bad in [b'',b'A'*64,b'a'*65]:self.assertNotEqual(self.lib.ck_store_reset_matter(bad),0) def test_torn_journal_and_corrupt_new_slot(self): self.assertEqual(self.put('matter/test',b'old'),0) for point in ['after-erase','before-commit']: @@ -149,6 +181,24 @@ def test_verification_rejects_wrong_or_damaged_images(self): for value in bad: self.ota.write_bytes(value);self.assertNotEqual(self.run_updater('--verify',self.ota).returncode,0) self.assertEqual(self.flash.read_bytes(),before) + def test_factory_kernel_hash_covers_code_padding_and_checks_header(self): + kernel=bytearray(b'\xa5'*0x120000) + struct.pack_into('>4sIII',kernel,0,b'cr6c',0x80500000,0x8000004d,0x11fff0) + expected=hashlib.sha256(kernel).digest() + path=self.work/'kernel.bin';out=ctypes.create_string_buffer(32) + for mark in (0x8000004d,0x80000051,0xfffffff0): + struct.pack_into('>I',kernel,8,mark);path.write_bytes(kernel) + with path.open('rb') as fd:self.assertEqual(self.lib.ck_hash_factory_kernel(fd.fileno(),0,out),0) + self.assertEqual(out.raw,expected) + for at in (20,0x110000,0x11ffff): + changed=bytearray(kernel);changed[at]^=1;path.write_bytes(changed) + with path.open('rb') as fd:self.assertEqual(self.lib.ck_hash_factory_kernel(fd.fileno(),0,out),0) + self.assertNotEqual(out.raw,expected) + for at in (0,4,12): + changed=bytearray(kernel);changed[at]^=1;path.write_bytes(changed) + with path.open('rb') as fd:self.assertNotEqual(self.lib.ck_hash_factory_kernel(fd.fileno(),0,out),0) + path.write_bytes(kernel[:-1]) + with path.open('rb') as fd:self.assertNotEqual(self.lib.ck_hash_factory_kernel(fd.fileno(),0,out),0) def test_rootfs_write_and_preservation(self): self.assertEqual(self.put('matter/fabric',b'paired'),0) self.assertEqual(self.lib.ck_energy_save(1234567,17),0) @@ -169,6 +219,84 @@ def test_interrupted_initial_import_can_retry_with_bootstrap(self): self.flash.write_bytes(self.image) os.environ['CK_FAIL_STORE']='before-commit';self.assertNotEqual(self.lib.ck_store_import(),0) os.environ.pop('CK_FAIL_STORE');self.assertEqual(self.lib.ck_store_import(),0) + def fresh_auto(self,seed=None): + sys.path.insert(0,str(ROOT/'tools')) + from factory_image import journal_initializer + image=bytearray(self.image);image[JOURNAL:JOURNAL+0x20000]=journal_initializer(True) + self.flash.write_bytes(image) + (self.work/'etc/ck-identity-policy').write_bytes(b'auto-v1\n') + for path in ('etc/matter-device.key','etc/matter-diag.key','var/run/ck-first-setup'): + (self.work/path).unlink(missing_ok=True) + for path in (self.work/'var/lib/ck-state').iterdir():path.unlink() + path=self.work/'seed.bin';path.write_bytes(os.urandom(32) if seed is None else seed) + os.environ['CK_TEST_SEED_FILE']=str(path) + return path + def test_auto_identity_generated_once_and_onboarding_persists(self): + seed=os.urandom(32);path=self.fresh_auto(seed) + self.assertEqual(self.run_updater('--import').returncode,0) + state=self.work/'var/lib/ck-state' + self.assertEqual((state/'device.key').read_bytes(),hmac.new(seed,b'commandkit/device/v1',hashlib.sha256).digest()) + diag=hmac.new(seed,b'commandkit/diagnostics/v1',hashlib.sha256).hexdigest().encode()+b'\n' + self.assertEqual((state/'diag.key').read_bytes(),diag) + self.assertNotEqual((state/'device.key').read_bytes(),bytes.fromhex(diag.decode().strip())) + before=self.flash.read_bytes();path.unlink() + self.assertEqual(self.run_updater('--import').returncode,0) + self.assertEqual(before,self.flash.read_bytes()) + self.assertTrue((self.work/'var/run/ck-first-setup').exists()) + self.assertEqual(self.run_updater('--onboarded').returncode,0) + self.assertFalse((self.work/'var/run/ck-first-setup').exists()) + self.assertEqual(self.run_updater('--import').returncode,0) + self.assertFalse((self.work/'var/run/ck-first-setup').exists()) + for name in ('device.key','diag.key','update.pub'):self.assertEqual((state/name).stat().st_mode&0o777,0o600) + def test_auto_identity_two_devices_get_different_keys(self): + keys=[] + for _ in range(2): + self.fresh_auto();self.assertEqual(self.run_updater('--import').returncode,0) + keys.append([(self.work/'var/lib/ck-state'/name).read_bytes() for name in ('device.key','diag.key')]) + self.assertNotEqual(keys[0][0],keys[1][0]);self.assertNotEqual(keys[0][1],keys[1][1]) + def test_auto_identity_entropy_pending_makes_no_flash_or_identity(self): + for seed in (b'',os.urandom(17)): + self.fresh_auto(seed);before=self.flash.read_bytes() + self.assertEqual(self.run_updater('--import').returncode,75) + self.assertEqual(before,self.flash.read_bytes()) + self.assertEqual(list((self.work/'var/lib/ck-state').iterdir()),[]) + def test_auto_identity_wait_accumulates_partial_seed_without_blocking_startup(self): + seed=os.urandom(32);path=self.fresh_auto(seed[:9]) + proc=subprocess.Popen([*self.runner,str(self.updater),'--import-wait'],stdout=subprocess.PIPE,stderr=subprocess.PIPE) + try: + time.sleep(.4);self.assertIsNone(proc.poll()) + self.assertFalse((self.work/'var/lib/ck-state/device.key').exists()) + with path.open('ab') as stream:stream.write(seed[9:]) + output=proc.communicate(timeout=10);self.assertEqual(proc.returncode,0,output) + self.assertEqual((self.work/'var/lib/ck-state/device.key').read_bytes(),hmac.new(seed,b'commandkit/device/v1',hashlib.sha256).digest()) + finally: + if proc.poll() is None:proc.kill();proc.communicate() + def test_auto_identity_interrupted_first_write_and_retire_recover_before_export(self): + for point in ('after-erase','before-commit','retire'): + first=os.urandom(32);path=self.fresh_auto(first) + variable='CK_FAIL_INIT_RETIRE' if point=='retire' else 'CK_FAIL_STORE' + os.environ[variable]=point + self.assertNotEqual(self.run_updater('--import').returncode,0) + self.assertEqual(list((self.work/'var/lib/ck-state').iterdir()),[]) + os.environ.pop(variable);second=os.urandom(32);path.write_bytes(second) + self.assertEqual(self.run_updater('--import').returncode,0) + expected=first if point=='retire' else second + self.assertEqual((self.work/'var/lib/ck-state/device.key').read_bytes(),hmac.new(expected,b'commandkit/device/v1',hashlib.sha256).digest()) + def test_auto_identity_unknown_or_later_corrupt_state_is_never_rekeyed(self): + self.fresh_auto();self.assertEqual(self.run_updater('--import').returncode,0) + changed=bytearray(self.flash.read_bytes());changed[JOURNAL]^=1;self.flash.write_bytes(changed) + self.assertNotEqual(self.run_updater('--import').returncode,0);self.assertEqual(self.flash.read_bytes(),changed) + self.fresh_auto();changed=bytearray(self.flash.read_bytes());changed[JOURNAL+0x10000]^=1;self.flash.write_bytes(changed) + self.assertNotEqual(self.run_updater('--import').returncode,0);self.assertEqual(self.flash.read_bytes(),changed) + def test_auto_policy_keeps_existing_legacy_identity_without_entropy(self): + before=self.flash.read_bytes();state=self.work/'var/lib/ck-state' + original={p.name:p.read_bytes() for p in state.iterdir()} + (self.work/'etc/ck-identity-policy').write_bytes(b'auto-v1\n') + (self.work/'etc/matter-device.key').unlink();(self.work/'etc/matter-diag.key').unlink() + os.environ['CK_TEST_SEED_FILE']=str(self.work/'absent') + self.assertEqual(self.run_updater('--import').returncode,0) + self.assertEqual(before,self.flash.read_bytes());self.assertFalse((self.work/'var/run/ck-first-setup').exists()) + for name,value in original.items():self.assertEqual((state/name).read_bytes(),value) def test_exec_variant_and_profile_recovery(self): self.flash.write_bytes(self.image) (self.work/'etc/ck-update.meta').write_bytes(struct.pack('>III',0x434b0004,2,1)) @@ -182,17 +310,426 @@ def test_exec_variant_and_profile_recovery(self): injected=fwlib.profile_keys(profile,self.flash.read_bytes(),'exec-test',values) self.assertEqual(injected['etc/matter-diag.key'][0],self.diag.hex().encode()+b'\n') self.assertEqual(injected['etc/ck-setup-ap.bin'][0],credential) - def start_server(self): + def start_server(self,login=True): sock=socket.socket();sock.bind(('127.0.0.1',0));self.port=sock.getsockname()[1];sock.close() self.server=subprocess.Popen([*self.runner,str(self.server_binary),str(self.port),'lo'],stdout=subprocess.PIPE,stderr=subprocess.PIPE) for _ in range(50): - try:self.request('GET','/status');return + try: + self.request('GET','/status') + if login:self.login() + return except OSError:time.sleep(.03) raise RuntimeError('server did not start') - def request(self,method,path,body=None,headers=None): - c=http.client.HTTPConnection('127.0.0.1',self.port,timeout=10);c.request(method,path,body=body,headers=headers or {});r=c.getresponse();result=(r.status,r.read());c.close();return result + def login(self,key=None): + status,body=self.request('GET','/auth/challenge',authenticated=False);self.assertEqual(status,200) + nonce=json.loads(body)['nonce'];proof=nonce+':'+hmac.new(key or self.admin_key,('ck-admin-login-v1\n'+nonce).encode(),hashlib.sha256).hexdigest() + c=http.client.HTTPConnection('127.0.0.1',self.port,timeout=10) + c.request('POST','/auth/login',body=b'',headers={'X-CK-Login':proof});r=c.getresponse();data=json.loads(r.read()) + if r.status==200: + self.cookie=r.getheader('Set-Cookie').split(';',1)[0];self.csrf=data['csrf'] + code=r.status;c.close();return code,proof + def request(self,method,path,body=None,headers=None,authenticated=True): + h={} + if authenticated and self.cookie:h={'Cookie':self.cookie,'X-CK-CSRF':self.csrf} + h.update(headers or {}) + c=http.client.HTTPConnection('127.0.0.1',self.port,timeout=10);c.request(method,path,body=body,headers=h);r=c.getresponse();result=(r.status,r.read());c.close();return result def request_headers(self,data,image_hash=None): return {'X-CK-SHA256':image_hash or hashlib.sha256(data).hexdigest(),'Content-Type':'application/octet-stream'} + def protocol_proof(self,mode,key=None): + return {'X-CK-CSRF':self.csrf if key is None else '0'*64,'Content-Type':'text/plain'} + def protocol_reboot(self): + for name in ['ck-protocol-active','ck-protocol-pending']:(self.work/'var/run'/name).unlink(missing_ok=True) + lock=self.work/'var/run/ck-matter-request.lock' + if lock.exists():lock.rmdir() + self.assertEqual(self.run_updater('--protocol-boot').returncode,0) + def test_led_admin_auth_validation_persistence_noop_and_preservation(self): + for key,value in [('identity/factory',b'unique'),('matter/fabric',b'paired'),('config/wifi',b'saved')]:self.assertEqual(self.put(key,value),0) + self.assertEqual(self.lib.ck_energy_save(1234567,0),0) + self.start_server() + self.assertEqual(self.request('GET','/admin/led',authenticated=False)[0],401) + config=b'CKLED1 inverse 1 1380 420 480 pool.ntp.org\n' + self.assertEqual(self.request('POST','/admin/led',config,authenticated=False)[0],401) + self.assertEqual(self.request('POST','/admin/led',config,headers={'X-CK-CSRF':'0'*64})[0],401) + for bad in [b'CKLED1 unknown 0 1380 420 480 pool.ntp.org',b'CKLED1 smart 2 1380 420 480 pool.ntp.org',b'CKLED1 smart 4294967296 1380 420 480 pool.ntp.org',b'CKLED1 smart 1 420 420 480 pool.ntp.org',b'CKLED1 smart 0 1380 420 481 pool.ntp.org',b'CKLED1 smart 0 1380 420 480 evil;cmd',b'CKLED1 smart 0 1380 420 480 server"',b'CKLED1 smart 0 1380 420 480 ntp extra']: + self.assertEqual(self.request('POST','/admin/led',bad)[0],400,bad) + self.assertEqual(self.request('POST','/admin/led',config)[0],200) + before=self.flash.read_bytes();self.assertEqual(self.request('POST','/admin/led',config)[0],200) + self.assertEqual(self.flash.read_bytes(),before) + d=json.loads(self.request('GET','/admin/led')[1]);self.assertEqual((d['mode'],d['schedule'],d['start'],d['end'],d['utc_offset']),('inverse',True,1380,420,480)) + self.assertEqual(self.lib.ck_store_import(),0);self.assertEqual(self.get('config/led'),(0,config)) + for key,value in [('identity/factory',b'unique'),('matter/fabric',b'paired'),('config/wifi',b'saved')]:self.assertEqual(self.get(key),(0,value)) + self.assertEqual(self.energy()[1],1234567) + self.assertEqual(self.lib.ck_store_factory_clear(),0);self.assertEqual(self.get('config/led')[0],1) + + def test_led_modes_priority_patterns_and_cross_midnight(self): + class Config(ctypes.Structure):_fields_=[('mode',ctypes.c_int),('schedule',ctypes.c_int),('start',ctypes.c_int),('end',ctypes.c_int),('offset',ctypes.c_int),('ntp',ctypes.c_char*128)] + class Input(ctypes.Structure):_fields_=[(n,ctypes.c_int) for n in ['known','on','load','setup','joining','pairing','error','identify','time_valid']]+[('utc_s',ctypes.c_uint64),('now_ms',ctypes.c_uint64)] + class Output(ctypes.Structure):_fields_=[('on',ctypes.c_int),('reason',ctypes.c_int),('night',ctypes.c_int)] + self.lib.ck_led_evaluate.argtypes=[ctypes.POINTER(Config),ctypes.POINTER(Input)];self.lib.ck_led_evaluate.restype=Output + c=Config(0,0,1380,420,480,b'pool.ntp.org');i=Input();i.known=i.on=i.load=1;i.now_ms=0 + evaluate=lambda:self.lib.ck_led_evaluate(ctypes.byref(c),ctypes.byref(i)) + for mode,expected in enumerate([1,1,0,1,1,0]):c.mode=mode;self.assertEqual(evaluate().on,expected) + c.mode=0;i.setup=i.joining=i.pairing=i.error=i.identify=1;self.assertEqual(evaluate().reason,6) + i.identify=0;self.assertEqual(evaluate().reason,5);i.error=0;self.assertEqual(evaluate().reason,4) + i.setup=0;self.assertEqual(evaluate().reason,3);i.joining=0;self.assertEqual(evaluate().reason,2) + for reason,field,expected in [(2,'pairing',[1,0,1,0,0]),(5,'error',[1,0,1,0,1])]: + i.pairing=0;setattr(i,field,1) + for t,want in zip([0,100,200,300,400],expected):i.now_ms=t;self.assertEqual(evaluate().on,want) + setattr(i,field,0) + c.schedule=1;i.time_valid=1 + for minute,night in [(22*60+59,0),(23*60,1),(0,1),(6*60+59,1),(7*60,0)]: + i.utc_s=1704067200+((minute-480)%1440)*60;self.assertEqual(evaluate().night,night) + i.utc_s=1704067200;i.time_valid=0;self.assertEqual(evaluate().night,0) + c.mode=5;i.error=1;i.now_ms=0;self.assertEqual((evaluate().reason,evaluate().on),(5,1)) + c.mode=1;self.assertEqual(evaluate().reason,0) + i.known=0;c.mode=2;self.assertEqual(evaluate().on,0) + + def test_led_find_and_phone_time_are_ram_only_and_bounded(self): + self.start_server();before=self.flash.read_bytes() + self.assertEqual(self.request('POST','/admin/led-find')[0],200) + deadline=int.from_bytes((self.work/'var/run/ck-led-find').read_bytes(),'big') + self.assertTrue(14000<=deadline-int(time.monotonic()*1000)<=15000) + for bad in [b'0',b'-1',b'99999999999',b'1704067200oops',b'1704067200 ']:self.assertEqual(self.request('POST','/admin/led-time',bad)[0],400) + self.assertEqual(self.request('POST','/admin/led-time',b'1792512000')[0],200) + b=(self.work/'var/run/ck-led-clock').read_bytes();self.assertEqual(b[:8],b'CKLEDT1\n');self.assertEqual(int.from_bytes(b[16:24],'big'),1792512000) + self.assertEqual(self.flash.read_bytes(),before) + + def test_led_ntp_validates_peer_nonce_stratum_and_keeps_system_clock(self): + udp=socket.socket(socket.AF_INET,socket.SOCK_DGRAM) + try:udp.bind(('127.0.0.1',123)) + except PermissionError:udp.close();self.skipTest('UDP 123 fixture requires a permitted local bind') + udp.settimeout(8);errors=[] + def reply(): + try: + for valid in (False,True): + q,peer=udp.recvfrom(256);self.assertEqual(len(q),48) + r=bytearray(48);r[0]=0x24;r[1]=2 if valid else 0;r[24:32]=q[40:48] + struct.pack_into('>I',r,40,(int(time.time())+2208988800)&0xffffffff);udp.sendto(r,peer) + except Exception as e:errors.append(e) + thread=threading.Thread(target=reply);thread.start();before=self.flash.read_bytes();wall=time.time();mono=time.monotonic() + try: + self.assertNotEqual(self.run_updater('--led-ntp','127.0.0.1').returncode,0) + self.assertFalse((self.work/'var/run/ck-led-clock').exists()) + self.assertEqual(self.run_updater('--led-ntp','127.0.0.1').returncode,0) + clock=(self.work/'var/run/ck-led-clock').read_bytes();self.assertEqual(int.from_bytes(clock[24:28],'big'),1) + self.assertLess(abs(int.from_bytes(clock[16:24],'big')-time.time()),3) + self.assertLess(abs((time.time()-wall)-(time.monotonic()-mono)),1) + self.assertEqual(self.flash.read_bytes(),before) + finally:thread.join(timeout=9);udp.close() + self.assertFalse(thread.is_alive());self.assertEqual(errors,[]) + # No responder: the isolated worker must terminate within its deadline. + begun=time.monotonic();self.assertNotEqual(self.run_updater('--led-ntp','127.0.0.1').returncode,0);self.assertLess(time.monotonic()-begun,13) + + def test_led_worker_relay_load_stale_identify_and_ota_latch(self): + for path in ['proc/wlan0','var/log']:(self.work/path).mkdir(parents=True,exist_ok=True) + gpio=self.work/'proc/wlan0/gpio_ctrl';gpio.write_bytes(b' '*32) + (self.work/'var/run/ck-protocol-active').write_text('homekit');(self.work/'var/run/ck-setup-state').write_text('state=online\n') + self.assertEqual(self.put('config/led',b'CKLED1 relay 1 1380 420 480 ntp.invalid\n'),0) + # A deliberately slow DNS/NTP child must not stop the GPIO/control loop. + (self.work/'bin/ck-updater').write_text('#!/bin/sh\n: >"$CK_TEST_ROOT/var/run/ck-led-ntp-started"\nsleep 4\nexit 1\n') + (self.work/'bin/ck-updater').chmod(0o755) + def sample(seq=1,age=0,power=6000,on=True): + now=int(time.monotonic()*1000) + data=b'CKLEDHW1'+struct.pack('>QQQQIIII',now,now-age,now-age,0,power,1|4|8|16|(2 if on else 0),seq,0) + (self.work/'var/run/ck-led-homekit.bin').write_bytes(data) + def state(): + b=(self.work/'var/run/ck-led-state').read_bytes();return b[16],b[17],b[18],b[19] + def until(check,seconds=4): + end=time.monotonic()+seconds + while time.monotonic()Q',deadline)) + until(lambda:state()[1]==6);until(lambda:state()[1]==0) + (self.work/'var/run/ck-ota/queued').write_bytes(b'1');latched=gpio.read_bytes() + (self.work/'var/run/ck-led-homekit-identify').write_bytes(struct.pack('>Q',int(time.monotonic()*1000)+15000));time.sleep(.7) + self.assertEqual(gpio.read_bytes(),latched);self.assertIsNone(worker.poll()) + finally:worker.terminate();worker.wait(timeout=5);worker.stdout.close();worker.stderr.close() + + def test_protocol_switch_keeps_identity_fabrics_wifi_hap_and_kwh(self): + self.assertEqual(self.run_updater('--protocol-boot').returncode,0) + original={k:self.get(k) for k in ['identity/device','identity/diag','identity/update']} + for key,value in {'matter/fabric':b'paired','platform/chip_kvs':b'paired cache','wifi/fixture':b'saved wifi','config/led':b'CKLED1 off 1 1380 420 480 ntp.invalid\n'}.items(): + self.assertEqual(self.put(key,value),0);original[key]=(0,value) + self.assertEqual(self.lib.ck_energy_save(987654321,123),0) + before=self.flash.read_bytes();self.start_server() + self.assertEqual(json.loads(self.request('GET','/protocol')[1]),{'selected':'matter','active':'matter','restart_pending':False}) + for mode in ['homekit','matter']: + self.assertEqual(self.request('POST','/protocol',mode,self.protocol_proof(mode))[0],200) + self.assertEqual(self.get('config/protocol'),(0,mode.encode())) + state=json.loads(self.request('GET','/protocol')[1]);self.assertTrue(state['restart_pending']) + self.assertNotEqual(state['active'],mode) + # The snapshot is immutable through server restarts and repeated calls. + self.assertEqual(self.run_updater('--protocol-boot').returncode,0) + self.assertEqual(json.loads(self.request('GET','/protocol')[1])['active'],state['active']) + self.assertEqual(self.request('POST','/protocol',mode,self.protocol_proof(mode))[0],409) + self.protocol_reboot() + self.assertEqual(json.loads(self.request('GET','/protocol')[1]),{'selected':mode,'active':mode,'restart_pending':False}) + for key,value in original.items():self.assertEqual(self.get(key),value) + self.assertEqual(self.energy(),(0,987654321,123)) + after=self.flash.read_bytes() + for a,b in [(0,JOURNAL),(0x3cf000,0x3d0000),(0x3df000,0x800000)]:self.assertEqual(before[a:b],after[a:b]) + before=after + self.assertEqual(json.loads(self.request('POST','/protocol','matter',self.protocol_proof('matter'))[1])['status'],'unchanged') + self.assertEqual(before,self.flash.read_bytes()) + def test_protocol_requires_session_csrf_and_same_origin(self): + self.start_server();before=self.flash.read_bytes() + self.assertEqual(self.request('POST','/protocol','homekit',authenticated=False)[0],401) + self.assertEqual(self.request('POST','/protocol','homekit',self.protocol_proof('homekit',b'0'*32))[0],401) + proof=self.protocol_proof('homekit') + self.assertEqual(self.request('POST','/protocol','homekit',{**proof,'Origin':'http://evil.example'})[0],403) + self.assertEqual(self.request('POST','/protocol','homekit\0',proof)[0],400) + self.assertEqual(self.request('POST','/protocol','HOMEKIT',proof)[0],400) + self.assertEqual(before,self.flash.read_bytes()) + self.assertEqual(self.request('POST','/protocol','homekit',proof)[0],200) + self.assertEqual(self.request('POST','/protocol','homekit',proof)[0],409) + def test_protocol_flash_failure_has_no_success_or_pending_handoff(self): + os.environ['CK_FAIL_STORE']='before-commit';self.start_server() + self.assertEqual(self.request('POST','/protocol','homekit',self.protocol_proof('homekit'))[0],503) + self.assertEqual(self.get('config/protocol')[0],1) + self.assertFalse((self.work/'var/run/ck-protocol-pending').exists()) + self.assertFalse((self.work/'var/run/ck-matter-request.lock').exists()) + def test_protocol_invalid_preferences_fail_closed_and_operations_exclude_each_other(self): + self.assertEqual(self.put('config/protocol',b'matter\0'),0) + self.assertEqual(self.run_updater('--protocol-boot').returncode,0) + self.assertEqual((self.work/'var/run/ck-protocol-active').read_text(),'unavailable') + self.start_server() + self.assertEqual(json.loads(self.request('GET','/protocol')[1])['selected'],'unavailable') + operation=self.work/'var/run/ck-matter-request.lock';operation.mkdir() + before=self.flash.read_bytes() + self.assertEqual(self.request('POST','/protocol','homekit',self.protocol_proof('homekit'))[0],409) + self.assertEqual(before,self.flash.read_bytes());operation.rmdir() + self.assertEqual(self.run_updater('--stage',self.ota).returncode,0) + self.assertEqual(self.request('POST','/protocol','homekit',self.protocol_proof('homekit'))[0],200) + self.assertNotEqual(self.run_updater('--queue').returncode,0) + self.assertEqual(self.request('POST','/upload',self.ota.read_bytes(),self.request_headers(self.ota.read_bytes()))[0],409) + def test_protocol_owner_retains_lock_in_forked_homekit_daemon(self): + self.assertEqual(self.put('config/protocol',b'homekit'),0) + (self.work/'dev').mkdir();(self.work/'dev/ttyACM0').touch() + # Saved Wi-Fi without association/DHCP must not block MCU ownership. + (self.work/'var/run/ck-setup-boot').write_text('configured') + (self.work/'var/run/ck-setup-state').write_text('state=reconnecting\nerror=0\n') + hap=self.work/'bin/homekitserver' + hap.write_text('#!/usr/bin/python3\nimport os,time\nfrom pathlib import Path\n' + 'root=Path(os.environ["CK_TEST_ROOT"])\n' + '(root/"hap-launches").open("a").write("homekit\\n")\n' + 'pid=os.fork()\n' + 'if pid:\n' + ' while not (root/"proc"/str(pid)/"status").exists():time.sleep(.01)\n' + ' (root/"var/run/homekitserver.pid").write_text(str(pid))\n os._exit(0)\n' + 'p=root/"proc"/str(os.getpid());p.mkdir(parents=True)\n' + '(p/"status").write_text("Name:\\thomekitserver\\n")\n' + 'while True:time.sleep(1)\n');hap.chmod(0o700) + matter=self.work/'bin/matter-test-start';matter.write_text('#!/bin/sh\necho unexpected > "'+str(self.work/'unexpected-matter')+'"\n');matter.chmod(0o700) + process=subprocess.Popen([*self.runner,str(self.updater),'--run-protocol'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True) + try: + until=time.monotonic()+5 + while not (self.work/'var/run/homekitserver.pid').exists() and time.monotonic() "'+str(self.work/'unexpected-matter')+'"')]: + p=self.work/'bin'/name;p.write_text('#!/bin/sh\n'+body+'\n');p.chmod(0o700) + process=subprocess.Popen([*self.runner,str(self.updater),'--run-protocol'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True) + try: + until=time.monotonic()+3 + while not (self.work/'var/run/ck-rescue').exists() and time.monotonic()I',600000)+os.urandom(16)+new_key + self.assertEqual(self.request('POST','/auth/password',record.hex())[0],200) + self.assertEqual(self.get('admin/credential'),(0,record)) + self.assertEqual(self.request('GET','/admin/state')[0],401) + self.assertEqual(self.login()[0],403) + self.admin_key=new_key;self.assertEqual(self.login()[0],200) + self.assertTrue(json.loads(self.request('GET','/auth/session')[1])['authenticated']) + def test_dashboard_remains_responsive_while_identity_import_holds_flash_lock(self): + self.start_server();lock=(self.work/'var/lock/ck-flash.lock').open('a+') + fcntl.flock(lock,fcntl.LOCK_EX) + try: + at=time.monotonic() + for path in ['/','/status','/measurements','/protocol','/auth/session']: + self.assertEqual(self.request('GET',path)[0],200) + self.assertLess(time.monotonic()-at,2) + self.assertFalse(json.loads(self.request('GET','/auth/session')[1])['storage_ready']) + finally:lock.close() + self.assertTrue(json.loads(self.request('GET','/auth/session')[1])['authenticated']) + def test_password_enrollment_is_local_setup_only_and_cannot_overwrite_account(self): + self.assertEqual(self.lib.ck_store_delete(b'admin/credential'),0) + record=self.admin_record;request=self.work/'var/run/ck-admin-enroll' + request.write_bytes(record) + self.assertNotEqual(self.run_updater('--enroll-admin').returncode,0) + self.assertEqual(self.get('admin/credential')[0],1) + (self.work/'var/run/ck-setup-state').write_text('state=setup\n') + request.write_bytes(record) + self.assertEqual(self.run_updater('--enroll-admin').returncode,0) + self.assertEqual(self.get('admin/credential'),(0,record)) + self.assertTrue((self.work/'var/run/ck-admin-enroll-ok').exists()) + request.write_bytes(record[:-1]+b'X') + self.assertNotEqual(self.run_updater('--enroll-admin').returncode,0) + self.assertEqual(self.get('admin/credential'),(0,record)) + def test_explicit_energy_reset_is_atomic_and_allows_a_new_accumulation(self): + self.assertEqual(self.lib.ck_energy_save(5000000,123),0) + kept=self.get('admin/credential');before=self.flash.read_bytes() + os.environ['CK_FAIL_ENERGY']='before-commit' + self.assertNotEqual(self.lib.ck_energy_reset(),0) + os.environ.pop('CK_FAIL_ENERGY');self.assertEqual(self.energy(),(0,5000000,123)) + self.assertEqual(self.lib.ck_energy_reset(),0);self.assertEqual(self.energy(),(0,0,0)) + self.assertEqual(self.lib.ck_energy_save(17,19),0);self.assertEqual(self.energy(),(0,17,19)) + self.assertEqual(self.get('admin/credential'),kept) + after=self.flash.read_bytes() + for a,b in [(0,0x3cf000),(0x3d0000,0x3df000),(0x3e0000,0x800000)]:self.assertEqual(before[a:b],after[a:b]) + (self.work/'var/run/ck-protocol-active').write_text('homekit');self.start_server() + self.assertEqual(self.request('POST','/admin/energy-reset','')[0],200) + self.assertEqual(self.energy(),(0,0,0)) + saved=json.loads(self.request('GET','/measurements')[1]);self.assertFalse(saved['available']);self.assertEqual(saved['saved_energy_mwh'],'0') + def test_matter_energy_reset_queues_to_owner_without_stale_writer_race(self): + self.assertEqual(self.lib.ck_energy_save(1234567,0),0) + (self.work/'var/run/ck-protocol-active').write_text('matter');self.start_server() + self.assertEqual(self.request('POST','/admin/energy-reset','')[0],503) + stamp=time.monotonic_ns()//1000000000 + (self.work/'var/run/ck-matter-state.json').write_text(json.dumps({'updated_s':stamp,'fabrics':1},separators=(',',':'))) + code,body=self.request('POST','/admin/energy-reset','');self.assertEqual(code,202) + request=json.loads(body)['request_id'] + self.assertEqual((self.work/'var/run/ck-matter-request').read_text(),'E '+request+'\n') + self.assertEqual(self.request('POST','/protocol','homekit')[0],409) + self.assertEqual(self.energy(),(0,1234567,0)) + def test_factory_clear_preserves_only_hardware_identity_and_ota_trust(self): + kept={k:self.get(k) for k in ['identity/device','identity/diag','identity/update']} + self.assertEqual(self.put('ota/floor',b'\0\0\0\x0b'),0);kept['ota/floor']=(0,b'\0\0\0\x0b') + for k in ['identity/onboarded','matter/fabric','platform/chip_kvs','wifi/fixture','config/protocol']: + self.assertEqual(self.put(k,b'old setting'),0) + self.assertEqual(self.run_updater('--factory-reset').returncode,0) + os.environ['CK_FAIL_STORE']='before-commit' + self.assertNotEqual(self.lib.ck_store_factory_clear(),0) + os.environ.pop('CK_FAIL_STORE');self.assertEqual(self.get('matter/fabric'),(0,b'old setting')) + self.assertEqual(self.get('control/factory-reset'),(0,b'CKRESET1')) + self.assertEqual(self.lib.ck_store_factory_clear(),0) + for k in ['admin/credential','identity/onboarded','matter/fabric','platform/chip_kvs','wifi/fixture','config/protocol','control/factory-reset']: + self.assertEqual(self.get(k)[0],1,k) + for k,v in kept.items():self.assertEqual(self.get(k),v) + self.assertEqual(self.get('config/needs-setup'),(0,b'\x01')) + def test_newer_programmer_recovery_adopts_pending_version_without_reset(self): + self.assertEqual(self.put('ota/floor',struct.pack('>I',6)),0) + self.assertEqual(self.put('ota/pending',struct.pack('>I',8)),0) + self.assertEqual(self.put('ota/attempts',struct.pack('>I',3)),0) + self.assertEqual(self.put('matter/fabric',b'keep paired'),0) + self.assertEqual(self.lib.ck_energy_save(7654321,123),0) + kept={k:self.get(k) for k in ['identity/device','identity/diag','identity/update','admin/credential','matter/fabric']} + (self.work/'etc/ck-update.meta').write_bytes(struct.pack('>III',0x434b0004,1,12)) + before=self.flash.read_bytes() + self.assertEqual(self.run_updater('--boot').returncode,0) + self.assertEqual(self.get('ota/pending'),(0,struct.pack('>I',12))) + self.assertEqual(self.get('ota/attempts'),(0,struct.pack('>I',1))) + self.assertEqual(self.get('ota/floor'),(0,struct.pack('>I',6))) + for k,v in kept.items():self.assertEqual(self.get(k),v) + self.assertEqual(self.energy(),(0,7654321,123)) + self.assertEqual(self.flash.read_bytes()[0x3e0000:],before[0x3e0000:]) + self.assertEqual(self.run_updater('--confirm').returncode,0) + self.assertEqual(self.get('ota/floor'),(0,struct.pack('>I',12))) + self.assertEqual(self.get('ota/pending')[0],1) + def test_recovery_adoption_commit_failure_retries_without_erasing_configuration(self): + self.assertEqual(self.put('ota/pending',struct.pack('>I',8)),0) + self.assertEqual(self.put('matter/fabric',b'keep paired'),0) + (self.work/'etc/ck-update.meta').write_bytes(struct.pack('>III',0x434b0004,1,12)) + os.environ['CK_FAIL_STORE']='before-commit' + self.assertNotEqual(self.run_updater('--boot').returncode,0) + os.environ.pop('CK_FAIL_STORE') + self.assertEqual(self.get('ota/pending'),(0,struct.pack('>I',8))) + self.assertEqual(self.get('matter/fabric'),(0,b'keep paired')) + self.assertEqual(self.run_updater('--boot').returncode,0) + self.assertEqual(self.get('ota/pending'),(0,struct.pack('>I',12))) + self.assertEqual(self.get('ota/attempts'),(0,struct.pack('>I',1))) + self.assertEqual(self.get('matter/fabric'),(0,b'keep paired')) + def test_factory_boot_resumes_after_wifi_or_final_commit_failure(self): + kept=self.get('identity/device');self.assertEqual(self.put('matter/fabric',b'paired'),0) + self.assertEqual(self.lib.ck_energy_save(9000000,12),0) + hap=self.work/'bin/homekitserver' + hap.write_text('#!/usr/bin/python3\nimport os\nwith open(os.environ["CK_FLASH_FILE"],"r+b") as f:\n f.seek(0x3e0000);f.write(b"PHRX"+bytes(0xa5d-4))\n') + hap.chmod(0o700) + fail=self.work/'wifi-fail';fail.touch() + wifi=self.work/'bin/ck-wificfg' + wifi.write_text('#!/bin/sh\ntest "$1" = unconfigure || exit 2\n[ ! -f "'+str(fail)+'" ] || exit 1\nprintf cleared > "'+str(self.work/'wifi-state')+'"\n') + wifi.chmod(0o700) + self.assertEqual(self.run_updater('--factory-reset').returncode,0) + self.assertNotEqual(self.run_updater('--protocol-boot').returncode,0) + self.assertEqual(self.energy(),(0,9000000,12));self.assertEqual(self.get('control/factory-reset'),(0,b'CKRESET1')) + self.assertEqual(self.get('matter/fabric'),(0,b'paired')) + fail.unlink();os.environ['CK_FAIL_STORE']='before-commit' + self.assertNotEqual(self.run_updater('--protocol-boot').returncode,0) + os.environ.pop('CK_FAIL_STORE');self.assertEqual(self.energy(),(0,0,0)) + self.assertEqual(self.get('admin/credential'),(0,self.admin_record)) + self.assertEqual(self.get('control/factory-reset'),(0,b'CKRESET1')) + self.assertEqual(self.run_updater('--protocol-boot').returncode,0) + for k in ['admin/credential','control/factory-reset','matter/fabric']:self.assertEqual(self.get(k)[0],1) + self.assertEqual(self.get('identity/device'),kept) + self.assertEqual((self.work/'var/run/ck-protocol-active').read_text(),'matter') + after=self.flash.read_bytes() + self.assertEqual(after[0x3e0000:0x3e0a5d],b'PHRX'+bytes(0xa5d-4)) + self.assertEqual(after[0x3e0a5d:],self.image[0x3e0a5d:]) + def test_wifi_change_excludes_protocol_reset_and_ota_until_completion(self): + self.start_server();request=b'CKW1'+bytes([7,8,1,0])+b'Fixture'+bytes(25)+b'password'+bytes(56) + self.assertEqual(len(request),104) + self.assertEqual(self.request('POST','/admin/wifi',request.hex())[0],202) + marker=self.work/'var/run/ck-network-changing';self.assertTrue(marker.exists()) + # setupd has consumed the mailbox but is still joining/committing. + (self.work/'var/run/ck-admin-wifi').unlink() + self.assertEqual(self.request('POST','/protocol','homekit')[0],409) + self.assertEqual(self.request('POST','/admin/factory-reset','')[0],409) + self.assertNotEqual(self.run_updater('--stage',self.ota).returncode,0) + self.assertEqual(self.request('GET','/status')[0],200) + marker.unlink();self.assertEqual(self.run_updater('--stage',self.ota).returncode,0) + operation=self.work/'var/run/ck-matter-request.lock';operation.mkdir() + self.assertNotEqual(self.run_updater('--queue').returncode,0) + self.assertNotEqual(self.run_updater('--factory-reset').returncode,0) + operation.rmdir() + self.assertEqual(self.run_updater('--queue').returncode,0) def meter_snapshot(self,**overrides): now=time.monotonic_ns()//1000000 s=dict(published=now,flags=63,voltage=122313,current=12,power=0, @@ -293,7 +830,7 @@ def test_http_partial_and_digest_mismatch_leave_no_staged_image(self): self.assertEqual(self.request('POST','/upload',bytes(changed),headers)[0],422) self.assertFalse((self.work/'var/run/ck-ota/image.ota').exists()) headers=self.request_headers(data);c=socket.create_connection(('127.0.0.1',self.port)) - request=f'POST /upload HTTP/1.1\r\nHost: 127.0.0.1:{self.port}\r\nContent-Length: {len(data)}\r\nX-CK-SHA256: {headers["X-CK-SHA256"]}\r\n\r\n'.encode()+data[:300] + request=f'POST /upload HTTP/1.1\r\nHost: 127.0.0.1:{self.port}\r\nContent-Length: {len(data)}\r\nX-CK-SHA256: {headers["X-CK-SHA256"]}\r\nCookie: {self.cookie}\r\nX-CK-CSRF: {self.csrf}\r\n\r\n'.encode()+data[:300] c.sendall(request);c.shutdown(socket.SHUT_WR);c.recv(4096);c.close() self.assertFalse((self.work/'var/run/ck-ota/image.ota').exists());self.assertFalse((self.work/'var/run/ck-ota/upload.ota').exists()) diff --git a/tools/admin_session.py b/tools/admin_session.py new file mode 100644 index 0000000..59f1e99 --- /dev/null +++ b/tools/admin_session.py @@ -0,0 +1,32 @@ +"""Password login shared by local maintenance clients. No credential files.""" +import hashlib +import hmac +import http.cookiejar +import json +import re +import urllib.request + +class AdminSession: + def __init__(self,base): + self.base=base;self.csrf='' + self.opener=urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())) + + def login(self,password): + with self.opener.open(self.base+'/auth/challenge',timeout=10) as r:challenge=json.load(r) + nonce=challenge.get('nonce','');salt=challenge.get('salt','') + if not re.fullmatch('[0-9a-f]{64}',nonce) or not re.fullmatch('[0-9a-f]{32}',salt) or challenge.get('iterations')!=600000: + raise ValueError('裝置的密碼驗證格式不符') + key=hashlib.pbkdf2_hmac('sha256',password.encode(),bytes.fromhex(salt),600000,32) + proof=nonce+':'+hmac.new(key,('ck-admin-login-v1\n'+nonce).encode(),hashlib.sha256).hexdigest() + request=urllib.request.Request(self.base+'/auth/login',data=b'',method='POST',headers={'X-CK-Login':proof}) + with self.opener.open(request,timeout=15) as r:data=json.load(r) + if not data.get('authenticated') or not re.fullmatch('[0-9a-f]{64}',data.get('csrf','')): + raise ValueError('管理員登入失敗') + self.csrf=data['csrf'] + + def post(self,path,data,headers=None): + request=urllib.request.Request(self.base+path,data=data,method='POST',headers={**(headers or {}),'X-CK-CSRF':self.csrf}) + with self.opener.open(request,timeout=120) as r:return json.load(r) + + def get(self,path): + with self.opener.open(self.base+path,timeout=15) as r:return json.load(r) diff --git a/tools/build.py b/tools/build.py index 180ea4a..3a5a72a 100644 --- a/tools/build.py +++ b/tools/build.py @@ -113,8 +113,15 @@ def add(dest, data, mode=0o755): ("matter-test-start", "setup/matter-relay-start"), ("ck-setup-start", "setup/ck-setup-device-start"), ("ck-setup-backend", "setup/ck-setup-device-backend"), + ("ck-onboarding-start", "setup/ck-onboarding-start"), + ("ck-protocol-start", "setup/ck-protocol-start"), + ("ck-management-start", "setup/ck-management-start"), + ("ck-led-start", "setup/ck-led-start"), + ("ck-reset", "setup/ck-reset"), + ("ck-identity-start", "update/ck-identity-start"), ("ck-udhcpc", "setup/ck-udhcpc-fixed")): add("bin/" + dest, (source / filename).read_bytes()) + add("bin/ck-boot-guard", (REPO / "firmware/matter/artifacts/update/ck-boot-guard").read_bytes()) else: rc = rc.replace("\n/bin/matter-diag-start &", "").replace("\n/bin/matter-test-start &", "") rc += "\n/bin/exec-diag-start &\n/bin/exec-test-start &\n" @@ -138,25 +145,59 @@ def add(dest, data, mode=0o755): add("etc/ck-update.meta", struct.pack(">III", 0x434B0004, 1 if variant=="matter" else 2, version["software_version"]), 0o444) add("etc/ck-version", (version["version"] + "\n").encode(), 0o444) add("etc/ck-variant", (variant+"\n").encode(), 0o444) + if variant == "matter": + add("etc/ck-identity-policy", b"auto-v1\n", 0o444) + add("etc/ck-admin-policy", b"password-v1\n",0o444) inittab = read_file(vendor_root, "etc/inittab").decode() require("::restart:" not in inittab, "已有 PID1 restart action,拒絕覆寫") + if variant == "matter": + for script in ("ck-setup-start", "matter-diag-start", "matter-test-start"): + rc = rc.replace("/bin/" + script + " &", "# Supervised by PID1: " + script) + for script in ("ck-identity-start", "ck-onboarding-start", "ck-setup-start", "matter-diag-start", "ck-protocol-start", "ck-management-start", "ck-led-start"): + inittab += "\n::respawn:/bin/" + script add("etc/inittab", (inittab + "\n::restart:/var/run/ck-ota/apply --pid1\n").encode(), 0o644) # Import journal before diagnostics, identity generation, or Matter startup. rc = rc.replace("# Start system script", "/bin/ck-ota-start\n\n# Start system script", 1) require(rc.count("/bin/ck-ota-start\n") == 1, "OTA startup injection 不符") + # Replace the unused router GUI with our setup/update services. Keep all + # Wi-Fi, MFi and HAP dependencies and the original journal boundaries. + unused = set() + if variant == "matter": + unused = {p for p in file_inventory(vendor_root) if p == "bin/boa" or p == "web" or p.startswith("web/")} + rc = rc.replace("\nboa\n", "\n# Management: ck-setupd / ck-updated\n").replace("\ncd /web\n", "\n") + hap = read_file(vendor_root,"bin/homekitserver") + from homekit_led import build_payload,patch + payload,symbols=build_payload(binaries/"homekit-led") + hap,led_patch=patch(hap,payload,symbols) + add("etc/ck-homekit-led.json",(json.dumps(led_patch,separators=(",",":"))+"\n").encode(),0o444) + # The stock reset also changes a MIB MAC/accessory identifier. A long + # press now requests the shared durable factory reset. The CLI reset + # below clears pairing without changing the factory MAC/identity. + original_size = len(hap) + for command in (b"flash default-sw\0",b"homekitserver -c\0"): + require(hap.count(command) == 1,"HomeKit long-press reset command mismatch") + action=b"/bin/ck-reset" if command.startswith(b"flash") else b"true" + hap = hap.replace(command, action.ljust(len(command)-1,b" ")+b"\0") + # CLI -c runs only before protocols start, under a durable reset intent. + # Disable HMInitFlash's MIB/MAC randomization, retaining factory identity. + require(hap[0x1b8e4:0x1b8e8] == bytes.fromhex("24040001"),"HomeKit reset argument mismatch") + hap=hap[:0x1b8e4]+bytes.fromhex("24040000")+hap[0x1b8e8:] + hap = hap.replace(b"/home/" + b"al" + b"fred/", b"/home/vendor/") + require(len(hap) == original_size,"HomeKit patch changed ELF size") + add("bin/homekitserver",hap) additions["etc/init.d/rcS"] = (rc.encode(), 0o755) add("etc/ck-udhcpd.conf", (source / "setup/ck-udhcpd.conf").read_bytes(), 0o644) # Equal-length debug-path substitutions preserve ELF layout and addresses. for name, line in file_inventory(vendor_root).items(): fields = line.split() - if len(fields) > 3 and fields[1] == "R" and name not in additions and name != "bin/upgfw": + if len(fields) > 3 and fields[1] == "R" and name not in additions and name not in unused and name != "bin/upgfw": data = read_file(vendor_root, name) clean = data.replace(b"/home/" + b"al" + b"fred/", b"/home/vendor/") if clean != data: require(len(clean) == len(data), "ELF 字串替换改變長度") add(name, clean, int(fields[3], 8)) stale = {"bin/matterd-test", "bin/matter-diag", "bin/matter-test-start", "bin/matter-diag-start"} if variant=="exec-test" else set() - root = repack(vendor_root, additions, remove=SECRET_FILES | {"bin/upgfw"} | stale) + root = repack(vendor_root, additions, remove=SECRET_FILES | {"bin/upgfw"} | stale | unused) image = b"\xff" * START + root + b"\xff" * (SIZE - END) atomic_write(output, image, mode=0o644) write_json(output.with_suffix(".json"), {"format": "commandkit-region-v1", "variant": variant, diff --git a/tools/check_public.py b/tools/check_public.py index 12e3611..27d73cf 100644 --- a/tools/check_public.py +++ b/tools/check_public.py @@ -18,6 +18,7 @@ def public_paths(repository): return sorted(p for p in repository.rglob("*") if (p.is_file() or p.is_symlink()) and not EXCLUDED & set(p.relative_to(repository).parts) and + not (p.name.startswith('commandkit-management') and p.suffix == '.json') and p.name != ".DS_Store" and not p.name.endswith(".pyc")) @@ -37,6 +38,11 @@ def audit(repository, release): require(not PERSONAL.search(data), "仍有可識別路径/歷史裝置名稱:" + rel) require(path.suffix not in (".key", ".pseudo", ".cpio", ".sqsh"), "公開包含私人格式:" + rel) + if path.suffix == '.json': + try: record = json.loads(data) + except (ValueError, UnicodeDecodeError): record = None + require(not isinstance(record,dict) or record.get('format') != 'commandkit-management-v1', + '公開包含管理金鑰:' + rel) if path.suffix == ".md": text = data.decode() for link in re.findall(r"(?4sIII', kernel) + require(signature == b'cr6c' and entry == 0x80500000 and mark == CANONICAL_MARK and + n == 0xfd002 and sum16(kernel[16:16+n]) == 0, 'kernel header/checksum 不符') + # A full-span kernel write replaces old kernel padding deterministically. + # The embedded loader and original compressed kernel bytes stay intact. + struct.pack_into('>II', kernel, 8, CANONICAL_MARK, KERNEL_SIZE - 16) + kernel[-2:] = b'\0\0' + struct.pack_into('>H', kernel, KERNEL_SIZE - 2, (-sum16(kernel[16:])) & 65535) + require(sha(kernel) == hashes['FACTORY'], '首次安裝 kernel 不符') + # +8 is a partition-relative seek in the upload container. Boa replaces it + # with the new boot mark after seeking, before handing the buffer to fwd. + struct.pack_into('>I', kernel, 8, 0x10000) + return bytes(kernel) + + +def root_image(root, public=False): + require(len(root) == BUDGET, 'rootfs budget 不符') + checked = rootfs_check(root) + require(checked['checksum_extent'] <= BUDGET - 2, 'rootfs 沒有外層 checksum 空間') + payload = bytearray(root + journal_initializer(public)) + # Keep the entire new persistent journal erased. Put the independent Boa + # checksum in unused filesystem padding, outside SquashFS/boot checksum. + payload[BUDGET-2:BUDGET] = b'\0\0' + struct.pack_into('>H', payload, BUDGET-2, (-sum16(payload)) & 65535) + require(sum16(payload) == 0 and payload[BUDGET:] == journal_initializer(public), + '首次安裝 journal/checksum 不符') + rootfs_check(payload) + return struct.pack('>4sIII', b'r6cr', 0, 0, len(payload)) + payload + + +def inspect(data): + require(len(data) == KERNEL_SIZE + 16 + PART_SIZE + 16, '首次安裝檔案大小不符') + require(data[:4] == b'cr6c' and struct.unpack_from('>III', data, 4) == + (0x80500000, 0x10000, KERNEL_SIZE-16), 'kernel 上傳 header 不符') + require(sum16(data[16:KERNEL_SIZE]) == 0, 'kernel checksum 不符') + canonical = bytearray(data[:KERNEL_SIZE]) + struct.pack_into('>I', canonical, 8, CANONICAL_MARK) + require(sha(canonical) == identities()['FACTORY'], 'kernel 內容不符') + signature, _, burn, n = struct.unpack_from('>4sIII', data, KERNEL_SIZE) + require(signature == b'r6cr' and burn == 0 and n == PART_SIZE, 'rootfs 上傳 header 不符') + payload = data[KERNEL_SIZE+16:KERNEL_SIZE+16+n] + require(sum16(payload) == 0, 'rootfs 上傳 checksum 不符') + rootfs_check(payload) + require(payload[BUDGET:] in (journal_initializer(False), journal_initializer(True)), + '首次安裝 journal 不符') + require(data[-16:] == b'dual' + bytes(12), 'dual trailer 不符') + return payload[:BUDGET] + + +def prepare(reference, release, manifest, profile, output, public=False, factory_template=None): + output = Path(output) + require(not output.exists() and not output.with_suffix('.json').exists(), '輸出已存在') + root, meta = load_release(release, manifest) + require(meta['variant'] == 'matter', '首次安裝只支援 Matter 版') + version = struct.unpack('>III', read_file(root, 'etc/ck-update.meta'))[2] + require(version >= 6, '首次安裝需使用含 factory OTA 相容性支援的 v0.2.4 以上') + require((reference is None) != (factory_template is None), '請指定一種 kernel 來源') + if factory_template is not None: + require(meta['compatibility'] == json.loads((ROOT/'third_party/platform.json').read_text()), + '公開模板的平台不相容') + template = Path(factory_template).read_bytes() + inspect(template) + kernel = template[:KERNEL_SIZE] + else: + kernel = factory_kernel(Path(reference).read_bytes(), meta['compatibility']) + if public: + require(profile is None and version >= 7, '公開版需 v0.2.5 以上且不指定 profile') + require(read_file(root,'etc/ck-identity-policy') == b'auto-v1\n', '映像未支援自動產生身分') + data = kernel + root_image(root, True) + b'dual' + bytes(12) + packed = inspect(data) + require(not {'etc/matter-device.key','etc/matter-diag.key','etc/ck-setup-ap.bin'} & + set(file_inventory(packed)), '公開首次安裝檔不可含裝置金鑰') + else: + require(profile is not None, '私人安裝需指定 profile') + with profile_lock(profile) as folder: + require(set(p.name for p in folder.iterdir()) == {'.lock'}, '首次安裝請使用新的專用 profile') + diag = secrets.token_hex(32).encode() + b'\n' + device = secrets.token_bytes(32) + atomic_write(folder/'diag.key', diag) + atomic_write(folder/'device-identity.key', device) + write_json(folder/'factory-profile.json', { + 'format': 'commandkit-factory-profile-v1', 'installation_id': secrets.token_hex(16), + 'model': 'CMNDKT-004', 'version': meta['release'], + 'single_outlet_only': True, 'hardware_binding': 'not_read_from_target'}) + personalized = repack(root, {'etc/matter-diag.key': (diag, 0o600), + 'etc/matter-device.key': (device, 0o600)}) + data = kernel + root_image(personalized) + b'dual' + bytes(12) + packed_root = inspect(data) + require(read_file(packed_root, 'etc/matter-diag.key') == diag and + read_file(packed_root, 'etc/matter-device.key') == device, + '私人身分重打包不一致') + require(len(data) < 0x400000, '上傳檔案超過 4 MiB') + atomic_write(output, data, mode=0o644 if public else 0o600) + write_json(output.with_suffix('.json'), { + 'format': 'commandkit-boa-first-install-v1', 'model': 'CMNDKT-004', + 'release': meta['release'], 'software_version': version, + 'size': len(data), 'sha256': sha(data), + 'upload_endpoint': '/boafrm/formUpload', 'file_field': 'binary', + 'required_stock_version': '10219', 'required_active_bank': 2, + 'required_dual_firmware': True, 'target_bank': 1, + 'write_ranges': [[0x10000, START], [START, 0x3e0000]], + 'journal_initialized': [END, 0x3e0000], + 'identity_initialization': 'first_boot_credited_randomness' if public else 'private_bootstrap', + 'preserved_ranges': [[0, 0x10000], [0x3e0000, SIZE]], + 'private_single_outlet_image': not public, + 'device_credentials_included': not public, + 'rootfs_source_sha256': meta['sha256'], + 'physical_factory_install_test': 'not_performed', + 'target_compatibility_checked_online': False}) + return output + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + sources = p.add_mutually_exclusive_group(required=True) + sources.add_argument('--reference', type=Path, help='已驗證的本機平台參考映像') + sources.add_argument('--factory-template', type=Path, help='已驗證的公開首次安裝 BIN,只取 kernel') + p.add_argument('--release', type=Path, required=True) + p.add_argument('--manifest', type=Path, required=True) + group = p.add_mutually_exclusive_group(required=True) + group.add_argument('--profile', type=Path) + group.add_argument('--public', action='store_true', help='公開共用;首次開機自行產生身分') + p.add_argument('--output', type=Path, required=True) + a = p.parse_args() + path = prepare(a.reference, a.release, a.manifest, a.profile, a.output, a.public, a.factory_template) + print(('公開' if a.public else '私人') + '首次安裝檔已產生;未連線或寫入插座:' + str(path)) + + +if __name__ == '__main__': + main() diff --git a/tools/fwlib.py b/tools/fwlib.py index ae78be7..110d2e4 100644 --- a/tools/fwlib.py +++ b/tools/fwlib.py @@ -65,6 +65,23 @@ def write_json(path, value, overwrite=False): overwrite=overwrite) +def profile_diag_key(profile): + """Accept a legacy profile directory/key or the setup page's private JSON.""" + path = Path(profile) + if path.is_dir(): + path = path / 'diag.key' + require(path.is_file() and path.stat().st_size <= 4096, '管理金鑰檔案不符') + raw = path.read_bytes() + if path.suffix == '.json': + record = json.loads(raw) + require(isinstance(record, dict) and record.get('format') == 'commandkit-management-v1' and + isinstance(record.get('diag_key'), str) and + re.fullmatch(r'[0-9a-f]{64}', record['diag_key']), '管理金鑰 JSON 格式不符') + return bytes.fromhex(record['diag_key']) + require(re.fullmatch(b'[0-9a-f]{64}\n',raw), '管理金鑰格式不符') + return bytes.fromhex(raw.decode().strip()) + + def rootfs_check(data, budget=BUDGET): require(len(data) >= 96 and data[:4] == b"hsqs", "rootfs magic 不符") extent = struct.unpack_from(">I", data, 8)[0] + 0x282 @@ -94,7 +111,23 @@ def compatibility(image): def compatible(image, expected): require(len(image) == SIZE, "備份大小不是 8 MiB") - require(compatibility(image) == expected, "kernel/Bank 2 不相容;拒絕寫入") + actual = compatibility(image) + matches = actual == expected + if not matches and actual["bank2_executable_sha256"] == expected.get("bank2_executable_sha256"): + # Match the same strict alternative accepted by ck_ota.c. Boa's + # first-install container wraps the verified kernel in a full-span + # length/checksum; only its mutable bank mark is canonicalized. + text = (Path(__file__).resolve().parents[1] / + "firmware/matter/src/update/ck_factory.h").read_text() + hashes = dict(re.findall(r'#define CK_(LEGACY|FACTORY)_KERNEL_SHA256 "([0-9a-f]{64})"', text)) + kernel = bytearray(image[0x10000:START]) + magic, entry, mark, length = struct.unpack_from(">4sIII", kernel) + if (expected.get("bank1_kernel_sha256") == hashes.get("LEGACY") and + magic == b"cr6c" and entry == 0x80500000 and + mark >= 0x80000002 and length == 0x11fff0): + struct.pack_into(">I", kernel, 8, 0x8000004d) + matches = sha(kernel) == hashes.get("FACTORY") + require(matches, "kernel/Bank 2 不相容;拒絕寫入") image_checks(image) diff --git a/tools/generate_update.py b/tools/generate_update.py index 5e43b33..4c31b14 100644 --- a/tools/generate_update.py +++ b/tools/generate_update.py @@ -14,6 +14,8 @@ def main(): if not re.fullmatch(r'[A-Za-z0-9.-]{1,31}',label):raise ValueError('version') if not config['catalog_url'].startswith('https://'):raise ValueError('catalog_url') source=ROOT/'firmware/matter/src' + password=(source/'update/password.js').read_text() + write_if_changed(source/'update/password_script.h','/* Generated public password worker. */\nstatic const char ck_password_script[] = '+json.dumps(password,ensure_ascii=False)+';\n') write_if_changed(source/'matter-app-device/Version.h','#pragma once\n#define CK_SOFTWARE_VERSION '+str(version)+'\n#define CK_VERSION_STRING '+json.dumps(label)+'\n') page=(source/'update/update.html').read_text().replace('{{CATALOG_URL}}',json.dumps(config['catalog_url'])).replace('{{SCRIPT}}',(source/'update/update.js').read_text()) write_if_changed(source/'update/update_page.h','/* Generated by tools/generate_update.py. */\nstatic const char ck_update_page[] = '+json.dumps(page,ensure_ascii=False)+';\n') diff --git a/tools/homekit_led.py b/tools/homekit_led.py new file mode 100644 index 0000000..5d3511e --- /dev/null +++ b/tools/homekit_led.py @@ -0,0 +1,59 @@ +"""Reproducible, hash-bound 10219 LED observer patch. Never accesses an outlet.""" +import hashlib,json,struct,subprocess +from pathlib import Path +ROOT=Path(__file__).resolve().parents[1] +ORIGINAL_SHA='2d50339436759f3d602c150fb4f1a42f0fdf122fc18e9a4f54f6e6b07d7a0c62' +VADDR=0x630000 +# Every site is an original read() call, with its delay slot preserved. +READS={0x43335c:'ck_hap_read_relay',0x433cd0:'ck_hap_read_power',0x434528:'ck_hap_read_relay'} +def sha(b):return hashlib.sha256(b).hexdigest() +def build_payload(dest,test=False): + from build import llvm_tool,FLAGS + dest=Path(dest);dest.mkdir(parents=True,exist_ok=True);src=ROOT/'firmware/homekit-led/src' + objects=[] + for name,path in [('hook',src/'hook.c'),('bridge',src/'bridge.S'),('syscall',ROOT/'firmware/matter/src/linux_o32.S')]: + obj=dest/(name+'.o');subprocess.run([llvm_tool('clang'),*FLAGS,*( ['-DCK_LED_HOOK_TEST'] if test else []),'-c',str(path),'-o',str(obj)],check=True);objects.append(obj) + elf=dest/'hook.elf' + subprocess.run([llvm_tool('ld.lld'),'-m','elf32btsmip','--gc-sections',*[v for name in ['ck_hap_read_relay','ck_hap_read_power','ck_hap_identify','ck_hap_identify_start']+(['ck_hap_test_entry'] if test else []) for v in ['-u',name]],'--no-undefined','--build-id=none','-T',str(src/'payload.ld'),*map(str,objects),'-o',str(elf)],check=True) + symbols={} + for line in subprocess.check_output([llvm_tool('llvm-nm'),'-n',str(elf)],text=True).splitlines(): + v=line.split() + if len(v)==3:symbols[v[2]]=int(v[0],16) + raw=dest/'hook.bin';subprocess.run([llvm_tool('llvm-objcopy'),'-O','binary',str(elf),str(raw)],check=True) + asm=subprocess.check_output([llvm_tool('llvm-objdump'),'-d',str(elf)],text=True) + import re + # Probe-only assembly sections are discarded by the linker below or must + # be excluded: this payload never calls LL/SC/TLS CPU probes. + if re.search(r'\s(?:ll|sc|sync|rdhwr|lwl|lwr|swl|swr|lwc1|swc1|ldc1|sdc1|pref)\s',asm):raise ValueError('unsupported hook ISA') + (dest/'symbols.json').write_text(json.dumps(symbols,indent=2)+'\n') + return raw.read_bytes(),symbols + +def patch(original,payload,symbols,test=False): + if sha(original)!=ORIGINAL_SHA:raise ValueError('HomeKit LED: original 10219 hash mismatch') + b=bytearray(original);edits=[] + def edit(off,old,new): + if b[off:off+len(old)]!=old:raise ValueError('HomeKit LED patch site mismatch: '+hex(off)) + b[off:off+len(old)]=new;edits.append({'offset':off,'old':old.hex(),'new':new.hex()}) + def jump(addr,target,old,link=False):edit(addr-0x400000,old,struct.pack('>I',(0x0c000000 if link else 0x08000000)|(target>>2))) + for addr,name in READS.items():jump(addr,symbols[name],bytes.fromhex('0320f809'),True) + # Identify_LEDSetup is also called during GPIO initialization. All LED + # levels belong to ck-led; suppress this legacy low-level entry entirely. + edit(0x432d24-0x400000,original[0x32d24:0x32d30],bytes.fromhex('03e000080000102100000000')) + for addr,name in [(0x432ea4,'ck_hap_identify'),(0x43247c,'ck_hap_identify_start')]: + jump(addr,symbols[name],original[addr-0x400000:addr-0x400000+4]);edit(addr-0x400000+4,original[addr-0x400000+4:addr-0x400000+8],b'\0'*4) + edit(0x33048,bytes.fromhex('0320f80924060006'),b'\0'*8) # only GPIO6 configuration; retain GPIO0/5 + # Reuse the reserved PT_NULL slot, keeping the mapped PHDR table and all + # original load segments/addresses unchanged. BSS comes from p_memsz. + ph=52+7*32;old=bytes(b[ph:ph+32]);assert struct.unpack('>8I',old)==(0,0,0,0,0,0,0,4) + at=(len(b)+65535)&~65535;end=symbols['__payload_end'];assert VADDR+len(payload)<=end8I',1,at,VADDR,VADDR,len(payload),end-VADDR,7,65536)) + if test:edit(24,original[24:28],struct.pack('>I',symbols['ck_hap_test_entry'])) + b.extend(b'\0'*(at-len(b)));b.extend(payload) + record={'original_sha256':sha(original),'original_size':len(original),'payload_offset':at,'payload_size':len(payload),'payload_memsize':end-VADDR,'edits':edits,'patched_sha256':sha(b)} + return bytes(b),record + +def restore(patched,record): + b=bytearray(patched) + for e in record['edits']: + off=e['offset'];new=bytes.fromhex(e['new']);assert b[off:off+len(new)]==new;b[off:off+len(new)]=bytes.fromhex(e['old']) + b=b[:record['original_size']];assert sha(b)==ORIGINAL_SHA;return bytes(b) diff --git a/tools/ota.py b/tools/ota.py index 87740ea..496d491 100644 --- a/tools/ota.py +++ b/tools/ota.py @@ -89,6 +89,19 @@ def catalog(packages, base_url, output): write_json(output, {"format": "commandkit-catalog-v1", "updates": entries}) +def validate_catalog_upgrade(previous, incoming): + """Keep supported channels monotonic; retire the legacy exec-test channel.""" + updates=incoming.get('updates',[]) + versions={item['variant']:item['software_version'] for item in updates} + require('matter' in versions and len(versions)==len(updates), 'Missing or duplicate Matter update channel') + for item in previous.get('updates',[]): + variant=item['variant'] + if variant not in versions: + require(variant=='exec-test','Refusing to remove a supported update channel') + else: + require(versions[variant]>=item['software_version'],'Refusing to replace the feed with an older release') + + def main(): p = argparse.ArgumentParser(description=__doc__) commands = p.add_subparsers(dest="command", required=True) diff --git a/tools/release.py b/tools/release.py index eccc62a..26925e7 100644 --- a/tools/release.py +++ b/tools/release.py @@ -20,8 +20,10 @@ def main(): p.add_argument('--output',type=Path,required=True) p.add_argument('--template',type=Path,help='已下載的固定公開基底 BIN') p.add_argument('--sdk',type=Path,default=WORK/'deps/connectedhomeip') + p.add_argument('--include-exec-test',action='store_true',help='只供重建 v0.2.8 或更早的歷史診斷版') p.add_argument('--skip-compile',action='store_true',help='使用剛完成的本機建置產物') a=p.parse_args();config=json.loads((ROOT/'version.json').read_text());lock=json.loads((ROOT/'third_party/release-base.json').read_text()) + require(not a.include_exec_test or config['software_version']<=10,'診斷版尚未遷移密碼設定,不得發行新版診斷 OTA') out=a.output.resolve();require(not out.exists(),'輸出已存在,請使用新的版本目錄');out.mkdir(parents=True) base=ROOT/'artifacts/update-base';base.mkdir(parents=True,exist_ok=True) image=a.template or base/'matter-8m-region.bin';manifest=image.with_suffix('.json') @@ -41,22 +43,25 @@ def main(): for script in ('build_updates.sh','build_uclibc_matter_device.sh','build_setup_device.sh','build_setup_legacy.sh'): call([*docker,'sh','tools/'+script]) call(['python3',ROOT/'tools/build.py','matter','--binaries',WORK/'artifacts/softap-device-code','--base',image,'--template-manifest',manifest,'--output',out/'matter-8m-region.bin']) - exec_binaries=ROOT/'firmware/exec-test/artifacts' - exec_binaries.mkdir(parents=True,exist_ok=True) - for name,source in [('ck-wificfg',WORK/'artifacts/softap/ck-wificfg'), - ('ck-wifiscan',WORK/'artifacts/softap-device-code/ck-wifiscan'), - ('ck-lease',WORK/'artifacts/softap-device-code/ck-lease')]: - require(source.exists(),'缺少共用服務:'+str(source));shutil.copy2(source,exec_binaries/name) - call(['python3',ROOT/'tools/build.py','exec-test','--binaries',exec_binaries,'--base',image,'--template-manifest',manifest,'--output',out/'exec-test-8m-region.bin']) package=out/'matter.ota' pack(out/'matter-8m-region.bin',out/'matter-8m-region.json',a.private,config['software_version'],config['version'],package) - exec_package=out/'exec-test.ota' - pack(out/'exec-test-8m-region.bin',out/'exec-test-8m-region.json',a.private,config['software_version'],config['version'],exec_package) + packages=[package] + if a.include_exec_test: + exec_binaries=ROOT/'firmware/exec-test/artifacts' + exec_binaries.mkdir(parents=True,exist_ok=True) + for name,source in [('ck-wificfg',WORK/'artifacts/softap/ck-wificfg'), + ('ck-wifiscan',WORK/'artifacts/softap-device-code/ck-wifiscan'), + ('ck-lease',WORK/'artifacts/softap-device-code/ck-lease')]: + require(source.exists(),'缺少共用服務:'+str(source));shutil.copy2(source,exec_binaries/name) + call(['python3',ROOT/'tools/build.py','exec-test','--binaries',exec_binaries,'--base',image,'--template-manifest',manifest,'--output',out/'exec-test-8m-region.bin']) + exec_package=out/'exec-test.ota' + pack(out/'exec-test-8m-region.bin',out/'exec-test-8m-region.json',a.private,config['software_version'],config['version'],exec_package) + packages.append(exec_package) tool=a.sdk/'src/app/ota_image_tool.py' require(tool.exists(),'缺少固定版本 Matter SDK,無法產生 Matter OTA 容器') if tool.exists(): call(['python3',tool,'create','-v','0xFFF1','-p','0x8000','-vn',config['software_version'],'-vs',config['version'],'-da','sha256',package,out/'matter-matter-ota.bin']) - catalog([package,exec_package],config['catalog_url'].rsplit('/',1)[0],out/'index.json') + catalog(packages,config['catalog_url'].rsplit('/',1)[0],out/'index.json') files=sorted(p for p in out.iterdir() if p.is_file());(out/'SHA256SUMS').write_text(''.join(hashlib.sha256(p.read_bytes()).hexdigest()+' '+p.name+'\n' for p in files)) print('公開更新包完成:'+str(out)) diff --git a/tools/setup_control.py b/tools/setup_control.py new file mode 100644 index 0000000..1cc9982 --- /dev/null +++ b/tools/setup_control.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Authenticated Wi-Fi setup and Matter pairing controls using the admin password (legacy profile optional).""" +import argparse +import getpass +import ipaddress +import socket +from admin_session import AdminSession +import hashlib +import hmac +import http.client +import re +import json +import time +from pathlib import Path +from fwlib import require, profile_diag_key + + +def control(host, profile, action='status', port=18081): + routes={'status':('GET','/state'),'enter-setup':('POST','/enter-setup'), + 'matter-status':('GET','/matter-state'),'reset-matter':('POST','/reset-matter'), + 'open-commissioning':('POST','/open-commissioning')} + require(action in routes, '不支援的操作') + key=profile_diag_key(profile) + def request(method,path,headers=None): + client=http.client.HTTPConnection(host,port,timeout=15) + try: + client.request(method,path,body=b'' if method=='POST' else None,headers=headers or {}) + response=client.getresponse();body=response.read(4097) + require(len(body)<=4096, '回應過長') + require(response.status in (200,202), 'HTTP '+str(response.status)) + return body + finally:client.close() + nonce=request('GET','/challenge') + require(re.fullmatch(b'[0-9a-f]{64}',nonce), 'challenge 格式不符') + method,path=routes[action] + signature=hmac.new(key, + (method+' '+path+'\n').encode()+nonce,hashlib.sha256).hexdigest() + return request(method,path,{'X-Setup-Proof':nonce.decode()+':'+signature}).decode('utf-8') + + +def main(): + p=argparse.ArgumentParser(description=__doc__) + p.add_argument('action',choices=('status','enter-setup','matter-status','reset-matter','open-commissioning')) + p.add_argument('--host',required=True) + p.add_argument('--profile',type=Path,help='僅舊版 HMAC 介面使用') + p.add_argument('--wait',action='store_true',help='等待這次 Matter 操作完成並驗證結果') + a=p.parse_args() + require(not a.wait or a.action in ('reset-matter','open-commissioning'),'--wait 僅適用於 Matter 操作') + if a.profile: + invoke=lambda action:control(a.host,a.profile,action) + else: + require(a.action!='enter-setup','新版請登入管理網頁修改 Wi-Fi,或實體長按恢復出廠') + address=ipaddress.IPv4Address(socket.gethostbyname(a.host)) + require(address.is_private and not address.is_multicast and not address.is_unspecified,'請指定區網 IPv4 位址') + session=AdminSession('http://'+str(address)+':18082') + if a.action!='status':session.login(getpass.getpass('管理密碼:')) + def invoke(action): + if action=='status':data=session.get('/status') + elif action=='matter-status':data=session.get('/admin/state')['matter'] + else: + require(session.get('/protocol').get('active')=='matter','目前不是 Matter 模式,請到管理後台選擇協議') + data=session.post({'reset-matter':'/admin/pairing-reset','open-commissioning':'/admin/open-commissioning'}[action],b'') + return json.dumps(data,ensure_ascii=False)+'\n' + result=invoke(a.action) + if a.wait: + request_id=json.loads(result)['request_id'];deadline=time.monotonic()+90 + while time.monotonic()