From ba2a5e8e54f72c71b15c73b847796e3fb7916cbf Mon Sep 17 00:00:00 2001 From: lee98064 <46412433+lee98064@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:07:04 +0800 Subject: [PATCH 01/13] Add signed firmware updates, persistent Matter state and electrical measurements --- .github/workflows/ota.yml | 84 + .github/workflows/update-feed.yml | 58 + README.md | 8 +- docs/build.md | 14 +- docs/flashing.md | 2 + docs/measurements.md | 51 + docs/ota-release-notes.md | 9 + docs/publishing.md | 2 + docs/technical-report.md | 15 +- docs/updates.md | 121 + docs/usage.md | 4 +- firmware/exec-test/src/exec-diag-start | 2 +- firmware/exec-test/src/setup/ck-setup-backend | 2 +- firmware/exec-test/src/setup/ck-setup-start | 2 +- .../matter/src/matter-app-device/BUILD.gn | 17 +- .../src/matter-app-device/CHIPProjectConfig.h | 3 + .../src/matter-app-device/DeviceIdentity.cpp | 8 +- .../ElectricalMeasurements.cpp | 49 + .../ElectricalMeasurements.h | 60 + .../src/matter-app-device/FlashStorage.h | 36 + .../src/matter-app-device/MatterOta.cpp | 233 ++ .../matter/src/matter-app-device/MatterOta.h | 6 + .../matter/src/matter-app-device/Version.h | 3 + .../matter/src/matter-app-device/args.gni | 4 +- .../matter/src/matter-app-device/main.cpp | 49 +- .../third_party/connectedhomeip | 1 + firmware/matter/src/matter-diag-start | 2 +- firmware/matter/src/matter_device_init.c | 30 +- firmware/matter/src/relay/McuController.cpp | 9 +- firmware/matter/src/relay/RelayEngine.cpp | 35 +- firmware/matter/src/relay/RelayEngine.h | 7 +- .../src/sdk-overrides/CHIPLinuxStorage.cpp | 373 ++ .../matter/src/setup/ck-setup-device-start | 2 +- firmware/matter/src/setup/matter-relay-start | 1 + firmware/matter/src/update/ck-ota-start | 47 + firmware/matter/src/update/ck_common.c | 372 ++ firmware/matter/src/update/ck_common.h | 52 + firmware/matter/src/update/ck_flash.c | 175 + firmware/matter/src/update/ck_ota.c | 256 ++ firmware/matter/src/update/ck_ota.h | 11 + firmware/matter/src/update/ck_store.c | 303 ++ firmware/matter/src/update/ck_store.h | 20 + firmware/matter/src/update/ck_updated.c | 351 ++ firmware/matter/src/update/ck_updater.c | 210 ++ firmware/matter/src/update/update.html | 14 + firmware/matter/src/update/update.js | 50 + firmware/matter/src/update/update_page.h | 2 + .../matter/src/vendor/monocypher/LICENCE.md | 173 + .../vendor/monocypher/monocypher-ed25519.c | 503 +++ .../vendor/monocypher/monocypher-ed25519.h | 140 + .../matter/src/vendor/monocypher/monocypher.c | 2988 +++++++++++++++++ .../matter/src/vendor/monocypher/monocypher.h | 321 ++ .../src/vendor/monocypher/provenance.json | 25 + firmware/matter/tests/relay_engine_tests.cpp | 34 +- .../tools/build_matter_device_fixture.py | 17 +- .../tools/build_uclibc_matter_device.sh | 29 +- firmware/matter/tools/build_updates.sh | 27 + firmware/matter/tools/test_device_vm.py | 104 +- tests/test_deployment.py | 2 + tests/test_update_crypto.js | 8 + tests/test_update_pid1.py | 54 + tests/test_updates.py | 205 ++ third_party/README.md | 2 + third_party/ota-signing.pub | Bin 0 -> 32 bytes third_party/release-base.json | 6 + tools/build.py | 60 +- tools/fwlib.py | 47 +- tools/generate_update.py | 20 + tools/ota.py | 107 + tools/package_public.py | 30 +- tools/release.py | 63 + tools/requirements.txt | 1 + tools/setup_github_updates.py | 31 + tools/update.py | 59 + verification/README.md | 6 +- verification/ota-results.json | 147 + verification/public-audit-v0.2.0-preview.json | 492 +++ version.json | 5 + 78 files changed, 8776 insertions(+), 95 deletions(-) create mode 100644 .github/workflows/ota.yml create mode 100644 .github/workflows/update-feed.yml create mode 100644 docs/measurements.md create mode 100644 docs/ota-release-notes.md create mode 100644 docs/updates.md create mode 100644 firmware/matter/src/matter-app-device/ElectricalMeasurements.cpp create mode 100644 firmware/matter/src/matter-app-device/ElectricalMeasurements.h create mode 100644 firmware/matter/src/matter-app-device/FlashStorage.h create mode 100644 firmware/matter/src/matter-app-device/MatterOta.cpp create mode 100644 firmware/matter/src/matter-app-device/MatterOta.h create mode 100644 firmware/matter/src/matter-app-device/Version.h create mode 120000 firmware/matter/src/matter-app-device/third_party/connectedhomeip create mode 100644 firmware/matter/src/sdk-overrides/CHIPLinuxStorage.cpp create mode 100644 firmware/matter/src/update/ck-ota-start create mode 100644 firmware/matter/src/update/ck_common.c create mode 100644 firmware/matter/src/update/ck_common.h create mode 100644 firmware/matter/src/update/ck_flash.c create mode 100644 firmware/matter/src/update/ck_ota.c create mode 100644 firmware/matter/src/update/ck_ota.h create mode 100644 firmware/matter/src/update/ck_store.c create mode 100644 firmware/matter/src/update/ck_store.h create mode 100644 firmware/matter/src/update/ck_updated.c create mode 100644 firmware/matter/src/update/ck_updater.c create mode 100644 firmware/matter/src/update/update.html create mode 100644 firmware/matter/src/update/update.js create mode 100644 firmware/matter/src/update/update_page.h create mode 100644 firmware/matter/src/vendor/monocypher/LICENCE.md create mode 100644 firmware/matter/src/vendor/monocypher/monocypher-ed25519.c create mode 100644 firmware/matter/src/vendor/monocypher/monocypher-ed25519.h create mode 100644 firmware/matter/src/vendor/monocypher/monocypher.c create mode 100644 firmware/matter/src/vendor/monocypher/monocypher.h create mode 100644 firmware/matter/src/vendor/monocypher/provenance.json create mode 100644 firmware/matter/tools/build_updates.sh create mode 100644 tests/test_update_crypto.js create mode 100644 tests/test_update_pid1.py create mode 100644 tests/test_updates.py create mode 100644 third_party/ota-signing.pub create mode 100644 third_party/release-base.json create mode 100644 tools/generate_update.py create mode 100644 tools/ota.py create mode 100644 tools/release.py create mode 100644 tools/requirements.txt create mode 100644 tools/setup_github_updates.py create mode 100644 tools/update.py create mode 100644 verification/ota-results.json create mode 100644 verification/public-audit-v0.2.0-preview.json create mode 100644 version.json diff --git a/.github/workflows/ota.yml b/.github/workflows/ota.yml new file mode 100644 index 0000000..3943895 --- /dev/null +++ b/.github/workflows/ota.yml @@ -0,0 +1,84 @@ +name: Build signed OTA release +on: + pull_request: + push: + tags: ['v*'] + workflow_dispatch: +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.12' + - run: pip install -r tools/requirements.txt + - run: python3 tools/generate_update.py + - run: python3 tests/test_updates.py + - run: node tests/test_update_crypto.js + - 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: + needs: test + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-24.04 + timeout-minutes: 360 + permissions: + contents: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.12' + - name: Check version matches tag + env: + RELEASE_TAG: ${{ github.ref_name }} + run: python3 -c 'import json,os; assert os.environ["RELEASE_TAG"] == "v"+json.load(open("version.json"))["version"]' + - run: sudo apt-get update && sudo apt-get install -y squashfs-tools llvm lld + - run: pip install -r tools/requirements.txt + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + firmware/matter/deps + firmware/matter/toolchains + firmware/matter/artifacts/uclibc-toolchain + key: ota-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('third_party/sdk-lock.json', 'third_party/download-lock.json', 'firmware/matter/tools/prepare*.py', 'firmware/matter/tools/uclibc.config') }} + - run: docker build -t incipio-public-build:v0.1.0-preview . + - run: python3 tools/bootstrap.py --sdk --toolchain + - name: Build compatible toolchain + run: | + docker run --rm -v "$PWD/firmware/matter:/work" incipio-public-build:v0.1.0-preview sh -c 'make -C deps/musl-cross-make -j4 && make -C deps/musl-cross-make install && bash tools/build_uclibc_toolchain.sh' + - name: Load signing key + env: + OTA_SIGNING_KEY_HEX: ${{ secrets.OTA_SIGNING_KEY_HEX }} + run: | + python3 - <<'PY' + import os + from pathlib import Path + value=bytes.fromhex(os.environ['OTA_SIGNING_KEY_HEX']) + assert len(value)==32, 'Configure OTA_SIGNING_KEY_HEX first' + path=Path('private/ota-signing.key');path.parent.mkdir(mode=0o700,exist_ok=True) + fd=os.open(path,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600) + with os.fdopen(fd,'wb') as f:f.write(value) + PY + - name: Test the actual MIPS updater + 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' + 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 + - name: Validate release assembly + run: | + python3 tests/test_deployment.py --release-dir artifacts/ota-release -v + python3 tools/check_public.py --repository . --release-dir artifacts/ota-release + - name: Create reviewable release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + run: gh release create "$RELEASE_TAG" artifacts/ota-release/* --verify-tag --draft --prerelease --title "$RELEASE_TAG" --notes-file docs/ota-release-notes.md + - name: Remove signing material + if: always() + run: rm -f private/ota-signing.key diff --git a/.github/workflows/update-feed.yml b/.github/workflows/update-feed.yml new file mode 100644 index 0000000..78dae8b --- /dev/null +++ b/.github/workflows/update-feed.yml @@ -0,0 +1,58 @@ +name: Publish update feed +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: Published release tag + required: true +permissions: + contents: read + pages: write + id-token: write +concurrency: + group: update-feed + cancel-in-progress: false +jobs: + publish: + runs-on: ubuntu-24.04 + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.12' + - run: pip install -r tools/requirements.txt + - name: Download and verify published firmware + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} + GH_REPO: ${{ github.repository }} + run: | + mkdir -p site/updates + gh release download "$RELEASE_TAG" --pattern '*.ota' --pattern 'matter.json' --pattern 'exec-test.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 + python3 - <<'PY' + import json,urllib.request,urllib.error + url=json.load(open('version.json'))['catalog_url'] + try: + with urllib.request.urlopen(url,timeout=30) as source:old=json.load(source) + except urllib.error.HTTPError as error: + 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' + PY + touch site/.nojekyll + - uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 + - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 + with: + path: site + - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 + id: deployment diff --git a/README.md b/README.md index 8ff36f3..a6cf458 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 自有 CMNDKT-004/RTL8196E 智慧插座的逆向紀錄,以及保留原廠 kernel/驅動的 Matter 和執行檔診斷預覽版。說明以繁體中文為主。 -公開來源位於 [GitHub repository](https://github.com/lee98064/incipio-commandkit);兩份去識別化 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.1 歷史去識別化 BIN、JSON 與校驗碼見 [v0.1.0-preview Release](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,6 +12,8 @@ | 內容 | 入口 | |---|---| +| 網頁/Matter OTA 與發布新版 | [更新說明](docs/updates.md) | +| 標準電壓、RMS 電流、功率 | [Matter 電力量測](docs/measurements.md) | | 使用、配網、Matter、診斷 | [使用說明](docs/usage.md) | | 備份、限定區域燒錄、讀回、復原 | [燒錄說明](docs/flashing.md) | | 從自己的原廠備份重建 | [建置說明](docs/build.md) | @@ -21,11 +23,13 @@ | 測試與發布交付狀態 | [驗證索引](verification/README.md) | | 上傳 GitHub 與建立 Release | [發布說明](docs/publishing.md) | +目前來源為 `v0.2.0-preview` 候選版;v0.1 Release 尚無永久 OTA,第一次升級仍須限定區域燒錄。 + ## 兩種韌體 | 版本 | 功能 | 限制 | |---|---|---| -| `matter` | 開放設定熱點、Wi-Fi 掃描、裝置專屬 Matter 碼/QR、On/Off/Toggle、MCU 確認、Identify、診斷 | 配對 fabrics 在 RAM,斷電需重新配對;停用 HomeKit 啟動與原廠 OTA;使用開發測試 attestation | +| `matter` | 開放設定熱點、Wi-Fi 掃描、裝置專屬 Matter 碼/QR、On/Off/Toggle、MCU 確認、Identify、診斷 | v0.2 已加入持久 fabrics、簽章 OTA、標準電力量測;使用開發測試 attestation;原機驗收待完成 | | `exec-test` | 依序執行三支附件 MIPS 程式、逾時/資源/hash 限制、認證診斷下載 | 不啟動 Matter/HomeKit;不提供軟體繼電器控制;附件只有執行檔,沒有其原始碼 | 這兩份公開重打包版本尚未實體燒錄與原機驗收。歷史版本的原機平台/協議測試與本次 VM 測試分開記錄;不可用模擬機結果宣稱原機 RF、家庭 app、負載切換或長期穩定性通過。 diff --git a/docs/build.md b/docs/build.md index 196c548..9abbab1 100644 --- a/docs/build.md +++ b/docs/build.md @@ -24,6 +24,18 @@ docker run --rm -v "$PWD/firmware/matter:/work" incipio-public-build:v0.1.0-prev 需使用 Linux 的區分大小寫檔案系統解壓舊 kernel headers;腳本已在容器 `/tmp` 處理。SDK、下載、工具鏈與 build 產物不應加入 Git。 +## v0.2 一次建立兩版及更新附件 + +依賴及工具鏈準備完成後,使用 [更新說明](updates.md) 的 `tools/release.py`。它從固定 SHA-256 的公開 v0.1 基底組裝兩種韌體,不需要將私人 Flash 備份交給 CI。新版私鑰需沿用,公鑰已固定於來源。 + +下列個別建置命令也需先產生版本及更新服務: + +```bash +python3 tools/generate_update.py +docker run --rm -v "$PWD/firmware/matter:/work" incipio-public-build:v0.1.0-preview \ + sh tools/build_updates.sh +``` + ## 提取自己的原廠 helper libraries ```bash @@ -43,7 +55,7 @@ python3 tools/build.py matter --binaries firmware/matter/artifacts/softap-device --base private/read-01/before-1.bin --output artifacts/matter-8m-region.bin ``` -`build.py` 從搬移後的來源編譯診斷、設定頁、RAM runner、Wi-Fi scan 與 lease publisher,稽核 ISA、組裝原廠 Bank 2 rootfs、停用 HomeKit/OTA,並移除私人憑證。輸出公開格式 BIN 與 JSON;接著使用 [prepare](flashing.md) 個別化,不能直接全片寫入此公開檔。 +`build.py` 從搬移後的來源編譯診斷、設定頁、RAM runner、Wi-Fi scan 與 lease publisher,稽核 ISA、組裝原廠 Bank 2 rootfs、停用原廠 HomeKit/OTA,加入簽章更新入口,並移除私人憑證。輸出公開格式 BIN 與 JSON;接著使用 [prepare](flashing.md) 個別化,不能直接全片寫入此公開檔。 生成頁面 header 的腳本是來源的一部分;重建可能更新生成 header。沒有共用配對碼的私有 header 依賴。 diff --git a/docs/flashing.md b/docs/flashing.md index 000678a..ea882d8 100644 --- a/docs/flashing.md +++ b/docs/flashing.md @@ -2,6 +2,8 @@ 本工具使用 CH341A、flashrom **v1.8.0** 與 **GD25Q64(B),8 MiB,JEDEC C8 40 17**。首版支援 macOS/Linux。它沒有全片清除、任意區域、強制寫入或略過驗證介面。 +v0.2 首次安裝後可使用[簽章 OTA](updates.md),後續更新不需再次拆機。v0.1 沒有永久 OTA 入口。 + ## 接線與前提 1. 插座與市電完全斷開。不得一邊接市電一邊接 USB 燒錄器/UART。 diff --git a/docs/measurements.md b/docs/measurements.md new file mode 100644 index 0000000..6fb75c1 --- /dev/null +++ b/docs/measurements.md @@ -0,0 +1,51 @@ +# Matter 電力量測 + +v0.2 在同一個 endpoint 1 宣告 On/Off Plug-in Unit 與 Electrical Sensor,提供標準 ElectricalPowerMeasurement(`0x0090`)及 PowerTopology(`0x009C`,NodeTopology)。已配對且獲 ACL 允許的 Matter controller 可透過標準讀取/訂閱取得相同資料,不使用廠商私有 characteristic 或 Apple 專屬橋接。 + +**可配對不等於該系統已實作電力量測 UI。** Matter 1.3 加入標準電力/能源回報;較舊或尚未支援這些叢集的 controller 可能只顯示開關。Apple 官方 Matter framework 提供此類 cluster API,但本專案沒有完成 iPhone 家庭 app 或其他家庭平台的真機 UI 驗收,不能保證每個 app 都會顯示每個欄位。 + +## 可取得的數值 + +| 屬性 | ID | Matter 單位 | MCU 換算 | 語義 | +|---|---|---|---|---| +| Voltage | `0x0004` | mV | `round(raw × 1000 / 64)` | 原廠 Vrms,通用電壓欄位 | +| RMSVoltage | `0x000B` | mV | 同上 | 明確標示 RMS 的同一讀值 | +| RMSCurrent | `0x000C` | mA | `round(raw × 1000 / 256)` | 原廠 Irms | +| ActivePower | `0x0008` | mW | `raw × 1000` | 原廠 Power | + +`raw = b1 + 256*b2`,數值回應為 `FF b1 b2 55`。A5、A6、A7 查詢與原廠公式來自既有[逐 byte 協議核對](../reverse/docs/meter_protocol.md)。例如 `FF 40 1B 55` 在 A5 查詢後對應 109000 mV;`FF 80 01 55` 在 A6 後對應 1500 mA;`FF F4 01 55` 在 A7 後對應 500000 mW。 + +這是依原廠 Vrms/Irms/Power 命名、算式及 300/15/2000 上限採用的**名義 V/A/W 映射**。原機與參考電表比對、非零負載精度及校正仍待驗收,不宣稱計費精度。Accuracy 列出量測類型及名義範圍,沒有填入未經證實的誤差百分比。 + +只取得 RMS 電流,不能據此聲稱得到 ActiveCurrent(同相分量);此屬性不列入 AttributeList。未取得的 reactive/apparent power、頻率、功率因數及累積 kWh 也不宣告。沒有已確認的硬體能源計數器,因此此版不提供 ElectricalEnergyMeasurement(`0x0091`),也不把功率積分假裝成硬體總電量。 + +## 更新、過期與通知 + +單一 MCU owner 依序查詢,電力量測約每 5 秒一次;繼電器確認命令優先。每次只有一個待回覆查詢,拆段回應逐 byte 解析。數值回應沒有 transaction ID,因此無法從內容判別任意延遲跨查詢的回覆;逾時後加入安靜間隔並忽略非待查詢數值,實際最大回應延遲仍需原機確認。 + +查詢逾時、斷線、超出原廠名義上限,或資料超過 15 秒,屬性為 Matter `null`。有效的 0 保持為 0;失去資料不補 0,不依繼電器狀態推算功率。讀值變更及有效/null 轉換會通知 Matter reporting engine,依 controller 訂閱間隔回報。 + +## 使用標準 controller 驗證 + +以下 node ID `12344321` 是本機 VM 測試節點,實際使用時改成自己的 node ID: + +```bash +chip-tool descriptor read device-type-list 12344321 1 +chip-tool descriptor read server-list 12344321 1 +chip-tool electricalpowermeasurement read attribute-list 12344321 1 +chip-tool electricalpowermeasurement read voltage 12344321 1 +chip-tool electricalpowermeasurement read rmscurrent 12344321 1 +chip-tool electricalpowermeasurement read active-power 12344321 1 +chip-tool electricalpowermeasurement subscribe active-power 1 10 12344321 1 +``` + +模擬機測試使用實際 MIPS Matter stack、完整 commissioning、加密 CASE、kernel TTY 及可控制的 MCU 對端,驗證縮放、訂閱變更、過期 null 及恢復;結果見[驗證紀錄](../verification/ota-results.json)。模擬 MCU 數值只作測試,不植入產品執行路徑。 + +## 平台驗收與來源 + +原機需要與參考電表比對電壓/電流/功率;再以每個目標系統完成配對、讀取、訂閱與失聯測試。Apple 家庭 iPhone 是使用者的優先 UI;尚未取得該 UI 實測證據。不能用 chip-tool 成功代替 Apple 家庭或其他系統已顯示成功。 + +- [CSA:Matter 1.3 的即時電力與能源回報](https://csa-iot.org/newsroom/matter-1-3-specification-released/) +- [固定 SDK 的標準 ElectricalPowerMeasurement 實作](https://github.com/project-chip/connectedhomeip/tree/3bcdd56ba54fb88b2afb4bfef575014671df7aa7/src/app/clusters/electrical-power-measurement-server) +- [Apple Matter framework](https://developer.apple.com/documentation/matter) +- [Apple Matter 互通說明](https://developer.apple.com/apple-home/matter/) diff --git a/docs/ota-release-notes.md b/docs/ota-release-notes.md new file mode 100644 index 0000000..70a2382 --- /dev/null +++ b/docs/ota-release-notes.md @@ -0,0 +1,9 @@ +此版新增簽章 OTA、區網管理頁、線上版本檢查、Matter OTA Requestor,以及設定/fabrics 持久儲存。 + +首次安裝仍需以原燒錄工具將支援 OTA 的韌體寫入 Bank 1;後續使用 `.ota` 更新包。`matter-8m-region.bin` 是部分燒錄映像,必須本機個別化,禁止整片覆蓋。`matter-matter-ota.bin` 是提供 Matter OTA Provider 的標準外層容器。 + +保留原廠 Bank 2、bootloader、MAC、校正、HomeKit/MFi 與出廠資料。自訂 journal 使用 Bank 1 rootfs 尾端預留 128 KiB;遇到未辨識的既有資料會拒絕初始化。 + +本版屬預覽版本。離線、模擬 Flash 與 VM 測試結果不能代替原機斷電、Flash driver、RF、Matter 控制及復原驗收。原廠槽只提供映像校驗失敗的備援,沒有宣稱完整自動版本回退。應用失敗時可使用維修模式安裝較新修正版。 + +Matter 版加入標準 ElectricalPowerMeasurement,提供電壓、RMS 電流、功率與訂閱通知。無有效 MCU 資料時為 null。累積 kWh、實機校正及 Apple/其他家庭平台 UI 未宣稱已驗收,詳見電力量測文件。 diff --git a/docs/publishing.md b/docs/publishing.md index 41aa303..ef81a38 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -1,5 +1,7 @@ # 上傳 GitHub 與建立 Release +v0.2 起使用 [簽章建置與 Pages 發布流程](updates.md),以 tag 建立草稿 Release,人工發布後才更新索引。以下是 v0.1 的歷史交付方式。 + 交付資料夾的 `repository/` 是 GitHub repository 內容;`release-assets/v0.1.0-preview/` 是 GitHub Release 附件。本機驗證快取、私人備份及金鑰不屬於公開交付內容,不能上傳。 1. 上傳 `repository/` 的公開檔案清單內容;不要把整個研究工作區或外層資料夾上傳。 diff --git a/docs/technical-report.md b/docs/technical-report.md index 2c9ab23..e7d3ece 100644 --- a/docs/technical-report.md +++ b/docs/technical-report.md @@ -4,7 +4,7 @@ ## 概述 -將原廠 Linux/Wi-Fi/硬體初始化保留於每台插座,只替換 Bank 1 rootfs。Matter 版接入開放設定 AP、Wi-Fi 選單、裝置身分與 MCU 控制;獨立診斷版驗證外部工具鏈產出的三支 MIPS 執行檔。公開 BIN 的保留區填值,不帶這台插座的設定與出廠資料;本機個別化與限定區域寫入工具將裝置資料與公開程式區分開。預覽版尚未原機驗收,fabrics 持久儲存與正式 OTA 不在本次實作。 +將原廠 Linux/Wi-Fi/硬體初始化保留於每台插座,只替換 Bank 1 rootfs。Matter 版接入開放設定 AP、Wi-Fi 選單、裝置身分與 MCU 控制;獨立診斷版驗證外部工具鏈產出的三支 MIPS 執行檔。公開 BIN 的保留區填值,不帶這台插座的設定與出廠資料;本機個別化與限定區域寫入工具將裝置資料與公開程式區分開。v0.2 加入 journal 持久儲存、簽章 OTA 與 Matter 電力量測;原機驗收尚待完成。實作與新證據見 [更新流程](updates.md)、[標準量測](measurements.md)及[更新驗證](../verification/ota-results.json)。其餘歷史 v0.1 測試仍保留原適用範圍。 ## 硬體與保留邊界 @@ -14,7 +14,8 @@ Realtek RTL8196E/RLX4181、RTL8188ER Wi-Fi、GD25Q64 8 MiB。CPU 為 MIPS 32-b |---|---| | `[0,0x130000)` | 保留 bootloader、硬體/default/current MIB、Bank 1 kernel | | `[0x130000,0x3C0000)` | 唯一允許替換的 Bank 1 rootfs,2,686,976 bytes | -| `[0x3C0000,0x800000)` | 保留 journal、HomeKit、Bank 2、factory、MAC/校正/production 資料 | +| `[0x3C0000,0x3E0000)` | 首次限定區域燒錄保留;v0.2 執行期間使用為兩份持久 journal | +| `[0x3E0000,0x800000)` | 保留 HomeKit、Bank 2、factory、MAC/校正/production 資料 | Release 容器有相同位址範圍,但兩段保留區是全 `0xFF`,不是可部署的內容。私人目標從當次備份取得兩段原值,再替換 rootfs。bankmark/kernel 不更改;相容性限制由公開 executable-range hashes 表達,沒有原裝置完整備份 hash 或 bytes。 @@ -41,6 +42,8 @@ DeviceIdentity 讀取實際 `wl0` MAC 與部署時新產生的 32-byte 金鑰, | E-003 | verification/public-audit.json、Release JSON/SHA256SUMS | 公開 BIN padding、checksum、解壓後憑證缺席與檔案 hash | | E-004 | verification/test-results.json;搬移後服務與 Matter 來源 | 本次執行的獨立建置、MIPS/HTTP/身分/協議測試及明確限制 | | E-005 | tests/test_dummy_flashrom.py | flashrom dummy backend 的限定區域寫入、保留區與復原 | +| E-006 | verification/ota-results.json;tests/test_updates.py、tests/test_update_pid1.py | v0.2 簽章、未知 journal 拒絕、寫入中斷、保留區及 PID 1 安裝路徑 | +| E-007 | firmware/matter/tools/test_device_vm.py;verification/ota-results.json | 持久配對、標準電力量測讀取/訂閱、null/恢復,僅限模擬 MCU | F-001:公開包不含原裝置保留區與原憑證。Evidence E-002/E-003;confidence=high,依本次檔案 manifest 的範圍成立。 @@ -52,6 +55,14 @@ P-001(callflow,E-002/E-003/E-005 → F-001/F-002):一致備份 → P-002(callflow,E-004 → F-003):MAC/私人金鑰 → RAM 身分與 QR → 開放設定 AP → 原廠 Wi-Fi helper → LAN Matter commissioning → 加密命令 → MCU TX/RX 確認 → Matter 屬性同步。VM 的 MCU 為模擬對端。 +F-004:v0.2 的區網 OTA 不需要更新操作憑證,仍強制檢查簽章、機型、種類、版本及平台。E-006 的 native/MIPS/隔離 PID 1 測試支持實作行為,未驗證原機 MTD driver。 + +F-005:E-007 支持同一標準 Matter 資料可由已授權 controller 讀取和訂閱,不支持「所有家庭 app 必然顯示」或「原機量測精度已確認」的結論。 + +P-003(E-006 → F-004):區網上傳/Matter BDX → RAM 暫存 → 驗簽與相容性檢查 → 固定 PID 1 action → RAM root → 限定 rootfs 寫入及讀回 → 重開 → MCU 健康確認版本。完整 Provider 傳輸及原機驗收仍待完成。 + +P-004(E-007 → F-005):A5/A6/A7 → 有界待查詢解析 → 原廠名義單位縮放 → 標準 ElectricalPowerMeasurement → 讀取/訂閱;逾時、斷線或過期 → null。 + ## 狀態與時序 2026-10-11 完成原廠兩槽與 bootloader 逆向及 V2 修補;2026-10-12~13 經原機 SIGILL 問題調整 runtime、LL/SC/TLS/大端 TLV,再接入繼電器;2026-10-14 完成装置專屬身分及獨立執行檔測試版。歷史 `softap-scan-fix` 曾由實機診斷觀察到 commissioning response ACK;這不能替代最新 DeviceIdentity 版或本次公開重打包的原機驗收。 diff --git a/docs/updates.md b/docs/updates.md new file mode 100644 index 0000000..8cbd24e --- /dev/null +++ b/docs/updates.md @@ -0,0 +1,121 @@ +# 安裝更新與發布新版 + +v0.2.0-preview 提供區網網頁更新、線上更新索引、Ed25519 簽章,以及 Matter OTA Requestor。Matter fabrics、平台設定及裝置身分改存入 Flash journal。**第一次仍須以 CH341A 安裝支援 OTA 的版本;v0.1.0-preview 沒有永久更新入口。**原機 Flash driver、斷電、RF 及家庭平台驗收尚待完成;已通過的模擬/VM 檢查見 [更新驗證](../verification/ota-results.json)。 + +## 在插座網頁更新 + +1. 首次依 [燒錄說明](flashing.md) 安裝新的 `matter-8m-region.bin` 或 `exec-test-8m-region.bin`,沿用同一台插座的 profile。 +2. 配網後,從路由器取得插座 IPv4 位址,在瀏覽器開啟 `http://插座IP:18082/`。 +3. 按「檢查新版本」及「下載並更新」,或選擇已下載的 `.ota`,按「驗證並更新」。 +4. 等候簽章核對、安裝、重開及健康確認。保持供電;更新暫停 Matter 控制,現有啟動邏輯會要求 MCU 確認 OFF。 + +相同或較舊版本、不同韌體種類、錯誤機型、平台 hash 不符、截斷及簽章錯誤都會拒絕。不能把 8 MiB BIN 當成 `.ota`,也不能透過 OTA 在 Matter 與診斷版之間切換。 + +初次安裝時,journal 必須為全 `0xFF` 或已辨識的 `CKSTORE1`。準備工具會拒絕未知資料,不自動清除。首個版本的 RAM-only fabrics 無法從 Flash 備份還原,因此首次換到此版仍需重新配對;此版建立的 fabrics 才會持久保留。 + +## 使用電腦更新 + +若瀏覽器無法下載線上檔案,Python 用戶端會代為透過 HTTPS 下載並先驗證簽章: + +```bash +python3 -m pip install -r tools/requirements.txt +read -r OUTLET_IP +python3 tools/update.py "$OUTLET_IP" --latest +``` + +`read` 時輸入插座 IP 或可解析的 `.local` 名稱。使用本機檔案、只檢查更新包: + +```bash +python3 tools/update.py "$OUTLET_IP" \ + --package artifacts/releases/v0.2.0-preview/matter.ota --check-only +``` + +第一次安裝 v0.2.0-preview 後,必須發布 `software_version` 大於 2 的更新才能安裝。`--check-only` 不傳送更新,也不重新啟動。 + +## 更新包與保留範圍 + +| 附件 | 用途 | +|---|---| +| `matter-8m-region.bin`/`.json` | Matter 版首次個別化及 CH341A 限定區域燒錄 | +| `exec-test-8m-region.bin`/`.json` | 診斷版首次安裝 | +| `matter.ota`/`exec-test.ota` | 管理頁及 Python 用戶端安裝;不含每台裝置的憑證 | +| `matter-matter-ota.bin` | Matter 標準外層容器,內含同一份簽章 `.ota` | +| `index.json`/`SHA256SUMS` | 線上索引及附件校驗 | + +8 MiB BIN 仍是部分燒錄容器,保留區為 `0xFF`;不能全片覆蓋。OTA payload 固定為 `[0x130000,0x3C0000)`。運作中的持久儲存另使用 `[0x3C0000,0x3E0000)` 的 128 KiB;bootloader、kernel、原廠 HomeKit 資料、Bank 2、MAC、校正及出廠區域不寫入。 + +更新器檢查目前 bank、`/proc/mtd`、MTD 型別/容量/erase size,並核對 kernel 和 Bank 2 的 hash。原廠節點使用連續 minor,標準 Linux MTD 的 odd minor 則是唯讀別名;程式會核對幾何資訊,必要時在 RAM 建立 0600 的標準節點,無法確認時停止。 + +## 設定如何保留 + +journal 為兩個 64 KiB snapshot,使用不同序號、SHA-256 與最後寫入的 commit word。只擦除新 snapshot 實際用到的 4 KiB sectors;相同值不寫入。每次寫入都讀回核對,成功後才回覆 Matter 的儲存請求。 + +| key 前綴 | 資料 | +|---|---| +| `identity/` | 裝置身分、診斷金鑰、更新信任公鑰、診斷版 AP 憑證 | +| `matter/` | fabrics、ACL、金鑰、session resumption、持久屬性 | +| `platform/` | SDK factory/config/counters/KVS 的 INI 內容,單筆上限 16 KiB | +| `ota/` | 已確認版本、待確認版本及啟動嘗試次數 | + +總 payload 上限 65,472 bytes。Wi-Fi 原廠 MIB 沿用既有 helper;不用公開更新包重建 MAC/校正或裝置金鑰。新 profile 可從已辨識 journal 恢復相同憑證;已有 profile 與 journal 不符會停止,避免默默換碼。既有 profile 缺少檔案仍須先復原,不會隨機補上新金鑰。 + +SDK 原本寫入 `/tmp/*.ini` 的平台設定也會同步保存至 journal。此修改保留 upstream 授權,來源見 `src/sdk-overrides/CHIPLinuxStorage.cpp` 與 [第三方說明](../third_party/README.md)。 + +## 安裝與復原流程 + +區網內可直接上傳及安裝,不需要 diag.key、HMAC、nonce 或額外操作授權。上傳完成後才驗證整包;管理頁及電腦工具會附上所選 package 的 checksum,避免套用不同的暫存檔。BusyBox PID 1 的固定 restart action 執行 RAM 中的靜態更新器;它停止其他使用者行程、切換 RAM root 並卸離舊 rootfs,才開寫。先使 rootfs magic 無效,寫入 body,最後補回第一個 sector,讀回 hash 與 journal 後重開。 + +Matter 必須連續 30 秒取得新鮮 MCU 狀態才確認新版本;診斷版確認網路及兩個服務已啟動。應用未通過健康檢查時,獨立管理頁保留維修入口;連續三次未確認啟動後不再啟動 Matter。可以安裝較新的修正版。 + +**這不是完整 A/B 版本回退。**rootfs 校驗損壞時,既有 bootloader 可嘗試原廠 Bank 2;回到原廠後沒有此 OTA 服務,需要原有復原/燒錄流程。kernel、早期 init 或兩份 journal 同時損壞也不屬於應用維修頁能處理的範圍。原機斷電及 driver 行為必須另行驗收。 + +## 從來源產生新版 + +依 [建置說明](build.md) 準備 Docker/固定依賴/工具鏈。建置可使用已固定 SHA-256 的 v0.1.0-preview 公開去識別化 rootfs,不需要把裝置備份放入 CI。簽章私鑰不掛入編譯容器。 + +維護者的私鑰保存在自己的 `private/ota-signing.key`,公鑰是 `third_party/ota-signing.pub`。發布既有裝置更新時必須沿用原金鑰;不要重新執行 keygen。fork 的維護者須自行產生金鑰,替換其來源公鑰,並首次安裝自己的信任來源。 + +1. 修改 `version.json`,增加 `software_version` 並更新版本名稱;例如數值 3、名稱 `0.3.0-preview`。 +2. 執行完整建置;工具也會產生 Matter 執行檔的版本紀錄,拒絕混用舊執行檔或測試 backend: + +```bash +python3 tools/release.py --private private/ota-signing.key \ + --output artifacts/releases/v0.3.0-preview +``` + +3. 核對附件及測試後,將 tag 設為 `v` 加版本名稱。GitHub workflow 的 tag 必須與 `version.json` 一致。 + +`release.py` 的 `--skip-compile` 僅供使用剛完成的本機建置;`--sdk` 可指定相同固定 commit 的 SDK 快取。一般發布請使用完整建置。 + +## GitHub 發布設定 + +初次設定由 `tools/setup_github_updates.py --private private/ota-signing.key` 完成:私鑰透過 stdin 交給 GitHub CLI 加密成 repository secret `OTA_SIGNING_KEY_HEX`,不放入 argv、日誌、Git 或 Release。GitHub Pages 使用 workflow 模式。既有 signing secret 不會被默默輪替。 + +- PR 執行 native C/HTTP/儲存測試與瀏覽器 SHA 對照。 +- 發布 tag 後,再建置工具鏈、測試 MIPS 更新器與 Linux PID 1 路徑、產生簽章附件並建立 **草稿 pre-release**。 +- 人工發布 Release 後,另一個 workflow 驗證簽章,把兩種 `.ota` 與索引部署至 Pages。較舊版本不能覆蓋較新的索引。 + +預設索引為 `https://lee98064.github.io/incipio-commandkit/updates/index.json`。尚未發布更新時,網頁會說明來源未就緒,手動上傳仍可使用。首次設定 Pages 不代表附件已經發布。 + +## Matter OTA + +Root endpoint 宣告 OTA Requestor 及 Provider client;使用官方 DefaultOTARequestor、BDXDownloader 與此硬體的 image processor。收到標準 Matter 映像後,檢查內外版本一致,將內層 `.ota` 交給同一更新器驗簽;不執行 Linux 範例中的任意下載程式。版本確認等到裝置健康檢查通過後才通知 Provider。 + +Provider 使用 `matter-matter-ota.bin`。可以依 [SDK Linux Provider 說明](https://github.com/project-chip/connectedhomeip/tree/3bcdd56ba54fb88b2afb4bfef575014671df7aa7/examples/ota-provider-app/linux)建立 Provider,讓 Provider 與插座在同一 fabric,再送出 `AnnounceOTAProvider`。此專案仍使用開發 VID/PID/attestation,沒有登錄正式 DCL 更新;不能因此宣稱 Apple 家庭或 HA 已能自動分發這個自訂版本。 + +目前已驗證 Requestor cluster 可由加密 Matter session 讀取、fabrics/ACL 在清除 RAM 設定並重啟後仍可用。原機 Provider→BDX→安裝→重開的完整流程,以及 Apple/HA 的更新 UI,列為待驗收項目。 + +## 更新 API + +TCP 18082,只綁定 `br0`,Host 必須為該連線的 IPv4 與 port;有 Origin 時必須同源。下載、上傳、套用皆有固定範圍,沒有任意 shell 或路徑入口。 + +| API | 行為 | +|---|---| +| `GET /` | 自包含管理頁,不載入第三方 JavaScript | +| `GET /status` | 版本、種類、維修及健康狀態,不含金鑰 | +| `POST /upload` | 接收完整 `.ota`,驗證並暫存 RAM,不寫 rootfs | +| `POST /apply` | 排程 PID 1 安裝,可附所選 package hash 核對 | + +不需要更新憑證。`X-CK-SHA256` 是可選的傳輸 checksum;管理頁與電腦工具自動附上,上傳時對應 body,apply 時對應先前核對的完整 package。未提供 checksum 的區網用戶端仍可直接更新,但更新包簽章、版本、機型、平台與長度檢查不會省略。服務另檢查來源 IP、Host、Origin、重複 header 及固定長度。原有診斷和 Wi-Fi 設定服務仍使用其原認證。 + +`.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。 diff --git a/docs/usage.md b/docs/usage.md index b442b86..0b3b2e5 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -12,7 +12,9 @@ Matter On/Off/Toggle 會送出 MCU 命令並等待狀態確認;按鍵回報同步到 Matter 屬性。MCU 未確認時命令失敗,不假裝已切換。冷啟動要求 OFF 確認;Identify 與負載燈號邏輯包含於原始碼。首次原機驗收先採無負載測試。 -**fabrics/配對紀錄目前只在 RAM,斷電後需重新配對。**本版不啟動 HomeKit server,且停用原廠 OTA。保留 Bank 2 不代表應用崩潰時 bootloader 必然自動切換。 +v0.2 的 fabrics/配對紀錄存入 Flash journal,後續重開及 OTA 沿用相同身分與配對。從 v0.1 首次轉入仍需重新配對。本版不啟動原廠 HomeKit server;[新版更新服務](updates.md)使用簽章包。保留 Bank 2 不代表應用崩潰時 bootloader 必然自動切換。 + +電力量測使用標準 Matter cluster,可讀取並訂閱電壓、RMS 電流與功率。MCU 無有效資料時為 null;各平台是否顯示取決於其 cluster 支援,見 [讀值及平台驗收](measurements.md)。 ## 執行檔診斷版 diff --git a/firmware/exec-test/src/exec-diag-start b/firmware/exec-test/src/exec-diag-start index a965d11..d71443c 100644 --- a/firmware/exec-test/src/exec-diag-start +++ b/firmware/exec-test/src/exec-diag-start @@ -2,6 +2,6 @@ umask 077 sleep 15 while :; do - /bin/exec-diag --serve br0 /etc/matter-diag.key + /bin/exec-diag --serve br0 /var/lib/ck-state/diag.key sleep 5 done diff --git a/firmware/exec-test/src/setup/ck-setup-backend b/firmware/exec-test/src/setup/ck-setup-backend index ed71e18..2437e73 100644 --- a/firmware/exec-test/src/setup/ck-setup-backend +++ b/firmware/exec-test/src/setup/ck-setup-backend @@ -25,7 +25,7 @@ if [ -f /var/run/ck-udhcpc.pid ]; then fi rm -f /var/run/ck-wifi-bound if [ "$op" = ap ]; then - /bin/ck-wificfg ap /etc/ck-setup-ap.bin + /bin/ck-wificfg ap /var/lib/ck-state/setup-ap.bin /bin/sysconf init ap all /bin/killall boa dnrd udhcpd 2>/dev/null || true sleep 1 diff --git a/firmware/exec-test/src/setup/ck-setup-start b/firmware/exec-test/src/setup/ck-setup-start index ab72959..df03424 100644 --- a/firmware/exec-test/src/setup/ck-setup-start +++ b/firmware/exec-test/src/setup/ck-setup-start @@ -7,4 +7,4 @@ case "$status" in *) 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 /etc/matter-diag.key +exec /bin/ck-setupd --serve /var/lib/ck-state/diag.key diff --git a/firmware/matter/src/matter-app-device/BUILD.gn b/firmware/matter/src/matter-app-device/BUILD.gn index d631428..c4119aa 100644 --- a/firmware/matter/src/matter-app-device/BUILD.gn +++ b/firmware/matter/src/matter-app-device/BUILD.gn @@ -1,5 +1,8 @@ import("//build_overrides/chip.gni") -declare_args() { incipio_uclibc_runtime = false } +declare_args() { + incipio_uclibc_runtime = false + incipio_update_test = false +} config("app_config") { include_dirs = [ "../.." ] @@ -7,8 +10,15 @@ config("app_config") { } executable("matterd-test") { - sources = [ "main.cpp", "DeviceIdentity.cpp", "../vendor/qrcodegen/qrcodegen.c", "../platform_probe.cpp", "../sdk-overrides/TLVReader.cpp", "../relay/RelayEngine.cpp", "../relay/RelayEngine.h", "../relay/McuController.cpp", "../relay/McuController.h" ] + sources = [ "main.cpp", "DeviceIdentity.cpp", "MatterOta.cpp", "ElectricalMeasurements.cpp", "../update/ck_store.c", "../update/ck_common.c", "../update/ck_flash.c", "../vendor/qrcodegen/qrcodegen.c", "../platform_probe.cpp", "../sdk-overrides/TLVReader.cpp", "../sdk-overrides/CHIPLinuxStorage.cpp", "../relay/RelayEngine.cpp", "../relay/RelayEngine.h", "../relay/McuController.cpp", "../relay/McuController.h" ] + sources += [ + "${chip_root}/src/app/clusters/ota-requestor/BDXDownloader.cpp", + "${chip_root}/src/app/clusters/ota-requestor/DefaultOTARequestor.cpp", + "${chip_root}/src/app/clusters/ota-requestor/DefaultOTARequestorDriver.cpp", + "${chip_root}/src/app/clusters/ota-requestor/DefaultOTARequestorStorage.cpp", + ] defines = [ "INCIPIO_MATTER_EMBEDDED_PROBE=1" ] + if (incipio_update_test) { defines += [ "CK_TESTING=1" ] } include_dirs = [ ".", "${chip_root}/examples/all-devices-app/all-devices-common", @@ -20,6 +30,9 @@ executable("matterd-test") { "${chip_root}/src/app/persistence", "${chip_root}/src/app/clusters/on-off-server", "${chip_root}/src/app/clusters/identify-server", + "${chip_root}/src/app/clusters/ota-requestor", + "${chip_root}/src/app/clusters/electrical-power-measurement-server", + "${chip_root}/src/app/clusters/power-topology-server", "${chip_root}/src/data-model-providers/codedriven", "${chip_root}/src/credentials", "${chip_root}/src/lib", diff --git a/firmware/matter/src/matter-app-device/CHIPProjectConfig.h b/firmware/matter/src/matter-app-device/CHIPProjectConfig.h index 95c8adf..8183670 100644 --- a/firmware/matter/src/matter-app-device/CHIPProjectConfig.h +++ b/firmware/matter/src/matter-app-device/CHIPProjectConfig.h @@ -1,4 +1,7 @@ #pragma once +#include "Version.h" +#define CHIP_DEVICE_CONFIG_DEVICE_SOFTWARE_VERSION CK_SOFTWARE_VERSION +#define CHIP_DEVICE_CONFIG_DEVICE_SOFTWARE_VERSION_STRING CK_VERSION_STRING // Phase C, native on-network interoperability prototype. No production claim. #define CHIP_CONFIG_MAX_FABRICS 4 #define CHIP_CONFIG_IM_MAX_NUM_SUBSCRIPTIONS 8 diff --git a/firmware/matter/src/matter-app-device/DeviceIdentity.cpp b/firmware/matter/src/matter-app-device/DeviceIdentity.cpp index f3ceae5..599644e 100644 --- a/firmware/matter/src/matter-app-device/DeviceIdentity.cpp +++ b/firmware/matter/src/matter-app-device/DeviceIdentity.cpp @@ -16,7 +16,9 @@ #include namespace { -constexpr const char * keyPath = "/etc/matter-device.key"; +const char * KeyPath() { + return access("/etc/ck-update.meta",F_OK)==0 ? "/var/lib/ck-state/device.key" : "/etc/matter-device.key"; +} constexpr const char * recordPath = "/var/run/matter-device-identity"; bool ReadExact(const char * path, void * output, size_t length) { int fd = open(path, O_RDONLY | O_NOFOLLOW); @@ -113,7 +115,7 @@ bool InitializeDeviceIdentity(const char * interfaceName) { unlink(recordPath); unlink("/var/run/matter-manual-code"); unlink("/var/run/matter-qr.svg"); unlink("/var/run/ck-setup-ap.bin"); uint8_t key[32]{}; - if (!ReadExact(keyPath, key, sizeof(key)) || strlen(interfaceName) >= IFNAMSIZ) return false; + if (!ReadExact(KeyPath(), key, sizeof(key)) || strlen(interfaceName) >= IFNAMSIZ) return false; int fd = socket(AF_INET, SOCK_DGRAM, 0); struct ifreq request{}; strcpy(request.ifr_name, interfaceName); if (fd < 0) return false; @@ -148,7 +150,7 @@ bool InitializeDeviceIdentity(const char * interfaceName) { bool LoadDeviceIdentity(DeviceIdentityData & identity) { uint8_t record[66]{}, key[32]{}, digest[32]{}; - if (!ReadExact(keyPath, key, sizeof(key)) || !ReadExact(recordPath, record, sizeof(record)) || + if (!ReadExact(KeyPath(), key, sizeof(key)) || !ReadExact(recordPath, record, sizeof(record)) || memcmp(record, "CKI1", 4) || record[10] || record[11] || !Hash(key, record, 34, digest)) return false; uint8_t difference = 0; for (unsigned i = 0; i < 32; ++i) difference |= digest[i] ^ record[34 + i]; diff --git a/firmware/matter/src/matter-app-device/ElectricalMeasurements.cpp b/firmware/matter/src/matter-app-device/ElectricalMeasurements.cpp new file mode 100644 index 0000000..e7dfa68 --- /dev/null +++ b/firmware/matter/src/matter-app-device/ElectricalMeasurements.cpp @@ -0,0 +1,49 @@ +#include "ElectricalMeasurements.h" +using namespace chip; +using namespace chip::app; +namespace EPM = Clusters::ElectricalPowerMeasurement; + +ElectricalMeasurements::PowerCluster::PowerCluster(ElectricalMeasurements & owner) : + ElectricalPowerMeasurementCluster({1, owner, BitMask(EPM::Feature::kAlternatingCurrent), + EPM::ElectricalPowerMeasurementCluster::OptionalAttributesSet() + .Set() + .Set() + .Set()}) {} + +ElectricalMeasurements::ElectricalMeasurements() { + // 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]; } +} +CHIP_ERROR ElectricalMeasurements::Register(CodeDrivenDataModelProvider & model) { + ReturnErrorOnFailure(model.AddCluster(mTopologyRegistration)); + return model.AddCluster(mPowerRegistration); +} +void ElectricalMeasurements::Unregister(CodeDrivenDataModelProvider & model) { + LogErrorOnFailure(model.RemoveCluster(&mPowerCluster)); + LogErrorOnFailure(model.RemoveCluster(&mTopology)); +} +EPM::PowerModeEnum ElectricalMeasurements::GetPowerMode() { return EPM::PowerModeEnum::kAc; } +CHIP_ERROR ElectricalMeasurements::GetAccuracyByIndex(uint8_t index, Accuracy & out) { + using Type = Clusters::detail::MeasurementTypeEnum; + static const Type types[] = {Type::kVoltage, Type::kRMSVoltage, Type::kRMSCurrent, Type::kActivePower}; + if (index>=4) { return CHIP_ERROR_PROVIDER_LIST_EXHAUSTED; } + out={}; out.measurementType=types[index]; out.measured=true; + out.minMeasuredValue=0; out.maxMeasuredValue=mRanges[index].rangeMax; + out.accuracyRanges=DataModel::List(&mRanges[index],1); + return CHIP_NO_ERROR; +} +void ElectricalMeasurements::Poll(const incipio::RelaySnapshot & s, uint64_t now) { + auto value=[](bool valid, uint64_t at, uint64_t current, int64_t numerator, int64_t divisor) -> Value { + if (!valid || current-at>15000) { return {}; } + return DataModel::MakeNullable((numerator+divisor/2)/divisor); + }; + Value voltage=value(s.voltageValid,s.voltageAt,now,int64_t(s.voltageRaw)*1000,64); + Value current=value(s.currentValid,s.currentAt,now,int64_t(s.currentRaw)*1000,256); + Value power=value(s.powerValid,s.powerAt,now,int64_t(s.powerRaw)*1000,1); + if (voltage!=mVoltage) { + mVoltage=voltage; mPowerCluster.Changed(EPM::Attributes::Voltage::Id); mPowerCluster.Changed(EPM::Attributes::RMSVoltage::Id); + } + if (current!=mCurrent) { mCurrent=current; mPowerCluster.Changed(EPM::Attributes::RMSCurrent::Id); } + if (power!=mPower) { mPower=power; mPowerCluster.Changed(EPM::Attributes::ActivePower::Id); } +} diff --git a/firmware/matter/src/matter-app-device/ElectricalMeasurements.h b/firmware/matter/src/matter-app-device/ElectricalMeasurements.h new file mode 100644 index 0000000..4b65b2d --- /dev/null +++ b/firmware/matter/src/matter-app-device/ElectricalMeasurements.h @@ -0,0 +1,60 @@ +#pragma once +#include "../relay/McuController.h" +#include +#include +#include + +// Native Matter attributes; no manufacturer-specific cluster or ecosystem bridge. +// MCU Vrms / Irms are RMS values. Never label Irms as the in-phase ActiveCurrent. +class ElectricalMeasurements final : public chip::app::Clusters::ElectricalPowerMeasurement::Delegate, + public chip::app::Clusters::PowerTopology::Delegate { + using Value = chip::app::DataModel::Nullable; + using Accuracy = chip::app::Clusters::ElectricalPowerMeasurement::Structs::MeasurementAccuracyStruct::Type; + using Range = chip::app::Clusters::ElectricalPowerMeasurement::Structs::MeasurementAccuracyRangeStruct::Type; + class PowerCluster final : public chip::app::Clusters::ElectricalPowerMeasurement::ElectricalPowerMeasurementCluster { + public: + explicit PowerCluster(ElectricalMeasurements & owner); + void Changed(chip::AttributeId id) { NotifyAttributeChanged(id); } + }; + Value mVoltage, mCurrent, mPower; + Range mRanges[4]{}; + PowerCluster mPowerCluster{*this}; + chip::app::ServerClusterRegistration mPowerRegistration{mPowerCluster}; + chip::app::Clusters::PowerTopology::PowerTopologyCluster mTopology{ + {1, *this, chip::BitMask(chip::app::Clusters::PowerTopology::Feature::kNodeTopology)}}; + chip::app::ServerClusterRegistration mTopologyRegistration{mTopology}; +public: + ElectricalMeasurements(); + CHIP_ERROR Register(chip::app::CodeDrivenDataModelProvider & model); + void Unregister(chip::app::CodeDrivenDataModelProvider & model); + void Poll(const incipio::RelaySnapshot & snapshot, uint64_t now); + chip::app::Clusters::ElectricalPowerMeasurement::PowerModeEnum GetPowerMode() override; + uint8_t GetNumberOfMeasurementTypes() override { return 4; } + CHIP_ERROR StartAccuracyRead() override { return CHIP_NO_ERROR; } + CHIP_ERROR GetAccuracyByIndex(uint8_t index, Accuracy & out) override; + CHIP_ERROR EndAccuracyRead() override { return CHIP_NO_ERROR; } + CHIP_ERROR StartRangesRead() override { return CHIP_NO_ERROR; } + CHIP_ERROR GetRangeByIndex(uint8_t, chip::app::Clusters::ElectricalPowerMeasurement::Structs::MeasurementRangeStruct::Type &) override { return CHIP_ERROR_PROVIDER_LIST_EXHAUSTED; } + CHIP_ERROR EndRangesRead() override { return CHIP_NO_ERROR; } + CHIP_ERROR StartHarmonicCurrentsRead() override { return CHIP_NO_ERROR; } + CHIP_ERROR GetHarmonicCurrentsByIndex(uint8_t, chip::app::Clusters::ElectricalPowerMeasurement::Structs::HarmonicMeasurementStruct::Type &) override { return CHIP_ERROR_PROVIDER_LIST_EXHAUSTED; } + CHIP_ERROR EndHarmonicCurrentsRead() override { return CHIP_NO_ERROR; } + CHIP_ERROR StartHarmonicPhasesRead() override { return CHIP_NO_ERROR; } + CHIP_ERROR GetHarmonicPhasesByIndex(uint8_t, chip::app::Clusters::ElectricalPowerMeasurement::Structs::HarmonicMeasurementStruct::Type &) override { return CHIP_ERROR_PROVIDER_LIST_EXHAUSTED; } + CHIP_ERROR EndHarmonicPhasesRead() override { return CHIP_NO_ERROR; } + Value GetVoltage() override { return mVoltage; } + Value GetRMSVoltage() override { return mVoltage; } + Value GetRMSCurrent() override { return mCurrent; } + Value GetActivePower() override { return mPower; } + Value GetActiveCurrent() override { return {}; } + Value GetReactiveCurrent() override { return {}; } + Value GetApparentCurrent() override { return {}; } + Value GetReactivePower() override { return {}; } + Value GetApparentPower() override { return {}; } + Value GetRMSPower() override { return {}; } + Value GetFrequency() override { return {}; } + Value GetPowerFactor() override { return {}; } + Value GetNeutralCurrent() override { return {}; } + CHIP_ERROR GetAvailableEndpointAtIndex(size_t, chip::EndpointId &) override { return CHIP_ERROR_PROVIDER_LIST_EXHAUSTED; } + CHIP_ERROR GetActiveEndpointAtIndex(size_t, chip::EndpointId &) override { return CHIP_ERROR_PROVIDER_LIST_EXHAUSTED; } +}; diff --git a/firmware/matter/src/matter-app-device/FlashStorage.h b/firmware/matter/src/matter-app-device/FlashStorage.h new file mode 100644 index 0000000..4e3486a --- /dev/null +++ b/firmware/matter/src/matter-app-device/FlashStorage.h @@ -0,0 +1,36 @@ +#pragma once +#include "../update/ck_store.h" +#include +#include +class FlashStorage final : public chip::PersistentStorageDelegate { +public: + CHIP_ERROR SyncGetKeyValue(const char *key, void *value, + uint16_t &size) override { + if (!key || (size && !value)) + return CHIP_ERROR_INVALID_ARGUMENT; + size_t n = size; + int r = ck_store_get((std::string("matter/") + key).c_str(), value, &n); + if (n > 65535) + return CHIP_ERROR_BUFFER_TOO_SMALL; + size = static_cast(n); + return r == 0 ? CHIP_NO_ERROR + : r == 1 ? CHIP_ERROR_PERSISTED_STORAGE_VALUE_NOT_FOUND + : r == 2 ? CHIP_ERROR_BUFFER_TOO_SMALL + : CHIP_ERROR_PERSISTED_STORAGE_FAILED; + } + CHIP_ERROR SyncSetKeyValue(const char *key, const void *value, + uint16_t size) override { + if (!key || (size && !value)) + return CHIP_ERROR_INVALID_ARGUMENT; + return ck_store_set((std::string("matter/") + key).c_str(), value, size) + ? CHIP_ERROR_PERSISTED_STORAGE_FAILED + : CHIP_NO_ERROR; + } + CHIP_ERROR SyncDeleteKeyValue(const char *key) override { + if (!key) + return CHIP_ERROR_INVALID_ARGUMENT; + return ck_store_delete((std::string("matter/") + key).c_str()) + ? CHIP_ERROR_PERSISTED_STORAGE_FAILED + : CHIP_NO_ERROR; + } +}; diff --git a/firmware/matter/src/matter-app-device/MatterOta.cpp b/firmware/matter/src/matter-app-device/MatterOta.cpp new file mode 100644 index 0000000..175e8bb --- /dev/null +++ b/firmware/matter/src/matter-app-device/MatterOta.cpp @@ -0,0 +1,233 @@ +#include "MatterOta.h" +#include "../update/ck_common.h" +#include "../update/ck_store.h" +#include "Version.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +using namespace chip; +namespace { +constexpr const char *download = "/var/run/ck-ota/matter.ota"; +int RunUpdater(const char *op, const char *file = nullptr) { + pid_t child = fork(); + if (child < 0) + return -1; + if (child == 0) { + if (file) + execl("/bin/ck-updater", "ck-updater", op, file, nullptr); + else + execl("/bin/ck-updater", "ck-updater", op, nullptr); + _exit(127); + } + int status = 0; + while (waitpid(child, &status, 0) < 0) { + if (errno != EINTR) + return -1; + } + return status; +} +class Processor final : public OTAImageProcessorInterface { + int fd = -1; + int transfer = -1; + uint32_t expectedVersion = 0; + char packageHash[65]{}; + OTAImageHeaderParser parser; + std::vector buffer; + static void Prepared(intptr_t p) { + auto *self = reinterpret_cast(p); + self->parser.Init(); + self->mParams = {}; + self->expectedVersion = 0; + self->transfer = ck_download_begin(); + if (self->transfer < 0) { + (void)self->downloader->OnPreparedForDownload(CHIP_ERROR_BUSY); + return; + } + unlink(download); + self->fd = open(download, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0600); + if (self->fd < 0) { + close(self->transfer); + self->transfer = -1; + } + (void)self->downloader->OnPreparedForDownload( + self->fd < 0 ? CHIP_ERROR_OPEN_FAILED : CHIP_NO_ERROR); + } + static void Block(intptr_t p) { + auto *self = reinterpret_cast(p); + ByteSpan block(self->buffer.data(), self->buffer.size()); + if (self->parser.IsInitialized()) { + OTAImageHeader header; + CHIP_ERROR err = self->parser.AccumulateAndDecode(block, header); + if (err == CHIP_ERROR_BUFFER_TOO_SMALL) { + (void)self->downloader->FetchNextData(); + return; + } + if (err != CHIP_NO_ERROR || header.mPayloadSize != 0x290000 + 256 || + header.mSoftwareVersion <= CK_SOFTWARE_VERSION) { + LogErrorOnFailure(self->Abort()); + self->downloader->EndDownload(CHIP_ERROR_INVALID_FILE_IDENTIFIER); + return; + } + self->mParams.totalFileBytes = header.mPayloadSize; + self->expectedVersion = header.mSoftwareVersion; + self->parser.Clear(); + } + if (self->fd < 0 || self->mParams.downloadedBytes + block.size() > + self->mParams.totalFileBytes) { + LogErrorOnFailure(self->Abort()); + self->downloader->EndDownload(CHIP_ERROR_INVALID_FILE_IDENTIFIER); + return; + } + size_t at = 0; + while (at < block.size()) { + ssize_t n = write(self->fd, block.data() + at, block.size() - at); + if (n < 0 && errno == EINTR) + continue; + if (n <= 0) { + LogErrorOnFailure(self->Abort()); + self->downloader->EndDownload(CHIP_ERROR_WRITE_FAILED); + return; + } + at += static_cast(n); + } + self->mParams.downloadedBytes += block.size(); + (void)self->downloader->FetchNextData(); + } + +public: + OTADownloader *downloader = nullptr; + CHIP_ERROR PrepareDownload() override { + return DeviceLayer::PlatformMgr().ScheduleWork( + Prepared, reinterpret_cast(this)); + } + CHIP_ERROR ProcessBlock(ByteSpan &b) override { + if (b.size() > 4096) + return CHIP_ERROR_INVALID_ARGUMENT; + buffer.assign(b.begin(), b.end()); + return DeviceLayer::PlatformMgr().ScheduleWork( + Block, reinterpret_cast(this)); + } + CHIP_ERROR Finalize() override { + if (fd < 0) + return CHIP_ERROR_INCORRECT_STATE; + int r = fsync(fd); + if (close(fd)) + r = -1; + fd = -1; + uint8_t inner[24]; + int check = open(download, O_RDONLY | O_NOFOLLOW); + ssize_t count = check < 0 || ck_read_all(check, inner, sizeof(inner)) ? -1 : sizeof(inner); + uint8_t digest[32]; + if (check < 0 || ck_hash_fd(check, 0, CK_OTA_HEADER + CK_ROOTFS_SIZE, digest)) r = -1; + else ck_hex(digest, 32, packageHash); + if (check >= 0) + close(check); + uint32_t innerVersion = + count == 24 ? uint32_t(inner[20]) << 24 | uint32_t(inner[21]) << 16 | + uint32_t(inner[22]) << 8 | inner[23] + : 0; + if (innerVersion != expectedVersion || !GetRequestorInstance() || + GetRequestorInstance()->GetTargetVersion() != expectedVersion) + r = -1; + if (r || mParams.downloadedBytes != mParams.totalFileBytes || + RunUpdater("--stage", download)) { + unlink(download); + if (transfer >= 0) + close(transfer); + transfer = -1; + return CHIP_ERROR_INVALID_FILE_IDENTIFIER; + } + unlink(download); + if (transfer >= 0) + close(transfer); + transfer = -1; + return CHIP_NO_ERROR; + } + CHIP_ERROR Abort() override { + if (fd >= 0) + close(fd); + if (transfer >= 0) + close(transfer); + transfer = -1; + fd = -1; + unlink(download); + buffer.clear(); + return CHIP_NO_ERROR; + } + CHIP_ERROR Apply() override { + return RunUpdater("--queue", packageHash) ? CHIP_ERROR_WRITE_FAILED : CHIP_NO_ERROR; + } + bool IsFirstImageRun() override { + size_t size = 4; + uint8_t value[4]; + return GetRequestorInstance() && + GetRequestorInstance()->GetCurrentUpdateState() == + OTARequestorInterface::OTAUpdateStateEnum::kApplying && + ck_store_get("ota/pending", value, &size) == 0; + } + CHIP_ERROR ConfirmCurrentImage() override { + return ck_store_floor() == CK_SOFTWARE_VERSION ? CHIP_NO_ERROR + : CHIP_ERROR_INCORRECT_STATE; + } +}; +DefaultOTARequestor requestor; +DefaultOTARequestorStorage requestorStorage; +class HealthDriver final : public DeviceLayer::DefaultOTARequestorDriver { + bool notify = false; + +public: + void InitDeferred(OTARequestorInterface *core, + OTAImageProcessorInterface *image) { + mRequestor = core; + mImageProcessor = image; + mProviderRetryCount = 0; + mInvalidSessionRetryCount = 0; + notify = image->IsFirstImageRun(); + if (!notify && core->GetCurrentUpdateState() != + OTARequestorInterface::OTAUpdateStateEnum::kIdle) + core->Reset(); + } + void Healthy() { + if (notify && mImageProcessor->ConfirmCurrentImage() == CHIP_NO_ERROR) { + (void)mRequestor->NotifyUpdateApplied(); + notify = false; + } + StartPeriodicQueryTimer(); + } +}; +HealthDriver driver; +BDXDownloader downloader; +Processor processor; +OTARequestorAttributes attributes; +app::LazyRegisteredServerCluster cluster; +} // namespace +CHIP_ERROR RegisterMatterOta(app::CodeDrivenDataModelProvider &model) { + cluster.Create(0, requestor, attributes, + Server::GetInstance().GetFabricTable()); + return model.AddCluster(cluster.Registration()); +} +CHIP_ERROR StartMatterOta() { + SetRequestorInstance(&requestor); + requestorStorage.Init(Server::GetInstance().GetPersistentStorage()); + driver.SetPeriodicQueryTimeout(86400); + ReturnErrorOnFailure(requestor.Init(Server::GetInstance(), requestorStorage, + driver, downloader, attributes, + cluster.Cluster())); + driver.InitDeferred(&requestor, &processor); + processor.downloader = &downloader; + downloader.SetImageProcessorDelegate(&processor); + return CHIP_NO_ERROR; +} +void MatterOtaHealthy() { driver.Healthy(); } diff --git a/firmware/matter/src/matter-app-device/MatterOta.h b/firmware/matter/src/matter-app-device/MatterOta.h new file mode 100644 index 0000000..927b202 --- /dev/null +++ b/firmware/matter/src/matter-app-device/MatterOta.h @@ -0,0 +1,6 @@ +#pragma once +#include +#include +CHIP_ERROR RegisterMatterOta(chip::app::CodeDrivenDataModelProvider &model); +CHIP_ERROR StartMatterOta(); +void MatterOtaHealthy(); diff --git a/firmware/matter/src/matter-app-device/Version.h b/firmware/matter/src/matter-app-device/Version.h new file mode 100644 index 0000000..d26c8a5 --- /dev/null +++ b/firmware/matter/src/matter-app-device/Version.h @@ -0,0 +1,3 @@ +#pragma once +#define CK_SOFTWARE_VERSION 2 +#define CK_VERSION_STRING "0.2.0-preview" diff --git a/firmware/matter/src/matter-app-device/args.gni b/firmware/matter/src/matter-app-device/args.gni index c20d7f0..4566a1a 100644 --- a/firmware/matter/src/matter-app-device/args.gni +++ b/firmware/matter/src/matter-app-device/args.gni @@ -12,11 +12,11 @@ chip_detail_logging = false chip_automation_logging = false chip_data_model_extra_logging = false chip_persist_subscriptions = false -chip_enable_ota_requestor = false +chip_enable_ota_requestor = true chip_with_lwip = false enable_pie = false -chip_enable_read_client = false +chip_enable_read_client = true chip_enable_transport_trace = false chip_project_config_include = "" chip_project_config_include_dirs = [ "/work/src", "${chip_root}/config/standalone" ] diff --git a/firmware/matter/src/matter-app-device/main.cpp b/firmware/matter/src/matter-app-device/main.cpp index 9c2c719..dc1808b 100644 --- a/firmware/matter/src/matter-app-device/main.cpp +++ b/firmware/matter/src/matter-app-device/main.cpp @@ -1,5 +1,5 @@ // Integrated relay test: one MCU owner and confirmed physical state. -// Fabrics remain RAM-only. No MTD writes or meter attributes. +// Fabrics use a bounded, verified journal. Firmware update writes only Bank 1. #include #include #include @@ -32,6 +32,10 @@ #include #include #include "DeviceIdentity.h" +#include "FlashStorage.h" +#include "ElectricalMeasurements.h" +#include "MatterOta.h" +#include "Version.h" static DeviceIdentityData deviceIdentity; using namespace chip; using namespace chip::app; @@ -142,27 +146,35 @@ class TestIdentify final : public Clusters::IdentifyDelegate { void OnTriggerEffect(Clusters::IdentifyCluster&) override{} bool IsTriggerEffectEnabled()const override{return false;} }; +static const DataModel::DeviceTypeEntry plugTypes[] = {Device::Type::kOnOffPlugInUnit, Device::Type::kElectricalSensor}; class TestPlug final : public SingleEndpoint { + ElectricalMeasurements measurements; RelayOnOff onoff; ServerClusterRegistration onoffRegistration{onoff}; TestIdentify identifyDelegate; LazyRegisteredServerCluster identify; public: - TestPlug():SingleEndpoint(Span(&Device::Type::kOnOffPlugInUnit,1)){} - void Poll() { onoff.Poll(); } + TestPlug():SingleEndpoint(Span(plugTypes)){} + 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)); identify.Create(Clusters::IdentifyCluster::Config(id,timer).WithDelegate(&identifyDelegate).WithIdentifyType(Clusters::Identify::IdentifyTypeEnum::kVisibleIndicator)); ReturnErrorOnFailure(model.AddCluster(identify.Registration())); ReturnErrorOnFailure(model.AddCluster(onoffRegistration)); + ReturnErrorOnFailure(measurements.Register(model)); return model.AddEndpoint(mEndpointRegistration); } void Unregister(CodeDrivenDataModelProvider&model) override { + measurements.Unregister(model); LogErrorOnFailure(model.RemoveCluster(&onoff)); LogErrorOnFailure(model.RemoveCluster(&identify.Cluster()));identify.Destroy();UnregisterDescriptor(model); } }; -static BoundedRamStorage storage; +static BoundedRamStorage vmStorage; +static FlashStorage flashStorage; +static PersistentStorageDelegate *storage = &flashStorage; +static bool isVm = false, healthConfirmed = false; +static uint64_t healthySince = 0; static Identity identity; static CommonCaseDeviceServerInitParams params; static DefaultAttributePersistenceProvider attributes; @@ -172,7 +184,16 @@ static std::unique_ptr root; static TestPlug plug; static void PollHardware(System::Layer * layer, void *) { if (stopping) { hardware.Stop(); _exit(0); } - plug.Poll(); LogErrorOnFailure(layer->StartTimer(System::Clock::Milliseconds32(100), PollHardware, nullptr)); + plug.Poll(); + auto snapshot=hardware.Snapshot();uint64_t now=incipio::McuController::Now(); + if(!isVm&&!healthConfirmed){ + if(!snapshot.known||now-snapshot.relayAt>10000)healthySince=0; + else if(!healthySince)healthySince=now; + else if(now-healthySince>=30000&&ck_store_confirm(CK_SOFTWARE_VERSION)==0){ + int fd=open("/var/run/ck-update-healthy",O_WRONLY|O_CREAT|O_TRUNC,0600);if(fd>=0){close(fd);healthConfirmed=true;MatterOtaHealthy();} + } + } + LogErrorOnFailure(layer->StartTimer(System::Clock::Milliseconds32(100), PollHardware, nullptr)); } static int Fail(CHIP_ERROR err,const char*where){ std::fprintf(stderr,"matterd-test: %s failed: %" CHIP_ERROR_FORMAT "\n",where,err.Format()); @@ -192,12 +213,14 @@ int main(int argc,char**argv){ if (!LoadDeviceIdentity(deviceIdentity)) { std::fputs("device identity unavailable or invalid; no shared pairing code fallback\n",stderr); _exit(5); } - bool vm = argc == 5 && strcmp(argv[1], "--vm") == 0; + bool persistentVm=argc==5&&strcmp(argv[1],"--vm-persistent")==0; + bool vm = argc == 5 && (strcmp(argv[1], "--vm") == 0 || persistentVm); + isVm=vm;if(vm&&!persistentVm)storage=&vmStorage; bool hardwareOnly = argc == 4 && strcmp(argv[1], "--hardware-only") == 0; const char * iface = vm ? argv[2] : (argc==2 ? argv[1] : "br0"); const char * port = (vm || hardwareOnly) ? argv[hardwareOnly ? 2 : 3] : "/dev/ttyACM0"; const char * gpio = (vm || hardwareOnly) ? argv[hardwareOnly ? 3 : 4] : "/proc/wlan0/gpio_ctrl"; - std::puts("matterd-test/relay-test SDK v1.6.1.0; one MCU owner; cold OFF; fabrics are RAM-only"); + std::puts("matterd-test " CK_VERSION_STRING "; persistent fabrics; signed OTA; one MCU owner; cold OFF"); signal(SIGTERM, SignalStop); signal(SIGINT, SignalStop); if (!hardware.Start(port, gpio)) { std::fputs("MCU ownership/thread setup failed\n", stderr); return 4; } if (!vm) { @@ -213,28 +236,30 @@ int main(int argc,char**argv){ TRY(Platform::MemoryInit());TRY(PlatformMgr().InitChipStack());TRY(identity.Init()); SetCommissionableDataProvider(&identity); Credentials::SetDeviceAttestationCredentialsProvider(Credentials::Examples::GetExampleDACProvider()); - params.persistentStorageDelegate=&storage;TRY(params.InitializeStaticResourcesBeforeServerInit()); - TRY(attributes.Init(&storage));TRY(safeAttributes.Init(&storage));SetSafeAttributePersistenceProvider(&safeAttributes); - model=std::make_unique(storage,attributes); + params.persistentStorageDelegate=storage;TRY(params.InitializeStaticResourcesBeforeServerInit()); + TRY(attributes.Init(storage));TRY(safeAttributes.Init(storage));SetSafeAttributePersistenceProvider(&safeAttributes); + model=std::make_unique(*storage,attributes); auto&s=Server::GetInstance();auto*info=GetDeviceInstanceInfoProvider();if(info==nullptr)return 3; root=std::make_unique(RootNode::Context{ .commissioningWindowManager=s.GetCommissioningWindowManager(),.configurationManager=ConfigurationMgr(), .deviceControlServer=DeviceControlServer::DeviceControlSvr(),.fabricTable=s.GetFabricTable(), - .accessControl=s.GetAccessControl(),.persistentStorage=storage,.failSafeContext=s.GetFailSafeContext(), + .accessControl=s.GetAccessControl(),.persistentStorage=*storage,.failSafeContext=s.GetFailSafeContext(), .deviceInstanceInfoProvider=*info,.platformManager=PlatformMgr(),.groupDataProvider=*params.groupDataProvider, .sessionManager=s.GetSecureSessionManager(),.dnssdServer=DnssdServer::Instance(), .deviceLoadStatusProvider=*InteractionModelEngine::GetInstance(),.diagnosticDataProvider=GetDiagnosticDataProvider(), .testEventTriggerDelegate=nullptr,.dacProvider=*Credentials::GetDeviceAttestationCredentialsProvider(), .eventManagement=EventManagement::GetInstance(),.timerDelegate=timer}); TRY(root->Register(0,*model));TRY(plug.Register(1,*model)); + TRY(RegisterMatterOta(*model)); params.dataModelProvider=model.get();params.interfaceId=Inet::InterfaceId(index); TRY(s.Init(params)); + TRY(StartMatterOta()); 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); std::string qr;TRY(QRCodeSetupPayloadGenerator(payload).payloadBase38Representation(qr)); // Redirect logs to bounded RAM sink in firmware; QR is available only through authenticated download. - std::printf("ONBOARDING_QR=%s\nREADY: interface=%s; port=5540; confirmed relay enabled; fabrics RAM-only\n",qr.c_str(),iface); + std::printf("ONBOARDING_QR=%s\nREADY: interface=%s; port=5540; confirmed relay enabled; persistent fabrics; signed OTA\n",qr.c_str(),iface); TRY(SystemLayer().StartTimer(System::Clock::Milliseconds32(100), PollHardware, nullptr)); PlatformMgr().RunEventLoop();hardware.Stop();return 0; } diff --git a/firmware/matter/src/matter-app-device/third_party/connectedhomeip b/firmware/matter/src/matter-app-device/third_party/connectedhomeip new file mode 120000 index 0000000..7eaf153 --- /dev/null +++ b/firmware/matter/src/matter-app-device/third_party/connectedhomeip @@ -0,0 +1 @@ +../../../deps/connectedhomeip \ No newline at end of file diff --git a/firmware/matter/src/matter-diag-start b/firmware/matter/src/matter-diag-start index 2af38cc..947f03d 100644 --- a/firmware/matter/src/matter-diag-start +++ b/firmware/matter/src/matter-diag-start @@ -5,6 +5,6 @@ sleep 15 # The vendor may assign the br0 address after starting userspace. # One daemon, no growing log files, no shell command API. while :; do - /bin/matter-diag --serve br0 /etc/matter-diag.key + /bin/matter-diag --serve br0 /var/lib/ck-state/diag.key sleep 5 done diff --git a/firmware/matter/src/matter_device_init.c b/firmware/matter/src/matter_device_init.c index fae5faf..63e5d23 100644 --- a/firmware/matter/src/matter_device_init.c +++ b/firmware/matter/src/matter_device_init.c @@ -16,6 +16,10 @@ #include #include #include +#ifdef CK_TESTING +#include "update/ck_store.h" +#include +#endif static int module(const char *name) { int fd = open(name, O_RDONLY); if (fd < 0) return -1; int n = lseek(fd, 0, SEEK_END); lseek(fd, 0, SEEK_SET); @@ -27,6 +31,11 @@ int main(void) { setvbuf(stdout, NULL, _IONBF, 0); mount("proc", "/proc", "proc", 0, NULL); mount("ramfs", "/var", "ramfs", 0, NULL); mkdir("/var/run", 0755); mkdir("/var/log", 0755); +#ifdef CK_TESTING + 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;} +#endif if (module("/mii.ko") || module("/pcnet32.ko")) { perror("network module"); 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"); @@ -54,16 +63,35 @@ int main(void) { puts("DEVICE_IDENTITY_VM_READY"); puts("MATTER_TLV_VM_NETWORK_READY"); sleep(2); +restart_application: int child = fork(); if (!child) { struct rlimit limit = {8*1024*1024,8*1024*1024}; if (setrlimit(RLIMIT_AS,&limit)) _exit(126); alarm(300); - char *args[] = {"/bin/matterd-test","--vm","br0","/dev/ttyS1","/tmp/gpio",NULL}; + char *args[] = {"/bin/matterd-test", +#ifdef CK_TESTING + "--vm-persistent", +#else + "--vm", +#endif + "br0","/dev/ttyS1","/tmp/gpio",NULL}; execv(args[0],args); _exit(127); } int status; +#ifdef CK_TESTING + int control=open("/dev/ttyS0",O_RDONLY|O_NONBLOCK); +#endif while (waitpid(child,&status,WNOHANG)==0) { +#ifdef CK_TESTING + char command[64];int got=control<0?0:read(control,command,sizeof(command)-1); + if(got>0){command[got]=0;if(strstr(command,"restart")){ + kill(child,SIGTERM);waitpid(child,&status,0);close(control); + kill(-1,SIGKILL);while(waitpid(-1,NULL,WNOHANG)>0){}sleep(1); + unlink("/tmp/chip_factory.ini");unlink("/tmp/chip_config.ini");unlink("/tmp/chip_counters.ini"); + unlink("/tmp/chip_kvs");puts("PERSISTENT_VM_RESTARTING_WITHOUT_COMMISSIONING");goto restart_application; + }} +#endif int report = open("/var/log/matter-hardware.txt",O_RDONLY); if (report>=0) { char text[1024]; int n=read(report,text,sizeof(text)); if (n>0) write(1,text,n); close(report); } sleep(1); diff --git a/firmware/matter/src/relay/McuController.cpp b/firmware/matter/src/relay/McuController.cpp index 9772daa..006278e 100644 --- a/firmware/matter/src/relay/McuController.cpp +++ b/firmware/matter/src/relay/McuController.cpp @@ -86,14 +86,17 @@ void McuController::Gpio(const char * command) } void McuController::Report(uint64_t now, const RelaySnapshot & s, bool led) { - char text[768]; + 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" - "reset_events=%u reset_action=record_only\nfabrics=RAM_only\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", 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.resetEvents); + 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.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); if (fd >= 0) { if (n > 0 && n < int(sizeof(text)) && write(fd, text, n) == n) { close(fd); rename("/var/log/matter-hardware.txt.tmp", "/var/log/matter-hardware.txt"); } diff --git a/firmware/matter/src/relay/RelayEngine.cpp b/firmware/matter/src/relay/RelayEngine.cpp index 3c0d843..f52a722 100644 --- a/firmware/matter/src/relay/RelayEngine.cpp +++ b/firmware/matter/src/relay/RelayEngine.cpp @@ -13,7 +13,7 @@ bool RelayEngine::Request(uint32_t id, bool on, uint64_t now) { if (!id || Busy() || (on && !mState.bootOffConfirmed)) { return false; } mId = id; mDesired = on; mCommand = Command::Send; mDeadline = now + 3000; - mQuery = Query::None; mCount = 0; + mQuery = Query::None; mCount = 0; mQuietUntil = now + 800; return true; } void RelayEngine::Finish(RelayResult result) @@ -26,7 +26,12 @@ void RelayEngine::Finish(RelayResult result) bool RelayEngine::NextTx(uint64_t now, McuTx & tx) { if (Busy() && now >= mDeadline) { Finish(RelayResult::Timeout); } - if (mQuery != Query::None && now >= mQueryDeadline) { mQuery = Query::None; mCount = 0; } + if (mQuery != Query::None && now >= mQueryDeadline) { + if (mQuery == Query::Voltage) { mState.voltageValid = false; } + if (mQuery == Query::Current) { mState.currentValid = false; } + if (mQuery == Query::Power) { mState.powerValid = false; } + mQuery = Query::None; mCount = 0; mQuietUntil = now + 200; + } if (mCommand == Command::Send) { Frame(0xa1, mDesired, tx); mCommand = Command::Verify; mNextVerify = now + 100; return true; @@ -37,11 +42,15 @@ bool RelayEngine::NextTx(uint64_t now, McuTx & tx) mNextVerify = now + 700; mCount = 0; return true; } - if (mQuery != Query::None) { return false; } + if (mQuery != Query::None || now < mQuietUntil) { return false; } if (now >= mRelayDue) { Frame(0xa4, false, tx); mQuery = Query::Relay; mRelayDue = now + 2000; } else if (now >= mPowerDue) { Frame(0xa7, false, tx); mQuery = Query::Power; mPowerDue = now + 5000; + } else if (now >= mVoltageDue) { + Frame(0xa5, false, tx); mQuery = Query::Voltage; mVoltageDue = now + 5000; + } else if (now >= mCurrentDue) { + Frame(0xa6, false, tx); mQuery = Query::Current; mCurrentDue = now + 5000; } else { return false; } mQueryDeadline = now + 600; mCount = 0; return true; @@ -60,8 +69,20 @@ void RelayEngine::Feed(uint8_t byte, uint64_t now) mQuery = Query::None; } mCount = 0; - } else if (mWindow[0] == 0xff && mWindow[3] == 0x55 && mQuery == Query::Power && now < mQueryDeadline) { - mState.powerRaw = uint32_t(mWindow[1]) + 256u * mWindow[2]; mState.powerAt = now; ++mState.powerFrames; + } else if (mWindow[0] == 0xff && mWindow[3] == 0x55 && (mQuery == Query::Power || mQuery == Query::Voltage || mQuery == Query::Current) && now < mQueryDeadline) { + uint32_t raw = uint32_t(mWindow[1]) + 256u * mWindow[2]; + if (mQuery == Query::Voltage) { + mState.voltageRaw = raw; mState.voltageAt = now; ++mState.voltageFrames; + mState.voltageValid = raw <= 300u * 64; + mQuery = Query::None; mCount = 0; return; + } + if (mQuery == Query::Current) { + mState.currentRaw = raw; mState.currentAt = now; ++mState.currentFrames; + mState.currentValid = raw <= 15u * 256; + mQuery = Query::None; mCount = 0; return; + } + mState.powerRaw = raw; mState.powerAt = now; ++mState.powerFrames; + mState.powerValid = raw <= 2000; if (Fresh(now) && mState.on) { if (mState.powerRaw >= 5 && mState.powerRaw <= 2000) { mLow = 0; if (mHigh < 2) { ++mHigh; } if (mHigh == 2) { mState.load = true; } @@ -78,10 +99,10 @@ void RelayEngine::Feed(uint8_t byte, uint64_t now) void RelayEngine::Disconnect() { if (Busy()) { Finish(RelayResult::TransportError); } - mQuery = Query::None; mCount = 0; mState.known = false; mState.load = false; mHigh = mLow = 0; + mQuery = Query::None; mCount = 0; mState.voltageValid = mState.currentValid = mState.powerValid = false; mState.known = false; mState.load = false; mHigh = mLow = 0; } bool RelayEngine::LoadBlink(uint64_t now) const { - return Fresh(now) && mState.on && mState.load && now - mState.powerAt < 15000; + return Fresh(now) && mState.on && mState.powerValid && mState.load && now - mState.powerAt < 15000; } } // namespace incipio diff --git a/firmware/matter/src/relay/RelayEngine.h b/firmware/matter/src/relay/RelayEngine.h index 2eb8d12..6da783b 100644 --- a/firmware/matter/src/relay/RelayEngine.h +++ b/firmware/matter/src/relay/RelayEngine.h @@ -6,6 +6,9 @@ namespace incipio { enum class RelayResult : uint8_t { None, Confirmed, Timeout, TransportError }; struct RelaySnapshot { bool known = false, on = false, bootOffConfirmed = false, load = false; + bool voltageValid = false, currentValid = false, powerValid = false; + uint32_t voltageRaw = 0, currentRaw = 0, voltageFrames = 0, currentFrames = 0; + uint64_t voltageAt = 0, currentAt = 0; uint64_t relayAt = 0, powerAt = 0; uint32_t completedId = 0, powerRaw = 0, relayFrames = 0, powerFrames = 0, resetEvents = 0; RelayResult result = RelayResult::None; @@ -27,13 +30,13 @@ class RelayEngine { bool LoadBlink(uint64_t now) const; private: enum class Command : uint8_t { Idle, Send, Verify }; - enum class Query : uint8_t { None, Relay, Power }; + enum class Query : uint8_t { None, Relay, Power, Voltage, Current }; RelaySnapshot mState; Command mCommand = Command::Idle; Query mQuery = Query::None; bool mDesired = false; uint32_t mId = 0; - uint64_t mDeadline = 0, mNextVerify = 0, mQueryDeadline = 0, mRelayDue = 0, mPowerDue = 5000; + uint64_t mDeadline = 0, mNextVerify = 0, mQueryDeadline = 0, mRelayDue = 0, mPowerDue = 5000, mVoltageDue = 500, mCurrentDue = 1500, mQuietUntil = 0; uint8_t mWindow[4] = {}, mCount = 0, mHigh = 0, mLow = 0; void Finish(RelayResult result); static void Frame(uint8_t code, bool on, McuTx & tx); diff --git a/firmware/matter/src/sdk-overrides/CHIPLinuxStorage.cpp b/firmware/matter/src/sdk-overrides/CHIPLinuxStorage.cpp new file mode 100644 index 0000000..5dcd3e2 --- /dev/null +++ b/firmware/matter/src/sdk-overrides/CHIPLinuxStorage.cpp @@ -0,0 +1,373 @@ +/* + * + * Copyright (c) 2020-2022 Project CHIP Authors + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @file + * This file implements a class for managing client application + * user-editable settings on Linux platform. + * + */ + +#include +#include +#include +#include +#include +#include +extern "C" { +#include "../update/ck_common.h" +} +#include "../update/ck_store.h" +// CommandKit: mirror SDK platform settings through the same verified journal. +// Upstream copyright and license are retained. Provenance: sdk-lock.json. +static bool JournalEnabled() { + return access("/etc/ck-update.meta", F_OK) == 0; +} +static std::string JournalKey(const char *file) { + std::string path = file; + auto at = path.rfind('/'); + return "platform/" + path.substr(at == std::string::npos ? 0 : at + 1); +} +static int RestorePlatform(const char *file) { + if (!JournalEnabled()) + return 0; + std::vector bytes(16384); + size_t size = bytes.size(); + int r = ck_store_get(JournalKey(file).c_str(), bytes.data(), &size); + if (r == 1) + return 0; + if (r) + return -1; + return ck_atomic(file, bytes.data(), size, 0600); +} +static int PersistPlatform(const std::string &file) { + if (!JournalEnabled()) + return 0; + struct stat st; + int fd = open(file.c_str(), O_RDONLY | O_NOFOLLOW | O_CLOEXEC); + if (fd < 0) + return -1; + int r = -1; + if (!fstat(fd, &st) && S_ISREG(st.st_mode) && st.st_size >= 0 && + st.st_size <= 16384) { + std::vector bytes(static_cast(st.st_size)); + if (ck_read_all(fd, bytes.data(), bytes.size()) == 0) + r = ck_store_set(JournalKey(file.c_str()).c_str(), bytes.data(), + bytes.size()); + } + close(fd); + return r; +} +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace chip { +namespace DeviceLayer { +namespace Internal { + +ChipLinuxStorage::ChipLinuxStorage() { mDirty = false; } + +ChipLinuxStorage::~ChipLinuxStorage() {} + +CHIP_ERROR ChipLinuxStorage::Init(const char *configFile) { + CHIP_ERROR retval = CHIP_NO_ERROR; + + if (mInitialized) { + ChipLogError(DeviceLayer, + "ChipLinuxStorage::Init: Attempt to re-initialize with KVS " + "config file: %s, IGNORING.", + StringOrNullMarker(configFile)); + return CHIP_NO_ERROR; + } + + ChipLogDetail(DeviceLayer, + "ChipLinuxStorage::Init: Using KVS config file: %s", + StringOrNullMarker(configFile)); + + if (RestorePlatform(configFile)) + return CHIP_ERROR_PERSISTED_STORAGE_FAILED; + mConfigPath.assign(configFile); + retval = ChipLinuxStorageIni::Init(); + + if (retval == CHIP_NO_ERROR) { + std::ifstream ifs; + + ifs.open(configFile, std::ifstream::in); + + // Create default setting file if not exist. + if (!ifs.good()) { + mDirty = true; + retval = Commit(); + mDirty = false; + } + } + + if (retval == CHIP_NO_ERROR) { + retval = ChipLinuxStorageIni::AddConfig(mConfigPath); + } + + mInitialized = true; + + return retval; +} + +CHIP_ERROR ChipLinuxStorage::ReadValue(const char *key, bool &val) { + CHIP_ERROR retval = CHIP_NO_ERROR; + uint32_t result; + + mLock.lock(); + + retval = ChipLinuxStorageIni::GetUIntValue(key, result); + val = (result != 0); + + mLock.unlock(); + + return retval; +} + +CHIP_ERROR ChipLinuxStorage::ReadValue(const char *key, uint16_t &val) { + CHIP_ERROR retval = CHIP_NO_ERROR; + + mLock.lock(); + + retval = ChipLinuxStorageIni::GetUInt16Value(key, val); + + mLock.unlock(); + + return retval; +} + +CHIP_ERROR ChipLinuxStorage::ReadValue(const char *key, uint32_t &val) { + CHIP_ERROR retval = CHIP_NO_ERROR; + + mLock.lock(); + + retval = ChipLinuxStorageIni::GetUIntValue(key, val); + + mLock.unlock(); + + return retval; +} + +CHIP_ERROR ChipLinuxStorage::ReadValue(const char *key, uint64_t &val) { + CHIP_ERROR retval = CHIP_NO_ERROR; + + mLock.lock(); + + retval = ChipLinuxStorageIni::GetUInt64Value(key, val); + + mLock.unlock(); + + return retval; +} + +CHIP_ERROR ChipLinuxStorage::ReadValueStr(const char *key, char *buf, + size_t bufSize, size_t &outLen) { + CHIP_ERROR retval = CHIP_NO_ERROR; + + mLock.lock(); + + retval = ChipLinuxStorageIni::GetStringValue(key, buf, bufSize, outLen); + + mLock.unlock(); + + return retval; +} + +CHIP_ERROR ChipLinuxStorage::ReadValueBin(const char *key, uint8_t *buf, + size_t bufSize, size_t &outLen) { + CHIP_ERROR retval = CHIP_NO_ERROR; + + mLock.lock(); + + retval = ChipLinuxStorageIni::GetBinaryBlobValue(key, buf, bufSize, outLen); + + mLock.unlock(); + + return retval; +} + +CHIP_ERROR ChipLinuxStorage::WriteValue(const char *key, bool val) { + CHIP_ERROR retval = CHIP_NO_ERROR; + + if (val) { + retval = WriteValue(key, static_cast(1)); + } else { + retval = WriteValue(key, static_cast(0)); + } + + return retval; +} + +CHIP_ERROR ChipLinuxStorage::WriteValue(const char *key, uint16_t val) { + char buf[16]; + + snprintf(buf, sizeof(buf), "%u", val); + + return WriteValueStr(key, buf); +} + +CHIP_ERROR ChipLinuxStorage::WriteValue(const char *key, uint32_t val) { + char buf[32]; + + snprintf(buf, sizeof(buf), "%d", val); + + return WriteValueStr(key, buf); +} + +CHIP_ERROR ChipLinuxStorage::WriteValue(const char *key, uint64_t val) { + char buf[64]; + + snprintf(buf, sizeof(buf), "%" PRIu64, val); + + return WriteValueStr(key, buf); +} + +CHIP_ERROR ChipLinuxStorage::WriteValueStr(const char *key, const char *val) { + CHIP_ERROR retval = CHIP_NO_ERROR; + + mLock.lock(); + + retval = ChipLinuxStorageIni::AddEntry(key, val); + + mDirty = true; + + mLock.unlock(); + + return retval; +} + +CHIP_ERROR ChipLinuxStorage::WriteValueBin(const char *key, const uint8_t *data, + size_t dataLen) { + static const size_t kMaxBlobSize = static_cast(10) * 1024; + + CHIP_ERROR retval = CHIP_NO_ERROR; + chip::Platform::ScopedMemoryBuffer encodedData; + size_t encodedDataLen = 0; + size_t expectedEncodedLen = ((dataLen + 3) * 4) / 3; + + // We only support encoding blobs up to 10kb + if (dataLen > kMaxBlobSize) { + retval = CHIP_ERROR_INVALID_ARGUMENT; + } + + // Compute our expectedEncodedLen + // Allocate just enough space for the encoded data, and the NULL terminator + if (retval == CHIP_NO_ERROR) { + if (!encodedData.Alloc(expectedEncodedLen + 1)) { + retval = CHIP_ERROR_NO_MEMORY; + } + } + + // Encode it + if (retval == CHIP_NO_ERROR) { + // We tested above that dataLen is no more than kMaxBlobSize. + static_assert(kMaxBlobSize < UINT16_MAX, "dataLen won't fit"); + encodedDataLen = + Base64Encode(data, static_cast(dataLen), encodedData.Get()); + encodedData[encodedDataLen] = 0; + } + + // Store it + if (retval == CHIP_NO_ERROR) { + retval = WriteValueStr(key, encodedData.Get()); + } + + return retval; +} + +CHIP_ERROR ChipLinuxStorage::ClearValue(const char *key) { + CHIP_ERROR retval = CHIP_NO_ERROR; + + mLock.lock(); + + retval = ChipLinuxStorageIni::RemoveEntry(key); + + if (retval == CHIP_NO_ERROR) { + mDirty = true; + } else { + retval = CHIP_ERROR_KEY_NOT_FOUND; + } + + mLock.unlock(); + + return retval; +} + +CHIP_ERROR ChipLinuxStorage::ClearAll() { + CHIP_ERROR retval = CHIP_NO_ERROR; + + mLock.lock(); + + retval = ChipLinuxStorageIni::RemoveAll(); + + mLock.unlock(); + + if (retval == CHIP_NO_ERROR) { + mDirty = true; + retval = Commit(); + } else { + retval = CHIP_ERROR_WRITE_FAILED; + } + + return retval; +} + +bool ChipLinuxStorage::HasValue(const char *key) { + bool retval; + + mLock.lock(); + + retval = ChipLinuxStorageIni::HasValue(key); + + mLock.unlock(); + + return retval; +} + +CHIP_ERROR ChipLinuxStorage::Commit() { + CHIP_ERROR retval = CHIP_NO_ERROR; + + if (mDirty && !mConfigPath.empty()) { + mLock.lock(); + + retval = ChipLinuxStorageIni::CommitConfig(mConfigPath); + if (retval == CHIP_NO_ERROR && PersistPlatform(mConfigPath)) + retval = CHIP_ERROR_PERSISTED_STORAGE_FAILED; + + mLock.unlock(); + } else { + retval = CHIP_ERROR_WRITE_FAILED; + } + + return retval; +} + +} // namespace Internal +} // namespace DeviceLayer +} // namespace chip diff --git a/firmware/matter/src/setup/ck-setup-device-start b/firmware/matter/src/setup/ck-setup-device-start index d6211fe..845d2ea 100644 --- a/firmware/matter/src/setup/ck-setup-device-start +++ b/firmware/matter/src/setup/ck-setup-device-start @@ -9,4 +9,4 @@ case "$status" in *) 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 /etc/matter-diag.key +exec /bin/ck-setupd --serve /var/lib/ck-state/diag.key diff --git a/firmware/matter/src/setup/matter-relay-start b/firmware/matter/src/setup/matter-relay-start index 5c1de15..14302ae 100644 --- a/firmware/matter/src/setup/matter-relay-start +++ b/firmware/matter/src/setup/matter-relay-start @@ -1,5 +1,6 @@ #!/bin/sh # Firmware starts only this MCU owner; the V2 executable is retained for recovery. +if [ -f /var/run/ck-rescue ]; then exit 1; fi if [ -n "$(/bin/pidof homekitserver 2>/dev/null)" ]; then echo 'MCU_OWNER_CONFLICT: HomeKit is running; Matter relay not started' > /var/log/matter-test.log exit 1 diff --git a/firmware/matter/src/update/ck-ota-start b/firmware/matter/src/update/ck-ota-start new file mode 100644 index 0000000..c554427 --- /dev/null +++ b/firmware/matter/src/update/ck-ota-start @@ -0,0 +1,47 @@ +#!/bin/sh +set -eu +umask 077 +mkdir -p /var/lib/ck-state /var/run/ck-ota /var/lock +if ! /bin/ck-updater --import; then + echo 'Persistent identity unavailable; network updater and Matter disabled.' > /var/log/ck-update.log + touch /var/run/ck-rescue + exit 1 +fi +/bin/ck-updater --boot || touch /var/run/ck-rescue +( + while :; do + 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-updated > /var/log/ck-update.log 2>&1 || true + sleep 5 + done +) & +# A failed application leaves the independent maintenance page available. +( + # Allow the owner unlimited time to complete first-time Wi-Fi setup. + while :; do + state= + if [ -f /var/run/ck-setup-state ]; then + read -r state < /var/run/ck-setup-state || true + fi + if [ "$state" = state=online ]; then break; fi + sleep 2 + done + variant= + 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 + else + touch /var/run/ck-rescue + fi + exit 0 + fi + sleep 180 + if [ ! -f /var/run/ck-update-healthy ]; then + touch /var/run/ck-rescue + /bin/killall matterd-test 2>/dev/null || true + fi +) & diff --git a/firmware/matter/src/update/ck_common.c b/firmware/matter/src/update/ck_common.c new file mode 100644 index 0000000..b4cb755 --- /dev/null +++ b/firmware/matter/src/update/ck_common.c @@ -0,0 +1,372 @@ +#define _GNU_SOURCE +#include "ck_common.h" +#include +#include +#include +#include +#include +#include +#include +#include +typedef uint32_t u32; +typedef uint8_t u8; +typedef size_t usize; +static void copy(void *d, const void *s, size_t n) { + volatile uint8_t *a = d; + const volatile uint8_t *b = s; + while (n--) + *a++ = *b++; +} +static void zero(void *d, size_t n) { memset(d, 0, n); } +/* SHA-256 / HMAC, RFC 6234 / RFC 4231. No dynamic allocation. */ +typedef struct { + u32 h[8], count; + u8 b[64]; + unsigned fill; +} Sha; +static const u32 K[64] = { + 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}; +static u32 rr(u32 x, unsigned n) { return (x >> n) | (x << (32 - n)); } +static void block(Sha *s) { + u32 w[64], a, b, c, d, e, f, g, h, t1, t2; + unsigned i; + for (i = 0; i < 16; i++) { + const volatile u8 *p = s->b + i * 4; + w[i] = ((u32)p[0] << 24) | ((u32)p[1] << 16) | ((u32)p[2] << 8) | p[3]; + } + for (i = 16; i < 64; i++) { + u32 x = w[i - 15], y = w[i - 2]; + w[i] = w[i - 16] + (rr(x, 7) ^ rr(x, 18) ^ (x >> 3)) + w[i - 7] + + (rr(y, 17) ^ rr(y, 19) ^ (y >> 10)); + } + a = s->h[0]; + b = s->h[1]; + c = s->h[2]; + d = s->h[3]; + e = s->h[4]; + f = s->h[5]; + g = s->h[6]; + h = s->h[7]; + for (i = 0; i < 64; i++) { + t1 = h + (rr(e, 6) ^ rr(e, 11) ^ rr(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + + w[i]; + t2 = (rr(a, 2) ^ rr(a, 13) ^ rr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c)); + h = g; + g = f; + f = e; + e = d + t1; + d = c; + c = b; + b = a; + a = t1 + t2; + } + s->h[0] += a; + s->h[1] += b; + s->h[2] += c; + s->h[3] += d; + s->h[4] += e; + s->h[5] += f; + s->h[6] += g; + s->h[7] += h; +} +static void sha_init(Sha *s) { + static const u32 iv[8] = {0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19}; + copy(s->h, iv, 32); + s->count = s->fill = 0; +} +static void sha_add(Sha *s, const void *p, usize n) { + const u8 *b = p; + s->count += (u32)n; + while (n--) { + s->b[s->fill++] = *b++; + if (s->fill == 64) { + block(s); + s->fill = 0; + } + } +} +static void sha_end(Sha *s, u8 *out) { + u32 len = s->count * 8; + unsigned i; + s->b[s->fill++] = 128; + if (s->fill > 56) { + while (s->fill < 64) + s->b[s->fill++] = 0; + block(s); + s->fill = 0; + } + while (s->fill < 60) + s->b[s->fill++] = 0; + for (i = 0; i < 4; i++) + s->b[60 + i] = (u8)(len >> (24 - i * 8)); + block(s); + for (i = 0; i < 32; i++) + out[i] = (u8)(s->h[i / 4] >> (24 - (i % 4) * 8)); +} +static void hmac(const u8 *k, unsigned kn, const void *p, usize n, u8 *out) { + Sha s; + u8 pad[64], inner[32]; + unsigned i; + zero(pad, 64); + copy(pad, k, kn); + for (i = 0; i < 64; i++) + pad[i] ^= 0x36; + sha_init(&s); + sha_add(&s, pad, 64); + sha_add(&s, p, n); + sha_end(&s, inner); + for (i = 0; i < 64; i++) + pad[i] ^= 0x36 ^ 0x5c; + sha_init(&s); + sha_add(&s, pad, 64); + sha_add(&s, inner, 32); + sha_end(&s, out); + zero(pad, 64); + zero(inner, 32); +} +static void hex(const u8 *b, unsigned n, char *out) { + const char *t = "0123456789abcdef"; + unsigned i; + for (i = 0; i < n; i++) { + out[2 * i] = t[b[i] >> 4]; + out[2 * i + 1] = t[b[i] & 15]; + } + out[2 * n] = 0; +} +static int unhex(const char *p, u8 *out, unsigned n) { + unsigned i, j; + for (i = 0; i < n; i++) { + out[i] = 0; + for (j = 0; j < 2; j++) { + char c = *p++; + int v = c >= '0' && c <= '9' ? c - '0' + : c >= 'a' && c <= 'f' ? c - 'a' + 10 + : -1; + if (v < 0) + return -1; + out[i] = (u8)((out[i] << 4) | v); + } + } + return 0; +} + +const char *ck_path(const char *path) { +#ifdef CK_TESTING + static char paths[64][1024], names[64][128], previous[1024]; + static unsigned count; + const char *root = getenv("CK_TEST_ROOT"); + if (root) { + if (strcmp(previous, root)) { + if (strlen(root) >= sizeof(previous)) + abort(); + strcpy(previous, root); + count = 0; + } + for (unsigned i = 0; i < count; i++) + if (!strcmp(names[i], path)) + return paths[i]; + if (count >= 64 || strlen(path) >= 128) + abort(); + strcpy(names[count], path); + if (snprintf(paths[count], 1024, "%s%s", root, path) >= 1024) + abort(); + return paths[count++]; + } +#endif + return path; +} +int ck_transfer_lock(void) { + int fd = open(ck_path("/var/lock/ck-transfer.lock"), + O_CREAT | O_RDWR | O_NOFOLLOW | O_CLOEXEC, 0600); + if (fd < 0) + return -1; + if (flock(fd, LOCK_EX | LOCK_NB)) { + close(fd); + return -1; + } + return fd; +} +int ck_ram_storage(void) { +#ifndef CK_TESTING + struct statfs fs; + if (statfs("/var", &fs) || ((unsigned long)fs.f_type != 0x858458f6UL && + (unsigned long)fs.f_type != 0x01021994UL)) + return -1; +#endif + return 0; +} +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)) { + close(fd); + return -1; + } +#ifndef CK_TESTING + FILE *info = fopen("/proc/meminfo", "r"); + char line[128]; + unsigned free_kb = 0; + if (info) { + while (fgets(line, sizeof(line), info)) + if (sscanf(line, "MemFree: %u", &free_kb) == 1) + break; + fclose(info); + } + if (free_kb < 4096) { + close(fd); + return -1; + } +#endif + /* One transfer, one RAM image; the active rootfs is unaffected. */ + unlink(ck_path("/var/run/ck-ota/image.ota")); + return fd; +} +uint32_t ck_be32(const uint8_t *p) { + return (uint32_t)p[0] << 24 | (uint32_t)p[1] << 16 | (uint32_t)p[2] << 8 | + p[3]; +} +uint64_t ck_be64(const uint8_t *p) { + return (uint64_t)ck_be32(p) << 32 | ck_be32(p + 4); +} +void ck_put32(uint8_t *p, uint32_t n) { + for (int i = 0; i < 4; i++) + p[i] = (uint8_t)(n >> (24 - i * 8)); +} +void ck_put64(uint8_t *p, uint64_t n) { + ck_put32(p, (uint32_t)(n >> 32)); + ck_put32(p + 4, (uint32_t)n); +} +int ck_read_all(int fd, void *p, size_t n) { + uint8_t *b = p; + while (n) { + ssize_t r = read(fd, b, n); + if (r < 0 && errno == EINTR) + continue; + if (r <= 0) + return -1; + b += r; + n -= (size_t)r; + } + return 0; +} +int ck_write_all(int fd, const void *p, size_t n) { + const uint8_t *b = p; + while (n) { + ssize_t r = write(fd, b, n); + if (r < 0 && errno == EINTR) + continue; + if (r <= 0) + return -1; + b += r; + n -= (size_t)r; + } + return 0; +} +int ck_atomic(const char *path, const void *p, size_t n, unsigned mode) { + char temp[1024]; + if (snprintf(temp, sizeof(temp), "%s.tmp.%ld", path, (long)getpid()) >= + (int)sizeof(temp)) + return -1; + int fd = + open(temp, O_CREAT | O_EXCL | O_WRONLY | O_NOFOLLOW | O_CLOEXEC, mode); + if (fd < 0) + return -1; + int r = ck_write_all(fd, p, n); + if (!r) + r = fsync(fd); + if (close(fd)) + r = -1; + if (!r) + r = rename(temp, path); + if (r) + unlink(temp); + return r; +} +int ck_exact_file(const char *path, void *p, size_t n) { + int fd = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC); + if (fd < 0) + return -1; + int r = ck_read_all(fd, p, n); + uint8_t extra; + if (read(fd, &extra, 1) != 0) + r = -1; + close(fd); + return r; +} +void ck_sha256(const void *p, size_t n, uint8_t out[32]) { + Sha s; + sha_init(&s); + sha_add(&s, p, n); + sha_end(&s, out); +} +void ck_sha256_pair(const void *a, size_t an, const void *b, size_t bn, + uint8_t out[32]) { + Sha s; + sha_init(&s); + sha_add(&s, a, an); + sha_add(&s, b, bn); + sha_end(&s, out); +} +int ck_hash_pair(int a, unsigned ao, unsigned an, int b, unsigned bo, + unsigned bn, uint8_t out[32]) { + Sha s; + uint8_t buffer[4096]; + int fds[2] = {a, b}; + unsigned offsets[2] = {ao, bo}, sizes[2] = {an, bn}; + sha_init(&s); + for (unsigned i = 0; i < 2; i++) { + if (lseek(fds[i], offsets[i], SEEK_SET) < 0) + return -1; + while (sizes[i]) { + unsigned take = sizes[i] > sizeof(buffer) ? sizeof(buffer) : sizes[i]; + if (ck_read_all(fds[i], buffer, take)) + return -1; + sha_add(&s, buffer, take); + sizes[i] -= take; + } + } + sha_end(&s, out); + return 0; +} +int ck_hash_fd(int fd, unsigned off, unsigned n, uint8_t out[32]) { + Sha s; + uint8_t b[4096]; + if (lseek(fd, off, SEEK_SET) < 0) + return -1; + sha_init(&s); + while (n) { + unsigned take = n > sizeof(b) ? sizeof(b) : n; + if (ck_read_all(fd, b, take)) + return -1; + sha_add(&s, b, take); + n -= 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); +} +void ck_hex(const uint8_t *p, size_t n, char *out) { hex(p, (unsigned)n, out); } +int ck_unhex(const char *p, size_t n, uint8_t *out) { + return unhex(p, out, (unsigned)n); +} +int ck_equal(const void *a, const void *b, size_t n) { + const uint8_t *x = a, *y = b; + unsigned diff = 0; + while (n--) + diff |= *x++ ^ *y++; + return diff == 0; +} diff --git a/firmware/matter/src/update/ck_common.h b/firmware/matter/src/update/ck_common.h new file mode 100644 index 0000000..f79f71f --- /dev/null +++ b/firmware/matter/src/update/ck_common.h @@ -0,0 +1,52 @@ +#ifndef CK_COMMON_H +#define CK_COMMON_H +#include +#include +#ifdef __cplusplus +extern "C" { +#endif +#define CK_ROOTFS_START 0x130000u +#define CK_ROOTFS_SIZE 0x290000u +#define CK_JOURNAL_OFFSET 0x290000u +#define CK_SLOT_SIZE 0x10000u +#define CK_PART_SIZE 0x2b0000u +#define CK_ERASE_SIZE 0x1000u +#define CK_OTA_HEADER 256u +const char *ck_path(const char *path); +uint32_t ck_be32(const uint8_t *p); +uint64_t ck_be64(const uint8_t *p); +void ck_put32(uint8_t *p, uint32_t n); +void ck_put64(uint8_t *p, uint64_t n); +int ck_read_all(int fd, void *p, size_t size); +int ck_write_all(int fd, const void *p, size_t size); +int ck_atomic(const char *path, const void *p, size_t size, unsigned mode); +int ck_exact_file(const char *path, void *p, size_t size); +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_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, + uint8_t out[32]); +void ck_hex(const uint8_t *in, size_t n, char *out); +int ck_unhex(const char *in, size_t n, uint8_t *out); +int ck_equal(const void *a, const void *b, size_t size); +struct ck_flash { + int fd; + unsigned base, size; +}; +int ck_flash_open(struct ck_flash *f, unsigned part, int writeable); +int ck_flash_read(struct ck_flash *f, unsigned at, void *p, unsigned n); +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); +void ck_unlock(int fd); +int ck_download_begin(void); +int ck_transfer_lock(void); +int ck_ram_storage(void); +#ifdef __cplusplus +} +#endif +#endif diff --git a/firmware/matter/src/update/ck_flash.c b/firmware/matter/src/update/ck_flash.c new file mode 100644 index 0000000..850f84c --- /dev/null +++ b/firmware/matter/src/update/ck_flash.c @@ -0,0 +1,175 @@ +#define _GNU_SOURCE +#include "ck_common.h" +#include +#include +#include +#include +#include +#include +#include +#include +#ifndef CK_TESTING +#include +#include +#include +#endif + +int ck_flash_open(struct ck_flash *f, unsigned part, int writable) { + static const unsigned bases[] = {0, 0x130000, 0x3e0000, + 0x400000, 0x530000, 0x7e0000}; + static const unsigned sizes[] = {0x130000, 0x2b0000, 0x20000, + 0x130000, 0x2b0000, 0x20000}; + struct stat st; + char path[64]; + f->fd = -1; + if (part > 5 || (writable && part != 1)) + return -1; +#ifdef CK_TESTING + const char *fixture = getenv("CK_FLASH_FILE"); + if (!fixture) + return -1; + f->fd = + open(fixture, (writable ? O_RDWR : O_RDONLY) | O_NOFOLLOW | O_CLOEXEC); + if (f->fd < 0) + return -1; + if (fstat(f->fd, &st) || !S_ISREG(st.st_mode) || st.st_size != 0x800000) + goto fail; + f->base = bases[part]; + (void)path; +#else + (void)bases; + struct mtd_info_user info; + int matched = 0; + for (unsigned candidate = 0; candidate < 2; candidate++) { + if (candidate == 0) + snprintf(path, sizeof(path), "/dev/mtd%u", part); + else { + /* Vendor images contain sequential minor numbers; mainline MTD uses + * minor=2*partition and odd minors are read-only aliases. Probe the + * existing node first, then a private canonical node, and verify + * geometry. */ + if (mkdir("/var/run/ck-mtd", 0700) && errno != EEXIST) + goto fail; + if (lstat("/var/run/ck-mtd", &st) || !S_ISDIR(st.st_mode) || + st.st_uid != 0 || (st.st_mode & 077)) + goto fail; + snprintf(path, sizeof(path), "/var/run/ck-mtd/%u", part); + if (mknod(path, S_IFCHR | 0600, makedev(90, 2 * part)) && errno != EEXIST) + goto fail; + if (lstat(path, &st) || !S_ISCHR(st.st_mode) || + st.st_rdev != makedev(90, 2 * part)) + goto fail; + } + f->fd = open(path, (writable ? O_RDWR : O_RDONLY) | O_NOFOLLOW | O_CLOEXEC | + O_SYNC); + if (f->fd < 0) + continue; + if (!fstat(f->fd, &st) && S_ISCHR(st.st_mode) && + !ioctl(f->fd, MEMGETINFO, &info) && info.type == MTD_NORFLASH && + info.size == sizes[part] && info.erasesize == CK_ERASE_SIZE) { + matched = 1; + break; + } + close(f->fd); + f->fd = -1; + } + if (!matched) + goto fail; + if (writable) { + char bank[32] = {0}; + int fd = open("/proc/bootbank", O_RDONLY | O_CLOEXEC); + int n = fd < 0 ? -1 : (int)read(fd, bank, sizeof(bank) - 1); + if (fd >= 0) + close(fd); + if (n < 1 || bank[0] != '1' || + (n > 1 && bank[1] != '\n' && bank[1] != '\0')) + goto fail; + /* Never infer a writable mapping from a device number alone. */ + FILE *fp = fopen("/proc/mtd", "r"); + char line[160]; + int found = 0; + if (!fp) + goto fail; + while (fgets(line, sizeof(line), fp)) { + unsigned size, erase; + char name[64]; + if (sscanf(line, "mtd1: %x %x \"%63[^\"]\"", &size, &erase, name) == 3 && + size == CK_PART_SIZE && erase == CK_ERASE_SIZE && + !strcmp(name, "root fs(bank1)")) + found = 1; + } + fclose(fp); + if (!found) + goto fail; + } + f->base = 0; +#endif + f->size = sizes[part]; + return 0; +fail: + close(f->fd); + f->fd = -1; + return -1; +} +int ck_flash_read(struct ck_flash *f, unsigned at, void *p, unsigned n) { + if (at > f->size || n > f->size - at || + lseek(f->fd, f->base + at, SEEK_SET) < 0) + return -1; + return ck_read_all(f->fd, p, n); +} +int ck_flash_write(struct ck_flash *f, unsigned at, const void *p, unsigned n) { + if (at > f->size || n > f->size - at || + lseek(f->fd, f->base + at, SEEK_SET) < 0) + return -1; +#ifdef CK_TESTING + /* NOR programming can only clear bits. */ + uint8_t old[4096]; + const uint8_t *in = p; + for (unsigned pos = 0; pos < n;) { + unsigned take = n - pos > sizeof(old) ? sizeof(old) : n - pos; + if (ck_flash_read(f, at + pos, old, take)) + return -1; + for (unsigned i = 0; i < take; i++) + if ((old[i] & in[pos + i]) != in[pos + i]) + return -1; + pos += take; + } + if (lseek(f->fd, f->base + at, SEEK_SET) < 0) + return -1; +#endif + return ck_write_all(f->fd, p, n); +} +int ck_flash_erase(struct ck_flash *f, unsigned at, unsigned n) { + if (at % CK_ERASE_SIZE || n % CK_ERASE_SIZE || at > f->size || + n > f->size - at) + return -1; +#ifdef CK_TESTING + uint8_t erased[CK_ERASE_SIZE]; + memset(erased, 255, sizeof(erased)); + for (unsigned i = 0; i < n; i += sizeof(erased)) { + if (lseek(f->fd, f->base + at + i, SEEK_SET) < 0 || + ck_write_all(f->fd, erased, sizeof(erased))) + return -1; + } + return 0; +#else + struct erase_info_user erase = {at, n}; + return ioctl(f->fd, MEMERASE, &erase); +#endif +} +int ck_flash_sync(struct ck_flash *f) { return fsync(f->fd); } +int ck_lock(void) { + int fd = open(ck_path("/var/lock/ck-flash.lock"), + O_RDWR | O_CREAT | O_NOFOLLOW | O_CLOEXEC, 0600); + if (fd >= 0 && flock(fd, LOCK_EX) == 0) + return fd; + if (fd >= 0) + close(fd); + return -1; +} +void ck_unlock(int fd) { + if (fd >= 0) { + flock(fd, LOCK_UN); + close(fd); + } +} diff --git a/firmware/matter/src/update/ck_ota.c b/firmware/matter/src/update/ck_ota.c new file mode 100644 index 0000000..392f609 --- /dev/null +++ b/firmware/matter/src/update/ck_ota.c @@ -0,0 +1,256 @@ +#define _GNU_SOURCE +#include "ck_ota.h" +#include "../vendor/monocypher/monocypher-ed25519.h" +#include "ck_common.h" +#include "ck_store.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +static int compatibility(const uint8_t *h) { + struct ck_flash a, b; + uint8_t hash[32]; + int r = -1; + if (ck_flash_open(&a, 0, 0)) + return -1; + if (!ck_hash_fd(a.fd, a.base + 0x10000, 0x120000, hash) && + ck_equal(hash, h + 64, 32)) + r = 0; + close(a.fd); + if (r) + return -1; + if (ck_flash_open(&a, 3, 0)) + return -1; + if (ck_flash_open(&b, 4, 0)) { + close(a.fd); + return -1; + } + r = ck_hash_pair(a.fd, a.base + 0x10000, 0x120000, b.fd, b.base, CK_PART_SIZE, + hash); + if (!r && !ck_equal(hash, h + 96, 32)) + r = -1; + close(a.fd); + close(b.fd); + return r; +} +int ck_ota_verify(const char *path, struct ck_image *im) { + struct stat st; + uint8_t public_key[32], meta[12], hash[32], root[96], buffer[4096]; + int fd = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC), result = -1; + if (fd < 0) + return -1; + if (fstat(fd, &st) || !S_ISREG(st.st_mode) || + st.st_size < (off_t)CK_OTA_HEADER || ck_read_all(fd, im->header, 256)) + goto done; + uint8_t *h = im->header; + if (memcmp(h, "CKOTA1\r\n", 8) || ck_be32(h + 8) != 1 || + ck_be32(h + 12) != 0x434b0004 || ck_be32(h + 28)) + goto done; + im->version = ck_be32(h + 20); + im->length = ck_be32(h + 24); + if (!im->version || im->length != CK_ROOTFS_SIZE || + st.st_size != (off_t)(256 + im->length)) + goto done; + if (ck_exact_file(ck_path("/etc/ck-update.pub"), public_key, 32) || + !ck_equal(h + 128, public_key, 32) || + crypto_ed25519_check(h + 192, public_key, h, 192)) + goto done; + if (ck_exact_file(ck_path("/etc/ck-update.meta"), meta, 12) || + ck_be32(meta) != 0x434b0004 || ck_be32(meta + 4) != ck_be32(h + 16) || + im->version <= ck_be32(meta + 8) || im->version <= ck_store_floor()) + goto done; + if (!memchr(h + 160, 0, 32) || !h[160]) + goto done; + for (unsigned i = 160; i < 192 && h[i]; i++) + if (!((h[i] >= 'a' && h[i] <= 'z') || (h[i] >= 'A' && h[i] <= 'Z') || + (h[i] >= '0' && h[i] <= '9') || h[i] == '.' || h[i] == '-')) + goto done; + if (ck_hash_fd(fd, 256, im->length, hash) || !ck_equal(hash, h + 32, 32)) + goto done; + if (lseek(fd, 256, SEEK_SET) < 0 || ck_read_all(fd, root, sizeof(root)) || + memcmp(root, "hsqs", 4)) + goto done; + unsigned extent = ck_be32(root + 8); + if (extent > CK_ROOTFS_SIZE - 0x282) + goto done; + extent += 0x282; + uint64_t used = 0; + for (int i = 7; i >= 0; i--) + used = (used << 8) | root[40 + i]; + if (extent < 96 || extent > im->length || extent % 2 || used < 96 || + used > extent) + goto done; + if (lseek(fd, 256, SEEK_SET) < 0) + goto done; + unsigned remaining = extent, sum = 0; + while (remaining) { + unsigned n = remaining > sizeof(buffer) ? sizeof(buffer) : remaining; + if (ck_read_all(fd, buffer, n)) + goto done; + for (unsigned i = 0; i < n; i += 2) + sum += ((unsigned)buffer[i] << 8) | buffer[i + 1]; + remaining -= n; + } + if ((sum & 65535) || compatibility(h)) + goto done; + /* An OTA-ready device must already have migrated identity and trust. */ + size_t n = 32; + uint8_t trusted[32]; + if (ck_store_get("identity/update", trusted, &n) || n != 32 || + !ck_equal(trusted, public_key, 32)) + goto done; + struct ck_flash target; + if (ck_flash_open(&target, 1, 1)) + goto done; + close(target.fd); + result = 0; +done: + close(fd); + return result; +} +static int cp(const char *from, const char *to, unsigned mode) { + int in = open(from, O_RDONLY | O_NOFOLLOW | O_CLOEXEC); + if (in < 0) + return -1; + int out = + open(to, O_CREAT | O_EXCL | O_WRONLY | O_NOFOLLOW | O_CLOEXEC, mode); + if (out < 0) { + close(in); + return -1; + } + uint8_t buffer[4096]; + ssize_t n; + int r = 0; + while ((n = read(in, buffer, sizeof(buffer))) != 0) { + if (n < 0 && errno == EINTR) + continue; + if (n < 0 || ck_write_all(out, buffer, (size_t)n)) { + r = -1; + break; + } + } + if (fsync(out)) + r = -1; + close(in); + if (close(out)) + r = -1; + if (r) + unlink(to); + return r; +} +static int manager_lock(void) { + int fd = open(ck_path("/var/lock/ck-update.lock"), + O_CREAT | O_RDWR | O_NOFOLLOW | O_CLOEXEC, 0600); + if (fd >= 0 && flock(fd, LOCK_EX) == 0) + return fd; + if (fd >= 0) + close(fd); + return -1; +} +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)) { + close(lock); + return -1; + } + struct ck_image im; + if (ck_ota_verify(path, &im)) { + close(lock); + return -1; + } + char dest[1024]; + snprintf(dest, sizeof(dest), "%s", ck_path("/var/run/ck-ota/image.ota")); + if (!strcmp(path, dest)) { + close(lock); + return 0; + } + if (!strcmp(path, ck_path("/var/run/ck-ota/upload.ota")) || + !strcmp(path, ck_path("/var/run/ck-ota/matter.ota"))) { + int result = rename(path, dest); + if (!result) + result = ck_ota_verify(dest, &im); + close(lock); + return result; + } + unlink(ck_path("/var/run/ck-ota/staging.ota")); + int r = cp(path, ck_path("/var/run/ck-ota/staging.ota"), 0600); + if (!r) + r = rename(ck_path("/var/run/ck-ota/staging.ota"), dest); + if (!r) + r = ck_ota_verify(dest, &im); + if (r) + unlink(ck_path("/var/run/ck-ota/staging.ota")); + close(lock); + return r; +} +int ck_ota_queue(const char *expected_hash) { + if (ck_ram_storage()) + return -1; + int lock = manager_lock(); + if (lock < 0) + return -1; + int result = -1; + int transfer = ck_transfer_lock(); + if (transfer < 0) { + close(lock); + return -1; + } + if (!access(ck_path("/var/run/ck-ota/queued"), F_OK)) { + close(transfer); + close(lock); + return -1; + } + struct ck_image im; + if (ck_ota_verify(ck_path("/var/run/ck-ota/image.ota"), &im)) + goto done; + if (expected_hash) { + uint8_t digest[32]; + char actual[65]; + int fd = open(ck_path("/var/run/ck-ota/image.ota"), + O_RDONLY | O_NOFOLLOW | O_CLOEXEC); + if (fd < 0) + goto done; + int r = ck_hash_fd(fd, 0, CK_OTA_HEADER + CK_ROOTFS_SIZE, digest); + close(fd); + ck_hex(digest, 32, actual); + if (r || strcmp(expected_hash, actual)) + goto done; + } + /* PID1's restart action executes this fixed static binary from RAM. */ + unlink(ck_path("/var/run/ck-ota/apply")); + if (cp(ck_path("/bin/ck-updater"), ck_path("/var/run/ck-ota/apply"), 0700)) + goto done; + uint8_t version[4]; + ck_put32(version, im.version); + if (ck_store_set("ota/pending", version, 4) || + ck_store_delete("ota/attempts") || + ck_atomic(ck_path("/var/run/ck-ota/queued"), version, 4, 0600)) + goto done; +#ifndef CK_TESTING + pid_t child = fork(); + if (child < 0) + goto done; + if (child == 0) { + close(lock); + for (int fd = 0; fd < 128; fd++) + close(fd); + sleep(2); + kill(1, SIGQUIT); + _exit(0); + } +#endif + result = 0; +done: + if (result) + unlink(ck_path("/var/run/ck-ota/queued")); + close(lock); + close(transfer); + return result; +} diff --git a/firmware/matter/src/update/ck_ota.h b/firmware/matter/src/update/ck_ota.h new file mode 100644 index 0000000..9996b2b --- /dev/null +++ b/firmware/matter/src/update/ck_ota.h @@ -0,0 +1,11 @@ +#ifndef CK_OTA_H +#define CK_OTA_H +#include +struct ck_image { + uint8_t header[256]; + unsigned version, length; +}; +int ck_ota_verify(const char *path, struct ck_image *image); +int ck_ota_stage(const char *path); +int ck_ota_queue(const char *expected_hash); +#endif diff --git a/firmware/matter/src/update/ck_store.c b/firmware/matter/src/update/ck_store.c new file mode 100644 index 0000000..cfee21f --- /dev/null +++ b/firmware/matter/src/update/ck_store.c @@ -0,0 +1,303 @@ +#define _GNU_SOURCE +#include "ck_store.h" +#include "ck_common.h" +#include +#include +#include +#include +#include +#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 erased(const uint8_t *p, size_t n) { + while (n--) + if (*p++ != 255) + return 0; + return 1; +} +static int valid(const uint8_t *p) { + uint8_t hash[32]; + unsigned n = ck_be32(p + 16); + if (memcmp(p, "CKSTORE1", 8) || ck_be32(p + 60) != 0x434b4f4b || + n > PAYLOAD_MAX) + return 0; + ck_sha256_pair(p + 8, 12, p + HEADER, n, hash); + if (!ck_equal(hash, p + 20, 32)) + return 0; + unsigned at = 0; + while (at < n) { + if (n - at < 4) + return 0; + unsigned k = p[HEADER + at], + v = (unsigned)p[HEADER + at + 2] << 8 | p[HEADER + at + 3]; + if (!k || k > 96 || p[HEADER + at + 1] || 4 + k + v > n - at) + return 0; + for (unsigned i = 0; i < k; i++) { + unsigned c = p[HEADER + at + 4 + i]; + if (c < 33 || c > 126) + return 0; + } + at += 4 + k + v; + } + return at == n; +} +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))) { + 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 + * the original personalized bootstrap still present in this rootfs. */ + if (memcmp(snapshots[0], "CKSTORE1", 8) || + ck_be64(snapshots[0] + 8) != 1 || + !erased(snapshots[1], CK_SLOT_SIZE) || + ck_exact_file(ck_path("/etc/matter-diag.key"), bootstrap, 65)) + return -1; /* never format unknown vendor bytes */ + } + *active = -1; + *seq = 0; + *n = 0; + return 0; + } + uint64_t sa = ck_be64(snapshots[0] + 8), sb = ck_be64(snapshots[1] + 8); + if (a && b && sa == sb && !ck_equal(snapshots[0], snapshots[1], CK_SLOT_SIZE)) + return -1; + *active = (!a || (b && sb > sa)) ? 1 : 0; + *seq = ck_be64(snapshots[*active] + 8); + *n = ck_be32(snapshots[*active] + 16); + memcpy(work + HEADER, snapshots[*active] + HEADER, *n); + return 0; +} +static int entry(const char *key, unsigned n, unsigned *at, unsigned *size) { + size_t k = strlen(key); + if (!k || k > 96) + return -1; + unsigned pos = 0; + while (pos < n) { + unsigned len = work[HEADER + pos], + v = (unsigned)work[HEADER + pos + 2] << 8 | work[HEADER + pos + 3]; + if (len == k && !memcmp(work + HEADER + pos + 4, key, k)) { + *at = pos; + *size = v; + return 1; + } + pos += 4 + len + v; + } + *at = n; + *size = 0; + return 0; +} +static int save(struct ck_flash *f, int active, uint64_t seq, unsigned n) { + if (seq == UINT64_MAX) + return -1; + uint8_t digest[32], commit[4]; + memset(work, 255, HEADER); + memcpy(work, "CKSTORE1", 8); + ck_put64(work + 8, seq + 1); + ck_put32(work + 16, n); + ck_sha256_pair(work + 8, 12, work + HEADER, n, digest); + memcpy(work + 20, digest, 32); + unsigned dest = CK_JOURNAL_OFFSET + (active == 0 ? CK_SLOT_SIZE : 0); + unsigned erase_size = + (HEADER + n + CK_ERASE_SIZE - 1) / CK_ERASE_SIZE * CK_ERASE_SIZE; + if (ck_flash_erase(f, dest, erase_size)) + return -1; +#ifdef CK_TESTING + if (getenv("CK_FAIL_STORE") && + !strcmp(getenv("CK_FAIL_STORE"), "after-erase")) + return -1; +#endif + if (ck_flash_write(f, dest, work, HEADER + n) || ck_flash_sync(f)) + return -1; + uint8_t *verify = snapshots[active == 0 ? 1 : 0]; + if (ck_flash_read(f, dest, verify, HEADER + n) || + !ck_equal(work, verify, HEADER + n)) + return -1; +#ifdef CK_TESTING + if (getenv("CK_FAIL_STORE") && + !strcmp(getenv("CK_FAIL_STORE"), "before-commit")) + return -1; +#endif + ck_put32(commit, 0x434b4f4b); + if (ck_flash_write(f, dest + 60, commit, 4) || ck_flash_sync(f) || + ck_flash_read(f, dest, verify, CK_SLOT_SIZE) || !valid(verify)) + return -1; + return 0; +} +int ck_store_get(const char *key, void *value, size_t *size) { + int lock = ck_lock(), result = -1, active; + unsigned n, at, v; + uint64_t seq; + struct ck_flash f; + if (lock < 0) + return -1; + if (ck_flash_open(&f, 1, 0)) + goto done; + if (load(&f, &active, &seq, &n)) + goto closed; + int found = entry(key, n, &at, &v); + if (found < 0) + goto closed; + if (!found) { + result = 1; + goto closed; + } + if (*size < v) { + *size = v; + result = 2; + goto closed; + } + if (v) + memcpy(value, work + HEADER + at + 4 + strlen(key), v); + *size = v; + result = 0; +closed: + close(f.fd); +done: + ck_unlock(lock); + return result; +} +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)) + return -1; + for (size_t i = 0; i < k; i++) + if ((unsigned char)key[i] < 33 || (unsigned char)key[i] > 126) + return -1; + int lock = ck_lock(), result = -1, active; + unsigned n, at, v; + 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)) + goto closed; + int found = entry(key, n, &at, &v); + if (found < 0) + goto closed; + if ((remove && !found) || + (found && !remove && v == size && + !memcmp(work + HEADER + at + 4 + k, value, size))) { + result = 0; + goto closed; + } + unsigned old = found ? (unsigned)(4 + k + v) : 0; + if (n - old + (remove ? 0 : 4 + k + size) > PAYLOAD_MAX) + goto closed; + if (found) { + memmove(work + HEADER + at, work + HEADER + at + old, n - at - old); + n -= old; + } + if (!remove) { + 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); + if (size) + memcpy(work + HEADER + n + 4 + k, value, size); + n += (unsigned)(4 + k + size); + } + result = save(&f, active, seq, n); +closed: + close(f.fd); +done: + ck_unlock(lock); + return result; +} +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); } +static int migrate(const char *name, const char *source, const char *dest, + size_t expected) { + uint8_t b[128]; + size_t n = sizeof(b); + int r = ck_store_get(name, b, &n); + if (r == 1) { + if (ck_exact_file(ck_path(source), b, expected) || + ck_store_set(name, b, expected)) + return -1; + n = expected; + } else if (r) + return -1; + if (n != expected) + return -1; + r = ck_atomic(ck_path(dest), b, n, 0600); + memset(b, 0, sizeof(b)); + return r; +} +int ck_store_import(void) { + 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) { + if (migrate("identity/device", "/etc/matter-device.key", + "/var/lib/ck-state/device.key", 32)) + return -1; + } else if (variant == 2) { + if (migrate("identity/setup-ap", "/etc/ck-setup-ap.bin", + "/var/lib/ck-state/setup-ap.bin", 104)) + return -1; + } else + return -1; + if (migrate("identity/diag", "/etc/matter-diag.key", + "/var/lib/ck-state/diag.key", 65) || + migrate("identity/update", "/etc/ck-update.pub", + "/var/lib/ck-state/update.pub", 32)) + return -1; + uint8_t trusted[32], current[32]; + if (ck_exact_file(ck_path("/var/lib/ck-state/update.pub"), trusted, 32) || + ck_exact_file(ck_path("/etc/ck-update.pub"), current, 32) || + !ck_equal(trusted, current, 32)) + return -1; + return 0; +} +uint32_t ck_store_floor(void) { + uint8_t b[4]; + size_t n = 4; + int r = ck_store_get("ota/floor", b, &n); + return r == 1 ? 0 : r || n != 4 ? UINT32_MAX : ck_be32(b); +} +int ck_store_confirm(uint32_t v) { + uint32_t floor = ck_store_floor(); + uint8_t b[4]; + size_t pending_n = 4; + int pending = ck_store_get("ota/pending", b, &pending_n); + if (v < floor || + (pending != 1 && (pending || pending_n != 4 || ck_be32(b) != v))) + return -1; + ck_put32(b, v); + if (ck_store_set("ota/floor", b, 4) || ck_store_delete("ota/pending") || + ck_store_delete("ota/attempts")) + return -1; + return 0; +} +int ck_store_boot(void) { + uint8_t b[4]; + size_t n = 4; + int r = ck_store_get("ota/pending", b, &n); + if (r == 1) + return 0; + if (r || n != 4) + return -1; + n = 4; + uint32_t count = 0; + r = ck_store_get("ota/attempts", b, &n); + if (!r && n == 4) + count = ck_be32(b); + else if (r != 1) + return -1; + if (count >= 3) + return 1; + ck_put32(b, count + 1); + return ck_store_set("ota/attempts", b, 4); +} diff --git a/firmware/matter/src/update/ck_store.h b/firmware/matter/src/update/ck_store.h new file mode 100644 index 0000000..d71d00f --- /dev/null +++ b/firmware/matter/src/update/ck_store.h @@ -0,0 +1,20 @@ +#ifndef CK_STORE_H +#define CK_STORE_H +#include +#include +#ifdef __cplusplus +extern "C" { +#endif +/* 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_set(const char *key, const void *value, size_t size); +int ck_store_delete(const char *key); +int ck_store_import(void); +int ck_store_boot(void); +int ck_store_confirm(uint32_t version); +uint32_t ck_store_floor(void); +#ifdef __cplusplus +} +#endif +#endif diff --git a/firmware/matter/src/update/ck_updated.c b/firmware/matter/src/update/ck_updated.c new file mode 100644 index 0000000..3e458d4 --- /dev/null +++ b/firmware/matter/src/update/ck_updated.c @@ -0,0 +1,351 @@ +#define _GNU_SOURCE +#include "ck_common.h" +#include "ck_ota.h" +#include "ck_store.h" +#include "update_page.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +static int sendall(int fd, const void *p, size_t n) { + const char *b = p; + while (n) { + ssize_t r = send(fd, b, n, MSG_NOSIGNAL); + if (r < 0 && errno == EINTR) + continue; + if (r <= 0) + return -1; + b += r; + n -= (size_t)r; + } + return 0; +} +static void reply(int fd, int status, const char *type, const char *body) { + char h[384]; + size_t n = strlen(body); + int len = snprintf( + h, sizeof(h), + "HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: " + "%zu\r\nConnection: close\r\nCache-Control: " + "no-store\r\nX-Content-Type-Options: nosniff\r\nContent-Security-Policy: " + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' " + "'unsafe-inline'; connect-src 'self' https:; frame-ancestors " + "'none'\r\n\r\n", + status, status == 200 ? "OK" : "Error", type, n); + if (len > 0 && len < (int)sizeof(h)) { + sendall(fd, h, (size_t)len); + sendall(fd, body, n); + } +} +static void json(int fd, int status, const char *body) { + reply(fd, status, "application/json; charset=utf-8", body); +} +static void handle(int fd, int server) { + char request[4096], method[12], path[80], version[16], + host[128] = "", origin[160] = "", digest[80] = ""; + unsigned n = 0, base = 0, length = 0, seen = 0; + time_t start = time(NULL); + while (!base && n < sizeof(request) - 1 && time(NULL) - start < 10) { + ssize_t r = recv(fd, request + n, sizeof(request) - 1 - n, 0); + if (r <= 0) + return; + n += (unsigned)r; + request[n] = 0; + char *end = strstr(request, "\r\n\r\n"); + if (end) + base = (unsigned)(end - request) + 4; + } + if (!base || sscanf(request, "%11s %79s %15s", method, path, version) != 3 || + strcmp(version, "HTTP/1.1")) { + json(fd, 400, "{\"error\":\"request\"}"); + return; + } + char *line = strstr(request, "\r\n"); + if (!line) + return; + line += 2; + while (line < request + base - 2) { + char *end = strstr(line, "\r\n"), *colon = strchr(line, ':'); + if (!end || !colon || colon > end) { + json(fd, 400, "{}"); + return; + } + *colon = 0; + *end = 0; + char *value = colon + 1; + while (*value == ' ' || *value == '\t') + value++; + unsigned bit = 0; + char *out = NULL; + size_t cap = 0; + if (!strcasecmp(line, "Host")) { + bit = 1; + out = host; + cap = sizeof(host); + } else if (!strcasecmp(line, "Origin")) { + bit = 2; + out = origin; + cap = sizeof(origin); + } else if (!strcasecmp(line, "X-CK-SHA256")) { + bit = 8; + out = digest; + cap = sizeof(digest); + } else if (!strcasecmp(line, "Content-Length")) { + bit = 16; + char *tail; + unsigned long count = strtoul(value, &tail, 10); + if (!*value || *value < '0' || *value > '9' || *tail || + count > CK_ROOTFS_SIZE + 256) { + json(fd, 400, "{}"); + return; + } + length = (unsigned)count; + } else if (!strcasecmp(line, "Transfer-Encoding")) { + json(fd, 400, "{}"); + return; + } + if (bit && (seen & bit)) { + json(fd, 400, "{}"); + return; + } + seen |= bit; + if (out) { + if (strlen(value) >= cap) { + json(fd, 400, "{}"); + return; + } + strcpy(out, value); + } + line = end + 2; + } + uint8_t supplied_hash[32]; + if (*digest && + (strlen(digest) != 64 || ck_unhex(digest, 32, supplied_hash))) { + json(fd, 400, "{\"error\":\"checksum_header\"}"); + return; + } + struct sockaddr_in peer; + socklen_t peer_size = sizeof(peer); + if (getpeername(fd, (struct sockaddr *)&peer, &peer_size)) { + json(fd, 403, "{}"); + return; + } + uint32_t remote = ntohl(peer.sin_addr.s_addr); + if ((remote & 0xff000000u) != 0x0a000000u && + (remote & 0xfff00000u) != 0xac100000u && + (remote & 0xffff0000u) != 0xc0a80000u && + (remote & 0xffff0000u) != 0xa9fe0000u && + (remote & 0xff000000u) != 0x7f000000u) { + json(fd, 403, "{\"error\":\"lan_only\"}"); + return; + } + /* Numeric Host must be this listening socket, blocking DNS rebinding. */ + struct sockaddr_in local; + socklen_t localn = sizeof(local); + char expected[160], ip[INET_ADDRSTRLEN]; + if (getsockname(fd, (struct sockaddr *)&local, &localn) || + !inet_ntop(AF_INET, &local.sin_addr, ip, sizeof(ip))) + return; + snprintf(expected, sizeof(expected), "%s:%u", ip, ntohs(local.sin_port)); + if (strcmp(host, expected)) { + json(fd, 403, "{\"error\":\"host\"}"); + return; + } + if (*origin) { + snprintf(expected, sizeof(expected), "http://%s", host); + if (strcmp(origin, expected)) { + json(fd, 403, "{\"error\":\"origin\"}"); + 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, "/status")) { + char b[512], label[32] = {0}; + uint8_t meta[12]; + if (ck_exact_file(ck_path("/etc/ck-update.meta"), meta, 12)) { + json(fd, 503, "{}"); + return; + } + FILE *f = fopen(ck_path("/etc/ck-version"), "r"); + if (f) { + if (!fgets(label, sizeof(label), f)) + label[0] = 0; + fclose(f); + } + label[strcspn(label, "\r\n")] = 0; + snprintf(b, sizeof(b), + "{\"software_version\":%u,\"variant\":%u,\"version\":\"%s\"," + "\"rescue\":%s,\"healthy\":%s,\"confirmed_version\":%u,\"max_" + "size\":%u}", + ck_be32(meta + 8), ck_be32(meta + 4), label, + 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); + json(fd, 200, b); + return; + } + json(fd, 404, "{}"); + return; + } + if (strcmp(method, "POST") || + (!strcmp(path, "/upload") ? length != CK_ROOTFS_SIZE + 256 + : strcmp(path, "/apply") || length)) { + json(fd, 400, "{}"); + return; + } + if (!strcmp(path, "/apply")) { + (void)server; + if (n != base || ck_ota_queue(*digest ? digest : NULL)) { + json(fd, 409, "{\"error\":\"no_verified_image\"}"); + return; + } + json(fd, 200, "{\"status\":\"restarting\"}"); + return; + } + if (n - base > length) { + json(fd, 400, "{}"); + return; + } + int transfer = ck_download_begin(); + if (transfer < 0) { + json(fd, 409, "{\"error\":\"busy_or_low_memory\"}"); + return; + } + const char *file = ck_path("/var/run/ck-ota/upload.ota"); + int out = + open(file, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600); + if (out < 0) { + close(transfer); + json(fd, 409, "{\"error\":\"busy\"}"); + return; + } + unsigned got = n - base; + int error = ck_write_all(out, request + base, got); + uint8_t buf[4096]; + while (!error && got < length && time(NULL) - start < 120) { + unsigned take = length - got; + if (take > sizeof(buf)) + take = sizeof(buf); + ssize_t r = recv(fd, buf, take, 0); + if (r <= 0) { + error = -1; + break; + } + error = ck_write_all(out, buf, (size_t)r); + got += (unsigned)r; + } + if (got != length) + error = -1; + if (fsync(out)) + error = -1; + if (close(out)) + error = -1; + int in = open(file, O_RDONLY | O_NOFOLLOW | O_CLOEXEC); + uint8_t hash[32]; + char actual[65]; + if (in < 0 || ck_hash_fd(in, 0, length, hash)) + error = -1; + else { + ck_hex(hash, 32, actual); + if (*digest && strcmp(actual, digest)) + error = -1; + } + if (in >= 0) + close(in); + if (!error) + error = ck_ota_stage(file); + unlink(file); + close(transfer); + json(fd, error ? 422 : 200, + error ? "{\"error\":\"package_rejected\"}" + : "{\"status\":\"verified\"}"); +} +int main(int argc, char **argv) { + unsigned port = 18082; + const char *interface = "br0"; +#ifdef CK_TESTING + if (argc == 3) { + port = (unsigned)atoi(argv[1]); + interface = argv[2]; + } +#else + (void)argc; + (void)argv; + struct statfs fs; + if (statfs("/var", &fs) || ((unsigned long)fs.f_type != 0x858458f6UL && + (unsigned long)fs.f_type != 0x01021994UL)) + return 2; +#endif + signal(SIGPIPE, SIG_IGN); + signal(SIGCHLD, SIG_IGN); + int sock = socket(AF_INET, SOCK_STREAM, 0), one = 1; + if (sock < 0) + return 4; +#ifdef CK_TESTING + int loopback = !strcmp(interface, "lo"); +#else + int loopback = 0; +#endif + if (!loopback && setsockopt(sock, SOL_SOCKET, SO_BINDTODEVICE, interface, + strlen(interface) + 1)) + return 5; + setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + struct sockaddr_in addr = {0}; + addr.sin_family = AF_INET; + addr.sin_port = htons((uint16_t)port); + if (loopback) + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (bind(sock, (struct sockaddr *)&addr, sizeof(addr)) || listen(sock, 2)) + return 6; + unsigned bound_index = if_nametoindex(interface); + if (!bound_index) + return 7; + for (;;) { + if (if_nametoindex(interface) != bound_index) + return 7; + struct pollfd ready = {sock, POLLIN, 0}; + if (poll(&ready, 1, 1000) <= 0) + continue; + int fd = accept(sock, NULL, NULL); + if (fd < 0) { + if (errno == EINTR) + continue; + return 7; + } + struct timeval timeout = {5, 0}; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); + handle(fd, sock); + /* Preserve an error response when a client was already sending a body. */ + shutdown(fd, SHUT_WR); + timeout.tv_sec = 0; + timeout.tv_usec = 100000; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + char discard[4096]; + unsigned drained = 0; + ssize_t got; + while (drained < CK_ROOTFS_SIZE + 256 && + (got = recv(fd, discard, sizeof(discard), 0)) > 0) + drained += (unsigned)got; + close(fd); + } +} diff --git a/firmware/matter/src/update/ck_updater.c b/firmware/matter/src/update/ck_updater.c new file mode 100644 index 0000000..ad85dfa --- /dev/null +++ b/firmware/matter/src/update/ck_updater.c @@ -0,0 +1,210 @@ +#define _GNU_SOURCE +#include "ck_common.h" +#include "ck_ota.h" +#include "ck_store.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +static int other_processes(void) { + DIR *dir = opendir("/proc"); + if (!dir) + return 1; + struct dirent *e; + int found = 0; + while ((e = readdir(dir))) { + char *end; + long pid = strtol(e->d_name, &end, 10); + if (*end || pid <= 1) + continue; + char p[80], line[256]; + snprintf(p, sizeof(p), "/proc/%ld/status", pid); + FILE *f = fopen(p, "r"); + if (!f) { + found = 1; + break; + } + int user = 0; + while (fgets(line, sizeof(line), f)) + if (!strncmp(line, "VmSize:", 7)) + user = 1; + fclose(f); + if (user) { + found = 1; + break; + } + } + closedir(dir); + return found; +} +static int apply(int simulation) { + struct ck_image im; + const char *path = ck_path("/var/run/ck-ota/image.ota"); + if (!simulation && getpid() != 1) + return 2; + if (ck_ota_verify(path, &im)) + return 3; + fputs("CK_UPDATE: image verified\n", stderr); + int image = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC); + struct ck_flash flash; + if (image < 0 || ck_flash_open(&flash, 1, 1)) { + if (image >= 0) + close(image); + return 4; + } + int lock = ck_lock(); + if (lock < 0) { + close(image); + close(flash.fd); + return 5; + } +#if !defined(CK_TESTING) || defined(CK_TEST_PID1) + if (simulation) + return 7; + /* Every process including PID1 must leave the live SquashFS before erase. */ + signal(SIGTERM, SIG_IGN); + signal(SIGINT, SIG_IGN); + signal(SIGQUIT, SIG_IGN); + kill(-1, SIGTERM); + sleep(2); + kill(-1, SIGKILL); + sleep(1); + while (waitpid(-1, NULL, WNOHANG) > 0) { + } + if (other_processes()) + return 8; + fputs("CK_UPDATE: userspace quiesced\n", stderr); + if (unlink(path)) + return 9; + const char *ramroot = ck_path("/var/run/ck-ota/root"); + if (mkdir(ramroot, 0700) && access(ramroot, F_OK)) + return 9; + if (mount("ramfs", ramroot, "ramfs", MS_NOSUID | MS_NODEV, NULL)) + return 10; + char old[128]; + snprintf(old, sizeof(old), "%s/old", ramroot); + if (mkdir(old, 0700) || mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL) || + chdir(ramroot) || syscall(SYS_pivot_root, ".", "old") || chdir("/") || + umount2("/old", MNT_DETACH)) + return 11; + fputs("CK_UPDATE: old root detached\n", stderr); +#else + (void)other_processes; +#endif + /* Keep a RAM copy of the journal for an independent preservation check. */ + uint8_t *journal = malloc(2 * CK_SLOT_SIZE); + uint8_t *after = malloc(2 * CK_SLOT_SIZE); + uint8_t *payload = malloc(CK_ROOTFS_SIZE); + if (!journal || !after || !payload || + ck_flash_read(&flash, CK_JOURNAL_OFFSET, journal, 2 * CK_SLOT_SIZE) || + lseek(image, 256, SEEK_SET) < 0 || + ck_read_all(image, payload, CK_ROOTFS_SIZE)) + return 6; + close(image); +#ifdef CK_TESTING + if (simulation) + unlink(path); +#endif + /* Invalidate the magic first; commit the header only after all body sectors. + */ + int result = 0; + if (ck_flash_erase(&flash, 0, CK_ERASE_SIZE)) + result = -1; + for (unsigned at = CK_ERASE_SIZE; !result && at < CK_ROOTFS_SIZE; + at += CK_ERASE_SIZE) { +#ifdef CK_TESTING + const char *fail = getenv("CK_FAIL_OTA_AFTER"); + if (fail && at / CK_ERASE_SIZE >= (unsigned)strtoul(fail, NULL, 10)) { + result = -1; + break; + } +#endif + if (ck_flash_erase(&flash, at, CK_ERASE_SIZE) || + ck_flash_write(&flash, at, payload + at, CK_ERASE_SIZE)) + result = -1; + } + if (!result && (ck_flash_sync(&flash) || + ck_flash_write(&flash, 0, payload, CK_ERASE_SIZE) || + ck_flash_sync(&flash))) + result = -1; + uint8_t digest[32]; + if (!result && (ck_hash_fd(flash.fd, flash.base, CK_ROOTFS_SIZE, digest) || + !ck_equal(digest, im.header + 32, 32))) + result = -1; + if (ck_flash_read(&flash, CK_JOURNAL_OFFSET, after, 2 * CK_SLOT_SIZE) || + !ck_equal(journal, after, 2 * CK_SLOT_SIZE)) + result = -1; + free(journal); + free(after); + free(payload); + ck_unlock(lock); + close(flash.fd); + fprintf(stderr, "CK_UPDATE: readback result=%d\n", result); +#if !defined(CK_TESTING) || defined(CK_TEST_PID1) + /* After quiescing PID1, always reboot; a partial image is deliberately + * invalid. */ + sync(); + reboot(RB_AUTOBOOT); + fprintf(stderr, "CK_UPDATE: reboot failed errno=%d\n", errno); + for (;;) + pause(); +#else + +#endif + return result ? 12 : 0; +} +int main(int argc, char **argv) { + if (argc == 3 && !strcmp(argv[1], "--verify")) { + struct ck_image im; + int r = ck_ota_verify(argv[2], &im); + puts(r ? "rejected" : "verified"); + return r ? 1 : 0; + } + if (argc == 3 && !strcmp(argv[1], "--stage")) + return ck_ota_stage(argv[2]) ? 1 : 0; + if ((argc == 2 || argc == 3) && !strcmp(argv[1], "--queue")) { + uint8_t digest[32]; + if (argc == 3 && (strlen(argv[2]) != 64 || ck_unhex(argv[2], 32, digest))) return 2; + return ck_ota_queue(argc == 3 ? argv[2] : NULL) ? 1 : 0; + } + if (argc == 2 && !strcmp(argv[1], "--pid1")) { + int result = apply(0); + fprintf(stderr, "CK_UPDATE: preflight failed code=%d\n", result); +#if !defined(CK_TESTING) || defined(CK_TEST_PID1) + if (getpid() == 1) { + sync(); + reboot(RB_AUTOBOOT); + for (;;) + pause(); + } +#endif + return result; + } +#ifdef CK_TESTING + 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], "--boot")) + return ck_store_boot(); + if (argc == 2 && !strcmp(argv[1], "--confirm")) { + uint8_t meta[12]; + if (ck_exact_file(ck_path("/etc/ck-update.meta"), meta, 12)) + return 1; + return ck_store_confirm(ck_be32(meta + 8)) ? 1 : 0; + } + fputs("ck-updater: --verify FILE | --stage FILE | --queue | --import | " + "--boot | --confirm\n", + stderr); + return 2; +} diff --git a/firmware/matter/src/update/update.html b/firmware/matter/src/update/update.html new file mode 100644 index 0000000..605c393 --- /dev/null +++ b/firmware/matter/src/update/update.html @@ -0,0 +1,14 @@ + + +CommandKit 更新 + +
COMMANDKIT / DEVICE CARE

讓插座保持更新

目前版本 讀取中… · CMNDKT-004

+

線上更新

檢查已發布的版本,下載後由插座驗證簽章。

+

使用已下載的更新包

更新會暫停控制並重新啟動插座,請保持供電。裝置設定與已保存的配對會保留。

+

可檢查新版本,或選擇已下載的更新包。

+
更新完成並重新連線通常需要數分鐘。若應用程式無法啟動,可透過此頁安裝修正版。
+ diff --git a/firmware/matter/src/update/update.js b/firmware/matter/src/update/update.js new file mode 100644 index 0000000..a3f5477 --- /dev/null +++ b/firmware/matter/src/update/update.js @@ -0,0 +1,50 @@ +/* SHA-256 also work on an HTTP LAN page where SubtleCrypto is unavailable. */ +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(''); +if (typeof module !== 'undefined') module.exports={sha256,hex}; +if (typeof document !== 'undefined') (async () => { + const $=id=>document.getElementById(id), message=text=>{$('message').textContent=text;}; + let status=null, latest=null, busy=false; + 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); + 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);} +})(); diff --git a/firmware/matter/src/update/update_page.h b/firmware/matter/src/update/update_page.h new file mode 100644 index 0000000..466f29e --- /dev/null +++ b/firmware/matter/src/update/update_page.h @@ -0,0 +1,2 @@ +/* Generated by tools/generate_update.py. */ +static const char ck_update_page[] = "\n\nCommandKit 更新\n\n
COMMANDKIT / DEVICE CARE

讓插座保持更新

目前版本 讀取中… · CMNDKT-004

\n

線上更新

檢查已發布的版本,下載後由插座驗證簽章。

\n

使用已下載的更新包

更新會暫停控制並重新啟動插座,請保持供電。裝置設定與已保存的配對會保留。

\n

可檢查新版本,或選擇已下載的更新包。

\n
更新完成並重新連線通常需要數分鐘。若應用程式無法啟動,可透過此頁安裝修正版。
\n\n"; diff --git a/firmware/matter/src/vendor/monocypher/LICENCE.md b/firmware/matter/src/vendor/monocypher/LICENCE.md new file mode 100644 index 0000000..9d69ad7 --- /dev/null +++ b/firmware/matter/src/vendor/monocypher/LICENCE.md @@ -0,0 +1,173 @@ +Monocypher as a whole is dual-licensed. Choose whichever licence you +want from the two licences listed below. + +The first licence is a regular 2-clause BSD licence. The second licence +is the CC-0 from Creative Commons. It is intended to release Monocypher +to the public domain. The BSD licence serves as a fallback option. + +See the individual files for specific information about who contributed +to what file during which years. See below for special notes. + +Licence 1 (2-clause BSD) +------------------------ + +Copyright (c) 2017-2023, Loup Vaillant +Copyright (c) 2017-2019, Michael Savage +Copyright (c) 2017-2023, Fabio Scotoni +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the + distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +Licence 2 (CC-0) +---------------- + +> CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE +> LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN +> ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS +> INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES +> REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS +> PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM +> THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED +> HEREUNDER. + +### Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work +of authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without +fear of later claims of infringement build upon, modify, incorporate in +other works, reuse and redistribute as freely as possible in any form +whatsoever and for any purposes, including without limitation commercial +purposes. These owners may contribute to the Commons to promote the +ideal of a free culture and the further production of creative, cultural +and scientific works, or to gain reputation or greater distribution for +their Work in part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or +she is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under +its terms, with knowledge of his or her Copyright and Related Rights in +the Work and the meaning and intended legal effect of CC0 on those +rights. + +1. **Copyright and Related Rights.** A Work made available under CC0 may + be protected by copyright and related or neighboring rights + ("Copyright and Related Rights"). Copyright and Related Rights + include, but are not limited to, the following: + + - the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + - moral rights retained by the original author(s) and/or + performer(s); publicity and privacy rights pertaining to a person's + image or likeness depicted in a Work; + - rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + - rights protecting the extraction, dissemination, use and reuse of + data in a Work; + - database rights (such as those arising under Directive 96/9/EC of + the European Parliament and of the Council of 11 March 1996 on the + legal protection of databases, and under any national + implementation thereof, including any amended or successor version + of such directive); and + - other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. **Waiver.** To the greatest extent permitted by, but not in + contravention of, applicable law, Affirmer hereby overtly, fully, + permanently, irrevocably and unconditionally waives, abandons, and + surrenders all of Affirmer's Copyright and Related Rights and + associated claims and causes of action, whether now known or unknown + (including existing as well as future claims and causes of action), + in the Work (i) in all territories worldwide, (ii) for the maximum + duration provided by applicable law or treaty (including future time + extensions), (iii) in any current or future medium and for any number + of copies, and (iv) for any purpose whatsoever, including without + limitation commercial, advertising or promotional purposes (the + "Waiver"). Affirmer makes the Waiver for the benefit of each member + of the public at large and to the detriment of Affirmer's heirs and + successors, fully intending that such Waiver shall not be subject to + revocation, rescission, cancellation, termination, or any other legal + or equitable action to disrupt the quiet enjoyment of the Work by the + public as contemplated by Affirmer's express Statement of Purpose. + +3. **Public License Fallback.** Should any part of the Waiver for any + reason be judged legally invalid or ineffective under applicable law, + then the Waiver shall be preserved to the maximum extent permitted + taking into account Affirmer's express Statement of Purpose. In + addition, to the extent the Waiver is so judged Affirmer hereby + grants to each affected person a royalty-free, non transferable, non + sublicensable, non exclusive, irrevocable and unconditional license + to exercise Affirmer's Copyright and Related Rights in the Work (i) + in all territories worldwide, (ii) for the maximum duration provided + by applicable law or treaty (including future time extensions), (iii) + in any current or future medium and for any number of copies, and + (iv) for any purpose whatsoever, including without limitation + commercial, advertising or promotional purposes (the "License"). The + License shall be deemed effective as of the date CC0 was applied by + Affirmer to the Work. Should any part of the License for any reason + be judged legally invalid or ineffective under applicable law, such + partial invalidity or ineffectiveness shall not invalidate the + remainder of the License, and in such case Affirmer hereby affirms + that he or she will not (i) exercise any of his or her remaining + Copyright and Related Rights in the Work or (ii) assert any + associated claims and causes of action with respect to the Work, in + either case contrary to Affirmer's express Statement of Purpose. + +4. **Limitations and Disclaimers.** + + - No trademark or patent rights held by Affirmer are waived, + abandoned, surrendered, licensed or otherwise affected by this + document. + - Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, + or the present or absence of errors, whether or not discoverable, + all to the greatest extent permissible under applicable law. + - Affirmer disclaims responsibility for clearing rights of other + persons that may apply to the Work or any use thereof, including + without limitation any person's Copyright and Related Rights in the + Work. Further, Affirmer disclaims responsibility for obtaining any + necessary consents, permissions or other rights required for any + use of the Work. + - Affirmer understands and acknowledges that Creative Commons is not + a party to this document and has no duty or obligation with respect + to this CC0 or use of the Work. + +Special notes +------------- + +The files in `tests/externals/` were placed in the public domain by +their respective authors. See the `AUTHORS.md` files in each directory. diff --git a/firmware/matter/src/vendor/monocypher/monocypher-ed25519.c b/firmware/matter/src/vendor/monocypher/monocypher-ed25519.c new file mode 100644 index 0000000..4fdd076 --- /dev/null +++ b/firmware/matter/src/vendor/monocypher/monocypher-ed25519.c @@ -0,0 +1,503 @@ +// Monocypher version __git__ +// +// This file is dual-licensed. Choose whichever licence you want from +// the two licences listed below. +// +// The first licence is a regular 2-clause BSD licence. The second licence +// is the CC-0 from Creative Commons. It is intended to release Monocypher +// to the public domain. The BSD licence serves as a fallback option. +// +// SPDX-License-Identifier: BSD-2-Clause OR CC0-1.0 +// +// ------------------------------------------------------------------------ +// +// Copyright (c) 2017-2019, Loup Vaillant +// All rights reserved. +// +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// 1. Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the +// distribution. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +// +// ------------------------------------------------------------------------ +// +// Written in 2017-2019 by Loup Vaillant +// +// To the extent possible under law, the author(s) have dedicated all copyright +// and related neighboring rights to this software to the public domain +// worldwide. This software is distributed without any warranty. +// +// You should have received a copy of the CC0 Public Domain Dedication along +// with this software. If not, see +// + +#include "monocypher-ed25519.h" + +#ifdef MONOCYPHER_CPP_NAMESPACE +namespace MONOCYPHER_CPP_NAMESPACE { +#endif + +///////////////// +/// Utilities /// +///////////////// +#define FOR(i, min, max) for (size_t i = min; i < max; i++) +#define COPY(dst, src, size) FOR(_i_, 0, size) (dst)[_i_] = (src)[_i_] +#define ZERO(buf, size) FOR(_i_, 0, size) (buf)[_i_] = 0 +#define WIPE_CTX(ctx) crypto_wipe(ctx , sizeof(*(ctx))) +#define WIPE_BUFFER(buffer) crypto_wipe(buffer, sizeof(buffer)) +#define MIN(a, b) ((a) <= (b) ? (a) : (b)) +typedef uint8_t u8; +typedef uint64_t u64; + +// Returns the smallest positive integer y such that +// (x + y) % pow_2 == 0 +// Basically, it's how many bytes we need to add to "align" x. +// Only works when pow_2 is a power of 2. +// Note: we use ~x+1 instead of -x to avoid compiler warnings +static size_t align(size_t x, size_t pow_2) +{ + return (~x + 1) & (pow_2 - 1); +} + +static u64 load64_be(const u8 s[8]) +{ + return((u64)s[0] << 56) + | ((u64)s[1] << 48) + | ((u64)s[2] << 40) + | ((u64)s[3] << 32) + | ((u64)s[4] << 24) + | ((u64)s[5] << 16) + | ((u64)s[6] << 8) + | (u64)s[7]; +} + +static void store64_be(u8 out[8], u64 in) +{ + out[0] = (u8)(in >> 56); + out[1] = (u8)(in >> 48); + out[2] = (u8)(in >> 40); + out[3] = (u8)(in >> 32); + out[4] = (u8)(in >> 24); + out[5] = (u8)(in >> 16); + out[6] = (u8)(in >> 8); + out[7] = (u8) in ; +} + +static void load64_be_buf (u64 *dst, const u8 *src, size_t size) { + FOR(i, 0, size) { dst[i] = load64_be(src + i*8); } +} + +/////////////// +/// SHA 512 /// +/////////////// +static u64 rot(u64 x, int c ) { return (x >> c) | (x << (64 - c)); } +static u64 ch (u64 x, u64 y, u64 z) { return (x & y) ^ (~x & z); } +static u64 maj(u64 x, u64 y, u64 z) { return (x & y) ^ ( x & z) ^ (y & z); } +static u64 big_sigma0(u64 x) { return rot(x, 28) ^ rot(x, 34) ^ rot(x, 39); } +static u64 big_sigma1(u64 x) { return rot(x, 14) ^ rot(x, 18) ^ rot(x, 41); } +static u64 lit_sigma0(u64 x) { return rot(x, 1) ^ rot(x, 8) ^ (x >> 7); } +static u64 lit_sigma1(u64 x) { return rot(x, 19) ^ rot(x, 61) ^ (x >> 6); } + +static const u64 K[80] = { + 0x428a2f98d728ae22,0x7137449123ef65cd,0xb5c0fbcfec4d3b2f,0xe9b5dba58189dbbc, + 0x3956c25bf348b538,0x59f111f1b605d019,0x923f82a4af194f9b,0xab1c5ed5da6d8118, + 0xd807aa98a3030242,0x12835b0145706fbe,0x243185be4ee4b28c,0x550c7dc3d5ffb4e2, + 0x72be5d74f27b896f,0x80deb1fe3b1696b1,0x9bdc06a725c71235,0xc19bf174cf692694, + 0xe49b69c19ef14ad2,0xefbe4786384f25e3,0x0fc19dc68b8cd5b5,0x240ca1cc77ac9c65, + 0x2de92c6f592b0275,0x4a7484aa6ea6e483,0x5cb0a9dcbd41fbd4,0x76f988da831153b5, + 0x983e5152ee66dfab,0xa831c66d2db43210,0xb00327c898fb213f,0xbf597fc7beef0ee4, + 0xc6e00bf33da88fc2,0xd5a79147930aa725,0x06ca6351e003826f,0x142929670a0e6e70, + 0x27b70a8546d22ffc,0x2e1b21385c26c926,0x4d2c6dfc5ac42aed,0x53380d139d95b3df, + 0x650a73548baf63de,0x766a0abb3c77b2a8,0x81c2c92e47edaee6,0x92722c851482353b, + 0xa2bfe8a14cf10364,0xa81a664bbc423001,0xc24b8b70d0f89791,0xc76c51a30654be30, + 0xd192e819d6ef5218,0xd69906245565a910,0xf40e35855771202a,0x106aa07032bbd1b8, + 0x19a4c116b8d2d0c8,0x1e376c085141ab53,0x2748774cdf8eeb99,0x34b0bcb5e19b48a8, + 0x391c0cb3c5c95a63,0x4ed8aa4ae3418acb,0x5b9cca4f7763e373,0x682e6ff3d6b2b8a3, + 0x748f82ee5defb2fc,0x78a5636f43172f60,0x84c87814a1f0ab72,0x8cc702081a6439ec, + 0x90befffa23631e28,0xa4506cebde82bde9,0xbef9a3f7b2c67915,0xc67178f2e372532b, + 0xca273eceea26619c,0xd186b8c721c0c207,0xeada7dd6cde0eb1e,0xf57d4f7fee6ed178, + 0x06f067aa72176fba,0x0a637dc5a2c898a6,0x113f9804bef90dae,0x1b710b35131c471b, + 0x28db77f523047d84,0x32caab7b40c72493,0x3c9ebe0a15c9bebc,0x431d67c49c100d4c, + 0x4cc5d4becb3e42b6,0x597f299cfc657e2a,0x5fcb6fab3ad6faec,0x6c44198c4a475817 +}; + +static void sha512_compress(crypto_sha512_ctx *ctx) +{ + u64 a = ctx->hash[0]; u64 b = ctx->hash[1]; + u64 c = ctx->hash[2]; u64 d = ctx->hash[3]; + u64 e = ctx->hash[4]; u64 f = ctx->hash[5]; + u64 g = ctx->hash[6]; u64 h = ctx->hash[7]; + + FOR (j, 0, 16) { + u64 in = K[j] + ctx->input[j]; + u64 t1 = big_sigma1(e) + ch (e, f, g) + h + in; + u64 t2 = big_sigma0(a) + maj(a, b, c); + h = g; g = f; f = e; e = d + t1; + d = c; c = b; b = a; a = t1 + t2; + } + size_t i16 = 0; + FOR(i, 1, 5) { + i16 += 16; + FOR (j, 0, 16) { + ctx->input[j] += lit_sigma1(ctx->input[(j- 2) & 15]); + ctx->input[j] += lit_sigma0(ctx->input[(j-15) & 15]); + ctx->input[j] += ctx->input[(j- 7) & 15]; + u64 in = K[i16 + j] + ctx->input[j]; + u64 t1 = big_sigma1(e) + ch (e, f, g) + h + in; + u64 t2 = big_sigma0(a) + maj(a, b, c); + h = g; g = f; f = e; e = d + t1; + d = c; c = b; b = a; a = t1 + t2; + } + } + + ctx->hash[0] += a; ctx->hash[1] += b; + ctx->hash[2] += c; ctx->hash[3] += d; + ctx->hash[4] += e; ctx->hash[5] += f; + ctx->hash[6] += g; ctx->hash[7] += h; +} + +// Write 1 input byte +static void sha512_set_input(crypto_sha512_ctx *ctx, u8 input) +{ + size_t word = ctx->input_idx >> 3; + size_t byte = ctx->input_idx & 7; + ctx->input[word] |= (u64)input << (8 * (7 - byte)); +} + +// Increment a 128-bit "word". +static void sha512_incr(u64 x[2], u64 y) +{ + x[1] += y; + if (x[1] < y) { + x[0]++; + } +} + +void crypto_sha512_init(crypto_sha512_ctx *ctx) +{ + ctx->hash[0] = 0x6a09e667f3bcc908; + ctx->hash[1] = 0xbb67ae8584caa73b; + ctx->hash[2] = 0x3c6ef372fe94f82b; + ctx->hash[3] = 0xa54ff53a5f1d36f1; + ctx->hash[4] = 0x510e527fade682d1; + ctx->hash[5] = 0x9b05688c2b3e6c1f; + ctx->hash[6] = 0x1f83d9abfb41bd6b; + ctx->hash[7] = 0x5be0cd19137e2179; + ctx->input_size[0] = 0; + ctx->input_size[1] = 0; + ctx->input_idx = 0; + ZERO(ctx->input, 16); +} + +void crypto_sha512_update(crypto_sha512_ctx *ctx, + const u8 *message, size_t message_size) +{ + // Avoid undefined NULL pointer increments with empty messages + if (message_size == 0) { + return; + } + + // Align ourselves with word boundaries + if ((ctx->input_idx & 7) != 0) { + size_t nb_bytes = MIN(align(ctx->input_idx, 8), message_size); + FOR (i, 0, nb_bytes) { + sha512_set_input(ctx, message[i]); + ctx->input_idx++; + } + message += nb_bytes; + message_size -= nb_bytes; + } + + // Align ourselves with block boundaries + if ((ctx->input_idx & 127) != 0) { + size_t nb_words = MIN(align(ctx->input_idx, 128), message_size) >> 3; + load64_be_buf(ctx->input + (ctx->input_idx >> 3), message, nb_words); + ctx->input_idx += nb_words << 3; + message += nb_words << 3; + message_size -= nb_words << 3; + } + + // Compress block if needed + if (ctx->input_idx == 128) { + sha512_incr(ctx->input_size, 1024); // size is in bits + sha512_compress(ctx); + ctx->input_idx = 0; + ZERO(ctx->input, 16); + } + + // Process the message block by block + FOR (i, 0, message_size >> 7) { // number of blocks + load64_be_buf(ctx->input, message, 16); + sha512_incr(ctx->input_size, 1024); // size is in bits + sha512_compress(ctx); + ctx->input_idx = 0; + ZERO(ctx->input, 16); + message += 128; + } + message_size &= 127; + + if (message_size != 0) { + // Remaining words + size_t nb_words = message_size >> 3; + load64_be_buf(ctx->input, message, nb_words); + ctx->input_idx += nb_words << 3; + message += nb_words << 3; + message_size -= nb_words << 3; + + // Remaining bytes + FOR (i, 0, message_size) { + sha512_set_input(ctx, message[i]); + ctx->input_idx++; + } + } +} + +void crypto_sha512_final(crypto_sha512_ctx *ctx, u8 hash[64]) +{ + // Add padding bit + if (ctx->input_idx == 0) { + ZERO(ctx->input, 16); + } + sha512_set_input(ctx, 128); + + // Update size + sha512_incr(ctx->input_size, ctx->input_idx * 8); + + // Compress penultimate block (if any) + if (ctx->input_idx > 111) { + sha512_compress(ctx); + ZERO(ctx->input, 14); + } + // Compress last block + ctx->input[14] = ctx->input_size[0]; + ctx->input[15] = ctx->input_size[1]; + sha512_compress(ctx); + + // Copy hash to output (big endian) + FOR (i, 0, 8) { + store64_be(hash + i*8, ctx->hash[i]); + } + + WIPE_CTX(ctx); +} + +void crypto_sha512(u8 hash[64], const u8 *message, size_t message_size) +{ + crypto_sha512_ctx ctx; + crypto_sha512_init (&ctx); + crypto_sha512_update(&ctx, message, message_size); + crypto_sha512_final (&ctx, hash); +} + +//////////////////// +/// HMAC SHA 512 /// +//////////////////// +void crypto_sha512_hmac_init(crypto_sha512_hmac_ctx *ctx, + const u8 *key, size_t key_size) +{ + // hash key if it is too long + if (key_size > 128) { + crypto_sha512(ctx->key, key, key_size); + key = ctx->key; + key_size = 64; + } + // Compute inner key: padded key XOR 0x36 + FOR (i, 0, key_size) { ctx->key[i] = key[i] ^ 0x36; } + FOR (i, key_size, 128) { ctx->key[i] = 0x36; } + // Start computing inner hash + crypto_sha512_init (&ctx->ctx); + crypto_sha512_update(&ctx->ctx, ctx->key, 128); +} + +void crypto_sha512_hmac_update(crypto_sha512_hmac_ctx *ctx, + const u8 *message, size_t message_size) +{ + crypto_sha512_update(&ctx->ctx, message, message_size); +} + +void crypto_sha512_hmac_final(crypto_sha512_hmac_ctx *ctx, u8 hmac[64]) +{ + // Finish computing inner hash + crypto_sha512_final(&ctx->ctx, hmac); + // Compute outer key: padded key XOR 0x5c + FOR (i, 0, 128) { + ctx->key[i] ^= 0x36 ^ 0x5c; + } + // Compute outer hash + crypto_sha512_init (&ctx->ctx); + crypto_sha512_update(&ctx->ctx, ctx->key , 128); + crypto_sha512_update(&ctx->ctx, hmac, 64); + crypto_sha512_final (&ctx->ctx, hmac); // outer hash + WIPE_CTX(ctx); +} + +void crypto_sha512_hmac(u8 hmac[64], const u8 *key, size_t key_size, + const u8 *message, size_t message_size) +{ + crypto_sha512_hmac_ctx ctx; + crypto_sha512_hmac_init (&ctx, key, key_size); + crypto_sha512_hmac_update(&ctx, message, message_size); + crypto_sha512_hmac_final (&ctx, hmac); +} + +//////////////////// +/// HKDF SHA 512 /// +//////////////////// +void crypto_sha512_hkdf_expand(u8 *okm, size_t okm_size, + const u8 *prk, size_t prk_size, + const u8 *info, size_t info_size) +{ + int not_first = 0; + u8 ctr = 1; + u8 blk[64]; + + while (okm_size > 0) { + size_t out_size = MIN(okm_size, sizeof(blk)); + + crypto_sha512_hmac_ctx ctx; + crypto_sha512_hmac_init(&ctx, prk , prk_size); + if (not_first) { + // For some reason HKDF uses some kind of CBC mode. + // For some reason CTR mode alone wasn't enough. + // Like what, they didn't trust HMAC in 2010? Really?? + crypto_sha512_hmac_update(&ctx, blk , sizeof(blk)); + } + crypto_sha512_hmac_update(&ctx, info, info_size); + crypto_sha512_hmac_update(&ctx, &ctr, 1); + crypto_sha512_hmac_final(&ctx, blk); + + COPY(okm, blk, out_size); + + not_first = 1; + okm += out_size; + okm_size -= out_size; + ctr++; + } + WIPE_BUFFER(blk); +} + +void crypto_sha512_hkdf(u8 *okm , size_t okm_size, + const u8 *ikm , size_t ikm_size, + const u8 *salt, size_t salt_size, + const u8 *info, size_t info_size) +{ + // Extract + u8 prk[64]; + crypto_sha512_hmac(prk, salt, salt_size, ikm, ikm_size); + + // Expand + crypto_sha512_hkdf_expand(okm, okm_size, prk, sizeof(prk), info, info_size); + + WIPE_BUFFER(prk); +} + +/////////////// +/// Ed25519 /// +/////////////// +void crypto_ed25519_key_pair(u8 secret_key[64], u8 public_key[32], u8 seed[32]) +{ + u8 a[64]; + COPY(a, seed, 32); // a[ 0..31] = seed + crypto_wipe(seed, 32); + COPY(secret_key, a, 32); // secret key = seed + crypto_sha512(a, a, 32); // a[ 0..31] = scalar + crypto_eddsa_trim_scalar(a, a); // a[ 0..31] = trimmed scalar + crypto_eddsa_scalarbase(public_key, a); // public key = [trimmed scalar]B + COPY(secret_key + 32, public_key, 32); // secret key includes public half + WIPE_BUFFER(a); +} + +static void hash_reduce(u8 h[32], + const u8 *a, size_t a_size, + const u8 *b, size_t b_size, + const u8 *c, size_t c_size, + const u8 *d, size_t d_size) +{ + u8 hash[64]; + crypto_sha512_ctx ctx; + crypto_sha512_init (&ctx); + crypto_sha512_update(&ctx, a, a_size); + crypto_sha512_update(&ctx, b, b_size); + crypto_sha512_update(&ctx, c, c_size); + crypto_sha512_update(&ctx, d, d_size); + crypto_sha512_final (&ctx, hash); + crypto_eddsa_reduce(h, hash); +} + +static void ed25519_dom_sign(u8 signature[64], const u8 secret_key[64], + const u8 *dom, size_t dom_size, + const u8 *message, size_t message_size) +{ + u8 a[64]; // secret scalar and prefix + u8 r[32]; // secret deterministic "random" nonce + u8 h[32]; // publically verifiable hash of the message (not wiped) + u8 R[32]; // first half of the signature (allows overlapping inputs) + const u8 *pk = secret_key + 32; + + crypto_sha512(a, secret_key, 32); + crypto_eddsa_trim_scalar(a, a); + hash_reduce(r, dom, dom_size, a + 32, 32, message, message_size, 0, 0); + crypto_eddsa_scalarbase(R, r); + hash_reduce(h, dom, dom_size, R, 32, pk, 32, message, message_size); + COPY(signature, R, 32); + crypto_eddsa_mul_add(signature + 32, h, a, r); + + WIPE_BUFFER(a); + WIPE_BUFFER(r); +} + +void crypto_ed25519_sign(u8 signature [64], const u8 secret_key[64], + const u8 *message, size_t message_size) +{ + ed25519_dom_sign(signature, secret_key, 0, 0, message, message_size); +} + +int crypto_ed25519_check(const u8 signature[64], const u8 public_key[32], + const u8 *msg, size_t msg_size) +{ + u8 h_ram[32]; + hash_reduce(h_ram, signature, 32, public_key, 32, msg, msg_size, 0, 0); + return crypto_eddsa_check_equation(signature, public_key, h_ram); +} + +static const u8 domain[34] = "SigEd25519 no Ed25519 collisions\1"; + +void crypto_ed25519_ph_sign(uint8_t signature[64], const uint8_t secret_key[64], + const uint8_t message_hash[64]) +{ + ed25519_dom_sign(signature, secret_key, domain, sizeof(domain), + message_hash, 64); +} + +int crypto_ed25519_ph_check(const uint8_t sig[64], const uint8_t pk[32], + const uint8_t msg_hash[64]) +{ + u8 h_ram[32]; + hash_reduce(h_ram, domain, sizeof(domain), sig, 32, pk, 32, msg_hash, 64); + return crypto_eddsa_check_equation(sig, pk, h_ram); +} + + +#ifdef MONOCYPHER_CPP_NAMESPACE +} +#endif diff --git a/firmware/matter/src/vendor/monocypher/monocypher-ed25519.h b/firmware/matter/src/vendor/monocypher/monocypher-ed25519.h new file mode 100644 index 0000000..d7aa004 --- /dev/null +++ b/firmware/matter/src/vendor/monocypher/monocypher-ed25519.h @@ -0,0 +1,140 @@ +// Monocypher version __git__ +// +// This file is dual-licensed. Choose whichever licence you want from +// the two licences listed below. +// +// The first licence is a regular 2-clause BSD licence. The second licence +// is the CC-0 from Creative Commons. It is intended to release Monocypher +// to the public domain. The BSD licence serves as a fallback option. +// +// SPDX-License-Identifier: BSD-2-Clause OR CC0-1.0 +// +// ------------------------------------------------------------------------ +// +// Copyright (c) 2017-2019, Loup Vaillant +// All rights reserved. +// +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// 1. Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the +// distribution. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +// +// ------------------------------------------------------------------------ +// +// Written in 2017-2019 by Loup Vaillant +// +// To the extent possible under law, the author(s) have dedicated all copyright +// and related neighboring rights to this software to the public domain +// worldwide. This software is distributed without any warranty. +// +// You should have received a copy of the CC0 Public Domain Dedication along +// with this software. If not, see +// + +#ifndef ED25519_H +#define ED25519_H + +#include "monocypher.h" + +#ifdef MONOCYPHER_CPP_NAMESPACE +namespace MONOCYPHER_CPP_NAMESPACE { +#elif defined(__cplusplus) +extern "C" { +#endif + +//////////////////////// +/// Type definitions /// +//////////////////////// + +// Do not rely on the size or content on any of those types, +// they may change without notice. +typedef struct { + uint64_t hash[8]; + uint64_t input[16]; + uint64_t input_size[2]; + size_t input_idx; +} crypto_sha512_ctx; + +typedef struct { + uint8_t key[128]; + crypto_sha512_ctx ctx; +} crypto_sha512_hmac_ctx; + + +// SHA 512 +// ------- +void crypto_sha512_init (crypto_sha512_ctx *ctx); +void crypto_sha512_update(crypto_sha512_ctx *ctx, + const uint8_t *message, size_t message_size); +void crypto_sha512_final (crypto_sha512_ctx *ctx, uint8_t hash[64]); +void crypto_sha512(uint8_t hash[64], + const uint8_t *message, size_t message_size); + +// SHA 512 HMAC +// ------------ +void crypto_sha512_hmac_init(crypto_sha512_hmac_ctx *ctx, + const uint8_t *key, size_t key_size); +void crypto_sha512_hmac_update(crypto_sha512_hmac_ctx *ctx, + const uint8_t *message, size_t message_size); +void crypto_sha512_hmac_final(crypto_sha512_hmac_ctx *ctx, uint8_t hmac[64]); +void crypto_sha512_hmac(uint8_t hmac[64], + const uint8_t *key , size_t key_size, + const uint8_t *message, size_t message_size); + +// SHA 512 HKDF +// ------------ +void crypto_sha512_hkdf_expand(uint8_t *okm, size_t okm_size, + const uint8_t *prk, size_t prk_size, + const uint8_t *info, size_t info_size); +void crypto_sha512_hkdf(uint8_t *okm , size_t okm_size, + const uint8_t *ikm , size_t ikm_size, + const uint8_t *salt, size_t salt_size, + const uint8_t *info, size_t info_size); + +// Ed25519 +// ------- +// Signatures (EdDSA with curve25519 + SHA-512) +// -------------------------------------------- +void crypto_ed25519_key_pair(uint8_t secret_key[64], + uint8_t public_key[32], + uint8_t seed[32]); +void crypto_ed25519_sign(uint8_t signature [64], + const uint8_t secret_key[64], + const uint8_t *message, size_t message_size); +int crypto_ed25519_check(const uint8_t signature [64], + const uint8_t public_key[32], + const uint8_t *message, size_t message_size); + +// Pre-hash variants +void crypto_ed25519_ph_sign(uint8_t signature [64], + const uint8_t secret_key [64], + const uint8_t message_hash[64]); +int crypto_ed25519_ph_check(const uint8_t signature [64], + const uint8_t public_key [32], + const uint8_t message_hash[64]); + +#ifdef __cplusplus +} +#endif + +#endif // ED25519_H diff --git a/firmware/matter/src/vendor/monocypher/monocypher.c b/firmware/matter/src/vendor/monocypher/monocypher.c new file mode 100644 index 0000000..c7c5450 --- /dev/null +++ b/firmware/matter/src/vendor/monocypher/monocypher.c @@ -0,0 +1,2988 @@ +// Monocypher version __git__ +// +// This file is dual-licensed. Choose whichever licence you want from +// the two licences listed below. +// +// The first licence is a regular 2-clause BSD licence. The second licence +// is the CC-0 from Creative Commons. It is intended to release Monocypher +// to the public domain. The BSD licence serves as a fallback option. +// +// SPDX-License-Identifier: BSD-2-Clause OR CC0-1.0 +// +// ------------------------------------------------------------------------ +// +// Copyright (c) 2017-2020, Loup Vaillant +// All rights reserved. +// +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// 1. Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the +// distribution. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +// +// ------------------------------------------------------------------------ +// +// Written in 2017-2020 by Loup Vaillant +// +// To the extent possible under law, the author(s) have dedicated all copyright +// and related neighboring rights to this software to the public domain +// worldwide. This software is distributed without any warranty. +// +// You should have received a copy of the CC0 Public Domain Dedication along +// with this software. If not, see +// + +#include "monocypher.h" + +#ifdef MONOCYPHER_CPP_NAMESPACE +namespace MONOCYPHER_CPP_NAMESPACE { +#endif + +///////////////// +/// Utilities /// +///////////////// +#define FOR_T(type, i, start, end) for (type i = (start); i < (end); i++) +#define FOR(i, start, end) FOR_T(size_t, i, start, end) +#define COPY(dst, src, size) FOR(_i_, 0, size) (dst)[_i_] = (src)[_i_] +#define ZERO(buf, size) FOR(_i_, 0, size) (buf)[_i_] = 0 +#define WIPE_CTX(ctx) crypto_wipe(ctx , sizeof(*(ctx))) +#define WIPE_BUFFER(buffer) crypto_wipe(buffer, sizeof(buffer)) +#define MIN(a, b) ((a) <= (b) ? (a) : (b)) +#define MAX(a, b) ((a) >= (b) ? (a) : (b)) + +typedef int8_t i8; +typedef uint8_t u8; +typedef int16_t i16; +typedef uint32_t u32; +typedef int32_t i32; +typedef int64_t i64; +typedef uint64_t u64; + +static const u8 zero[128] = {0}; + +// returns the smallest positive integer y such that +// (x + y) % pow_2 == 0 +// Basically, y is the "gap" missing to align x. +// Only works when pow_2 is a power of 2. +// Note: we use ~x+1 instead of -x to avoid compiler warnings +static size_t gap(size_t x, size_t pow_2) +{ + return (~x + 1) & (pow_2 - 1); +} + +static u32 load24_le(const u8 s[3]) +{ + return + ((u32)s[0] << 0) | + ((u32)s[1] << 8) | + ((u32)s[2] << 16); +} + +static u32 load32_le(const u8 s[4]) +{ + return + ((u32)s[0] << 0) | + ((u32)s[1] << 8) | + ((u32)s[2] << 16) | + ((u32)s[3] << 24); +} + +static u64 load64_le(const u8 s[8]) +{ + return load32_le(s) | ((u64)load32_le(s+4) << 32); +} + +static void store32_le(u8 out[4], u32 in) +{ + out[0] = (u8)(in ); + out[1] = (u8)(in >> 8); + out[2] = (u8)(in >> 16); + out[3] = (u8)(in >> 24); +} + +static void store64_le(u8 out[8], u64 in) +{ + store32_le(out , (u32)(in )); + store32_le(out + 4, (u32)(in >> 32)); +} + +static void load32_le_buf (u32 *dst, const u8 *src, size_t size) { + FOR(i, 0, size) { dst[i] = load32_le(src + i*4); } +} +static void load64_le_buf (u64 *dst, const u8 *src, size_t size) { + FOR(i, 0, size) { dst[i] = load64_le(src + i*8); } +} +static void store32_le_buf(u8 *dst, const u32 *src, size_t size) { + FOR(i, 0, size) { store32_le(dst + i*4, src[i]); } +} +static void store64_le_buf(u8 *dst, const u64 *src, size_t size) { + FOR(i, 0, size) { store64_le(dst + i*8, src[i]); } +} + +static u64 rotr64(u64 x, u64 n) { return (x >> n) ^ (x << (64 - n)); } +static u32 rotl32(u32 x, u32 n) { return (x << n) ^ (x >> (32 - n)); } + +static int neq0(u64 diff) +{ + // constant time comparison to zero + // return diff != 0 ? -1 : 0 + u64 half = (diff >> 32) | ((u32)diff); // half < 2^32 + u64 eq0 = 1 & ((half - 1) >> 32); // half == 0 ? 1 : 0 + return (int)eq0 - 1; // half == 0 ? 0 : -1 +} + +static u64 x16(const u8 a[16], const u8 b[16]) +{ + return (load64_le(a + 0) ^ load64_le(b + 0)) + | (load64_le(a + 8) ^ load64_le(b + 8)); +} +static u64 x32(const u8 a[32],const u8 b[32]){return x16(a,b)| x16(a+16, b+16);} +static u64 x64(const u8 a[64],const u8 b[64]){return x32(a,b)| x32(a+32, b+32);} +int crypto_verify16(const u8 a[16], const u8 b[16]){ return neq0(x16(a, b)); } +int crypto_verify32(const u8 a[32], const u8 b[32]){ return neq0(x32(a, b)); } +int crypto_verify64(const u8 a[64], const u8 b[64]){ return neq0(x64(a, b)); } + +void crypto_wipe(void *secret, size_t size) +{ + volatile u8 *v_secret = (u8*)secret; + ZERO(v_secret, size); +} + +///////////////// +/// Chacha 20 /// +///////////////// +#define QUARTERROUND(a, b, c, d) \ + a += b; d = rotl32(d ^ a, 16); \ + c += d; b = rotl32(b ^ c, 12); \ + a += b; d = rotl32(d ^ a, 8); \ + c += d; b = rotl32(b ^ c, 7) + +static void chacha20_rounds(u32 out[16], const u32 in[16]) +{ + // The temporary variables make Chacha20 10% faster. + u32 t0 = in[ 0]; u32 t1 = in[ 1]; u32 t2 = in[ 2]; u32 t3 = in[ 3]; + u32 t4 = in[ 4]; u32 t5 = in[ 5]; u32 t6 = in[ 6]; u32 t7 = in[ 7]; + u32 t8 = in[ 8]; u32 t9 = in[ 9]; u32 t10 = in[10]; u32 t11 = in[11]; + u32 t12 = in[12]; u32 t13 = in[13]; u32 t14 = in[14]; u32 t15 = in[15]; + + FOR (i, 0, 10) { // 20 rounds, 2 rounds per loop. + QUARTERROUND(t0, t4, t8 , t12); // column 0 + QUARTERROUND(t1, t5, t9 , t13); // column 1 + QUARTERROUND(t2, t6, t10, t14); // column 2 + QUARTERROUND(t3, t7, t11, t15); // column 3 + QUARTERROUND(t0, t5, t10, t15); // diagonal 0 + QUARTERROUND(t1, t6, t11, t12); // diagonal 1 + QUARTERROUND(t2, t7, t8 , t13); // diagonal 2 + QUARTERROUND(t3, t4, t9 , t14); // diagonal 3 + } + out[ 0] = t0; out[ 1] = t1; out[ 2] = t2; out[ 3] = t3; + out[ 4] = t4; out[ 5] = t5; out[ 6] = t6; out[ 7] = t7; + out[ 8] = t8; out[ 9] = t9; out[10] = t10; out[11] = t11; + out[12] = t12; out[13] = t13; out[14] = t14; out[15] = t15; +} + +static const u8 *chacha20_constant = (const u8*)"expand 32-byte k"; // 16 bytes + +void crypto_chacha20_h(u8 out[32], const u8 key[32], const u8 in [16]) +{ + u32 block[16]; + load32_le_buf(block , chacha20_constant, 4); + load32_le_buf(block + 4, key , 8); + load32_le_buf(block + 12, in , 4); + + chacha20_rounds(block, block); + + // prevent reversal of the rounds by revealing only half of the buffer. + store32_le_buf(out , block , 4); // constant + store32_le_buf(out+16, block+12, 4); // counter and nonce + WIPE_BUFFER(block); +} + +u64 crypto_chacha20_djb(u8 *cipher_text, const u8 *plain_text, + size_t text_size, const u8 key[32], const u8 nonce[8], + u64 ctr) +{ + u32 input[16]; + load32_le_buf(input , chacha20_constant, 4); + load32_le_buf(input + 4, key , 8); + load32_le_buf(input + 14, nonce , 2); + input[12] = (u32) ctr; + input[13] = (u32)(ctr >> 32); + + // Whole blocks + u32 pool[16]; + size_t nb_blocks = text_size >> 6; + FOR (i, 0, nb_blocks) { + chacha20_rounds(pool, input); + if (plain_text != NULL) { + FOR (j, 0, 16) { + u32 p = pool[j] + input[j]; + store32_le(cipher_text, p ^ load32_le(plain_text)); + cipher_text += 4; + plain_text += 4; + } + } else { + FOR (j, 0, 16) { + u32 p = pool[j] + input[j]; + store32_le(cipher_text, p); + cipher_text += 4; + } + } + input[12]++; + if (input[12] == 0) { + input[13]++; + } + } + text_size &= 63; + + // Last (incomplete) block + if (text_size > 0) { + if (plain_text == NULL) { + plain_text = zero; + } + chacha20_rounds(pool, input); + u8 tmp[64]; + FOR (i, 0, 16) { + store32_le(tmp + i*4, pool[i] + input[i]); + } + FOR (i, 0, text_size) { + cipher_text[i] = tmp[i] ^ plain_text[i]; + } + WIPE_BUFFER(tmp); + } + ctr = input[12] + ((u64)input[13] << 32) + (text_size > 0); + + WIPE_BUFFER(pool); + WIPE_BUFFER(input); + return ctr; +} + +u32 crypto_chacha20_ietf(u8 *cipher_text, const u8 *plain_text, + size_t text_size, + const u8 key[32], const u8 nonce[12], u32 ctr) +{ + u64 big_ctr = ctr + ((u64)load32_le(nonce) << 32); + return (u32)crypto_chacha20_djb(cipher_text, plain_text, text_size, + key, nonce + 4, big_ctr); +} + +u64 crypto_chacha20_x(u8 *cipher_text, const u8 *plain_text, + size_t text_size, + const u8 key[32], const u8 nonce[24], u64 ctr) +{ + u8 sub_key[32]; + crypto_chacha20_h(sub_key, key, nonce); + ctr = crypto_chacha20_djb(cipher_text, plain_text, text_size, + sub_key, nonce + 16, ctr); + WIPE_BUFFER(sub_key); + return ctr; +} + +///////////////// +/// Poly 1305 /// +///////////////// + +// h = (h + c) * r +// preconditions: +// ctx->h <= 4_ffffffff_ffffffff_ffffffff_ffffffff +// ctx->r <= 0ffffffc_0ffffffc_0ffffffc_0fffffff +// end <= 1 +// Postcondition: +// ctx->h <= 4_ffffffff_ffffffff_ffffffff_ffffffff +static void poly_blocks(crypto_poly1305_ctx *ctx, const u8 *in, + size_t nb_blocks, unsigned end) +{ + // Local all the things! + const u32 r0 = ctx->r[0]; + const u32 r1 = ctx->r[1]; + const u32 r2 = ctx->r[2]; + const u32 r3 = ctx->r[3]; + const u32 rr0 = (r0 >> 2) * 5; // lose 2 bits... + const u32 rr1 = (r1 >> 2) + r1; // rr1 == (r1 >> 2) * 5 + const u32 rr2 = (r2 >> 2) + r2; // rr1 == (r2 >> 2) * 5 + const u32 rr3 = (r3 >> 2) + r3; // rr1 == (r3 >> 2) * 5 + const u32 rr4 = r0 & 3; // ...recover 2 bits + u32 h0 = ctx->h[0]; + u32 h1 = ctx->h[1]; + u32 h2 = ctx->h[2]; + u32 h3 = ctx->h[3]; + u32 h4 = ctx->h[4]; + + FOR (i, 0, nb_blocks) { + // h + c, without carry propagation + const u64 s0 = (u64)h0 + load32_le(in); in += 4; + const u64 s1 = (u64)h1 + load32_le(in); in += 4; + const u64 s2 = (u64)h2 + load32_le(in); in += 4; + const u64 s3 = (u64)h3 + load32_le(in); in += 4; + const u32 s4 = h4 + end; + + // (h + c) * r, without carry propagation + const u64 x0 = s0*r0+ s1*rr3+ s2*rr2+ s3*rr1+ s4*rr0; + const u64 x1 = s0*r1+ s1*r0 + s2*rr3+ s3*rr2+ s4*rr1; + const u64 x2 = s0*r2+ s1*r1 + s2*r0 + s3*rr3+ s4*rr2; + const u64 x3 = s0*r3+ s1*r2 + s2*r1 + s3*r0 + s4*rr3; + const u32 x4 = s4*rr4; + + // partial reduction modulo 2^130 - 5 + const u32 u5 = (u32)(x3 >> 32) + x4; // u5 <= 7ffffff5 + const u64 u0 = (u32)(u5 >> 2) * 5 + (x0 & 0xffffffff); + const u64 u1 = (u32)(u0 >> 32) + (x1 & 0xffffffff) + (x0 >> 32); + const u64 u2 = (u32)(u1 >> 32) + (x2 & 0xffffffff) + (x1 >> 32); + const u64 u3 = (u32)(u2 >> 32) + (x3 & 0xffffffff) + (x2 >> 32); + const u32 u4 = (u32)(u3 >> 32) + (u5 & 3); // u4 <= 4 + + // Update the hash + h0 = u0 & 0xffffffff; + h1 = u1 & 0xffffffff; + h2 = u2 & 0xffffffff; + h3 = u3 & 0xffffffff; + h4 = u4; + } + ctx->h[0] = h0; + ctx->h[1] = h1; + ctx->h[2] = h2; + ctx->h[3] = h3; + ctx->h[4] = h4; +} + +void crypto_poly1305_init(crypto_poly1305_ctx *ctx, const u8 key[32]) +{ + ZERO(ctx->h, 5); // Initial hash is zero + ctx->c_idx = 0; + // load r and pad (r has some of its bits cleared) + load32_le_buf(ctx->r , key , 4); + load32_le_buf(ctx->pad, key+16, 4); + FOR (i, 0, 1) { ctx->r[i] &= 0x0fffffff; } + FOR (i, 1, 4) { ctx->r[i] &= 0x0ffffffc; } +} + +void crypto_poly1305_update(crypto_poly1305_ctx *ctx, + const u8 *message, size_t message_size) +{ + // Avoid undefined NULL pointer increments with empty messages + if (message_size == 0) { + return; + } + + // Align ourselves with block boundaries + size_t aligned = MIN(gap(ctx->c_idx, 16), message_size); + FOR (i, 0, aligned) { + ctx->c[ctx->c_idx] = *message; + ctx->c_idx++; + message++; + message_size--; + } + + // If block is complete, process it + if (ctx->c_idx == 16) { + poly_blocks(ctx, ctx->c, 1, 1); + ctx->c_idx = 0; + } + + // Process the message block by block + size_t nb_blocks = message_size >> 4; + poly_blocks(ctx, message, nb_blocks, 1); + message += nb_blocks << 4; + message_size &= 15; + + // remaining bytes (we never complete a block here) + FOR (i, 0, message_size) { + ctx->c[ctx->c_idx] = message[i]; + ctx->c_idx++; + } +} + +void crypto_poly1305_final(crypto_poly1305_ctx *ctx, u8 mac[16]) +{ + // Process the last block (if any) + // We move the final 1 according to remaining input length + // (this will add less than 2^130 to the last input block) + if (ctx->c_idx != 0) { + ZERO(ctx->c + ctx->c_idx, 16 - ctx->c_idx); + ctx->c[ctx->c_idx] = 1; + poly_blocks(ctx, ctx->c, 1, 0); + } + + // check if we should subtract 2^130-5 by performing the + // corresponding carry propagation. + u64 c = 5; + FOR (i, 0, 4) { + c += ctx->h[i]; + c >>= 32; + } + c += ctx->h[4]; + c = (c >> 2) * 5; // shift the carry back to the beginning + // c now indicates how many times we should subtract 2^130-5 (0 or 1) + FOR (i, 0, 4) { + c += (u64)ctx->h[i] + ctx->pad[i]; + store32_le(mac + i*4, (u32)c); + c = c >> 32; + } + WIPE_CTX(ctx); +} + +void crypto_poly1305(u8 mac[16], const u8 *message, + size_t message_size, const u8 key[32]) +{ + crypto_poly1305_ctx ctx; + crypto_poly1305_init (&ctx, key); + crypto_poly1305_update(&ctx, message, message_size); + crypto_poly1305_final (&ctx, mac); +} + +//////////////// +/// BLAKE2 b /// +//////////////// +static const u64 iv[8] = { + 0x6a09e667f3bcc908, 0xbb67ae8584caa73b, + 0x3c6ef372fe94f82b, 0xa54ff53a5f1d36f1, + 0x510e527fade682d1, 0x9b05688c2b3e6c1f, + 0x1f83d9abfb41bd6b, 0x5be0cd19137e2179, +}; + +static void blake2b_compress(crypto_blake2b_ctx *ctx, int is_last_block) +{ + static const u8 sigma[12][16] = { + { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 }, + { 14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3 }, + { 11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4 }, + { 7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8 }, + { 9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13 }, + { 2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9 }, + { 12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11 }, + { 13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10 }, + { 6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5 }, + { 10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0 }, + { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 }, + { 14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3 }, + }; + + // increment input offset + u64 *x = ctx->input_offset; + size_t y = ctx->input_idx; + x[0] += y; + if (x[0] < y) { + x[1]++; + } + + // init work vector + u64 v0 = ctx->hash[0]; u64 v8 = iv[0]; + u64 v1 = ctx->hash[1]; u64 v9 = iv[1]; + u64 v2 = ctx->hash[2]; u64 v10 = iv[2]; + u64 v3 = ctx->hash[3]; u64 v11 = iv[3]; + u64 v4 = ctx->hash[4]; u64 v12 = iv[4] ^ ctx->input_offset[0]; + u64 v5 = ctx->hash[5]; u64 v13 = iv[5] ^ ctx->input_offset[1]; + u64 v6 = ctx->hash[6]; u64 v14 = iv[6] ^ (u64)~(is_last_block - 1); + u64 v7 = ctx->hash[7]; u64 v15 = iv[7]; + + // mangle work vector + u64 *input = ctx->input; +#define BLAKE2_G(a, b, c, d, x, y) \ + a += b + x; d = rotr64(d ^ a, 32); \ + c += d; b = rotr64(b ^ c, 24); \ + a += b + y; d = rotr64(d ^ a, 16); \ + c += d; b = rotr64(b ^ c, 63) +#define BLAKE2_ROUND(i) \ + BLAKE2_G(v0, v4, v8 , v12, input[sigma[i][ 0]], input[sigma[i][ 1]]); \ + BLAKE2_G(v1, v5, v9 , v13, input[sigma[i][ 2]], input[sigma[i][ 3]]); \ + BLAKE2_G(v2, v6, v10, v14, input[sigma[i][ 4]], input[sigma[i][ 5]]); \ + BLAKE2_G(v3, v7, v11, v15, input[sigma[i][ 6]], input[sigma[i][ 7]]); \ + BLAKE2_G(v0, v5, v10, v15, input[sigma[i][ 8]], input[sigma[i][ 9]]); \ + BLAKE2_G(v1, v6, v11, v12, input[sigma[i][10]], input[sigma[i][11]]); \ + BLAKE2_G(v2, v7, v8 , v13, input[sigma[i][12]], input[sigma[i][13]]); \ + BLAKE2_G(v3, v4, v9 , v14, input[sigma[i][14]], input[sigma[i][15]]) + +#ifdef BLAKE2_NO_UNROLLING + FOR (i, 0, 12) { + BLAKE2_ROUND(i); + } +#else + BLAKE2_ROUND(0); BLAKE2_ROUND(1); BLAKE2_ROUND(2); BLAKE2_ROUND(3); + BLAKE2_ROUND(4); BLAKE2_ROUND(5); BLAKE2_ROUND(6); BLAKE2_ROUND(7); + BLAKE2_ROUND(8); BLAKE2_ROUND(9); BLAKE2_ROUND(10); BLAKE2_ROUND(11); +#endif + + // update hash + ctx->hash[0] ^= v0 ^ v8; ctx->hash[1] ^= v1 ^ v9; + ctx->hash[2] ^= v2 ^ v10; ctx->hash[3] ^= v3 ^ v11; + ctx->hash[4] ^= v4 ^ v12; ctx->hash[5] ^= v5 ^ v13; + ctx->hash[6] ^= v6 ^ v14; ctx->hash[7] ^= v7 ^ v15; +} + +void crypto_blake2b_keyed_init(crypto_blake2b_ctx *ctx, size_t hash_size, + const u8 *key, size_t key_size) +{ + // initial hash + COPY(ctx->hash, iv, 8); + ctx->hash[0] ^= 0x01010000 ^ (key_size << 8) ^ hash_size; + + ctx->input_offset[0] = 0; // beginning of the input, no offset + ctx->input_offset[1] = 0; // beginning of the input, no offset + ctx->hash_size = hash_size; + ctx->input_idx = 0; + ZERO(ctx->input, 16); + + // if there is a key, the first block is that key (padded with zeroes) + if (key_size > 0) { + u8 key_block[128] = {0}; + COPY(key_block, key, key_size); + // same as calling crypto_blake2b_update(ctx, key_block , 128) + load64_le_buf(ctx->input, key_block, 16); + ctx->input_idx = 128; + WIPE_BUFFER(key_block); + } +} + +void crypto_blake2b_init(crypto_blake2b_ctx *ctx, size_t hash_size) +{ + crypto_blake2b_keyed_init(ctx, hash_size, 0, 0); +} + +void crypto_blake2b_update(crypto_blake2b_ctx *ctx, + const u8 *message, size_t message_size) +{ + // Avoid undefined NULL pointer increments with empty messages + if (message_size == 0) { + return; + } + + // Align with word boundaries + if ((ctx->input_idx & 7) != 0) { + size_t nb_bytes = MIN(gap(ctx->input_idx, 8), message_size); + size_t word = ctx->input_idx >> 3; + size_t byte = ctx->input_idx & 7; + FOR (i, 0, nb_bytes) { + ctx->input[word] |= (u64)message[i] << ((byte + i) << 3); + } + ctx->input_idx += nb_bytes; + message += nb_bytes; + message_size -= nb_bytes; + } + + // Align with block boundaries (faster than byte by byte) + if ((ctx->input_idx & 127) != 0) { + size_t nb_words = MIN(gap(ctx->input_idx, 128), message_size) >> 3; + load64_le_buf(ctx->input + (ctx->input_idx >> 3), message, nb_words); + ctx->input_idx += nb_words << 3; + message += nb_words << 3; + message_size -= nb_words << 3; + } + + // Process block by block + size_t nb_blocks = message_size >> 7; + FOR (i, 0, nb_blocks) { + if (ctx->input_idx == 128) { + blake2b_compress(ctx, 0); + } + load64_le_buf(ctx->input, message, 16); + message += 128; + ctx->input_idx = 128; + } + message_size &= 127; + + if (message_size != 0) { + // Compress block & flush input buffer as needed + if (ctx->input_idx == 128) { + blake2b_compress(ctx, 0); + ctx->input_idx = 0; + } + if (ctx->input_idx == 0) { + ZERO(ctx->input, 16); + } + // Fill remaining words (faster than byte by byte) + size_t nb_words = message_size >> 3; + load64_le_buf(ctx->input, message, nb_words); + ctx->input_idx += nb_words << 3; + message += nb_words << 3; + message_size -= nb_words << 3; + + // Fill remaining bytes + FOR (i, 0, message_size) { + size_t word = ctx->input_idx >> 3; + size_t byte = ctx->input_idx & 7; + ctx->input[word] |= (u64)message[i] << (byte << 3); + ctx->input_idx++; + } + } +} + +void crypto_blake2b_final(crypto_blake2b_ctx *ctx, u8 *hash) +{ + blake2b_compress(ctx, 1); // compress the last block + size_t hash_size = MIN(ctx->hash_size, 64); + size_t nb_words = hash_size >> 3; + store64_le_buf(hash, ctx->hash, nb_words); + FOR (i, nb_words << 3, hash_size) { + hash[i] = (ctx->hash[i >> 3] >> (8 * (i & 7))) & 0xff; + } + WIPE_CTX(ctx); +} + +void crypto_blake2b_keyed(u8 *hash, size_t hash_size, + const u8 *key, size_t key_size, + const u8 *message, size_t message_size) +{ + crypto_blake2b_ctx ctx; + crypto_blake2b_keyed_init(&ctx, hash_size, key, key_size); + crypto_blake2b_update (&ctx, message, message_size); + crypto_blake2b_final (&ctx, hash); +} + +void crypto_blake2b(u8 *hash, size_t hash_size, const u8 *msg, size_t msg_size) +{ + crypto_blake2b_keyed(hash, hash_size, 0, 0, msg, msg_size); +} + +////////////// +/// Argon2 /// +////////////// +// references to R, Z, Q etc. come from the spec + +// Argon2 operates on 1024 byte blocks. +typedef struct { u64 a[128]; } blk; + +// updates a BLAKE2 hash with a 32 bit word, little endian. +static void blake_update_32(crypto_blake2b_ctx *ctx, u32 input) +{ + u8 buf[4]; + store32_le(buf, input); + crypto_blake2b_update(ctx, buf, 4); + WIPE_BUFFER(buf); +} + +static void blake_update_32_buf(crypto_blake2b_ctx *ctx, + const u8 *buf, u32 size) +{ + blake_update_32(ctx, size); + crypto_blake2b_update(ctx, buf, size); +} + + +static void copy_block(blk *o,const blk*in){FOR(i, 0, 128) o->a[i] = in->a[i];} +static void xor_block(blk *o,const blk*in){FOR(i, 0, 128) o->a[i] ^= in->a[i];} + +// Hash with a virtually unlimited digest size. +// Doesn't extract more entropy than the base hash function. +// Mainly used for filling a whole kilobyte block with pseudo-random bytes. +// (One could use a stream cipher with a seed hash as the key, but +// this would introduce another dependency —and point of failure.) +static void extended_hash(u8 *digest, u32 digest_size, + const u8 *input , u32 input_size) +{ + crypto_blake2b_ctx ctx; + crypto_blake2b_init (&ctx, MIN(digest_size, 64)); + blake_update_32 (&ctx, digest_size); + crypto_blake2b_update(&ctx, input, input_size); + crypto_blake2b_final (&ctx, digest); + + if (digest_size > 64) { + // the conversion to u64 avoids integer overflow on + // ludicrously big hash sizes. + u32 r = (u32)(((u64)digest_size + 31) >> 5) - 2; + u32 i = 1; + u32 in = 0; + u32 out = 32; + while (i < r) { + // Input and output overlap. This is intentional + crypto_blake2b(digest + out, 64, digest + in, 64); + i += 1; + in += 32; + out += 32; + } + crypto_blake2b(digest + out, digest_size - (32 * r), digest + in , 64); + } +} + +#define LSB(x) ((u64)(u32)x) +#define G(a, b, c, d) \ + a += b + ((LSB(a) * LSB(b)) << 1); d ^= a; d = rotr64(d, 32); \ + c += d + ((LSB(c) * LSB(d)) << 1); b ^= c; b = rotr64(b, 24); \ + a += b + ((LSB(a) * LSB(b)) << 1); d ^= a; d = rotr64(d, 16); \ + c += d + ((LSB(c) * LSB(d)) << 1); b ^= c; b = rotr64(b, 63) +#define ROUND(v0, v1, v2, v3, v4, v5, v6, v7, \ + v8, v9, v10, v11, v12, v13, v14, v15) \ + G(v0, v4, v8, v12); G(v1, v5, v9, v13); \ + G(v2, v6, v10, v14); G(v3, v7, v11, v15); \ + G(v0, v5, v10, v15); G(v1, v6, v11, v12); \ + G(v2, v7, v8, v13); G(v3, v4, v9, v14) + +// Core of the compression function G. Computes Z from R in place. +static void g_rounds(blk *b) +{ + // column rounds (work_block = Q) + for (int i = 0; i < 128; i += 16) { + ROUND(b->a[i ], b->a[i+ 1], b->a[i+ 2], b->a[i+ 3], + b->a[i+ 4], b->a[i+ 5], b->a[i+ 6], b->a[i+ 7], + b->a[i+ 8], b->a[i+ 9], b->a[i+10], b->a[i+11], + b->a[i+12], b->a[i+13], b->a[i+14], b->a[i+15]); + } + // row rounds (b = Z) + for (int i = 0; i < 16; i += 2) { + ROUND(b->a[i ], b->a[i+ 1], b->a[i+ 16], b->a[i+ 17], + b->a[i+32], b->a[i+33], b->a[i+ 48], b->a[i+ 49], + b->a[i+64], b->a[i+65], b->a[i+ 80], b->a[i+ 81], + b->a[i+96], b->a[i+97], b->a[i+112], b->a[i+113]); + } +} + +const crypto_argon2_extras crypto_argon2_no_extras = { 0, 0, 0, 0 }; + +void crypto_argon2(u8 *hash, u32 hash_size, void *work_area, + crypto_argon2_config config, + crypto_argon2_inputs inputs, + crypto_argon2_extras extras) +{ + const u32 segment_size = config.nb_blocks / config.nb_lanes / 4; + const u32 lane_size = segment_size * 4; + const u32 nb_blocks = lane_size * config.nb_lanes; // rounding down + + // work area seen as blocks (must be suitably aligned) + blk *blocks = (blk*)work_area; + { + u8 initial_hash[72]; // 64 bytes plus 2 words for future hashes + crypto_blake2b_ctx ctx; + crypto_blake2b_init (&ctx, 64); + blake_update_32 (&ctx, config.nb_lanes ); // p: number of "threads" + blake_update_32 (&ctx, hash_size); + blake_update_32 (&ctx, config.nb_blocks); + blake_update_32 (&ctx, config.nb_passes); + blake_update_32 (&ctx, 0x13); // v: version number + blake_update_32 (&ctx, config.algorithm); // y: Argon2i, Argon2d... + blake_update_32_buf (&ctx, inputs.pass, inputs.pass_size); + blake_update_32_buf (&ctx, inputs.salt, inputs.salt_size); + blake_update_32_buf (&ctx, extras.key, extras.key_size); + blake_update_32_buf (&ctx, extras.ad, extras.ad_size); + crypto_blake2b_final(&ctx, initial_hash); // fill 64 first bytes only + + // fill first 2 blocks of each lane + u8 hash_area[1024]; + FOR_T(u32, l, 0, config.nb_lanes) { + FOR_T(u32, i, 0, 2) { + store32_le(initial_hash + 64, i); // first additional word + store32_le(initial_hash + 68, l); // second additional word + extended_hash(hash_area, 1024, initial_hash, 72); + load64_le_buf(blocks[l * lane_size + i].a, hash_area, 128); + } + } + + WIPE_BUFFER(initial_hash); + WIPE_BUFFER(hash_area); + } + + // Argon2i and Argon2id start with constant time indexing + int constant_time = config.algorithm != CRYPTO_ARGON2_D; + + // Fill (and re-fill) the rest of the blocks + // + // Note: even though each segment within the same slice can be + // computed in parallel, (one thread per lane), we are computing + // them sequentially, because Monocypher doesn't support threads. + // + // Yet optimal performance (and therefore security) requires one + // thread per lane. The only reason Monocypher supports multiple + // lanes is compatibility. + blk tmp; + FOR_T(u32, pass, 0, config.nb_passes) { + FOR_T(u32, slice, 0, 4) { + // On the first slice of the first pass, + // blocks 0 and 1 are already filled, hence pass_offset. + u32 pass_offset = pass == 0 && slice == 0 ? 2 : 0; + u32 slice_offset = slice * segment_size; + + // Argon2id switches back to non-constant time indexing + // after the first two slices of the first pass + if (slice == 2 && config.algorithm == CRYPTO_ARGON2_ID) { + constant_time = 0; + } + + // Each iteration of the following loop may be performed in + // a separate thread. All segments must be fully completed + // before we start filling the next slice. + FOR_T(u32, segment, 0, config.nb_lanes) { + blk index_block; + u32 index_ctr = 1; + FOR_T (u32, block, pass_offset, segment_size) { + // Current and previous blocks + u32 lane_offset = segment * lane_size; + blk *segment_start = blocks + lane_offset + slice_offset; + blk *current = segment_start + block; + blk *previous = + block == 0 && slice_offset == 0 + ? segment_start + lane_size - 1 + : segment_start + block - 1; + + u64 index_seed; + if (constant_time) { + if (block == pass_offset || (block % 128) == 0) { + // Fill or refresh deterministic indices block + + // seed the beginning of the block... + ZERO(index_block.a, 128); + index_block.a[0] = pass; + index_block.a[1] = segment; + index_block.a[2] = slice; + index_block.a[3] = nb_blocks; + index_block.a[4] = config.nb_passes; + index_block.a[5] = config.algorithm; + index_block.a[6] = index_ctr; + index_ctr++; + + // ... then shuffle it + copy_block(&tmp, &index_block); + g_rounds (&index_block); + xor_block (&index_block, &tmp); + copy_block(&tmp, &index_block); + g_rounds (&index_block); + xor_block (&index_block, &tmp); + } + index_seed = index_block.a[block % 128]; + } else { + index_seed = previous->a[0]; + } + + // Establish the reference set. *Approximately* comprises: + // - The last 3 slices (if they exist yet) + // - The already constructed blocks in the current segment + u32 next_slice = ((slice + 1) % 4) * segment_size; + u32 window_start = pass == 0 ? 0 : next_slice; + u32 nb_segments = pass == 0 ? slice : 3; + u32 lane = + pass == 0 && slice == 0 + ? segment + : (u32)(index_seed >> 32) % config.nb_lanes; + u32 window_size = + nb_segments * segment_size + + (lane == segment ? block-1 : + block == 0 ? (u32)-1 : 0); + + // Find reference block + u64 j1 = index_seed & 0xffffffff; // block selector + u64 x = (j1 * j1) >> 32; + u64 y = (window_size * x) >> 32; + u64 z = (window_size - 1) - y; + u32 ref = (u32)((window_start + z) % lane_size); + u32 index = lane * lane_size + ref; + blk *reference = blocks + index; + + // Shuffle the previous & reference block + // into the current block + copy_block(&tmp, previous); + xor_block (&tmp, reference); + if (pass == 0) { copy_block(current, &tmp); } + else { xor_block (current, &tmp); } + g_rounds (&tmp); + xor_block (current, &tmp); + } + } + } + } + + // Wipe temporary block + volatile u64* p = tmp.a; + ZERO(p, 128); + + // XOR last blocks of each lane + blk *last_block = blocks + lane_size - 1; + FOR_T (u32, lane, 1, config.nb_lanes) { + blk *next_block = last_block + lane_size; + xor_block(next_block, last_block); + last_block = next_block; + } + + // Serialize last block + u8 final_block[1024]; + store64_le_buf(final_block, last_block->a, 128); + + // Wipe work area + p = (u64*)work_area; + ZERO(p, 128 * nb_blocks); + + // Hash the very last block with H' into the output hash + extended_hash(hash, hash_size, final_block, 1024); + WIPE_BUFFER(final_block); +} + +//////////////////////////////////// +/// Arithmetic modulo 2^255 - 19 /// +//////////////////////////////////// +// Originally taken from SUPERCOP's ref10 implementation. +// A bit bigger than TweetNaCl, over 4 times faster. + +// field element +typedef i32 fe[10]; + +// field constants +// +// fe_one : 1 +// sqrtm1 : sqrt(-1) +// d : -121665 / 121666 +// D2 : 2 * -121665 / 121666 +// lop_x, lop_y: low order point in Edwards coordinates +// ufactor : -sqrt(-1) * 2 +// A2 : 486662^2 (A squared) +static const fe fe_one = {1}; +static const fe sqrtm1 = { + -32595792, -7943725, 9377950, 3500415, 12389472, + -272473, -25146209, -2005654, 326686, 11406482, +}; +static const fe d = { + -10913610, 13857413, -15372611, 6949391, 114729, + -8787816, -6275908, -3247719, -18696448, -12055116, +}; +static const fe D2 = { + -21827239, -5839606, -30745221, 13898782, 229458, + 15978800, -12551817, -6495438, 29715968, 9444199, +}; +static const fe lop_x = { + 21352778, 5345713, 4660180, -8347857, 24143090, + 14568123, 30185756, -12247770, -33528939, 8345319, +}; +static const fe lop_y = { + -6952922, -1265500, 6862341, -7057498, -4037696, + -5447722, 31680899, -15325402, -19365852, 1569102, +}; +static const fe ufactor = { + -1917299, 15887451, -18755900, -7000830, -24778944, + 544946, -16816446, 4011309, -653372, 10741468, +}; +static const fe A2 = { + 12721188, 3529, 0, 0, 0, 0, 0, 0, 0, 0, +}; + +static void fe_0(fe h) { ZERO(h , 10); } +static void fe_1(fe h) { h[0] = 1; ZERO(h+1, 9); } + +static void fe_copy(fe h,const fe f ){FOR(i,0,10) h[i] = f[i]; } +static void fe_neg (fe h,const fe f ){FOR(i,0,10) h[i] = -f[i]; } +static void fe_add (fe h,const fe f,const fe g){FOR(i,0,10) h[i] = f[i] + g[i];} +static void fe_sub (fe h,const fe f,const fe g){FOR(i,0,10) h[i] = f[i] - g[i];} + +// Some compilers, when inlining fe_cswap() or fe_ccopy(), may introduce +// a timing leak. It happens when it notices `b` has only 2 possible +// values, and either replace the arithmetic by a secret dependent +// branch, or (as has been observed), swap pointers instead of values, +// which intruduces a secret dependent index. +// +// We apply two mitigations here: +// - Add `volatile` in the mask declaration. +// - Unroll the copy loop (costs couple hundred bytes of binary code). +// +// As of June 2026, those mitigation work when applied separately or +// together. Applying them both is currently overkill, but may help +// delay the day compilers grow clever enough to defeat it. (The true +// fix is in the semantics of the language itself: C currently has no +// way to specify constant time code). +// +// Note (Loup): as of June 2026, the problem has yet to surface in +// fe_cswap(), but since this is almost the same code as fe_ccopy() I +// believe it is more prudent to apply the precaution there too. +static void fe_cswap(fe f, fe g, int b) +{ + volatile i32 mask = -b; // -1 = 0xffffffff + i32 x0 = (f[0] ^ g[0]) & mask; f[0] = f[0] ^ x0; g[0] = g[0] ^ x0; + i32 x1 = (f[1] ^ g[1]) & mask; f[1] = f[1] ^ x1; g[1] = g[1] ^ x1; + i32 x2 = (f[2] ^ g[2]) & mask; f[2] = f[2] ^ x2; g[2] = g[2] ^ x2; + i32 x3 = (f[3] ^ g[3]) & mask; f[3] = f[3] ^ x3; g[3] = g[3] ^ x3; + i32 x4 = (f[4] ^ g[4]) & mask; f[4] = f[4] ^ x4; g[4] = g[4] ^ x4; + i32 x5 = (f[5] ^ g[5]) & mask; f[5] = f[5] ^ x5; g[5] = g[5] ^ x5; + i32 x6 = (f[6] ^ g[6]) & mask; f[6] = f[6] ^ x6; g[6] = g[6] ^ x6; + i32 x7 = (f[7] ^ g[7]) & mask; f[7] = f[7] ^ x7; g[7] = g[7] ^ x7; + i32 x8 = (f[8] ^ g[8]) & mask; f[8] = f[8] ^ x8; g[8] = g[8] ^ x8; + i32 x9 = (f[9] ^ g[9]) & mask; f[9] = f[9] ^ x9; g[9] = g[9] ^ x9; +} + +static void fe_ccopy(fe f, const fe g, int b) +{ + volatile i32 mask = -b; // -1 = 0xffffffff + i32 x0 = (f[0] ^ g[0]) & mask; f[0] = f[0] ^ x0; + i32 x1 = (f[1] ^ g[1]) & mask; f[1] = f[1] ^ x1; + i32 x2 = (f[2] ^ g[2]) & mask; f[2] = f[2] ^ x2; + i32 x3 = (f[3] ^ g[3]) & mask; f[3] = f[3] ^ x3; + i32 x4 = (f[4] ^ g[4]) & mask; f[4] = f[4] ^ x4; + i32 x5 = (f[5] ^ g[5]) & mask; f[5] = f[5] ^ x5; + i32 x6 = (f[6] ^ g[6]) & mask; f[6] = f[6] ^ x6; + i32 x7 = (f[7] ^ g[7]) & mask; f[7] = f[7] ^ x7; + i32 x8 = (f[8] ^ g[8]) & mask; f[8] = f[8] ^ x8; + i32 x9 = (f[9] ^ g[9]) & mask; f[9] = f[9] ^ x9; +} + + +// Signed carry propagation +// ------------------------ +// +// Let t be a number. It can be uniquely decomposed thus: +// +// t = h*2^26 + l +// such that -2^25 <= l < 2^25 +// +// Let c = (t + 2^25) / 2^26 (rounded down) +// c = (h*2^26 + l + 2^25) / 2^26 (rounded down) +// c = h + (l + 2^25) / 2^26 (rounded down) +// c = h (exactly) +// Because 0 <= l + 2^25 < 2^26 +// +// Let u = t - c*2^26 +// u = h*2^26 + l - h*2^26 +// u = l +// Therefore, -2^25 <= u < 2^25 +// +// Additionally, if |t| < x, then |h| < x/2^26 (rounded down) +// +// Notations: +// - In C, 1<<25 means 2^25. +// - In C, x>>25 means floor(x / (2^25)). +// - All of the above applies with 25 & 24 as well as 26 & 25. +// +// +// Note on negative right shifts +// ----------------------------- +// +// In C, x >> n, where x is a negative integer, is implementation +// defined. In practice, all platforms do arithmetic shift, which is +// equivalent to division by 2^26, rounded down. Some compilers, like +// GCC, even guarantee it. +// +// If we ever stumble upon a platform that does not propagate the sign +// bit (we won't), visible failures will show at the slightest test, and +// the signed shifts can be replaced by the following: +// +// typedef struct { i64 x:39; } s25; +// typedef struct { i64 x:38; } s26; +// i64 shift25(i64 x) { s25 s; s.x = ((u64)x)>>25; return s.x; } +// i64 shift26(i64 x) { s26 s; s.x = ((u64)x)>>26; return s.x; } +// +// Current compilers cannot optimise this, causing a 30% drop in +// performance. Fairly expensive for something that never happens. +// +// +// Precondition +// ------------ +// +// |t0| < 2^63 +// |t1|..|t9| < 2^62 +// +// Algorithm +// --------- +// c = t0 + 2^25 / 2^26 -- |c| <= 2^36 +// t0 -= c * 2^26 -- |t0| <= 2^25 +// t1 += c -- |t1| <= 2^63 +// +// c = t4 + 2^25 / 2^26 -- |c| <= 2^36 +// t4 -= c * 2^26 -- |t4| <= 2^25 +// t5 += c -- |t5| <= 2^63 +// +// c = t1 + 2^24 / 2^25 -- |c| <= 2^38 +// t1 -= c * 2^25 -- |t1| <= 2^24 +// t2 += c -- |t2| <= 2^63 +// +// c = t5 + 2^24 / 2^25 -- |c| <= 2^38 +// t5 -= c * 2^25 -- |t5| <= 2^24 +// t6 += c -- |t6| <= 2^63 +// +// c = t2 + 2^25 / 2^26 -- |c| <= 2^37 +// t2 -= c * 2^26 -- |t2| <= 2^25 < 1.1 * 2^25 (final t2) +// t3 += c -- |t3| <= 2^63 +// +// c = t6 + 2^25 / 2^26 -- |c| <= 2^37 +// t6 -= c * 2^26 -- |t6| <= 2^25 < 1.1 * 2^25 (final t6) +// t7 += c -- |t7| <= 2^63 +// +// c = t3 + 2^24 / 2^25 -- |c| <= 2^38 +// t3 -= c * 2^25 -- |t3| <= 2^24 < 1.1 * 2^24 (final t3) +// t4 += c -- |t4| <= 2^25 + 2^38 < 2^39 +// +// c = t7 + 2^24 / 2^25 -- |c| <= 2^38 +// t7 -= c * 2^25 -- |t7| <= 2^24 < 1.1 * 2^24 (final t7) +// t8 += c -- |t8| <= 2^63 +// +// c = t4 + 2^25 / 2^26 -- |c| <= 2^13 +// t4 -= c * 2^26 -- |t4| <= 2^25 < 1.1 * 2^25 (final t4) +// t5 += c -- |t5| <= 2^24 + 2^13 < 1.1 * 2^24 (final t5) +// +// c = t8 + 2^25 / 2^26 -- |c| <= 2^37 +// t8 -= c * 2^26 -- |t8| <= 2^25 < 1.1 * 2^25 (final t8) +// t9 += c -- |t9| <= 2^63 +// +// c = t9 + 2^24 / 2^25 -- |c| <= 2^38 +// t9 -= c * 2^25 -- |t9| <= 2^24 < 1.1 * 2^24 (final t9) +// t0 += c * 19 -- |t0| <= 2^25 + 2^38*19 < 2^44 +// +// c = t0 + 2^25 / 2^26 -- |c| <= 2^18 +// t0 -= c * 2^26 -- |t0| <= 2^25 < 1.1 * 2^25 (final t0) +// t1 += c -- |t1| <= 2^24 + 2^18 < 1.1 * 2^24 (final t1) +// +// Postcondition +// ------------- +// |t0|, |t2|, |t4|, |t6|, |t8| < 1.1 * 2^25 +// |t1|, |t3|, |t5|, |t7|, |t9| < 1.1 * 2^24 +#define FE_CARRY \ + i64 c; \ + c = (t0 + ((i64)1<<25)) >> 26; t0 -= c * ((i64)1 << 26); t1 += c; \ + c = (t4 + ((i64)1<<25)) >> 26; t4 -= c * ((i64)1 << 26); t5 += c; \ + c = (t1 + ((i64)1<<24)) >> 25; t1 -= c * ((i64)1 << 25); t2 += c; \ + c = (t5 + ((i64)1<<24)) >> 25; t5 -= c * ((i64)1 << 25); t6 += c; \ + c = (t2 + ((i64)1<<25)) >> 26; t2 -= c * ((i64)1 << 26); t3 += c; \ + c = (t6 + ((i64)1<<25)) >> 26; t6 -= c * ((i64)1 << 26); t7 += c; \ + c = (t3 + ((i64)1<<24)) >> 25; t3 -= c * ((i64)1 << 25); t4 += c; \ + c = (t7 + ((i64)1<<24)) >> 25; t7 -= c * ((i64)1 << 25); t8 += c; \ + c = (t4 + ((i64)1<<25)) >> 26; t4 -= c * ((i64)1 << 26); t5 += c; \ + c = (t8 + ((i64)1<<25)) >> 26; t8 -= c * ((i64)1 << 26); t9 += c; \ + c = (t9 + ((i64)1<<24)) >> 25; t9 -= c * ((i64)1 << 25); t0 += c * 19; \ + c = (t0 + ((i64)1<<25)) >> 26; t0 -= c * ((i64)1 << 26); t1 += c; \ + h[0]=(i32)t0; h[1]=(i32)t1; h[2]=(i32)t2; h[3]=(i32)t3; h[4]=(i32)t4; \ + h[5]=(i32)t5; h[6]=(i32)t6; h[7]=(i32)t7; h[8]=(i32)t8; h[9]=(i32)t9 + +// Decodes a field element from a byte buffer. +// mask specifies how many bits we ignore. +// Traditionally we ignore 1. It's useful for EdDSA, +// which uses that bit to denote the sign of x. +// Elligator however uses positive representatives, +// which means ignoring 2 bits instead. +static void fe_frombytes_mask(fe h, const u8 s[32], unsigned nb_mask) +{ + u32 mask = 0xffffff >> nb_mask; + i64 t0 = load32_le(s); // t0 < 2^32 + i64 t1 = load24_le(s + 4) << 6; // t1 < 2^30 + i64 t2 = load24_le(s + 7) << 5; // t2 < 2^29 + i64 t3 = load24_le(s + 10) << 3; // t3 < 2^27 + i64 t4 = load24_le(s + 13) << 2; // t4 < 2^26 + i64 t5 = load32_le(s + 16); // t5 < 2^32 + i64 t6 = load24_le(s + 20) << 7; // t6 < 2^31 + i64 t7 = load24_le(s + 23) << 5; // t7 < 2^29 + i64 t8 = load24_le(s + 26) << 4; // t8 < 2^28 + i64 t9 = (load24_le(s + 29) & mask) << 2; // t9 < 2^25 + FE_CARRY; // Carry precondition OK +} + +static void fe_frombytes(fe h, const u8 s[32]) +{ + fe_frombytes_mask(h, s, 1); +} + + +// Precondition +// |h[0]|, |h[2]|, |h[4]|, |h[6]|, |h[8]| < 1.1 * 2^25 +// |h[1]|, |h[3]|, |h[5]|, |h[7]|, |h[9]| < 1.1 * 2^24 +// +// Therefore, |h| < 2^255-19 +// There are two possibilities: +// +// - If h is positive, all we need to do is reduce its individual +// limbs down to their tight positive range. +// - If h is negative, we also need to add 2^255-19 to it. +// Or just remove 19 and chop off any excess bit. +static void fe_tobytes(u8 s[32], const fe h) +{ + i32 t[10]; + COPY(t, h, 10); + i32 q = (19 * t[9] + (((i32) 1) << 24)) >> 25; + // |t9| < 1.1 * 2^24 + // -1.1 * 2^24 < t9 < 1.1 * 2^24 + // -21 * 2^24 < 19 * t9 < 21 * 2^24 + // -2^29 < 19 * t9 + 2^24 < 2^29 + // -2^29 / 2^25 < (19 * t9 + 2^24) / 2^25 < 2^29 / 2^25 + // -16 < (19 * t9 + 2^24) / 2^25 < 16 + FOR (i, 0, 5) { + q += t[2*i ]; q >>= 26; // q = 0 or -1 + q += t[2*i+1]; q >>= 25; // q = 0 or -1 + } + // q = 0 iff h >= 0 + // q = -1 iff h < 0 + // Adding q * 19 to h reduces h to its proper range. + q *= 19; // Shift carry back to the beginning + FOR (i, 0, 5) { + t[i*2 ] += q; q = t[i*2 ] >> 26; t[i*2 ] -= q * ((i32)1 << 26); + t[i*2+1] += q; q = t[i*2+1] >> 25; t[i*2+1] -= q * ((i32)1 << 25); + } + // h is now fully reduced, and q represents the excess bit. + + store32_le(s + 0, ((u32)t[0] >> 0) | ((u32)t[1] << 26)); + store32_le(s + 4, ((u32)t[1] >> 6) | ((u32)t[2] << 19)); + store32_le(s + 8, ((u32)t[2] >> 13) | ((u32)t[3] << 13)); + store32_le(s + 12, ((u32)t[3] >> 19) | ((u32)t[4] << 6)); + store32_le(s + 16, ((u32)t[5] >> 0) | ((u32)t[6] << 25)); + store32_le(s + 20, ((u32)t[6] >> 7) | ((u32)t[7] << 19)); + store32_le(s + 24, ((u32)t[7] >> 13) | ((u32)t[8] << 12)); + store32_le(s + 28, ((u32)t[8] >> 20) | ((u32)t[9] << 6)); + + WIPE_BUFFER(t); +} + +// Precondition +// ------------- +// |f0|, |f2|, |f4|, |f6|, |f8| < 1.65 * 2^26 +// |f1|, |f3|, |f5|, |f7|, |f9| < 1.65 * 2^25 +// +// |g0|, |g2|, |g4|, |g6|, |g8| < 1.65 * 2^26 +// |g1|, |g3|, |g5|, |g7|, |g9| < 1.65 * 2^25 +static void fe_mul_small(fe h, const fe f, i32 g) +{ + i64 t0 = f[0] * (i64) g; i64 t1 = f[1] * (i64) g; + i64 t2 = f[2] * (i64) g; i64 t3 = f[3] * (i64) g; + i64 t4 = f[4] * (i64) g; i64 t5 = f[5] * (i64) g; + i64 t6 = f[6] * (i64) g; i64 t7 = f[7] * (i64) g; + i64 t8 = f[8] * (i64) g; i64 t9 = f[9] * (i64) g; + // |t0|, |t2|, |t4|, |t6|, |t8| < 1.65 * 2^26 * 2^31 < 2^58 + // |t1|, |t3|, |t5|, |t7|, |t9| < 1.65 * 2^25 * 2^31 < 2^57 + + FE_CARRY; // Carry precondition OK +} + +// Precondition +// ------------- +// |f0|, |f2|, |f4|, |f6|, |f8| < 1.65 * 2^26 +// |f1|, |f3|, |f5|, |f7|, |f9| < 1.65 * 2^25 +// +// |g0|, |g2|, |g4|, |g6|, |g8| < 1.65 * 2^26 +// |g1|, |g3|, |g5|, |g7|, |g9| < 1.65 * 2^25 +static void fe_mul(fe h, const fe f, const fe g) +{ + // Everything is unrolled and put in temporary variables. + // We could roll the loop, but that would make curve25519 twice as slow. + i32 f0 = f[0]; i32 f1 = f[1]; i32 f2 = f[2]; i32 f3 = f[3]; i32 f4 = f[4]; + i32 f5 = f[5]; i32 f6 = f[6]; i32 f7 = f[7]; i32 f8 = f[8]; i32 f9 = f[9]; + i32 g0 = g[0]; i32 g1 = g[1]; i32 g2 = g[2]; i32 g3 = g[3]; i32 g4 = g[4]; + i32 g5 = g[5]; i32 g6 = g[6]; i32 g7 = g[7]; i32 g8 = g[8]; i32 g9 = g[9]; + i32 F1 = f1*2; i32 F3 = f3*2; i32 F5 = f5*2; i32 F7 = f7*2; i32 F9 = f9*2; + i32 G1 = g1*19; i32 G2 = g2*19; i32 G3 = g3*19; + i32 G4 = g4*19; i32 G5 = g5*19; i32 G6 = g6*19; + i32 G7 = g7*19; i32 G8 = g8*19; i32 G9 = g9*19; + // |F1|, |F3|, |F5|, |F7|, |F9| < 1.65 * 2^26 + // |G0|, |G2|, |G4|, |G6|, |G8| < 2^31 + // |G1|, |G3|, |G5|, |G7|, |G9| < 2^30 + + i64 t0 = f0*(i64)g0 + F1*(i64)G9 + f2*(i64)G8 + F3*(i64)G7 + f4*(i64)G6 + + F5*(i64)G5 + f6*(i64)G4 + F7*(i64)G3 + f8*(i64)G2 + F9*(i64)G1; + i64 t1 = f0*(i64)g1 + f1*(i64)g0 + f2*(i64)G9 + f3*(i64)G8 + f4*(i64)G7 + + f5*(i64)G6 + f6*(i64)G5 + f7*(i64)G4 + f8*(i64)G3 + f9*(i64)G2; + i64 t2 = f0*(i64)g2 + F1*(i64)g1 + f2*(i64)g0 + F3*(i64)G9 + f4*(i64)G8 + + F5*(i64)G7 + f6*(i64)G6 + F7*(i64)G5 + f8*(i64)G4 + F9*(i64)G3; + i64 t3 = f0*(i64)g3 + f1*(i64)g2 + f2*(i64)g1 + f3*(i64)g0 + f4*(i64)G9 + + f5*(i64)G8 + f6*(i64)G7 + f7*(i64)G6 + f8*(i64)G5 + f9*(i64)G4; + i64 t4 = f0*(i64)g4 + F1*(i64)g3 + f2*(i64)g2 + F3*(i64)g1 + f4*(i64)g0 + + F5*(i64)G9 + f6*(i64)G8 + F7*(i64)G7 + f8*(i64)G6 + F9*(i64)G5; + i64 t5 = f0*(i64)g5 + f1*(i64)g4 + f2*(i64)g3 + f3*(i64)g2 + f4*(i64)g1 + + f5*(i64)g0 + f6*(i64)G9 + f7*(i64)G8 + f8*(i64)G7 + f9*(i64)G6; + i64 t6 = f0*(i64)g6 + F1*(i64)g5 + f2*(i64)g4 + F3*(i64)g3 + f4*(i64)g2 + + F5*(i64)g1 + f6*(i64)g0 + F7*(i64)G9 + f8*(i64)G8 + F9*(i64)G7; + i64 t7 = f0*(i64)g7 + f1*(i64)g6 + f2*(i64)g5 + f3*(i64)g4 + f4*(i64)g3 + + f5*(i64)g2 + f6*(i64)g1 + f7*(i64)g0 + f8*(i64)G9 + f9*(i64)G8; + i64 t8 = f0*(i64)g8 + F1*(i64)g7 + f2*(i64)g6 + F3*(i64)g5 + f4*(i64)g4 + + F5*(i64)g3 + f6*(i64)g2 + F7*(i64)g1 + f8*(i64)g0 + F9*(i64)G9; + i64 t9 = f0*(i64)g9 + f1*(i64)g8 + f2*(i64)g7 + f3*(i64)g6 + f4*(i64)g5 + + f5*(i64)g4 + f6*(i64)g3 + f7*(i64)g2 + f8*(i64)g1 + f9*(i64)g0; + // t0 < 0.67 * 2^61 + // t1 < 0.41 * 2^61 + // t2 < 0.52 * 2^61 + // t3 < 0.32 * 2^61 + // t4 < 0.38 * 2^61 + // t5 < 0.22 * 2^61 + // t6 < 0.23 * 2^61 + // t7 < 0.13 * 2^61 + // t8 < 0.09 * 2^61 + // t9 < 0.03 * 2^61 + + FE_CARRY; // Everything below 2^62, Carry precondition OK +} + +// Precondition +// ------------- +// |f0|, |f2|, |f4|, |f6|, |f8| < 1.65 * 2^26 +// |f1|, |f3|, |f5|, |f7|, |f9| < 1.65 * 2^25 +// +// Note: we could use fe_mul() for this, but this is significantly faster +static void fe_sq(fe h, const fe f) +{ + i32 f0 = f[0]; i32 f1 = f[1]; i32 f2 = f[2]; i32 f3 = f[3]; i32 f4 = f[4]; + i32 f5 = f[5]; i32 f6 = f[6]; i32 f7 = f[7]; i32 f8 = f[8]; i32 f9 = f[9]; + i32 f0_2 = f0*2; i32 f1_2 = f1*2; i32 f2_2 = f2*2; i32 f3_2 = f3*2; + i32 f4_2 = f4*2; i32 f5_2 = f5*2; i32 f6_2 = f6*2; i32 f7_2 = f7*2; + i32 f5_38 = f5*38; i32 f6_19 = f6*19; i32 f7_38 = f7*38; + i32 f8_19 = f8*19; i32 f9_38 = f9*38; + // |f0_2| , |f2_2| , |f4_2| , |f6_2| , |f8_2| < 1.65 * 2^27 + // |f1_2| , |f3_2| , |f5_2| , |f7_2| , |f9_2| < 1.65 * 2^26 + // |f5_38|, |f6_19|, |f7_38|, |f8_19|, |f9_38| < 2^31 + + i64 t0 = f0 *(i64)f0 + f1_2*(i64)f9_38 + f2_2*(i64)f8_19 + + f3_2*(i64)f7_38 + f4_2*(i64)f6_19 + f5 *(i64)f5_38; + i64 t1 = f0_2*(i64)f1 + f2 *(i64)f9_38 + f3_2*(i64)f8_19 + + f4 *(i64)f7_38 + f5_2*(i64)f6_19; + i64 t2 = f0_2*(i64)f2 + f1_2*(i64)f1 + f3_2*(i64)f9_38 + + f4_2*(i64)f8_19 + f5_2*(i64)f7_38 + f6 *(i64)f6_19; + i64 t3 = f0_2*(i64)f3 + f1_2*(i64)f2 + f4 *(i64)f9_38 + + f5_2*(i64)f8_19 + f6 *(i64)f7_38; + i64 t4 = f0_2*(i64)f4 + f1_2*(i64)f3_2 + f2 *(i64)f2 + + f5_2*(i64)f9_38 + f6_2*(i64)f8_19 + f7 *(i64)f7_38; + i64 t5 = f0_2*(i64)f5 + f1_2*(i64)f4 + f2_2*(i64)f3 + + f6 *(i64)f9_38 + f7_2*(i64)f8_19; + i64 t6 = f0_2*(i64)f6 + f1_2*(i64)f5_2 + f2_2*(i64)f4 + + f3_2*(i64)f3 + f7_2*(i64)f9_38 + f8 *(i64)f8_19; + i64 t7 = f0_2*(i64)f7 + f1_2*(i64)f6 + f2_2*(i64)f5 + + f3_2*(i64)f4 + f8 *(i64)f9_38; + i64 t8 = f0_2*(i64)f8 + f1_2*(i64)f7_2 + f2_2*(i64)f6 + + f3_2*(i64)f5_2 + f4 *(i64)f4 + f9 *(i64)f9_38; + i64 t9 = f0_2*(i64)f9 + f1_2*(i64)f8 + f2_2*(i64)f7 + + f3_2*(i64)f6 + f4 *(i64)f5_2; + // t0 < 0.67 * 2^61 + // t1 < 0.41 * 2^61 + // t2 < 0.52 * 2^61 + // t3 < 0.32 * 2^61 + // t4 < 0.38 * 2^61 + // t5 < 0.22 * 2^61 + // t6 < 0.23 * 2^61 + // t7 < 0.13 * 2^61 + // t8 < 0.09 * 2^61 + // t9 < 0.03 * 2^61 + + FE_CARRY; +} + +// Parity check. Returns 0 if even, 1 if odd +static int fe_isodd(const fe f) +{ + u8 s[32]; + fe_tobytes(s, f); + u8 isodd = s[0] & 1; + WIPE_BUFFER(s); + return isodd; +} + +// Returns 1 if equal, 0 if not equal +static int fe_isequal(const fe f, const fe g) +{ + u8 fs[32]; + u8 gs[32]; + fe_tobytes(fs, f); + fe_tobytes(gs, g); + int isdifferent = crypto_verify32(fs, gs); + WIPE_BUFFER(fs); + WIPE_BUFFER(gs); + return 1 + isdifferent; +} + +// Inverse square root. +// Returns true if x is a square, false otherwise. +// After the call: +// isr = sqrt(1/x) if x is a non-zero square. +// isr = sqrt(sqrt(-1)/x) if x is not a square. +// isr = 0 if x is zero. +// We do not guarantee the sign of the square root. +// +// Notes: +// Let quartic = x^((p-1)/4) +// +// x^((p-1)/2) = chi(x) +// quartic^2 = chi(x) +// quartic = sqrt(chi(x)) +// quartic = 1 or -1 or sqrt(-1) or -sqrt(-1) +// +// Note that x is a square if quartic is 1 or -1 +// There are 4 cases to consider: +// +// if quartic = 1 (x is a square) +// then x^((p-1)/4) = 1 +// x^((p-5)/4) * x = 1 +// x^((p-5)/4) = 1/x +// x^((p-5)/8) = sqrt(1/x) or -sqrt(1/x) +// +// if quartic = -1 (x is a square) +// then x^((p-1)/4) = -1 +// x^((p-5)/4) * x = -1 +// x^((p-5)/4) = -1/x +// x^((p-5)/8) = sqrt(-1) / sqrt(x) +// x^((p-5)/8) * sqrt(-1) = sqrt(-1)^2 / sqrt(x) +// x^((p-5)/8) * sqrt(-1) = -1/sqrt(x) +// x^((p-5)/8) * sqrt(-1) = -sqrt(1/x) or sqrt(1/x) +// +// if quartic = sqrt(-1) (x is not a square) +// then x^((p-1)/4) = sqrt(-1) +// x^((p-5)/4) * x = sqrt(-1) +// x^((p-5)/4) = sqrt(-1)/x +// x^((p-5)/8) = sqrt(sqrt(-1)/x) or -sqrt(sqrt(-1)/x) +// +// Note that the product of two non-squares is always a square: +// For any non-squares a and b, chi(a) = -1 and chi(b) = -1. +// Since chi(x) = x^((p-1)/2), chi(a)*chi(b) = chi(a*b) = 1. +// Therefore a*b is a square. +// +// Since sqrt(-1) and x are both non-squares, their product is a +// square, and we can compute their square root. +// +// if quartic = -sqrt(-1) (x is not a square) +// then x^((p-1)/4) = -sqrt(-1) +// x^((p-5)/4) * x = -sqrt(-1) +// x^((p-5)/4) = -sqrt(-1)/x +// x^((p-5)/8) = sqrt(-sqrt(-1)/x) +// x^((p-5)/8) = sqrt( sqrt(-1)/x) * sqrt(-1) +// x^((p-5)/8) * sqrt(-1) = sqrt( sqrt(-1)/x) * sqrt(-1)^2 +// x^((p-5)/8) * sqrt(-1) = sqrt( sqrt(-1)/x) * -1 +// x^((p-5)/8) * sqrt(-1) = -sqrt(sqrt(-1)/x) or sqrt(sqrt(-1)/x) +static int invsqrt(fe isr, const fe x) +{ + fe t0, t1, t2; + + // t0 = x^((p-5)/8) + // Can be achieved with a simple double & add ladder, + // but it would be slower. + fe_sq(t0, x); + fe_sq(t1,t0); fe_sq(t1, t1); fe_mul(t1, x, t1); + fe_mul(t0, t0, t1); + fe_sq(t0, t0); fe_mul(t0, t1, t0); + fe_sq(t1, t0); FOR (i, 1, 5) { fe_sq(t1, t1); } fe_mul(t0, t1, t0); + fe_sq(t1, t0); FOR (i, 1, 10) { fe_sq(t1, t1); } fe_mul(t1, t1, t0); + fe_sq(t2, t1); FOR (i, 1, 20) { fe_sq(t2, t2); } fe_mul(t1, t2, t1); + fe_sq(t1, t1); FOR (i, 1, 10) { fe_sq(t1, t1); } fe_mul(t0, t1, t0); + fe_sq(t1, t0); FOR (i, 1, 50) { fe_sq(t1, t1); } fe_mul(t1, t1, t0); + fe_sq(t2, t1); FOR (i, 1, 100) { fe_sq(t2, t2); } fe_mul(t1, t2, t1); + fe_sq(t1, t1); FOR (i, 1, 50) { fe_sq(t1, t1); } fe_mul(t0, t1, t0); + fe_sq(t0, t0); FOR (i, 1, 2) { fe_sq(t0, t0); } fe_mul(t0, t0, x); + + // quartic = x^((p-1)/4) + i32 *quartic = t1; + fe_sq (quartic, t0); + fe_mul(quartic, quartic, x); + + i32 *check = t2; + fe_0 (check); int z0 = fe_isequal(x , check); + fe_1 (check); int p1 = fe_isequal(quartic, check); + fe_neg(check, check ); int m1 = fe_isequal(quartic, check); + fe_neg(check, sqrtm1); int ms = fe_isequal(quartic, check); + + // if quartic == -1 or sqrt(-1) + // then isr = x^((p-1)/4) * sqrt(-1) + // else isr = x^((p-1)/4) + fe_mul(isr, t0, sqrtm1); + fe_ccopy(isr, t0, 1 - (m1 | ms)); + + WIPE_BUFFER(t0); + WIPE_BUFFER(t1); + WIPE_BUFFER(t2); + return p1 | m1 | z0; +} + +// Inverse in terms of inverse square root. +// Requires two additional squarings to get rid of the sign. +// +// 1/x = x * (+invsqrt(x^2))^2 +// = x * (-invsqrt(x^2))^2 +// +// A fully optimised exponentiation by p-1 would save 6 field +// multiplications, but it would require more code. +static void fe_invert(fe out, const fe x) +{ + fe tmp; + fe_sq(tmp, x); + invsqrt(tmp, tmp); + fe_sq(tmp, tmp); + fe_mul(out, tmp, x); + WIPE_BUFFER(tmp); +} + +// trim a scalar for scalar multiplication +void crypto_eddsa_trim_scalar(u8 out[32], const u8 in[32]) +{ + COPY(out, in, 32); + out[ 0] &= 248; + out[31] &= 127; + out[31] |= 64; +} + +// get bit from scalar at position i +static int scalar_bit(const u8 s[32], int i) +{ + if (i < 0) { return 0; } // handle -1 for sliding windows + return (s[i>>3] >> (i&7)) & 1; +} + +/////////////// +/// X-25519 /// Taken from SUPERCOP's ref10 implementation. +/////////////// +static void scalarmult(u8 q[32], const u8 scalar[32], const u8 p[32], + int nb_bits) +{ + // computes the scalar product + fe x1; + fe_frombytes(x1, p); + + // computes the actual scalar product (the result is in x2 and z2) + fe x2, z2, x3, z3, t0, t1; + // Montgomery ladder + // In projective coordinates, to avoid divisions: x = X / Z + // We don't care about the y coordinate, it's only 1 bit of information + fe_1(x2); fe_0(z2); // "zero" point + fe_copy(x3, x1); fe_1(z3); // "one" point + int swap = 0; + for (int pos = nb_bits-1; pos >= 0; --pos) { + // constant time conditional swap before ladder step + int b = scalar_bit(scalar, pos); + swap ^= b; // xor trick avoids swapping at the end of the loop + fe_cswap(x2, x3, swap); + fe_cswap(z2, z3, swap); + swap = b; // anticipates one last swap after the loop + + // Montgomery ladder step: replaces (P2, P3) by (P2*2, P2+P3) + // with differential addition + fe_sub(t0, x3, z3); + fe_sub(t1, x2, z2); + fe_add(x2, x2, z2); + fe_add(z2, x3, z3); + fe_mul(z3, t0, x2); + fe_mul(z2, z2, t1); + fe_sq (t0, t1 ); + fe_sq (t1, x2 ); + fe_add(x3, z3, z2); + fe_sub(z2, z3, z2); + fe_mul(x2, t1, t0); + fe_sub(t1, t1, t0); + fe_sq (z2, z2 ); + fe_mul_small(z3, t1, 121666); + fe_sq (x3, x3 ); + fe_add(t0, t0, z3); + fe_mul(z3, x1, z2); + fe_mul(z2, t1, t0); + } + // last swap is necessary to compensate for the xor trick + // Note: after this swap, P3 == P2 + P1. + fe_cswap(x2, x3, swap); + fe_cswap(z2, z3, swap); + + // normalises the coordinates: x == X / Z + fe_invert(z2, z2); + fe_mul(x2, x2, z2); + fe_tobytes(q, x2); + + WIPE_BUFFER(x1); + WIPE_BUFFER(x2); WIPE_BUFFER(z2); WIPE_BUFFER(t0); + WIPE_BUFFER(x3); WIPE_BUFFER(z3); WIPE_BUFFER(t1); +} + +void crypto_x25519(u8 raw_shared_secret[32], + const u8 your_secret_key [32], + const u8 their_public_key [32]) +{ + // restrict the possible scalar values + u8 e[32]; + crypto_eddsa_trim_scalar(e, your_secret_key); + scalarmult(raw_shared_secret, e, their_public_key, 255); + WIPE_BUFFER(e); +} + +void crypto_x25519_public_key(u8 public_key[32], + const u8 secret_key[32]) +{ + static const u8 base_point[32] = {9}; + crypto_x25519(public_key, secret_key, base_point); +} + +/////////////////////////// +/// Arithmetic modulo L /// +/////////////////////////// +static const u32 L[8] = { + 0x5cf5d3ed, 0x5812631a, 0xa2f79cd6, 0x14def9de, + 0x00000000, 0x00000000, 0x00000000, 0x10000000, +}; + +// p = a*b + p +static void multiply(u32 p[16], const u32 a[8], const u32 b[8]) +{ + FOR (i, 0, 8) { + u64 carry = 0; + FOR (j, 0, 8) { + carry += p[i+j] + (u64)a[i] * b[j]; + p[i+j] = (u32)carry; + carry >>= 32; + } + p[i+8] = (u32)carry; + } +} + +static int is_above_l(const u32 x[8]) +{ + // We work with L directly, in a 2's complement encoding + // (-L == ~L + 1) + u64 carry = 1; + FOR (i, 0, 8) { + carry += (u64)x[i] + (~L[i] & 0xffffffff); + carry >>= 32; + } + return (int)carry; // carry is either 0 or 1 +} + +// Final reduction modulo L, by conditionally removing L. +// if x < l , then r = x +// if l <= x 2*l, then r = x-l +// otherwise the result will be wrong +static void remove_l(u32 r[8], const u32 x[8]) +{ + u64 carry = (u64)is_above_l(x); + u32 mask = ~(u32)carry + 1; // carry == 0 or 1 + FOR (i, 0, 8) { + carry += (u64)x[i] + (~L[i] & mask); + r[i] = (u32)carry; + carry >>= 32; + } +} + +// Full reduction modulo L (Barrett reduction) +static void mod_l(u8 reduced[32], const u32 x[16]) +{ + static const u32 r[9] = { + 0x0a2c131b,0xed9ce5a3,0x086329a7,0x2106215d, + 0xffffffeb,0xffffffff,0xffffffff,0xffffffff,0xf, + }; + // xr = x * r + u32 xr[25] = {0}; + FOR (i, 0, 9) { + u64 carry = 0; + FOR (j, 0, 16) { + carry += xr[i+j] + (u64)r[i] * x[j]; + xr[i+j] = (u32)carry; + carry >>= 32; + } + xr[i+16] = (u32)carry; + } + // xr = floor(xr / 2^512) * L + // Since the result is guaranteed to be below 2*L, + // it is enough to only compute the first 256 bits. + // The division is performed by saying xr[i+16]. (16 * 32 = 512) + ZERO(xr, 8); + FOR (i, 0, 8) { + u64 carry = 0; + FOR (j, 0, 8-i) { + carry += xr[i+j] + (u64)xr[i+16] * L[j]; + xr[i+j] = (u32)carry; + carry >>= 32; + } + } + // xr = x - xr + u64 carry = 1; + FOR (i, 0, 8) { + carry += (u64)x[i] + (~xr[i] & 0xffffffff); + xr[i] = (u32)carry; + carry >>= 32; + } + // Final reduction modulo L (conditional subtraction) + remove_l(xr, xr); + store32_le_buf(reduced, xr, 8); + + WIPE_BUFFER(xr); +} + +void crypto_eddsa_reduce(u8 reduced[32], const u8 expanded[64]) +{ + u32 x[16]; + load32_le_buf(x, expanded, 16); + mod_l(reduced, x); + WIPE_BUFFER(x); +} + +// r = (a * b) + c +void crypto_eddsa_mul_add(u8 r[32], + const u8 a[32], const u8 b[32], const u8 c[32]) +{ + u32 A[8]; load32_le_buf(A, a, 8); + u32 B[8]; load32_le_buf(B, b, 8); + u32 p[16]; load32_le_buf(p, c, 8); ZERO(p + 8, 8); + multiply(p, A, B); + mod_l(r, p); + WIPE_BUFFER(p); + WIPE_BUFFER(A); + WIPE_BUFFER(B); +} + +/////////////// +/// Ed25519 /// +/////////////// + +// Point (group element, ge) in a twisted Edwards curve, +// in extended projective coordinates. +// ge : x = X/Z, y = Y/Z, T = XY/Z +// ge_cached : Yp = X+Y, Ym = X-Y, T2 = T*D2 +// ge_precomp: Z = 1 +typedef struct { fe X; fe Y; fe Z; fe T; } ge; +typedef struct { fe Yp; fe Ym; fe Z; fe T2; } ge_cached; +typedef struct { fe Yp; fe Ym; fe T2; } ge_precomp; + +static void ge_zero(ge *p) +{ + fe_0(p->X); + fe_1(p->Y); + fe_1(p->Z); + fe_0(p->T); +} + +static void ge_tobytes(u8 s[32], const ge *h) +{ + fe recip, x, y; + fe_invert(recip, h->Z); + fe_mul(x, h->X, recip); + fe_mul(y, h->Y, recip); + fe_tobytes(s, y); + s[31] ^= (u8)fe_isodd(x) << 7; + + WIPE_BUFFER(recip); + WIPE_BUFFER(x); + WIPE_BUFFER(y); +} + +// h = -s, where s is a point encoded in 32 bytes +// +// Variable time! Inputs must not be secret! +// => Use only to *check* signatures. +// +// From the specifications: +// The encoding of s contains y and the sign of x +// x = sqrt((y^2 - 1) / (d*y^2 + 1)) +// In extended coordinates: +// X = x, Y = y, Z = 1, T = x*y +// +// Note that num * den is a square iff num / den is a square +// If num * den is not a square, the point was not on the curve. +// From the above: +// Let num = y^2 - 1 +// Let den = d*y^2 + 1 +// x = sqrt((y^2 - 1) / (d*y^2 + 1)) +// x = sqrt(num / den) +// x = sqrt(num^2 / (num * den)) +// x = num * sqrt(1 / (num * den)) +// +// Therefore, we can just compute: +// num = y^2 - 1 +// den = d*y^2 + 1 +// isr = invsqrt(num * den) // abort if not square +// x = num * isr +// Finally, negate x if its sign is not as specified. +static int ge_frombytes_neg_vartime(ge *h, const u8 s[32]) +{ + fe_frombytes(h->Y, s); + fe_1(h->Z); + fe_sq (h->T, h->Y); // t = y^2 + fe_mul(h->X, h->T, d ); // x = d*y^2 + fe_sub(h->T, h->T, h->Z); // t = y^2 - 1 + fe_add(h->X, h->X, h->Z); // x = d*y^2 + 1 + fe_mul(h->X, h->T, h->X); // x = (y^2 - 1) * (d*y^2 + 1) + int is_square = invsqrt(h->X, h->X); + if (!is_square) { + return -1; // Not on the curve, abort + } + fe_mul(h->X, h->T, h->X); // x = sqrt((y^2 - 1) / (d*y^2 + 1)) + if (fe_isodd(h->X) == (s[31] >> 7)) { + fe_neg(h->X, h->X); + } + fe_mul(h->T, h->X, h->Y); + return 0; +} + +static void ge_cache(ge_cached *c, const ge *p) +{ + fe_add (c->Yp, p->Y, p->X); + fe_sub (c->Ym, p->Y, p->X); + fe_copy(c->Z , p->Z ); + fe_mul (c->T2, p->T, D2 ); +} + +// Internal buffers are not wiped! Inputs must not be secret! +// => Use only to *check* signatures. +static void ge_add(ge *s, const ge *p, const ge_cached *q) +{ + fe a, b; + fe_add(a , p->Y, p->X ); + fe_sub(b , p->Y, p->X ); + fe_mul(a , a , q->Yp); + fe_mul(b , b , q->Ym); + fe_add(s->Y, a , b ); + fe_sub(s->X, a , b ); + + fe_add(s->Z, p->Z, p->Z ); + fe_mul(s->Z, s->Z, q->Z ); + fe_mul(s->T, p->T, q->T2); + fe_add(a , s->Z, s->T ); + fe_sub(b , s->Z, s->T ); + + fe_mul(s->T, s->X, s->Y); + fe_mul(s->X, s->X, b ); + fe_mul(s->Y, s->Y, a ); + fe_mul(s->Z, a , b ); +} + +// Internal buffers are not wiped! Inputs must not be secret! +// => Use only to *check* signatures. +static void ge_sub(ge *s, const ge *p, const ge_cached *q) +{ + ge_cached neg; + fe_copy(neg.Ym, q->Yp); + fe_copy(neg.Yp, q->Ym); + fe_copy(neg.Z , q->Z ); + fe_neg (neg.T2, q->T2); + ge_add(s, p, &neg); +} + +static void ge_madd(ge *s, const ge *p, const ge_precomp *q, fe a, fe b) +{ + fe_add(a , p->Y, p->X ); + fe_sub(b , p->Y, p->X ); + fe_mul(a , a , q->Yp); + fe_mul(b , b , q->Ym); + fe_add(s->Y, a , b ); + fe_sub(s->X, a , b ); + + fe_add(s->Z, p->Z, p->Z ); + fe_mul(s->T, p->T, q->T2); + fe_add(a , s->Z, s->T ); + fe_sub(b , s->Z, s->T ); + + fe_mul(s->T, s->X, s->Y); + fe_mul(s->X, s->X, b ); + fe_mul(s->Y, s->Y, a ); + fe_mul(s->Z, a , b ); +} + +// Internal buffers are not wiped! Inputs must not be secret! +// => Use only to *check* signatures. +static void ge_msub(ge *s, const ge *p, const ge_precomp *q, fe a, fe b) +{ + ge_precomp neg; + fe_copy(neg.Ym, q->Yp); + fe_copy(neg.Yp, q->Ym); + fe_neg (neg.T2, q->T2); + ge_madd(s, p, &neg, a, b); +} + +static void ge_double(ge *s, const ge *p, ge *q) +{ + fe_sq (q->X, p->X); + fe_sq (q->Y, p->Y); + fe_sq (q->Z, p->Z); // qZ = pZ^2 + fe_mul_small(q->Z, q->Z, 2); // qZ = pZ^2 * 2 + fe_add(q->T, p->X, p->Y); + fe_sq (s->T, q->T); + fe_add(q->T, q->Y, q->X); + fe_sub(q->Y, q->Y, q->X); + fe_sub(q->X, s->T, q->T); + fe_sub(q->Z, q->Z, q->Y); + + fe_mul(s->X, q->X , q->Z); + fe_mul(s->Y, q->T , q->Y); + fe_mul(s->Z, q->Y , q->Z); + fe_mul(s->T, q->X , q->T); +} + +// 5-bit signed window in cached format (Niels coordinates, Z=1) +static const ge_precomp b_window[8] = { + {{25967493,-14356035,29566456,3660896,-12694345, + 4014787,27544626,-11754271,-6079156,2047605,}, + {-12545711,934262,-2722910,3049990,-727428, + 9406986,12720692,5043384,19500929,-15469378,}, + {-8738181,4489570,9688441,-14785194,10184609, + -12363380,29287919,11864899,-24514362,-4438546,},}, + {{15636291,-9688557,24204773,-7912398,616977, + -16685262,27787600,-14772189,28944400,-1550024,}, + {16568933,4717097,-11556148,-1102322,15682896, + -11807043,16354577,-11775962,7689662,11199574,}, + {30464156,-5976125,-11779434,-15670865,23220365, + 15915852,7512774,10017326,-17749093,-9920357,},}, + {{10861363,11473154,27284546,1981175,-30064349, + 12577861,32867885,14515107,-15438304,10819380,}, + {4708026,6336745,20377586,9066809,-11272109, + 6594696,-25653668,12483688,-12668491,5581306,}, + {19563160,16186464,-29386857,4097519,10237984, + -4348115,28542350,13850243,-23678021,-15815942,},}, + {{5153746,9909285,1723747,-2777874,30523605, + 5516873,19480852,5230134,-23952439,-15175766,}, + {-30269007,-3463509,7665486,10083793,28475525, + 1649722,20654025,16520125,30598449,7715701,}, + {28881845,14381568,9657904,3680757,-20181635, + 7843316,-31400660,1370708,29794553,-1409300,},}, + {{-22518993,-6692182,14201702,-8745502,-23510406, + 8844726,18474211,-1361450,-13062696,13821877,}, + {-6455177,-7839871,3374702,-4740862,-27098617, + -10571707,31655028,-7212327,18853322,-14220951,}, + {4566830,-12963868,-28974889,-12240689,-7602672, + -2830569,-8514358,-10431137,2207753,-3209784,},}, + {{-25154831,-4185821,29681144,7868801,-6854661, + -9423865,-12437364,-663000,-31111463,-16132436,}, + {25576264,-2703214,7349804,-11814844,16472782, + 9300885,3844789,15725684,171356,6466918,}, + {23103977,13316479,9739013,-16149481,817875, + -15038942,8965339,-14088058,-30714912,16193877,},}, + {{-33521811,3180713,-2394130,14003687,-16903474, + -16270840,17238398,4729455,-18074513,9256800,}, + {-25182317,-4174131,32336398,5036987,-21236817, + 11360617,22616405,9761698,-19827198,630305,}, + {-13720693,2639453,-24237460,-7406481,9494427, + -5774029,-6554551,-15960994,-2449256,-14291300,},}, + {{-3151181,-5046075,9282714,6866145,-31907062, + -863023,-18940575,15033784,25105118,-7894876,}, + {-24326370,15950226,-31801215,-14592823,-11662737, + -5090925,1573892,-2625887,2198790,-15804619,}, + {-3099351,10324967,-2241613,7453183,-5446979, + -2735503,-13812022,-16236442,-32461234,-12290683,},}, +}; + +// Incremental sliding windows (left to right) +// Based on Roberto Maria Avanzi[2005] +typedef struct { + i16 next_index; // position of the next signed digit + i8 next_digit; // next signed digit (odd number below 2^window_width) + u8 next_check; // point at which we must check for a new window +} slide_ctx; + +static void slide_init(slide_ctx *ctx, const u8 scalar[32]) +{ + // scalar is guaranteed to be below L, either because we checked (s), + // or because we reduced it modulo L (h_ram). L is under 2^253, so + // so bits 253 to 255 are guaranteed to be zero. No need to test them. + // + // Note however that L is very close to 2^252, so bit 252 is almost + // always zero. If we were to start at bit 251, the tests wouldn't + // catch the off-by-one error (constructing one that does would be + // prohibitively expensive). + // + // We should still check bit 252, though. + int i = 252; + while (i > 0 && scalar_bit(scalar, i) == 0) { + i--; + } + ctx->next_check = (u8)(i + 1); + ctx->next_index = -1; + ctx->next_digit = -1; +} + +static int slide_step(slide_ctx *ctx, int width, int i, const u8 scalar[32]) +{ + if (i == ctx->next_check) { + if (scalar_bit(scalar, i) == scalar_bit(scalar, i - 1)) { + ctx->next_check--; + } else { + // compute digit of next window + int w = MIN(width, i + 1); + int v = -(scalar_bit(scalar, i) << (w-1)); + FOR_T (int, j, 0, w-1) { + v += scalar_bit(scalar, i-(w-1)+j) << j; + } + v += scalar_bit(scalar, i-w); + int lsb = v & (~v + 1); // smallest bit of v + int s = // log2(lsb) + (((lsb & 0xAA) != 0) << 0) | + (((lsb & 0xCC) != 0) << 1) | + (((lsb & 0xF0) != 0) << 2); + ctx->next_index = (i16)(i-(w-1)+s); + ctx->next_digit = (i8) (v >> s ); + ctx->next_check -= (u8) w; + } + } + return i == ctx->next_index ? ctx->next_digit: 0; +} + +#define P_W_WIDTH 3 // Affects the size of the stack +#define B_W_WIDTH 5 // Affects the size of the binary +#define P_W_SIZE (1<<(P_W_WIDTH-2)) + +int crypto_eddsa_check_equation(const u8 signature[64], const u8 public_key[32], + const u8 h[32]) +{ + ge minus_A; // -public_key + ge minus_R; // -first_half_of_signature + const u8 *s = signature + 32; + + // Check that A and R are on the curve + // Check that 0 <= S < L (prevents malleability) + // *Allow* non-cannonical encoding for A and R + { + u32 s32[8]; + load32_le_buf(s32, s, 8); + if (ge_frombytes_neg_vartime(&minus_A, public_key) || + ge_frombytes_neg_vartime(&minus_R, signature) || + is_above_l(s32)) { + return -1; + } + } + + // look-up table for minus_A + ge_cached lutA[P_W_SIZE]; + { + ge minus_A2, tmp; + ge_double(&minus_A2, &minus_A, &tmp); + ge_cache(&lutA[0], &minus_A); + FOR (i, 1, P_W_SIZE) { + ge_add(&tmp, &minus_A2, &lutA[i-1]); + ge_cache(&lutA[i], &tmp); + } + } + + // sum = [s]B - [h]A + // Merged double and add ladder, fused with sliding + slide_ctx h_slide; slide_init(&h_slide, h); + slide_ctx s_slide; slide_init(&s_slide, s); + int i = MAX(h_slide.next_check, s_slide.next_check); + ge *sum = &minus_A; // reuse minus_A for the sum + ge_zero(sum); + while (i >= 0) { + ge tmp; + ge_double(sum, sum, &tmp); + int h_digit = slide_step(&h_slide, P_W_WIDTH, i, h); + int s_digit = slide_step(&s_slide, B_W_WIDTH, i, s); + if (h_digit > 0) { ge_add(sum, sum, &lutA[ h_digit / 2]); } + if (h_digit < 0) { ge_sub(sum, sum, &lutA[-h_digit / 2]); } + fe t1, t2; + if (s_digit > 0) { ge_madd(sum, sum, b_window + s_digit/2, t1, t2); } + if (s_digit < 0) { ge_msub(sum, sum, b_window + -s_digit/2, t1, t2); } + i--; + } + + // Compare [8](sum-R) and the zero point + // The multiplication by 8 eliminates any low-order component + // and ensures consistency with batched verification. + ge_cached cached; + u8 check[32]; + static const u8 zero_point[32] = {1}; // Point of order 1 + ge_cache(&cached, &minus_R); + ge_add(sum, sum, &cached); + ge_double(sum, sum, &minus_R); // reuse minus_R as temporary + ge_double(sum, sum, &minus_R); // reuse minus_R as temporary + ge_double(sum, sum, &minus_R); // reuse minus_R as temporary + ge_tobytes(check, sum); + return crypto_verify32(check, zero_point); +} + +// 5-bit signed comb in cached format (Niels coordinates, Z=1) +static const ge_precomp b_comb_low[8] = { + {{-6816601,-2324159,-22559413,124364,18015490, + 8373481,19993724,1979872,-18549925,9085059,}, + {10306321,403248,14839893,9633706,8463310, + -8354981,-14305673,14668847,26301366,2818560,}, + {-22701500,-3210264,-13831292,-2927732,-16326337, + -14016360,12940910,177905,12165515,-2397893,},}, + {{-12282262,-7022066,9920413,-3064358,-32147467, + 2927790,22392436,-14852487,2719975,16402117,}, + {-7236961,-4729776,2685954,-6525055,-24242706, + -15940211,-6238521,14082855,10047669,12228189,}, + {-30495588,-12893761,-11161261,3539405,-11502464, + 16491580,-27286798,-15030530,-7272871,-15934455,},}, + {{17650926,582297,-860412,-187745,-12072900, + -10683391,-20352381,15557840,-31072141,-5019061,}, + {-6283632,-2259834,-4674247,-4598977,-4089240, + 12435688,-31278303,1060251,6256175,10480726,}, + {-13871026,2026300,-21928428,-2741605,-2406664, + -8034988,7355518,15733500,-23379862,7489131,},}, + {{6883359,695140,23196907,9644202,-33430614, + 11354760,-20134606,6388313,-8263585,-8491918,}, + {-7716174,-13605463,-13646110,14757414,-19430591, + -14967316,10359532,-11059670,-21935259,12082603,}, + {-11253345,-15943946,10046784,5414629,24840771, + 8086951,-6694742,9868723,15842692,-16224787,},}, + {{9639399,11810955,-24007778,-9320054,3912937, + -9856959,996125,-8727907,-8919186,-14097242,}, + {7248867,14468564,25228636,-8795035,14346339, + 8224790,6388427,-7181107,6468218,-8720783,}, + {15513115,15439095,7342322,-10157390,18005294, + -7265713,2186239,4884640,10826567,7135781,},}, + {{-14204238,5297536,-5862318,-6004934,28095835, + 4236101,-14203318,1958636,-16816875,3837147,}, + {-5511166,-13176782,-29588215,12339465,15325758, + -15945770,-8813185,11075932,-19608050,-3776283,}, + {11728032,9603156,-4637821,-5304487,-7827751, + 2724948,31236191,-16760175,-7268616,14799772,},}, + {{-28842672,4840636,-12047946,-9101456,-1445464, + 381905,-30977094,-16523389,1290540,12798615,}, + {27246947,-10320914,14792098,-14518944,5302070, + -8746152,-3403974,-4149637,-27061213,10749585,}, + {25572375,-6270368,-15353037,16037944,1146292, + 32198,23487090,9585613,24714571,-1418265,},}, + {{19844825,282124,-17583147,11004019,-32004269, + -2716035,6105106,-1711007,-21010044,14338445,}, + {8027505,8191102,-18504907,-12335737,25173494, + -5923905,15446145,7483684,-30440441,10009108,}, + {-14134701,-4174411,10246585,-14677495,33553567, + -14012935,23366126,15080531,-7969992,7663473,},}, +}; + +static const ge_precomp b_comb_high[8] = { + {{33055887,-4431773,-521787,6654165,951411, + -6266464,-5158124,6995613,-5397442,-6985227,}, + {4014062,6967095,-11977872,3960002,8001989, + 5130302,-2154812,-1899602,-31954493,-16173976,}, + {16271757,-9212948,23792794,731486,-25808309, + -3546396,6964344,-4767590,10976593,10050757,},}, + {{2533007,-4288439,-24467768,-12387405,-13450051, + 14542280,12876301,13893535,15067764,8594792,}, + {20073501,-11623621,3165391,-13119866,13188608, + -11540496,-10751437,-13482671,29588810,2197295,}, + {-1084082,11831693,6031797,14062724,14748428, + -8159962,-20721760,11742548,31368706,13161200,},}, + {{2050412,-6457589,15321215,5273360,25484180, + 124590,-18187548,-7097255,-6691621,-14604792,}, + {9938196,2162889,-6158074,-1711248,4278932, + -2598531,-22865792,-7168500,-24323168,11746309,}, + {-22691768,-14268164,5965485,9383325,20443693, + 5854192,28250679,-1381811,-10837134,13717818,},}, + {{-8495530,16382250,9548884,-4971523,-4491811, + -3902147,6182256,-12832479,26628081,10395408,}, + {27329048,-15853735,7715764,8717446,-9215518, + -14633480,28982250,-5668414,4227628,242148,}, + {-13279943,-7986904,-7100016,8764468,-27276630, + 3096719,29678419,-9141299,3906709,11265498,},}, + {{11918285,15686328,-17757323,-11217300,-27548967, + 4853165,-27168827,6807359,6871949,-1075745,}, + {-29002610,13984323,-27111812,-2713442,28107359, + -13266203,6155126,15104658,3538727,-7513788,}, + {14103158,11233913,-33165269,9279850,31014152, + 4335090,-1827936,4590951,13960841,12787712,},}, + {{1469134,-16738009,33411928,13942824,8092558, + -8778224,-11165065,1437842,22521552,-2792954,}, + {31352705,-4807352,-25327300,3962447,12541566, + -9399651,-27425693,7964818,-23829869,5541287,}, + {-25732021,-6864887,23848984,3039395,-9147354, + 6022816,-27421653,10590137,25309915,-1584678,},}, + {{-22951376,5048948,31139401,-190316,-19542447, + -626310,-17486305,-16511925,-18851313,-12985140,}, + {-9684890,14681754,30487568,7717771,-10829709, + 9630497,30290549,-10531496,-27798994,-13812825,}, + {5827835,16097107,-24501327,12094619,7413972, + 11447087,28057551,-1793987,-14056981,4359312,},}, + {{26323183,2342588,-21887793,-1623758,-6062284, + 2107090,-28724907,9036464,-19618351,-13055189,}, + {-29697200,14829398,-4596333,14220089,-30022969, + 2955645,12094100,-13693652,-5941445,7047569,}, + {-3201977,14413268,-12058324,-16417589,-9035655, + -7224648,9258160,1399236,30397584,-5684634,},}, +}; + +static void lookup_add(ge *p, ge_precomp *tmp_c, fe tmp_a, fe tmp_b, + const ge_precomp comb[8], const u8 scalar[32], int i) +{ + u8 teeth = (u8)((scalar_bit(scalar, i) ) + + (scalar_bit(scalar, i + 32) << 1) + + (scalar_bit(scalar, i + 64) << 2) + + (scalar_bit(scalar, i + 96) << 3)); + u8 high = teeth >> 3; + u8 index = (teeth ^ (high - 1)) & 7; + FOR (j, 0, 8) { + i32 select = 1 & (((j ^ index) - 1) >> 8); + fe_ccopy(tmp_c->Yp, comb[j].Yp, select); + fe_ccopy(tmp_c->Ym, comb[j].Ym, select); + fe_ccopy(tmp_c->T2, comb[j].T2, select); + } + fe_neg(tmp_a, tmp_c->T2); + fe_cswap(tmp_c->T2, tmp_a , high ^ 1); + fe_cswap(tmp_c->Yp, tmp_c->Ym, high ^ 1); + ge_madd(p, p, tmp_c, tmp_a, tmp_b); +} + +// p = [scalar]B, where B is the base point +static void ge_scalarmult_base(ge *p, const u8 scalar[32]) +{ + // twin 4-bits signed combs, from Mike Hamburg's + // Fast and compact elliptic-curve cryptography (2012) + // 1 / 2 modulo L + static const u8 half_mod_L[32] = { + 247,233,122,46,141,49,9,44,107,206,123,81,239,124,111,10, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8, + }; + // (2^256 - 1) / 2 modulo L + static const u8 half_ones[32] = { + 142,74,204,70,186,24,118,107,184,231,190,57,250,173,119,99, + 255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,7, + }; + + // All bits set form: 1 means 1, 0 means -1 + u8 s_scalar[32]; + crypto_eddsa_mul_add(s_scalar, scalar, half_mod_L, half_ones); + + // Double and add ladder + fe tmp_a, tmp_b; // temporaries for addition + ge_precomp tmp_c; // temporary for comb lookup + ge tmp_d; // temporary for doubling + fe_1(tmp_c.Yp); + fe_1(tmp_c.Ym); + fe_0(tmp_c.T2); + + // Save a double on the first iteration + ge_zero(p); + lookup_add(p, &tmp_c, tmp_a, tmp_b, b_comb_low , s_scalar, 31); + lookup_add(p, &tmp_c, tmp_a, tmp_b, b_comb_high, s_scalar, 31+128); + // Regular double & add for the rest + for (int i = 30; i >= 0; i--) { + ge_double(p, p, &tmp_d); + lookup_add(p, &tmp_c, tmp_a, tmp_b, b_comb_low , s_scalar, i); + lookup_add(p, &tmp_c, tmp_a, tmp_b, b_comb_high, s_scalar, i+128); + } + // Note: we could save one addition at the end if we assumed the + // scalar fit in 252 bits. Which it does in practice if it is + // selected at random. However, non-random, non-hashed scalars + // *can* overflow 252 bits in practice. Better account for that + // than leaving that kind of subtle corner case. + + WIPE_BUFFER(tmp_a); WIPE_CTX(&tmp_d); + WIPE_BUFFER(tmp_b); WIPE_CTX(&tmp_c); + WIPE_BUFFER(s_scalar); +} + +void crypto_eddsa_scalarbase(u8 point[32], const u8 scalar[32]) +{ + ge P; + ge_scalarmult_base(&P, scalar); + ge_tobytes(point, &P); + WIPE_CTX(&P); +} + +void crypto_eddsa_key_pair(u8 secret_key[64], u8 public_key[32], u8 seed[32]) +{ + // To allow overlaps, observable writes happen in this order: + // 1. seed + // 2. secret_key + // 3. public_key + u8 a[64]; + COPY(a, seed, 32); + crypto_wipe(seed, 32); + COPY(secret_key, a, 32); + crypto_blake2b(a, 64, a, 32); + crypto_eddsa_trim_scalar(a, a); + crypto_eddsa_scalarbase(secret_key + 32, a); + COPY(public_key, secret_key + 32, 32); + WIPE_BUFFER(a); +} + +static void hash_reduce(u8 h[32], + const u8 *a, size_t a_size, + const u8 *b, size_t b_size, + const u8 *c, size_t c_size) +{ + u8 hash[64]; + crypto_blake2b_ctx ctx; + crypto_blake2b_init (&ctx, 64); + crypto_blake2b_update(&ctx, a, a_size); + crypto_blake2b_update(&ctx, b, b_size); + crypto_blake2b_update(&ctx, c, c_size); + crypto_blake2b_final (&ctx, hash); + crypto_eddsa_reduce(h, hash); +} + +// Digital signature of a message with from a secret key. +// +// The secret key comprises two parts: +// - The seed that generates the key (secret_key[ 0..31]) +// - The public key (secret_key[32..63]) +// +// The seed and the public key are bundled together to make sure users +// don't use mismatched seeds and public keys, which would instantly +// leak the secret scalar and allow forgeries (allowing this to happen +// has resulted in critical vulnerabilities in the wild). +// +// The seed is hashed to derive the secret scalar and a secret prefix. +// The sole purpose of the prefix is to generate a secret random nonce. +// The properties of that nonce must be as follows: +// - Unique: we need a different one for each message. +// - Secret: third parties must not be able to predict it. +// - Random: any detectable bias would break all security. +// +// There are two ways to achieve these properties. The obvious one is +// to simply generate a random number. Here that would be a parameter +// (Monocypher doesn't have an RNG). It works, but then users may reuse +// the nonce by accident, which _also_ leaks the secret scalar and +// allows forgeries. This has happened in the wild too. +// +// This is no good, so instead we generate that nonce deterministically +// by reducing modulo L a hash of the secret prefix and the message. +// The secret prefix makes the nonce unpredictable, the message makes it +// unique, and the hash/reduce removes all bias. +// +// The cost of that safety is hashing the message twice. If that cost +// is unacceptable, there are two alternatives: +// +// - Signing a hash of the message instead of the message itself. This +// is fine as long as the hash is collision resistant. It is not +// compatible with existing "pure" signatures, but at least it's safe. +// +// - Using a random nonce. Please exercise **EXTREME CAUTION** if you +// ever do that. It is absolutely **critical** that the nonce is +// really an unbiased random number between 0 and L-1, never reused, +// and wiped immediately. +// +// To lower the likelihood of complete catastrophe if the RNG is +// either flawed or misused, you can hash the RNG output together with +// the secret prefix and the beginning of the message, and use the +// reduction of that hash instead of the RNG output itself. It's not +// foolproof (you'd need to hash the whole message) but it helps. +// +// Signing a message involves the following operations: +// +// scalar, prefix = HASH(secret_key) +// r = HASH(prefix || message) % L +// R = [r]B +// h = HASH(R || public_key || message) % L +// S = ((h * a) + r) % L +// signature = R || S +void crypto_eddsa_sign(u8 signature [64], const u8 secret_key[64], + const u8 *message, size_t message_size) +{ + u8 a[64]; // secret scalar and prefix + u8 r[32]; // secret deterministic "random" nonce + u8 h[32]; // publically verifiable hash of the message (not wiped) + u8 R[32]; // first half of the signature (allows overlapping inputs) + + crypto_blake2b(a, 64, secret_key, 32); + crypto_eddsa_trim_scalar(a, a); + hash_reduce(r, a + 32, 32, message, message_size, 0, 0); + crypto_eddsa_scalarbase(R, r); + hash_reduce(h, R, 32, secret_key + 32, 32, message, message_size); + COPY(signature, R, 32); + crypto_eddsa_mul_add(signature + 32, h, a, r); + + WIPE_BUFFER(a); + WIPE_BUFFER(r); +} + +// To check the signature R, S of the message M with the public key A, +// there are 3 steps: +// +// compute h = HASH(R || A || message) % L +// check that A is on the curve. +// check that R == [s]B - [h]A +// +// The last two steps are done in crypto_eddsa_check_equation() +int crypto_eddsa_check(const u8 signature[64], const u8 public_key[32], + const u8 *message, size_t message_size) +{ + u8 h[32]; + hash_reduce(h, signature, 32, public_key, 32, message, message_size); + return crypto_eddsa_check_equation(signature, public_key, h); +} + +///////////////////////// +/// EdDSA <--> X25519 /// +///////////////////////// +void crypto_eddsa_to_x25519(u8 x25519[32], const u8 eddsa[32]) +{ + // (u, v) = ((1+y)/(1-y), sqrt(-486664)*u/x) + // Only converting y to u, the sign of x is ignored. + fe t1, t2; + fe_frombytes(t2, eddsa); + fe_add(t1, fe_one, t2); + fe_sub(t2, fe_one, t2); + fe_invert(t2, t2); + fe_mul(t1, t1, t2); + fe_tobytes(x25519, t1); + WIPE_BUFFER(t1); + WIPE_BUFFER(t2); +} + +void crypto_x25519_to_eddsa(u8 eddsa[32], const u8 x25519[32]) +{ + // (x, y) = (sqrt(-486664)*u/v, (u-1)/(u+1)) + // Only converting u to y, x is assumed positive. + fe t1, t2; + fe_frombytes(t2, x25519); + fe_sub(t1, t2, fe_one); + fe_add(t2, t2, fe_one); + fe_invert(t2, t2); + fe_mul(t1, t1, t2); + fe_tobytes(eddsa, t1); + WIPE_BUFFER(t1); + WIPE_BUFFER(t2); +} + +///////////////////////////////////////////// +/// Dirty ephemeral public key generation /// +///////////////////////////////////////////// + +// Those functions generates a public key, *without* clearing the +// cofactor. Sending that key over the network leaks 3 bits of the +// private key. Use only to generate ephemeral keys that will be hidden +// with crypto_curve_to_hidden(). +// +// The public key is otherwise compatible with crypto_x25519(), which +// properly clears the cofactor. +// +// Note that the distribution of the resulting public keys is almost +// uniform. Flipping the sign of the v coordinate (not provided by this +// function), covers the entire key space almost perfectly, where +// "almost" means a 2^-128 bias (undetectable). This uniformity is +// needed to ensure the proper randomness of the resulting +// representatives (once we apply crypto_curve_to_hidden()). +// +// Recall that Curve25519 has order C = 2^255 + e, with e < 2^128 (not +// to be confused with the prime order of the main subgroup, L, which is +// 8 times less than that). +// +// Generating all points would require us to multiply a point of order C +// (the base point plus any point of order 8) by all scalars from 0 to +// C-1. Clamping limits us to scalars between 2^254 and 2^255 - 1. But +// by negating the resulting point at random, we also cover scalars from +// -2^255 + 1 to -2^254 (which modulo C is congruent to e+1 to 2^254 + e). +// +// In practice: +// - Scalars from 0 to e + 1 are never generated +// - Scalars from 2^255 to 2^255 + e are never generated +// - Scalars from 2^254 + 1 to 2^254 + e are generated twice +// +// Since e < 2^128, detecting this bias requires observing over 2^100 +// representatives from a given source (this will never happen), *and* +// recovering enough of the private key to determine that they do, or do +// not, belong to the biased set (this practically requires solving +// discrete logarithm, which is conjecturally intractable). +// +// In practice, this means the bias is impossible to detect. + +// s + (x*L) % 8*L +// Guaranteed to fit in 256 bits iff s fits in 255 bits. +// L < 2^253 +// x%8 < 2^3 +// L * (x%8) < 2^255 +// s < 2^255 +// s + L * (x%8) < 2^256 +static void add_xl(u8 s[32], u8 x) +{ + u64 mod8 = x & 7; + u64 carry = 0; + FOR (i , 0, 8) { + carry = carry + load32_le(s + 4*i) + L[i] * mod8; + store32_le(s + 4*i, (u32)carry); + carry >>= 32; + } +} + +// "Small" dirty ephemeral key. +// Use if you need to shrink the size of the binary, and can afford to +// slow down by a factor of two (compared to the fast version) +// +// This version works by decoupling the cofactor from the main factor. +// +// - The trimmed scalar determines the main factor +// - The clamped bits of the scalar determine the cofactor. +// +// Cofactor and main factor are combined into a single scalar, which is +// then multiplied by a point of order 8*L (unlike the base point, which +// has prime order). That "dirty" base point is the addition of the +// regular base point (9), and a point of order 8. +void crypto_x25519_dirty_small(u8 public_key[32], const u8 secret_key[32]) +{ + // Base point of order 8*L + // Raw scalar multiplication with it does not clear the cofactor, + // and the resulting public key will reveal 3 bits of the scalar. + // + // The low order component of this base point has been chosen + // to yield the same results as crypto_x25519_dirty_fast(). + static const u8 dirty_base_point[32] = { + 0xd8, 0x86, 0x1a, 0xa2, 0x78, 0x7a, 0xd9, 0x26, + 0x8b, 0x74, 0x74, 0xb6, 0x82, 0xe3, 0xbe, 0xc3, + 0xce, 0x36, 0x9a, 0x1e, 0x5e, 0x31, 0x47, 0xa2, + 0x6d, 0x37, 0x7c, 0xfd, 0x20, 0xb5, 0xdf, 0x75, + }; + // separate the main factor & the cofactor of the scalar + u8 scalar[32]; + crypto_eddsa_trim_scalar(scalar, secret_key); + + // Separate the main factor and the cofactor + // + // The scalar is trimmed, so its cofactor is cleared. The three + // least significant bits however still have a main factor. We must + // remove it for X25519 compatibility. + // + // cofactor = lsb * L (modulo 8*L) + // combined = scalar + cofactor (modulo 8*L) + add_xl(scalar, secret_key[0]); + scalarmult(public_key, scalar, dirty_base_point, 256); + WIPE_BUFFER(scalar); +} + +// Select low order point +// We're computing the [cofactor]lop scalar multiplication, where: +// +// cofactor = tweak & 7. +// lop = (lop_x, lop_y) +// lop_x = sqrt((sqrt(d + 1) + 1) / d) +// lop_y = -lop_x * sqrtm1 +// +// The low order point has order 8. There are 4 such points. We've +// chosen the one whose both coordinates are positive (below p/2). +// The 8 low order points are as follows: +// +// [0]lop = ( 0 , 1 ) +// [1]lop = ( lop_x , lop_y) +// [2]lop = ( sqrt(-1), -0 ) +// [3]lop = ( lop_x , -lop_y) +// [4]lop = (-0 , -1 ) +// [5]lop = (-lop_x , -lop_y) +// [6]lop = (-sqrt(-1), 0 ) +// [7]lop = (-lop_x , lop_y) +// +// The x coordinate is either 0, sqrt(-1), lop_x, or their opposite. +// The y coordinate is either 0, -1 , lop_y, or their opposite. +// The pattern for both is the same, except for a rotation of 2 (modulo 8) +// +// This helper function captures the pattern, and we can use it thus: +// +// select_lop(x, lop_x, sqrtm1, cofactor); +// select_lop(y, lop_y, fe_one, cofactor + 2); +// +// This is faster than an actual scalar multiplication, +// and requires less code than naive constant time look up. +static void select_lop(fe out, const fe x, const fe k, u8 cofactor) +{ + fe tmp; + fe_0(out); + fe_ccopy(out, k , (cofactor >> 1) & 1); // bit 1 + fe_ccopy(out, x , (cofactor >> 0) & 1); // bit 0 + fe_neg (tmp, out); + fe_ccopy(out, tmp, (cofactor >> 2) & 1); // bit 2 + WIPE_BUFFER(tmp); +} + +// "Fast" dirty ephemeral key +// We use this one by default. +// +// This version works by performing a regular scalar multiplication, +// then add a low order point. The scalar multiplication is done in +// Edwards space for more speed (*2 compared to the "small" version). +// The cost is a bigger binary for programs that don't also sign messages. +void crypto_x25519_dirty_fast(u8 public_key[32], const u8 secret_key[32]) +{ + // Compute clean scalar multiplication + u8 scalar[32]; + ge pk; + crypto_eddsa_trim_scalar(scalar, secret_key); + ge_scalarmult_base(&pk, scalar); + + // Compute low order point + fe t1, t2; + select_lop(t1, lop_x, sqrtm1, secret_key[0]); + select_lop(t2, lop_y, fe_one, secret_key[0] + 2); + ge_precomp low_order_point; + fe_add(low_order_point.Yp, t2, t1); + fe_sub(low_order_point.Ym, t2, t1); + fe_mul(low_order_point.T2, t2, t1); + fe_mul(low_order_point.T2, low_order_point.T2, D2); + + // Add low order point to the public key + ge_madd(&pk, &pk, &low_order_point, t1, t2); + + // Convert to Montgomery u coordinate (we ignore the sign) + fe_add(t1, pk.Z, pk.Y); + fe_sub(t2, pk.Z, pk.Y); + fe_invert(t2, t2); + fe_mul(t1, t1, t2); + + fe_tobytes(public_key, t1); + + WIPE_BUFFER(t1); WIPE_CTX(&pk); + WIPE_BUFFER(t2); WIPE_CTX(&low_order_point); + WIPE_BUFFER(scalar); +} + +/////////////////// +/// Elligator 2 /// +/////////////////// +static const fe A = {486662}; + +// Elligator direct map +// +// Computes the point corresponding to a representative, encoded in 32 +// bytes (little Endian). Since positive representatives fits in 254 +// bits, The two most significant bits are ignored. +// +// From the paper: +// w = -A / (fe(1) + non_square * r^2) +// e = chi(w^3 + A*w^2 + w) +// u = e*w - (fe(1)-e)*(A//2) +// v = -e * sqrt(u^3 + A*u^2 + u) +// +// We ignore v because we don't need it for X25519 (the Montgomery +// ladder only uses u). +// +// Note that e is either 0, 1 or -1 +// if e = 0 u = 0 and v = 0 +// if e = 1 u = w +// if e = -1 u = -w - A = w * non_square * r^2 +// +// Let r1 = non_square * r^2 +// Let r2 = 1 + r1 +// Note that r2 cannot be zero, -1/non_square is not a square. +// We can (tediously) verify that: +// w^3 + A*w^2 + w = (A^2*r1 - r2^2) * A / r2^3 +// Therefore: +// chi(w^3 + A*w^2 + w) = chi((A^2*r1 - r2^2) * (A / r2^3)) +// chi(w^3 + A*w^2 + w) = chi((A^2*r1 - r2^2) * (A / r2^3)) * 1 +// chi(w^3 + A*w^2 + w) = chi((A^2*r1 - r2^2) * (A / r2^3)) * chi(r2^6) +// chi(w^3 + A*w^2 + w) = chi((A^2*r1 - r2^2) * (A / r2^3) * r2^6) +// chi(w^3 + A*w^2 + w) = chi((A^2*r1 - r2^2) * A * r2^3) +// Corollary: +// e = 1 if (A^2*r1 - r2^2) * A * r2^3) is a non-zero square +// e = -1 if (A^2*r1 - r2^2) * A * r2^3) is not a square +// Note that w^3 + A*w^2 + w (and therefore e) can never be zero: +// w^3 + A*w^2 + w = w * (w^2 + A*w + 1) +// w^3 + A*w^2 + w = w * (w^2 + A*w + A^2/4 - A^2/4 + 1) +// w^3 + A*w^2 + w = w * (w + A/2)^2 - A^2/4 + 1) +// which is zero only if: +// w = 0 (impossible) +// (w + A/2)^2 = A^2/4 - 1 (impossible, because A^2/4-1 is not a square) +// +// Let isr = invsqrt((A^2*r1 - r2^2) * A * r2^3) +// isr = sqrt(1 / ((A^2*r1 - r2^2) * A * r2^3)) if e = 1 +// isr = sqrt(sqrt(-1) / ((A^2*r1 - r2^2) * A * r2^3)) if e = -1 +// +// if e = 1 +// let u1 = -A * (A^2*r1 - r2^2) * A * r2^2 * isr^2 +// u1 = w +// u1 = u +// +// if e = -1 +// let ufactor = -non_square * sqrt(-1) * r^2 +// let vfactor = sqrt(ufactor) +// let u2 = -A * (A^2*r1 - r2^2) * A * r2^2 * isr^2 * ufactor +// u2 = w * -1 * -non_square * r^2 +// u2 = w * non_square * r^2 +// u2 = u +void crypto_elligator_map(u8 curve[32], const u8 hidden[32]) +{ + fe r, u, t1, t2, t3; + fe_frombytes_mask(r, hidden, 2); // r is encoded in 254 bits. + fe_sq(r, r); + fe_add(t1, r, r); + fe_add(u, t1, fe_one); + fe_sq (t2, u); + fe_mul(t3, A2, t1); + fe_sub(t3, t3, t2); + fe_mul(t3, t3, A); + fe_mul(t1, t2, u); + fe_mul(t1, t3, t1); + int is_square = invsqrt(t1, t1); + fe_mul(u, r, ufactor); + fe_ccopy(u, fe_one, is_square); + fe_sq (t1, t1); + fe_mul(u, u, A); + fe_mul(u, u, t3); + fe_mul(u, u, t2); + fe_mul(u, u, t1); + fe_neg(u, u); + fe_tobytes(curve, u); + + WIPE_BUFFER(t1); WIPE_BUFFER(r); + WIPE_BUFFER(t2); WIPE_BUFFER(u); + WIPE_BUFFER(t3); +} + +// Elligator inverse map +// +// Computes the representative of a point, if possible. If not, it does +// nothing and returns -1. Note that the success of the operation +// depends only on the point (more precisely its u coordinate). The +// tweak parameter is used only upon success +// +// The tweak should be a random byte. Beyond that, its contents are an +// implementation detail. Currently, the tweak comprises: +// - Bit 1 : sign of the v coordinate (0 if positive, 1 if negative) +// - Bit 2-5: not used +// - Bits 6-7: random padding +// +// From the paper: +// Let sq = -non_square * u * (u+A) +// if sq is not a square, or u = -A, there is no mapping +// Assuming there is a mapping: +// if v is positive: r = sqrt(-u / (non_square * (u+A))) +// if v is negative: r = sqrt(-(u+A) / (non_square * u )) +// +// We compute isr = invsqrt(-non_square * u * (u+A)) +// if it wasn't a square, abort. +// else, isr = sqrt(-1 / (non_square * u * (u+A)) +// +// If v is positive, we return isr * u: +// isr * u = sqrt(-1 / (non_square * u * (u+A)) * u +// isr * u = sqrt(-u / (non_square * (u+A)) +// +// If v is negative, we return isr * (u+A): +// isr * (u+A) = sqrt(-1 / (non_square * u * (u+A)) * (u+A) +// isr * (u+A) = sqrt(-(u+A) / (non_square * u) +int crypto_elligator_rev(u8 hidden[32], const u8 public_key[32], u8 tweak) +{ + fe t1, t2, t3; + fe_frombytes(t1, public_key); // t1 = u + + fe_add(t2, t1, A); // t2 = u + A + fe_mul(t3, t1, t2); + fe_mul_small(t3, t3, -2); + int is_square = invsqrt(t3, t3); // t3 = sqrt(-1 / non_square * u * (u+A)) + if (is_square) { + // The only variable time bit. This ultimately reveals how many + // tries it took us to find a representable key. + // This does not affect security as long as we try keys at random. + + fe_ccopy (t1, t2, tweak & 1); // multiply by u if v is positive, + fe_mul (t3, t1, t3); // multiply by u+A otherwise + fe_mul_small(t1, t3, 2); + fe_neg (t2, t3); + fe_ccopy (t3, t2, fe_isodd(t1)); + fe_tobytes(hidden, t3); + + // Pad with two random bits + hidden[31] |= tweak & 0xc0; + } + + WIPE_BUFFER(t1); + WIPE_BUFFER(t2); + WIPE_BUFFER(t3); + return is_square - 1; +} + +void crypto_elligator_key_pair(u8 hidden[32], u8 secret_key[32], u8 seed[32]) +{ + u8 pk [32]; // public key + u8 buf[64]; // seed + representative + COPY(buf + 32, seed, 32); + do { + crypto_chacha20_djb(buf, 0, 64, buf+32, zero, 0); + crypto_x25519_dirty_fast(pk, buf); // or the "small" version + } while(crypto_elligator_rev(buf+32, pk, buf[32])); + // Note that the return value of crypto_elligator_rev() is + // independent from its tweak parameter. + // Therefore, buf[32] is not actually reused. Either we loop one + // more time and buf[32] is used for the new seed, or we succeeded, + // and buf[32] becomes the tweak parameter. + + crypto_wipe(seed, 32); + COPY(hidden , buf + 32, 32); + COPY(secret_key, buf , 32); + WIPE_BUFFER(buf); + WIPE_BUFFER(pk); +} + +/////////////////////// +/// Scalar division /// +/////////////////////// + +// Montgomery reduction. +// Divides x by (2^256), and reduces the result modulo L +// +// Precondition: +// x < L * 2^256 +// Constants: +// r = 2^256 (makes division by r trivial) +// k = (r * (1/r) - 1) // L (1/r is computed modulo L ) +// Algorithm: +// s = (x * k) % r +// t = x + s*L (t is always a multiple of r) +// u = (t/r) % L (u is always below 2*L, conditional subtraction is enough) +static void redc(u32 u[8], u32 x[16]) +{ + static const u32 k[8] = { + 0x12547e1b, 0xd2b51da3, 0xfdba84ff, 0xb1a206f2, + 0xffa36bea, 0x14e75438, 0x6fe91836, 0x9db6c6f2, + }; + + // s = x * k (modulo 2^256) + // This is cheaper than the full multiplication. + u32 s[8] = {0}; + FOR (i, 0, 8) { + u64 carry = 0; + FOR (j, 0, 8-i) { + carry += s[i+j] + (u64)x[i] * k[j]; + s[i+j] = (u32)carry; + carry >>= 32; + } + } + u32 t[16] = {0}; + multiply(t, s, L); + + // t = t + x + u64 carry = 0; + FOR (i, 0, 16) { + carry += (u64)t[i] + x[i]; + t[i] = (u32)carry; + carry >>= 32; + } + + // u = (t / 2^256) % L + // Note that t / 2^256 is always below 2*L, + // So a constant time conditional subtraction is enough + remove_l(u, t+8); + + WIPE_BUFFER(s); + WIPE_BUFFER(t); +} + +void crypto_x25519_inverse(u8 blind_salt [32], const u8 private_key[32], + const u8 curve_point[32]) +{ + static const u8 Lm2[32] = { // L - 2 + 0xeb, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58, + 0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9, 0xde, 0x14, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, + }; + // 1 in Montgomery form + u32 m_inv [8] = { + 0x8d98951d, 0xd6ec3174, 0x737dcf70, 0xc6ef5bf4, + 0xfffffffe, 0xffffffff, 0xffffffff, 0x0fffffff, + }; + + u8 scalar[32]; + crypto_eddsa_trim_scalar(scalar, private_key); + + // Convert the scalar in Montgomery form + // m_scl = scalar * 2^256 (modulo L) + u32 m_scl[8]; + { + u32 tmp[16]; + ZERO(tmp, 8); + load32_le_buf(tmp+8, scalar, 8); + mod_l(scalar, tmp); + load32_le_buf(m_scl, scalar, 8); + WIPE_BUFFER(tmp); // Wipe ASAP to save stack space + } + + // Compute the inverse + u32 product[16]; + for (int i = 252; i >= 0; i--) { + ZERO(product, 16); + multiply(product, m_inv, m_inv); + redc(m_inv, product); + if (scalar_bit(Lm2, i)) { + ZERO(product, 16); + multiply(product, m_inv, m_scl); + redc(m_inv, product); + } + } + // Convert the inverse *out* of Montgomery form + // scalar = m_inv / 2^256 (modulo L) + COPY(product, m_inv, 8); + ZERO(product + 8, 8); + redc(m_inv, product); + store32_le_buf(scalar, m_inv, 8); // the *inverse* of the scalar + + // Clear the cofactor of scalar: + // cleared = scalar * (3*L + 1) (modulo 8*L) + // cleared = scalar + scalar * 3 * L (modulo 8*L) + // Note that (scalar * 3) is reduced modulo 8, so we only need the + // first byte. + add_xl(scalar, scalar[0] * 3); + + // Recall that 8*L < 2^256. However it is also very close to + // 2^255. If we spanned the ladder over 255 bits, random tests + // wouldn't catch the off-by-one error. + scalarmult(blind_salt, scalar, curve_point, 256); + + WIPE_BUFFER(scalar); WIPE_BUFFER(m_scl); + WIPE_BUFFER(product); WIPE_BUFFER(m_inv); +} + +//////////////////////////////// +/// Authenticated encryption /// +//////////////////////////////// +static void lock_auth(u8 mac[16], const u8 auth_key[32], + const u8 *ad , size_t ad_size, + const u8 *cipher_text, size_t text_size) +{ + u8 sizes[16]; // Not secret, not wiped + store64_le(sizes + 0, ad_size); + store64_le(sizes + 8, text_size); + crypto_poly1305_ctx poly_ctx; // auto wiped... + crypto_poly1305_init (&poly_ctx, auth_key); + crypto_poly1305_update(&poly_ctx, ad , ad_size); + crypto_poly1305_update(&poly_ctx, zero , gap(ad_size, 16)); + crypto_poly1305_update(&poly_ctx, cipher_text, text_size); + crypto_poly1305_update(&poly_ctx, zero , gap(text_size, 16)); + crypto_poly1305_update(&poly_ctx, sizes , 16); + crypto_poly1305_final (&poly_ctx, mac); // ...here +} + +void crypto_aead_init_x(crypto_aead_ctx *ctx, + u8 const key[32], const u8 nonce[24]) +{ + crypto_chacha20_h(ctx->key, key, nonce); + COPY(ctx->nonce, nonce + 16, 8); + ctx->counter = 0; +} + +void crypto_aead_init_djb(crypto_aead_ctx *ctx, + const u8 key[32], const u8 nonce[8]) +{ + COPY(ctx->key , key , 32); + COPY(ctx->nonce, nonce, 8); + ctx->counter = 0; +} + +void crypto_aead_init_ietf(crypto_aead_ctx *ctx, + const u8 key[32], const u8 nonce[12]) +{ + COPY(ctx->key , key , 32); + COPY(ctx->nonce, nonce + 4, 8); + ctx->counter = (u64)load32_le(nonce) << 32; +} + +void crypto_aead_write(crypto_aead_ctx *ctx, u8 *cipher_text, u8 mac[16], + const u8 *ad, size_t ad_size, + const u8 *plain_text, size_t text_size) +{ + u8 auth_key[64]; // the last 32 bytes are used for rekeying. + crypto_chacha20_djb(auth_key, 0, 64, ctx->key, ctx->nonce, ctx->counter); + crypto_chacha20_djb(cipher_text, plain_text, text_size, + ctx->key, ctx->nonce, ctx->counter + 1); + lock_auth(mac, auth_key, ad, ad_size, cipher_text, text_size); + COPY(ctx->key, auth_key + 32, 32); + WIPE_BUFFER(auth_key); +} + +int crypto_aead_read(crypto_aead_ctx *ctx, u8 *plain_text, const u8 mac[16], + const u8 *ad, size_t ad_size, + const u8 *cipher_text, size_t text_size) +{ + u8 auth_key[64]; // the last 32 bytes are used for rekeying. + u8 real_mac[16]; + crypto_chacha20_djb(auth_key, 0, 64, ctx->key, ctx->nonce, ctx->counter); + lock_auth(real_mac, auth_key, ad, ad_size, cipher_text, text_size); + int mismatch = crypto_verify16(mac, real_mac); + if (!mismatch) { + crypto_chacha20_djb(plain_text, cipher_text, text_size, + ctx->key, ctx->nonce, ctx->counter + 1); + COPY(ctx->key, auth_key + 32, 32); + } + WIPE_BUFFER(auth_key); + WIPE_BUFFER(real_mac); + return mismatch; +} + +void crypto_aead_lock(u8 *cipher_text, u8 mac[16], const u8 key[32], + const u8 nonce[24], const u8 *ad, size_t ad_size, + const u8 *plain_text, size_t text_size) +{ + crypto_aead_ctx ctx; + crypto_aead_init_x(&ctx, key, nonce); + crypto_aead_write(&ctx, cipher_text, mac, ad, ad_size, + plain_text, text_size); + crypto_wipe(&ctx, sizeof(ctx)); +} + +int crypto_aead_unlock(u8 *plain_text, const u8 mac[16], const u8 key[32], + const u8 nonce[24], const u8 *ad, size_t ad_size, + const u8 *cipher_text, size_t text_size) +{ + crypto_aead_ctx ctx; + crypto_aead_init_x(&ctx, key, nonce); + int mismatch = crypto_aead_read(&ctx, plain_text, mac, ad, ad_size, + cipher_text, text_size); + crypto_wipe(&ctx, sizeof(ctx)); + return mismatch; +} + +#ifdef MONOCYPHER_CPP_NAMESPACE +} +#endif diff --git a/firmware/matter/src/vendor/monocypher/monocypher.h b/firmware/matter/src/vendor/monocypher/monocypher.h new file mode 100644 index 0000000..cf635e8 --- /dev/null +++ b/firmware/matter/src/vendor/monocypher/monocypher.h @@ -0,0 +1,321 @@ +// Monocypher version __git__ +// +// This file is dual-licensed. Choose whichever licence you want from +// the two licences listed below. +// +// The first licence is a regular 2-clause BSD licence. The second licence +// is the CC-0 from Creative Commons. It is intended to release Monocypher +// to the public domain. The BSD licence serves as a fallback option. +// +// SPDX-License-Identifier: BSD-2-Clause OR CC0-1.0 +// +// ------------------------------------------------------------------------ +// +// Copyright (c) 2017-2019, Loup Vaillant +// All rights reserved. +// +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// 1. Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the +// distribution. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +// +// ------------------------------------------------------------------------ +// +// Written in 2017-2019 by Loup Vaillant +// +// To the extent possible under law, the author(s) have dedicated all copyright +// and related neighboring rights to this software to the public domain +// worldwide. This software is distributed without any warranty. +// +// You should have received a copy of the CC0 Public Domain Dedication along +// with this software. If not, see +// + +#ifndef MONOCYPHER_H +#define MONOCYPHER_H + +#include +#include + +#ifdef MONOCYPHER_CPP_NAMESPACE +namespace MONOCYPHER_CPP_NAMESPACE { +#elif defined(__cplusplus) +extern "C" { +#endif + +// Constant time comparisons +// ------------------------- + +// Return 0 if a and b are equal, -1 otherwise +int crypto_verify16(const uint8_t a[16], const uint8_t b[16]); +int crypto_verify32(const uint8_t a[32], const uint8_t b[32]); +int crypto_verify64(const uint8_t a[64], const uint8_t b[64]); + + +// Erase sensitive data +// -------------------- +void crypto_wipe(void *secret, size_t size); + + +// Authenticated encryption +// ------------------------ +void crypto_aead_lock(uint8_t *cipher_text, + uint8_t mac [16], + const uint8_t key [32], + const uint8_t nonce[24], + const uint8_t *ad, size_t ad_size, + const uint8_t *plain_text, size_t text_size); +int crypto_aead_unlock(uint8_t *plain_text, + const uint8_t mac [16], + const uint8_t key [32], + const uint8_t nonce[24], + const uint8_t *ad, size_t ad_size, + const uint8_t *cipher_text, size_t text_size); + +// Authenticated stream +// -------------------- +typedef struct { + uint64_t counter; + uint8_t key[32]; + uint8_t nonce[8]; +} crypto_aead_ctx; + +void crypto_aead_init_x(crypto_aead_ctx *ctx, + const uint8_t key[32], const uint8_t nonce[24]); +void crypto_aead_init_djb(crypto_aead_ctx *ctx, + const uint8_t key[32], const uint8_t nonce[8]); +void crypto_aead_init_ietf(crypto_aead_ctx *ctx, + const uint8_t key[32], const uint8_t nonce[12]); + +void crypto_aead_write(crypto_aead_ctx *ctx, + uint8_t *cipher_text, + uint8_t mac[16], + const uint8_t *ad , size_t ad_size, + const uint8_t *plain_text, size_t text_size); +int crypto_aead_read(crypto_aead_ctx *ctx, + uint8_t *plain_text, + const uint8_t mac[16], + const uint8_t *ad , size_t ad_size, + const uint8_t *cipher_text, size_t text_size); + + +// General purpose hash (BLAKE2b) +// ------------------------------ + +// Direct interface +void crypto_blake2b(uint8_t *hash, size_t hash_size, + const uint8_t *message, size_t message_size); + +void crypto_blake2b_keyed(uint8_t *hash, size_t hash_size, + const uint8_t *key, size_t key_size, + const uint8_t *message, size_t message_size); + +// Incremental interface +typedef struct { + // Do not rely on the size or contents of this type, + // for they may change without notice. + uint64_t hash[8]; + uint64_t input_offset[2]; + uint64_t input[16]; + size_t input_idx; + size_t hash_size; +} crypto_blake2b_ctx; + +void crypto_blake2b_init(crypto_blake2b_ctx *ctx, size_t hash_size); +void crypto_blake2b_keyed_init(crypto_blake2b_ctx *ctx, size_t hash_size, + const uint8_t *key, size_t key_size); +void crypto_blake2b_update(crypto_blake2b_ctx *ctx, + const uint8_t *message, size_t message_size); +void crypto_blake2b_final(crypto_blake2b_ctx *ctx, uint8_t *hash); + + +// Password key derivation (Argon2) +// -------------------------------- +#define CRYPTO_ARGON2_D 0 +#define CRYPTO_ARGON2_I 1 +#define CRYPTO_ARGON2_ID 2 + +typedef struct { + uint32_t algorithm; // Argon2d, Argon2i, Argon2id + uint32_t nb_blocks; // memory hardness, >= 8 * nb_lanes + uint32_t nb_passes; // CPU hardness, >= 1 (>= 3 recommended for Argon2i) + uint32_t nb_lanes; // parallelism level (single threaded anyway) +} crypto_argon2_config; + +typedef struct { + const uint8_t *pass; + const uint8_t *salt; + uint32_t pass_size; + uint32_t salt_size; // 16 bytes recommended +} crypto_argon2_inputs; + +typedef struct { + const uint8_t *key; // may be NULL if no key + const uint8_t *ad; // may be NULL if no additional data + uint32_t key_size; // 0 if no key (32 bytes recommended otherwise) + uint32_t ad_size; // 0 if no additional data +} crypto_argon2_extras; + +extern const crypto_argon2_extras crypto_argon2_no_extras; + +void crypto_argon2(uint8_t *hash, uint32_t hash_size, void *work_area, + crypto_argon2_config config, + crypto_argon2_inputs inputs, + crypto_argon2_extras extras); + + +// Key exchange (X-25519) +// ---------------------- + +// Shared secrets are not quite random. +// Hash them to derive an actual shared key. +void crypto_x25519_public_key(uint8_t public_key[32], + const uint8_t secret_key[32]); +void crypto_x25519(uint8_t raw_shared_secret[32], + const uint8_t your_secret_key [32], + const uint8_t their_public_key [32]); + +// Conversion to EdDSA +void crypto_x25519_to_eddsa(uint8_t eddsa[32], const uint8_t x25519[32]); + +// scalar "division" +// Used for OPRF. Be aware that exponential blinding is less secure +// than Diffie-Hellman key exchange. +void crypto_x25519_inverse(uint8_t blind_salt [32], + const uint8_t private_key[32], + const uint8_t curve_point[32]); + +// "Dirty" versions of x25519_public_key(). +// Use with crypto_elligator_rev(). +// Leaks 3 bits of the private key. +void crypto_x25519_dirty_small(uint8_t pk[32], const uint8_t sk[32]); +void crypto_x25519_dirty_fast (uint8_t pk[32], const uint8_t sk[32]); + + +// Signatures +// ---------- + +// EdDSA with curve25519 + BLAKE2b +void crypto_eddsa_key_pair(uint8_t secret_key[64], + uint8_t public_key[32], + uint8_t seed[32]); +void crypto_eddsa_sign(uint8_t signature [64], + const uint8_t secret_key[64], + const uint8_t *message, size_t message_size); +int crypto_eddsa_check(const uint8_t signature [64], + const uint8_t public_key[32], + const uint8_t *message, size_t message_size); + +// Conversion to X25519 +void crypto_eddsa_to_x25519(uint8_t x25519[32], const uint8_t eddsa[32]); + +// EdDSA building blocks +void crypto_eddsa_trim_scalar(uint8_t out[32], const uint8_t in[32]); +void crypto_eddsa_reduce(uint8_t reduced[32], const uint8_t expanded[64]); +void crypto_eddsa_mul_add(uint8_t r[32], + const uint8_t a[32], + const uint8_t b[32], + const uint8_t c[32]); +void crypto_eddsa_scalarbase(uint8_t point[32], const uint8_t scalar[32]); +int crypto_eddsa_check_equation(const uint8_t signature[64], + const uint8_t public_key[32], + const uint8_t h_ram[32]); + + +// Chacha20 +// -------- + +// Specialised hash. +// Used to hash X25519 shared secrets. +void crypto_chacha20_h(uint8_t out[32], + const uint8_t key[32], + const uint8_t in [16]); + +// Unauthenticated stream cipher. +// Don't forget to add authentication. +uint64_t crypto_chacha20_djb(uint8_t *cipher_text, + const uint8_t *plain_text, + size_t text_size, + const uint8_t key[32], + const uint8_t nonce[8], + uint64_t ctr); +uint32_t crypto_chacha20_ietf(uint8_t *cipher_text, + const uint8_t *plain_text, + size_t text_size, + const uint8_t key[32], + const uint8_t nonce[12], + uint32_t ctr); +uint64_t crypto_chacha20_x(uint8_t *cipher_text, + const uint8_t *plain_text, + size_t text_size, + const uint8_t key[32], + const uint8_t nonce[24], + uint64_t ctr); + + +// Poly 1305 +// --------- + +// This is a *one time* authenticator. +// Disclosing the mac reveals the key. +// See crypto_lock() on how to use it properly. + +// Direct interface +void crypto_poly1305(uint8_t mac[16], + const uint8_t *message, size_t message_size, + const uint8_t key[32]); + +// Incremental interface +typedef struct { + // Do not rely on the size or contents of this type, + // for they may change without notice. + uint8_t c[16]; // chunk of the message + size_t c_idx; // How many bytes are there in the chunk. + uint32_t r [4]; // constant multiplier (from the secret key) + uint32_t pad[4]; // random number added at the end (from the secret key) + uint32_t h [5]; // accumulated hash +} crypto_poly1305_ctx; + +void crypto_poly1305_init (crypto_poly1305_ctx *ctx, const uint8_t key[32]); +void crypto_poly1305_update(crypto_poly1305_ctx *ctx, + const uint8_t *message, size_t message_size); +void crypto_poly1305_final (crypto_poly1305_ctx *ctx, uint8_t mac[16]); + + +// Elligator 2 +// ----------- + +// Elligator mappings proper +void crypto_elligator_map(uint8_t curve [32], const uint8_t hidden[32]); +int crypto_elligator_rev(uint8_t hidden[32], const uint8_t curve [32], + uint8_t tweak); + +// Easy to use key pair generation +void crypto_elligator_key_pair(uint8_t hidden[32], uint8_t secret_key[32], + uint8_t seed[32]); + +#ifdef __cplusplus +} +#endif + +#endif // MONOCYPHER_H diff --git a/firmware/matter/src/vendor/monocypher/provenance.json b/firmware/matter/src/vendor/monocypher/provenance.json new file mode 100644 index 0000000..3d952f4 --- /dev/null +++ b/firmware/matter/src/vendor/monocypher/provenance.json @@ -0,0 +1,25 @@ +{ + "version": "4.0.3", + "files": { + "monocypher.c": { + "url": "https://raw.githubusercontent.com/LoupVaillant/Monocypher/4.0.3/src/monocypher.c", + "sha256": "f1f838cdd483bdebe0df0ff5c5ed60535e496f769c6a2f933ac4c0b114207123" + }, + "monocypher.h": { + "url": "https://raw.githubusercontent.com/LoupVaillant/Monocypher/4.0.3/src/monocypher.h", + "sha256": "fcaf6ed771358bb4f40fba016f6518ae86ec02b1b877d2cc35ad92d3a26fd7b3" + }, + "monocypher-ed25519.c": { + "url": "https://raw.githubusercontent.com/LoupVaillant/Monocypher/4.0.3/src/optional/monocypher-ed25519.c", + "sha256": "ce0d2f8e32ca8f66398ba5b3456cc74327c3eff14e7b950ce7d57be9025cc453" + }, + "monocypher-ed25519.h": { + "url": "https://raw.githubusercontent.com/LoupVaillant/Monocypher/4.0.3/src/optional/monocypher-ed25519.h", + "sha256": "3a3035181f991a158d0e1c7567258f0bae8ba0f1f23c5512b4a1db1b3c9730ce" + }, + "LICENCE.md": { + "url": "https://raw.githubusercontent.com/LoupVaillant/Monocypher/4.0.3/LICENCE.md", + "sha256": "a5781770269d2516e52ba4863f790c10a16da4089a1e81823aee19ff1e9026b0" + } + } +} diff --git a/firmware/matter/tests/relay_engine_tests.cpp b/firmware/matter/tests/relay_engine_tests.cpp index 5da3233..7373ad8 100644 --- a/firmware/matter/tests/relay_engine_tests.cpp +++ b/firmware/matter/tests/relay_engine_tests.cpp @@ -19,9 +19,14 @@ static void confirmed(RelayEngine & e, uint32_t id, bool on, uint64_t at) { auto s=e.Snapshot(); check(s.completedId==id&&s.result==RelayResult::Confirmed&&s.on==on,"fresh confirmation"); } static void power(RelayEngine & e,uint16_t raw,uint64_t at) { - McuTx tx; check(e.NextTx(at,tx),"poll available"); - if(tx.bytes[1]==0xa4) { state(e,true,at); check(e.NextTx(at+1,tx),"power after relay"); } - check(tx.bytes[1]==0xa7,"power query only"); + McuTx tx; bool found=false; + for (unsigned i=0;i<6;++i) { + check(e.NextTx(at+i,tx),"poll available"); + if(tx.bytes[1]==0xa4) { state(e,true,at+i); } + else if(tx.bytes[1]==0xa7) { found=true; break; } + else { uint8_t reply[]={0xff,0,0,0x55}; for(auto b:reply)e.Feed(b,at+i); } + } + check(found,"power query only"); uint8_t frame[]={0xff,uint8_t(raw),uint8_t(raw>>8),0x55}; for(auto b:frame)e.Feed(b,at+2); } int main() { @@ -58,5 +63,28 @@ int main() { 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); check(late.Snapshot().result==RelayResult::Timeout,"expired replies do not confirm"); + RelayEngine meter; + // Numerical replies have no command ID: accept only one pending query. + check(meter.NextTx(0,tx)&&tx.bytes[1]==0xa4,"initial relay query");state(meter,false,1); + uint8_t unsolicited[]={0xff,0x40,0x1b,0x55};for(auto b:unsolicited)meter.Feed(b,20); + check(!meter.Snapshot().voltageValid,"unsolicited number ignored"); + check(meter.NextTx(500,tx)&&tx.bytes[1]==0xa5,"voltage poll"); + for(auto b:unsolicited)meter.Feed(b,510); // 109.0 V = 6976 / 64. + check(meter.Snapshot().voltageRaw==6976&&meter.Snapshot().voltageValid,"fragmented voltage raw"); + check(meter.NextTx(1500,tx)&&tx.bytes[1]==0xa6,"RMS current poll"); + uint8_t amps[]={0xff,0x80,0x01,0x55};for(auto b:amps)meter.Feed(b,1510); + check(meter.Snapshot().currentRaw==384&&meter.Snapshot().currentValid,"1.5 A raw"); + check(meter.NextTx(5000,tx)&&tx.bytes[1]==0xa4,"relay keeps priority");state(meter,false,5001); + check(meter.NextTx(5002,tx)&&tx.bytes[1]==0xa7,"power measurement"); + uint8_t watts[]={0xff,0xf4,1,0x55};for(auto b:watts)meter.Feed(b,5003); + check(meter.Snapshot().powerRaw==500&&meter.Snapshot().powerValid,"500 W raw"); + check(meter.NextTx(5500,tx)&&tx.bytes[1]==0xa5,"second voltage poll"); + uint8_t invalid[]={0xff,0xff,0xff,0x55};for(auto b:invalid)meter.Feed(b,5501); + check(!meter.Snapshot().voltageValid,"out of range voltage unavailable"); + check(meter.NextTx(6500,tx)&&tx.bytes[1]==0xa6,"second current poll"); + meter.NextTx(7100,tx);check(!meter.Snapshot().currentValid,"query timeout invalidates current"); + for(auto b:amps)meter.Feed(b,7110); + check(!meter.Snapshot().currentValid,"late response ignored during quiet interval"); + meter.Disconnect();check(!meter.Snapshot().powerValid,"disconnect invalidates metering"); std::printf("relay_engine=PASS checks=%d commands=100\n",checks);return 0; } diff --git a/firmware/matter/tools/build_matter_device_fixture.py b/firmware/matter/tools/build_matter_device_fixture.py index 8a5b3f6..993f5b7 100644 --- a/firmware/matter/tools/build_matter_device_fixture.py +++ b/firmware/matter/tools/build_matter_device_fixture.py @@ -1,12 +1,17 @@ #!/usr/bin/env python3 """Isolated full MIPS VM: native Matter initialization on br0 under 8 MiB AS.""" -import hashlib,json,stat,subprocess +import hashlib,json,stat,subprocess,os,struct from pathlib import Path OUT=Path('/work');ART=OUT/'artifacts/softap-device-code';EV=OUT/'evidence/softap-device-code' EV.mkdir(parents=True,exist_ok=True) +persistent=os.environ.get('CK_UPDATE_TEST')=='1' +extra=[] +if persistent: + ART=OUT/'artifacts/update-matter-test' + extra=['-DCK_TESTING',*[str(OUT/'src/update'/p) for p in ('ck_store.c','ck_flash.c','ck_common.c')]] cc=str(OUT/'toolchains/uclibc/bin/mips-linux-uclibc-gcc') subprocess.run([cc,'-Os','-static','-mno-shared','-mno-llsc','-mno-load-store-pairs','-mmemcpy', - str(OUT/'src/matter_device_init.c'),'-o',str(ART/'vm-init')],check=True) + str(OUT/'src/matter_device_init.c'),*extra,'-o',str(ART/'vm-init')],check=True) records=[] def entry(name,data=b'',mode=stat.S_IFREG|0o755,rdev=(0,0)): name=name.encode()+b'\0';fields=[len(records)+1,mode,0,0,1,0,len(data),0,0,*rdev,len(name),0] @@ -17,6 +22,14 @@ def entry(name,data=b'',mode=stat.S_IFREG|0o755,rdev=(0,0)): entry('dev/'+name,mode=stat.S_IFCHR|mode,rdev=device) 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: + 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) + config=json.loads((Path('/repo/version.json')).read_text()) + entry('etc/ck-update.meta',struct.pack('>III',0x434b0004,1,config['software_version']),mode=stat.S_IFREG|0o444) + entry('etc/matter-diag.key',(OUT/'private/diag.key').read_bytes(),mode=stat.S_IFREG|0o600) + fixture=bytearray(bytes(range(256))*(0x800000//256));fixture[0x3c0000:0x3e0000]=b'\xff'*0x20000 + entry('flash.bin',fixture,mode=stat.S_IFREG|0o600) entry('init',(ART/'vm-init').read_bytes());entry('bin/matterd-test',(ART/'matterd-test').read_bytes()) for name in ['mii.ko','pcnet32.ko']:entry(name,(OUT/'deps/downloads'/name).read_bytes()) entry('TRAILER!!!',mode=0);(ART/'vm.cpio').write_bytes(b''.join(records)) diff --git a/firmware/matter/tools/build_uclibc_matter_device.sh b/firmware/matter/tools/build_uclibc_matter_device.sh index c9bea24..e3adfdf 100644 --- a/firmware/matter/tools/build_uclibc_matter_device.sh +++ b/firmware/matter/tools/build_uclibc_matter_device.sh @@ -5,14 +5,29 @@ cd /work export PATH=/work/.sdk-venv/bin:$PATH mkdir -p evidence/softap evidence/runtime-v2 evidence/softap-device-code dir=/work/artifacts/matter-app-uclibc-device -gn gen "$dir" --root=/work/src/matter-app-device --args='target_os="linux" target_cpu="mips" custom_toolchain="custom" target_cc="/work/toolchains/uclibc/bin/mips-linux-uclibc-gcc -mno-shared -mno-llsc -mno-load-store-pairs -mmemcpy -fno-builtin-memcpy -fno-builtin-memmove" target_cxx="/work/toolchains/uclibc/bin/mips-linux-uclibc-g++ -mno-shared -mno-llsc -mno-load-store-pairs -mmemcpy -fno-builtin-memcpy -fno-builtin-memmove" target_ar="/work/toolchains/uclibc/bin/mips-linux-uclibc-ar" is_clang=false is_debug=false optimize_for_size=true enable_pic=false enable_pie=false chip_device_platform="linux" treat_warnings_as_errors=false incipio_uclibc_runtime=true chip_target_is_big_endian=true' +extra=${CK_GN_EXTRA:-} +if [ "${CK_UPDATE_TEST:-0}" = 1 ]; then dir=/work/artifacts/matter-app-uclibc-update-test; fi +gn gen "$dir" --root=/work/src/matter-app-device --args='target_os="linux" target_cpu="mips" custom_toolchain="custom" target_cc="/work/toolchains/uclibc/bin/mips-linux-uclibc-gcc -mno-shared -mno-llsc -mno-load-store-pairs -mmemcpy -fno-builtin-memcpy -fno-builtin-memmove" target_cxx="/work/toolchains/uclibc/bin/mips-linux-uclibc-g++ -mno-shared -mno-llsc -mno-load-store-pairs -mmemcpy -fno-builtin-memcpy -fno-builtin-memmove" target_ar="/work/toolchains/uclibc/bin/mips-linux-uclibc-ar" is_clang=false is_debug=false optimize_for_size=true enable_pic=false enable_pie=false chip_device_platform="linux" treat_warnings_as_errors=false incipio_uclibc_runtime=true chip_target_is_big_endian=true'" $extra" ninja -C "$dir" -j8 matterd-test -mkdir -p artifacts/softap-device-code -cp "$dir/obj/bin/matterd-test" artifacts/softap-device-code/matterd-test.full -toolchains/uclibc/bin/mips-linux-uclibc-strip -o artifacts/softap-device-code/matterd-test artifacts/softap-device-code/matterd-test.full -python3 - <<'PY' +dest=artifacts/softap-device-code +if [ "${CK_UPDATE_TEST:-0}" = 1 ]; then dest=artifacts/update-matter-test; fi +mkdir -p "$dest" +cp "$dir/obj/bin/matterd-test" "$dest/matterd-test.full" +toolchains/uclibc/bin/mips-linux-uclibc-strip -o "$dest/matterd-test" "$dest/matterd-test.full" +CK_BUILD_DIR="$dir/gen/include/core/CHIPBuildConfig.h" python3 - <<'PY' from pathlib import Path -text = Path('/work/artifacts/matter-app-uclibc-device/gen/include/core/CHIPBuildConfig.h').read_text() +text = Path(__import__('os').environ['CK_BUILD_DIR']).read_text() assert '#define CHIP_CONFIG_BIG_ENDIAN_TARGET 1' in text PY -sha256sum artifacts/softap-device-code/matterd-test +sha256sum "$dest/matterd-test" +CK_BUILD_DEST="$dest" CK_BUILD_DIR="$dir" python3 - <<'PY' +import hashlib,json,os,re +from pathlib import Path +dest=Path(os.environ['CK_BUILD_DEST']) +header=Path('src/matter-app-device/Version.h').read_text() +args=Path(os.environ['CK_BUILD_DIR'])/'args.gn' +test=bool(re.search(r'incipio_update_test\s*=\s*true',args.read_text())) +record={'software_version':int(re.search(r'CK_SOFTWARE_VERSION (\d+)',header)[1]), + 'test_backend':test,'binary_sha256':hashlib.sha256((dest/'matterd-test').read_bytes()).hexdigest()} +(dest/'matter-build.json').write_text(json.dumps(record,indent=2)+'\n') +PY diff --git a/firmware/matter/tools/build_updates.sh b/firmware/matter/tools/build_updates.sh new file mode 100644 index 0000000..f748d64 --- /dev/null +++ b/firmware/matter/tools/build_updates.sh @@ -0,0 +1,27 @@ +#!/bin/sh +set -eu +cd /work +export PATH=/work/toolchains/uclibc/bin:$PATH +cross=/work/toolchains/uclibc/bin/mips-linux-uclibc- +dest=artifacts/update +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' +${cross}gcc $flags $common src/update/ck_updater.c -o "$dest/ck-updater" +${cross}gcc $flags $common src/update/ck_updated.c -o "$dest/ck-updated" +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" +CK_BUILD_DEST="$dest" python3 - <<'PY' +import re,os +from pathlib import Path +s=(Path(os.environ['CK_BUILD_DEST'])/'disassembly.txt').read_text() +bad=re.findall(r'\s(?:ll|sc|sync|rdhwr|lwl|lwr|swl|swr|lwc1|swc1|ldc1|sdc1|pref)\s[^\n]*',s) +if bad:raise SystemExit('Unsupported ISA: '+repr(bad[:8])) +print('Update services built; ISA audit passed.') +PY diff --git a/firmware/matter/tools/test_device_vm.py b/firmware/matter/tools/test_device_vm.py index 8bf4129..7e38e69 100644 --- a/firmware/matter/tools/test_device_vm.py +++ b/firmware/matter/tools/test_device_vm.py @@ -1,17 +1,20 @@ #!/usr/bin/env python3 """Real MIPS Matter stack and kernel TTY, with a simulated MCU; no outlet access.""" -import hashlib,json,re,socket,subprocess,threading,time +import hashlib,json,re,socket,subprocess,threading,time,os,sys from datetime import datetime from pathlib import Path OUT=Path(__file__).resolve().parents[1] NAME='incipio-matter-device-vm' EV=OUT/'evidence/softap-device-code' +PERSISTENT='--persistent' in sys.argv +ART='artifacts/update-matter-test' if PERSISTENT else 'artifacts/softap-device-code' +DEPS=Path(os.environ.get('CK_SDK_DIR',str(OUT/'deps'))) def sha(p):return hashlib.sha256(p.read_bytes()).hexdigest() class Mcu: def __init__(self): - self.on=False;self.drop=False;self.done=False;self.bad=[];self.frames=[];self.socket=None + self.meter_drop=False;self.voltage=6976;self.current=384;self.power=500;self.on=False;self.drop=False;self.done=False;self.bad=[];self.frames=[];self.socket=None def event(self,on): self.on=on self.socket.sendall(bytes([0xfe,0x31 if on else 0x30,0x31 if on else 0x30,0x56])) @@ -33,11 +36,13 @@ def run(self): code=frame[1] expected=bytearray([0xff,code,0,0xe0,0x4c,0x81,0x96,0xc1,0xc1,0,0x1b,0x32,0,0x55]) if code==0xa1:expected[9]=frame[9];expected[11]=0 - if code not in (0xa1,0xa4,0xa7) or frame!=bytes(expected):self.bad.append(frame.hex());continue + if code not in (0xa1,0xa4,0xa5,0xa6,0xa7) or frame!=bytes(expected):self.bad.append(frame.hex());continue self.frames.append({'command':hex(code),'on':frame[9]==0x31 if code==0xa1 else None}) if code==0xa1:self.on=frame[9]==0x31 elif code==0xa4 and not self.drop:self.event(self.on) - elif code==0xa7:self.socket.sendall(bytes([0xff,6 if self.on else 0,0,0x55])) + elif code in (0xa5,0xa6,0xa7) and not self.meter_drop: + raw={0xa5:self.voltage,0xa6:self.current,0xa7:self.power}[code] + self.socket.sendall(bytes([0xff,raw&255,raw>>8,0x55])) def main(): stamp=datetime.now().strftime('%Y%m%d-%H%M%S') @@ -49,17 +54,17 @@ def main(): identity={'setup_pin':struct.unpack_from('>I',record,12)[0]} build=json.loads((OUT/'evidence/chip-tool-macos/build.json').read_text()) binary=Path(build['binary']);assert sha(binary)==build['binary_sha256'] - cmd=['docker','run','--rm','--name',NAME,'-p','127.0.0.1:55640:55640/udp','-p','127.0.0.1:15551:15551', - '-v',str(OUT)+':/work:ro','incipio-public-build:v0.1.0-preview','qemu-system-mips', + cmd=['docker','run','-i','--rm','--name',NAME,'-p','127.0.0.1:55640:55640/udp','-p','127.0.0.1:15551:15551', + '-v',str(OUT)+':/work:ro','-v',os.environ.get('CK_SDK_DIR',str(OUT/'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/softap-device-code/vm.cpio', + '-kernel','/work/deps/downloads/vmlinux-4.19.0-21-4kc-malta','-initrd','/work/'+ART+'/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','-device','pcnet,netdev=n,romfile=,mac=52:54:00:12:34:56'] report={'status':'failed','environment':'MIPS Malta/24Kc Linux 4.19 VM, 64 MiB RAM, 8 MiB daemon AS; simulated serial MCU', - 'binary_sha256':sha(OUT/'artifacts/softap-device-code/matterd-test'),'source_sha256':{str(p.relative_to(OUT)):sha(p) for p in + 'binary_sha256':sha(OUT/ART/'matterd-test'),'source_sha256':{str(p.relative_to(OUT)):sha(p) for p in [OUT/'src/relay/RelayEngine.cpp',OUT/'src/relay/McuController.cpp',OUT/'src/matter-app-device/main.cpp',Path(__file__)]}, - 'original_hardware_test':False,'flash_access':False,'full_commissioning':False,'checks':[]} + 'original_hardware_test':False,'flash_access':'regular-file journal fixture' if PERSISTENT else False,'full_commissioning':False,'checks':[]} proxies=[];proxy_nodes=set();stop=threading.Event();mcu=Mcu() def proxy_loop(): # The VM's multicast cannot traverse user-mode NAT. Publish the SDK's @@ -76,13 +81,19 @@ def tool(name,arguments,timeout=45): log=private/(name+'.log') with log.open('x') as sink: log.chmod(0o600) - child=subprocess.run([str(binary),*arguments,'--storage-directory',str(private),'--timeout','30'], + child=subprocess.run([str(binary),*arguments,'--storage-directory',str(private),*([] if '--timeout' in arguments else ['--timeout','30'])], stdout=sink,stderr=subprocess.STDOUT,timeout=timeout) report.setdefault('commands',[]).append({'name':name,'returncode':child.returncode,'log':str(log.relative_to(OUT)),'sha256':sha(log)}) return child.returncode,log.read_text(errors='replace') with console.open('w') as sink: - vm=subprocess.Popen(cmd,stdout=sink,stderr=subprocess.STDOUT) - worker=threading.Thread(target=mcu.run,daemon=True);worker.start() + vm=subprocess.Popen(cmd,stdin=subprocess.PIPE if PERSISTENT else None,stdout=sink,stderr=subprocess.STDOUT) + def mcu_when_ready(): + until=time.monotonic()+25 + while time.monotonic()III',0x434b0004,1,1)) + (work/'etc/matter-device.key').write_bytes(os.urandom(32)) + (work/'etc/matter-diag.key').write_text(os.urandom(32).hex()+'\n') + image=bytearray(bytes(range(256))*(0x800000//256));image[0x3c0000:0x3e0000]=b'\xff'*0x20000 + (work/'flash.bin').write_bytes(image);(work/'update.ota').write_bytes(package(key,image)) + docker=['docker','run','--rm','--network','none','--pids-limit','32', + '-e','CK_TEST_ROOT=/fixture','-e','CK_FLASH_FILE=/fixture/flash.bin', + '-v',str(work)+':/fixture','-v',str(ROOT)+':/repo:ro'] + binary='/repo/firmware/matter/artifacts/update-test/ck-updater' + 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'] + 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) + command=[*docker,'--cap-add','SYS_ADMIN','--cap-add','SYS_BOOT','--security-opt','seccomp=unconfined', + '--security-opt','apparmor=unconfined','--name','incipio-ota-pid1-test','incipio-public-build:v0.1.0-preview','sh','-c', + 'mkdir -p /var/run/ck-ota && mount -t ramfs ramfs /var/run/ck-ota && cp /fixture/apply-native /var/run/ck-ota/apply && exec /var/run/ck-ota/apply --pid1'] + try:result=subprocess.run(command,capture_output=True,timeout=45) + finally:subprocess.run(['docker','rm','-f','incipio-ota-pid1-test'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) + after=(work/'flash.bin').read_bytes() + assert after[START:END]==rootfs(),f'PID1 path did not complete, exit={result.returncode}: {result.stderr[-1500:]!r}' + assert before[:START]==after[:START] and before[END:]==after[END:] + report={'status':'pass','environment':'native Linux PID1 in private container namespaces; regular-file NOR fixture; separate MIPS writer suite', + 'checks':['real process quiesce and pivot_root branch','updater executable copied to ramfs', + 'rootfs written and read back','all protected bytes and journal unchanged','namespace reboot'], + 'physical_device_access':False,'container_exit_code':result.returncode, + 'updater_sha256':hashlib.sha256((work/'apply-native').read_bytes()).hexdigest()} + print(json.dumps(report,indent=2)) + +if __name__=='__main__':main() diff --git a/tests/test_updates.py b/tests/test_updates.py new file mode 100644 index 0000000..ce2a43e --- /dev/null +++ b/tests/test_updates.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Execute the C journal, verifier, writer, and HTTP server on a NOR fixture. + +Run in Linux: python3 tests/test_updates.py. CK_TESTING has no physical MTD path. +""" +import ctypes +import hashlib +import http.client +import json +import os +import socket +import shlex +import struct +import subprocess +import sys +import tempfile +import time +import unittest +from pathlib import Path +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives import serialization + +ROOT=Path(__file__).resolve().parents[1] +SOURCE=ROOT/'firmware/matter/src' +START,END,JOURNAL=0x130000,0x3c0000,0x3c0000 + +def public(key):return key.public_key().public_bytes(serialization.Encoding.Raw,serialization.PublicFormat.Raw) +def rootfs(seed=17): + data=bytearray(b'\xff'*(END-START));data[:1026]=bytes([seed])*1026 + data[:4]=b'hsqs';struct.pack_into('>I',data,8,1026-0x282);struct.pack_into('H',data[:1026])) + struct.pack_into('>H',data,1024,(-total)&65535);return bytes(data) +def package(key,image,version=2,variant=1,model=0x434b0004): + payload=rootfs();h=bytearray(256) + struct.pack_into('>8s6I',h,0,b'CKOTA1\r\n',1,model,variant,version,len(payload),0) + h[32:64]=hashlib.sha256(payload).digest();h[64:96]=hashlib.sha256(image[0x10000:START]).digest() + h[96:128]=hashlib.sha256(image[0x410000:0x7e0000]).digest();h[128:160]=public(key);h[160:165]=b'0.2.0';h[192:]=key.sign(h[:192]) + return bytes(h)+payload + +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_ota.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) + for output,source in [('updater','ck_updater.c'),('server','ck_updated.c')]: + subprocess.run(flags+common+[str(SOURCE/'update'/source),'-o',str(cls.bin/output)],check=True) + cls.runner=shlex.split(os.environ.get('CK_TEST_RUNNER','')) + cls.program_dir=Path(os.environ.get('CK_TEST_BIN',str(cls.bin))) + cls.updater=cls.program_dir/('ck-updater' if cls.runner else 'updater') + cls.server_binary=cls.program_dir/('ck-updated' if cls.runner else 'server') + cls.lib=ctypes.CDLL(str(cls.bin/'state.so')) + cls.lib.ck_store_set.argtypes=[ctypes.c_char_p,ctypes.c_void_p,ctypes.c_size_t] + cls.lib.ck_store_get.argtypes=[ctypes.c_char_p,ctypes.c_void_p,ctypes.POINTER(ctypes.c_size_t)] + cls.lib.ck_store_delete.argtypes=[ctypes.c_char_p] + + @classmethod + def tearDownClass(cls):cls.build.cleanup() + def setUp(self): + self.tmp=tempfile.TemporaryDirectory(prefix='ck-update-test-');self.work=Path(self.tmp.name) + for path in ['etc','bin','var/lock','var/lib/ck-state','var/run/ck-ota']:(self.work/path).mkdir(parents=True,exist_ok=True) + self.key=Ed25519PrivateKey.generate();self.diag=os.urandom(32) + (self.work/'etc/ck-update.pub').write_bytes(public(self.key));(self.work/'etc/ck-update.meta').write_bytes(struct.pack('>III',0x434b0004,1,1)) + (self.work/'etc/ck-version').write_text('0.1.0\n');(self.work/'etc/matter-device.key').write_bytes(os.urandom(32)) + (self.work/'etc/matter-diag.key').write_text(self.diag.hex()+'\n');(self.work/'bin/ck-updater').write_bytes(b'test-copy-only') + self.image=bytearray(bytes(range(256))*(0x800000//256));self.image[JOURNAL:JOURNAL+0x20000]=b'\xff'*0x20000 + 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) + 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 + 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);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)) + def run_updater(self,*args):return subprocess.run([*self.runner,str(self.updater),*map(str,args)],capture_output=True,timeout=15) + def test_persistence_and_noop_wear(self): + self.assertEqual(self.put('matter/fabric',b'paired fabric secret'),0) + before=self.flash.read_bytes();self.assertEqual(self.put('matter/fabric',b'paired fabric secret'),0);self.assertEqual(before,self.flash.read_bytes()) + 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_torn_journal_and_corrupt_new_slot(self): + self.assertEqual(self.put('matter/test',b'old'),0) + for point in ['after-erase','before-commit']: + os.environ['CK_FAIL_STORE']=point;self.assertNotEqual(self.put('matter/test',b'new'),0) + os.environ.pop('CK_FAIL_STORE');self.assertEqual(self.get('matter/test'),(0,b'old')) + self.assertEqual(self.put('matter/test',b'new'),0) + blob=bytearray(self.flash.read_bytes());slots=[JOURNAL,JOURNAL+0x10000] + latest=max(slots,key=lambda p:struct.unpack_from('>Q',blob,p+8)[0]);blob[latest+100]^=1;self.flash.write_bytes(blob) + self.assertEqual(self.get('matter/test'),(0,b'old')) + def test_unknown_journal_is_never_erased(self): + blob=bytearray(self.image);blob[JOURNAL:JOURNAL+6]=b'vendor';self.flash.write_bytes(blob) + self.assertNotEqual(self.put('matter/test',b'new'),0);self.assertEqual(self.flash.read_bytes(),blob) + def test_journal_full_keeps_previous_state(self): + self.assertEqual(self.put('matter/large',b'x'*60000),0);before=self.flash.read_bytes() + self.assertNotEqual(self.put('matter/overflow',b'x'*6000),0);self.assertEqual(self.flash.read_bytes(),before) + def test_verification_rejects_wrong_or_damaged_images(self): + self.assertEqual(self.run_updater('--verify',self.ota).returncode,0) + good=self.ota.read_bytes();before=self.flash.read_bytes() + bad=[] + for index in [12,20,64,128,192,512]: + changed=bytearray(good);changed[index]^=1;bad.append(bytes(changed)) + bad += [good[:-1],good+b'x',package(Ed25519PrivateKey.generate(),self.image),package(self.key,self.image,version=1),package(self.key,self.image,variant=2),package(self.key,self.image,model=4)] + 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_rootfs_write_and_preservation(self): + self.assertEqual(self.put('matter/fabric',b'paired'),0) + before=self.flash.read_bytes();self.assertEqual(self.run_updater('--stage',self.ota).returncode,0) + self.assertEqual(self.run_updater('--simulate').returncode,0) + after=self.flash.read_bytes();self.assertEqual(after[START:END],rootfs());self.assertEqual(before[:START],after[:START]);self.assertEqual(before[END:],after[END:]) + self.assertEqual(self.get('matter/fabric'),(0,b'paired')) + def test_interrupted_write_leaves_invalid_magic_and_original_bank(self): + before=self.flash.read_bytes();self.assertEqual(self.run_updater('--stage',self.ota).returncode,0) + os.environ['CK_FAIL_OTA_AFTER']='4';self.assertNotEqual(self.run_updater('--simulate').returncode,0) + after=self.flash.read_bytes();self.assertEqual(after[START:START+4],b'\xff'*4);self.assertEqual(before[:START],after[:START]);self.assertEqual(before[END:],after[END:]) + def test_floor_and_rescue_attempts(self): + self.assertEqual(self.lib.ck_store_confirm(2),0);self.assertNotEqual(self.run_updater('--verify',self.ota).returncode,0) + self.assertEqual(self.put('ota/pending',struct.pack('>I',3)),0) + self.assertEqual([self.lib.ck_store_boot() for _ in range(4)],[0,0,0,1]) + 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 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)) + (self.work/'etc/matter-device.key').unlink();credential=b'CKW1'+bytes([4,8,1,0])+b'test'+bytes(28)+b'12345678'+bytes(56) + (self.work/'etc/ck-setup-ap.bin').write_bytes(credential) + self.assertEqual(self.lib.ck_store_import(),0) + self.assertEqual((self.work/'var/lib/ck-state/setup-ap.bin').read_bytes(),credential) + sys.path.insert(0,str(ROOT/'tools'));import fwlib + values=fwlib.journal_state(self.flash.read_bytes()) + with fwlib.profile_lock(self.work/'profile') as profile: + 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): + 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 + 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 request_headers(self,data,image_hash=None): + return {'X-CK-SHA256':image_hash or hashlib.sha256(data).hexdigest(),'Content-Type':'application/octet-stream'} + def test_http_direct_lan_upload_and_origin(self): + self.start_server();data=self.ota.read_bytes();before=self.flash.read_bytes() + self.assertEqual(self.request('GET','/')[0],200) + self.assertEqual(self.request('GET','/challenge')[0],404) + self.assertEqual(self.request('POST','/upload',data)[0],200) + self.assertEqual(self.request('POST','/upload',data,{'Origin':'http://evil.example'})[0],403) + self.assertEqual(self.request('POST','/upload',data)[0],200) + self.assertEqual(self.flash.read_bytes(),before) + self.assertEqual((self.work/'var/run/ck-ota/image.ota').read_bytes(),data) + self.assertEqual(self.request('GET','/status',headers={'Host':'evil.example'})[0],403) + def test_apply_is_bound_to_verified_package(self): + self.start_server();data=self.ota.read_bytes() + self.assertEqual(self.request('POST','/upload',data)[0],200) + self.assertEqual(self.request('POST','/apply',b'',self.request_headers(b'',image_hash='0'*64))[0],409) + self.assertNotEqual(self.run_updater('--queue','0'*64).returncode,0) + self.assertNotEqual(self.run_updater('--queue','not-a-hash').returncode,0) + headers=self.request_headers(b'',image_hash=hashlib.sha256(data).hexdigest()) + self.assertEqual(self.request('POST','/apply',b'',headers)[0],200) + self.assertEqual(self.get('ota/pending'),(0,struct.pack('>I',2))) + self.assertNotEqual(self.lib.ck_store_confirm(1),0) + self.assertTrue((self.work/'var/run/ck-ota/queued').exists()) + def test_apply_without_extra_credentials_or_checksum(self): + self.start_server();data=self.ota.read_bytes() + self.assertEqual(self.request('POST','/upload',data)[0],200) + self.assertEqual(self.request('POST','/apply',b'')[0],200) + self.assertEqual(self.get('ota/pending'),(0,struct.pack('>I',2))) + def test_single_transfer_lock_prevents_simultaneous_ram_images(self): + self.start_server();data=self.ota.read_bytes() + transfer=self.lib.ck_download_begin();self.assertGreaterEqual(transfer,0) + try: + self.assertLess(self.lib.ck_download_begin(),0) + self.assertEqual(self.request('POST','/upload',data)[0],409) + self.assertFalse((self.work/'var/run/ck-ota/image.ota').exists()) + finally:os.close(transfer) + self.assertEqual(self.request('POST','/upload',data)[0],200) + def test_unsigned_http_payload_never_changes_flash(self): + self.start_server();before=self.flash.read_bytes();data=bytearray(self.ota.read_bytes());data[512]^=1 + self.assertEqual(self.request('POST','/upload',bytes(data))[0],422) + self.assertEqual(before,self.flash.read_bytes()) + def test_http_partial_and_digest_mismatch_leave_no_staged_image(self): + self.start_server();data=self.ota.read_bytes();headers=self.request_headers(data) + changed=bytearray(data);changed[-1]^=1 + 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] + 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()) + +if __name__=='__main__':unittest.main(verbosity=2) diff --git a/third_party/README.md b/third_party/README.md index f81d94e..425d782 100644 --- a/third_party/README.md +++ b/third_party/README.md @@ -3,6 +3,8 @@ | 元件 | 固定來源/用途 | 授權紀錄 | |---|---|---| | connectedhomeip | `sdk-lock.json` 的 v1.6.1.0 commit/submodules,Matter stack 與 SDK 範例 attestation | Apache-2.0,文字保存在 licenses/ | +| Monocypher 4.0.3 | `firmware/matter/src/vendor/monocypher/provenance.json`,逐檔 URL/SHA-256,Ed25519 驗簽 | BSD-2-Clause/CC0 雙授權,原 LICENSE 保留 | +| LinuxStorage override | 相同固定 SDK 的 `src/platform/Linux/CHIPLinuxStorage.cpp`,增加平台設定 journal mirror | Apache-2.0 header 保留於 override | | Nayuki QR encoder | `firmware/matter/src/vendor/qrcodegen/provenance.json`,固定 commit/兩檔 SHA-256 | MIT 文字包含於來源 | | musl-cross-make | 固定 commit,GCC 14.3.0/binutils 2.44/musl bootstrap,輕量 builder 來源也保存 | builder MIT;各工具鏈元件保留 upstream 授權 | | uClibc-ng 1.0.54 | `download-lock.json`,RTL8196E 相容 C/C++ runtime | LGPL-2.1 文字保存;相容修補脚本公開 | diff --git a/third_party/ota-signing.pub b/third_party/ota-signing.pub new file mode 100644 index 0000000000000000000000000000000000000000..5e427d50a19a70983d424b0a58bcdb73db416b65 GIT binary patch literal 32 qcmV+*0N?)ud^$n$7ku6_@(~oBJf``F;E;&`lTx*}nPIPIII", 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) + inittab = read_file(vendor_root, "etc/inittab").decode() + require("::restart:" not in inittab, "已有 PID1 restart action,拒絕覆寫") + 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 不符") 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. @@ -122,14 +155,16 @@ def add(dest, data, mode=0o755): if clean != data: require(len(clean) == len(data), "ELF 字串替换改變長度") add(name, clean, int(fields[3], 8)) - root = repack(vendor_root, additions, remove=SECRET_FILES | {"bin/upgfw"}) + 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) 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, "size": SIZE, "sha256": sha(image), "write_range": [START, END], - "compatibility": compatibility(before), "rootfs": rootfs_check(root), + "compatibility": base_compatibility, "rootfs": rootfs_check(root), "credentials_included": False, "requires_local_personalization": True, - "physical_boot_validation": "pending for this public repack", "release": "v0.1.0-preview"}) + "physical_boot_validation": "pending for this public repack", + "release": "v" + json.loads((REPO / "version.json").read_text())["version"]}) def main(): @@ -137,12 +172,13 @@ def main(): parser.add_argument("variant", choices=("matter", "exec-test")) parser.add_argument("--binaries", type=Path, required=True) parser.add_argument("--base", type=Path, help="使用者自己的相容 8 MiB 備份;省略時只編譯服務") + parser.add_argument("--template-manifest", type=Path, help="使用去識別化公開 BIN 作基底時的 JSON") parser.add_argument("--output", type=Path) args = parser.parse_args() services(args.variant, args.binaries) if args.base: require(args.output, "組裝需指定 --output") - assemble(args.variant, args.base, args.binaries, args.output) + assemble(args.variant, args.base, args.binaries, args.output, args.template_manifest) print("本機建置完成;未存取燒錄器。") diff --git a/tools/fwlib.py b/tools/fwlib.py index 517cc45..aa76beb 100644 --- a/tools/fwlib.py +++ b/tools/fwlib.py @@ -228,7 +228,39 @@ def profile_lock(profile): os.close(fd) -def profile_keys(profile, before, variant): +def journal_state(before): + """Accept erased or committed CKSTORE1 only; never silently claim vendor data.""" + area = before[END:END+0x20000] + if area == b"\xff" * len(area): + return {} + valid = [] + for index in range(2): + block = area[index*0x10000:(index+1)*0x10000] + size = struct.unpack_from(">I", block, 16)[0] + if block[:8] != b"CKSTORE1" or block[60:64] != b"CKOK" or size > 0x10000-64: + continue + payload = block[64:64+size] + if hashlib.sha256(block[8:20]+payload).digest() != block[20:52]: + continue + values, at = {}, 0 + while at < size: + require(size-at >= 4, "journal entry 截斷") + key_size, reserved, value_size = struct.unpack_from(">BBH", payload, at) + require(0 < key_size <= 96 and reserved == 0 and 4+key_size+value_size <= size-at, + "journal entry 格式不符") + key = payload[at+4:at+4+key_size].decode("ascii") + require(all(33 <= ord(c) <= 126 for c in key) and key not in values, "journal key 不符") + values[key] = payload[at+4+key_size:at+4+key_size+value_size] + at += 4+key_size+value_size + valid.append((struct.unpack_from(">Q", block, 8)[0], block, values)) + require(valid, "預留 journal 含未辨識資料;拒絕初始化,請保存當次備份") + if len(valid) == 2 and valid[0][0] == valid[1][0]: + require(valid[0][1] == valid[1][1], "journal 同序號內容衝突") + return max(valid, key=lambda item:item[0])[2] + + +def profile_keys(profile, before, variant, journal_data=None): + journal_data = journal_data or {} identity = sha(before[0x6000:0x8000] + before[0x7F8000:]) binding = profile / "device.json" existing_keys = [] @@ -245,10 +277,13 @@ def profile_keys(profile, before, variant): def key(name, count, hexadecimal=False): dest = profile / name require(not dest.is_symlink(), "金鑰不可為 symlink") + stored = journal_data.get({"diag.key": "identity/diag", "device-identity.key": "identity/device"}[name]) if not dest.exists(): raw = secrets.token_bytes(count) - atomic_write(dest, raw.hex().encode() + b"\n" if hexadecimal else raw) + generated = raw.hex().encode() + b"\n" if hexadecimal else raw + atomic_write(dest, stored if stored is not None else generated) data = dest.read_bytes() + require(stored is None or data == stored, "profile 金鑰與裝置持久身分不符") require((re.fullmatch(b"[0-9a-f]{64}\n", data) if hexadecimal else len(data) == count), "profile 金鑰格式不符") os.chmod(dest, 0o600) @@ -267,9 +302,12 @@ def key(name, count, hexadecimal=False): record[4:7] = bytes([len(ssid), len(password), 1]) record[8:8 + len(ssid)] = ssid record[40:40 + len(password)] = password + record = journal_data.get("identity/setup-ap", record) atomic_write(dest, record) - write_json(profile / "setup-ap.json", {"ssid": ssid.decode(), "password": password.decode()}) + write_json(profile / "setup-ap.json", {"ssid": bytes(record[8:8+record[4]]).decode(), "password": bytes(record[40:40+record[5]]).decode()}) data = dest.read_bytes() + require("identity/setup-ap" not in journal_data or data == journal_data["identity/setup-ap"], + "profile AP 憑證與裝置持久設定不符") require(len(data) == 104 and data[:4] == b"CKW1" and data[6] == 1, "設定熱點憑證格式不符") os.chmod(dest, 0o600) @@ -288,8 +326,9 @@ def prepare(first, second, release, manifest, profile, output): before = matching_backups(first, second) root, meta = load_release(release, manifest) compatible(before, meta["compatibility"]) + journal_data = journal_state(before) if "etc/ck-update.meta" in file_inventory(root) else {} with profile_lock(profile) as folder: - injected = profile_keys(folder, before, meta["variant"]) + injected = profile_keys(folder, before, meta["variant"], journal_data) root = repack(root, injected) target = before[:START] + root + before[END:] image_checks(target) diff --git a/tools/generate_update.py b/tools/generate_update.py new file mode 100644 index 0000000..5e43b33 --- /dev/null +++ b/tools/generate_update.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +"""Generate version and self-contained management page from tracked sources.""" +import json +from pathlib import Path +ROOT=Path(__file__).resolve().parents[1] +def write_if_changed(path,text): + if not path.exists() or path.read_text()!=text:path.write_text(text) + +def main(): + config=json.loads((ROOT/'version.json').read_text()) + version=config['software_version'];label=config['version'] + if not isinstance(version,int) or not 08s6I", h) + require(magic == MAGIC and fmt == 1 and model == MODEL and variant in VARIANTS.values() + and version > 0 and length == END - START and flags == 0, "更新包格式不符") + require(h[128:160] == public, "更新簽署者不符") + Ed25519PublicKey.from_public_bytes(public).verify(h[192:256], h[:192]) + require(hashlib.sha256(data[256:]).digest() == h[32:64], "更新內容 hash 不符") + rootfs_check(data[256:]) + label = h[160:192].split(b"\0", 1)[0].decode("ascii") + require(re.fullmatch(r"[A-Za-z0-9.-]{1,31}", label), "版本名稱不符") + return {"format": "commandkit-ota-v1", "model": "CMNDKT-004", "variant": next(k for k,v in VARIANTS.items() if v == variant), + "software_version": version, "version": label, "size": len(data), + "sha256": hashlib.sha256(data).hexdigest(), "rootfs_sha256": h[32:64].hex(), + "bank1_kernel_sha256": h[64:96].hex(), "bank2_executable_sha256": h[96:128].hex(), + "signing_public_key": public.hex()} + + +def pack(image, manifest, private, version, label, output): + require(re.fullmatch(r"[A-Za-z0-9.-]{1,31}", label) and 0 < version < 2**32, "版本不符") + data = Path(image).read_bytes() + meta = json.loads(Path(manifest).read_text()) + require(len(data) == SIZE and hashlib.sha256(data).hexdigest() == meta["sha256"], "Release 不符") + require(meta["write_range"] == [START, END] and data[:START] == b"\xff"*START and + data[END:] == b"\xff"*(SIZE-END), "Release 保留區不符") + root = data[START:END] + rootfs_check(root) + require(not SECRET_FILES & set(file_inventory(root)), "更新包不可含裝置憑證") + key = Ed25519PrivateKey.from_private_bytes(Path(private).read_bytes()) + public = key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + require(read_file(root, "etc/ck-update.pub") == public, "映像信任公鑰與簽章金鑰不符") + require(read_file(root, "etc/ck-version") == (label+"\n").encode(), "映像版本名稱不符") + variant = VARIANTS[meta["variant"]] + require(read_file(root, "etc/ck-update.meta") == struct.pack(">III", MODEL, variant, version), + "映像內版本與更新包版本不符") + h = bytearray(256) + struct.pack_into(">8s6I", h, 0, MAGIC, 1, MODEL, variant, version, len(root), 0) + h[32:64] = hashlib.sha256(root).digest() + h[64:96] = bytes.fromhex(meta["compatibility"]["bank1_kernel_sha256"]) + h[96:128] = bytes.fromhex(meta["compatibility"]["bank2_executable_sha256"]) + h[128:160] = public + h[160:160+len(label)] = label.encode() + h[192:256] = key.sign(bytes(h[:192])) + package = bytes(h)+root + details = verify(package, public) + atomic_write(output, package, mode=0o644) + write_json(Path(output).with_suffix(".json"), details) + return details + + +def catalog(packages, base_url, output): + require(base_url.startswith("https://"), "線上更新來源必須是 HTTPS") + entries = [] + for p in packages: + p = Path(p) + meta = json.loads(p.with_suffix(".json").read_text()) + verified = verify(p.read_bytes(), bytes.fromhex(meta["signing_public_key"])) + require(meta == verified, "更新索引資料不符") + entries.append({**verified, "url": base_url.rstrip("/")+"/"+p.name}) + write_json(output, {"format": "commandkit-catalog-v1", "updates": entries}) + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + commands = p.add_subparsers(dest="command", required=True) + k = commands.add_parser("keygen"); k.add_argument("--private",type=Path,required=True); k.add_argument("--public",type=Path,required=True) + b = commands.add_parser("pack") + for name in ("image","manifest","private","output"): b.add_argument("--"+name,type=Path,required=True) + b.add_argument("--version",type=int,required=True); b.add_argument("--label",required=True) + v=commands.add_parser("verify");v.add_argument("package",type=Path);v.add_argument("--public",type=Path,required=True) + c=commands.add_parser("catalog");c.add_argument("packages",nargs="+",type=Path);c.add_argument("--base-url",required=True);c.add_argument("--output",type=Path,required=True) + a=p.parse_args() + if a.command=="keygen":keygen(a.private,a.public);print("簽章私鑰已保存於私人目錄;公鑰可隨來源發布。") + elif a.command=="pack":print(json.dumps(pack(a.image,a.manifest,a.private,a.version,a.label,a.output),indent=2)) + elif a.command=="verify":print(json.dumps(verify(a.package.read_bytes(),a.public.read_bytes()),indent=2)) + else:catalog(a.packages,a.base_url,a.output) + +if __name__=="__main__":main() diff --git a/tools/package_public.py b/tools/package_public.py index 2f8671f..80013ab 100644 --- a/tools/package_public.py +++ b/tools/package_public.py @@ -3,6 +3,7 @@ import argparse import hashlib import json +import re import zipfile from pathlib import Path from check_public import audit, public_paths @@ -14,32 +15,43 @@ def main(): parser.add_argument("--release-dir", type=Path, required=True) parser.add_argument("--output-dir", type=Path, required=True) args = parser.parse_args() + config = args.repository / "version.json" + version = "v" + json.loads(config.read_text())["version"] if config.exists() else "v0.1.0-preview" + if not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?", version): + raise ValueError("版本格式不符") report = audit(args.repository, args.release_dir) - (args.repository / "verification/public-audit.json").write_text( + (args.repository / ("verification/public-audit-" + version + ".json")).write_text( json.dumps(report, ensure_ascii=False, indent=2) + "\n") files = public_paths(args.repository) rows = [] for path in files: - if path.is_symlink(): - raise ValueError("發布目錄不應包含生成的 SDK link;由 bootstrap 重建") - rows.append({"path": str(path.relative_to(args.repository)), "bytes": path.stat().st_size, - "sha256": hashlib.sha256(path.read_bytes()).hexdigest()}) + data = str(path.readlink()).encode() if path.is_symlink() else path.read_bytes() + rows.append({"path": str(path.relative_to(args.repository)), "bytes": len(data), + "sha256": hashlib.sha256(data).hexdigest(), "symlink": path.is_symlink()}) args.output_dir.mkdir(parents=True, exist_ok=True) - source = args.output_dir / "incipio-source-v0.1.0-preview.zip" + source = args.output_dir / ("incipio-source-" + version + ".zip") + if source.exists(): raise ValueError("來源 archive 已存在,請改用新目錄") with zipfile.ZipFile(source, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as archive: for row in rows: - archive.write(args.repository / row["path"], "repository/" + row["path"]) + path = args.repository / row["path"] + if row['symlink']: + info = zipfile.ZipInfo("repository/" + row["path"]) + info.create_system = 3 + info.external_attr = 0o120777 << 16 + archive.writestr(info, str(path.readlink())) + else: + archive.write(path, "repository/" + row["path"]) manifest = {"format": "commandkit-public-source-v1", "files": rows, "archive": source.name, "archive_sha256": hashlib.sha256(source.read_bytes()).hexdigest(), "private_directories_included": False} (args.output_dir / "SOURCE_MANIFEST.json").write_text(json.dumps(manifest, indent=2) + "\n") (args.output_dir / "SOURCE_SHA256SUMS").write_text( "".join(row["sha256"] + " repository/" + row["path"] + "\n" for row in rows)) - release = args.output_dir / "incipio-release-v0.1.0-preview.zip" + release = args.output_dir / ("incipio-release-" + version + ".zip") with zipfile.ZipFile(release, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as archive: for path in sorted(args.release_dir.iterdir()): if path.is_file(): - archive.write(path, "release-assets/v0.1.0-preview/" + path.name) + archive.write(path, "release-assets/" + version + "/" + path.name) (args.output_dir / "ARCHIVE_SHA256SUMS").write_text( "".join(hashlib.sha256(p.read_bytes()).hexdigest() + " " + p.name + "\n" for p in (source, release))) print(json.dumps({"status": "pass", "source_files": len(rows), "source_zip_bytes": source.stat().st_size, diff --git a/tools/release.py b/tools/release.py new file mode 100644 index 0000000..eccc62a --- /dev/null +++ b/tools/release.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Build public OTA artifacts using a pinned public template, without device dumps.""" +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import urllib.request +from pathlib import Path +from fwlib import require,load_release,read_file,atomic_write +from ota import pack,catalog +ROOT=Path(__file__).resolve().parents[1] +WORK=ROOT/'firmware/matter' + +def call(argv,**kwargs):subprocess.run(list(map(str,argv)),check=True,**kwargs) +def main(): + p=argparse.ArgumentParser(description=__doc__) + p.add_argument('--private',type=Path,required=True,help='簽章私鑰,不會進入容器或附件') + 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('--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()) + 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') + if not image.exists(): + with urllib.request.urlopen(lock['url'],timeout=60) as r:atomic_write(image,r.read(0x800001),mode=0o644) + require(hashlib.sha256(image.read_bytes()).hexdigest()==lock['sha256'],'公開基底 hash 不符') + if not manifest.exists(): + with urllib.request.urlopen(lock['manifest_url'],timeout=60) as r:atomic_write(manifest,r.read(64*1024),mode=0o644) + root,_=load_release(image,manifest) + vendor=ROOT/'artifacts/update-vendor' + for name in ('libapmib.so','libc.so.0','libgcc_s.so.1','ld-uClibc.so.0'): + path=vendor/'lib'/name + if not path.exists():atomic_write(path,read_file(root,'lib/'+name)) + call(['python3',ROOT/'tools/generate_update.py']) + if not a.skip_compile: + docker=['docker','run','--rm','-v',str(WORK)+':/work','-v',str(vendor)+':/firmware:ro','incipio-public-build:v0.1.0-preview'] + 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) + 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') + 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)) + +if __name__=='__main__':main() diff --git a/tools/requirements.txt b/tools/requirements.txt new file mode 100644 index 0000000..8aa17b9 --- /dev/null +++ b/tools/requirements.txt @@ -0,0 +1 @@ +cryptography==47.0.0 diff --git a/tools/setup_github_updates.py b/tools/setup_github_updates.py new file mode 100644 index 0000000..bcf4003 --- /dev/null +++ b/tools/setup_github_updates.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Configure an encrypted repository signing secret and a workflow-based update feed.""" +import argparse +import json +import re +import subprocess +from pathlib import Path +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives import serialization +from fwlib import require +ROOT=Path(__file__).resolve().parents[1] +def gh(args,**kwargs):return subprocess.run(['gh',*args],capture_output=True,check=True,**kwargs) +def main(): + p=argparse.ArgumentParser(description=__doc__) + p.add_argument('--repository',default='lee98064/incipio-commandkit') + p.add_argument('--private',type=Path,required=True) + a=p.parse_args();require(re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+',a.repository),'repository 格式不符') + raw=a.private.read_bytes();key=Ed25519PrivateKey.from_private_bytes(raw) + require(key.public_key().public_bytes(serialization.Encoding.Raw,serialization.PublicFormat.Raw)==(ROOT/'third_party/ota-signing.pub').read_bytes(),'私鑰與韌體公鑰不符') + secrets=json.loads(gh(['secret','list','--repo',a.repository,'--json','name']).stdout) + require(not any(s['name']=='OTA_SIGNING_KEY_HEX' for s in secrets),'簽章 secret 已存在;拒絕默默輪替,請核對原金鑰') + # GitHub CLI encrypts with the repository public key. Never put the value in argv or logs. + gh(['secret','set','OTA_SIGNING_KEY_HEX','--repo',a.repository],input=raw.hex().encode()) + result=subprocess.run(['gh','api','repos/'+a.repository+'/pages'],capture_output=True) + if result.returncode: + require(b'404' in result.stderr or b'"status":"404"' in result.stdout,'無法確認 Pages 設定') + gh(['api','--method','POST','repos/'+a.repository+'/pages','--input','-'],input=json.dumps({'build_type':'workflow'}).encode()) + else: + require(json.loads(result.stdout).get('build_type')=='workflow','既有 Pages 使用其他來源;未更動既有網站') + print('GitHub 簽章 secret 已加密保存;Pages 使用 workflow 發布。尚未發布任何更新。') +if __name__=='__main__':main() diff --git a/tools/update.py b/tools/update.py new file mode 100644 index 0000000..4217a59 --- /dev/null +++ b/tools/update.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""LAN OTA client with HTTPS release download and signature checks.""" +import argparse +import hashlib +import ipaddress +import json +import socket +import urllib.parse +import urllib.request +from pathlib import Path +from ota import verify +from fwlib import require +ROOT=Path(__file__).resolve().parents[1] +LIMIT=0x290000+256 + +def download(url,limit): + require(urllib.parse.urlparse(url).scheme=='https', '更新下載必須使用 HTTPS') + with urllib.request.urlopen(url,timeout=60) as response: + require(urllib.parse.urlparse(response.url).scheme=='https','拒絕不安全的下載重新導向') + data=response.read(limit+1) + require(len(data)<=limit,'下載檔案過大');return data + +def post(base,path,data,image_hash=None): + digest=image_hash or hashlib.sha256(data).hexdigest() + request=urllib.request.Request(base+path,data=data,method='POST',headers={ + 'Content-Type':'application/octet-stream','X-CK-SHA256':digest}) + with urllib.request.urlopen(request,timeout=120) as r:return json.load(r) + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('device',help='插座 IPv4 位址') + parser.add_argument('--public',type=Path,default=ROOT/'third_party/ota-signing.pub') + group=parser.add_mutually_exclusive_group(required=True);group.add_argument('--package',type=Path);group.add_argument('--latest',action='store_true') + parser.add_argument('--check-only',action='store_true',help='只驗證新版,不上傳或安裝') + args=parser.parse_args() + try:address=ipaddress.IPv4Address(args.device) + except ipaddress.AddressValueError:address=ipaddress.IPv4Address(socket.gethostbyname(args.device)) + require(address.is_private and not address.is_multicast and not address.is_unspecified,'請指定區網 IPv4 位址') + base=f'http://{address}:18082' + with urllib.request.urlopen(base+'/status',timeout=10) as r:status=json.load(r) + if args.latest: + url=json.loads((ROOT/'version.json').read_text())['catalog_url'] + catalog=json.loads(download(url,64*1024));require(catalog.get('format')=='commandkit-catalog-v1','索引格式不符') + variant='matter' if status['variant']==1 else 'exec-test' + entries=[x for x in catalog['updates'] if x.get('variant')==variant and x.get('model')=='CMNDKT-004' and x['software_version']>status['software_version']] + if not entries:print('目前已是最新版本。');return + entry=max(entries,key=lambda x:x['software_version']);data=download(entry['url'],LIMIT) + require(hashlib.sha256(data).hexdigest()==entry['sha256'],'下載 hash 不符') + else:data=args.package.read_bytes() + metadata=verify(data,args.public.read_bytes()) + require(metadata['software_version']>status['software_version'],'裝置已是此版本或更高版本') + require(metadata['variant']==('matter' if status['variant']==1 else 'exec-test'),'韌體種類不符') + print('已驗證更新包:'+metadata['version']) + if args.check_only:return + post(base,'/upload',data);post(base,'/apply',b'',hashlib.sha256(data).hexdigest()) + print('更新已提交,裝置正在安裝並重新啟動。請保持供電。') + +if __name__=='__main__':main() diff --git a/verification/README.md b/verification/README.md index 84da48c..6abb22d 100644 --- a/verification/README.md +++ b/verification/README.md @@ -1,6 +1,8 @@ # 驗證索引 -最終結果見 [test-results.json](test-results.json)、[公開包稽核](public-audit.json)、[flashrom dummy backend](dummy-flashrom.json)。公開 manifest 的 hash 對應本次檔案;原始 VM console/配對記錄只留在本機。 +v0.2 的新增測試見 [OTA/持久儲存/量測結果](ota-results.json);以下歷史 v0.1 結果與新版本分開保存。 + +歷史結果見 [test-results.json](test-results.json)、[公開包稽核](public-audit.json)、[flashrom dummy backend](dummy-flashrom.json)。公開 manifest 的 hash 對應本次檔案;原始 VM console/配對記錄只留在本機。 本次獨立 SDK 建置使用搬移後的來源與本專案 Dockerfile,第三方 SDK/編譯器使用固定版本快取的獨立複本,不讀取原研究來源路徑。原廠 libraries 從裝置備份提取;公開重打包只替換等長 debug source path 字串。 @@ -54,4 +56,4 @@ python3 firmware/matter/tools/test_device_vm.py ## 原機待驗收 -公開重打包、Mac/driver/OTP 相容性、RF/Wi-Fi 掃描、手機 captive portal、家庭 app UI、繼電器及負載切換、10 次重啟與長時間 RAM 穩定性未在本次完成。fabrics 持久化與 OTA 尚未實作。模擬機/dummy pass 不提升这些狀態。 +公開重打包、Mac/driver/OTP 相容性、RF/Wi-Fi 掃描、手機 captive portal、家庭 app UI、繼電器及負載切換、10 次重啟與長時間 RAM 穩定性未在本次完成。v0.2 已實作 fabrics 持久化、OTA 與標準量測,其 fixture/VM 檢查見上方新增結果;原機仍須驗收。模擬機/dummy pass 不提升这些狀態。 diff --git a/verification/ota-results.json b/verification/ota-results.json new file mode 100644 index 0000000..7649523 --- /dev/null +++ b/verification/ota-results.json @@ -0,0 +1,147 @@ +{ + "format": "commandkit-ota-verification-v1", + "date": "2026-10-14", + "version": "0.2.0-preview", + "software_version": 2, + "status": "pass_for_listed_fixture_and_vm_checks", + "physical_device_access": false, + "native_update_suite": { + "status": "pass", + "tests": 16, + "log_sha256": "89841d854973d06ea337be4fc129ababd0e841ffb0717d241de84347f844b465" + }, + "mips_update_suite": { + "status": "pass", + "tests": 16, + "log_sha256": "514225dd0f396d5b0782f57fe27d7b53e87730fe012a70bccd2fc89ec8486583" + }, + "deployment_suite": { + "status": "pass", + "tests": 11, + "log_sha256": "1b716bad176959bdd8de8887dbe72344184127c7817a601ba8c973c6e7838a8c" + }, + "pid1": { + "status": "pass", + "environment": "native Linux PID1 in private container namespaces; regular-file NOR fixture; separate MIPS writer suite", + "checks": [ + "real process quiesce and pivot_root branch", + "updater executable copied to ramfs", + "rootfs written and read back", + "all protected bytes and journal unchanged", + "namespace reboot" + ], + "physical_device_access": false, + "container_exit_code": 129, + "updater_sha256": "bc345a9f4c97bcf60769e8a98a0423089b724731c99878672d7dfb848217f9b7" + }, + "browser_crypto": { + "status": "pass", + "sha256_independent_node_vectors": 9 + }, + "protocol": { + "status": "pass", + "native_checks": 574, + "confirmed_command_cycles": 100 + }, + "matter_vm": { + "status": "pass", + "environment": "MIPS Malta/24Kc Linux 4.19 VM, 64 MiB RAM, 8 MiB daemon AS; simulated serial MCU", + "binary_sha256": "39732f48392101148d3feb9998dc276945c9ec4d0be9e8ee37da7ed843a2ad3d", + "original_hardware_test": false, + "flash_access": "regular-file journal fixture", + "full_commissioning": true, + "checks": [ + "cold boot sends OFF and awaits MCU confirmation", + "full development commissioning, default attestation verifier", + "OTA Requestor cluster available over encrypted Matter session", + "process reboot retains fabrics; encrypted CASE command succeeds without commissioning again", + "standard ElectricalPowerMeasurement: 109000 mV, 1500 mA RMS and 500000 mW over CASE", + "standard active-power subscription reports changed MCU sample", + "stale metering becomes null, fresh readings recover; unsupported ActiveCurrent rejected", + "10 encrypted On/Off/Toggle commands including duplicates confirmed through kernel TTY", + "unsolicited physical-button frame reflected in Matter attribute", + "MCU timeout returns failure; subsequent command recovers", + "Identify command accepted by integrated LED delegate" + ], + "mcu_tx_frames": 92, + "mcu_bad_frames": [] + }, + "production_matter_elf_sha256": "c9830b0c695c854cb3c550f38894ab434294ff23a70fb9080646fa97eb10aa56", + "linux_rootfs_assembly": { + "status": "pass", + "log_sha256": "38bf88382c29ff1098bacc47be2dae20b87ea61a2e95f0f6eb83c64aa851cfdf" + }, + "workflow_lint": { + "status": "pass", + "tool": "actionlint 1.7.12" + }, + "browser_ui": { + "status": "pass", + "target": "local mock preview, no device writes", + "checks": [ + "no update key / authorization selector", + "current version shown", + "missing package message", + "unpublished catalog fallback" + ] + }, + "artifacts": { + "SHA256SUMS": { + "size": 837, + "sha256": "8278a3179dfe8e40fdb5f0301a4f01205cd19624a891b11d5ebb6fcbc6330296" + }, + "exec-test-8m-region.bin": { + "size": 8388608, + "sha256": "e63932474b22ff779626925315ed029c06ed4a7ce0bcc2a02aff5c7840c7d8b4" + }, + "exec-test-8m-region.json": { + "size": 679, + "sha256": "dc937e96f908e1589dc8f0aa36f23b549d12bcde40635366accf9f1f9af0a267" + }, + "exec-test.json": { + "size": 610, + "sha256": "c899fd5cbe6b6d8b152987a93181b22e553c9f05aa65088e4f7fed74f2cd805a" + }, + "exec-test.ota": { + "size": 2687232, + "sha256": "809e959d7f0ac94631dd3586ed6b0001850bf7a009da9902f5ebf8ed64304759" + }, + "index.json": { + "size": 1547, + "sha256": "3b2d5427dc643dc1f074888d7f43ea0218039a39edbe21411c3bb85131ee4375" + }, + "matter-8m-region.bin": { + "size": 8388608, + "sha256": "627db077554679b5e6ae34938091331559db01a3809f3303ef65a880bbedc7d1" + }, + "matter-8m-region.json": { + "size": 676, + "sha256": "db7779e162957384596196b984ea6597178d03fb9ce65db8bf7bb95f26bf331f" + }, + "matter-matter-ota.bin": { + "size": 2687321, + "sha256": "e4df350b455a4415b51b340ebae174e9ef2c696fee36e11a78f66c82b6507ec1" + }, + "matter.json": { + "size": 607, + "sha256": "308a8244e413d5b4ce81793e78ec16d2551b844246bd30929b0a0efd9f6ee273" + }, + "matter.ota": { + "size": 2687232, + "sha256": "2181b319da2a30cd77052851c9dcc18f6b664e97ed0070fbe05636cb0f532847" + } + }, + "limitations": [ + "No physical RLX4181 / vendor Linux 2.6.30.9 flash-driver or power-cut validation", + "No complete Matter Provider-to-BDX-to-installed-new-version validation", + "No original-hardware meter comparison/calibration or verified cumulative energy counter", + "No Apple Home / other phone ecosystem UI validation; pairing alone does not imply energy UI support", + "Preserved vendor Bank 2 is checksum fallback, not full application-failure A/B rollback", + "Release artifacts are local preview candidates; no new release or live update feed published" + ], + "references": { + "update": "docs/updates.md", + "measurement": "docs/measurements.md", + "technical_report": "docs/technical-report.md" + } +} diff --git a/verification/public-audit-v0.2.0-preview.json b/verification/public-audit-v0.2.0-preview.json new file mode 100644 index 0000000..830e268 --- /dev/null +++ b/verification/public-audit-v0.2.0-preview.json @@ -0,0 +1,492 @@ +{ + "status": "pass", + "scope": "selected public files and decompressed release rootfs", + "public_file_count": 5041, + "public_bytes": 24732448, + "variants": { + "matter": { + "sha256": "627db077554679b5e6ae34938091331559db01a3809f3303ef65a880bbedc7d1", + "size": 8388608, + "regular_files": 231, + "file_sha256": { + "bin/WACServer": "fc7fad2d10fd83927f82f5471bcf70fcb853ab54a140aa6dad27682efd02e63f", + "bin/acltd": "1f3cd73bed035ad5a7b1215a7a31dd43ec441231cafa05c6017fbfc2e2af2cb7", + "bin/auth": "af1f4e5f83842233b9c3ba499e1fe019bf45e682787220faab58500f3d855031", + "bin/avahi-autoipd": "e3ca507667b99a4fcbc0ed8d0d640abc3998332e24691ee50e04d5c3e1c63c8b", + "bin/boa": "175c66efa68eabda38c8ca6990e824ddcc41dfec0219bc739bce7b8ffe0a587a", + "bin/brctl": "f4acc1905b421d52f5e8e6e24f483080f64194d3e40896f52659214569521218", + "bin/busybox": "3cd0ec8270214a74879ee4b67e592be285e23d6a9cca964b64a86b88c821e619", + "bin/ck-lease": "38e9987f8fc53109113f8aefba380f3c5a4e2575ec053d1ec82b140d28ccbe66", + "bin/ck-ota-start": "4d5a1ec59c9b32ba52b64a66975216f72c71a6df2bd0d66de9ccaa4aff85391c", + "bin/ck-ram-probe": "edca27a1c7932af7e5088829ac0f1b43ffbebbeffe4476e7cf2dae4b32b037c6", + "bin/ck-setup-backend": "226d6b78cd8c7163809c62391a587a285c88fb2720d2d2accc850cbcb3ed8616", + "bin/ck-setup-start": "744a44317ea3a7ff2cc76a7b6d2372b72e2498191d6a1f5ea26d3e10f898eecb", + "bin/ck-setupd": "5b5dee66061b2860906bfec429eefd089b5196f6ad14473815838fb3fb5ac954", + "bin/ck-udhcpc": "97eb31d69d3514a5a552b075a5089527ef95917e29ee08105a830996d3ac6b85", + "bin/ck-updated": "92ea60bf35ddeba883e96c87ab941db2e8704e2791d3998f57233d60d7c42c21", + "bin/ck-updater": "d1ade93bb7ba559c3c3fe496cbd6b754fef26c8f2f8eab82e38e00a66516bf39", + "bin/ck-wificfg": "350402de512d6e124346f24a39695b0fdb2b3b4ea06ec0c5bbaf1e852f0ab2dd", + "bin/ck-wifiscan": "e9f77c51d6424501c8703351b8f23f181fb471ed652256f386ef623746975721", + "bin/connect.sh": "116a58d9caa45a8a83a43b7db5e42505b3e7640e4cdee06e62c6dcc7b576b433", + "bin/connect6.sh": "97e8946dc7c28bdbde8beda01e87315e3bedf07e1704db140c8c889a036dd0aa", + "bin/ddns_inet": "d1d3bc14909540e6f17f07d72ae6d208fefc4bfb9aa33c97fbc784d2986affb4", + "bin/dhcp6c": "50cc13f2cb4b10d2a44a90750760cf8bc7227b40d39a8bb1c1a6cec45d1873cb", + "bin/dhcp6cRcv.sh": "72d0f7430a95c2e5405d3b469a44cc788cac215eb8e5d91f7182e366f595e206", + "bin/dhcp6ctl": "c391c1a0828c2e5c7a2e4892fa7dce1dd28018f2a0610bdb9ac1c623f9cc77ae", + "bin/dhcp6s": "794c877af3c4bef3d3f457e1afc7ae9bd635bc775ec8bb4f4152176e73b26823", + "bin/disconnect.sh": "ec469e947e3e2e4c9bf0aa0a6d95a4a24d9bb7521ed467d5bd183a74dfef6798", + "bin/dnrd": "55c99a83c659cbd127e75b7c34c13f73853f9c6e612271bd311f81a1a711ebd8", + "bin/dnsmasq": "541c75037f62bc3a104989ece61b044463370f15219c4c4ae21e7f68ceb159b6", + "bin/dw": "5ef838655702ff0ab7cb8f5993a31e5903afc1e437a17e44f4c4842124d72266", + "bin/ecmh": "b56eb2a6e88722ba361042b329d67b3d5bc3634aa083690c949596c239e7f81f", + "bin/ew": "1a3e6bc1afc8345387dc9a38887e6af4adde619bc8743c25f0366df6928a649a", + "bin/firewall.sh": "5688eba3b9e7f322c8e7b1e2e831f6e999d6fcec125283fc8de75d144e42e7f1", + "bin/flash": "28aca13b5d8df12040dd2c6ffb3dc69e61bfd1e0c860d25447f3a08e66963523", + "bin/fwd": "698eb8a02fcc0f2dcfca6702ed766f7338dfe51f4df6f85c72a2a78d789a190c", + "bin/getmib": "e3c872196f6afe6501514101614cc8b8ece902e6f8b6b9a1ad352a10c2a61e25", + "bin/getmib1": "89bc956fe6fd4e5e134c8769e611253fecf50272e2396ae65015e44d5d3d8b44", + "bin/gigadevice_chk": "f555ae6b974c2e7303072a1441820b171907993dd389e062557c7ac9d5191a19", + "bin/homekitserver": "2d50339436759f3d602c150fb4f1a42f0fdf122fc18e9a4f54f6e6b07d7a0c62", + "bin/iapp": "0dda7f960b1b0340f03f1823dc457b0b2fef84dd887d3b07d2158e5845f78bdb", + "bin/ib": "23540feab73c9f0f287e4f91284343ec8677aac9e5b1f51bb2bcf0db1075e697", + "bin/ib1": "439aeeffb8b3253384713b53f95120a2206f97911c64ebb90366c487ec1f2024", + "bin/id1": "cead109ab787def7dc01e63c4cdbb782493f588cecd709aee61a298b263d6dbd", + "bin/idd": "fd0a6763249a6064351ba787fbadce230b9ccfdeacff4f3de662b0c40b46df50", + "bin/idd1": "cead109ab787def7dc01e63c4cdbb782493f588cecd709aee61a298b263d6dbd", + "bin/init.sh": "37a89df20b2efa6b9e4268320fae90e29774dc894168a257f25b89f452e7bda2", + "bin/ip_qos.sh": "0bbaa983ce78b27b561a9c780c3ba9264563d4a38b61209f1898fc83a5473ee0", + "bin/irf": "848040ed72207dc5a47d3f67d6f54f8ce6eb3f36c38104089aa438d55114bfcc", + "bin/irf1": "9e7d8c249b22c9e65e9dc3050bff451f3bdeaf9b4eb8db6bbec5b9b39899eb0d", + "bin/iw": "82e621203cfa87228dc1bb520f2837bf7833346a0fc9f16757970f8ed07f11da", + "bin/iw1": "0ee09886704fa633ecd6111c1fe8ce373054e2c5feed8924a006255017ff8987", + "bin/iwcontrol": "5df1e0c326ef676d8dcab9885e079b9b25bc956b5aba6cb1e99af5dc11c5ae0a", + "bin/iwpriv": "0d54f6042b99bfbecbbeb68152a462f8a54663407a7ecf804c2c8bca2d6da6d9", + "bin/killsh.sh": "402bc7a2845df34d9c15049c38dac2688cd448995a908def3a27438fc762c019", + "bin/l2tp.sh": "075ba0c8e3a533efcbe14d42f66d3ef1163571fc22759e249ffcca17496ec0bb", + "bin/matter-diag": "86dc2ddeb26a64578751cf16404402c0580d4beb7c73b615aa35466c2e6d8a2f", + "bin/matter-diag-start": "d214053c374cbe396097f76a8fa97927a767fad3620bfc40ab1116e38fb5c31a", + "bin/matter-test-start": "5105d81cf6ad9ae0a834cdea346b3f474a41659b98402343eb7b83ef52290e8d", + "bin/matterd-test": "c9830b0c695c854cb3c550f38894ab434294ff23a70fb9080646fa97eb10aa56", + "bin/mdnsd": "7d5243636c0870e9cf32f4ecda0d494c6b47091da70cd470911e5961430ca177", + "bin/mldproxy": "eed2c2737eeefc4e808ce088b5c2ee7611fb93e414c826d4bf116218b51d48c3", + "bin/mmd_cmdr": "b71745208ba972d3a02991af759d4827b81c2e5156995c47e09b558b640cb163", + "bin/mmd_cmdw": "aeb2542634581cccaf6a9e76a41b69147c840663b2aff43b652ad5a1c4a5a346", + "bin/mp.sh": "e41e053700105de8511c57a984cbd03347381bcc48497ac391bde8bf6b68cb99", + "bin/ntp_inet": "79d56c4369c418fb4a4920f0394aec7c1e7ccccc9dbe119bfb0518f2395abcfa", + "bin/ntpclient": "e736240aab44bdd9d701bafe6eeaf383fa98878e57cb2913126e524185678aec", + "bin/ob": "452a3031a31802e8a7a79e200d2ef1c149ddb045a83ca9faea72133f1d10d4bd", + "bin/ob1": "c3f3f85889c443c69b17daa93fe0aa27e1e2dca99fa7e0f36b5579e749c1dc7b", + "bin/od": "be0f8099e8e1440e10b1b7cf6bf4ed063ff926887c4334fd47c941fe05c5635d", + "bin/od1": "aaae9dc1b71c3ee083efb9fc3b4862be9a32deb117a8b2edac5a3a057226e213", + "bin/orf": "6de1b42f0975076302439227c5542c353faff0293ff49c27932bec9b94d373c8", + "bin/orf1": "0d83ffdf74e8b6153062b24c765cb562bae115bb6e94ceccf40e198a26268b99", + "bin/ow": "3e06231474fe83569add90f05af1fbd478e243da89fa7767a6f1e27efab5b2ac", + "bin/ow1": "cb4d076d2a8efc2168e5a5d67b3da4ac3b0eec54b9fecf0735d3a4dc5602b07e", + "bin/phyr": "0ea4bf45ead3a0da7d55feaeef12bcfb095e63f69a70a842672f36b008065e80", + "bin/phytrex_tools": "a4733e1b2b8c52e48c109c9c40a2b9af532248e4b4116eb72260b1f0f20e6fc2", + "bin/phyw": "830bfdb9473fae12ab9aa8298d7aa3f9831bff63e7393a7f2b29dd530f134545", + "bin/ppp_inet": "44b1db22dc2b786e90ed558175915a9eef926a7818bd43f1c675724a06b77109", + "bin/pppoe.sh": "28d59a56e07f42c240ec59a7ac5a784371d00cef92f1984943232b3050ca011b", + "bin/pppoe_conn_patch.sh": "b6da8ac9633bf1a4a2bc665945247882ef8181e1fca9e65f3a8e7af631cc1e7e", + "bin/pppoe_disc_patch.sh": "025aac686216515eb57a2a26f5bb7b7c507f66b48c5404d8502dc0f414f1cb93", + "bin/pptp.sh": "c79acbc24a203398e6858f464065d65dc83eb0087bb3d6fd0ac47ed294a7754b", + "bin/radvd": "9db7fe7c307562b562fc1a96a33ece6b53be4cafe7bc63648d09a8c4567135b6", + "bin/radvdump": "c4c9694b057105652c461308b901dfe02da3bdf8901175dfcd8d112e6a21d71e", + "bin/reload": "22f689f717c259ecbeb628cc051fa4d82a3011146f2f4e48fd37cd5380184be3", + "bin/rssi": "091d5107b5c27b266252585b7c9992df4b2270704f81dc939eb1bbccc7a0ee6d", + "bin/rssi1": "1cca70b5b1cca70afdfa9637b180d904e0236cc487ac55d8ab1395283dcb8c67", + "bin/rtk_cmd": "89dbb16d504b668c48abae30b0d99724c20df7ce1a35faa55cc18f887bc7d7bb", + "bin/rtk_eventd": "1638b8ace10a21145ce26df5c835e8e8aef72bfbfd4f6ff13a91f3894ba9be38", + "bin/setmib": "2025b4ed81e89dcad0a58d4411c101ca67f0d6fdb85b1eb73539049f8675a3a3", + "bin/setmib1": "89ef32a13b8888d9e2b2f1e6a84c849f0d35e73e03c94c82d5be11f68f59209b", + "bin/snmpd.sh": "014ca345c401ea958db53e7dcb7c4dc050198f680e9a746410268a7a84a0875b", + "bin/startup.sh": "d620040cff0aa1ada99a9d68d1fc8436cd335ace89481880c5c2daf70a7e8fdb", + "bin/sysconf": "a88ffd520ea3382bcd1b656c1c2b4e02a416619da2fc87d4fe108ee08e76c9a0", + "bin/timelycheck": "cf7afc7be127701b340c4ddcfacfeb690af40041b6c93fc37e29e63c0ce41511", + "bin/udhcpd": "6c65f86b575cc3956504e37b9688fe6b0cafae8a38938aab56a2973b0802a699", + "bin/wfaudio": "ba269a6c1565c02b8322d18e3d40e89de6f7f98097a90d0ce06e3452218d5d22", + "bin/wlanapp.sh": "a45b6d40a8b126283a575f0bbf1bc97f076f39c58f1ca74f27bcb0997aaa563f", + "etc/avahi-autoipd.action": "6e14205c720227f5294bfb9348dc1071dcdcc914723695619a10d5e11c395176", + "etc/boa/boa.conf": "6236c22da5f177dcaad1bd6ec70ce3dca62cb1a56daf83b668933cd8c0aef6e1", + "etc/boa/mime.types": "1e3e9fe650ec67e99cafeddcfbaf52a11f910666ec11d0f2c7a264392622b9bb", + "etc/ck-udhcpd.conf": "53e4458bbbb97de591d528efeeaa146eeaba2d5c7612d7b2389cadb4b8123819", + "etc/ck-update.meta": "f8b7eccc6ca0160d614bcbfb3e1c2a2d4c56efca5b51898d00caed7cb96000ae", + "etc/ck-update.pub": "386eb1742a602b5e9bd2007bcffd3a39c0a467500f92a1856d7c6059c88f5a2c", + "etc/ck-variant": "acbc154cdaa2d015cd86d03b5b1ce12da9accf509e6c00ddb6c7316b760f6209", + "etc/ck-version": "8473a5650daa3ca1ca310bc8375300c5c5e4cad88faeb2224cbcecf9f60fee7f", + "etc/customize.version": "44f87003455f39f4bb1e8fd1be8342c591b7c7fff44143d6949f6adbee4600b1", + "etc/dnsmasq.conf": "0ae2408b285a62bce4c8b0c7fab85e350889699d22feff1b6adcc54253bb95d4", + "etc/group": "5148fde8988f4762c1a14039adf6ca7cfcf3b201c71cd48332da60ca69ff49a8", + "etc/host.conf": "313728062c7ae797e11187c7f1d66b559f0098115320166bca6100486c167824", + "etc/hosts": "f80e3d5c3a0e4517e650bccee94c8033131e263d8458b37a18dcee440cb3819b", + "etc/init.d/rcS": "2e8254920a09c4c068a6506f5be87f7b00bc54124b8b246ddff5aea4e3982eff", + "etc/init.d/rcS_32M": "54b23c1e0a0a9a4f02484f6287e21f60588d0a98a790e76e9d9b4af3578237a0", + "etc/inittab": "7728ecf0138d2ed4b1408204e8c7b0c7eacfe650b1fe29cadfe941031b476e3a", + "etc/mime.types": "8d0a3ce3b74daccb330e158471cab898d40d4e40ed8a74ff9d710909a82cdf6e", + "etc/motd": "3b727708cd2efa362aa751c12c8d2e9e0e2050cb07b3d646dae2c6f4bcdd1cb3", + "etc/passwd": "8e619aab9bdf9ddd2644bed3ac319e862e908c4664b862120c2f4209c401bd32", + "etc/radvd.conf": "9ef8db8aa53148d8671e93b9d2879f18e1aec399ccba92aae324271796b5868e", + "etc/samba/smb.conf": "a56cfd4403599a01be6a78bfadcb1e6cc5177f7b4022e3a17e2ca861d2eb7c6d", + "etc/samba/smbpasswd": "2187a31165dc5cb2190130abdc04d6c5fef3d78ffc609b390b74a444f8e76c94", + "etc/services": "6ae28a7390a44890f3709f772dd5a2d8246880b40a734f1317cb117eea6de748", + "etc/shadow.sample": "106bf188d92eae79b255c106cb9f05777b5ba225ddef95132fb1da552b81caac", + "etc/svn.version": "9b3e52b747c1564c1a2b4eb757b829ae5d5095a0f591b8bcb49549364e75d461", + "etc/ushare.conf": "8e1b479172c4f1c7478a6658bae1a1049b607e7aacd24f2c40ff415b6e3da057", + "etc/version": "de64bb7381aaec500c2765ea8b939c7f1cc1f806609a932ba410e9e30a8957a8", + "etc/vsftpd.conf": "c86e2c41c3c17be5510db27e4998fbf372751727c340dce68362012d47744e92", + "lib/ld-uClibc-0.9.30.3.so": "35051ff978746f071c792d3d262b14f7e78dbbc461a9fd5c1e56a73966b0cb30", + "lib/libapmib.so": "e88721af68971ac96e949e7a918ae59e339cea1a3e7ecc9d40d59d5024e62824", + "lib/libdaemon.so.0": "a6ba44569b6a17ae9806aa89eb0bca7018946cc3aa4e167cf6b67e1f17ed8910", + "lib/libdl-0.9.30.3.so": "fd211b05beb2139a4e97a2441001be7554170e2602ac3c5298b228571cd1e3cf", + "lib/libdns_sd.so": "b0175db146f751120165c1d93a46be5e9da19f3e4201f13dbf0365fc053fddad", + "lib/libgcc_s.so.1": "c8f9860a3518dc0d6b78ff6640240aeb68e9b38a59aaf37bdc86bc1c0cd361d1", + "lib/libm-0.9.30.3.so": "bd31abbb878d32393b26573ae5b6055d1e73fa8126b4ade9a76465458d8bff2f", + "lib/libpthread-0.9.30.3.so": "7cde98eebc3ee088f150497282edb955b9d9aa1fb72f65603b0f6505d4919872", + "lib/librt-0.9.30.3.so": "ba9ebba8c75a71046716ac9ed2c37a25534ccf0647e0b123d8aea010738db2df", + "lib/libuClibc-0.9.30.3.so": "3bdde33cb95c8a4ce9a786d654f3e87f7a86e667201f14cea4fa33ee6749c278", + "usr/share/udhcpc/br0.renew": "0f225c0c1b9feeb416f46c86f729eabde73518f6920f26bf4dfaa8d501d54221", + "usr/share/udhcpc/br0.sh": "8be90d840672e3038b1e60ab59c206672479a28fb7168c9e2d9ffb267f3f787e", + "usr/share/udhcpc/eth0.bound": "394691277bad5f0f1a6b708d95e7ed57d6a0432ead294af299727f08c8c4904e", + "usr/share/udhcpc/eth0.sh": "f663605c21f57feadba977443b7b2ee298803752664b7b64207af0a75a27a17f", + "usr/share/udhcpc/eth1.bound": "e721f2ad88cbdfa3f37c5a7053a05c15766955f4b2d5ee9f7c7f7a195a7ad89b", + "usr/share/udhcpc/eth1.renew": "0f225c0c1b9feeb416f46c86f729eabde73518f6920f26bf4dfaa8d501d54221", + "usr/share/udhcpc/eth1.sh": "87e4066153462feec83c9ff7666812eb258bc4d38550e3498b31c33617441e7b", + "usr/share/udhcpc/wlan0-vxd.bound": "cf1ad0dc6a1fd3fd71b839a47e2c8ccce076db67608d7b87e412aa92010c02d3", + "usr/share/udhcpc/wlan0-vxd.sh": "092105e71c24ea2a1ecd4e8516b8bf32cd0468343bc803516f8b2e7baa54ec2e", + "usr/share/udhcpc/wlan0.bound": "cf1ad0dc6a1fd3fd71b839a47e2c8ccce076db67608d7b87e412aa92010c02d3", + "usr/share/udhcpc/wlan0.sh": "b60b564cf829f019db892c4b2c05d0950557f45e0cb8c33d8b1a0b11f4fd289c", + "usr/share/udhcpc/wlan1-vxd.bound": "cf1ad0dc6a1fd3fd71b839a47e2c8ccce076db67608d7b87e412aa92010c02d3", + "usr/share/udhcpc/wlan1-vxd.sh": "ad88d272a3ae0f31bfcff86a1e08be1342f57511615693782a7fb11a93b50189", + "usr/share/udhcpc/wlan1.bound": "cf1ad0dc6a1fd3fd71b839a47e2c8ccce076db67608d7b87e412aa92010c02d3", + "usr/share/udhcpc/wlan1.sh": "f4f1548b05bef9b69c13c32c2f157017b9623a946870774fd32e2246f8192d4c", + "web/code.htm": "ef39c1dcc759b7d9f614fe6b8049ddf2a9122e9d9e81a614849e5f5700afea0a", + "web/countDownPage.htm": "b4d199553f8dfa58f0a19c8923be713c8fd6de556bbd1190ad412e5295536cab", + "web/ddns.htm": "3c66f89fe65b19061a42d09ccdbd7d8abe3a2812189f1e0b57a9a0cea73e45a7", + "web/dhcp6s.htm": "05234b252c5c08471952974dfa9c30254d3baf12efc537ac1765d9b8c71af22f", + "web/dhcptbl.htm": "969251aeabe941809d38398f0168ed3ba8b06d3a33534c537b7442950534e81b", + "web/dmz.htm": "5b067a0e5c07e015a81ba3bb0556f6e73192ad044f2ad69bab3b2a34820f58d9", + "web/dnsv6.htm": "86a8711db19229c3f2680d64e45df06cdcdfc5d336d7db49f917907dba7a5b92", + "web/dos.htm": "86e3fc95e66b4d75cde12767dc5812e019dfc4718b897068ac1c79380a060458", + "web/favicon.ico": "fdce9e8910ba9bc5ea2357c8002568c6f434a80175f381d882bbd71feffd910e", + "web/graphics/back.gif": "0e9ced1019385b1101fdaa7c07d01d63aae771176a44d3df81d47b115b5128aa", + "web/graphics/dir.gif": "16f741d14a9af2cf05588d4ca748dd9a4bba9780c464f424076258b3911807ee", + "web/graphics/router.gif": "69a4f526ad2a35621afc9e9eabd1dbbb4f24f3f0e0496a1bf62b8c3d17363eca", + "web/graphics/text.gif": "ad62db6395e4e4bc21d0442062659db0f5ce93656e8d6464c172328dff5b2ac8", + "web/graphics/topbar.gif": "295b5e68db8a2e727b60c5d142b32661daca0722f3de4981aee91c77c49abda3", + "web/home.htm": "c39b5a02b4a8d9b72ced04cbdb4b881186533fad9a699e28086455f61d1cf880", + "web/index.htm": "af6083187e85b9711ce81f6fc86ec2a4dd31a7f578414359de3a3ccffce73076", + "web/index.html": "169ae52b96b7129c3a8c7a9d848f219c49a1b47bb88e417829b047e20d255852", + "web/ip6_qos.htm": "9927ebc69e819f57761419019c6896a0bbcfd79b4b1baa84b24a359153b1c25a", + "web/ip6filter.htm": "0b7b40aac77aae6482a97c1ac5c277b318a1624d541c5ff40572d63b352acc8f", + "web/ipv6_basic.htm": "af8f5f372f4e053ed8b74e970936d1456cbb4cb940eec19726532886d6bf7618", + "web/ipv6_wan.htm": "597459af6fd8763e565d8848890aac17de4931bf1717f3b6802af2d0bdd0aad2", + "web/logout.htm": "ef640d19f21fe339e1eea7ff032b2d16f6bc9a91bdebac269de12ebc96e91991", + "web/macfilter.htm": "0ba33043f8efa0593580d7557e1e11ea8d0d9f0184c1d9f3d1558af80f608eab", + "web/menu-images/menu_bar.gif": "647aded4cdccaa9a9f00d73e66ca92b1bc1d8f9775b76212a3d04504ed5410e4", + "web/menu-images/menu_corner.gif": "39af5e39f3b1e770f7488e44b445624c3ed7271e4c7de8dbb34dd2f4416e7af6", + "web/menu-images/menu_corner_minus.gif": "b1c5de54ea4ab836eff259195d32a578deee31c295288b06d90a8e7e98e085f3", + "web/menu-images/menu_corner_plus.gif": "d39a780f3d0e3283a3ae64e67f9ebf128b17c0b5b1417a06e4f78f09ae619e42", + "web/menu-images/menu_folder_closed.gif": "09e687ff155d3000d791ed036ff0e3f77b8696157eec186257dba3684e372f24", + "web/menu-images/menu_folder_open.gif": "1612a163cef16999411d2b63e12aa2e53c7d3bab1a2b5c2468cb0591727cb39b", + "web/menu-images/menu_link_default.gif": "5f0f5f53e769f08ac4be05097ac5e3474f4a28b1b0215a34805ec9e79297b0bd", + "web/menu-images/menu_pixel.gif": "63b98a0c8568c08fd01a6946a147bca65eff26c8085f1ccb5330aafe0f0dcd15", + "web/menu-images/menu_root.gif": "b38b3fd6af062f1ceda57128c8671b6b906e396cf9d624772614fb62cd23c694", + "web/menu-images/menu_tee.gif": "bf5d4fc1fdbe4b5e3e6a5c468a716ab34e2a1a2ab4478267df7e8351c8d1587b", + "web/menu-images/menu_tee_minus.gif": "bd10a4f7b54a08d5d63e7322f267fabfe383755471cb01186d9c7b16736e3d3b", + "web/menu-images/menu_tee_plus.gif": "a62fcf9bbafcc56e9af47307710dc9c34c9239b2fe8610a6e923c576e62f8a5e", + "web/menu.htm": "133ad67524dede590787680066b45cf58a70c6db2b5b7e3577b45ce44230a105", + "web/menu_empty.html": "9f18250ded12c95522893bbb6dec6c6be3bbfd395b4fce8a2ba0d04469aa743b", + "web/mtmcode.js": "565b4fbc6a66e0ca9802269de674454adcc5187d035f1a94ba93b0978b156334", + "web/ntp.htm": "5e33de238716fb88a914e1c470baaf913eb88702f664cd4ccd3e8c292f8d3f44", + "web/opmode.htm": "c6499fda559e682e5b78345522d1f7806a7152a00de8c4a28a8fe10f2bcd1803", + "web/password.htm": "800ccb6722a5be356a8f9dc1c44034d0f72c6857f5890fcfaae974204c1fe754", + "web/pocket_sitesurvey.htm": "0dce90317c717e55895d44502a14b2acbd72518447d9029f6717f4cbf538ba1f", + "web/portfilter6.htm": "74530b908cc389547fe4596e5eaf7826bc0c57d3ab7fedb8c785a8a7567845ee", + "web/portfw.htm": "938bb94911baec450bc2a49904a7fadb2f27068cc9cd65c02d7f0008d27b38c1", + "web/radvd.htm": "31ea7295aa23b9f4a73ae3fd2ac8f61283391a88780c2ef06d53dfed54c0b439", + "web/reload.htm": "eae613794aad31e1b6edfe9ab0b4695e05c5a977e1d5820810abbaea618cc4e7", + "web/saveconf.htm": "d3e5f98e82cbfc600474236c4e97d24f84e22d589ceb586f46ba69d2cb512bae", + "web/stats.htm": "4bee4161dcbb79b2193dd48fe303f72253a7c81dda1435351944e4fce505cfee", + "web/status.htm": "2f3cc0421f10b19555821466a824f2d769d410b291eed45e34f10e0004516ed9", + "web/style.css": "dfe13bca7a30354e51274a0979ea60af38b1717bcc6135ddcbc2f28dd19fd536", + "web/syscmd.htm": "dca82d34c2877902ae4f9b48dd434c7e0098969f6fcf09dc15e6e97b5c725971", + "web/syslog.htm": "ede23274b094361f7abdd6ff6242bf7ed570552dd14c710026b1af6c1a0d4d5e", + "web/tcpip_staticdhcp.htm": "a3451b01d98df04559ece73c80b931bc92fd8ed43e77f0011ee751044c47baba", + "web/tcpiplan.htm": "4629d26d6da1150971347176160880802ff8a498b2240a5ca22487cea59fc57e", + "web/tcpipwan.htm": "2d4bae96143f5392173f7f3d055b69f3a0fb3b4d68f8cfb9b3e6ded997778a6f", + "web/title.htm": "87a29d29b9b7ae7b28bcf2f1a00002a997fa1038a2b33d88c12b316f0e3164ee", + "web/tunnel6.htm": "6d2bf69b88a08d1efcc22285b1ce206ecc86f520dee2f017cf8d6c272af0d3bc", + "web/upload.htm": "c9ff896145a303104587a4c376df2dad1a7cc4f5ec2c74e87f0130e989c49d01", + "web/upload_st.htm": "11e65da936c795621500b5c1ca3abe428a2bdd2946a39a8086f7b63956f28bd9", + "web/urlfilter.htm": "108ef6504f70587f5311425bd696c22e0f0a23646e3b7f0124533ef438bceaf2", + "web/util_gw.js": "9a20c547b2d34e77039eb330e7cf845e7d252320cf8f40dbc30a252e1cf092c1", + "web/util_qos.js": "ffcccd7675ac37779d846ad43b84ccf2cd683f4b796af4496024f6a74395d104", + "web/vlan.htm": "9e2200ec452e1ce62d61d6b84ce3d83ab9ca1867293769cd23f13962f4cbe472", + "web/wizard.htm": "6f6f9a6ae33f260778698dc22afc7d24e5a967fe346cb75a21f01afa97f6c8fc", + "web/wlactrl.htm": "97edf03d39e15e5709b79a41e3a142760918e155417ba183bda60ed3a5c5b3ca", + "web/wladvanced.htm": "53b78f4f9d9d06d2e1dbe1b7d4bb3aa1538a82dac0631905d6a1603d6aab15ca", + "web/wlan_schedule.htm": "17b329f065b0d1d136c901d4fe9f8bf9aae22e3b285e31ef65c009288f7ed3a1", + "web/wlbandmode.htm": "d7f34cdf8832acc9b96316f76ef52c9c60d063b2ce0c04e160c40341909faab4", + "web/wlbasic.htm": "7c758561c8e7983f98a84bf2cf1264f4a07f26e98000ce6ab9a8065be4b24c20", + "web/wlmeshproxy.htm": "022b01ffdd7df8850cfa603b7c312d80005639f8c5fb6a11c3e48c00928d23ae", + "web/wlmultipleap.htm": "c15318e6b8983a253fe8d828d32a6c053d31223b7539dbabfd9ffc5c0fd74a39", + "web/wlsch.htm": "9fffd931fdd22cf085489f4bd36d3da9f0def6b4bd65a06e0cecea650fde8bd3", + "web/wlsecurity.htm": "378353ff4e39815f9312c6c6cc33c7f9734ba2e44c1afd008953866cf170ad4c", + "web/wlsecurity_all.htm": "6be91b7ba91497d0756dd9162f54b6fca067992c9a9ea9ead8fb5c2791cd71b9", + "web/wlstatbl.htm": "a37f69ff59dc3443c2c896013f56e80cdd6831a7497034e6e6d860ab551a2441", + "web/wlstatbl_vap.htm": "7e3d4f04f66a6665aa0ade7b32a50f57116ef7011de40e1850f9888f9e8aad48", + "web/wlsurvey.htm": "90273cb11ca78519ca079a2d87f3aa143b415f2ae53d0ff7f924ad5d4c5d4c08", + "web/wlwds.htm": "ba327360f7963117f850a1096c0379e505218e6c9c38f877f4105cb1451d7885", + "web/wlwdsenp.htm": "5da00b29e79db94a6382698b70bdfb2e2dd2b0703b0fef79ffb549fc5873db15", + "web/wlwdstbl.htm": "7a53914672c15459ef3e8875e30b84f03ad967222fb61f0e3355701c7abc113d", + "web/wlwps.htm": "ca071459c523b9af21f3104cb92fe0a1c8dd38a76c731e64e3e11707bb9e1e05" + }, + "protected_ranges_all_ff": true, + "credential_files_absent": true + }, + "exec-test": { + "sha256": "e63932474b22ff779626925315ed029c06ed4a7ce0bcc2a02aff5c7840c7d8b4", + "size": 8388608, + "regular_files": 234, + "file_sha256": { + "bin/WACServer": "fc7fad2d10fd83927f82f5471bcf70fcb853ab54a140aa6dad27682efd02e63f", + "bin/acltd": "1f3cd73bed035ad5a7b1215a7a31dd43ec441231cafa05c6017fbfc2e2af2cb7", + "bin/auth": "af1f4e5f83842233b9c3ba499e1fe019bf45e682787220faab58500f3d855031", + "bin/avahi-autoipd": "e3ca507667b99a4fcbc0ed8d0d640abc3998332e24691ee50e04d5c3e1c63c8b", + "bin/boa": "175c66efa68eabda38c8ca6990e824ddcc41dfec0219bc739bce7b8ffe0a587a", + "bin/brctl": "f4acc1905b421d52f5e8e6e24f483080f64194d3e40896f52659214569521218", + "bin/busybox": "3cd0ec8270214a74879ee4b67e592be285e23d6a9cca964b64a86b88c821e619", + "bin/ck-lease": "38e9987f8fc53109113f8aefba380f3c5a4e2575ec053d1ec82b140d28ccbe66", + "bin/ck-ota-start": "4d5a1ec59c9b32ba52b64a66975216f72c71a6df2bd0d66de9ccaa4aff85391c", + "bin/ck-ram-probe": "edca27a1c7932af7e5088829ac0f1b43ffbebbeffe4476e7cf2dae4b32b037c6", + "bin/ck-setup-backend": "f3f9c319a4e2b25f97eaf56e4ec3212b27406bbdcd45bf7fe56e30dbeeadb0a5", + "bin/ck-setup-start": "1421bb89de36f8d8e80939ffe850f3a73f4b27db7bbe35b93133aaeb316fcfe8", + "bin/ck-setupd": "eed41cbdd055676430d1507c23e3845b31343a28b45ca9e995cd41e895f022b8", + "bin/ck-udhcpc": "97eb31d69d3514a5a552b075a5089527ef95917e29ee08105a830996d3ac6b85", + "bin/ck-updated": "92ea60bf35ddeba883e96c87ab941db2e8704e2791d3998f57233d60d7c42c21", + "bin/ck-updater": "d1ade93bb7ba559c3c3fe496cbd6b754fef26c8f2f8eab82e38e00a66516bf39", + "bin/ck-wificfg": "4993b29ee38b04a160501154a954b9db4872190c94425ff6cf3069a4065fd9b4", + "bin/ck-wifiscan": "e9f77c51d6424501c8703351b8f23f181fb471ed652256f386ef623746975721", + "bin/connect.sh": "116a58d9caa45a8a83a43b7db5e42505b3e7640e4cdee06e62c6dcc7b576b433", + "bin/connect6.sh": "97e8946dc7c28bdbde8beda01e87315e3bedf07e1704db140c8c889a036dd0aa", + "bin/ddns_inet": "d1d3bc14909540e6f17f07d72ae6d208fefc4bfb9aa33c97fbc784d2986affb4", + "bin/dhcp6c": "50cc13f2cb4b10d2a44a90750760cf8bc7227b40d39a8bb1c1a6cec45d1873cb", + "bin/dhcp6cRcv.sh": "72d0f7430a95c2e5405d3b469a44cc788cac215eb8e5d91f7182e366f595e206", + "bin/dhcp6ctl": "c391c1a0828c2e5c7a2e4892fa7dce1dd28018f2a0610bdb9ac1c623f9cc77ae", + "bin/dhcp6s": "794c877af3c4bef3d3f457e1afc7ae9bd635bc775ec8bb4f4152176e73b26823", + "bin/disconnect.sh": "ec469e947e3e2e4c9bf0aa0a6d95a4a24d9bb7521ed467d5bd183a74dfef6798", + "bin/dnrd": "55c99a83c659cbd127e75b7c34c13f73853f9c6e612271bd311f81a1a711ebd8", + "bin/dnsmasq": "541c75037f62bc3a104989ece61b044463370f15219c4c4ae21e7f68ceb159b6", + "bin/dw": "5ef838655702ff0ab7cb8f5993a31e5903afc1e437a17e44f4c4842124d72266", + "bin/ecmh": "b56eb2a6e88722ba361042b329d67b3d5bc3634aa083690c949596c239e7f81f", + "bin/ew": "1a3e6bc1afc8345387dc9a38887e6af4adde619bc8743c25f0366df6928a649a", + "bin/exec-diag": "b9e64135afb90d77f5038f70d9a888c9c0af72f0d4bf5c1f33ab9df0b9d06a7d", + "bin/exec-diag-start": "4102861444c4bcf08a1b561b0b5614ffe8acee13102517f1fd87077319064f94", + "bin/exec-test-runner": "7433da986fa5ab813a11e04f9e3f5fe5babb736eb76bc3e689f4a1153c76e823", + "bin/exec-test-start": "29fd9968dda63926bf99d4ac3d94d7acd415c5e320268983555be1d5cffa10dc", + "bin/firewall.sh": "5688eba3b9e7f322c8e7b1e2e831f6e999d6fcec125283fc8de75d144e42e7f1", + "bin/flash": "28aca13b5d8df12040dd2c6ffb3dc69e61bfd1e0c860d25447f3a08e66963523", + "bin/fwd": "698eb8a02fcc0f2dcfca6702ed766f7338dfe51f4df6f85c72a2a78d789a190c", + "bin/getmib": "e3c872196f6afe6501514101614cc8b8ece902e6f8b6b9a1ad352a10c2a61e25", + "bin/getmib1": "89bc956fe6fd4e5e134c8769e611253fecf50272e2396ae65015e44d5d3d8b44", + "bin/gigadevice_chk": "f555ae6b974c2e7303072a1441820b171907993dd389e062557c7ac9d5191a19", + "bin/homekitserver": "2d50339436759f3d602c150fb4f1a42f0fdf122fc18e9a4f54f6e6b07d7a0c62", + "bin/iapp": "0dda7f960b1b0340f03f1823dc457b0b2fef84dd887d3b07d2158e5845f78bdb", + "bin/ib": "23540feab73c9f0f287e4f91284343ec8677aac9e5b1f51bb2bcf0db1075e697", + "bin/ib1": "439aeeffb8b3253384713b53f95120a2206f97911c64ebb90366c487ec1f2024", + "bin/id1": "cead109ab787def7dc01e63c4cdbb782493f588cecd709aee61a298b263d6dbd", + "bin/idd": "fd0a6763249a6064351ba787fbadce230b9ccfdeacff4f3de662b0c40b46df50", + "bin/idd1": "cead109ab787def7dc01e63c4cdbb782493f588cecd709aee61a298b263d6dbd", + "bin/init.sh": "37a89df20b2efa6b9e4268320fae90e29774dc894168a257f25b89f452e7bda2", + "bin/ip_qos.sh": "0bbaa983ce78b27b561a9c780c3ba9264563d4a38b61209f1898fc83a5473ee0", + "bin/irf": "848040ed72207dc5a47d3f67d6f54f8ce6eb3f36c38104089aa438d55114bfcc", + "bin/irf1": "9e7d8c249b22c9e65e9dc3050bff451f3bdeaf9b4eb8db6bbec5b9b39899eb0d", + "bin/iw": "82e621203cfa87228dc1bb520f2837bf7833346a0fc9f16757970f8ed07f11da", + "bin/iw1": "0ee09886704fa633ecd6111c1fe8ce373054e2c5feed8924a006255017ff8987", + "bin/iwcontrol": "5df1e0c326ef676d8dcab9885e079b9b25bc956b5aba6cb1e99af5dc11c5ae0a", + "bin/iwpriv": "0d54f6042b99bfbecbbeb68152a462f8a54663407a7ecf804c2c8bca2d6da6d9", + "bin/killsh.sh": "402bc7a2845df34d9c15049c38dac2688cd448995a908def3a27438fc762c019", + "bin/l2tp.sh": "075ba0c8e3a533efcbe14d42f66d3ef1163571fc22759e249ffcca17496ec0bb", + "bin/mdnsd": "7d5243636c0870e9cf32f4ecda0d494c6b47091da70cd470911e5961430ca177", + "bin/mldproxy": "eed2c2737eeefc4e808ce088b5c2ee7611fb93e414c826d4bf116218b51d48c3", + "bin/mmd_cmdr": "b71745208ba972d3a02991af759d4827b81c2e5156995c47e09b558b640cb163", + "bin/mmd_cmdw": "aeb2542634581cccaf6a9e76a41b69147c840663b2aff43b652ad5a1c4a5a346", + "bin/mp.sh": "e41e053700105de8511c57a984cbd03347381bcc48497ac391bde8bf6b68cb99", + "bin/ntp_inet": "79d56c4369c418fb4a4920f0394aec7c1e7ccccc9dbe119bfb0518f2395abcfa", + "bin/ntpclient": "e736240aab44bdd9d701bafe6eeaf383fa98878e57cb2913126e524185678aec", + "bin/ob": "452a3031a31802e8a7a79e200d2ef1c149ddb045a83ca9faea72133f1d10d4bd", + "bin/ob1": "c3f3f85889c443c69b17daa93fe0aa27e1e2dca99fa7e0f36b5579e749c1dc7b", + "bin/od": "be0f8099e8e1440e10b1b7cf6bf4ed063ff926887c4334fd47c941fe05c5635d", + "bin/od1": "aaae9dc1b71c3ee083efb9fc3b4862be9a32deb117a8b2edac5a3a057226e213", + "bin/orf": "6de1b42f0975076302439227c5542c353faff0293ff49c27932bec9b94d373c8", + "bin/orf1": "0d83ffdf74e8b6153062b24c765cb562bae115bb6e94ceccf40e198a26268b99", + "bin/ow": "3e06231474fe83569add90f05af1fbd478e243da89fa7767a6f1e27efab5b2ac", + "bin/ow1": "cb4d076d2a8efc2168e5a5d67b3da4ac3b0eec54b9fecf0735d3a4dc5602b07e", + "bin/phyr": "0ea4bf45ead3a0da7d55feaeef12bcfb095e63f69a70a842672f36b008065e80", + "bin/phytrex_tools": "a4733e1b2b8c52e48c109c9c40a2b9af532248e4b4116eb72260b1f0f20e6fc2", + "bin/phyw": "830bfdb9473fae12ab9aa8298d7aa3f9831bff63e7393a7f2b29dd530f134545", + "bin/ppp_inet": "44b1db22dc2b786e90ed558175915a9eef926a7818bd43f1c675724a06b77109", + "bin/pppoe.sh": "28d59a56e07f42c240ec59a7ac5a784371d00cef92f1984943232b3050ca011b", + "bin/pppoe_conn_patch.sh": "b6da8ac9633bf1a4a2bc665945247882ef8181e1fca9e65f3a8e7af631cc1e7e", + "bin/pppoe_disc_patch.sh": "025aac686216515eb57a2a26f5bb7b7c507f66b48c5404d8502dc0f414f1cb93", + "bin/pptp.sh": "c79acbc24a203398e6858f464065d65dc83eb0087bb3d6fd0ac47ed294a7754b", + "bin/radvd": "9db7fe7c307562b562fc1a96a33ece6b53be4cafe7bc63648d09a8c4567135b6", + "bin/radvdump": "c4c9694b057105652c461308b901dfe02da3bdf8901175dfcd8d112e6a21d71e", + "bin/reload": "22f689f717c259ecbeb628cc051fa4d82a3011146f2f4e48fd37cd5380184be3", + "bin/rssi": "091d5107b5c27b266252585b7c9992df4b2270704f81dc939eb1bbccc7a0ee6d", + "bin/rssi1": "1cca70b5b1cca70afdfa9637b180d904e0236cc487ac55d8ab1395283dcb8c67", + "bin/rtk_cmd": "89dbb16d504b668c48abae30b0d99724c20df7ce1a35faa55cc18f887bc7d7bb", + "bin/rtk_eventd": "1638b8ace10a21145ce26df5c835e8e8aef72bfbfd4f6ff13a91f3894ba9be38", + "bin/setmib": "2025b4ed81e89dcad0a58d4411c101ca67f0d6fdb85b1eb73539049f8675a3a3", + "bin/setmib1": "89ef32a13b8888d9e2b2f1e6a84c849f0d35e73e03c94c82d5be11f68f59209b", + "bin/snmpd.sh": "014ca345c401ea958db53e7dcb7c4dc050198f680e9a746410268a7a84a0875b", + "bin/startup.sh": "d620040cff0aa1ada99a9d68d1fc8436cd335ace89481880c5c2daf70a7e8fdb", + "bin/sysconf": "a88ffd520ea3382bcd1b656c1c2b4e02a416619da2fc87d4fe108ee08e76c9a0", + "bin/test-cpp_lean": "85a4e1fb97533f9d5b09ef630811a8df5e47d53d51f5de6e4e9374ca7660fb96", + "bin/test-tiny_eb": "897e409ae52a202d8f93aab9315686538c675adf80ee24a0f45c269a33cd2a1d", + "bin/test-tiny_gcc16": "60283435194b72e0c2dd61ffc0985f88d6478b26dc05be03eca629d2078f54c7", + "bin/timelycheck": "cf7afc7be127701b340c4ddcfacfeb690af40041b6c93fc37e29e63c0ce41511", + "bin/udhcpd": "6c65f86b575cc3956504e37b9688fe6b0cafae8a38938aab56a2973b0802a699", + "bin/wfaudio": "ba269a6c1565c02b8322d18e3d40e89de6f7f98097a90d0ce06e3452218d5d22", + "bin/wlanapp.sh": "a45b6d40a8b126283a575f0bbf1bc97f076f39c58f1ca74f27bcb0997aaa563f", + "etc/avahi-autoipd.action": "6e14205c720227f5294bfb9348dc1071dcdcc914723695619a10d5e11c395176", + "etc/boa/boa.conf": "6236c22da5f177dcaad1bd6ec70ce3dca62cb1a56daf83b668933cd8c0aef6e1", + "etc/boa/mime.types": "1e3e9fe650ec67e99cafeddcfbaf52a11f910666ec11d0f2c7a264392622b9bb", + "etc/ck-udhcpd.conf": "53e4458bbbb97de591d528efeeaa146eeaba2d5c7612d7b2389cadb4b8123819", + "etc/ck-update.meta": "f686e096483994ff2bb5a524d6eaa547876ea3509fcf3ae563559141b142e113", + "etc/ck-update.pub": "386eb1742a602b5e9bd2007bcffd3a39c0a467500f92a1856d7c6059c88f5a2c", + "etc/ck-variant": "ec162500e66bdd48e03d6123e62f8b31b1b37170c5759ad3cdbe4f9b469a178e", + "etc/ck-version": "8473a5650daa3ca1ca310bc8375300c5c5e4cad88faeb2224cbcecf9f60fee7f", + "etc/customize.version": "44f87003455f39f4bb1e8fd1be8342c591b7c7fff44143d6949f6adbee4600b1", + "etc/dnsmasq.conf": "0ae2408b285a62bce4c8b0c7fab85e350889699d22feff1b6adcc54253bb95d4", + "etc/group": "5148fde8988f4762c1a14039adf6ca7cfcf3b201c71cd48332da60ca69ff49a8", + "etc/host.conf": "313728062c7ae797e11187c7f1d66b559f0098115320166bca6100486c167824", + "etc/hosts": "f80e3d5c3a0e4517e650bccee94c8033131e263d8458b37a18dcee440cb3819b", + "etc/init.d/rcS": "f23fe4803d52812fadbd84af1761792831cfde40803c5e0fafd0fd32e9d11e4e", + "etc/init.d/rcS_32M": "54b23c1e0a0a9a4f02484f6287e21f60588d0a98a790e76e9d9b4af3578237a0", + "etc/inittab": "7728ecf0138d2ed4b1408204e8c7b0c7eacfe650b1fe29cadfe941031b476e3a", + "etc/mime.types": "8d0a3ce3b74daccb330e158471cab898d40d4e40ed8a74ff9d710909a82cdf6e", + "etc/motd": "3b727708cd2efa362aa751c12c8d2e9e0e2050cb07b3d646dae2c6f4bcdd1cb3", + "etc/passwd": "8e619aab9bdf9ddd2644bed3ac319e862e908c4664b862120c2f4209c401bd32", + "etc/radvd.conf": "9ef8db8aa53148d8671e93b9d2879f18e1aec399ccba92aae324271796b5868e", + "etc/samba/smb.conf": "a56cfd4403599a01be6a78bfadcb1e6cc5177f7b4022e3a17e2ca861d2eb7c6d", + "etc/samba/smbpasswd": "2187a31165dc5cb2190130abdc04d6c5fef3d78ffc609b390b74a444f8e76c94", + "etc/services": "6ae28a7390a44890f3709f772dd5a2d8246880b40a734f1317cb117eea6de748", + "etc/shadow.sample": "106bf188d92eae79b255c106cb9f05777b5ba225ddef95132fb1da552b81caac", + "etc/svn.version": "9b3e52b747c1564c1a2b4eb757b829ae5d5095a0f591b8bcb49549364e75d461", + "etc/ushare.conf": "8e1b479172c4f1c7478a6658bae1a1049b607e7aacd24f2c40ff415b6e3da057", + "etc/version": "de64bb7381aaec500c2765ea8b939c7f1cc1f806609a932ba410e9e30a8957a8", + "etc/vsftpd.conf": "c86e2c41c3c17be5510db27e4998fbf372751727c340dce68362012d47744e92", + "lib/ld-uClibc-0.9.30.3.so": "35051ff978746f071c792d3d262b14f7e78dbbc461a9fd5c1e56a73966b0cb30", + "lib/libapmib.so": "e88721af68971ac96e949e7a918ae59e339cea1a3e7ecc9d40d59d5024e62824", + "lib/libdaemon.so.0": "a6ba44569b6a17ae9806aa89eb0bca7018946cc3aa4e167cf6b67e1f17ed8910", + "lib/libdl-0.9.30.3.so": "fd211b05beb2139a4e97a2441001be7554170e2602ac3c5298b228571cd1e3cf", + "lib/libdns_sd.so": "b0175db146f751120165c1d93a46be5e9da19f3e4201f13dbf0365fc053fddad", + "lib/libgcc_s.so.1": "c8f9860a3518dc0d6b78ff6640240aeb68e9b38a59aaf37bdc86bc1c0cd361d1", + "lib/libm-0.9.30.3.so": "bd31abbb878d32393b26573ae5b6055d1e73fa8126b4ade9a76465458d8bff2f", + "lib/libpthread-0.9.30.3.so": "7cde98eebc3ee088f150497282edb955b9d9aa1fb72f65603b0f6505d4919872", + "lib/librt-0.9.30.3.so": "ba9ebba8c75a71046716ac9ed2c37a25534ccf0647e0b123d8aea010738db2df", + "lib/libuClibc-0.9.30.3.so": "3bdde33cb95c8a4ce9a786d654f3e87f7a86e667201f14cea4fa33ee6749c278", + "usr/share/udhcpc/br0.renew": "0f225c0c1b9feeb416f46c86f729eabde73518f6920f26bf4dfaa8d501d54221", + "usr/share/udhcpc/br0.sh": "8be90d840672e3038b1e60ab59c206672479a28fb7168c9e2d9ffb267f3f787e", + "usr/share/udhcpc/eth0.bound": "394691277bad5f0f1a6b708d95e7ed57d6a0432ead294af299727f08c8c4904e", + "usr/share/udhcpc/eth0.sh": "f663605c21f57feadba977443b7b2ee298803752664b7b64207af0a75a27a17f", + "usr/share/udhcpc/eth1.bound": "e721f2ad88cbdfa3f37c5a7053a05c15766955f4b2d5ee9f7c7f7a195a7ad89b", + "usr/share/udhcpc/eth1.renew": "0f225c0c1b9feeb416f46c86f729eabde73518f6920f26bf4dfaa8d501d54221", + "usr/share/udhcpc/eth1.sh": "87e4066153462feec83c9ff7666812eb258bc4d38550e3498b31c33617441e7b", + "usr/share/udhcpc/wlan0-vxd.bound": "cf1ad0dc6a1fd3fd71b839a47e2c8ccce076db67608d7b87e412aa92010c02d3", + "usr/share/udhcpc/wlan0-vxd.sh": "092105e71c24ea2a1ecd4e8516b8bf32cd0468343bc803516f8b2e7baa54ec2e", + "usr/share/udhcpc/wlan0.bound": "cf1ad0dc6a1fd3fd71b839a47e2c8ccce076db67608d7b87e412aa92010c02d3", + "usr/share/udhcpc/wlan0.sh": "b60b564cf829f019db892c4b2c05d0950557f45e0cb8c33d8b1a0b11f4fd289c", + "usr/share/udhcpc/wlan1-vxd.bound": "cf1ad0dc6a1fd3fd71b839a47e2c8ccce076db67608d7b87e412aa92010c02d3", + "usr/share/udhcpc/wlan1-vxd.sh": "ad88d272a3ae0f31bfcff86a1e08be1342f57511615693782a7fb11a93b50189", + "usr/share/udhcpc/wlan1.bound": "cf1ad0dc6a1fd3fd71b839a47e2c8ccce076db67608d7b87e412aa92010c02d3", + "usr/share/udhcpc/wlan1.sh": "f4f1548b05bef9b69c13c32c2f157017b9623a946870774fd32e2246f8192d4c", + "web/code.htm": "ef39c1dcc759b7d9f614fe6b8049ddf2a9122e9d9e81a614849e5f5700afea0a", + "web/countDownPage.htm": "b4d199553f8dfa58f0a19c8923be713c8fd6de556bbd1190ad412e5295536cab", + "web/ddns.htm": "3c66f89fe65b19061a42d09ccdbd7d8abe3a2812189f1e0b57a9a0cea73e45a7", + "web/dhcp6s.htm": "05234b252c5c08471952974dfa9c30254d3baf12efc537ac1765d9b8c71af22f", + "web/dhcptbl.htm": "969251aeabe941809d38398f0168ed3ba8b06d3a33534c537b7442950534e81b", + "web/dmz.htm": "5b067a0e5c07e015a81ba3bb0556f6e73192ad044f2ad69bab3b2a34820f58d9", + "web/dnsv6.htm": "86a8711db19229c3f2680d64e45df06cdcdfc5d336d7db49f917907dba7a5b92", + "web/dos.htm": "86e3fc95e66b4d75cde12767dc5812e019dfc4718b897068ac1c79380a060458", + "web/favicon.ico": "fdce9e8910ba9bc5ea2357c8002568c6f434a80175f381d882bbd71feffd910e", + "web/graphics/back.gif": "0e9ced1019385b1101fdaa7c07d01d63aae771176a44d3df81d47b115b5128aa", + "web/graphics/dir.gif": "16f741d14a9af2cf05588d4ca748dd9a4bba9780c464f424076258b3911807ee", + "web/graphics/router.gif": "69a4f526ad2a35621afc9e9eabd1dbbb4f24f3f0e0496a1bf62b8c3d17363eca", + "web/graphics/text.gif": "ad62db6395e4e4bc21d0442062659db0f5ce93656e8d6464c172328dff5b2ac8", + "web/graphics/topbar.gif": "295b5e68db8a2e727b60c5d142b32661daca0722f3de4981aee91c77c49abda3", + "web/home.htm": "c39b5a02b4a8d9b72ced04cbdb4b881186533fad9a699e28086455f61d1cf880", + "web/index.htm": "af6083187e85b9711ce81f6fc86ec2a4dd31a7f578414359de3a3ccffce73076", + "web/index.html": "169ae52b96b7129c3a8c7a9d848f219c49a1b47bb88e417829b047e20d255852", + "web/ip6_qos.htm": "9927ebc69e819f57761419019c6896a0bbcfd79b4b1baa84b24a359153b1c25a", + "web/ip6filter.htm": "0b7b40aac77aae6482a97c1ac5c277b318a1624d541c5ff40572d63b352acc8f", + "web/ipv6_basic.htm": "af8f5f372f4e053ed8b74e970936d1456cbb4cb940eec19726532886d6bf7618", + "web/ipv6_wan.htm": "597459af6fd8763e565d8848890aac17de4931bf1717f3b6802af2d0bdd0aad2", + "web/logout.htm": "ef640d19f21fe339e1eea7ff032b2d16f6bc9a91bdebac269de12ebc96e91991", + "web/macfilter.htm": "0ba33043f8efa0593580d7557e1e11ea8d0d9f0184c1d9f3d1558af80f608eab", + "web/menu-images/menu_bar.gif": "647aded4cdccaa9a9f00d73e66ca92b1bc1d8f9775b76212a3d04504ed5410e4", + "web/menu-images/menu_corner.gif": "39af5e39f3b1e770f7488e44b445624c3ed7271e4c7de8dbb34dd2f4416e7af6", + "web/menu-images/menu_corner_minus.gif": "b1c5de54ea4ab836eff259195d32a578deee31c295288b06d90a8e7e98e085f3", + "web/menu-images/menu_corner_plus.gif": "d39a780f3d0e3283a3ae64e67f9ebf128b17c0b5b1417a06e4f78f09ae619e42", + "web/menu-images/menu_folder_closed.gif": "09e687ff155d3000d791ed036ff0e3f77b8696157eec186257dba3684e372f24", + "web/menu-images/menu_folder_open.gif": "1612a163cef16999411d2b63e12aa2e53c7d3bab1a2b5c2468cb0591727cb39b", + "web/menu-images/menu_link_default.gif": "5f0f5f53e769f08ac4be05097ac5e3474f4a28b1b0215a34805ec9e79297b0bd", + "web/menu-images/menu_pixel.gif": "63b98a0c8568c08fd01a6946a147bca65eff26c8085f1ccb5330aafe0f0dcd15", + "web/menu-images/menu_root.gif": "b38b3fd6af062f1ceda57128c8671b6b906e396cf9d624772614fb62cd23c694", + "web/menu-images/menu_tee.gif": "bf5d4fc1fdbe4b5e3e6a5c468a716ab34e2a1a2ab4478267df7e8351c8d1587b", + "web/menu-images/menu_tee_minus.gif": "bd10a4f7b54a08d5d63e7322f267fabfe383755471cb01186d9c7b16736e3d3b", + "web/menu-images/menu_tee_plus.gif": "a62fcf9bbafcc56e9af47307710dc9c34c9239b2fe8610a6e923c576e62f8a5e", + "web/menu.htm": "133ad67524dede590787680066b45cf58a70c6db2b5b7e3577b45ce44230a105", + "web/menu_empty.html": "9f18250ded12c95522893bbb6dec6c6be3bbfd395b4fce8a2ba0d04469aa743b", + "web/mtmcode.js": "565b4fbc6a66e0ca9802269de674454adcc5187d035f1a94ba93b0978b156334", + "web/ntp.htm": "5e33de238716fb88a914e1c470baaf913eb88702f664cd4ccd3e8c292f8d3f44", + "web/opmode.htm": "c6499fda559e682e5b78345522d1f7806a7152a00de8c4a28a8fe10f2bcd1803", + "web/password.htm": "800ccb6722a5be356a8f9dc1c44034d0f72c6857f5890fcfaae974204c1fe754", + "web/pocket_sitesurvey.htm": "0dce90317c717e55895d44502a14b2acbd72518447d9029f6717f4cbf538ba1f", + "web/portfilter6.htm": "74530b908cc389547fe4596e5eaf7826bc0c57d3ab7fedb8c785a8a7567845ee", + "web/portfw.htm": "938bb94911baec450bc2a49904a7fadb2f27068cc9cd65c02d7f0008d27b38c1", + "web/radvd.htm": "31ea7295aa23b9f4a73ae3fd2ac8f61283391a88780c2ef06d53dfed54c0b439", + "web/reload.htm": "eae613794aad31e1b6edfe9ab0b4695e05c5a977e1d5820810abbaea618cc4e7", + "web/saveconf.htm": "d3e5f98e82cbfc600474236c4e97d24f84e22d589ceb586f46ba69d2cb512bae", + "web/stats.htm": "4bee4161dcbb79b2193dd48fe303f72253a7c81dda1435351944e4fce505cfee", + "web/status.htm": "2f3cc0421f10b19555821466a824f2d769d410b291eed45e34f10e0004516ed9", + "web/style.css": "dfe13bca7a30354e51274a0979ea60af38b1717bcc6135ddcbc2f28dd19fd536", + "web/syscmd.htm": "dca82d34c2877902ae4f9b48dd434c7e0098969f6fcf09dc15e6e97b5c725971", + "web/syslog.htm": "ede23274b094361f7abdd6ff6242bf7ed570552dd14c710026b1af6c1a0d4d5e", + "web/tcpip_staticdhcp.htm": "a3451b01d98df04559ece73c80b931bc92fd8ed43e77f0011ee751044c47baba", + "web/tcpiplan.htm": "4629d26d6da1150971347176160880802ff8a498b2240a5ca22487cea59fc57e", + "web/tcpipwan.htm": "2d4bae96143f5392173f7f3d055b69f3a0fb3b4d68f8cfb9b3e6ded997778a6f", + "web/title.htm": "87a29d29b9b7ae7b28bcf2f1a00002a997fa1038a2b33d88c12b316f0e3164ee", + "web/tunnel6.htm": "6d2bf69b88a08d1efcc22285b1ce206ecc86f520dee2f017cf8d6c272af0d3bc", + "web/upload.htm": "c9ff896145a303104587a4c376df2dad1a7cc4f5ec2c74e87f0130e989c49d01", + "web/upload_st.htm": "11e65da936c795621500b5c1ca3abe428a2bdd2946a39a8086f7b63956f28bd9", + "web/urlfilter.htm": "108ef6504f70587f5311425bd696c22e0f0a23646e3b7f0124533ef438bceaf2", + "web/util_gw.js": "9a20c547b2d34e77039eb330e7cf845e7d252320cf8f40dbc30a252e1cf092c1", + "web/util_qos.js": "ffcccd7675ac37779d846ad43b84ccf2cd683f4b796af4496024f6a74395d104", + "web/vlan.htm": "9e2200ec452e1ce62d61d6b84ce3d83ab9ca1867293769cd23f13962f4cbe472", + "web/wizard.htm": "6f6f9a6ae33f260778698dc22afc7d24e5a967fe346cb75a21f01afa97f6c8fc", + "web/wlactrl.htm": "97edf03d39e15e5709b79a41e3a142760918e155417ba183bda60ed3a5c5b3ca", + "web/wladvanced.htm": "53b78f4f9d9d06d2e1dbe1b7d4bb3aa1538a82dac0631905d6a1603d6aab15ca", + "web/wlan_schedule.htm": "17b329f065b0d1d136c901d4fe9f8bf9aae22e3b285e31ef65c009288f7ed3a1", + "web/wlbandmode.htm": "d7f34cdf8832acc9b96316f76ef52c9c60d063b2ce0c04e160c40341909faab4", + "web/wlbasic.htm": "7c758561c8e7983f98a84bf2cf1264f4a07f26e98000ce6ab9a8065be4b24c20", + "web/wlmeshproxy.htm": "022b01ffdd7df8850cfa603b7c312d80005639f8c5fb6a11c3e48c00928d23ae", + "web/wlmultipleap.htm": "c15318e6b8983a253fe8d828d32a6c053d31223b7539dbabfd9ffc5c0fd74a39", + "web/wlsch.htm": "9fffd931fdd22cf085489f4bd36d3da9f0def6b4bd65a06e0cecea650fde8bd3", + "web/wlsecurity.htm": "378353ff4e39815f9312c6c6cc33c7f9734ba2e44c1afd008953866cf170ad4c", + "web/wlsecurity_all.htm": "6be91b7ba91497d0756dd9162f54b6fca067992c9a9ea9ead8fb5c2791cd71b9", + "web/wlstatbl.htm": "a37f69ff59dc3443c2c896013f56e80cdd6831a7497034e6e6d860ab551a2441", + "web/wlstatbl_vap.htm": "7e3d4f04f66a6665aa0ade7b32a50f57116ef7011de40e1850f9888f9e8aad48", + "web/wlsurvey.htm": "90273cb11ca78519ca079a2d87f3aa143b415f2ae53d0ff7f924ad5d4c5d4c08", + "web/wlwds.htm": "ba327360f7963117f850a1096c0379e505218e6c9c38f877f4105cb1451d7885", + "web/wlwdsenp.htm": "5da00b29e79db94a6382698b70bdfb2e2dd2b0703b0fef79ffb549fc5873db15", + "web/wlwdstbl.htm": "7a53914672c15459ef3e8875e30b84f03ad967222fb61f0e3355701c7abc113d", + "web/wlwps.htm": "ca071459c523b9af21f3104cb92fe0a1c8dd38a76c731e64e3e11707bb9e1e05" + }, + "protected_ranges_all_ff": true, + "credential_files_absent": true + } + }, + "physical_device_access": false +} diff --git a/version.json b/version.json new file mode 100644 index 0000000..bec0783 --- /dev/null +++ b/version.json @@ -0,0 +1,5 @@ +{ + "software_version": 2, + "version": "0.2.0-preview", + "catalog_url": "https://lee98064.github.io/incipio-commandkit/updates/index.json" +} From cc154efe64f31fb54d01bcf917173899240dc476 Mon Sep 17 00:00:00 2001 From: lee98064 <46412433+lee98064@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:38:44 +0800 Subject: [PATCH 02/13] Accumulate estimated kWh with durable energy checkpoints and standard Matter reporting --- .github/workflows/ota.yml | 1 + README.md | 4 +- docs/measurements.md | 22 +- docs/ota-release-notes.md | 2 +- docs/technical-report.md | 2 + docs/updates.md | 12 +- docs/usage.md | 2 +- .../matter/src/matter-app-device/BUILD.gn | 3 +- .../ElectricalMeasurements.cpp | 36 +- .../ElectricalMeasurements.h | 23 +- .../matter/src/matter-app-device/Version.h | 4 +- .../matter/src/matter-app-device/main.cpp | 7 +- firmware/matter/src/relay/EnergyCounter.h | 34 ++ firmware/matter/src/update/ck_common.h | 1 + firmware/matter/src/update/ck_energy.c | 103 ++++ firmware/matter/src/update/ck_energy.h | 15 + firmware/matter/src/update/ck_store.c | 6 +- .../matter/tests/energy_counter_tests.cpp | 33 ++ firmware/matter/tools/test_device_vm.py | 35 +- tests/test_updates.py | 46 +- verification/README.md | 2 + verification/energy-results.json | 149 ++++++ verification/public-audit-v0.2.1-preview.json | 492 ++++++++++++++++++ version.json | 4 +- 24 files changed, 1007 insertions(+), 31 deletions(-) create mode 100644 firmware/matter/src/relay/EnergyCounter.h create mode 100644 firmware/matter/src/update/ck_energy.c create mode 100644 firmware/matter/src/update/ck_energy.h create mode 100644 firmware/matter/tests/energy_counter_tests.cpp create mode 100644 verification/energy-results.json create mode 100644 verification/public-audit-v0.2.1-preview.json diff --git a/.github/workflows/ota.yml b/.github/workflows/ota.yml index 3943895..6ce7a5d 100644 --- a/.github/workflows/ota.yml +++ b/.github/workflows/ota.yml @@ -18,6 +18,7 @@ jobs: - run: python3 tools/generate_update.py - run: python3 tests/test_updates.py - run: node tests/test_update_crypto.js + - 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: needs: test diff --git a/README.md b/README.md index a6cf458..fa4de8d 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ | 內容 | 入口 | |---|---| | 網頁/Matter OTA 與發布新版 | [更新說明](docs/updates.md) | -| 標準電壓、RMS 電流、功率 | [Matter 電力量測](docs/measurements.md) | +| 標準電壓、RMS 電流、功率及累積 kWh | [Matter 電力量測](docs/measurements.md) | | 使用、配網、Matter、診斷 | [使用說明](docs/usage.md) | | 備份、限定區域燒錄、讀回、復原 | [燒錄說明](docs/flashing.md) | | 從自己的原廠備份重建 | [建置說明](docs/build.md) | @@ -23,7 +23,7 @@ | 測試與發布交付狀態 | [驗證索引](verification/README.md) | | 上傳 GitHub 與建立 Release | [發布說明](docs/publishing.md) | -目前來源為 `v0.2.0-preview` 候選版;v0.1 Release 尚無永久 OTA,第一次升級仍須限定區域燒錄。 +目前來源為 `v0.2.1-preview` 候選版;v0.1 Release 尚無永久 OTA,第一次升級仍須限定區域燒錄。 ## 兩種韌體 diff --git a/docs/measurements.md b/docs/measurements.md index 6fb75c1..951c594 100644 --- a/docs/measurements.md +++ b/docs/measurements.md @@ -1,6 +1,6 @@ # Matter 電力量測 -v0.2 在同一個 endpoint 1 宣告 On/Off Plug-in Unit 與 Electrical Sensor,提供標準 ElectricalPowerMeasurement(`0x0090`)及 PowerTopology(`0x009C`,NodeTopology)。已配對且獲 ACL 允許的 Matter controller 可透過標準讀取/訂閱取得相同資料,不使用廠商私有 characteristic 或 Apple 專屬橋接。 +v0.2 在同一個 endpoint 1 宣告 On/Off Plug-in Unit 與 Electrical Sensor,提供標準 ElectricalPowerMeasurement(`0x0090`)、ElectricalEnergyMeasurement(`0x0091`,累積匯入電量)及 PowerTopology(`0x009C`,NodeTopology)。已配對且獲 ACL 允許的 Matter controller 可透過標準讀取/訂閱取得相同資料,不使用廠商私有 characteristic 或 Apple 專屬橋接。 **可配對不等於該系統已實作電力量測 UI。** Matter 1.3 加入標準電力/能源回報;較舊或尚未支援這些叢集的 controller 可能只顯示開關。Apple 官方 Matter framework 提供此類 cluster API,但本專案沒有完成 iPhone 家庭 app 或其他家庭平台的真機 UI 驗收,不能保證每個 app 都會顯示每個欄位。 @@ -17,7 +17,7 @@ v0.2 在同一個 endpoint 1 宣告 On/Off Plug-in Unit 與 Electrical Sensor, 這是依原廠 Vrms/Irms/Power 命名、算式及 300/15/2000 上限採用的**名義 V/A/W 映射**。原機與參考電表比對、非零負載精度及校正仍待驗收,不宣稱計費精度。Accuracy 列出量測類型及名義範圍,沒有填入未經證實的誤差百分比。 -只取得 RMS 電流,不能據此聲稱得到 ActiveCurrent(同相分量);此屬性不列入 AttributeList。未取得的 reactive/apparent power、頻率、功率因數及累積 kWh 也不宣告。沒有已確認的硬體能源計數器,因此此版不提供 ElectricalEnergyMeasurement(`0x0091`),也不把功率積分假裝成硬體總電量。 +只取得 RMS 電流,不能據此聲稱得到 ActiveCurrent(同相分量);此屬性不列入 AttributeList。未取得的 reactive/apparent power、頻率及功率因數不宣告。v0.2.1 新增由功率積分的軟體累積電量,透過標準 ElectricalEnergyMeasurement 回報,Accuracy.Measured=false 表示估算,不宣稱 MCU 有獨立硬體能源計數器。 ## 更新、過期與通知 @@ -25,6 +25,18 @@ v0.2 在同一個 endpoint 1 宣告 On/Off Plug-in Unit 與 Electrical Sensor, 查詢逾時、斷線、超出原廠名義上限,或資料超過 15 秒,屬性為 Matter `null`。有效的 0 保持為 0;失去資料不補 0,不依繼電器狀態推算功率。讀值變更及有效/null 轉換會通知 Matter reporting engine,依 controller 訂閱間隔回報。 +## 累積耗電 kWh + +`CumulativeEnergyImported.Energy` 是自功能初始化以來的累積用電,標準單位為 mWh;除以 **1,000,000** 就是 kWh。1,000 W 持續 1 小時會累積 1 kWh。 + +韌體對相鄰有效功率樣本作梯形積分:`ΔmWh = (前次 mW + 本次 mW) × Δ毫秒 / 7,200,000`。保留除法餘數,避免每 5 秒截斷造成小負載電量消失。只有相鄰、有效且相隔不超過 7.5 秒的樣本才累積;過期、斷線或查詢失敗會清除積分基準,恢復後重新起算,不補算缺測或斷電時間。 + +兩個獨立 4 KiB Flash pages 以 64-byte record 循環記錄總量、餘數及序號,並以 SHA-256、最後寫入的 commit word 及讀回驗證保護。每 page 64 筆、共 128 筆;只有輪替到另一 page 才擦除,保留最近有效紀錄。設定 journal 不會擦除這兩個電量 pages。 + +約每 **30 秒**保存一次,正常停止及 OTA 停止時另嘗試保存。Matter 只回報已確認保存的總量,因此一般斷電重開不會把先前已顯示的數字倒退。突然斷電可能遺失最後約 30 秒尚未保存的積分,加上尚未收到下一個功率樣本的時間;以 2,000 W 負載及 35 秒為例,約對應 0.0195 kWh。Flash 寫入失敗時維持上次已保存值並記錄錯誤,失敗期間損失可能更大,不聲稱完全零損失。 + +新功能起點為 0,不能還原功能安裝前的歷史耗電。保留資料跨重啟及後續 OTA;本版未提供清零操作。舊 journal 若曾用超過新的 61,376-byte payload 容量,會拒絕啟用電量紀錄,保留原資料供遷移,不會為電量強制擦除配對。 + ## 使用標準 controller 驗證 以下 node ID `12344321` 是本機 VM 測試節點,實際使用時改成自己的 node ID: @@ -37,9 +49,13 @@ chip-tool electricalpowermeasurement read voltage 12344321 1 chip-tool electricalpowermeasurement read rmscurrent 12344321 1 chip-tool electricalpowermeasurement read active-power 12344321 1 chip-tool electricalpowermeasurement subscribe active-power 1 10 12344321 1 +chip-tool electricalenergymeasurement read cumulative-energy-imported 12344321 1 +chip-tool electricalenergymeasurement read accuracy 12344321 1 +# 訂閱需使用 chip-tool interactive start 保持控制端運作 +chip-tool electricalenergymeasurement subscribe cumulative-energy-imported 1 60 12344321 1 ``` -模擬機測試使用實際 MIPS Matter stack、完整 commissioning、加密 CASE、kernel TTY 及可控制的 MCU 對端,驗證縮放、訂閱變更、過期 null 及恢復;結果見[驗證紀錄](../verification/ota-results.json)。模擬 MCU 數值只作測試,不植入產品執行路徑。 +模擬機測試使用實際 MIPS Matter stack、完整 commissioning、加密 CASE、kernel TTY 及可控制的 MCU 對端,驗證縮放、訂閱變更、過期 null、恢復、估算電量、缺測暫停及電量跨重啟保存;結果見[驗證紀錄](../verification/energy-results.json)。模擬 MCU 數值只作測試,不植入產品執行路徑。 ## 平台驗收與來源 diff --git a/docs/ota-release-notes.md b/docs/ota-release-notes.md index 70a2382..cce5b97 100644 --- a/docs/ota-release-notes.md +++ b/docs/ota-release-notes.md @@ -6,4 +6,4 @@ 本版屬預覽版本。離線、模擬 Flash 與 VM 測試結果不能代替原機斷電、Flash driver、RF、Matter 控制及復原驗收。原廠槽只提供映像校驗失敗的備援,沒有宣稱完整自動版本回退。應用失敗時可使用維修模式安裝較新修正版。 -Matter 版加入標準 ElectricalPowerMeasurement,提供電壓、RMS 電流、功率與訂閱通知。無有效 MCU 資料時為 null。累積 kWh、實機校正及 Apple/其他家庭平台 UI 未宣稱已驗收,詳見電力量測文件。 +Matter 版加入標準 ElectricalPowerMeasurement,提供電壓、RMS 電流、功率與訂閱通知。無有效 MCU 資料時為 null。累積 kWh 透過標準 ElectricalEnergyMeasurement 提供,標示為估算;實機校正及 Apple/其他家庭平台 UI 仍待驗收,詳見電力量測文件。 diff --git a/docs/technical-report.md b/docs/technical-report.md index e7d3ece..d29bac3 100644 --- a/docs/technical-report.md +++ b/docs/technical-report.md @@ -61,6 +61,8 @@ F-005:E-007 支持同一標準 Matter 資料可由已授權 controller 讀取 P-003(E-006 → F-004):區網上傳/Matter BDX → RAM 暫存 → 驗簽與相容性檢查 → 固定 PID 1 action → RAM root → 限定 rootfs 寫入及讀回 → 重開 → MCU 健康確認版本。完整 Provider 傳輸及原機驗收仍待完成。 +[v0.2.1 驗證](../verification/energy-results.json)補上功率積分的累積用電(EEM 0x0091),以估算標誌及 30 秒獨立 Flash 循環 checkpoint 回報。設定 snapshot 容量改為每 slot 前 60 KiB;末端各 4 KiB 保存電量,不碰原廠資料。積分與中斷寫入驗證見[電力量測](measurements.md)。 + P-004(E-007 → F-005):A5/A6/A7 → 有界待查詢解析 → 原廠名義單位縮放 → 標準 ElectricalPowerMeasurement → 讀取/訂閱;逾時、斷線或過期 → null。 ## 狀態與時序 diff --git a/docs/updates.md b/docs/updates.md index 8cbd24e..a807baf 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -1,6 +1,6 @@ # 安裝更新與發布新版 -v0.2.0-preview 提供區網網頁更新、線上更新索引、Ed25519 簽章,以及 Matter OTA Requestor。Matter fabrics、平台設定及裝置身分改存入 Flash journal。**第一次仍須以 CH341A 安裝支援 OTA 的版本;v0.1.0-preview 沒有永久更新入口。**原機 Flash driver、斷電、RF 及家庭平台驗收尚待完成;已通過的模擬/VM 檢查見 [更新驗證](../verification/ota-results.json)。 +v0.2.1-preview 提供區網網頁更新、線上更新索引、Ed25519 簽章,以及 Matter OTA Requestor。Matter fabrics、平台設定及裝置身分改存入 Flash journal。**第一次仍須以 CH341A 安裝支援 OTA 的版本;v0.1.0-preview 沒有永久更新入口。**原機 Flash driver、斷電、RF 及家庭平台驗收尚待完成;已通過的模擬/VM 檢查見 [v0.2.1 驗證](../verification/energy-results.json)。 ## 在插座網頁更新 @@ -27,10 +27,10 @@ python3 tools/update.py "$OUTLET_IP" --latest ```bash python3 tools/update.py "$OUTLET_IP" \ - --package artifacts/releases/v0.2.0-preview/matter.ota --check-only + --package artifacts/releases/v0.2.1-preview/matter.ota --check-only ``` -第一次安裝 v0.2.0-preview 後,必須發布 `software_version` 大於 2 的更新才能安裝。`--check-only` 不傳送更新,也不重新啟動。 +第一次安裝 v0.2.1-preview 後,必須發布 `software_version` 大於 3 的更新才能安裝。`--check-only` 不傳送更新,也不重新啟動。 ## 更新包與保留範圍 @@ -48,7 +48,7 @@ python3 tools/update.py "$OUTLET_IP" \ ## 設定如何保留 -journal 為兩個 64 KiB snapshot,使用不同序號、SHA-256 與最後寫入的 commit word。只擦除新 snapshot 實際用到的 4 KiB sectors;相同值不寫入。每次寫入都讀回核對,成功後才回覆 Matter 的儲存請求。 +journal 為兩個 64 KiB slots;各 slot 前 60 KiB 為設定 snapshot,最後 4 KiB 是獨立電量 append page。設定 snapshot 使用不同序號、SHA-256 與最後寫入的 commit word。只擦除新 snapshot 實際用到的 4 KiB sectors;相同值不寫入。每次寫入都讀回核對,成功後才回覆 Matter 的儲存請求。 | key 前綴 | 資料 | |---|---| @@ -57,7 +57,7 @@ journal 為兩個 64 KiB snapshot,使用不同序號、SHA-256 與最後寫入 | `platform/` | SDK factory/config/counters/KVS 的 INI 內容,單筆上限 16 KiB | | `ota/` | 已確認版本、待確認版本及啟動嘗試次數 | -總 payload 上限 65,472 bytes。Wi-Fi 原廠 MIB 沿用既有 helper;不用公開更新包重建 MAC/校正或裝置金鑰。新 profile 可從已辨識 journal 恢復相同憑證;已有 profile 與 journal 不符會停止,避免默默換碼。既有 profile 缺少檔案仍須先復原,不會隨機補上新金鑰。 +設定 payload 上限 61,376 bytes;電量另以 128 筆循環紀錄保存在兩個末端 pages,詳見[電量保存](measurements.md)。Wi-Fi 原廠 MIB 沿用既有 helper;不用公開更新包重建 MAC/校正或裝置金鑰。新 profile 可從已辨識 journal 恢復相同憑證;已有 profile 與 journal 不符會停止,避免默默換碼。既有 profile 缺少檔案仍須先復原,不會隨機補上新金鑰。 SDK 原本寫入 `/tmp/*.ini` 的平台設定也會同步保存至 journal。此修改保留 upstream 授權,來源見 `src/sdk-overrides/CHIPLinuxStorage.cpp` 與 [第三方說明](../third_party/README.md)。 @@ -75,7 +75,7 @@ Matter 必須連續 30 秒取得新鮮 MCU 狀態才確認新版本;診斷版 維護者的私鑰保存在自己的 `private/ota-signing.key`,公鑰是 `third_party/ota-signing.pub`。發布既有裝置更新時必須沿用原金鑰;不要重新執行 keygen。fork 的維護者須自行產生金鑰,替換其來源公鑰,並首次安裝自己的信任來源。 -1. 修改 `version.json`,增加 `software_version` 並更新版本名稱;例如數值 3、名稱 `0.3.0-preview`。 +1. 修改 `version.json`,增加 `software_version` 並更新版本名稱;例如數值 4、名稱 `0.3.0-preview`。 2. 執行完整建置;工具也會產生 Matter 執行檔的版本紀錄,拒絕混用舊執行檔或測試 backend: ```bash diff --git a/docs/usage.md b/docs/usage.md index 0b3b2e5..e475f22 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -14,7 +14,7 @@ Matter On/Off/Toggle 會送出 MCU 命令並等待狀態確認;按鍵回報同 v0.2 的 fabrics/配對紀錄存入 Flash journal,後續重開及 OTA 沿用相同身分與配對。從 v0.1 首次轉入仍需重新配對。本版不啟動原廠 HomeKit server;[新版更新服務](updates.md)使用簽章包。保留 Bank 2 不代表應用崩潰時 bootloader 必然自動切換。 -電力量測使用標準 Matter cluster,可讀取並訂閱電壓、RMS 電流與功率。MCU 無有效資料時為 null;各平台是否顯示取決於其 cluster 支援,見 [讀值及平台驗收](measurements.md)。 +電力量測使用標準 Matter cluster,可讀取並訂閱電壓、RMS 電流、功率與軟體累積電量(kWh)。MCU 無有效資料時為 null;各平台是否顯示取決於其 cluster 支援,見 [讀值及平台驗收](measurements.md)。 ## 執行檔診斷版 diff --git a/firmware/matter/src/matter-app-device/BUILD.gn b/firmware/matter/src/matter-app-device/BUILD.gn index c4119aa..39e0079 100644 --- a/firmware/matter/src/matter-app-device/BUILD.gn +++ b/firmware/matter/src/matter-app-device/BUILD.gn @@ -10,7 +10,7 @@ config("app_config") { } executable("matterd-test") { - sources = [ "main.cpp", "DeviceIdentity.cpp", "MatterOta.cpp", "ElectricalMeasurements.cpp", "../update/ck_store.c", "../update/ck_common.c", "../update/ck_flash.c", "../vendor/qrcodegen/qrcodegen.c", "../platform_probe.cpp", "../sdk-overrides/TLVReader.cpp", "../sdk-overrides/CHIPLinuxStorage.cpp", "../relay/RelayEngine.cpp", "../relay/RelayEngine.h", "../relay/McuController.cpp", "../relay/McuController.h" ] + sources = [ "main.cpp", "DeviceIdentity.cpp", "MatterOta.cpp", "ElectricalMeasurements.cpp", "../update/ck_store.c", "../update/ck_energy.c", "../update/ck_common.c", "../update/ck_flash.c", "../vendor/qrcodegen/qrcodegen.c", "../platform_probe.cpp", "../sdk-overrides/TLVReader.cpp", "../sdk-overrides/CHIPLinuxStorage.cpp", "../relay/RelayEngine.cpp", "../relay/RelayEngine.h", "../relay/McuController.cpp", "../relay/McuController.h" ] sources += [ "${chip_root}/src/app/clusters/ota-requestor/BDXDownloader.cpp", "${chip_root}/src/app/clusters/ota-requestor/DefaultOTARequestor.cpp", @@ -32,6 +32,7 @@ executable("matterd-test") { "${chip_root}/src/app/clusters/identify-server", "${chip_root}/src/app/clusters/ota-requestor", "${chip_root}/src/app/clusters/electrical-power-measurement-server", + "${chip_root}/src/app/clusters/electrical-energy-measurement-server", "${chip_root}/src/app/clusters/power-topology-server", "${chip_root}/src/data-model-providers/codedriven", "${chip_root}/src/credentials", diff --git a/firmware/matter/src/matter-app-device/ElectricalMeasurements.cpp b/firmware/matter/src/matter-app-device/ElectricalMeasurements.cpp index e7dfa68..35017f3 100644 --- a/firmware/matter/src/matter-app-device/ElectricalMeasurements.cpp +++ b/firmware/matter/src/matter-app-device/ElectricalMeasurements.cpp @@ -1,7 +1,9 @@ #include "ElectricalMeasurements.h" +#include "../update/ck_energy.h" using namespace chip; using namespace chip::app; namespace EPM = Clusters::ElectricalPowerMeasurement; +namespace EEM = Clusters::ElectricalEnergyMeasurement; ElectricalMeasurements::PowerCluster::PowerCluster(ElectricalMeasurements & owner) : ElectricalPowerMeasurementCluster({1, owner, BitMask(EPM::Feature::kAlternatingCurrent), @@ -10,16 +12,20 @@ ElectricalMeasurements::PowerCluster::PowerCluster(ElectricalMeasurements & owne .Set() .Set()}) {} -ElectricalMeasurements::ElectricalMeasurements() { +ElectricalMeasurements::ElectricalMeasurements(TimerDelegate & timer) : + mEnergyCluster({1,BitMask(EEM::Feature::kImportedEnergy,EEM::Feature::kCumulativeEnergy), + {},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]; } } CHIP_ERROR ElectricalMeasurements::Register(CodeDrivenDataModelProvider & model) { ReturnErrorOnFailure(model.AddCluster(mTopologyRegistration)); - return model.AddCluster(mPowerRegistration); + ReturnErrorOnFailure(model.AddCluster(mPowerRegistration)); + return model.AddCluster(mEnergyRegistration); } void ElectricalMeasurements::Unregister(CodeDrivenDataModelProvider & model) { + LogErrorOnFailure(model.RemoveCluster(&mEnergyCluster)); LogErrorOnFailure(model.RemoveCluster(&mPowerCluster)); LogErrorOnFailure(model.RemoveCluster(&mTopology)); } @@ -34,6 +40,13 @@ CHIP_ERROR ElectricalMeasurements::GetAccuracyByIndex(uint8_t index, Accuracy & return CHIP_NO_ERROR; } void ElectricalMeasurements::Poll(const incipio::RelaySnapshot & s, uint64_t now) { + mCounter.Observe(s.powerValid,s.powerRaw*1000,s.powerAt,now); + LogErrorOnFailure(SaveEnergy()); + // Advertise only durable totals, so a reported value cannot roll back merely + // because the device loses power before its next checkpoint. + if(mEnergyReady && (mFirstEnergyReport || mReportedMwh!=mSavedMwh)) { + mEnergyCluster.GenerateSnapshots();mFirstEnergyReport=false;mReportedMwh=mSavedMwh; + } auto value=[](bool valid, uint64_t at, uint64_t current, int64_t numerator, int64_t divisor) -> Value { if (!valid || current-at>15000) { return {}; } return DataModel::MakeNullable((numerator+divisor/2)/divisor); @@ -47,3 +60,22 @@ void ElectricalMeasurements::Poll(const incipio::RelaySnapshot & s, uint64_t now if (current!=mCurrent) { mCurrent=current; mPowerCluster.Changed(EPM::Attributes::RMSCurrent::Id); } if (power!=mPower) { mPower=power; mPowerCluster.Changed(EPM::Attributes::ActivePower::Id); } } + +CHIP_ERROR ElectricalMeasurements::InitEnergy(bool persistent) { + mPersistent=persistent; + uint64_t total=0;uint32_t remainder=0; + int result=persistent ? ck_energy_load(&total,&remainder) : 1; + if(result<0 || !mCounter.Restore(total,remainder)) return CHIP_ERROR_PERSISTED_STORAGE_FAILED; + if(persistent && result==1 && ck_energy_save(0,0)) return CHIP_ERROR_PERSISTED_STORAGE_FAILED; + mSavedMwh=total;mSavedRemainder=remainder;mEnergyReady=true; + mLastSaveAttempt=incipio::McuController::Now();return CHIP_NO_ERROR; +} +CHIP_ERROR ElectricalMeasurements::SaveEnergy(bool force) { + if(!mEnergyReady) return CHIP_NO_ERROR; + uint64_t now=incipio::McuController::Now(); + if(!force && now-mLastSaveAttempt<30000) return CHIP_NO_ERROR; + mLastSaveAttempt=now; + if(mSavedMwh==mCounter.TotalMwh() && mSavedRemainder==mCounter.Remainder()) return CHIP_NO_ERROR; + 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; +} diff --git a/firmware/matter/src/matter-app-device/ElectricalMeasurements.h b/firmware/matter/src/matter-app-device/ElectricalMeasurements.h index 4b65b2d..a6d3a29 100644 --- a/firmware/matter/src/matter-app-device/ElectricalMeasurements.h +++ b/firmware/matter/src/matter-app-device/ElectricalMeasurements.h @@ -1,5 +1,7 @@ #pragma once #include "../relay/McuController.h" +#include "../relay/EnergyCounter.h" +#include #include #include #include @@ -7,7 +9,8 @@ // Native Matter attributes; no manufacturer-specific cluster or ecosystem bridge. // MCU Vrms / Irms are RMS values. Never label Irms as the in-phase ActiveCurrent. class ElectricalMeasurements final : public chip::app::Clusters::ElectricalPowerMeasurement::Delegate, - public chip::app::Clusters::PowerTopology::Delegate { + public chip::app::Clusters::PowerTopology::Delegate, + public chip::app::Clusters::ElectricalEnergyMeasurement::Delegate { using Value = chip::app::DataModel::Nullable; using Accuracy = chip::app::Clusters::ElectricalPowerMeasurement::Structs::MeasurementAccuracyStruct::Type; using Range = chip::app::Clusters::ElectricalPowerMeasurement::Structs::MeasurementAccuracyRangeStruct::Type; @@ -16,6 +19,16 @@ class ElectricalMeasurements final : public chip::app::Clusters::ElectricalPower explicit PowerCluster(ElectricalMeasurements & owner); void Changed(chip::AttributeId id) { NotifyAttributeChanged(id); } }; + incipio::EnergyCounter mCounter; + bool mPersistent=false, mEnergyReady=false, mFirstEnergyReport=true; + uint64_t mSavedMwh=0, mLastSaveAttempt=0, mReportedMwh=0; + uint32_t mSavedRemainder=0; + Range mEnergyRange{.rangeMin=0,.rangeMax=int64_t(incipio::EnergyCounter::kMaxMwh)}; + Accuracy mEnergyAccuracy{.measurementType=chip::app::Clusters::detail::MeasurementTypeEnum::kElectricalEnergy, + .measured=false,.minMeasuredValue=0,.maxMeasuredValue=int64_t(incipio::EnergyCounter::kMaxMwh), + .accuracyRanges=chip::app::DataModel::List(&mEnergyRange,1)}; + chip::app::Clusters::ElectricalEnergyMeasurement::ElectricalEnergyMeasurementCluster mEnergyCluster; + chip::app::ServerClusterRegistration mEnergyRegistration{mEnergyCluster}; Value mVoltage, mCurrent, mPower; Range mRanges[4]{}; PowerCluster mPowerCluster{*this}; @@ -24,7 +37,13 @@ class ElectricalMeasurements final : public chip::app::Clusters::ElectricalPower {1, *this, chip::BitMask(chip::app::Clusters::PowerTopology::Feature::kNodeTopology)}}; chip::app::ServerClusterRegistration mTopologyRegistration{mTopology}; public: - ElectricalMeasurements(); + explicit ElectricalMeasurements(chip::TimerDelegate & timer); + CHIP_ERROR InitEnergy(bool persistent); + CHIP_ERROR SaveEnergy(bool force=false); + Value GetCumulativeEnergyImported() override { return mEnergyReady ? chip::app::DataModel::MakeNullable(int64_t(mSavedMwh)) : Value{}; } + Value GetCumulativeEnergyExported() override { return {}; } + Value GetPeriodicEnergyImported() override { return {}; } + Value GetPeriodicEnergyExported() override { return {}; } CHIP_ERROR Register(chip::app::CodeDrivenDataModelProvider & model); void Unregister(chip::app::CodeDrivenDataModelProvider & model); void Poll(const incipio::RelaySnapshot & snapshot, uint64_t now); diff --git a/firmware/matter/src/matter-app-device/Version.h b/firmware/matter/src/matter-app-device/Version.h index d26c8a5..996ea71 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 2 -#define CK_VERSION_STRING "0.2.0-preview" +#define CK_SOFTWARE_VERSION 3 +#define CK_VERSION_STRING "0.2.1-preview" diff --git a/firmware/matter/src/matter-app-device/main.cpp b/firmware/matter/src/matter-app-device/main.cpp index dc1808b..70416c3 100644 --- a/firmware/matter/src/matter-app-device/main.cpp +++ b/firmware/matter/src/matter-app-device/main.cpp @@ -148,13 +148,15 @@ class TestIdentify final : public Clusters::IdentifyDelegate { }; static const DataModel::DeviceTypeEntry plugTypes[] = {Device::Type::kOnOffPlugInUnit, Device::Type::kElectricalSensor}; class TestPlug final : public SingleEndpoint { - ElectricalMeasurements measurements; + ElectricalMeasurements measurements{timer}; RelayOnOff onoff; ServerClusterRegistration onoffRegistration{onoff}; TestIdentify identifyDelegate; LazyRegisteredServerCluster identify; public: TestPlug():SingleEndpoint(Span(plugTypes)){} + CHIP_ERROR InitEnergy(bool persistent) { return measurements.InitEnergy(persistent); } + void SaveEnergy() { LogErrorOnFailure(measurements.SaveEnergy(true)); } 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)); @@ -183,7 +185,7 @@ static std::unique_ptr model; static std::unique_ptr root; static TestPlug plug; static void PollHardware(System::Layer * layer, void *) { - if (stopping) { hardware.Stop(); _exit(0); } + if (stopping) { plug.SaveEnergy(); hardware.Stop(); _exit(0); } plug.Poll(); auto snapshot=hardware.Snapshot();uint64_t now=incipio::McuController::Now(); if(!isVm&&!healthConfirmed){ @@ -249,6 +251,7 @@ int main(int argc,char**argv){ .deviceLoadStatusProvider=*InteractionModelEngine::GetInstance(),.diagnosticDataProvider=GetDiagnosticDataProvider(), .testEventTriggerDelegate=nullptr,.dacProvider=*Credentials::GetDeviceAttestationCredentialsProvider(), .eventManagement=EventManagement::GetInstance(),.timerDelegate=timer}); + TRY(plug.InitEnergy(!vm || persistentVm)); TRY(root->Register(0,*model));TRY(plug.Register(1,*model)); TRY(RegisterMatterOta(*model)); params.dataModelProvider=model.get();params.interfaceId=Inet::InterfaceId(index); diff --git a/firmware/matter/src/relay/EnergyCounter.h b/firmware/matter/src/relay/EnergyCounter.h new file mode 100644 index 0000000..ec7183e --- /dev/null +++ b/firmware/matter/src/relay/EnergyCounter.h @@ -0,0 +1,34 @@ +#pragma once +#include +#include +namespace incipio { +// Trapezoidal integration of adjacent measured samples. Half-mW.ms units +// preserve all fractional mWh without floating point or per-sample rounding. +class EnergyCounter { + uint64_t mTotal=0, mAt=0; + uint32_t mRemainder=0, mPower=0; + bool mPrevious=false; +public: + static constexpr uint64_t kMaxMwh=uint64_t(1)<<62; + static constexpr uint32_t kDenominator=7200000; + static constexpr uint64_t kMaxGapMs=7500; + bool Restore(uint64_t total,uint32_t remainder) { + if(total>kMaxMwh || remainder>=kDenominator) return false; + mTotal=total;mRemainder=remainder;mPrevious=false;return true; + } + void Observe(bool valid,uint32_t powerMw,uint64_t at,uint64_t now) { + if(!valid || powerMw>2000000 || at>now || now-at>kMaxGapMs) { mPrevious=false;return; } + if(mPrevious && at==mAt) return; + if(mPrevious && at>mAt && at-mAt<=kMaxGapMs) { + uint64_t numerator=(uint64_t(mPower)+powerMw)*(at-mAt)+mRemainder; + uint64_t increment=numerator/kDenominator; + if(increment<=kMaxMwh-mTotal) { + mTotal+=increment;mRemainder=uint32_t(numerator%kDenominator); + } + } + mAt=at;mPower=powerMw;mPrevious=true; + } + uint64_t TotalMwh() const { return mTotal; } + uint32_t Remainder() const { return mRemainder; } +}; +} diff --git a/firmware/matter/src/update/ck_common.h b/firmware/matter/src/update/ck_common.h index f79f71f..2643ae0 100644 --- a/firmware/matter/src/update/ck_common.h +++ b/firmware/matter/src/update/ck_common.h @@ -11,6 +11,7 @@ extern "C" { #define CK_SLOT_SIZE 0x10000u #define CK_PART_SIZE 0x2b0000u #define CK_ERASE_SIZE 0x1000u +#define CK_STORE_PAYLOAD_MAX (CK_SLOT_SIZE - CK_ERASE_SIZE - 64u) #define CK_OTA_HEADER 256u const char *ck_path(const char *path); uint32_t ck_be32(const uint8_t *p); diff --git a/firmware/matter/src/update/ck_energy.c b/firmware/matter/src/update/ck_energy.c new file mode 100644 index 0000000..ab7c2a6 --- /dev/null +++ b/firmware/matter/src/update/ck_energy.c @@ -0,0 +1,103 @@ +#include "ck_energy.h" +#include "ck_common.h" +#include +#include +#include +#include +#define RECORD 64u +#define COUNT (2u*CK_ERASE_SIZE/RECORD) +#define ENERGY_MAX (UINT64_C(1)<<62) +#define DENOMINATOR 7200000u +static unsigned offset(unsigned i) { + return CK_JOURNAL_OFFSET+(i/(CK_ERASE_SIZE/RECORD))*CK_SLOT_SIZE+ + CK_SLOT_SIZE-CK_ERASE_SIZE+(i%(CK_ERASE_SIZE/RECORD))*RECORD; +} +static int erased(const uint8_t *p,unsigned n) { + while(n--) if(*p++!=255) return 0; + return 1; +} +static int valid(const uint8_t *p) { + uint8_t hash[32]; + if(memcmp(p,"CKENRG1\n",8) || memcmp(p+60,"CKOK",4) || !ck_be64(p+8) || + ck_be64(p+16)>ENERGY_MAX || ck_be32(p+24)>=DENOMINATOR) return 0; + ck_sha256(p,28,hash);return ck_equal(hash,p+28,32); +} +static int scan(struct ck_flash *f,uint8_t pages[2*CK_ERASE_SIZE],int *latest) { + *latest=-1; + /* Never take space occupied by an older, oversized snapshot. */ + for(unsigned i=0;i<2;i++) { + uint8_t header[64]; + if(ck_flash_read(f,CK_JOURNAL_OFFSET+i*CK_SLOT_SIZE,header,sizeof(header))) return -1; + if(!memcmp(header,"CKSTORE1",8) && !memcmp(header+60,"CKOK",4) && + ck_be32(header+16)>CK_STORE_PAYLOAD_MAX) return -1; + if(ck_flash_read(f,offset(i*(CK_ERASE_SIZE/RECORD)),pages+i*CK_ERASE_SIZE,CK_ERASE_SIZE)) return -1; + } + for(unsigned i=0;i=0 && ck_be64(p+8)==ck_be64(pages+*latest*RECORD+8) && + !ck_equal(p,pages+*latest*RECORD,RECORD)) return -1; + if(*latest<0 || ck_be64(p+8)>ck_be64(pages+*latest*RECORD+8)) *latest=(int)i; + } + if(*latest<0 && !erased(pages,2*CK_ERASE_SIZE)) { + /* A complete initial zero body with an interrupted commit can retry + * into the next erased record. Never reset an unknown/corrupt counter. */ + uint8_t first[RECORD];memcpy(first,pages,RECORD);memcpy(first+60,"CKOK",4); + if(!valid(first) || ck_be64(first+8)!=1 || ck_be64(first+16) || ck_be32(first+24) || + !erased(pages+60,4) || !erased(pages+RECORD,sizeof(uint8_t)*2*CK_ERASE_SIZE-RECORD)) return -1; + } + return 0; +} +int ck_energy_load(uint64_t *mwh,uint32_t *remainder) { + int 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; + if(scan(&f,pages,&latest)) goto closed; + *mwh=latest<0?0:ck_be64(pages+latest*RECORD+16); + *remainder=latest<0?0:ck_be32(pages+latest*RECORD+24); + result=latest<0?1:0; +closed:close(f.fd); +done:ck_unlock(lock);return result; +} +int ck_energy_save(uint64_t mwh,uint32_t remainder) { + 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]; + if(lock<0) return -1; + if(ck_flash_open(&f,1,1)) goto done; + if(scan(&f,pages,&latest)) goto closed; + uint64_t seq=latest<0?0:ck_be64(pages+latest*RECORD+8); + 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 +#ifdef __cplusplus +extern "C" { +#endif +/* Two dedicated 4 KiB append pages, outside configuration snapshot payloads. + * Returns 0 for a verified checkpoint, 1 for erased storage, -1 on failure. + */ +int ck_energy_load(uint64_t *mwh, uint32_t *remainder); +int ck_energy_save(uint64_t mwh, uint32_t remainder); +#ifdef __cplusplus +} +#endif +#endif diff --git a/firmware/matter/src/update/ck_store.c b/firmware/matter/src/update/ck_store.c index cfee21f..1342143 100644 --- a/firmware/matter/src/update/ck_store.c +++ b/firmware/matter/src/update/ck_store.c @@ -64,7 +64,7 @@ static int load(struct ck_flash *f, int *active, uint64_t *seq, unsigned *n) { return 0; } uint64_t sa = ck_be64(snapshots[0] + 8), sb = ck_be64(snapshots[1] + 8); - if (a && b && sa == sb && !ck_equal(snapshots[0], snapshots[1], CK_SLOT_SIZE)) + if (a && b && sa == sb && !ck_equal(snapshots[0], snapshots[1], HEADER + ck_be32(snapshots[0]+16))) return -1; *active = (!a || (b && sb > sa)) ? 1 : 0; *seq = ck_be64(snapshots[*active] + 8); @@ -92,7 +92,7 @@ static int entry(const char *key, unsigned n, unsigned *at, unsigned *size) { return 0; } static int save(struct ck_flash *f, int active, uint64_t seq, unsigned n) { - if (seq == UINT64_MAX) + if (seq == UINT64_MAX || n > CK_STORE_PAYLOAD_MAX) return -1; uint8_t digest[32], commit[4]; memset(work, 255, HEADER); @@ -188,7 +188,7 @@ static int change(const char *key, const void *value, size_t size, int remove) { goto closed; } unsigned old = found ? (unsigned)(4 + k + v) : 0; - if (n - old + (remove ? 0 : 4 + k + size) > PAYLOAD_MAX) + if (n - old + (remove ? 0 : 4 + k + size) > CK_STORE_PAYLOAD_MAX) goto closed; if (found) { memmove(work + HEADER + at, work + HEADER + at + old, n - at - old); diff --git a/firmware/matter/tests/energy_counter_tests.cpp b/firmware/matter/tests/energy_counter_tests.cpp new file mode 100644 index 0000000..8e59d71 --- /dev/null +++ b/firmware/matter/tests/energy_counter_tests.cpp @@ -0,0 +1,33 @@ +#include "../src/relay/EnergyCounter.h" +#include +#include +using incipio::EnergyCounter; +static unsigned checks; +static void check(bool ok,const char *message) { ++checks;if(!ok){std::fprintf(stderr,"FAIL %s\n",message);std::exit(1);} } +int main() { + EnergyCounter hour; + for(uint64_t t=0;t<=3600000;t+=5000) hour.Observe(true,1000000,t,t); + check(hour.TotalMwh()==1000000 && hour.Remainder()==0,"1000 W for 1 hour = 1 kWh"); + EnergyCounter small; + for(uint64_t t=0;t<=3600000;t+=5000) small.Observe(true,1,t,t); + check(small.TotalMwh()==1 && small.Remainder()==0,"fractional mWh carried across all samples"); + EnergyCounter ramp;ramp.Observe(true,0,0,0);ramp.Observe(true,1000000,5000,5000); + check(ramp.TotalMwh()==694 && ramp.Remainder()==3200000,"trapezoidal ramp"); + auto fraction=ramp.Remainder();ramp.Observe(true,1000000,5000,5100); + check(ramp.TotalMwh()==694 && ramp.Remainder()==fraction,"duplicate sample is not counted twice"); + ramp.Observe(false,1000000,5000,5500);ramp.Observe(true,1000000,10000,10000); + check(ramp.TotalMwh()==694,"invalid interval not interpolated"); + ramp.Observe(true,1000000,20000,20000);check(ramp.TotalMwh()==694,"long gap not filled"); + ramp.Observe(true,1000000,20000,30000);ramp.Observe(true,1000000,30000,30000); + check(ramp.TotalMwh()==694,"stale sample clears integration baseline"); + EnergyCounter restart;check(restart.Restore(ramp.TotalMwh(),ramp.Remainder()),"restore checkpoint"); + restart.Observe(true,1000000,100,100);check(restart.TotalMwh()==694,"no cross-boot time integration"); + restart.Observe(true,1000000,5100,5100); + check(restart.TotalMwh()==2083 && restart.Remainder()==2400000,"restore keeps sub-mWh residue"); + check(!restart.Restore(1,EnergyCounter::kDenominator),"invalid residue refused"); + check(!restart.Restore(EnergyCounter::kMaxMwh+1,0),"invalid total refused"); + EnergyCounter cap;check(cap.Restore(EnergyCounter::kMaxMwh,0),"maximum valid total"); + cap.Observe(true,2000000,0,0);cap.Observe(true,2000000,5000,5000); + check(cap.TotalMwh()==EnergyCounter::kMaxMwh,"overflow cannot wrap total"); + std::printf("energy_counter=PASS checks=%u\n",checks); +} diff --git a/firmware/matter/tools/test_device_vm.py b/firmware/matter/tools/test_device_vm.py index 7e38e69..c85d661 100644 --- a/firmware/matter/tools/test_device_vm.py +++ b/firmware/matter/tools/test_device_vm.py @@ -157,6 +157,15 @@ def mcu_when_ready(): if re.search(r'ActivePower:\s*625000\b',subscription_log.read_text(errors='replace')):break time.sleep(.1) else:raise RuntimeError('changed power subscription report missing') + subscriber.stdin.write(b'electricalenergymeasurement subscribe cumulative-energy-imported 1 10 12344321 1 --keepSubscriptions true\n');subscriber.stdin.flush() + until=time.monotonic()+40;initial=None + while time.monotonic()initial:break + time.sleep(.1) + else:raise RuntimeError('cumulative energy subscription update missing') finally: if subscriber.poll() is None: subscriber.stdin.write(b'exit\n');subscriber.stdin.flush() @@ -165,7 +174,7 @@ def mcu_when_ready(): subscriber.stdin.close() report.setdefault('commands',[]).append({'name':'meter-subscribe','returncode':subscriber.returncode, 'log':str(subscription_log.relative_to(OUT)),'sha256':sha(subscription_log)}) - report['checks'].append('standard active-power subscription reports changed MCU sample') + report['checks'].append('standard active-power and cumulative-energy subscriptions report MCU sample and durable energy changes') mcu.meter_drop=True;time.sleep(16) for attribute in ['voltage','rmscurrent','active-power']: rc,text=tool('meter-stale-'+attribute,['electricalpowermeasurement','read',attribute,'12344321','1']) @@ -177,6 +186,30 @@ def mcu_when_ready(): assert rc!=0,'RMS current must not be relabelled ActiveCurrent' report['checks'].append('stale metering becomes null, fresh readings recover; unsupported ActiveCurrent rejected') + def read_energy(name): + rc,text=tool(name,['electricalenergymeasurement','read','cumulative-energy-imported','12344321','1']) + match=re.search(r'Energy:\s*(\d+)',text) + assert rc==0 and match,'standard cumulative energy missing' + return int(match[1]) + time.sleep(31) + energy_before=read_energy('energy-before-restart');assert energy_before>0,'energy did not accumulate' + rc,text=tool('energy-accuracy',['electricalenergymeasurement','read','accuracy','12344321','1']) + assert rc==0 and re.search(r'Measured:\s*FALSE',text,re.I),'software energy must identify itself as estimated' + mcu.meter_drop=True;time.sleep(32) + frozen=read_energy('energy-gap-start');time.sleep(8) + assert read_energy('energy-gap-end')==frozen,'missing power must not manufacture energy' + if PERSISTENT: + count=console.read_text(errors='replace').count('READY: interface=') + vm.stdin.write(b'restart\n');vm.stdin.flush() + until=time.monotonic()+35 + while time.monotonic()I',before,0x3c0010,64000);before[0x3c003c:0x3c0040]=b'CKOK' + self.flash.write_bytes(before);self.assertNotEqual(self.lib.ck_energy_save(0,0),0) + self.assertEqual(self.flash.read_bytes(),before) def test_persistence_and_noop_wear(self): self.assertEqual(self.put('matter/fabric',b'paired fabric secret'),0) before=self.flash.read_bytes();self.assertEqual(self.put('matter/fabric',b'paired fabric secret'),0);self.assertEqual(before,self.flash.read_bytes()) @@ -113,10 +151,12 @@ def test_verification_rejects_wrong_or_damaged_images(self): self.assertEqual(self.flash.read_bytes(),before) 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) before=self.flash.read_bytes();self.assertEqual(self.run_updater('--stage',self.ota).returncode,0) self.assertEqual(self.run_updater('--simulate').returncode,0) after=self.flash.read_bytes();self.assertEqual(after[START:END],rootfs());self.assertEqual(before[:START],after[:START]);self.assertEqual(before[END:],after[END:]) self.assertEqual(self.get('matter/fabric'),(0,b'paired')) + self.assertEqual(self.energy(),(0,1234567,17)) def test_interrupted_write_leaves_invalid_magic_and_original_bank(self): before=self.flash.read_bytes();self.assertEqual(self.run_updater('--stage',self.ota).returncode,0) os.environ['CK_FAIL_OTA_AFTER']='4';self.assertNotEqual(self.run_updater('--simulate').returncode,0) diff --git a/verification/README.md b/verification/README.md index 6abb22d..da2f623 100644 --- a/verification/README.md +++ b/verification/README.md @@ -1,5 +1,7 @@ # 驗證索引 +v0.2.1 的軟體累積 kWh、獨立 Flash 循環紀錄及標準能源訂閱見 [電量結果](energy-results.json)。 + v0.2 的新增測試見 [OTA/持久儲存/量測結果](ota-results.json);以下歷史 v0.1 結果與新版本分開保存。 歷史結果見 [test-results.json](test-results.json)、[公開包稽核](public-audit.json)、[flashrom dummy backend](dummy-flashrom.json)。公開 manifest 的 hash 對應本次檔案;原始 VM console/配對記錄只留在本機。 diff --git a/verification/energy-results.json b/verification/energy-results.json new file mode 100644 index 0000000..c28cf21 --- /dev/null +++ b/verification/energy-results.json @@ -0,0 +1,149 @@ +{ + "version": "0.2.1-preview", + "software_version": 3, + "date": "2026-10-14", + "status": "pass_for_listed_fixture_and_vm_checks", + "physical_device_access": false, + "native": { + "status": "pass", + "tests": 20, + "log_sha256": "2ac9c7dc28ffb8a4559305f209ab77323264393b4957125346feb3a1078cae04" + }, + "mips": { + "status": "pass", + "tests": 20, + "log_sha256": "9ed57090d20804b659e12ce69019fc0afc4cc2e44a251662024d3b4493be5f72" + }, + "deployment": { + "status": "pass", + "tests": 11, + "log_sha256": "b279e839c450f702b0df9e60a41a69749130deb196939a833ce206b85ac8b964" + }, + "integration_math": { + "status": "pass", + "checks": 14, + "vectors": [ + "1000 W for 1 hour = 1 kWh", + "sub-mWh remainder retention", + "trapezoidal ramp", + "duplicate sample ignored", + "invalid/stale/long gaps not filled", + "fractional checkpoint restore", + "no cross-boot interpolation", + "bounds and overflow" + ] + }, + "flash_checks": [ + "two-page ring wraps across 260 records", + "configuration and all other bytes preserved", + "torn body and interrupted erase retain preceding committed energy", + "initial zero interrupted commit can retry", + "unknown tail and oversized legacy snapshot refused without erase", + "firmware write preserves accumulated energy and fabric" + ], + "storage": { + "record_bytes": 64, + "page_bytes": 4096, + "pages": 2, + "records": 128, + "checkpoint_seconds": 30, + "configuration_payload_max": 61376, + "physical_page_starts": [ + "0x3cf000", + "0x3df000" + ], + "matter_reports": "only verified durable mWh; Accuracy.Measured=false" + }, + "pid1": { + "status": "pass", + "environment": "native Linux PID1 in private container namespaces; regular-file NOR fixture; separate MIPS writer suite", + "checks": [ + "real process quiesce and pivot_root branch", + "updater executable copied to ramfs", + "rootfs written and read back", + "all protected bytes and journal unchanged", + "namespace reboot" + ], + "physical_device_access": false, + "container_exit_code": 129, + "updater_sha256": "d6c0853157fe57e5d9248463339e6434eea23550fc064abf0e4b7d2c3879b9d2" + }, + "matter_vm": { + "status": "pass", + "environment": "MIPS Malta/24Kc Linux 4.19 VM, 64 MiB RAM, 8 MiB daemon AS; simulated serial MCU", + "binary_sha256": "1faab3b4f6c6ed952c254cf40ee3a9db4b3d833d952b664ee2637cd7da4be4b2", + "full_commissioning": true, + "checks": [ + "cold boot sends OFF and awaits MCU confirmation", + "full development commissioning, default attestation verifier", + "OTA Requestor cluster available over encrypted Matter session", + "process reboot retains fabrics; encrypted CASE command succeeds without commissioning again", + "standard ElectricalPowerMeasurement: 109000 mV, 1500 mA RMS and 500000 mW over CASE", + "standard active-power and cumulative-energy subscriptions report MCU sample and durable energy changes", + "stale metering becomes null, fresh readings recover; unsupported ActiveCurrent rejected", + "standard cumulative imported mWh is estimated, pauses during missing samples and survives reboot", + "10 encrypted On/Off/Toggle commands including duplicates confirmed through kernel TTY", + "unsolicited physical-button frame reflected in Matter attribute", + "MCU timeout returns failure; subsequent command recovers", + "Identify command accepted by integrated LED delegate" + ], + "mcu_tx_frames": 197, + "mcu_bad_frames": [] + }, + "production_elf_sha256": "628e6f8cacfe1d089c5c21fb0bdea94747cea86a64b7fd3e7d6f2a092be8345d", + "artifacts": { + "SHA256SUMS": { + "bytes": 837, + "sha256": "b24cd45b854388f12300e0a197a1fa0f26ed8e330f3af2a645e85bb4ac114977" + }, + "exec-test-8m-region.bin": { + "bytes": 8388608, + "sha256": "8439fa6cc935a0ffad28f0a9454e7a48f6f5f810b9fbd99841a6ac28eab48f29" + }, + "exec-test-8m-region.json": { + "bytes": 679, + "sha256": "61579dc0d589142707944fdaf52aa53f05ae03ab2db772ec372df70756ac7c77" + }, + "exec-test.json": { + "bytes": 610, + "sha256": "db1c1e1e9fd78fca4cf7ce47e8617fe6b6216f830669bf1c532cbafaa7ac1ae7" + }, + "exec-test.ota": { + "bytes": 2687232, + "sha256": "8d2d8eb75b2084afb1d80ecc5c546f904abb0ae305aac15e87732807347a9273" + }, + "index.json": { + "bytes": 1547, + "sha256": "7db45f9e17d6ab7afc0cd651dafa7984e02a2f1a998cef8407cb346c7f1937a6" + }, + "matter-8m-region.bin": { + "bytes": 8388608, + "sha256": "05272b491d34e48b96ebfe4918c8668c8a590885497052756a7dc3d3edab144f" + }, + "matter-8m-region.json": { + "bytes": 676, + "sha256": "686170453699b904de902c619f33b331650d57597b5b9f4300493c7a4258c58b" + }, + "matter-matter-ota.bin": { + "bytes": 2687321, + "sha256": "4c356b40de491fedd8775030d9fe9c9e079149811404920aae490adc4a2b128f" + }, + "matter.json": { + "bytes": 607, + "sha256": "db26e04d63f2ef0ff23b3cfee0df3c2587fe635b1a9e64f80e935dd40bcb9720" + }, + "matter.ota": { + "bytes": 2687232, + "sha256": "fc3ecff9c2dc2c95cb1e7218b5386673935404cb645651d9a6ffce3946dfc690" + } + }, + "limitations": [ + "No original-hardware calibration or power-cut/MTD-driver validation", + "No Apple Home or other phone UI validation; controller must support standard energy clusters", + "Complete Matter Provider-to-installed-version path remains unverified", + "Sudden power loss can discard about 30 seconds of uncommitted integration plus one sample interval; storage failure can extend that loss", + "Missing samples and time without power are not estimated; counter starts at feature initialization, not factory lifetime", + "Legacy configuration payloads over 61376 bytes require migration; energy log refuses to erase them" + ], + "maximum_mwh": 4611686018427387904 +} diff --git a/verification/public-audit-v0.2.1-preview.json b/verification/public-audit-v0.2.1-preview.json new file mode 100644 index 0000000..cc40bf6 --- /dev/null +++ b/verification/public-audit-v0.2.1-preview.json @@ -0,0 +1,492 @@ +{ + "status": "pass", + "scope": "selected public files and decompressed release rootfs", + "public_file_count": 5047, + "public_bytes": 24804627, + "variants": { + "matter": { + "sha256": "05272b491d34e48b96ebfe4918c8668c8a590885497052756a7dc3d3edab144f", + "size": 8388608, + "regular_files": 231, + "file_sha256": { + "bin/WACServer": "fc7fad2d10fd83927f82f5471bcf70fcb853ab54a140aa6dad27682efd02e63f", + "bin/acltd": "1f3cd73bed035ad5a7b1215a7a31dd43ec441231cafa05c6017fbfc2e2af2cb7", + "bin/auth": "af1f4e5f83842233b9c3ba499e1fe019bf45e682787220faab58500f3d855031", + "bin/avahi-autoipd": "e3ca507667b99a4fcbc0ed8d0d640abc3998332e24691ee50e04d5c3e1c63c8b", + "bin/boa": "175c66efa68eabda38c8ca6990e824ddcc41dfec0219bc739bce7b8ffe0a587a", + "bin/brctl": "f4acc1905b421d52f5e8e6e24f483080f64194d3e40896f52659214569521218", + "bin/busybox": "3cd0ec8270214a74879ee4b67e592be285e23d6a9cca964b64a86b88c821e619", + "bin/ck-lease": "38e9987f8fc53109113f8aefba380f3c5a4e2575ec053d1ec82b140d28ccbe66", + "bin/ck-ota-start": "4d5a1ec59c9b32ba52b64a66975216f72c71a6df2bd0d66de9ccaa4aff85391c", + "bin/ck-ram-probe": "edca27a1c7932af7e5088829ac0f1b43ffbebbeffe4476e7cf2dae4b32b037c6", + "bin/ck-setup-backend": "226d6b78cd8c7163809c62391a587a285c88fb2720d2d2accc850cbcb3ed8616", + "bin/ck-setup-start": "744a44317ea3a7ff2cc76a7b6d2372b72e2498191d6a1f5ea26d3e10f898eecb", + "bin/ck-setupd": "5b5dee66061b2860906bfec429eefd089b5196f6ad14473815838fb3fb5ac954", + "bin/ck-udhcpc": "97eb31d69d3514a5a552b075a5089527ef95917e29ee08105a830996d3ac6b85", + "bin/ck-updated": "ebe7798937e6a291d803ecaec25de15689be86d44901239f2851e7d416ba0b87", + "bin/ck-updater": "40146dfc0887c2968ffa82d835eb081a4595aa9c3db16d336ae1d628d56bb6a3", + "bin/ck-wificfg": "350402de512d6e124346f24a39695b0fdb2b3b4ea06ec0c5bbaf1e852f0ab2dd", + "bin/ck-wifiscan": "e9f77c51d6424501c8703351b8f23f181fb471ed652256f386ef623746975721", + "bin/connect.sh": "116a58d9caa45a8a83a43b7db5e42505b3e7640e4cdee06e62c6dcc7b576b433", + "bin/connect6.sh": "97e8946dc7c28bdbde8beda01e87315e3bedf07e1704db140c8c889a036dd0aa", + "bin/ddns_inet": "d1d3bc14909540e6f17f07d72ae6d208fefc4bfb9aa33c97fbc784d2986affb4", + "bin/dhcp6c": "50cc13f2cb4b10d2a44a90750760cf8bc7227b40d39a8bb1c1a6cec45d1873cb", + "bin/dhcp6cRcv.sh": "72d0f7430a95c2e5405d3b469a44cc788cac215eb8e5d91f7182e366f595e206", + "bin/dhcp6ctl": "c391c1a0828c2e5c7a2e4892fa7dce1dd28018f2a0610bdb9ac1c623f9cc77ae", + "bin/dhcp6s": "794c877af3c4bef3d3f457e1afc7ae9bd635bc775ec8bb4f4152176e73b26823", + "bin/disconnect.sh": "ec469e947e3e2e4c9bf0aa0a6d95a4a24d9bb7521ed467d5bd183a74dfef6798", + "bin/dnrd": "55c99a83c659cbd127e75b7c34c13f73853f9c6e612271bd311f81a1a711ebd8", + "bin/dnsmasq": "541c75037f62bc3a104989ece61b044463370f15219c4c4ae21e7f68ceb159b6", + "bin/dw": "5ef838655702ff0ab7cb8f5993a31e5903afc1e437a17e44f4c4842124d72266", + "bin/ecmh": "b56eb2a6e88722ba361042b329d67b3d5bc3634aa083690c949596c239e7f81f", + "bin/ew": "1a3e6bc1afc8345387dc9a38887e6af4adde619bc8743c25f0366df6928a649a", + "bin/firewall.sh": "5688eba3b9e7f322c8e7b1e2e831f6e999d6fcec125283fc8de75d144e42e7f1", + "bin/flash": "28aca13b5d8df12040dd2c6ffb3dc69e61bfd1e0c860d25447f3a08e66963523", + "bin/fwd": "698eb8a02fcc0f2dcfca6702ed766f7338dfe51f4df6f85c72a2a78d789a190c", + "bin/getmib": "e3c872196f6afe6501514101614cc8b8ece902e6f8b6b9a1ad352a10c2a61e25", + "bin/getmib1": "89bc956fe6fd4e5e134c8769e611253fecf50272e2396ae65015e44d5d3d8b44", + "bin/gigadevice_chk": "f555ae6b974c2e7303072a1441820b171907993dd389e062557c7ac9d5191a19", + "bin/homekitserver": "2d50339436759f3d602c150fb4f1a42f0fdf122fc18e9a4f54f6e6b07d7a0c62", + "bin/iapp": "0dda7f960b1b0340f03f1823dc457b0b2fef84dd887d3b07d2158e5845f78bdb", + "bin/ib": "23540feab73c9f0f287e4f91284343ec8677aac9e5b1f51bb2bcf0db1075e697", + "bin/ib1": "439aeeffb8b3253384713b53f95120a2206f97911c64ebb90366c487ec1f2024", + "bin/id1": "cead109ab787def7dc01e63c4cdbb782493f588cecd709aee61a298b263d6dbd", + "bin/idd": "fd0a6763249a6064351ba787fbadce230b9ccfdeacff4f3de662b0c40b46df50", + "bin/idd1": "cead109ab787def7dc01e63c4cdbb782493f588cecd709aee61a298b263d6dbd", + "bin/init.sh": "37a89df20b2efa6b9e4268320fae90e29774dc894168a257f25b89f452e7bda2", + "bin/ip_qos.sh": "0bbaa983ce78b27b561a9c780c3ba9264563d4a38b61209f1898fc83a5473ee0", + "bin/irf": "848040ed72207dc5a47d3f67d6f54f8ce6eb3f36c38104089aa438d55114bfcc", + "bin/irf1": "9e7d8c249b22c9e65e9dc3050bff451f3bdeaf9b4eb8db6bbec5b9b39899eb0d", + "bin/iw": "82e621203cfa87228dc1bb520f2837bf7833346a0fc9f16757970f8ed07f11da", + "bin/iw1": "0ee09886704fa633ecd6111c1fe8ce373054e2c5feed8924a006255017ff8987", + "bin/iwcontrol": "5df1e0c326ef676d8dcab9885e079b9b25bc956b5aba6cb1e99af5dc11c5ae0a", + "bin/iwpriv": "0d54f6042b99bfbecbbeb68152a462f8a54663407a7ecf804c2c8bca2d6da6d9", + "bin/killsh.sh": "402bc7a2845df34d9c15049c38dac2688cd448995a908def3a27438fc762c019", + "bin/l2tp.sh": "075ba0c8e3a533efcbe14d42f66d3ef1163571fc22759e249ffcca17496ec0bb", + "bin/matter-diag": "86dc2ddeb26a64578751cf16404402c0580d4beb7c73b615aa35466c2e6d8a2f", + "bin/matter-diag-start": "d214053c374cbe396097f76a8fa97927a767fad3620bfc40ab1116e38fb5c31a", + "bin/matter-test-start": "5105d81cf6ad9ae0a834cdea346b3f474a41659b98402343eb7b83ef52290e8d", + "bin/matterd-test": "628e6f8cacfe1d089c5c21fb0bdea94747cea86a64b7fd3e7d6f2a092be8345d", + "bin/mdnsd": "7d5243636c0870e9cf32f4ecda0d494c6b47091da70cd470911e5961430ca177", + "bin/mldproxy": "eed2c2737eeefc4e808ce088b5c2ee7611fb93e414c826d4bf116218b51d48c3", + "bin/mmd_cmdr": "b71745208ba972d3a02991af759d4827b81c2e5156995c47e09b558b640cb163", + "bin/mmd_cmdw": "aeb2542634581cccaf6a9e76a41b69147c840663b2aff43b652ad5a1c4a5a346", + "bin/mp.sh": "e41e053700105de8511c57a984cbd03347381bcc48497ac391bde8bf6b68cb99", + "bin/ntp_inet": "79d56c4369c418fb4a4920f0394aec7c1e7ccccc9dbe119bfb0518f2395abcfa", + "bin/ntpclient": "e736240aab44bdd9d701bafe6eeaf383fa98878e57cb2913126e524185678aec", + "bin/ob": "452a3031a31802e8a7a79e200d2ef1c149ddb045a83ca9faea72133f1d10d4bd", + "bin/ob1": "c3f3f85889c443c69b17daa93fe0aa27e1e2dca99fa7e0f36b5579e749c1dc7b", + "bin/od": "be0f8099e8e1440e10b1b7cf6bf4ed063ff926887c4334fd47c941fe05c5635d", + "bin/od1": "aaae9dc1b71c3ee083efb9fc3b4862be9a32deb117a8b2edac5a3a057226e213", + "bin/orf": "6de1b42f0975076302439227c5542c353faff0293ff49c27932bec9b94d373c8", + "bin/orf1": "0d83ffdf74e8b6153062b24c765cb562bae115bb6e94ceccf40e198a26268b99", + "bin/ow": "3e06231474fe83569add90f05af1fbd478e243da89fa7767a6f1e27efab5b2ac", + "bin/ow1": "cb4d076d2a8efc2168e5a5d67b3da4ac3b0eec54b9fecf0735d3a4dc5602b07e", + "bin/phyr": "0ea4bf45ead3a0da7d55feaeef12bcfb095e63f69a70a842672f36b008065e80", + "bin/phytrex_tools": "a4733e1b2b8c52e48c109c9c40a2b9af532248e4b4116eb72260b1f0f20e6fc2", + "bin/phyw": "830bfdb9473fae12ab9aa8298d7aa3f9831bff63e7393a7f2b29dd530f134545", + "bin/ppp_inet": "44b1db22dc2b786e90ed558175915a9eef926a7818bd43f1c675724a06b77109", + "bin/pppoe.sh": "28d59a56e07f42c240ec59a7ac5a784371d00cef92f1984943232b3050ca011b", + "bin/pppoe_conn_patch.sh": "b6da8ac9633bf1a4a2bc665945247882ef8181e1fca9e65f3a8e7af631cc1e7e", + "bin/pppoe_disc_patch.sh": "025aac686216515eb57a2a26f5bb7b7c507f66b48c5404d8502dc0f414f1cb93", + "bin/pptp.sh": "c79acbc24a203398e6858f464065d65dc83eb0087bb3d6fd0ac47ed294a7754b", + "bin/radvd": "9db7fe7c307562b562fc1a96a33ece6b53be4cafe7bc63648d09a8c4567135b6", + "bin/radvdump": "c4c9694b057105652c461308b901dfe02da3bdf8901175dfcd8d112e6a21d71e", + "bin/reload": "22f689f717c259ecbeb628cc051fa4d82a3011146f2f4e48fd37cd5380184be3", + "bin/rssi": "091d5107b5c27b266252585b7c9992df4b2270704f81dc939eb1bbccc7a0ee6d", + "bin/rssi1": "1cca70b5b1cca70afdfa9637b180d904e0236cc487ac55d8ab1395283dcb8c67", + "bin/rtk_cmd": "89dbb16d504b668c48abae30b0d99724c20df7ce1a35faa55cc18f887bc7d7bb", + "bin/rtk_eventd": "1638b8ace10a21145ce26df5c835e8e8aef72bfbfd4f6ff13a91f3894ba9be38", + "bin/setmib": "2025b4ed81e89dcad0a58d4411c101ca67f0d6fdb85b1eb73539049f8675a3a3", + "bin/setmib1": "89ef32a13b8888d9e2b2f1e6a84c849f0d35e73e03c94c82d5be11f68f59209b", + "bin/snmpd.sh": "014ca345c401ea958db53e7dcb7c4dc050198f680e9a746410268a7a84a0875b", + "bin/startup.sh": "d620040cff0aa1ada99a9d68d1fc8436cd335ace89481880c5c2daf70a7e8fdb", + "bin/sysconf": "a88ffd520ea3382bcd1b656c1c2b4e02a416619da2fc87d4fe108ee08e76c9a0", + "bin/timelycheck": "cf7afc7be127701b340c4ddcfacfeb690af40041b6c93fc37e29e63c0ce41511", + "bin/udhcpd": "6c65f86b575cc3956504e37b9688fe6b0cafae8a38938aab56a2973b0802a699", + "bin/wfaudio": "ba269a6c1565c02b8322d18e3d40e89de6f7f98097a90d0ce06e3452218d5d22", + "bin/wlanapp.sh": "a45b6d40a8b126283a575f0bbf1bc97f076f39c58f1ca74f27bcb0997aaa563f", + "etc/avahi-autoipd.action": "6e14205c720227f5294bfb9348dc1071dcdcc914723695619a10d5e11c395176", + "etc/boa/boa.conf": "6236c22da5f177dcaad1bd6ec70ce3dca62cb1a56daf83b668933cd8c0aef6e1", + "etc/boa/mime.types": "1e3e9fe650ec67e99cafeddcfbaf52a11f910666ec11d0f2c7a264392622b9bb", + "etc/ck-udhcpd.conf": "53e4458bbbb97de591d528efeeaa146eeaba2d5c7612d7b2389cadb4b8123819", + "etc/ck-update.meta": "13f6e6fe2acd1100929b7721cb7a91cca1f3a12c768a647d9ed52db070a5f6e7", + "etc/ck-update.pub": "386eb1742a602b5e9bd2007bcffd3a39c0a467500f92a1856d7c6059c88f5a2c", + "etc/ck-variant": "acbc154cdaa2d015cd86d03b5b1ce12da9accf509e6c00ddb6c7316b760f6209", + "etc/ck-version": "f0fa4bcbb6bcc60f7b4606521c4ff94842e910add0d94b1a0f5c7db319c0f668", + "etc/customize.version": "44f87003455f39f4bb1e8fd1be8342c591b7c7fff44143d6949f6adbee4600b1", + "etc/dnsmasq.conf": "0ae2408b285a62bce4c8b0c7fab85e350889699d22feff1b6adcc54253bb95d4", + "etc/group": "5148fde8988f4762c1a14039adf6ca7cfcf3b201c71cd48332da60ca69ff49a8", + "etc/host.conf": "313728062c7ae797e11187c7f1d66b559f0098115320166bca6100486c167824", + "etc/hosts": "f80e3d5c3a0e4517e650bccee94c8033131e263d8458b37a18dcee440cb3819b", + "etc/init.d/rcS": "2e8254920a09c4c068a6506f5be87f7b00bc54124b8b246ddff5aea4e3982eff", + "etc/init.d/rcS_32M": "54b23c1e0a0a9a4f02484f6287e21f60588d0a98a790e76e9d9b4af3578237a0", + "etc/inittab": "7728ecf0138d2ed4b1408204e8c7b0c7eacfe650b1fe29cadfe941031b476e3a", + "etc/mime.types": "8d0a3ce3b74daccb330e158471cab898d40d4e40ed8a74ff9d710909a82cdf6e", + "etc/motd": "3b727708cd2efa362aa751c12c8d2e9e0e2050cb07b3d646dae2c6f4bcdd1cb3", + "etc/passwd": "8e619aab9bdf9ddd2644bed3ac319e862e908c4664b862120c2f4209c401bd32", + "etc/radvd.conf": "9ef8db8aa53148d8671e93b9d2879f18e1aec399ccba92aae324271796b5868e", + "etc/samba/smb.conf": "a56cfd4403599a01be6a78bfadcb1e6cc5177f7b4022e3a17e2ca861d2eb7c6d", + "etc/samba/smbpasswd": "2187a31165dc5cb2190130abdc04d6c5fef3d78ffc609b390b74a444f8e76c94", + "etc/services": "6ae28a7390a44890f3709f772dd5a2d8246880b40a734f1317cb117eea6de748", + "etc/shadow.sample": "106bf188d92eae79b255c106cb9f05777b5ba225ddef95132fb1da552b81caac", + "etc/svn.version": "9b3e52b747c1564c1a2b4eb757b829ae5d5095a0f591b8bcb49549364e75d461", + "etc/ushare.conf": "8e1b479172c4f1c7478a6658bae1a1049b607e7aacd24f2c40ff415b6e3da057", + "etc/version": "de64bb7381aaec500c2765ea8b939c7f1cc1f806609a932ba410e9e30a8957a8", + "etc/vsftpd.conf": "c86e2c41c3c17be5510db27e4998fbf372751727c340dce68362012d47744e92", + "lib/ld-uClibc-0.9.30.3.so": "35051ff978746f071c792d3d262b14f7e78dbbc461a9fd5c1e56a73966b0cb30", + "lib/libapmib.so": "e88721af68971ac96e949e7a918ae59e339cea1a3e7ecc9d40d59d5024e62824", + "lib/libdaemon.so.0": "a6ba44569b6a17ae9806aa89eb0bca7018946cc3aa4e167cf6b67e1f17ed8910", + "lib/libdl-0.9.30.3.so": "fd211b05beb2139a4e97a2441001be7554170e2602ac3c5298b228571cd1e3cf", + "lib/libdns_sd.so": "b0175db146f751120165c1d93a46be5e9da19f3e4201f13dbf0365fc053fddad", + "lib/libgcc_s.so.1": "c8f9860a3518dc0d6b78ff6640240aeb68e9b38a59aaf37bdc86bc1c0cd361d1", + "lib/libm-0.9.30.3.so": "bd31abbb878d32393b26573ae5b6055d1e73fa8126b4ade9a76465458d8bff2f", + "lib/libpthread-0.9.30.3.so": "7cde98eebc3ee088f150497282edb955b9d9aa1fb72f65603b0f6505d4919872", + "lib/librt-0.9.30.3.so": "ba9ebba8c75a71046716ac9ed2c37a25534ccf0647e0b123d8aea010738db2df", + "lib/libuClibc-0.9.30.3.so": "3bdde33cb95c8a4ce9a786d654f3e87f7a86e667201f14cea4fa33ee6749c278", + "usr/share/udhcpc/br0.renew": "0f225c0c1b9feeb416f46c86f729eabde73518f6920f26bf4dfaa8d501d54221", + "usr/share/udhcpc/br0.sh": "8be90d840672e3038b1e60ab59c206672479a28fb7168c9e2d9ffb267f3f787e", + "usr/share/udhcpc/eth0.bound": "394691277bad5f0f1a6b708d95e7ed57d6a0432ead294af299727f08c8c4904e", + "usr/share/udhcpc/eth0.sh": "f663605c21f57feadba977443b7b2ee298803752664b7b64207af0a75a27a17f", + "usr/share/udhcpc/eth1.bound": "e721f2ad88cbdfa3f37c5a7053a05c15766955f4b2d5ee9f7c7f7a195a7ad89b", + "usr/share/udhcpc/eth1.renew": "0f225c0c1b9feeb416f46c86f729eabde73518f6920f26bf4dfaa8d501d54221", + "usr/share/udhcpc/eth1.sh": "87e4066153462feec83c9ff7666812eb258bc4d38550e3498b31c33617441e7b", + "usr/share/udhcpc/wlan0-vxd.bound": "cf1ad0dc6a1fd3fd71b839a47e2c8ccce076db67608d7b87e412aa92010c02d3", + "usr/share/udhcpc/wlan0-vxd.sh": "092105e71c24ea2a1ecd4e8516b8bf32cd0468343bc803516f8b2e7baa54ec2e", + "usr/share/udhcpc/wlan0.bound": "cf1ad0dc6a1fd3fd71b839a47e2c8ccce076db67608d7b87e412aa92010c02d3", + "usr/share/udhcpc/wlan0.sh": "b60b564cf829f019db892c4b2c05d0950557f45e0cb8c33d8b1a0b11f4fd289c", + "usr/share/udhcpc/wlan1-vxd.bound": "cf1ad0dc6a1fd3fd71b839a47e2c8ccce076db67608d7b87e412aa92010c02d3", + "usr/share/udhcpc/wlan1-vxd.sh": "ad88d272a3ae0f31bfcff86a1e08be1342f57511615693782a7fb11a93b50189", + "usr/share/udhcpc/wlan1.bound": "cf1ad0dc6a1fd3fd71b839a47e2c8ccce076db67608d7b87e412aa92010c02d3", + "usr/share/udhcpc/wlan1.sh": "f4f1548b05bef9b69c13c32c2f157017b9623a946870774fd32e2246f8192d4c", + "web/code.htm": "ef39c1dcc759b7d9f614fe6b8049ddf2a9122e9d9e81a614849e5f5700afea0a", + "web/countDownPage.htm": "b4d199553f8dfa58f0a19c8923be713c8fd6de556bbd1190ad412e5295536cab", + "web/ddns.htm": "3c66f89fe65b19061a42d09ccdbd7d8abe3a2812189f1e0b57a9a0cea73e45a7", + "web/dhcp6s.htm": "05234b252c5c08471952974dfa9c30254d3baf12efc537ac1765d9b8c71af22f", + "web/dhcptbl.htm": "969251aeabe941809d38398f0168ed3ba8b06d3a33534c537b7442950534e81b", + "web/dmz.htm": "5b067a0e5c07e015a81ba3bb0556f6e73192ad044f2ad69bab3b2a34820f58d9", + "web/dnsv6.htm": "86a8711db19229c3f2680d64e45df06cdcdfc5d336d7db49f917907dba7a5b92", + "web/dos.htm": "86e3fc95e66b4d75cde12767dc5812e019dfc4718b897068ac1c79380a060458", + "web/favicon.ico": "fdce9e8910ba9bc5ea2357c8002568c6f434a80175f381d882bbd71feffd910e", + "web/graphics/back.gif": "0e9ced1019385b1101fdaa7c07d01d63aae771176a44d3df81d47b115b5128aa", + "web/graphics/dir.gif": "16f741d14a9af2cf05588d4ca748dd9a4bba9780c464f424076258b3911807ee", + "web/graphics/router.gif": "69a4f526ad2a35621afc9e9eabd1dbbb4f24f3f0e0496a1bf62b8c3d17363eca", + "web/graphics/text.gif": "ad62db6395e4e4bc21d0442062659db0f5ce93656e8d6464c172328dff5b2ac8", + "web/graphics/topbar.gif": "295b5e68db8a2e727b60c5d142b32661daca0722f3de4981aee91c77c49abda3", + "web/home.htm": "c39b5a02b4a8d9b72ced04cbdb4b881186533fad9a699e28086455f61d1cf880", + "web/index.htm": "af6083187e85b9711ce81f6fc86ec2a4dd31a7f578414359de3a3ccffce73076", + "web/index.html": "169ae52b96b7129c3a8c7a9d848f219c49a1b47bb88e417829b047e20d255852", + "web/ip6_qos.htm": "9927ebc69e819f57761419019c6896a0bbcfd79b4b1baa84b24a359153b1c25a", + "web/ip6filter.htm": "0b7b40aac77aae6482a97c1ac5c277b318a1624d541c5ff40572d63b352acc8f", + "web/ipv6_basic.htm": "af8f5f372f4e053ed8b74e970936d1456cbb4cb940eec19726532886d6bf7618", + "web/ipv6_wan.htm": "597459af6fd8763e565d8848890aac17de4931bf1717f3b6802af2d0bdd0aad2", + "web/logout.htm": "ef640d19f21fe339e1eea7ff032b2d16f6bc9a91bdebac269de12ebc96e91991", + "web/macfilter.htm": "0ba33043f8efa0593580d7557e1e11ea8d0d9f0184c1d9f3d1558af80f608eab", + "web/menu-images/menu_bar.gif": "647aded4cdccaa9a9f00d73e66ca92b1bc1d8f9775b76212a3d04504ed5410e4", + "web/menu-images/menu_corner.gif": "39af5e39f3b1e770f7488e44b445624c3ed7271e4c7de8dbb34dd2f4416e7af6", + "web/menu-images/menu_corner_minus.gif": "b1c5de54ea4ab836eff259195d32a578deee31c295288b06d90a8e7e98e085f3", + "web/menu-images/menu_corner_plus.gif": "d39a780f3d0e3283a3ae64e67f9ebf128b17c0b5b1417a06e4f78f09ae619e42", + "web/menu-images/menu_folder_closed.gif": "09e687ff155d3000d791ed036ff0e3f77b8696157eec186257dba3684e372f24", + "web/menu-images/menu_folder_open.gif": "1612a163cef16999411d2b63e12aa2e53c7d3bab1a2b5c2468cb0591727cb39b", + "web/menu-images/menu_link_default.gif": "5f0f5f53e769f08ac4be05097ac5e3474f4a28b1b0215a34805ec9e79297b0bd", + "web/menu-images/menu_pixel.gif": "63b98a0c8568c08fd01a6946a147bca65eff26c8085f1ccb5330aafe0f0dcd15", + "web/menu-images/menu_root.gif": "b38b3fd6af062f1ceda57128c8671b6b906e396cf9d624772614fb62cd23c694", + "web/menu-images/menu_tee.gif": "bf5d4fc1fdbe4b5e3e6a5c468a716ab34e2a1a2ab4478267df7e8351c8d1587b", + "web/menu-images/menu_tee_minus.gif": "bd10a4f7b54a08d5d63e7322f267fabfe383755471cb01186d9c7b16736e3d3b", + "web/menu-images/menu_tee_plus.gif": "a62fcf9bbafcc56e9af47307710dc9c34c9239b2fe8610a6e923c576e62f8a5e", + "web/menu.htm": "133ad67524dede590787680066b45cf58a70c6db2b5b7e3577b45ce44230a105", + "web/menu_empty.html": "9f18250ded12c95522893bbb6dec6c6be3bbfd395b4fce8a2ba0d04469aa743b", + "web/mtmcode.js": "565b4fbc6a66e0ca9802269de674454adcc5187d035f1a94ba93b0978b156334", + "web/ntp.htm": "5e33de238716fb88a914e1c470baaf913eb88702f664cd4ccd3e8c292f8d3f44", + "web/opmode.htm": "c6499fda559e682e5b78345522d1f7806a7152a00de8c4a28a8fe10f2bcd1803", + "web/password.htm": "800ccb6722a5be356a8f9dc1c44034d0f72c6857f5890fcfaae974204c1fe754", + "web/pocket_sitesurvey.htm": "0dce90317c717e55895d44502a14b2acbd72518447d9029f6717f4cbf538ba1f", + "web/portfilter6.htm": "74530b908cc389547fe4596e5eaf7826bc0c57d3ab7fedb8c785a8a7567845ee", + "web/portfw.htm": "938bb94911baec450bc2a49904a7fadb2f27068cc9cd65c02d7f0008d27b38c1", + "web/radvd.htm": "31ea7295aa23b9f4a73ae3fd2ac8f61283391a88780c2ef06d53dfed54c0b439", + "web/reload.htm": "eae613794aad31e1b6edfe9ab0b4695e05c5a977e1d5820810abbaea618cc4e7", + "web/saveconf.htm": "d3e5f98e82cbfc600474236c4e97d24f84e22d589ceb586f46ba69d2cb512bae", + "web/stats.htm": "4bee4161dcbb79b2193dd48fe303f72253a7c81dda1435351944e4fce505cfee", + "web/status.htm": "2f3cc0421f10b19555821466a824f2d769d410b291eed45e34f10e0004516ed9", + "web/style.css": "dfe13bca7a30354e51274a0979ea60af38b1717bcc6135ddcbc2f28dd19fd536", + "web/syscmd.htm": "dca82d34c2877902ae4f9b48dd434c7e0098969f6fcf09dc15e6e97b5c725971", + "web/syslog.htm": "ede23274b094361f7abdd6ff6242bf7ed570552dd14c710026b1af6c1a0d4d5e", + "web/tcpip_staticdhcp.htm": "a3451b01d98df04559ece73c80b931bc92fd8ed43e77f0011ee751044c47baba", + "web/tcpiplan.htm": "4629d26d6da1150971347176160880802ff8a498b2240a5ca22487cea59fc57e", + "web/tcpipwan.htm": "2d4bae96143f5392173f7f3d055b69f3a0fb3b4d68f8cfb9b3e6ded997778a6f", + "web/title.htm": "87a29d29b9b7ae7b28bcf2f1a00002a997fa1038a2b33d88c12b316f0e3164ee", + "web/tunnel6.htm": "6d2bf69b88a08d1efcc22285b1ce206ecc86f520dee2f017cf8d6c272af0d3bc", + "web/upload.htm": "c9ff896145a303104587a4c376df2dad1a7cc4f5ec2c74e87f0130e989c49d01", + "web/upload_st.htm": "11e65da936c795621500b5c1ca3abe428a2bdd2946a39a8086f7b63956f28bd9", + "web/urlfilter.htm": "108ef6504f70587f5311425bd696c22e0f0a23646e3b7f0124533ef438bceaf2", + "web/util_gw.js": "9a20c547b2d34e77039eb330e7cf845e7d252320cf8f40dbc30a252e1cf092c1", + "web/util_qos.js": "ffcccd7675ac37779d846ad43b84ccf2cd683f4b796af4496024f6a74395d104", + "web/vlan.htm": "9e2200ec452e1ce62d61d6b84ce3d83ab9ca1867293769cd23f13962f4cbe472", + "web/wizard.htm": "6f6f9a6ae33f260778698dc22afc7d24e5a967fe346cb75a21f01afa97f6c8fc", + "web/wlactrl.htm": "97edf03d39e15e5709b79a41e3a142760918e155417ba183bda60ed3a5c5b3ca", + "web/wladvanced.htm": "53b78f4f9d9d06d2e1dbe1b7d4bb3aa1538a82dac0631905d6a1603d6aab15ca", + "web/wlan_schedule.htm": "17b329f065b0d1d136c901d4fe9f8bf9aae22e3b285e31ef65c009288f7ed3a1", + "web/wlbandmode.htm": "d7f34cdf8832acc9b96316f76ef52c9c60d063b2ce0c04e160c40341909faab4", + "web/wlbasic.htm": "7c758561c8e7983f98a84bf2cf1264f4a07f26e98000ce6ab9a8065be4b24c20", + "web/wlmeshproxy.htm": "022b01ffdd7df8850cfa603b7c312d80005639f8c5fb6a11c3e48c00928d23ae", + "web/wlmultipleap.htm": "c15318e6b8983a253fe8d828d32a6c053d31223b7539dbabfd9ffc5c0fd74a39", + "web/wlsch.htm": "9fffd931fdd22cf085489f4bd36d3da9f0def6b4bd65a06e0cecea650fde8bd3", + "web/wlsecurity.htm": "378353ff4e39815f9312c6c6cc33c7f9734ba2e44c1afd008953866cf170ad4c", + "web/wlsecurity_all.htm": "6be91b7ba91497d0756dd9162f54b6fca067992c9a9ea9ead8fb5c2791cd71b9", + "web/wlstatbl.htm": "a37f69ff59dc3443c2c896013f56e80cdd6831a7497034e6e6d860ab551a2441", + "web/wlstatbl_vap.htm": "7e3d4f04f66a6665aa0ade7b32a50f57116ef7011de40e1850f9888f9e8aad48", + "web/wlsurvey.htm": "90273cb11ca78519ca079a2d87f3aa143b415f2ae53d0ff7f924ad5d4c5d4c08", + "web/wlwds.htm": "ba327360f7963117f850a1096c0379e505218e6c9c38f877f4105cb1451d7885", + "web/wlwdsenp.htm": "5da00b29e79db94a6382698b70bdfb2e2dd2b0703b0fef79ffb549fc5873db15", + "web/wlwdstbl.htm": "7a53914672c15459ef3e8875e30b84f03ad967222fb61f0e3355701c7abc113d", + "web/wlwps.htm": "ca071459c523b9af21f3104cb92fe0a1c8dd38a76c731e64e3e11707bb9e1e05" + }, + "protected_ranges_all_ff": true, + "credential_files_absent": true + }, + "exec-test": { + "sha256": "8439fa6cc935a0ffad28f0a9454e7a48f6f5f810b9fbd99841a6ac28eab48f29", + "size": 8388608, + "regular_files": 234, + "file_sha256": { + "bin/WACServer": "fc7fad2d10fd83927f82f5471bcf70fcb853ab54a140aa6dad27682efd02e63f", + "bin/acltd": "1f3cd73bed035ad5a7b1215a7a31dd43ec441231cafa05c6017fbfc2e2af2cb7", + "bin/auth": "af1f4e5f83842233b9c3ba499e1fe019bf45e682787220faab58500f3d855031", + "bin/avahi-autoipd": "e3ca507667b99a4fcbc0ed8d0d640abc3998332e24691ee50e04d5c3e1c63c8b", + "bin/boa": "175c66efa68eabda38c8ca6990e824ddcc41dfec0219bc739bce7b8ffe0a587a", + "bin/brctl": "f4acc1905b421d52f5e8e6e24f483080f64194d3e40896f52659214569521218", + "bin/busybox": "3cd0ec8270214a74879ee4b67e592be285e23d6a9cca964b64a86b88c821e619", + "bin/ck-lease": "38e9987f8fc53109113f8aefba380f3c5a4e2575ec053d1ec82b140d28ccbe66", + "bin/ck-ota-start": "4d5a1ec59c9b32ba52b64a66975216f72c71a6df2bd0d66de9ccaa4aff85391c", + "bin/ck-ram-probe": "edca27a1c7932af7e5088829ac0f1b43ffbebbeffe4476e7cf2dae4b32b037c6", + "bin/ck-setup-backend": "f3f9c319a4e2b25f97eaf56e4ec3212b27406bbdcd45bf7fe56e30dbeeadb0a5", + "bin/ck-setup-start": "1421bb89de36f8d8e80939ffe850f3a73f4b27db7bbe35b93133aaeb316fcfe8", + "bin/ck-setupd": "eed41cbdd055676430d1507c23e3845b31343a28b45ca9e995cd41e895f022b8", + "bin/ck-udhcpc": "97eb31d69d3514a5a552b075a5089527ef95917e29ee08105a830996d3ac6b85", + "bin/ck-updated": "ebe7798937e6a291d803ecaec25de15689be86d44901239f2851e7d416ba0b87", + "bin/ck-updater": "40146dfc0887c2968ffa82d835eb081a4595aa9c3db16d336ae1d628d56bb6a3", + "bin/ck-wificfg": "4993b29ee38b04a160501154a954b9db4872190c94425ff6cf3069a4065fd9b4", + "bin/ck-wifiscan": "e9f77c51d6424501c8703351b8f23f181fb471ed652256f386ef623746975721", + "bin/connect.sh": "116a58d9caa45a8a83a43b7db5e42505b3e7640e4cdee06e62c6dcc7b576b433", + "bin/connect6.sh": "97e8946dc7c28bdbde8beda01e87315e3bedf07e1704db140c8c889a036dd0aa", + "bin/ddns_inet": "d1d3bc14909540e6f17f07d72ae6d208fefc4bfb9aa33c97fbc784d2986affb4", + "bin/dhcp6c": "50cc13f2cb4b10d2a44a90750760cf8bc7227b40d39a8bb1c1a6cec45d1873cb", + "bin/dhcp6cRcv.sh": "72d0f7430a95c2e5405d3b469a44cc788cac215eb8e5d91f7182e366f595e206", + "bin/dhcp6ctl": "c391c1a0828c2e5c7a2e4892fa7dce1dd28018f2a0610bdb9ac1c623f9cc77ae", + "bin/dhcp6s": "794c877af3c4bef3d3f457e1afc7ae9bd635bc775ec8bb4f4152176e73b26823", + "bin/disconnect.sh": "ec469e947e3e2e4c9bf0aa0a6d95a4a24d9bb7521ed467d5bd183a74dfef6798", + "bin/dnrd": "55c99a83c659cbd127e75b7c34c13f73853f9c6e612271bd311f81a1a711ebd8", + "bin/dnsmasq": "541c75037f62bc3a104989ece61b044463370f15219c4c4ae21e7f68ceb159b6", + "bin/dw": "5ef838655702ff0ab7cb8f5993a31e5903afc1e437a17e44f4c4842124d72266", + "bin/ecmh": "b56eb2a6e88722ba361042b329d67b3d5bc3634aa083690c949596c239e7f81f", + "bin/ew": "1a3e6bc1afc8345387dc9a38887e6af4adde619bc8743c25f0366df6928a649a", + "bin/exec-diag": "b9e64135afb90d77f5038f70d9a888c9c0af72f0d4bf5c1f33ab9df0b9d06a7d", + "bin/exec-diag-start": "4102861444c4bcf08a1b561b0b5614ffe8acee13102517f1fd87077319064f94", + "bin/exec-test-runner": "7433da986fa5ab813a11e04f9e3f5fe5babb736eb76bc3e689f4a1153c76e823", + "bin/exec-test-start": "29fd9968dda63926bf99d4ac3d94d7acd415c5e320268983555be1d5cffa10dc", + "bin/firewall.sh": "5688eba3b9e7f322c8e7b1e2e831f6e999d6fcec125283fc8de75d144e42e7f1", + "bin/flash": "28aca13b5d8df12040dd2c6ffb3dc69e61bfd1e0c860d25447f3a08e66963523", + "bin/fwd": "698eb8a02fcc0f2dcfca6702ed766f7338dfe51f4df6f85c72a2a78d789a190c", + "bin/getmib": "e3c872196f6afe6501514101614cc8b8ece902e6f8b6b9a1ad352a10c2a61e25", + "bin/getmib1": "89bc956fe6fd4e5e134c8769e611253fecf50272e2396ae65015e44d5d3d8b44", + "bin/gigadevice_chk": "f555ae6b974c2e7303072a1441820b171907993dd389e062557c7ac9d5191a19", + "bin/homekitserver": "2d50339436759f3d602c150fb4f1a42f0fdf122fc18e9a4f54f6e6b07d7a0c62", + "bin/iapp": "0dda7f960b1b0340f03f1823dc457b0b2fef84dd887d3b07d2158e5845f78bdb", + "bin/ib": "23540feab73c9f0f287e4f91284343ec8677aac9e5b1f51bb2bcf0db1075e697", + "bin/ib1": "439aeeffb8b3253384713b53f95120a2206f97911c64ebb90366c487ec1f2024", + "bin/id1": "cead109ab787def7dc01e63c4cdbb782493f588cecd709aee61a298b263d6dbd", + "bin/idd": "fd0a6763249a6064351ba787fbadce230b9ccfdeacff4f3de662b0c40b46df50", + "bin/idd1": "cead109ab787def7dc01e63c4cdbb782493f588cecd709aee61a298b263d6dbd", + "bin/init.sh": "37a89df20b2efa6b9e4268320fae90e29774dc894168a257f25b89f452e7bda2", + "bin/ip_qos.sh": "0bbaa983ce78b27b561a9c780c3ba9264563d4a38b61209f1898fc83a5473ee0", + "bin/irf": "848040ed72207dc5a47d3f67d6f54f8ce6eb3f36c38104089aa438d55114bfcc", + "bin/irf1": "9e7d8c249b22c9e65e9dc3050bff451f3bdeaf9b4eb8db6bbec5b9b39899eb0d", + "bin/iw": "82e621203cfa87228dc1bb520f2837bf7833346a0fc9f16757970f8ed07f11da", + "bin/iw1": "0ee09886704fa633ecd6111c1fe8ce373054e2c5feed8924a006255017ff8987", + "bin/iwcontrol": "5df1e0c326ef676d8dcab9885e079b9b25bc956b5aba6cb1e99af5dc11c5ae0a", + "bin/iwpriv": "0d54f6042b99bfbecbbeb68152a462f8a54663407a7ecf804c2c8bca2d6da6d9", + "bin/killsh.sh": "402bc7a2845df34d9c15049c38dac2688cd448995a908def3a27438fc762c019", + "bin/l2tp.sh": "075ba0c8e3a533efcbe14d42f66d3ef1163571fc22759e249ffcca17496ec0bb", + "bin/mdnsd": "7d5243636c0870e9cf32f4ecda0d494c6b47091da70cd470911e5961430ca177", + "bin/mldproxy": "eed2c2737eeefc4e808ce088b5c2ee7611fb93e414c826d4bf116218b51d48c3", + "bin/mmd_cmdr": "b71745208ba972d3a02991af759d4827b81c2e5156995c47e09b558b640cb163", + "bin/mmd_cmdw": "aeb2542634581cccaf6a9e76a41b69147c840663b2aff43b652ad5a1c4a5a346", + "bin/mp.sh": "e41e053700105de8511c57a984cbd03347381bcc48497ac391bde8bf6b68cb99", + "bin/ntp_inet": "79d56c4369c418fb4a4920f0394aec7c1e7ccccc9dbe119bfb0518f2395abcfa", + "bin/ntpclient": "e736240aab44bdd9d701bafe6eeaf383fa98878e57cb2913126e524185678aec", + "bin/ob": "452a3031a31802e8a7a79e200d2ef1c149ddb045a83ca9faea72133f1d10d4bd", + "bin/ob1": "c3f3f85889c443c69b17daa93fe0aa27e1e2dca99fa7e0f36b5579e749c1dc7b", + "bin/od": "be0f8099e8e1440e10b1b7cf6bf4ed063ff926887c4334fd47c941fe05c5635d", + "bin/od1": "aaae9dc1b71c3ee083efb9fc3b4862be9a32deb117a8b2edac5a3a057226e213", + "bin/orf": "6de1b42f0975076302439227c5542c353faff0293ff49c27932bec9b94d373c8", + "bin/orf1": "0d83ffdf74e8b6153062b24c765cb562bae115bb6e94ceccf40e198a26268b99", + "bin/ow": "3e06231474fe83569add90f05af1fbd478e243da89fa7767a6f1e27efab5b2ac", + "bin/ow1": "cb4d076d2a8efc2168e5a5d67b3da4ac3b0eec54b9fecf0735d3a4dc5602b07e", + "bin/phyr": "0ea4bf45ead3a0da7d55feaeef12bcfb095e63f69a70a842672f36b008065e80", + "bin/phytrex_tools": "a4733e1b2b8c52e48c109c9c40a2b9af532248e4b4116eb72260b1f0f20e6fc2", + "bin/phyw": "830bfdb9473fae12ab9aa8298d7aa3f9831bff63e7393a7f2b29dd530f134545", + "bin/ppp_inet": "44b1db22dc2b786e90ed558175915a9eef926a7818bd43f1c675724a06b77109", + "bin/pppoe.sh": "28d59a56e07f42c240ec59a7ac5a784371d00cef92f1984943232b3050ca011b", + "bin/pppoe_conn_patch.sh": "b6da8ac9633bf1a4a2bc665945247882ef8181e1fca9e65f3a8e7af631cc1e7e", + "bin/pppoe_disc_patch.sh": "025aac686216515eb57a2a26f5bb7b7c507f66b48c5404d8502dc0f414f1cb93", + "bin/pptp.sh": "c79acbc24a203398e6858f464065d65dc83eb0087bb3d6fd0ac47ed294a7754b", + "bin/radvd": "9db7fe7c307562b562fc1a96a33ece6b53be4cafe7bc63648d09a8c4567135b6", + "bin/radvdump": "c4c9694b057105652c461308b901dfe02da3bdf8901175dfcd8d112e6a21d71e", + "bin/reload": "22f689f717c259ecbeb628cc051fa4d82a3011146f2f4e48fd37cd5380184be3", + "bin/rssi": "091d5107b5c27b266252585b7c9992df4b2270704f81dc939eb1bbccc7a0ee6d", + "bin/rssi1": "1cca70b5b1cca70afdfa9637b180d904e0236cc487ac55d8ab1395283dcb8c67", + "bin/rtk_cmd": "89dbb16d504b668c48abae30b0d99724c20df7ce1a35faa55cc18f887bc7d7bb", + "bin/rtk_eventd": "1638b8ace10a21145ce26df5c835e8e8aef72bfbfd4f6ff13a91f3894ba9be38", + "bin/setmib": "2025b4ed81e89dcad0a58d4411c101ca67f0d6fdb85b1eb73539049f8675a3a3", + "bin/setmib1": "89ef32a13b8888d9e2b2f1e6a84c849f0d35e73e03c94c82d5be11f68f59209b", + "bin/snmpd.sh": "014ca345c401ea958db53e7dcb7c4dc050198f680e9a746410268a7a84a0875b", + "bin/startup.sh": "d620040cff0aa1ada99a9d68d1fc8436cd335ace89481880c5c2daf70a7e8fdb", + "bin/sysconf": "a88ffd520ea3382bcd1b656c1c2b4e02a416619da2fc87d4fe108ee08e76c9a0", + "bin/test-cpp_lean": "85a4e1fb97533f9d5b09ef630811a8df5e47d53d51f5de6e4e9374ca7660fb96", + "bin/test-tiny_eb": "897e409ae52a202d8f93aab9315686538c675adf80ee24a0f45c269a33cd2a1d", + "bin/test-tiny_gcc16": "60283435194b72e0c2dd61ffc0985f88d6478b26dc05be03eca629d2078f54c7", + "bin/timelycheck": "cf7afc7be127701b340c4ddcfacfeb690af40041b6c93fc37e29e63c0ce41511", + "bin/udhcpd": "6c65f86b575cc3956504e37b9688fe6b0cafae8a38938aab56a2973b0802a699", + "bin/wfaudio": "ba269a6c1565c02b8322d18e3d40e89de6f7f98097a90d0ce06e3452218d5d22", + "bin/wlanapp.sh": "a45b6d40a8b126283a575f0bbf1bc97f076f39c58f1ca74f27bcb0997aaa563f", + "etc/avahi-autoipd.action": "6e14205c720227f5294bfb9348dc1071dcdcc914723695619a10d5e11c395176", + "etc/boa/boa.conf": "6236c22da5f177dcaad1bd6ec70ce3dca62cb1a56daf83b668933cd8c0aef6e1", + "etc/boa/mime.types": "1e3e9fe650ec67e99cafeddcfbaf52a11f910666ec11d0f2c7a264392622b9bb", + "etc/ck-udhcpd.conf": "53e4458bbbb97de591d528efeeaa146eeaba2d5c7612d7b2389cadb4b8123819", + "etc/ck-update.meta": "381052db01d98ba7f081df597e6b22ebbab029b0cb78c14a4d9bbf55127d01ad", + "etc/ck-update.pub": "386eb1742a602b5e9bd2007bcffd3a39c0a467500f92a1856d7c6059c88f5a2c", + "etc/ck-variant": "ec162500e66bdd48e03d6123e62f8b31b1b37170c5759ad3cdbe4f9b469a178e", + "etc/ck-version": "f0fa4bcbb6bcc60f7b4606521c4ff94842e910add0d94b1a0f5c7db319c0f668", + "etc/customize.version": "44f87003455f39f4bb1e8fd1be8342c591b7c7fff44143d6949f6adbee4600b1", + "etc/dnsmasq.conf": "0ae2408b285a62bce4c8b0c7fab85e350889699d22feff1b6adcc54253bb95d4", + "etc/group": "5148fde8988f4762c1a14039adf6ca7cfcf3b201c71cd48332da60ca69ff49a8", + "etc/host.conf": "313728062c7ae797e11187c7f1d66b559f0098115320166bca6100486c167824", + "etc/hosts": "f80e3d5c3a0e4517e650bccee94c8033131e263d8458b37a18dcee440cb3819b", + "etc/init.d/rcS": "f23fe4803d52812fadbd84af1761792831cfde40803c5e0fafd0fd32e9d11e4e", + "etc/init.d/rcS_32M": "54b23c1e0a0a9a4f02484f6287e21f60588d0a98a790e76e9d9b4af3578237a0", + "etc/inittab": "7728ecf0138d2ed4b1408204e8c7b0c7eacfe650b1fe29cadfe941031b476e3a", + "etc/mime.types": "8d0a3ce3b74daccb330e158471cab898d40d4e40ed8a74ff9d710909a82cdf6e", + "etc/motd": "3b727708cd2efa362aa751c12c8d2e9e0e2050cb07b3d646dae2c6f4bcdd1cb3", + "etc/passwd": "8e619aab9bdf9ddd2644bed3ac319e862e908c4664b862120c2f4209c401bd32", + "etc/radvd.conf": "9ef8db8aa53148d8671e93b9d2879f18e1aec399ccba92aae324271796b5868e", + "etc/samba/smb.conf": "a56cfd4403599a01be6a78bfadcb1e6cc5177f7b4022e3a17e2ca861d2eb7c6d", + "etc/samba/smbpasswd": "2187a31165dc5cb2190130abdc04d6c5fef3d78ffc609b390b74a444f8e76c94", + "etc/services": "6ae28a7390a44890f3709f772dd5a2d8246880b40a734f1317cb117eea6de748", + "etc/shadow.sample": "106bf188d92eae79b255c106cb9f05777b5ba225ddef95132fb1da552b81caac", + "etc/svn.version": "9b3e52b747c1564c1a2b4eb757b829ae5d5095a0f591b8bcb49549364e75d461", + "etc/ushare.conf": "8e1b479172c4f1c7478a6658bae1a1049b607e7aacd24f2c40ff415b6e3da057", + "etc/version": "de64bb7381aaec500c2765ea8b939c7f1cc1f806609a932ba410e9e30a8957a8", + "etc/vsftpd.conf": "c86e2c41c3c17be5510db27e4998fbf372751727c340dce68362012d47744e92", + "lib/ld-uClibc-0.9.30.3.so": "35051ff978746f071c792d3d262b14f7e78dbbc461a9fd5c1e56a73966b0cb30", + "lib/libapmib.so": "e88721af68971ac96e949e7a918ae59e339cea1a3e7ecc9d40d59d5024e62824", + "lib/libdaemon.so.0": "a6ba44569b6a17ae9806aa89eb0bca7018946cc3aa4e167cf6b67e1f17ed8910", + "lib/libdl-0.9.30.3.so": "fd211b05beb2139a4e97a2441001be7554170e2602ac3c5298b228571cd1e3cf", + "lib/libdns_sd.so": "b0175db146f751120165c1d93a46be5e9da19f3e4201f13dbf0365fc053fddad", + "lib/libgcc_s.so.1": "c8f9860a3518dc0d6b78ff6640240aeb68e9b38a59aaf37bdc86bc1c0cd361d1", + "lib/libm-0.9.30.3.so": "bd31abbb878d32393b26573ae5b6055d1e73fa8126b4ade9a76465458d8bff2f", + "lib/libpthread-0.9.30.3.so": "7cde98eebc3ee088f150497282edb955b9d9aa1fb72f65603b0f6505d4919872", + "lib/librt-0.9.30.3.so": "ba9ebba8c75a71046716ac9ed2c37a25534ccf0647e0b123d8aea010738db2df", + "lib/libuClibc-0.9.30.3.so": "3bdde33cb95c8a4ce9a786d654f3e87f7a86e667201f14cea4fa33ee6749c278", + "usr/share/udhcpc/br0.renew": "0f225c0c1b9feeb416f46c86f729eabde73518f6920f26bf4dfaa8d501d54221", + "usr/share/udhcpc/br0.sh": "8be90d840672e3038b1e60ab59c206672479a28fb7168c9e2d9ffb267f3f787e", + "usr/share/udhcpc/eth0.bound": "394691277bad5f0f1a6b708d95e7ed57d6a0432ead294af299727f08c8c4904e", + "usr/share/udhcpc/eth0.sh": "f663605c21f57feadba977443b7b2ee298803752664b7b64207af0a75a27a17f", + "usr/share/udhcpc/eth1.bound": "e721f2ad88cbdfa3f37c5a7053a05c15766955f4b2d5ee9f7c7f7a195a7ad89b", + "usr/share/udhcpc/eth1.renew": "0f225c0c1b9feeb416f46c86f729eabde73518f6920f26bf4dfaa8d501d54221", + "usr/share/udhcpc/eth1.sh": "87e4066153462feec83c9ff7666812eb258bc4d38550e3498b31c33617441e7b", + "usr/share/udhcpc/wlan0-vxd.bound": "cf1ad0dc6a1fd3fd71b839a47e2c8ccce076db67608d7b87e412aa92010c02d3", + "usr/share/udhcpc/wlan0-vxd.sh": "092105e71c24ea2a1ecd4e8516b8bf32cd0468343bc803516f8b2e7baa54ec2e", + "usr/share/udhcpc/wlan0.bound": "cf1ad0dc6a1fd3fd71b839a47e2c8ccce076db67608d7b87e412aa92010c02d3", + "usr/share/udhcpc/wlan0.sh": "b60b564cf829f019db892c4b2c05d0950557f45e0cb8c33d8b1a0b11f4fd289c", + "usr/share/udhcpc/wlan1-vxd.bound": "cf1ad0dc6a1fd3fd71b839a47e2c8ccce076db67608d7b87e412aa92010c02d3", + "usr/share/udhcpc/wlan1-vxd.sh": "ad88d272a3ae0f31bfcff86a1e08be1342f57511615693782a7fb11a93b50189", + "usr/share/udhcpc/wlan1.bound": "cf1ad0dc6a1fd3fd71b839a47e2c8ccce076db67608d7b87e412aa92010c02d3", + "usr/share/udhcpc/wlan1.sh": "f4f1548b05bef9b69c13c32c2f157017b9623a946870774fd32e2246f8192d4c", + "web/code.htm": "ef39c1dcc759b7d9f614fe6b8049ddf2a9122e9d9e81a614849e5f5700afea0a", + "web/countDownPage.htm": "b4d199553f8dfa58f0a19c8923be713c8fd6de556bbd1190ad412e5295536cab", + "web/ddns.htm": "3c66f89fe65b19061a42d09ccdbd7d8abe3a2812189f1e0b57a9a0cea73e45a7", + "web/dhcp6s.htm": "05234b252c5c08471952974dfa9c30254d3baf12efc537ac1765d9b8c71af22f", + "web/dhcptbl.htm": "969251aeabe941809d38398f0168ed3ba8b06d3a33534c537b7442950534e81b", + "web/dmz.htm": "5b067a0e5c07e015a81ba3bb0556f6e73192ad044f2ad69bab3b2a34820f58d9", + "web/dnsv6.htm": "86a8711db19229c3f2680d64e45df06cdcdfc5d336d7db49f917907dba7a5b92", + "web/dos.htm": "86e3fc95e66b4d75cde12767dc5812e019dfc4718b897068ac1c79380a060458", + "web/favicon.ico": "fdce9e8910ba9bc5ea2357c8002568c6f434a80175f381d882bbd71feffd910e", + "web/graphics/back.gif": "0e9ced1019385b1101fdaa7c07d01d63aae771176a44d3df81d47b115b5128aa", + "web/graphics/dir.gif": "16f741d14a9af2cf05588d4ca748dd9a4bba9780c464f424076258b3911807ee", + "web/graphics/router.gif": "69a4f526ad2a35621afc9e9eabd1dbbb4f24f3f0e0496a1bf62b8c3d17363eca", + "web/graphics/text.gif": "ad62db6395e4e4bc21d0442062659db0f5ce93656e8d6464c172328dff5b2ac8", + "web/graphics/topbar.gif": "295b5e68db8a2e727b60c5d142b32661daca0722f3de4981aee91c77c49abda3", + "web/home.htm": "c39b5a02b4a8d9b72ced04cbdb4b881186533fad9a699e28086455f61d1cf880", + "web/index.htm": "af6083187e85b9711ce81f6fc86ec2a4dd31a7f578414359de3a3ccffce73076", + "web/index.html": "169ae52b96b7129c3a8c7a9d848f219c49a1b47bb88e417829b047e20d255852", + "web/ip6_qos.htm": "9927ebc69e819f57761419019c6896a0bbcfd79b4b1baa84b24a359153b1c25a", + "web/ip6filter.htm": "0b7b40aac77aae6482a97c1ac5c277b318a1624d541c5ff40572d63b352acc8f", + "web/ipv6_basic.htm": "af8f5f372f4e053ed8b74e970936d1456cbb4cb940eec19726532886d6bf7618", + "web/ipv6_wan.htm": "597459af6fd8763e565d8848890aac17de4931bf1717f3b6802af2d0bdd0aad2", + "web/logout.htm": "ef640d19f21fe339e1eea7ff032b2d16f6bc9a91bdebac269de12ebc96e91991", + "web/macfilter.htm": "0ba33043f8efa0593580d7557e1e11ea8d0d9f0184c1d9f3d1558af80f608eab", + "web/menu-images/menu_bar.gif": "647aded4cdccaa9a9f00d73e66ca92b1bc1d8f9775b76212a3d04504ed5410e4", + "web/menu-images/menu_corner.gif": "39af5e39f3b1e770f7488e44b445624c3ed7271e4c7de8dbb34dd2f4416e7af6", + "web/menu-images/menu_corner_minus.gif": "b1c5de54ea4ab836eff259195d32a578deee31c295288b06d90a8e7e98e085f3", + "web/menu-images/menu_corner_plus.gif": "d39a780f3d0e3283a3ae64e67f9ebf128b17c0b5b1417a06e4f78f09ae619e42", + "web/menu-images/menu_folder_closed.gif": "09e687ff155d3000d791ed036ff0e3f77b8696157eec186257dba3684e372f24", + "web/menu-images/menu_folder_open.gif": "1612a163cef16999411d2b63e12aa2e53c7d3bab1a2b5c2468cb0591727cb39b", + "web/menu-images/menu_link_default.gif": "5f0f5f53e769f08ac4be05097ac5e3474f4a28b1b0215a34805ec9e79297b0bd", + "web/menu-images/menu_pixel.gif": "63b98a0c8568c08fd01a6946a147bca65eff26c8085f1ccb5330aafe0f0dcd15", + "web/menu-images/menu_root.gif": "b38b3fd6af062f1ceda57128c8671b6b906e396cf9d624772614fb62cd23c694", + "web/menu-images/menu_tee.gif": "bf5d4fc1fdbe4b5e3e6a5c468a716ab34e2a1a2ab4478267df7e8351c8d1587b", + "web/menu-images/menu_tee_minus.gif": "bd10a4f7b54a08d5d63e7322f267fabfe383755471cb01186d9c7b16736e3d3b", + "web/menu-images/menu_tee_plus.gif": "a62fcf9bbafcc56e9af47307710dc9c34c9239b2fe8610a6e923c576e62f8a5e", + "web/menu.htm": "133ad67524dede590787680066b45cf58a70c6db2b5b7e3577b45ce44230a105", + "web/menu_empty.html": "9f18250ded12c95522893bbb6dec6c6be3bbfd395b4fce8a2ba0d04469aa743b", + "web/mtmcode.js": "565b4fbc6a66e0ca9802269de674454adcc5187d035f1a94ba93b0978b156334", + "web/ntp.htm": "5e33de238716fb88a914e1c470baaf913eb88702f664cd4ccd3e8c292f8d3f44", + "web/opmode.htm": "c6499fda559e682e5b78345522d1f7806a7152a00de8c4a28a8fe10f2bcd1803", + "web/password.htm": "800ccb6722a5be356a8f9dc1c44034d0f72c6857f5890fcfaae974204c1fe754", + "web/pocket_sitesurvey.htm": "0dce90317c717e55895d44502a14b2acbd72518447d9029f6717f4cbf538ba1f", + "web/portfilter6.htm": "74530b908cc389547fe4596e5eaf7826bc0c57d3ab7fedb8c785a8a7567845ee", + "web/portfw.htm": "938bb94911baec450bc2a49904a7fadb2f27068cc9cd65c02d7f0008d27b38c1", + "web/radvd.htm": "31ea7295aa23b9f4a73ae3fd2ac8f61283391a88780c2ef06d53dfed54c0b439", + "web/reload.htm": "eae613794aad31e1b6edfe9ab0b4695e05c5a977e1d5820810abbaea618cc4e7", + "web/saveconf.htm": "d3e5f98e82cbfc600474236c4e97d24f84e22d589ceb586f46ba69d2cb512bae", + "web/stats.htm": "4bee4161dcbb79b2193dd48fe303f72253a7c81dda1435351944e4fce505cfee", + "web/status.htm": "2f3cc0421f10b19555821466a824f2d769d410b291eed45e34f10e0004516ed9", + "web/style.css": "dfe13bca7a30354e51274a0979ea60af38b1717bcc6135ddcbc2f28dd19fd536", + "web/syscmd.htm": "dca82d34c2877902ae4f9b48dd434c7e0098969f6fcf09dc15e6e97b5c725971", + "web/syslog.htm": "ede23274b094361f7abdd6ff6242bf7ed570552dd14c710026b1af6c1a0d4d5e", + "web/tcpip_staticdhcp.htm": "a3451b01d98df04559ece73c80b931bc92fd8ed43e77f0011ee751044c47baba", + "web/tcpiplan.htm": "4629d26d6da1150971347176160880802ff8a498b2240a5ca22487cea59fc57e", + "web/tcpipwan.htm": "2d4bae96143f5392173f7f3d055b69f3a0fb3b4d68f8cfb9b3e6ded997778a6f", + "web/title.htm": "87a29d29b9b7ae7b28bcf2f1a00002a997fa1038a2b33d88c12b316f0e3164ee", + "web/tunnel6.htm": "6d2bf69b88a08d1efcc22285b1ce206ecc86f520dee2f017cf8d6c272af0d3bc", + "web/upload.htm": "c9ff896145a303104587a4c376df2dad1a7cc4f5ec2c74e87f0130e989c49d01", + "web/upload_st.htm": "11e65da936c795621500b5c1ca3abe428a2bdd2946a39a8086f7b63956f28bd9", + "web/urlfilter.htm": "108ef6504f70587f5311425bd696c22e0f0a23646e3b7f0124533ef438bceaf2", + "web/util_gw.js": "9a20c547b2d34e77039eb330e7cf845e7d252320cf8f40dbc30a252e1cf092c1", + "web/util_qos.js": "ffcccd7675ac37779d846ad43b84ccf2cd683f4b796af4496024f6a74395d104", + "web/vlan.htm": "9e2200ec452e1ce62d61d6b84ce3d83ab9ca1867293769cd23f13962f4cbe472", + "web/wizard.htm": "6f6f9a6ae33f260778698dc22afc7d24e5a967fe346cb75a21f01afa97f6c8fc", + "web/wlactrl.htm": "97edf03d39e15e5709b79a41e3a142760918e155417ba183bda60ed3a5c5b3ca", + "web/wladvanced.htm": "53b78f4f9d9d06d2e1dbe1b7d4bb3aa1538a82dac0631905d6a1603d6aab15ca", + "web/wlan_schedule.htm": "17b329f065b0d1d136c901d4fe9f8bf9aae22e3b285e31ef65c009288f7ed3a1", + "web/wlbandmode.htm": "d7f34cdf8832acc9b96316f76ef52c9c60d063b2ce0c04e160c40341909faab4", + "web/wlbasic.htm": "7c758561c8e7983f98a84bf2cf1264f4a07f26e98000ce6ab9a8065be4b24c20", + "web/wlmeshproxy.htm": "022b01ffdd7df8850cfa603b7c312d80005639f8c5fb6a11c3e48c00928d23ae", + "web/wlmultipleap.htm": "c15318e6b8983a253fe8d828d32a6c053d31223b7539dbabfd9ffc5c0fd74a39", + "web/wlsch.htm": "9fffd931fdd22cf085489f4bd36d3da9f0def6b4bd65a06e0cecea650fde8bd3", + "web/wlsecurity.htm": "378353ff4e39815f9312c6c6cc33c7f9734ba2e44c1afd008953866cf170ad4c", + "web/wlsecurity_all.htm": "6be91b7ba91497d0756dd9162f54b6fca067992c9a9ea9ead8fb5c2791cd71b9", + "web/wlstatbl.htm": "a37f69ff59dc3443c2c896013f56e80cdd6831a7497034e6e6d860ab551a2441", + "web/wlstatbl_vap.htm": "7e3d4f04f66a6665aa0ade7b32a50f57116ef7011de40e1850f9888f9e8aad48", + "web/wlsurvey.htm": "90273cb11ca78519ca079a2d87f3aa143b415f2ae53d0ff7f924ad5d4c5d4c08", + "web/wlwds.htm": "ba327360f7963117f850a1096c0379e505218e6c9c38f877f4105cb1451d7885", + "web/wlwdsenp.htm": "5da00b29e79db94a6382698b70bdfb2e2dd2b0703b0fef79ffb549fc5873db15", + "web/wlwdstbl.htm": "7a53914672c15459ef3e8875e30b84f03ad967222fb61f0e3355701c7abc113d", + "web/wlwps.htm": "ca071459c523b9af21f3104cb92fe0a1c8dd38a76c731e64e3e11707bb9e1e05" + }, + "protected_ranges_all_ff": true, + "credential_files_absent": true + } + }, + "physical_device_access": false +} diff --git a/version.json b/version.json index bec0783..0d5d04b 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "software_version": 2, - "version": "0.2.0-preview", + "software_version": 3, + "version": "0.2.1-preview", "catalog_url": "https://lee98064.github.io/incipio-commandkit/updates/index.json" } From bf4e358dd7e9db83ccfbf0571adffd7604da710d Mon Sep 17 00:00:00 2001 From: lee98064 <46412433+lee98064@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:27:21 +0800 Subject: [PATCH 03/13] Fix startup identity sequencing and legacy MTD persistence --- .github/workflows/ota.yml | 2 + README.md | 4 +- docs/ota-release-notes.md | 4 +- docs/updates.md | 6 +- .../matter/src/matter-app-device/Version.h | 4 +- .../matter/src/matter-app-device/main.cpp | 19 ++- firmware/matter/src/matter_diag_relay.c | 9 +- .../matter/src/setup/ck-setup-device-start | 2 +- firmware/matter/src/setup/matter-relay-start | 15 +- firmware/matter/src/update/ck-ota-start | 19 ++- firmware/matter/src/update/ck_common.h | 3 +- firmware/matter/src/update/ck_flash.c | 33 ++++- firmware/matter/src/update/ck_store.c | 8 +- firmware/matter/src/update/ck_updater.c | 18 ++- tests/hardware_fallback_init.c | 57 +++++++ tests/legacy_mtd_shim.c | 140 ++++++++++++++++++ tests/test_deployment.py | 31 ++++ tests/test_hardware_fallback.py | 119 +++++++++++++++ tests/test_legacy_mtd.py | 118 +++++++++++++++ tests/test_startup_recovery.py | 99 +++++++++++++ tools/fwlib.py | 25 +++- verification/README.md | 2 + verification/recovery-results.json | 87 +++++++++++ version.json | 4 +- 24 files changed, 795 insertions(+), 33 deletions(-) create mode 100644 tests/hardware_fallback_init.c create mode 100644 tests/legacy_mtd_shim.c create mode 100644 tests/test_hardware_fallback.py create mode 100644 tests/test_legacy_mtd.py create mode 100644 tests/test_startup_recovery.py create mode 100644 verification/recovery-results.json diff --git a/.github/workflows/ota.yml b/.github/workflows/ota.yml index 6ce7a5d..e59b5d1 100644 --- a/.github/workflows/ota.yml +++ b/.github/workflows/ota.yml @@ -17,6 +17,8 @@ jobs: - run: pip install -r tools/requirements.txt - run: python3 tools/generate_update.py - run: python3 tests/test_updates.py + - run: python3 tests/test_legacy_mtd.py + - run: python3 tests/test_startup_recovery.py - run: node tests/test_update_crypto.js - 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 diff --git a/README.md b/README.md index fa4de8d..933337a 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ | 測試與發布交付狀態 | [驗證索引](verification/README.md) | | 上傳 GitHub 與建立 Release | [發布說明](docs/publishing.md) | -目前來源為 `v0.2.1-preview` 候選版;v0.1 Release 尚無永久 OTA,第一次升級仍須限定區域燒錄。 +目前來源為 `v0.2.2-preview` 候選版;修正設定介面名稱、首次身分初始化及 Flash 同步相容性,新增 MCU 控制退路,見[修復驗證](verification/recovery-results.json)。v0.1 Release 尚無永久 OTA,第一次升級仍須限定區域燒錄。 ## 兩種韌體 @@ -32,7 +32,7 @@ | `matter` | 開放設定熱點、Wi-Fi 掃描、裝置專屬 Matter 碼/QR、On/Off/Toggle、MCU 確認、Identify、診斷 | v0.2 已加入持久 fabrics、簽章 OTA、標準電力量測;使用開發測試 attestation;原機驗收待完成 | | `exec-test` | 依序執行三支附件 MIPS 程式、逾時/資源/hash 限制、認證診斷下載 | 不啟動 Matter/HomeKit;不提供軟體繼電器控制;附件只有執行檔,沒有其原始碼 | -這兩份公開重打包版本尚未實體燒錄與原機驗收。歷史版本的原機平台/協議測試與本次 VM 測試分開記錄;不可用模擬機結果宣稱原機 RF、家庭 app、負載切換或長期穩定性通過。 +Matter v0.2.2 已完成原機限定區域燒錄與獨立全片讀回;開機及操作驗收另記於修復結果。診斷版尚未完成本版原機驗收。歷史版本的原機平台/協議測試與本次 VM 測試分開記錄;不可用模擬機結果宣稱原機 RF、家庭 app、負載切換或長期穩定性通過。 ## 目錄 diff --git a/docs/ota-release-notes.md b/docs/ota-release-notes.md index cce5b97..6d4548d 100644 --- a/docs/ota-release-notes.md +++ b/docs/ota-release-notes.md @@ -1,4 +1,6 @@ -此版新增簽章 OTA、區網管理頁、線上版本檢查、Matter OTA Requestor,以及設定/fabrics 持久儲存。 +v0.2.2 修正原機 `wlan0` 介面名稱、身分產生前啟動 MCU 的順序,以及 Linux 2.6.30 MTD 不支援 `fsync` 的相容性。初始化失敗時保留本機 MCU 通訊及使用裝置原有 bootstrap 身分的設定/診斷入口;不將 RAM 退路當作持久 fabrics。 + +此系列提供簽章 OTA、區網管理頁、線上版本檢查、Matter OTA Requestor,以及設定/fabrics 持久儲存。 首次安裝仍需以原燒錄工具將支援 OTA 的韌體寫入 Bank 1;後續使用 `.ota` 更新包。`matter-8m-region.bin` 是部分燒錄映像,必須本機個別化,禁止整片覆蓋。`matter-matter-ota.bin` 是提供 Matter OTA Provider 的標準外層容器。 diff --git a/docs/updates.md b/docs/updates.md index a807baf..99fd717 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -1,6 +1,6 @@ # 安裝更新與發布新版 -v0.2.1-preview 提供區網網頁更新、線上更新索引、Ed25519 簽章,以及 Matter OTA Requestor。Matter fabrics、平台設定及裝置身分改存入 Flash journal。**第一次仍須以 CH341A 安裝支援 OTA 的版本;v0.1.0-preview 沒有永久更新入口。**原機 Flash driver、斷電、RF 及家庭平台驗收尚待完成;已通過的模擬/VM 檢查見 [v0.2.1 驗證](../verification/energy-results.json)。 +v0.2.2-preview 提供區網網頁更新、線上更新索引、Ed25519 簽章,以及 Matter OTA Requestor,並修正舊 MTD 同步、`wlan0` 設定介面及身分產生的啟動順序。Matter fabrics、平台設定及裝置身分改存入 Flash journal。**第一次仍須以 CH341A 安裝支援 OTA 的版本;v0.1.0-preview 沒有永久更新入口。**原機 Flash driver、斷電、RF 及家庭平台驗收尚待完成;新增檢查見[修復驗證](../verification/recovery-results.json),電量檢查見 [v0.2.1 驗證](../verification/energy-results.json)。 ## 在插座網頁更新 @@ -30,7 +30,7 @@ python3 tools/update.py "$OUTLET_IP" \ --package artifacts/releases/v0.2.1-preview/matter.ota --check-only ``` -第一次安裝 v0.2.1-preview 後,必須發布 `software_version` 大於 3 的更新才能安裝。`--check-only` 不傳送更新,也不重新啟動。 +第一次安裝 v0.2.2-preview 後,必須發布 `software_version` 大於 4 的更新才能安裝。`--check-only` 不傳送更新,也不重新啟動。 ## 更新包與保留範圍 @@ -75,7 +75,7 @@ Matter 必須連續 30 秒取得新鮮 MCU 狀態才確認新版本;診斷版 維護者的私鑰保存在自己的 `private/ota-signing.key`,公鑰是 `third_party/ota-signing.pub`。發布既有裝置更新時必須沿用原金鑰;不要重新執行 keygen。fork 的維護者須自行產生金鑰,替換其來源公鑰,並首次安裝自己的信任來源。 -1. 修改 `version.json`,增加 `software_version` 並更新版本名稱;例如數值 4、名稱 `0.3.0-preview`。 +1. 修改 `version.json`,增加 `software_version` 並更新版本名稱;例如數值 5、名稱 `0.3.0-preview`。 2. 執行完整建置;工具也會產生 Matter 執行檔的版本紀錄,拒絕混用舊執行檔或測試 backend: ```bash diff --git a/firmware/matter/src/matter-app-device/Version.h b/firmware/matter/src/matter-app-device/Version.h index 996ea71..4c3945e 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 3 -#define CK_VERSION_STRING "0.2.1-preview" +#define CK_SOFTWARE_VERSION 4 +#define CK_VERSION_STRING "0.2.2-preview" diff --git a/firmware/matter/src/matter-app-device/main.cpp b/firmware/matter/src/matter-app-device/main.cpp index 70416c3..b16b524 100644 --- a/firmware/matter/src/matter-app-device/main.cpp +++ b/firmware/matter/src/matter-app-device/main.cpp @@ -212,19 +212,30 @@ int main(int argc,char**argv){ } _exit(0); } - if (!LoadDeviceIdentity(deviceIdentity)) { - std::fputs("device identity unavailable or invalid; no shared pairing code fallback\n",stderr); _exit(5); - } + bool hardwareOnly = argc == 4 && strcmp(argv[1], "--hardware-only") == 0; bool persistentVm=argc==5&&strcmp(argv[1],"--vm-persistent")==0; bool vm = argc == 5 && (strcmp(argv[1], "--vm") == 0 || persistentVm); isVm=vm;if(vm&&!persistentVm)storage=&vmStorage; - bool hardwareOnly = argc == 4 && strcmp(argv[1], "--hardware-only") == 0; const char * iface = vm ? argv[2] : (argc==2 ? argv[1] : "br0"); const char * port = (vm || hardwareOnly) ? argv[hardwareOnly ? 2 : 3] : "/dev/ttyACM0"; const char * gpio = (vm || hardwareOnly) ? argv[hardwareOnly ? 3 : 4] : "/proc/wlan0/gpio_ctrl"; std::puts("matterd-test " CK_VERSION_STRING "; persistent fabrics; signed OTA; one MCU owner; cold OFF"); signal(SIGTERM, SignalStop); signal(SIGINT, SignalStop); if (!hardware.Start(port, gpio)) { std::fputs("MCU ownership/thread setup failed\n", stderr); return 4; } + if (!hardwareOnly) { + // 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) { + if (LoadDeviceIdentity(deviceIdentity)) { ready = true; break; } + if (attempt == 0) std::puts("MCU_ACTIVE_WAITING_FOR_DEVICE_IDENTITY"); + usleep(250000); + } + if (!ready) { + if (!stopping) std::fputs("device identity unavailable or invalid; no shared pairing code fallback\n",stderr); + hardware.Stop(); _exit(stopping ? 0 : 5); + } + } if (!vm) { for (;;) { char state[64] = {}; int fd = open("/var/run/ck-setup-state", O_RDONLY); diff --git a/firmware/matter/src/matter_diag_relay.c b/firmware/matter/src/matter_diag_relay.c index 47c6cb0..4b28f24 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/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","/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/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;} @@ -164,11 +164,12 @@ static int run_probe(void) { used=0;append("\nprobe_child_wait_status=");number(st);append(st==0?" PASS\n":" FAIL; Matter not started\n"); write_all(fd,report,(int)used);close_fd(fd);return st==0?0:1; } -static int run_matter(void) { +static int run_matter(int hardware_only) { int core[2]={0,0},address_space[2]={8*1024*1024,8*1024*1024}; char *args[]={"/bin/matterd-test","br0",0};char *env[]={0}; + char *hardware_args[]={"/bin/matterd-test","--hardware-only","/dev/ttyACM0","/proc/wlan0/gpio_ctrl",0}; if(S2(4075,4,P(core))<0||S2(4075,MIPS_RLIMIT_AS,P(address_space))<0)return 1; - int r=S3(4011,P(args[0]),P(args),P(env)); + int r=S3(4011,P(args[0]),P(hardware_only?hardware_args:args),P(env)); const char *t="matterd-test exec failed\n";S3(4004,2,P(t),slen(t));return r<0?127:0; } -int diag_main(int argc,char**argv){int r;if(argc==2&&equal(argv[1],"--selftest")){r=selftest();const char*t=r?"crypto vectors FAIL\n":"crypto vectors PASS\n";S3(4004,1,P(t),slen(t));return r;}if(argc==2&&equal(argv[1],"--snapshot")){snapshot();S3(4004,1,P(report),used);return 0;}if(argc==2&&equal(argv[1],"--log"))return log_sink();if(argc==2&&equal(argv[1],"--run-probe"))return run_probe();if(argc==2&&equal(argv[1],"--run-matter"))return run_matter();if(argc==4&&equal(argv[1],"--serve"))return serve(argv[2],argv[3]);const char*t="Usage: matter-diag --snapshot | --selftest | --serve INTERFACE KEYFILE\n";S3(4004,2,P(t),slen(t));return 2;} +int diag_main(int argc,char**argv){int r;if(argc==2&&equal(argv[1],"--selftest")){r=selftest();const char*t=r?"crypto vectors FAIL\n":"crypto vectors PASS\n";S3(4004,1,P(t),slen(t));return r;}if(argc==2&&equal(argv[1],"--snapshot")){snapshot();S3(4004,1,P(report),used);return 0;}if(argc==2&&equal(argv[1],"--log"))return log_sink();if(argc==2&&equal(argv[1],"--run-probe"))return run_probe();if(argc==2&&equal(argv[1],"--run-matter"))return run_matter(0);if(argc==2&&equal(argv[1],"--run-hardware"))return run_matter(1);if(argc==4&&equal(argv[1],"--serve"))return serve(argv[2],argv[3]);const char*t="Usage: matter-diag --snapshot | --selftest | --serve INTERFACE KEYFILE\n";S3(4004,2,P(t),slen(t));return 2;} diff --git a/firmware/matter/src/setup/ck-setup-device-start b/firmware/matter/src/setup/ck-setup-device-start index 845d2ea..5131843 100644 --- a/firmware/matter/src/setup/ck-setup-device-start +++ b/firmware/matter/src/setup/ck-setup-device-start @@ -2,7 +2,7 @@ umask 077 sleep 15 # Generate all onboarding data together before changing the network mode. -/bin/matterd-test --init-identity wl0 > /var/log/matter-identity.log 2>&1 || exit 1 +/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 case "$status" in *configured=1*) printf configured > /var/run/ck-setup-boot ;; diff --git a/firmware/matter/src/setup/matter-relay-start b/firmware/matter/src/setup/matter-relay-start index 14302ae..f9b055e 100644 --- a/firmware/matter/src/setup/matter-relay-start +++ b/firmware/matter/src/setup/matter-relay-start @@ -1,10 +1,17 @@ #!/bin/sh # Firmware starts only this MCU owner; the V2 executable is retained for recovery. -if [ -f /var/run/ck-rescue ]; then exit 1; fi if [ -n "$(/bin/pidof homekitserver 2>/dev/null)" ]; then echo 'MCU_OWNER_CONFLICT: HomeKit is running; Matter relay not started' > /var/log/matter-test.log exit 1 fi -if /bin/matter-diag --run-probe; then - (/bin/matter-diag --run-matter; echo "matterd-test_exit=$?; no automatic restart") 2>&1 | /bin/matter-diag --log -fi +( + if [ ! -f /var/run/ck-rescue ] && /bin/matter-diag --run-probe; then + /bin/matter-diag --run-matter + echo "matterd-test_exit=$?; switching to local button control" + 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 + exec /bin/matter-diag --run-hardware +) 2>&1 | /bin/matter-diag --log diff --git a/firmware/matter/src/update/ck-ota-start b/firmware/matter/src/update/ck-ota-start index c554427..ead979e 100644 --- a/firmware/matter/src/update/ck-ota-start +++ b/firmware/matter/src/update/ck-ota-start @@ -2,9 +2,20 @@ set -eu umask 077 mkdir -p /var/lib/ck-state /var/run/ck-ota /var/lock -if ! /bin/ck-updater --import; then - echo 'Persistent identity unavailable; network updater and Matter disabled.' > /var/log/ck-update.log +if ! /bin/ck-updater --import > /var/log/ck-store-init.log 2>&1; then + echo 'Persistent identity unavailable; Matter and firmware writes disabled.' >> /var/log/ck-store-init.log touch /var/run/ck-rescue + # 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. + for pair in matter-device.key:device.key matter-diag.key:diag.key ck-update.pub:update.pub ck-setup-ap.bin:setup-ap.bin; do + source=${pair%:*} + dest=${pair#*:} + if [ -f "/etc/$source" ]; then + cp "/etc/$source" "/var/lib/ck-state/$dest" + chmod 600 "/var/lib/ck-state/$dest" + fi + done exit 1 fi /bin/ck-updater --boot || touch /var/run/ck-rescue @@ -42,6 +53,8 @@ fi sleep 180 if [ ! -f /var/run/ck-update-healthy ]; then touch /var/run/ck-rescue - /bin/killall matterd-test 2>/dev/null || true + if [ ! -f /var/run/ck-mcu-only ]; then + /bin/killall matterd-test 2>/dev/null || true + fi fi ) & diff --git a/firmware/matter/src/update/ck_common.h b/firmware/matter/src/update/ck_common.h index 2643ae0..21afc54 100644 --- a/firmware/matter/src/update/ck_common.h +++ b/firmware/matter/src/update/ck_common.h @@ -35,7 +35,8 @@ int ck_unhex(const char *in, size_t n, uint8_t *out); int ck_equal(const void *a, const void *b, size_t size); struct ck_flash { int fd; - unsigned base, size; + unsigned base, size, part; + int writable; }; int ck_flash_open(struct ck_flash *f, unsigned part, int writeable); int ck_flash_read(struct ck_flash *f, unsigned at, void *p, unsigned n); diff --git a/firmware/matter/src/update/ck_flash.c b/firmware/matter/src/update/ck_flash.c index 850f84c..8e805cc 100644 --- a/firmware/matter/src/update/ck_flash.c +++ b/firmware/matter/src/update/ck_flash.c @@ -105,6 +105,8 @@ int ck_flash_open(struct ck_flash *f, unsigned part, int writable) { f->base = 0; #endif f->size = sizes[part]; + f->part = part; + f->writable = writable; return 0; fail: close(f->fd); @@ -157,7 +159,36 @@ int ck_flash_erase(struct ck_flash *f, unsigned at, unsigned n) { return ioctl(f->fd, MEMERASE, &erase); #endif } -int ck_flash_sync(struct ck_flash *f) { return fsync(f->fd); } +int ck_flash_sync(struct ck_flash *f) { + int result; + do { + result = fsync(f->fd); + } while (result && errno == EINTR); +#ifndef CK_TESTING + if (result && errno == EINVAL && f->writable) { + /* Linux 2.6.30 mtdchar has no .fsync callback: fsync returns EINVAL. + * Its .release callback calls mtd->sync for writable descriptors. + * Open and validate a replacement first, then close the original to + * perform that barrier. Callers seek explicitly before every I/O and + * still independently read back data before committing a record. + * Never treat a real I/O error or a different device as synchronized. */ + struct stat original, replacement; + struct ck_flash next; + if (fstat(f->fd, &original) || !S_ISCHR(original.st_mode) || + major(original.st_rdev) != 90 || + ck_flash_open(&next, f->part, f->writable)) + return -1; + if (fstat(next.fd, &replacement) || + replacement.st_rdev != original.st_rdev) { + close(next.fd); + return -1; + } + result = close(f->fd); + f->fd = next.fd; + } +#endif + return result; +} int ck_lock(void) { int fd = open(ck_path("/var/lock/ck-flash.lock"), O_RDWR | O_CREAT | O_NOFOLLOW | O_CLOEXEC, 0600); diff --git a/firmware/matter/src/update/ck_store.c b/firmware/matter/src/update/ck_store.c index 1342143..c5d59d0 100644 --- a/firmware/matter/src/update/ck_store.c +++ b/firmware/matter/src/update/ck_store.c @@ -222,11 +222,15 @@ static int migrate(const char *name, const char *source, const char *dest, int r = ck_store_get(name, b, &n); if (r == 1) { if (ck_exact_file(ck_path(source), b, expected) || - ck_store_set(name, b, expected)) + ck_store_set(name, b, expected)) { + fprintf(stderr, "CK_STORE_IMPORT_WRITE_FAILED key=%s\n", name); return -1; + } n = expected; - } else if (r) + } else if (r) { + fprintf(stderr, "CK_STORE_IMPORT_READ_FAILED key=%s result=%d\n", name, r); return -1; + } if (n != expected) return -1; r = ck_atomic(ck_path(dest), b, n, 0600); diff --git a/firmware/matter/src/update/ck_updater.c b/firmware/matter/src/update/ck_updater.c index ad85dfa..e6b681d 100644 --- a/firmware/matter/src/update/ck_updater.c +++ b/firmware/matter/src/update/ck_updater.c @@ -88,8 +88,24 @@ static int apply(int simulation) { const char *ramroot = ck_path("/var/run/ck-ota/root"); if (mkdir(ramroot, 0700) && access(ramroot, F_OK)) return 9; - if (mount("ramfs", ramroot, "ramfs", MS_NOSUID | MS_NODEV, NULL)) + if (mount("ramfs", ramroot, "ramfs", MS_NOSUID, NULL)) return 10; +#ifndef CK_TESTING + /* Legacy mtdchar synchronizes on close, then the backend opens the same + * verified device again. Keep that node and fresh proc metadata accessible + * after detaching the old SquashFS. Only PID1 remains; no device is inferred + * from an unchecked minor number. */ + struct stat device; + char devdir[128], node[128], procdir[128]; + snprintf(devdir, sizeof(devdir), "%s/dev", ramroot); + snprintf(node, sizeof(node), "%s/dev/mtd1", ramroot); + snprintf(procdir, sizeof(procdir), "%s/proc", ramroot); + if (fstat(flash.fd, &device) || !S_ISCHR(device.st_mode) || + mkdir(devdir, 0700) || mknod(node, S_IFCHR | 0600, device.st_rdev) || + mkdir(procdir, 0700) || + mount("proc", procdir, "proc", MS_NOSUID | MS_NODEV | MS_NOEXEC, NULL)) + return 11; +#endif char old[128]; snprintf(old, sizeof(old), "%s/old", ramroot); if (mkdir(old, 0700) || mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL) || diff --git a/tests/hardware_fallback_init.c b/tests/hardware_fallback_init.c new file mode 100644 index 0000000..ddce6dd --- /dev/null +++ b/tests/hardware_fallback_init.c @@ -0,0 +1,57 @@ +/* PID1 for an isolated MCU-only VM: intentionally no keys or Flash devices. */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +int main(void) { + setvbuf(stdout, NULL, _IONBF, 0); + mount("proc", "/proc", "proc", 0, NULL); + mount("ramfs", "/var", "ramfs", 0, NULL); + mkdir("/var/run", 0700); + mkdir("/var/log", 0700); + puts("MCU_ONLY_VM_READY_NO_IDENTITY"); + sleep(2); + int phase = 0; +start_phase: + unlink("/var/log/matter-hardware.txt"); + printf("MCU_VM_PHASE=%d\n", phase); + int child = fork(); + if (!child) { + struct rlimit limit = {8*1024*1024, 8*1024*1024}; + if (setrlimit(RLIMIT_AS, &limit)) _exit(126); + char *args[] = {"/bin/matterd-test", "--hardware-only", "/dev/ttyS1", "/tmp/gpio", NULL}; + char *waiting[] = {"/bin/matterd-test", "--vm", "br0", "/dev/ttyS1", "/tmp/gpio", NULL}; + execv(args[0], phase ? args : waiting); + _exit(127); + } + int control = open("/dev/ttyS0", O_RDONLY | O_NONBLOCK), status = -1; + for (unsigned tick = 0; tick < 200; 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")) { + kill(child, SIGTERM); + waitpid(child, &status, 0); + break; + } + int fd = open("/var/log/matter-hardware.txt", O_RDONLY); + if (fd >= 0) { + char text[1024]; + int size = read(fd, text, sizeof(text)); + if (size > 0) write(1, text, size); + close(fd); + } + usleep(250000); + } + close(control); + printf("MCU_VM_PHASE_%d_WAIT=%d\n", phase, status); + if (phase == 0 && status == 0) { phase = 1; goto start_phase; } + reboot(RB_POWER_OFF); + for (;;) pause(); +} diff --git a/tests/legacy_mtd_shim.c b/tests/legacy_mtd_shim.c new file mode 100644 index 0000000..a84e7ef --- /dev/null +++ b/tests/legacy_mtd_shim.c @@ -0,0 +1,140 @@ +/* Linux 2.6.30 mtdchar semantics, backed only by private regular files. */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "../firmware/matter/src/update/ck_common.h" + +static unsigned char mtd_fds[4096]; +static unsigned opens, releases, unsupported_syncs; + +static int is_mtd(int fd) { + return fd >= 0 && fd < (int)sizeof(mtd_fds) && mtd_fds[fd]; +} + +int legacy_open(const char *path, int flags, ...) { + mode_t mode = 0; + if (flags & O_CREAT) { + va_list args; + va_start(args, flags); + mode = va_arg(args, int); + va_end(args); + } + int mtd = !strcmp(path, "/dev/mtd1"); + char local[1024]; + if (mtd || !strcmp(path, "/proc/bootbank")) { + snprintf(local, sizeof(local), "%s/%s", getenv("CK_TEST_ROOT"), + mtd ? "partition.bin" : "bootbank"); + path = local; + } else if (strncmp(path, getenv("CK_TEST_ROOT"), + strlen(getenv("CK_TEST_ROOT")))) { + errno = EACCES; /* Never open a real device, even if a test changes. */ + return -1; + } + int fd = open(path, flags, mode); + if (mtd && fd >= 0) { + if (fd >= (int)sizeof(mtd_fds)) { + close(fd); + errno = EMFILE; + return -1; + } + mtd_fds[fd] = 1; + opens++; + } + return fd; +} + +FILE *legacy_fopen(const char *path, const char *mode) { + if (strcmp(path, "/proc/mtd")) { + errno = EACCES; + return NULL; + } + char local[1024]; + snprintf(local, sizeof(local), "%s/mtd", getenv("CK_TEST_ROOT")); + return fopen(local, mode); +} + +int legacy_fstat(int fd, struct stat *st) { + int result = fstat(fd, st); + if (!result && is_mtd(fd)) { + st->st_mode = S_IFCHR | 0600; + st->st_rdev = makedev(90, getenv("CK_MTD_REOPEN_BAD") && opens > 1 ? 4 : 2); + } + return result; +} + +int legacy_ioctl(int fd, unsigned long request, ...) { + va_list args; + va_start(args, request); + void *arg = va_arg(args, void *); + va_end(args); + if (!is_mtd(fd)) { + errno = ENOTTY; + return -1; + } + if (request == MEMGETINFO) { + struct mtd_info_user *info = arg; + memset(info, 0, sizeof(*info)); + info->type = MTD_NORFLASH; + info->size = CK_PART_SIZE; + info->erasesize = CK_ERASE_SIZE; + return 0; + } + if (request == MEMERASE) { + const struct erase_info_user *erase = arg; + unsigned char erased[CK_ERASE_SIZE]; + memset(erased, 255, sizeof(erased)); + for (unsigned at = 0; at < erase->length; at += sizeof(erased)) + if (pwrite(fd, erased, sizeof(erased), erase->start + at) != sizeof(erased)) + return -1; + return 0; + } + errno = ENOTTY; + return -1; +} + +int legacy_fsync(int fd) { + if (!is_mtd(fd)) + return fsync(fd); + const char *behavior = getenv("CK_MTD_SYNC"); + if (behavior && !strcmp(behavior, "modern")) + return fsync(fd); + unsupported_syncs++; + errno = behavior && !strcmp(behavior, "io-error") ? EIO : EINVAL; + return -1; +} + +int legacy_close(int fd) { + if (is_mtd(fd)) { + /* mtdchar's release callback synchronizes writable devices. */ + int result = fsync(fd); + mtd_fds[fd] = 0; + releases++; + if (result) { + close(fd); + return -1; + } + } + return close(fd); +} + +int legacy_sync_once(void) { + struct ck_flash flash; + if (ck_flash_open(&flash, 1, 1)) + return -2; + int result = ck_flash_sync(&flash); + legacy_close(flash.fd); + return result; +} +unsigned legacy_opens(void) { return opens; } +unsigned legacy_releases(void) { return releases; } +unsigned legacy_unsupported_syncs(void) { return unsupported_syncs; } +void legacy_reset(void) { opens = releases = unsupported_syncs = 0; } diff --git a/tests/test_deployment.py b/tests/test_deployment.py index 13d57de..4d064e9 100644 --- a/tests/test_deployment.py +++ b/tests/test_deployment.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Synthetic flash contents + real published rootfs. Never access a programmer.""" import argparse +import hashlib import json import os import shutil @@ -117,6 +118,36 @@ def test_mismatched_backups(self): self.prepare() self.assertFalse(self.output.exists()) + def test_intact_uncommitted_first_import_preserves_identity_and_journal(self): + self.prepare() + before = bytearray(self.output.read_bytes()) + key = b'identity/device' + identity = (self.profile / 'device-identity.key').read_bytes() + payload = struct.pack('>BBH', len(key), 0, len(identity)) + key + identity + header = bytearray(b'\xff' * 64) + header[:8] = b'CKSTORE1' + struct.pack_into('>QI', header, 8, 1, len(payload)) + header[20:52] = hashlib.sha256(header[8:20] + payload).digest() + before[0x3c0000:0x3c0000+64+len(payload)] = header + payload + self.first.write_bytes(before) + self.second.write_bytes(before) + result = self.work / 'recovery.bin' + self.prepare(output=result) + after = result.read_bytes() + self.assertEqual(after[fw.END:], before[fw.END:]) + self.assertEqual(fw.read_file(after[fw.START:fw.END], 'etc/matter-device.key'), identity) + for at in (0x3c000f, 0x3c0014, 0x3c003c, 0x3d0000): + damaged = bytearray(before) + damaged[at] ^= 1 + with self.assertRaises(ValueError): + fw.journal_state(damaged) + wrong = bytearray(before) + wrong[0x3c0040+len(payload)-1] ^= 1 + wrong[0x3c0014:0x3c0034] = hashlib.sha256(wrong[0x3c0008:0x3c0014] + + wrong[0x3c0040:0x3c0040+len(payload)]).digest() + with self.assertRaises(ValueError): + fw.journal_state(wrong) + def test_same_file_and_hardlink(self): with self.assertRaises(ValueError): self.prepare(second=self.first) diff --git a/tests/test_hardware_fallback.py b/tests/test_hardware_fallback.py new file mode 100644 index 0000000..6cb23df --- /dev/null +++ b/tests/test_hardware_fallback.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""MIPS/Linux VM: MCU polling without identity, Flash devices, or networking.""" +import argparse +import hashlib +import json +from pathlib import Path +import stat +import subprocess +import sys +import tempfile +import threading +import time + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'firmware/matter/tools')) +from test_device_vm import Mcu + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--deps', type=Path, required=True) + parser.add_argument('--toolchains', type=Path, required=True) + args = parser.parse_args() + binary = ROOT / 'firmware/matter/artifacts/softap-device-code/matterd-test' + with tempfile.TemporaryDirectory(prefix='ck-mcu-only-vm-') as name: + directory = Path(name) + subprocess.run(['docker', 'run', '--rm', '--network', 'none', + '-v', str(ROOT) + ':/repo:ro', '-v', str(directory) + ':/fixture', + '-v', str(args.toolchains.resolve()) + ':/work/toolchains:ro', + '-v', str(args.deps.resolve()) + ':/work/deps:ro', + '-e', 'PATH=/work/toolchains/uclibc/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', + 'incipio-public-build:v0.1.0-preview', + '/work/toolchains/uclibc/bin/mips-linux-uclibc-gcc', '-static', '-Os', + '-mno-shared', '-mno-llsc', '-mno-load-store-pairs', '-mmemcpy', + '/repo/tests/hardware_fallback_init.c', '-o', '/fixture/init'], 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'{value:08x}'.encode() for value in fields) + name + blob += b'\0' * (-len(blob) % 4) + blob += data + blob += b'\0' * (-len(data) % 4) + entries.append(blob) + + for path in ('bin', 'dev', 'proc', 'var', 'tmp', 'etc'): + entry(path, mode=stat.S_IFDIR | 0o755) + for path, device in [('console', (5, 1)), ('null', (1, 3)), + ('ttyS0', (4, 64)), ('ttyS1', (4, 65))]: + entry('dev/' + path, mode=stat.S_IFCHR | 0o600, device=device) + entry('init', (directory / 'init').read_bytes()) + entry('bin/matterd-test', binary.read_bytes()) + entry('tmp/gpio', b' ' * 32, mode=stat.S_IFREG | 0o600) + entry('TRAILER!!!', mode=0) + (directory / 'vm.cpio').write_bytes(b''.join(entries)) + console = directory / 'console.txt' + command = ['docker', 'run', '-i', '--rm', '--name', 'incipio-mcu-only-vm', + '-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', + '-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', + '-initrd', '/fixture/vm.cpio', '-append', 'console=ttyS0 rdinit=/init panic=1', + '-net', 'none'] + mcu = Mcu() + with console.open('wb') as output: + process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=output, stderr=subprocess.STDOUT) + + def wait_for(predicate, start=0, timeout=20): + until = time.monotonic() + timeout + while time.monotonic() < until: + text = console.read_text(errors='replace') + if predicate(text[start:]): + return + if process.poll() is not None: + raise AssertionError('VM exited: ' + text[-1800:]) + time.sleep(.1) + raise AssertionError('VM timed out: ' + console.read_text(errors='replace')[-1800:]) + + try: + wait_for(lambda text: 'MCU_ONLY_VM_READY_NO_IDENTITY' in text) + worker = threading.Thread(target=mcu.run, daemon=True) + worker.start() + for phase in range(2): + wait_for(lambda text: 'MCU_VM_PHASE=' + str(phase) in text) + 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) + if phase == 0: + wait_for(lambda text: 'MCU_ACTIVE_WAITING_FOR_DEVICE_IDENTITY' in text) + assert mcu.frames[0] == {'command': '0xa1', 'on': False} + for on in (True, False, True, False): + at = console.stat().st_size + mcu.event(on) + expected = 'relay_known=1 relay_on=' + str(int(on)) + wait_for(lambda text: expected in text, start=at) + process.stdin.write(b'stop\n') + process.stdin.flush() + wait_for(lambda text: 'MCU_VM_PHASE_' + str(phase) + '_WAIT=0' in text) + assert len([frame for frame in mcu.frames if frame['command'] == '0xa4']) >= 4 + assert not mcu.bad, mcu.bad + 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, + 'flash_devices_present': False, 'virtual_nic_present': False, + 'original_hardware_test': False, 'address_space_limit': 8*1024*1024})) + finally: + mcu.done = True + subprocess.run(['docker', 'stop', '-t', '1', 'incipio-mcu-only-vm'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10) + process.wait(timeout=10) + + +if __name__ == '__main__': + main() diff --git a/tests/test_legacy_mtd.py b/tests/test_legacy_mtd.py new file mode 100644 index 0000000..c68e565 --- /dev/null +++ b/tests/test_legacy_mtd.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Run the production MTD backend against Linux 2.6.30 character-device semantics. + +Linux only. All open/ioctl operations are redirected to private regular files; +no real MTD device or outlet is accessed. +""" +import ctypes +import os +from pathlib import Path +import struct +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / 'firmware/matter/src/update' + + +class LegacyMtd(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.build = tempfile.TemporaryDirectory(prefix='ck-legacy-mtd-build-') + output = Path(cls.build.name) + # Fortified libc open aliases bypass macro syscall redirection. + flags = ['cc', '-std=c11', '-O2', '-Wall', '-Wextra', '-Werror', '-fPIC', + '-U_FORTIFY_SOURCE', '-D_FORTIFY_SOURCE=0'] + wrappers = ['open', 'close', 'fstat', 'ioctl', 'fsync', 'fopen'] + subprocess.run([*flags, *['-D' + name + '=legacy_' + name for name in wrappers], + '-c', str(SOURCE / 'ck_flash.c'), '-o', str(output / 'flash.o')], check=True) + subprocess.run([*flags, '-shared', '-DCK_TESTING', str(output / 'flash.o'), + str(ROOT / 'tests/legacy_mtd_shim.c'), + *[str(SOURCE / name) for name in ('ck_common.c', 'ck_store.c', 'ck_energy.c')], + '-o', str(output / 'legacy.so')], check=True) + cls.lib = ctypes.CDLL(str(output / 'legacy.so')) + cls.lib.ck_store_get.argtypes = [ctypes.c_char_p, ctypes.c_void_p, + ctypes.POINTER(ctypes.c_size_t)] + cls.lib.ck_store_set.argtypes = [ctypes.c_char_p, ctypes.c_void_p, ctypes.c_size_t] + cls.lib.ck_energy_load.argtypes = [ctypes.POINTER(ctypes.c_uint64), + ctypes.POINTER(ctypes.c_uint32)] + cls.lib.ck_energy_save.argtypes = [ctypes.c_uint64, ctypes.c_uint32] + + @classmethod + def tearDownClass(cls): + cls.build.cleanup() + + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='ck-legacy-mtd-') + self.work = Path(self.temp.name) + for folder in ('etc', 'var/lock', 'var/lib/ck-state'): + (self.work / folder).mkdir(parents=True) + (self.work / 'partition.bin').write_bytes(b'\xff' * 0x2b0000) + (self.work / 'bootbank').write_text('1\n') + (self.work / 'mtd').write_text('dev: size erasesize name\n' + 'mtd1: 002b0000 00001000 "root fs(bank1)"\n') + (self.work / 'etc/ck-update.meta').write_bytes(struct.pack('>III', 0x434b0004, 1, 3)) + for name, data in [('matter-device.key', os.urandom(32)), + ('matter-diag.key', os.urandom(32).hex().encode() + b'\n'), + ('ck-update.pub', os.urandom(32))]: + (self.work / 'etc' / name).write_bytes(data) + os.environ['CK_TEST_ROOT'] = str(self.work) + os.environ.pop('CK_MTD_SYNC', None) + os.environ.pop('CK_MTD_REOPEN_BAD', None) + self.lib.legacy_reset() + + def tearDown(self): + os.environ.pop('CK_TEST_ROOT', None) + os.environ.pop('CK_MTD_SYNC', None) + os.environ.pop('CK_MTD_REOPEN_BAD', None) + self.temp.cleanup() + + def test_first_boot_import_and_energy_with_unsupported_fsync(self): + descriptors = set(os.listdir('/proc/self/fd')) + self.assertEqual(self.lib.ck_store_import(), 0) + self.assertGreater(self.lib.legacy_unsupported_syncs(), 0) + for original, imported in [('matter-device.key', 'device.key'), + ('matter-diag.key', 'diag.key'), + ('ck-update.pub', 'update.pub')]: + self.assertEqual((self.work / 'etc' / original).read_bytes(), + (self.work / 'var/lib/ck-state' / imported).read_bytes()) + self.assertEqual(self.lib.ck_store_set(b'matter/fabric', b'retain', 6), 0) + self.assertEqual(self.lib.ck_energy_save(1234567, 100), 0) + total, fraction = ctypes.c_uint64(), ctypes.c_uint32() + self.assertEqual(self.lib.ck_energy_load(ctypes.byref(total), ctypes.byref(fraction)), 0) + self.assertEqual((total.value, fraction.value), (1234567, 100)) + self.assertEqual(self.lib.ck_store_import(), 0) + value, size = ctypes.create_string_buffer(32), ctypes.c_size_t(32) + self.assertEqual(self.lib.ck_store_get(b'matter/fabric', value, ctypes.byref(size)), 0) + self.assertEqual(value.raw[:size.value], b'retain') + self.assertGreaterEqual(self.lib.legacy_releases(), self.lib.legacy_unsupported_syncs()) + self.assertEqual(set(os.listdir('/proc/self/fd')), descriptors) + + def test_modern_fsync_needs_no_reopen(self): + os.environ['CK_MTD_SYNC'] = 'modern' + self.assertEqual(self.lib.legacy_sync_once(), 0) + self.assertEqual(self.lib.legacy_opens(), 1) + + def test_recover_first_import_interrupted_before_commit(self): + os.environ['CK_MTD_SYNC'] = 'io-error' + self.assertNotEqual(self.lib.ck_store_import(), 0) + os.environ.pop('CK_MTD_SYNC') + self.assertEqual(self.lib.ck_store_import(), 0) + contents = (self.work / 'partition.bin').read_bytes() + self.assertEqual(contents[:0x290000], b'\xff' * 0x290000) + self.assertEqual(contents[0x29f000:0x2a0000], b'\xff' * 0x1000) + self.assertEqual(contents[0x2af000:], b'\xff' * 0x1000) + + def test_io_error_remains_a_failure(self): + os.environ['CK_MTD_SYNC'] = 'io-error' + self.assertNotEqual(self.lib.legacy_sync_once(), 0) + self.assertEqual(self.lib.legacy_opens(), 1) + + def test_reopened_device_must_match(self): + os.environ['CK_MTD_REOPEN_BAD'] = '1' + self.assertNotEqual(self.lib.legacy_sync_once(), 0) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tests/test_startup_recovery.py b/tests/test_startup_recovery.py new file mode 100644 index 0000000..450f26c --- /dev/null +++ b/tests/test_startup_recovery.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Fault-inject the startup scripts in private directories, without devices.""" +import os +from pathlib import Path +import subprocess +import re +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / 'firmware/matter/src' + + +class StartupRecovery(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='ck-startup-recovery-') + self.work = Path(self.temp.name) + for folder in ('etc', 'bin', 'var/log', 'var/run'): + (self.work / folder).mkdir(parents=True) + + def tearDown(self): + self.temp.cleanup() + + def executable(self, name, text): + output = self.work / 'bin' / name + output.write_text('#!/bin/sh\n' + text) + output.chmod(0o700) + + def run_script(self, source): + text = (SOURCE / source).read_text() + text = re.sub(r'/(?:bin|var|etc)/', lambda match: str(self.work) + match[0], text) + script = self.work / 'startup.sh' + script.write_text(text) + 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) + 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') + 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') + + def test_failed_import_keeps_bootstrap_for_setup_and_diagnostics(self): + self.executable('ck-updater', 'echo import_failed\nexit 1\n') + keys = {'matter-device.key': ('device.key', os.urandom(32)), + 'matter-diag.key': ('diag.key', os.urandom(32).hex().encode() + b'\n'), + 'ck-update.pub': ('update.pub', os.urandom(32))} + for name, (_, data) in keys.items(): + (self.work / 'etc' / name).write_bytes(data) + result = self.run_script('update/ck-ota-start') + self.assertEqual(result.returncode, 1) + self.assertTrue((self.work / 'var/run/ck-rescue').is_file()) + self.assertIn('import_failed', (self.work / 'var/log/ck-store-init.log').read_text()) + for _, (name, data) in keys.items(): + output = self.work / 'var/lib/ck-state' / name + self.assertEqual(output.read_bytes(), data) + self.assertEqual(output.stat().st_mode & 0o777, 0o600) + self.assertFalse((self.work / 'var/run/ck-update-healthy').exists()) + + def relay_script(self, rescue=False, conflict=False): + self.executable('pidof', 'echo homekitserver\n' if conflict else 'exit 1\n') + calls = self.work / 'calls' + self.executable('matter-diag', + 'if [ "$1" = --log ]; then exec cat; fi\n' + 'echo "$1" >> "' + str(calls) + '"\n' + 'case "$1" in --run-matter) exit 5 ;; *) exit 0 ;; esac\n') + if rescue: + (self.work / 'var/run/ck-rescue').touch() + result = self.run_script('setup/matter-relay-start') + return result, calls.read_text().splitlines() if calls.exists() else [] + + def test_rescue_starts_only_local_mcu(self): + result, calls = self.relay_script(rescue=True) + self.assertEqual(result.returncode, 0) + self.assertEqual(calls, ['--run-hardware']) + self.assertTrue((self.work / 'var/run/ck-mcu-only').is_file()) + + def test_application_failure_hands_mcu_to_local_mode(self): + result, calls = self.relay_script() + self.assertEqual(result.returncode, 0) + self.assertEqual(calls, ['--run-probe', '--run-matter', '--run-hardware']) + + def test_existing_homekit_owner_blocks_both_modes(self): + result, calls = self.relay_script(conflict=True) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(calls, []) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tools/fwlib.py b/tools/fwlib.py index aa76beb..ae78be7 100644 --- a/tools/fwlib.py +++ b/tools/fwlib.py @@ -229,7 +229,7 @@ def profile_lock(profile): def journal_state(before): - """Accept erased or committed CKSTORE1 only; never silently claim vendor data.""" + """Accept erased, committed, or bootstrap-matched first imports only.""" area = before[END:END+0x20000] if area == b"\xff" * len(area): return {} @@ -253,7 +253,28 @@ def journal_state(before): values[key] = payload[at+4+key_size:at+4+key_size+value_size] at += 4+key_size+value_size valid.append((struct.unpack_from(">Q", block, 8)[0], block, values)) - require(valid, "預留 journal 含未辨識資料;拒絕初始化,請保存當次備份") + if not valid: + # v0.2.1 may have programmed the complete first import before the old + # MTD driver's unsupported fsync prevented its commit. Recognize only + # an intact generation-1 body matching the personalized bootstrap; + # preserve the journal bytes for the repaired runtime to recover. + first, second = area[:0x10000], area[0x10000:] + size = struct.unpack_from(">I", first, 16)[0] + initial = (first[:8] == b"CKSTORE1" and struct.unpack_from(">Q", first, 8)[0] == 1 and + first[60:64] == b"\xff"*4 and 0 < size <= 0x10000-64 and + second == b"\xff"*len(second) and first[64+size:] == b"\xff"*(0x10000-64-size)) + if initial and hashlib.sha256(first[8:20]+first[64:64+size]).digest() == first[20:52]: + root = before[START:END] + names = file_inventory(root) + for key, path, count in (("identity/device", "etc/matter-device.key", 32), + ("identity/setup-ap", "etc/ck-setup-ap.bin", 104)): + if path not in names: + continue + bootstrap = read_file(root, path) + payload = struct.pack(">BBH", len(key), 0, count) + key.encode() + bootstrap + if len(bootstrap) == count and first[64:64+size] == payload: + return {key: bootstrap} + raise ValueError("預留 journal 含未辨識資料;拒絕初始化,請保存當次備份") if len(valid) == 2 and valid[0][0] == valid[1][0]: require(valid[0][1] == valid[1][1], "journal 同序號內容衝突") return max(valid, key=lambda item:item[0])[2] diff --git a/verification/README.md b/verification/README.md index da2f623..9e111ab 100644 --- a/verification/README.md +++ b/verification/README.md @@ -1,5 +1,7 @@ # 驗證索引 +v0.2.2 的舊 MTD、配網啟動、身分等待及 MCU 退路修復見[修復結果](recovery-results.json);原機開機驗收狀態與模擬結果分別記錄。 + v0.2.1 的軟體累積 kWh、獨立 Flash 循環紀錄及標準能源訂閱見 [電量結果](energy-results.json)。 v0.2 的新增測試見 [OTA/持久儲存/量測結果](ota-results.json);以下歷史 v0.1 結果與新版本分開保存。 diff --git a/verification/recovery-results.json b/verification/recovery-results.json new file mode 100644 index 0000000..2a19105 --- /dev/null +++ b/verification/recovery-results.json @@ -0,0 +1,87 @@ +{ + "version": "0.2.2-preview", + "software_version": 4, + "date": "2026-10-14", + "status": "pass_for_listed_fixture_and_vm_checks", + "checks": { + "legacy_mtd": { + "status": "pass", + "tests": 5, + "log_sha256": "08078df9b86dc2ffcb5480c802c7fa31caf8872610680c1fef71b5e5ce3e6afd" + }, + "startup": { + "status": "pass", + "tests": 5, + "log_sha256": "cc14148741917f176c522c2abd365eceb7a97afdcebc42f02ef610ad0a37d4a1" + }, + "native": { + "status": "pass", + "tests": 20, + "log_sha256": "d4f3cbeac9bd17b69fc4865cce4e688eec8ee43aa8c93e938112661fa43f6bc8" + }, + "mips": { + "status": "pass", + "tests": 20, + "log_sha256": "6b89f04a9485f54b2c8d5e4d4853aa6535264639384e86081140a5d3eb48536c" + }, + "deployment": { + "status": "pass", + "tests": 12, + "log_sha256": "bb5c167b5ad4f5cf22ade1cb5a063ae4fed2b795c0c6725c07d7f425f1040b61" + } + }, + "mcu_vm": { + "status": "pass", + "binary_sha256": "185d4feccb0989890c22e0582217ad3f79e180e88216f4a6f2ab343b67d8ee80", + "identity_missing": true, + "cold_off_confirmed": true, + "startup_identity_wait_and_mcu_only_mode": true, + "simulated_button_state_changes": 8, + "continued_mcu_polling": true, + "flash_devices_present": false, + "virtual_nic_present": false, + "original_hardware_test": false, + "address_space_limit": 8388608 + }, + "pid1": { + "status": "pass", + "environment": "native Linux PID1 in private container namespaces; regular-file NOR fixture; separate MIPS writer suite", + "checks": [ + "real process quiesce and pivot_root branch", + "updater executable copied to ramfs", + "rootfs written and read back", + "all protected bytes and journal unchanged", + "namespace reboot" + ], + "physical_device_access": false, + "container_exit_code": 129, + "updater_sha256": "e60adbb3f85c6a1cec32ea141980cf0569a41aead6b33080ef6a334e552832b5" + }, + "fixes": [ + "use original wlan0 interface when generating setup identity", + "start MCU communication before waiting up to 60 seconds for setup identity", + "synchronize legacy MTD by checked reopen and close barrier; real I/O errors still fail", + "retain validated device node and proc metadata in RAM updater root", + "preserve MCU-only recovery and bootstrap-based setup/diagnostics on failed persistent import", + "recognize only intact generation-1 uncommitted journal matching original bootstrap, without changing journal bytes during programmer installation" + ], + "original_device": { + "previous_version": "0.2.1-preview", + "owner_observation": [ + "setup AP absent", + "button turns relay on once, subsequent off unresponsive", + "LED steady on" + ], + "consistent_post_boot_dump": true, + "original_kernel_and_bank2_unchanged": true, + "installed_rootfs_unchanged": true, + "initial_identity_body_checksum_valid": true, + "initial_identity_commit_missing": true, + "repair_installation": "write_verified_with_independent_full_chip_readback", + "repair_boot_validation": "pending", + "journal_and_identity_preserved": true + }, + "matter_image_sha256": "27bb2d53da8f58b7ba993e65f3bd72b296c4203025dd6040a8ffc1d2e53718f1", + "original_hardware_power_cut_test": false, + "phone_ui_verification": false +} diff --git a/version.json b/version.json index 0d5d04b..50d3a1f 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "software_version": 3, - "version": "0.2.1-preview", + "software_version": 4, + "version": "0.2.2-preview", "catalog_url": "https://lee98064.github.io/incipio-commandkit/updates/index.json" } From 69034ae82c3ab35a46beadc5322d112b9c305002 Mon Sep 17 00:00:00 2001 From: lee98064 <46412433+lee98064@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:28:56 +0800 Subject: [PATCH 04/13] Record public artifact audit for v0.2.2 recovery build --- verification/public-audit-v0.2.2-preview.json | 492 ++++++++++++++++++ 1 file changed, 492 insertions(+) create mode 100644 verification/public-audit-v0.2.2-preview.json diff --git a/verification/public-audit-v0.2.2-preview.json b/verification/public-audit-v0.2.2-preview.json new file mode 100644 index 0000000..5851e22 --- /dev/null +++ b/verification/public-audit-v0.2.2-preview.json @@ -0,0 +1,492 @@ +{ + "status": "pass", + "scope": "selected public files and decompressed release rootfs", + "public_file_count": 5053, + "public_bytes": 24838218, + "variants": { + "matter": { + "sha256": "27bb2d53da8f58b7ba993e65f3bd72b296c4203025dd6040a8ffc1d2e53718f1", + "size": 8388608, + "regular_files": 231, + "file_sha256": { + "bin/WACServer": "fc7fad2d10fd83927f82f5471bcf70fcb853ab54a140aa6dad27682efd02e63f", + "bin/acltd": "1f3cd73bed035ad5a7b1215a7a31dd43ec441231cafa05c6017fbfc2e2af2cb7", + "bin/auth": "af1f4e5f83842233b9c3ba499e1fe019bf45e682787220faab58500f3d855031", + "bin/avahi-autoipd": "e3ca507667b99a4fcbc0ed8d0d640abc3998332e24691ee50e04d5c3e1c63c8b", + "bin/boa": "175c66efa68eabda38c8ca6990e824ddcc41dfec0219bc739bce7b8ffe0a587a", + "bin/brctl": "f4acc1905b421d52f5e8e6e24f483080f64194d3e40896f52659214569521218", + "bin/busybox": "3cd0ec8270214a74879ee4b67e592be285e23d6a9cca964b64a86b88c821e619", + "bin/ck-lease": "38e9987f8fc53109113f8aefba380f3c5a4e2575ec053d1ec82b140d28ccbe66", + "bin/ck-ota-start": "c108a2c73020bcf8cb78ea46d5fde32228e434caa3f1f9d12ce8d6f2b141a4a9", + "bin/ck-ram-probe": "edca27a1c7932af7e5088829ac0f1b43ffbebbeffe4476e7cf2dae4b32b037c6", + "bin/ck-setup-backend": "226d6b78cd8c7163809c62391a587a285c88fb2720d2d2accc850cbcb3ed8616", + "bin/ck-setup-start": "3399f3f277217be511874b8622115547bb5fe3b413596b49682a28dfc970e636", + "bin/ck-setupd": "5b5dee66061b2860906bfec429eefd089b5196f6ad14473815838fb3fb5ac954", + "bin/ck-udhcpc": "97eb31d69d3514a5a552b075a5089527ef95917e29ee08105a830996d3ac6b85", + "bin/ck-updated": "6417560e2817b7c5f50b059de6e6d38c7a737cd94c509df4f81c8d1baa7302e5", + "bin/ck-updater": "ee36c3083395d5e24cd9d5c58f6e5d3c7a529fc02fb79651209837e8eb2ff56c", + "bin/ck-wificfg": "350402de512d6e124346f24a39695b0fdb2b3b4ea06ec0c5bbaf1e852f0ab2dd", + "bin/ck-wifiscan": "e9f77c51d6424501c8703351b8f23f181fb471ed652256f386ef623746975721", + "bin/connect.sh": "116a58d9caa45a8a83a43b7db5e42505b3e7640e4cdee06e62c6dcc7b576b433", + "bin/connect6.sh": "97e8946dc7c28bdbde8beda01e87315e3bedf07e1704db140c8c889a036dd0aa", + "bin/ddns_inet": "d1d3bc14909540e6f17f07d72ae6d208fefc4bfb9aa33c97fbc784d2986affb4", + "bin/dhcp6c": "50cc13f2cb4b10d2a44a90750760cf8bc7227b40d39a8bb1c1a6cec45d1873cb", + "bin/dhcp6cRcv.sh": "72d0f7430a95c2e5405d3b469a44cc788cac215eb8e5d91f7182e366f595e206", + "bin/dhcp6ctl": "c391c1a0828c2e5c7a2e4892fa7dce1dd28018f2a0610bdb9ac1c623f9cc77ae", + "bin/dhcp6s": "794c877af3c4bef3d3f457e1afc7ae9bd635bc775ec8bb4f4152176e73b26823", + "bin/disconnect.sh": "ec469e947e3e2e4c9bf0aa0a6d95a4a24d9bb7521ed467d5bd183a74dfef6798", + "bin/dnrd": "55c99a83c659cbd127e75b7c34c13f73853f9c6e612271bd311f81a1a711ebd8", + "bin/dnsmasq": "541c75037f62bc3a104989ece61b044463370f15219c4c4ae21e7f68ceb159b6", + "bin/dw": "5ef838655702ff0ab7cb8f5993a31e5903afc1e437a17e44f4c4842124d72266", + "bin/ecmh": "b56eb2a6e88722ba361042b329d67b3d5bc3634aa083690c949596c239e7f81f", + "bin/ew": "1a3e6bc1afc8345387dc9a38887e6af4adde619bc8743c25f0366df6928a649a", + "bin/firewall.sh": "5688eba3b9e7f322c8e7b1e2e831f6e999d6fcec125283fc8de75d144e42e7f1", + "bin/flash": "28aca13b5d8df12040dd2c6ffb3dc69e61bfd1e0c860d25447f3a08e66963523", + "bin/fwd": "698eb8a02fcc0f2dcfca6702ed766f7338dfe51f4df6f85c72a2a78d789a190c", + "bin/getmib": "e3c872196f6afe6501514101614cc8b8ece902e6f8b6b9a1ad352a10c2a61e25", + "bin/getmib1": "89bc956fe6fd4e5e134c8769e611253fecf50272e2396ae65015e44d5d3d8b44", + "bin/gigadevice_chk": "f555ae6b974c2e7303072a1441820b171907993dd389e062557c7ac9d5191a19", + "bin/homekitserver": "2d50339436759f3d602c150fb4f1a42f0fdf122fc18e9a4f54f6e6b07d7a0c62", + "bin/iapp": "0dda7f960b1b0340f03f1823dc457b0b2fef84dd887d3b07d2158e5845f78bdb", + "bin/ib": "23540feab73c9f0f287e4f91284343ec8677aac9e5b1f51bb2bcf0db1075e697", + "bin/ib1": "439aeeffb8b3253384713b53f95120a2206f97911c64ebb90366c487ec1f2024", + "bin/id1": "cead109ab787def7dc01e63c4cdbb782493f588cecd709aee61a298b263d6dbd", + "bin/idd": "fd0a6763249a6064351ba787fbadce230b9ccfdeacff4f3de662b0c40b46df50", + "bin/idd1": "cead109ab787def7dc01e63c4cdbb782493f588cecd709aee61a298b263d6dbd", + "bin/init.sh": "37a89df20b2efa6b9e4268320fae90e29774dc894168a257f25b89f452e7bda2", + "bin/ip_qos.sh": "0bbaa983ce78b27b561a9c780c3ba9264563d4a38b61209f1898fc83a5473ee0", + "bin/irf": "848040ed72207dc5a47d3f67d6f54f8ce6eb3f36c38104089aa438d55114bfcc", + "bin/irf1": "9e7d8c249b22c9e65e9dc3050bff451f3bdeaf9b4eb8db6bbec5b9b39899eb0d", + "bin/iw": "82e621203cfa87228dc1bb520f2837bf7833346a0fc9f16757970f8ed07f11da", + "bin/iw1": "0ee09886704fa633ecd6111c1fe8ce373054e2c5feed8924a006255017ff8987", + "bin/iwcontrol": "5df1e0c326ef676d8dcab9885e079b9b25bc956b5aba6cb1e99af5dc11c5ae0a", + "bin/iwpriv": "0d54f6042b99bfbecbbeb68152a462f8a54663407a7ecf804c2c8bca2d6da6d9", + "bin/killsh.sh": "402bc7a2845df34d9c15049c38dac2688cd448995a908def3a27438fc762c019", + "bin/l2tp.sh": "075ba0c8e3a533efcbe14d42f66d3ef1163571fc22759e249ffcca17496ec0bb", + "bin/matter-diag": "dafd8378ea1f4e41a2fdde65c2aefba0d6da2c8cce5f3a24fd5e14344ab1ca2a", + "bin/matter-diag-start": "d214053c374cbe396097f76a8fa97927a767fad3620bfc40ab1116e38fb5c31a", + "bin/matter-test-start": "220891adfa0d2671da1fefb4b1347c2b014ce47b5aa1455240e934eb8d28c629", + "bin/matterd-test": "185d4feccb0989890c22e0582217ad3f79e180e88216f4a6f2ab343b67d8ee80", + "bin/mdnsd": "7d5243636c0870e9cf32f4ecda0d494c6b47091da70cd470911e5961430ca177", + "bin/mldproxy": "eed2c2737eeefc4e808ce088b5c2ee7611fb93e414c826d4bf116218b51d48c3", + "bin/mmd_cmdr": "b71745208ba972d3a02991af759d4827b81c2e5156995c47e09b558b640cb163", + "bin/mmd_cmdw": "aeb2542634581cccaf6a9e76a41b69147c840663b2aff43b652ad5a1c4a5a346", + "bin/mp.sh": "e41e053700105de8511c57a984cbd03347381bcc48497ac391bde8bf6b68cb99", + "bin/ntp_inet": "79d56c4369c418fb4a4920f0394aec7c1e7ccccc9dbe119bfb0518f2395abcfa", + "bin/ntpclient": "e736240aab44bdd9d701bafe6eeaf383fa98878e57cb2913126e524185678aec", + "bin/ob": "452a3031a31802e8a7a79e200d2ef1c149ddb045a83ca9faea72133f1d10d4bd", + "bin/ob1": "c3f3f85889c443c69b17daa93fe0aa27e1e2dca99fa7e0f36b5579e749c1dc7b", + "bin/od": "be0f8099e8e1440e10b1b7cf6bf4ed063ff926887c4334fd47c941fe05c5635d", + "bin/od1": "aaae9dc1b71c3ee083efb9fc3b4862be9a32deb117a8b2edac5a3a057226e213", + "bin/orf": "6de1b42f0975076302439227c5542c353faff0293ff49c27932bec9b94d373c8", + "bin/orf1": "0d83ffdf74e8b6153062b24c765cb562bae115bb6e94ceccf40e198a26268b99", + "bin/ow": "3e06231474fe83569add90f05af1fbd478e243da89fa7767a6f1e27efab5b2ac", + "bin/ow1": "cb4d076d2a8efc2168e5a5d67b3da4ac3b0eec54b9fecf0735d3a4dc5602b07e", + "bin/phyr": "0ea4bf45ead3a0da7d55feaeef12bcfb095e63f69a70a842672f36b008065e80", + "bin/phytrex_tools": "a4733e1b2b8c52e48c109c9c40a2b9af532248e4b4116eb72260b1f0f20e6fc2", + "bin/phyw": "830bfdb9473fae12ab9aa8298d7aa3f9831bff63e7393a7f2b29dd530f134545", + "bin/ppp_inet": "44b1db22dc2b786e90ed558175915a9eef926a7818bd43f1c675724a06b77109", + "bin/pppoe.sh": "28d59a56e07f42c240ec59a7ac5a784371d00cef92f1984943232b3050ca011b", + "bin/pppoe_conn_patch.sh": "b6da8ac9633bf1a4a2bc665945247882ef8181e1fca9e65f3a8e7af631cc1e7e", + "bin/pppoe_disc_patch.sh": "025aac686216515eb57a2a26f5bb7b7c507f66b48c5404d8502dc0f414f1cb93", + "bin/pptp.sh": "c79acbc24a203398e6858f464065d65dc83eb0087bb3d6fd0ac47ed294a7754b", + "bin/radvd": "9db7fe7c307562b562fc1a96a33ece6b53be4cafe7bc63648d09a8c4567135b6", + "bin/radvdump": "c4c9694b057105652c461308b901dfe02da3bdf8901175dfcd8d112e6a21d71e", + "bin/reload": "22f689f717c259ecbeb628cc051fa4d82a3011146f2f4e48fd37cd5380184be3", + "bin/rssi": "091d5107b5c27b266252585b7c9992df4b2270704f81dc939eb1bbccc7a0ee6d", + "bin/rssi1": "1cca70b5b1cca70afdfa9637b180d904e0236cc487ac55d8ab1395283dcb8c67", + "bin/rtk_cmd": "89dbb16d504b668c48abae30b0d99724c20df7ce1a35faa55cc18f887bc7d7bb", + "bin/rtk_eventd": "1638b8ace10a21145ce26df5c835e8e8aef72bfbfd4f6ff13a91f3894ba9be38", + "bin/setmib": "2025b4ed81e89dcad0a58d4411c101ca67f0d6fdb85b1eb73539049f8675a3a3", + "bin/setmib1": "89ef32a13b8888d9e2b2f1e6a84c849f0d35e73e03c94c82d5be11f68f59209b", + "bin/snmpd.sh": "014ca345c401ea958db53e7dcb7c4dc050198f680e9a746410268a7a84a0875b", + "bin/startup.sh": "d620040cff0aa1ada99a9d68d1fc8436cd335ace89481880c5c2daf70a7e8fdb", + "bin/sysconf": "a88ffd520ea3382bcd1b656c1c2b4e02a416619da2fc87d4fe108ee08e76c9a0", + "bin/timelycheck": "cf7afc7be127701b340c4ddcfacfeb690af40041b6c93fc37e29e63c0ce41511", + "bin/udhcpd": "6c65f86b575cc3956504e37b9688fe6b0cafae8a38938aab56a2973b0802a699", + "bin/wfaudio": "ba269a6c1565c02b8322d18e3d40e89de6f7f98097a90d0ce06e3452218d5d22", + "bin/wlanapp.sh": "a45b6d40a8b126283a575f0bbf1bc97f076f39c58f1ca74f27bcb0997aaa563f", + "etc/avahi-autoipd.action": "6e14205c720227f5294bfb9348dc1071dcdcc914723695619a10d5e11c395176", + "etc/boa/boa.conf": "6236c22da5f177dcaad1bd6ec70ce3dca62cb1a56daf83b668933cd8c0aef6e1", + "etc/boa/mime.types": "1e3e9fe650ec67e99cafeddcfbaf52a11f910666ec11d0f2c7a264392622b9bb", + "etc/ck-udhcpd.conf": "53e4458bbbb97de591d528efeeaa146eeaba2d5c7612d7b2389cadb4b8123819", + "etc/ck-update.meta": "b64568d6627993c257888c01e1b7f6786fd44dec2a10643c781289f9b9b681f2", + "etc/ck-update.pub": "386eb1742a602b5e9bd2007bcffd3a39c0a467500f92a1856d7c6059c88f5a2c", + "etc/ck-variant": "acbc154cdaa2d015cd86d03b5b1ce12da9accf509e6c00ddb6c7316b760f6209", + "etc/ck-version": "574392d535364e4db87a41d825df85c92aa96185b0551ca2b98f9b87e474eeeb", + "etc/customize.version": "44f87003455f39f4bb1e8fd1be8342c591b7c7fff44143d6949f6adbee4600b1", + "etc/dnsmasq.conf": "0ae2408b285a62bce4c8b0c7fab85e350889699d22feff1b6adcc54253bb95d4", + "etc/group": "5148fde8988f4762c1a14039adf6ca7cfcf3b201c71cd48332da60ca69ff49a8", + "etc/host.conf": "313728062c7ae797e11187c7f1d66b559f0098115320166bca6100486c167824", + "etc/hosts": "f80e3d5c3a0e4517e650bccee94c8033131e263d8458b37a18dcee440cb3819b", + "etc/init.d/rcS": "2e8254920a09c4c068a6506f5be87f7b00bc54124b8b246ddff5aea4e3982eff", + "etc/init.d/rcS_32M": "54b23c1e0a0a9a4f02484f6287e21f60588d0a98a790e76e9d9b4af3578237a0", + "etc/inittab": "7728ecf0138d2ed4b1408204e8c7b0c7eacfe650b1fe29cadfe941031b476e3a", + "etc/mime.types": "8d0a3ce3b74daccb330e158471cab898d40d4e40ed8a74ff9d710909a82cdf6e", + "etc/motd": "3b727708cd2efa362aa751c12c8d2e9e0e2050cb07b3d646dae2c6f4bcdd1cb3", + "etc/passwd": "8e619aab9bdf9ddd2644bed3ac319e862e908c4664b862120c2f4209c401bd32", + "etc/radvd.conf": "9ef8db8aa53148d8671e93b9d2879f18e1aec399ccba92aae324271796b5868e", + "etc/samba/smb.conf": "a56cfd4403599a01be6a78bfadcb1e6cc5177f7b4022e3a17e2ca861d2eb7c6d", + "etc/samba/smbpasswd": "2187a31165dc5cb2190130abdc04d6c5fef3d78ffc609b390b74a444f8e76c94", + "etc/services": "6ae28a7390a44890f3709f772dd5a2d8246880b40a734f1317cb117eea6de748", + "etc/shadow.sample": "106bf188d92eae79b255c106cb9f05777b5ba225ddef95132fb1da552b81caac", + "etc/svn.version": "9b3e52b747c1564c1a2b4eb757b829ae5d5095a0f591b8bcb49549364e75d461", + "etc/ushare.conf": "8e1b479172c4f1c7478a6658bae1a1049b607e7aacd24f2c40ff415b6e3da057", + "etc/version": "de64bb7381aaec500c2765ea8b939c7f1cc1f806609a932ba410e9e30a8957a8", + "etc/vsftpd.conf": "c86e2c41c3c17be5510db27e4998fbf372751727c340dce68362012d47744e92", + "lib/ld-uClibc-0.9.30.3.so": "35051ff978746f071c792d3d262b14f7e78dbbc461a9fd5c1e56a73966b0cb30", + "lib/libapmib.so": "e88721af68971ac96e949e7a918ae59e339cea1a3e7ecc9d40d59d5024e62824", + "lib/libdaemon.so.0": "a6ba44569b6a17ae9806aa89eb0bca7018946cc3aa4e167cf6b67e1f17ed8910", + "lib/libdl-0.9.30.3.so": "fd211b05beb2139a4e97a2441001be7554170e2602ac3c5298b228571cd1e3cf", + "lib/libdns_sd.so": "b0175db146f751120165c1d93a46be5e9da19f3e4201f13dbf0365fc053fddad", + "lib/libgcc_s.so.1": "c8f9860a3518dc0d6b78ff6640240aeb68e9b38a59aaf37bdc86bc1c0cd361d1", + "lib/libm-0.9.30.3.so": "bd31abbb878d32393b26573ae5b6055d1e73fa8126b4ade9a76465458d8bff2f", + "lib/libpthread-0.9.30.3.so": "7cde98eebc3ee088f150497282edb955b9d9aa1fb72f65603b0f6505d4919872", + "lib/librt-0.9.30.3.so": "ba9ebba8c75a71046716ac9ed2c37a25534ccf0647e0b123d8aea010738db2df", + "lib/libuClibc-0.9.30.3.so": "3bdde33cb95c8a4ce9a786d654f3e87f7a86e667201f14cea4fa33ee6749c278", + "usr/share/udhcpc/br0.renew": "0f225c0c1b9feeb416f46c86f729eabde73518f6920f26bf4dfaa8d501d54221", + "usr/share/udhcpc/br0.sh": "8be90d840672e3038b1e60ab59c206672479a28fb7168c9e2d9ffb267f3f787e", + "usr/share/udhcpc/eth0.bound": "394691277bad5f0f1a6b708d95e7ed57d6a0432ead294af299727f08c8c4904e", + "usr/share/udhcpc/eth0.sh": "f663605c21f57feadba977443b7b2ee298803752664b7b64207af0a75a27a17f", + "usr/share/udhcpc/eth1.bound": "e721f2ad88cbdfa3f37c5a7053a05c15766955f4b2d5ee9f7c7f7a195a7ad89b", + "usr/share/udhcpc/eth1.renew": "0f225c0c1b9feeb416f46c86f729eabde73518f6920f26bf4dfaa8d501d54221", + "usr/share/udhcpc/eth1.sh": "87e4066153462feec83c9ff7666812eb258bc4d38550e3498b31c33617441e7b", + "usr/share/udhcpc/wlan0-vxd.bound": "cf1ad0dc6a1fd3fd71b839a47e2c8ccce076db67608d7b87e412aa92010c02d3", + "usr/share/udhcpc/wlan0-vxd.sh": "092105e71c24ea2a1ecd4e8516b8bf32cd0468343bc803516f8b2e7baa54ec2e", + "usr/share/udhcpc/wlan0.bound": "cf1ad0dc6a1fd3fd71b839a47e2c8ccce076db67608d7b87e412aa92010c02d3", + "usr/share/udhcpc/wlan0.sh": "b60b564cf829f019db892c4b2c05d0950557f45e0cb8c33d8b1a0b11f4fd289c", + "usr/share/udhcpc/wlan1-vxd.bound": "cf1ad0dc6a1fd3fd71b839a47e2c8ccce076db67608d7b87e412aa92010c02d3", + "usr/share/udhcpc/wlan1-vxd.sh": "ad88d272a3ae0f31bfcff86a1e08be1342f57511615693782a7fb11a93b50189", + "usr/share/udhcpc/wlan1.bound": "cf1ad0dc6a1fd3fd71b839a47e2c8ccce076db67608d7b87e412aa92010c02d3", + "usr/share/udhcpc/wlan1.sh": "f4f1548b05bef9b69c13c32c2f157017b9623a946870774fd32e2246f8192d4c", + "web/code.htm": "ef39c1dcc759b7d9f614fe6b8049ddf2a9122e9d9e81a614849e5f5700afea0a", + "web/countDownPage.htm": "b4d199553f8dfa58f0a19c8923be713c8fd6de556bbd1190ad412e5295536cab", + "web/ddns.htm": "3c66f89fe65b19061a42d09ccdbd7d8abe3a2812189f1e0b57a9a0cea73e45a7", + "web/dhcp6s.htm": "05234b252c5c08471952974dfa9c30254d3baf12efc537ac1765d9b8c71af22f", + "web/dhcptbl.htm": "969251aeabe941809d38398f0168ed3ba8b06d3a33534c537b7442950534e81b", + "web/dmz.htm": "5b067a0e5c07e015a81ba3bb0556f6e73192ad044f2ad69bab3b2a34820f58d9", + "web/dnsv6.htm": "86a8711db19229c3f2680d64e45df06cdcdfc5d336d7db49f917907dba7a5b92", + "web/dos.htm": "86e3fc95e66b4d75cde12767dc5812e019dfc4718b897068ac1c79380a060458", + "web/favicon.ico": "fdce9e8910ba9bc5ea2357c8002568c6f434a80175f381d882bbd71feffd910e", + "web/graphics/back.gif": "0e9ced1019385b1101fdaa7c07d01d63aae771176a44d3df81d47b115b5128aa", + "web/graphics/dir.gif": "16f741d14a9af2cf05588d4ca748dd9a4bba9780c464f424076258b3911807ee", + "web/graphics/router.gif": "69a4f526ad2a35621afc9e9eabd1dbbb4f24f3f0e0496a1bf62b8c3d17363eca", + "web/graphics/text.gif": "ad62db6395e4e4bc21d0442062659db0f5ce93656e8d6464c172328dff5b2ac8", + "web/graphics/topbar.gif": "295b5e68db8a2e727b60c5d142b32661daca0722f3de4981aee91c77c49abda3", + "web/home.htm": "c39b5a02b4a8d9b72ced04cbdb4b881186533fad9a699e28086455f61d1cf880", + "web/index.htm": "af6083187e85b9711ce81f6fc86ec2a4dd31a7f578414359de3a3ccffce73076", + "web/index.html": "169ae52b96b7129c3a8c7a9d848f219c49a1b47bb88e417829b047e20d255852", + "web/ip6_qos.htm": "9927ebc69e819f57761419019c6896a0bbcfd79b4b1baa84b24a359153b1c25a", + "web/ip6filter.htm": "0b7b40aac77aae6482a97c1ac5c277b318a1624d541c5ff40572d63b352acc8f", + "web/ipv6_basic.htm": "af8f5f372f4e053ed8b74e970936d1456cbb4cb940eec19726532886d6bf7618", + "web/ipv6_wan.htm": "597459af6fd8763e565d8848890aac17de4931bf1717f3b6802af2d0bdd0aad2", + "web/logout.htm": "ef640d19f21fe339e1eea7ff032b2d16f6bc9a91bdebac269de12ebc96e91991", + "web/macfilter.htm": "0ba33043f8efa0593580d7557e1e11ea8d0d9f0184c1d9f3d1558af80f608eab", + "web/menu-images/menu_bar.gif": "647aded4cdccaa9a9f00d73e66ca92b1bc1d8f9775b76212a3d04504ed5410e4", + "web/menu-images/menu_corner.gif": "39af5e39f3b1e770f7488e44b445624c3ed7271e4c7de8dbb34dd2f4416e7af6", + "web/menu-images/menu_corner_minus.gif": "b1c5de54ea4ab836eff259195d32a578deee31c295288b06d90a8e7e98e085f3", + "web/menu-images/menu_corner_plus.gif": "d39a780f3d0e3283a3ae64e67f9ebf128b17c0b5b1417a06e4f78f09ae619e42", + "web/menu-images/menu_folder_closed.gif": "09e687ff155d3000d791ed036ff0e3f77b8696157eec186257dba3684e372f24", + "web/menu-images/menu_folder_open.gif": "1612a163cef16999411d2b63e12aa2e53c7d3bab1a2b5c2468cb0591727cb39b", + "web/menu-images/menu_link_default.gif": "5f0f5f53e769f08ac4be05097ac5e3474f4a28b1b0215a34805ec9e79297b0bd", + "web/menu-images/menu_pixel.gif": "63b98a0c8568c08fd01a6946a147bca65eff26c8085f1ccb5330aafe0f0dcd15", + "web/menu-images/menu_root.gif": "b38b3fd6af062f1ceda57128c8671b6b906e396cf9d624772614fb62cd23c694", + "web/menu-images/menu_tee.gif": "bf5d4fc1fdbe4b5e3e6a5c468a716ab34e2a1a2ab4478267df7e8351c8d1587b", + "web/menu-images/menu_tee_minus.gif": "bd10a4f7b54a08d5d63e7322f267fabfe383755471cb01186d9c7b16736e3d3b", + "web/menu-images/menu_tee_plus.gif": "a62fcf9bbafcc56e9af47307710dc9c34c9239b2fe8610a6e923c576e62f8a5e", + "web/menu.htm": "133ad67524dede590787680066b45cf58a70c6db2b5b7e3577b45ce44230a105", + "web/menu_empty.html": "9f18250ded12c95522893bbb6dec6c6be3bbfd395b4fce8a2ba0d04469aa743b", + "web/mtmcode.js": "565b4fbc6a66e0ca9802269de674454adcc5187d035f1a94ba93b0978b156334", + "web/ntp.htm": "5e33de238716fb88a914e1c470baaf913eb88702f664cd4ccd3e8c292f8d3f44", + "web/opmode.htm": "c6499fda559e682e5b78345522d1f7806a7152a00de8c4a28a8fe10f2bcd1803", + "web/password.htm": "800ccb6722a5be356a8f9dc1c44034d0f72c6857f5890fcfaae974204c1fe754", + "web/pocket_sitesurvey.htm": "0dce90317c717e55895d44502a14b2acbd72518447d9029f6717f4cbf538ba1f", + "web/portfilter6.htm": "74530b908cc389547fe4596e5eaf7826bc0c57d3ab7fedb8c785a8a7567845ee", + "web/portfw.htm": "938bb94911baec450bc2a49904a7fadb2f27068cc9cd65c02d7f0008d27b38c1", + "web/radvd.htm": "31ea7295aa23b9f4a73ae3fd2ac8f61283391a88780c2ef06d53dfed54c0b439", + "web/reload.htm": "eae613794aad31e1b6edfe9ab0b4695e05c5a977e1d5820810abbaea618cc4e7", + "web/saveconf.htm": "d3e5f98e82cbfc600474236c4e97d24f84e22d589ceb586f46ba69d2cb512bae", + "web/stats.htm": "4bee4161dcbb79b2193dd48fe303f72253a7c81dda1435351944e4fce505cfee", + "web/status.htm": "2f3cc0421f10b19555821466a824f2d769d410b291eed45e34f10e0004516ed9", + "web/style.css": "dfe13bca7a30354e51274a0979ea60af38b1717bcc6135ddcbc2f28dd19fd536", + "web/syscmd.htm": "dca82d34c2877902ae4f9b48dd434c7e0098969f6fcf09dc15e6e97b5c725971", + "web/syslog.htm": "ede23274b094361f7abdd6ff6242bf7ed570552dd14c710026b1af6c1a0d4d5e", + "web/tcpip_staticdhcp.htm": "a3451b01d98df04559ece73c80b931bc92fd8ed43e77f0011ee751044c47baba", + "web/tcpiplan.htm": "4629d26d6da1150971347176160880802ff8a498b2240a5ca22487cea59fc57e", + "web/tcpipwan.htm": "2d4bae96143f5392173f7f3d055b69f3a0fb3b4d68f8cfb9b3e6ded997778a6f", + "web/title.htm": "87a29d29b9b7ae7b28bcf2f1a00002a997fa1038a2b33d88c12b316f0e3164ee", + "web/tunnel6.htm": "6d2bf69b88a08d1efcc22285b1ce206ecc86f520dee2f017cf8d6c272af0d3bc", + "web/upload.htm": "c9ff896145a303104587a4c376df2dad1a7cc4f5ec2c74e87f0130e989c49d01", + "web/upload_st.htm": "11e65da936c795621500b5c1ca3abe428a2bdd2946a39a8086f7b63956f28bd9", + "web/urlfilter.htm": "108ef6504f70587f5311425bd696c22e0f0a23646e3b7f0124533ef438bceaf2", + "web/util_gw.js": "9a20c547b2d34e77039eb330e7cf845e7d252320cf8f40dbc30a252e1cf092c1", + "web/util_qos.js": "ffcccd7675ac37779d846ad43b84ccf2cd683f4b796af4496024f6a74395d104", + "web/vlan.htm": "9e2200ec452e1ce62d61d6b84ce3d83ab9ca1867293769cd23f13962f4cbe472", + "web/wizard.htm": "6f6f9a6ae33f260778698dc22afc7d24e5a967fe346cb75a21f01afa97f6c8fc", + "web/wlactrl.htm": "97edf03d39e15e5709b79a41e3a142760918e155417ba183bda60ed3a5c5b3ca", + "web/wladvanced.htm": "53b78f4f9d9d06d2e1dbe1b7d4bb3aa1538a82dac0631905d6a1603d6aab15ca", + "web/wlan_schedule.htm": "17b329f065b0d1d136c901d4fe9f8bf9aae22e3b285e31ef65c009288f7ed3a1", + "web/wlbandmode.htm": "d7f34cdf8832acc9b96316f76ef52c9c60d063b2ce0c04e160c40341909faab4", + "web/wlbasic.htm": "7c758561c8e7983f98a84bf2cf1264f4a07f26e98000ce6ab9a8065be4b24c20", + "web/wlmeshproxy.htm": "022b01ffdd7df8850cfa603b7c312d80005639f8c5fb6a11c3e48c00928d23ae", + "web/wlmultipleap.htm": "c15318e6b8983a253fe8d828d32a6c053d31223b7539dbabfd9ffc5c0fd74a39", + "web/wlsch.htm": "9fffd931fdd22cf085489f4bd36d3da9f0def6b4bd65a06e0cecea650fde8bd3", + "web/wlsecurity.htm": "378353ff4e39815f9312c6c6cc33c7f9734ba2e44c1afd008953866cf170ad4c", + "web/wlsecurity_all.htm": "6be91b7ba91497d0756dd9162f54b6fca067992c9a9ea9ead8fb5c2791cd71b9", + "web/wlstatbl.htm": "a37f69ff59dc3443c2c896013f56e80cdd6831a7497034e6e6d860ab551a2441", + "web/wlstatbl_vap.htm": "7e3d4f04f66a6665aa0ade7b32a50f57116ef7011de40e1850f9888f9e8aad48", + "web/wlsurvey.htm": "90273cb11ca78519ca079a2d87f3aa143b415f2ae53d0ff7f924ad5d4c5d4c08", + "web/wlwds.htm": "ba327360f7963117f850a1096c0379e505218e6c9c38f877f4105cb1451d7885", + "web/wlwdsenp.htm": "5da00b29e79db94a6382698b70bdfb2e2dd2b0703b0fef79ffb549fc5873db15", + "web/wlwdstbl.htm": "7a53914672c15459ef3e8875e30b84f03ad967222fb61f0e3355701c7abc113d", + "web/wlwps.htm": "ca071459c523b9af21f3104cb92fe0a1c8dd38a76c731e64e3e11707bb9e1e05" + }, + "protected_ranges_all_ff": true, + "credential_files_absent": true + }, + "exec-test": { + "sha256": "06c1c995f6c8ca03b0567719428e2e8f3fb2c7ee25e9ba31084ad4424b50c0ce", + "size": 8388608, + "regular_files": 234, + "file_sha256": { + "bin/WACServer": "fc7fad2d10fd83927f82f5471bcf70fcb853ab54a140aa6dad27682efd02e63f", + "bin/acltd": "1f3cd73bed035ad5a7b1215a7a31dd43ec441231cafa05c6017fbfc2e2af2cb7", + "bin/auth": "af1f4e5f83842233b9c3ba499e1fe019bf45e682787220faab58500f3d855031", + "bin/avahi-autoipd": "e3ca507667b99a4fcbc0ed8d0d640abc3998332e24691ee50e04d5c3e1c63c8b", + "bin/boa": "175c66efa68eabda38c8ca6990e824ddcc41dfec0219bc739bce7b8ffe0a587a", + "bin/brctl": "f4acc1905b421d52f5e8e6e24f483080f64194d3e40896f52659214569521218", + "bin/busybox": "3cd0ec8270214a74879ee4b67e592be285e23d6a9cca964b64a86b88c821e619", + "bin/ck-lease": "38e9987f8fc53109113f8aefba380f3c5a4e2575ec053d1ec82b140d28ccbe66", + "bin/ck-ota-start": "c108a2c73020bcf8cb78ea46d5fde32228e434caa3f1f9d12ce8d6f2b141a4a9", + "bin/ck-ram-probe": "edca27a1c7932af7e5088829ac0f1b43ffbebbeffe4476e7cf2dae4b32b037c6", + "bin/ck-setup-backend": "f3f9c319a4e2b25f97eaf56e4ec3212b27406bbdcd45bf7fe56e30dbeeadb0a5", + "bin/ck-setup-start": "1421bb89de36f8d8e80939ffe850f3a73f4b27db7bbe35b93133aaeb316fcfe8", + "bin/ck-setupd": "eed41cbdd055676430d1507c23e3845b31343a28b45ca9e995cd41e895f022b8", + "bin/ck-udhcpc": "97eb31d69d3514a5a552b075a5089527ef95917e29ee08105a830996d3ac6b85", + "bin/ck-updated": "6417560e2817b7c5f50b059de6e6d38c7a737cd94c509df4f81c8d1baa7302e5", + "bin/ck-updater": "ee36c3083395d5e24cd9d5c58f6e5d3c7a529fc02fb79651209837e8eb2ff56c", + "bin/ck-wificfg": "4993b29ee38b04a160501154a954b9db4872190c94425ff6cf3069a4065fd9b4", + "bin/ck-wifiscan": "e9f77c51d6424501c8703351b8f23f181fb471ed652256f386ef623746975721", + "bin/connect.sh": "116a58d9caa45a8a83a43b7db5e42505b3e7640e4cdee06e62c6dcc7b576b433", + "bin/connect6.sh": "97e8946dc7c28bdbde8beda01e87315e3bedf07e1704db140c8c889a036dd0aa", + "bin/ddns_inet": "d1d3bc14909540e6f17f07d72ae6d208fefc4bfb9aa33c97fbc784d2986affb4", + "bin/dhcp6c": "50cc13f2cb4b10d2a44a90750760cf8bc7227b40d39a8bb1c1a6cec45d1873cb", + "bin/dhcp6cRcv.sh": "72d0f7430a95c2e5405d3b469a44cc788cac215eb8e5d91f7182e366f595e206", + "bin/dhcp6ctl": "c391c1a0828c2e5c7a2e4892fa7dce1dd28018f2a0610bdb9ac1c623f9cc77ae", + "bin/dhcp6s": "794c877af3c4bef3d3f457e1afc7ae9bd635bc775ec8bb4f4152176e73b26823", + "bin/disconnect.sh": "ec469e947e3e2e4c9bf0aa0a6d95a4a24d9bb7521ed467d5bd183a74dfef6798", + "bin/dnrd": "55c99a83c659cbd127e75b7c34c13f73853f9c6e612271bd311f81a1a711ebd8", + "bin/dnsmasq": "541c75037f62bc3a104989ece61b044463370f15219c4c4ae21e7f68ceb159b6", + "bin/dw": "5ef838655702ff0ab7cb8f5993a31e5903afc1e437a17e44f4c4842124d72266", + "bin/ecmh": "b56eb2a6e88722ba361042b329d67b3d5bc3634aa083690c949596c239e7f81f", + "bin/ew": "1a3e6bc1afc8345387dc9a38887e6af4adde619bc8743c25f0366df6928a649a", + "bin/exec-diag": "b9e64135afb90d77f5038f70d9a888c9c0af72f0d4bf5c1f33ab9df0b9d06a7d", + "bin/exec-diag-start": "4102861444c4bcf08a1b561b0b5614ffe8acee13102517f1fd87077319064f94", + "bin/exec-test-runner": "7433da986fa5ab813a11e04f9e3f5fe5babb736eb76bc3e689f4a1153c76e823", + "bin/exec-test-start": "29fd9968dda63926bf99d4ac3d94d7acd415c5e320268983555be1d5cffa10dc", + "bin/firewall.sh": "5688eba3b9e7f322c8e7b1e2e831f6e999d6fcec125283fc8de75d144e42e7f1", + "bin/flash": "28aca13b5d8df12040dd2c6ffb3dc69e61bfd1e0c860d25447f3a08e66963523", + "bin/fwd": "698eb8a02fcc0f2dcfca6702ed766f7338dfe51f4df6f85c72a2a78d789a190c", + "bin/getmib": "e3c872196f6afe6501514101614cc8b8ece902e6f8b6b9a1ad352a10c2a61e25", + "bin/getmib1": "89bc956fe6fd4e5e134c8769e611253fecf50272e2396ae65015e44d5d3d8b44", + "bin/gigadevice_chk": "f555ae6b974c2e7303072a1441820b171907993dd389e062557c7ac9d5191a19", + "bin/homekitserver": "2d50339436759f3d602c150fb4f1a42f0fdf122fc18e9a4f54f6e6b07d7a0c62", + "bin/iapp": "0dda7f960b1b0340f03f1823dc457b0b2fef84dd887d3b07d2158e5845f78bdb", + "bin/ib": "23540feab73c9f0f287e4f91284343ec8677aac9e5b1f51bb2bcf0db1075e697", + "bin/ib1": "439aeeffb8b3253384713b53f95120a2206f97911c64ebb90366c487ec1f2024", + "bin/id1": "cead109ab787def7dc01e63c4cdbb782493f588cecd709aee61a298b263d6dbd", + "bin/idd": "fd0a6763249a6064351ba787fbadce230b9ccfdeacff4f3de662b0c40b46df50", + "bin/idd1": "cead109ab787def7dc01e63c4cdbb782493f588cecd709aee61a298b263d6dbd", + "bin/init.sh": "37a89df20b2efa6b9e4268320fae90e29774dc894168a257f25b89f452e7bda2", + "bin/ip_qos.sh": "0bbaa983ce78b27b561a9c780c3ba9264563d4a38b61209f1898fc83a5473ee0", + "bin/irf": "848040ed72207dc5a47d3f67d6f54f8ce6eb3f36c38104089aa438d55114bfcc", + "bin/irf1": "9e7d8c249b22c9e65e9dc3050bff451f3bdeaf9b4eb8db6bbec5b9b39899eb0d", + "bin/iw": "82e621203cfa87228dc1bb520f2837bf7833346a0fc9f16757970f8ed07f11da", + "bin/iw1": "0ee09886704fa633ecd6111c1fe8ce373054e2c5feed8924a006255017ff8987", + "bin/iwcontrol": "5df1e0c326ef676d8dcab9885e079b9b25bc956b5aba6cb1e99af5dc11c5ae0a", + "bin/iwpriv": "0d54f6042b99bfbecbbeb68152a462f8a54663407a7ecf804c2c8bca2d6da6d9", + "bin/killsh.sh": "402bc7a2845df34d9c15049c38dac2688cd448995a908def3a27438fc762c019", + "bin/l2tp.sh": "075ba0c8e3a533efcbe14d42f66d3ef1163571fc22759e249ffcca17496ec0bb", + "bin/mdnsd": "7d5243636c0870e9cf32f4ecda0d494c6b47091da70cd470911e5961430ca177", + "bin/mldproxy": "eed2c2737eeefc4e808ce088b5c2ee7611fb93e414c826d4bf116218b51d48c3", + "bin/mmd_cmdr": "b71745208ba972d3a02991af759d4827b81c2e5156995c47e09b558b640cb163", + "bin/mmd_cmdw": "aeb2542634581cccaf6a9e76a41b69147c840663b2aff43b652ad5a1c4a5a346", + "bin/mp.sh": "e41e053700105de8511c57a984cbd03347381bcc48497ac391bde8bf6b68cb99", + "bin/ntp_inet": "79d56c4369c418fb4a4920f0394aec7c1e7ccccc9dbe119bfb0518f2395abcfa", + "bin/ntpclient": "e736240aab44bdd9d701bafe6eeaf383fa98878e57cb2913126e524185678aec", + "bin/ob": "452a3031a31802e8a7a79e200d2ef1c149ddb045a83ca9faea72133f1d10d4bd", + "bin/ob1": "c3f3f85889c443c69b17daa93fe0aa27e1e2dca99fa7e0f36b5579e749c1dc7b", + "bin/od": "be0f8099e8e1440e10b1b7cf6bf4ed063ff926887c4334fd47c941fe05c5635d", + "bin/od1": "aaae9dc1b71c3ee083efb9fc3b4862be9a32deb117a8b2edac5a3a057226e213", + "bin/orf": "6de1b42f0975076302439227c5542c353faff0293ff49c27932bec9b94d373c8", + "bin/orf1": "0d83ffdf74e8b6153062b24c765cb562bae115bb6e94ceccf40e198a26268b99", + "bin/ow": "3e06231474fe83569add90f05af1fbd478e243da89fa7767a6f1e27efab5b2ac", + "bin/ow1": "cb4d076d2a8efc2168e5a5d67b3da4ac3b0eec54b9fecf0735d3a4dc5602b07e", + "bin/phyr": "0ea4bf45ead3a0da7d55feaeef12bcfb095e63f69a70a842672f36b008065e80", + "bin/phytrex_tools": "a4733e1b2b8c52e48c109c9c40a2b9af532248e4b4116eb72260b1f0f20e6fc2", + "bin/phyw": "830bfdb9473fae12ab9aa8298d7aa3f9831bff63e7393a7f2b29dd530f134545", + "bin/ppp_inet": "44b1db22dc2b786e90ed558175915a9eef926a7818bd43f1c675724a06b77109", + "bin/pppoe.sh": "28d59a56e07f42c240ec59a7ac5a784371d00cef92f1984943232b3050ca011b", + "bin/pppoe_conn_patch.sh": "b6da8ac9633bf1a4a2bc665945247882ef8181e1fca9e65f3a8e7af631cc1e7e", + "bin/pppoe_disc_patch.sh": "025aac686216515eb57a2a26f5bb7b7c507f66b48c5404d8502dc0f414f1cb93", + "bin/pptp.sh": "c79acbc24a203398e6858f464065d65dc83eb0087bb3d6fd0ac47ed294a7754b", + "bin/radvd": "9db7fe7c307562b562fc1a96a33ece6b53be4cafe7bc63648d09a8c4567135b6", + "bin/radvdump": "c4c9694b057105652c461308b901dfe02da3bdf8901175dfcd8d112e6a21d71e", + "bin/reload": "22f689f717c259ecbeb628cc051fa4d82a3011146f2f4e48fd37cd5380184be3", + "bin/rssi": "091d5107b5c27b266252585b7c9992df4b2270704f81dc939eb1bbccc7a0ee6d", + "bin/rssi1": "1cca70b5b1cca70afdfa9637b180d904e0236cc487ac55d8ab1395283dcb8c67", + "bin/rtk_cmd": "89dbb16d504b668c48abae30b0d99724c20df7ce1a35faa55cc18f887bc7d7bb", + "bin/rtk_eventd": "1638b8ace10a21145ce26df5c835e8e8aef72bfbfd4f6ff13a91f3894ba9be38", + "bin/setmib": "2025b4ed81e89dcad0a58d4411c101ca67f0d6fdb85b1eb73539049f8675a3a3", + "bin/setmib1": "89ef32a13b8888d9e2b2f1e6a84c849f0d35e73e03c94c82d5be11f68f59209b", + "bin/snmpd.sh": "014ca345c401ea958db53e7dcb7c4dc050198f680e9a746410268a7a84a0875b", + "bin/startup.sh": "d620040cff0aa1ada99a9d68d1fc8436cd335ace89481880c5c2daf70a7e8fdb", + "bin/sysconf": "a88ffd520ea3382bcd1b656c1c2b4e02a416619da2fc87d4fe108ee08e76c9a0", + "bin/test-cpp_lean": "85a4e1fb97533f9d5b09ef630811a8df5e47d53d51f5de6e4e9374ca7660fb96", + "bin/test-tiny_eb": "897e409ae52a202d8f93aab9315686538c675adf80ee24a0f45c269a33cd2a1d", + "bin/test-tiny_gcc16": "60283435194b72e0c2dd61ffc0985f88d6478b26dc05be03eca629d2078f54c7", + "bin/timelycheck": "cf7afc7be127701b340c4ddcfacfeb690af40041b6c93fc37e29e63c0ce41511", + "bin/udhcpd": "6c65f86b575cc3956504e37b9688fe6b0cafae8a38938aab56a2973b0802a699", + "bin/wfaudio": "ba269a6c1565c02b8322d18e3d40e89de6f7f98097a90d0ce06e3452218d5d22", + "bin/wlanapp.sh": "a45b6d40a8b126283a575f0bbf1bc97f076f39c58f1ca74f27bcb0997aaa563f", + "etc/avahi-autoipd.action": "6e14205c720227f5294bfb9348dc1071dcdcc914723695619a10d5e11c395176", + "etc/boa/boa.conf": "6236c22da5f177dcaad1bd6ec70ce3dca62cb1a56daf83b668933cd8c0aef6e1", + "etc/boa/mime.types": "1e3e9fe650ec67e99cafeddcfbaf52a11f910666ec11d0f2c7a264392622b9bb", + "etc/ck-udhcpd.conf": "53e4458bbbb97de591d528efeeaa146eeaba2d5c7612d7b2389cadb4b8123819", + "etc/ck-update.meta": "7ad31c3ca6f667bfbed4b6c042d98015060d8189ea81646043b5a53319567126", + "etc/ck-update.pub": "386eb1742a602b5e9bd2007bcffd3a39c0a467500f92a1856d7c6059c88f5a2c", + "etc/ck-variant": "ec162500e66bdd48e03d6123e62f8b31b1b37170c5759ad3cdbe4f9b469a178e", + "etc/ck-version": "574392d535364e4db87a41d825df85c92aa96185b0551ca2b98f9b87e474eeeb", + "etc/customize.version": "44f87003455f39f4bb1e8fd1be8342c591b7c7fff44143d6949f6adbee4600b1", + "etc/dnsmasq.conf": "0ae2408b285a62bce4c8b0c7fab85e350889699d22feff1b6adcc54253bb95d4", + "etc/group": "5148fde8988f4762c1a14039adf6ca7cfcf3b201c71cd48332da60ca69ff49a8", + "etc/host.conf": "313728062c7ae797e11187c7f1d66b559f0098115320166bca6100486c167824", + "etc/hosts": "f80e3d5c3a0e4517e650bccee94c8033131e263d8458b37a18dcee440cb3819b", + "etc/init.d/rcS": "f23fe4803d52812fadbd84af1761792831cfde40803c5e0fafd0fd32e9d11e4e", + "etc/init.d/rcS_32M": "54b23c1e0a0a9a4f02484f6287e21f60588d0a98a790e76e9d9b4af3578237a0", + "etc/inittab": "7728ecf0138d2ed4b1408204e8c7b0c7eacfe650b1fe29cadfe941031b476e3a", + "etc/mime.types": "8d0a3ce3b74daccb330e158471cab898d40d4e40ed8a74ff9d710909a82cdf6e", + "etc/motd": "3b727708cd2efa362aa751c12c8d2e9e0e2050cb07b3d646dae2c6f4bcdd1cb3", + "etc/passwd": "8e619aab9bdf9ddd2644bed3ac319e862e908c4664b862120c2f4209c401bd32", + "etc/radvd.conf": "9ef8db8aa53148d8671e93b9d2879f18e1aec399ccba92aae324271796b5868e", + "etc/samba/smb.conf": "a56cfd4403599a01be6a78bfadcb1e6cc5177f7b4022e3a17e2ca861d2eb7c6d", + "etc/samba/smbpasswd": "2187a31165dc5cb2190130abdc04d6c5fef3d78ffc609b390b74a444f8e76c94", + "etc/services": "6ae28a7390a44890f3709f772dd5a2d8246880b40a734f1317cb117eea6de748", + "etc/shadow.sample": "106bf188d92eae79b255c106cb9f05777b5ba225ddef95132fb1da552b81caac", + "etc/svn.version": "9b3e52b747c1564c1a2b4eb757b829ae5d5095a0f591b8bcb49549364e75d461", + "etc/ushare.conf": "8e1b479172c4f1c7478a6658bae1a1049b607e7aacd24f2c40ff415b6e3da057", + "etc/version": "de64bb7381aaec500c2765ea8b939c7f1cc1f806609a932ba410e9e30a8957a8", + "etc/vsftpd.conf": "c86e2c41c3c17be5510db27e4998fbf372751727c340dce68362012d47744e92", + "lib/ld-uClibc-0.9.30.3.so": "35051ff978746f071c792d3d262b14f7e78dbbc461a9fd5c1e56a73966b0cb30", + "lib/libapmib.so": "e88721af68971ac96e949e7a918ae59e339cea1a3e7ecc9d40d59d5024e62824", + "lib/libdaemon.so.0": "a6ba44569b6a17ae9806aa89eb0bca7018946cc3aa4e167cf6b67e1f17ed8910", + "lib/libdl-0.9.30.3.so": "fd211b05beb2139a4e97a2441001be7554170e2602ac3c5298b228571cd1e3cf", + "lib/libdns_sd.so": "b0175db146f751120165c1d93a46be5e9da19f3e4201f13dbf0365fc053fddad", + "lib/libgcc_s.so.1": "c8f9860a3518dc0d6b78ff6640240aeb68e9b38a59aaf37bdc86bc1c0cd361d1", + "lib/libm-0.9.30.3.so": "bd31abbb878d32393b26573ae5b6055d1e73fa8126b4ade9a76465458d8bff2f", + "lib/libpthread-0.9.30.3.so": "7cde98eebc3ee088f150497282edb955b9d9aa1fb72f65603b0f6505d4919872", + "lib/librt-0.9.30.3.so": "ba9ebba8c75a71046716ac9ed2c37a25534ccf0647e0b123d8aea010738db2df", + "lib/libuClibc-0.9.30.3.so": "3bdde33cb95c8a4ce9a786d654f3e87f7a86e667201f14cea4fa33ee6749c278", + "usr/share/udhcpc/br0.renew": "0f225c0c1b9feeb416f46c86f729eabde73518f6920f26bf4dfaa8d501d54221", + "usr/share/udhcpc/br0.sh": "8be90d840672e3038b1e60ab59c206672479a28fb7168c9e2d9ffb267f3f787e", + "usr/share/udhcpc/eth0.bound": "394691277bad5f0f1a6b708d95e7ed57d6a0432ead294af299727f08c8c4904e", + "usr/share/udhcpc/eth0.sh": "f663605c21f57feadba977443b7b2ee298803752664b7b64207af0a75a27a17f", + "usr/share/udhcpc/eth1.bound": "e721f2ad88cbdfa3f37c5a7053a05c15766955f4b2d5ee9f7c7f7a195a7ad89b", + "usr/share/udhcpc/eth1.renew": "0f225c0c1b9feeb416f46c86f729eabde73518f6920f26bf4dfaa8d501d54221", + "usr/share/udhcpc/eth1.sh": "87e4066153462feec83c9ff7666812eb258bc4d38550e3498b31c33617441e7b", + "usr/share/udhcpc/wlan0-vxd.bound": "cf1ad0dc6a1fd3fd71b839a47e2c8ccce076db67608d7b87e412aa92010c02d3", + "usr/share/udhcpc/wlan0-vxd.sh": "092105e71c24ea2a1ecd4e8516b8bf32cd0468343bc803516f8b2e7baa54ec2e", + "usr/share/udhcpc/wlan0.bound": "cf1ad0dc6a1fd3fd71b839a47e2c8ccce076db67608d7b87e412aa92010c02d3", + "usr/share/udhcpc/wlan0.sh": "b60b564cf829f019db892c4b2c05d0950557f45e0cb8c33d8b1a0b11f4fd289c", + "usr/share/udhcpc/wlan1-vxd.bound": "cf1ad0dc6a1fd3fd71b839a47e2c8ccce076db67608d7b87e412aa92010c02d3", + "usr/share/udhcpc/wlan1-vxd.sh": "ad88d272a3ae0f31bfcff86a1e08be1342f57511615693782a7fb11a93b50189", + "usr/share/udhcpc/wlan1.bound": "cf1ad0dc6a1fd3fd71b839a47e2c8ccce076db67608d7b87e412aa92010c02d3", + "usr/share/udhcpc/wlan1.sh": "f4f1548b05bef9b69c13c32c2f157017b9623a946870774fd32e2246f8192d4c", + "web/code.htm": "ef39c1dcc759b7d9f614fe6b8049ddf2a9122e9d9e81a614849e5f5700afea0a", + "web/countDownPage.htm": "b4d199553f8dfa58f0a19c8923be713c8fd6de556bbd1190ad412e5295536cab", + "web/ddns.htm": "3c66f89fe65b19061a42d09ccdbd7d8abe3a2812189f1e0b57a9a0cea73e45a7", + "web/dhcp6s.htm": "05234b252c5c08471952974dfa9c30254d3baf12efc537ac1765d9b8c71af22f", + "web/dhcptbl.htm": "969251aeabe941809d38398f0168ed3ba8b06d3a33534c537b7442950534e81b", + "web/dmz.htm": "5b067a0e5c07e015a81ba3bb0556f6e73192ad044f2ad69bab3b2a34820f58d9", + "web/dnsv6.htm": "86a8711db19229c3f2680d64e45df06cdcdfc5d336d7db49f917907dba7a5b92", + "web/dos.htm": "86e3fc95e66b4d75cde12767dc5812e019dfc4718b897068ac1c79380a060458", + "web/favicon.ico": "fdce9e8910ba9bc5ea2357c8002568c6f434a80175f381d882bbd71feffd910e", + "web/graphics/back.gif": "0e9ced1019385b1101fdaa7c07d01d63aae771176a44d3df81d47b115b5128aa", + "web/graphics/dir.gif": "16f741d14a9af2cf05588d4ca748dd9a4bba9780c464f424076258b3911807ee", + "web/graphics/router.gif": "69a4f526ad2a35621afc9e9eabd1dbbb4f24f3f0e0496a1bf62b8c3d17363eca", + "web/graphics/text.gif": "ad62db6395e4e4bc21d0442062659db0f5ce93656e8d6464c172328dff5b2ac8", + "web/graphics/topbar.gif": "295b5e68db8a2e727b60c5d142b32661daca0722f3de4981aee91c77c49abda3", + "web/home.htm": "c39b5a02b4a8d9b72ced04cbdb4b881186533fad9a699e28086455f61d1cf880", + "web/index.htm": "af6083187e85b9711ce81f6fc86ec2a4dd31a7f578414359de3a3ccffce73076", + "web/index.html": "169ae52b96b7129c3a8c7a9d848f219c49a1b47bb88e417829b047e20d255852", + "web/ip6_qos.htm": "9927ebc69e819f57761419019c6896a0bbcfd79b4b1baa84b24a359153b1c25a", + "web/ip6filter.htm": "0b7b40aac77aae6482a97c1ac5c277b318a1624d541c5ff40572d63b352acc8f", + "web/ipv6_basic.htm": "af8f5f372f4e053ed8b74e970936d1456cbb4cb940eec19726532886d6bf7618", + "web/ipv6_wan.htm": "597459af6fd8763e565d8848890aac17de4931bf1717f3b6802af2d0bdd0aad2", + "web/logout.htm": "ef640d19f21fe339e1eea7ff032b2d16f6bc9a91bdebac269de12ebc96e91991", + "web/macfilter.htm": "0ba33043f8efa0593580d7557e1e11ea8d0d9f0184c1d9f3d1558af80f608eab", + "web/menu-images/menu_bar.gif": "647aded4cdccaa9a9f00d73e66ca92b1bc1d8f9775b76212a3d04504ed5410e4", + "web/menu-images/menu_corner.gif": "39af5e39f3b1e770f7488e44b445624c3ed7271e4c7de8dbb34dd2f4416e7af6", + "web/menu-images/menu_corner_minus.gif": "b1c5de54ea4ab836eff259195d32a578deee31c295288b06d90a8e7e98e085f3", + "web/menu-images/menu_corner_plus.gif": "d39a780f3d0e3283a3ae64e67f9ebf128b17c0b5b1417a06e4f78f09ae619e42", + "web/menu-images/menu_folder_closed.gif": "09e687ff155d3000d791ed036ff0e3f77b8696157eec186257dba3684e372f24", + "web/menu-images/menu_folder_open.gif": "1612a163cef16999411d2b63e12aa2e53c7d3bab1a2b5c2468cb0591727cb39b", + "web/menu-images/menu_link_default.gif": "5f0f5f53e769f08ac4be05097ac5e3474f4a28b1b0215a34805ec9e79297b0bd", + "web/menu-images/menu_pixel.gif": "63b98a0c8568c08fd01a6946a147bca65eff26c8085f1ccb5330aafe0f0dcd15", + "web/menu-images/menu_root.gif": "b38b3fd6af062f1ceda57128c8671b6b906e396cf9d624772614fb62cd23c694", + "web/menu-images/menu_tee.gif": "bf5d4fc1fdbe4b5e3e6a5c468a716ab34e2a1a2ab4478267df7e8351c8d1587b", + "web/menu-images/menu_tee_minus.gif": "bd10a4f7b54a08d5d63e7322f267fabfe383755471cb01186d9c7b16736e3d3b", + "web/menu-images/menu_tee_plus.gif": "a62fcf9bbafcc56e9af47307710dc9c34c9239b2fe8610a6e923c576e62f8a5e", + "web/menu.htm": "133ad67524dede590787680066b45cf58a70c6db2b5b7e3577b45ce44230a105", + "web/menu_empty.html": "9f18250ded12c95522893bbb6dec6c6be3bbfd395b4fce8a2ba0d04469aa743b", + "web/mtmcode.js": "565b4fbc6a66e0ca9802269de674454adcc5187d035f1a94ba93b0978b156334", + "web/ntp.htm": "5e33de238716fb88a914e1c470baaf913eb88702f664cd4ccd3e8c292f8d3f44", + "web/opmode.htm": "c6499fda559e682e5b78345522d1f7806a7152a00de8c4a28a8fe10f2bcd1803", + "web/password.htm": "800ccb6722a5be356a8f9dc1c44034d0f72c6857f5890fcfaae974204c1fe754", + "web/pocket_sitesurvey.htm": "0dce90317c717e55895d44502a14b2acbd72518447d9029f6717f4cbf538ba1f", + "web/portfilter6.htm": "74530b908cc389547fe4596e5eaf7826bc0c57d3ab7fedb8c785a8a7567845ee", + "web/portfw.htm": "938bb94911baec450bc2a49904a7fadb2f27068cc9cd65c02d7f0008d27b38c1", + "web/radvd.htm": "31ea7295aa23b9f4a73ae3fd2ac8f61283391a88780c2ef06d53dfed54c0b439", + "web/reload.htm": "eae613794aad31e1b6edfe9ab0b4695e05c5a977e1d5820810abbaea618cc4e7", + "web/saveconf.htm": "d3e5f98e82cbfc600474236c4e97d24f84e22d589ceb586f46ba69d2cb512bae", + "web/stats.htm": "4bee4161dcbb79b2193dd48fe303f72253a7c81dda1435351944e4fce505cfee", + "web/status.htm": "2f3cc0421f10b19555821466a824f2d769d410b291eed45e34f10e0004516ed9", + "web/style.css": "dfe13bca7a30354e51274a0979ea60af38b1717bcc6135ddcbc2f28dd19fd536", + "web/syscmd.htm": "dca82d34c2877902ae4f9b48dd434c7e0098969f6fcf09dc15e6e97b5c725971", + "web/syslog.htm": "ede23274b094361f7abdd6ff6242bf7ed570552dd14c710026b1af6c1a0d4d5e", + "web/tcpip_staticdhcp.htm": "a3451b01d98df04559ece73c80b931bc92fd8ed43e77f0011ee751044c47baba", + "web/tcpiplan.htm": "4629d26d6da1150971347176160880802ff8a498b2240a5ca22487cea59fc57e", + "web/tcpipwan.htm": "2d4bae96143f5392173f7f3d055b69f3a0fb3b4d68f8cfb9b3e6ded997778a6f", + "web/title.htm": "87a29d29b9b7ae7b28bcf2f1a00002a997fa1038a2b33d88c12b316f0e3164ee", + "web/tunnel6.htm": "6d2bf69b88a08d1efcc22285b1ce206ecc86f520dee2f017cf8d6c272af0d3bc", + "web/upload.htm": "c9ff896145a303104587a4c376df2dad1a7cc4f5ec2c74e87f0130e989c49d01", + "web/upload_st.htm": "11e65da936c795621500b5c1ca3abe428a2bdd2946a39a8086f7b63956f28bd9", + "web/urlfilter.htm": "108ef6504f70587f5311425bd696c22e0f0a23646e3b7f0124533ef438bceaf2", + "web/util_gw.js": "9a20c547b2d34e77039eb330e7cf845e7d252320cf8f40dbc30a252e1cf092c1", + "web/util_qos.js": "ffcccd7675ac37779d846ad43b84ccf2cd683f4b796af4496024f6a74395d104", + "web/vlan.htm": "9e2200ec452e1ce62d61d6b84ce3d83ab9ca1867293769cd23f13962f4cbe472", + "web/wizard.htm": "6f6f9a6ae33f260778698dc22afc7d24e5a967fe346cb75a21f01afa97f6c8fc", + "web/wlactrl.htm": "97edf03d39e15e5709b79a41e3a142760918e155417ba183bda60ed3a5c5b3ca", + "web/wladvanced.htm": "53b78f4f9d9d06d2e1dbe1b7d4bb3aa1538a82dac0631905d6a1603d6aab15ca", + "web/wlan_schedule.htm": "17b329f065b0d1d136c901d4fe9f8bf9aae22e3b285e31ef65c009288f7ed3a1", + "web/wlbandmode.htm": "d7f34cdf8832acc9b96316f76ef52c9c60d063b2ce0c04e160c40341909faab4", + "web/wlbasic.htm": "7c758561c8e7983f98a84bf2cf1264f4a07f26e98000ce6ab9a8065be4b24c20", + "web/wlmeshproxy.htm": "022b01ffdd7df8850cfa603b7c312d80005639f8c5fb6a11c3e48c00928d23ae", + "web/wlmultipleap.htm": "c15318e6b8983a253fe8d828d32a6c053d31223b7539dbabfd9ffc5c0fd74a39", + "web/wlsch.htm": "9fffd931fdd22cf085489f4bd36d3da9f0def6b4bd65a06e0cecea650fde8bd3", + "web/wlsecurity.htm": "378353ff4e39815f9312c6c6cc33c7f9734ba2e44c1afd008953866cf170ad4c", + "web/wlsecurity_all.htm": "6be91b7ba91497d0756dd9162f54b6fca067992c9a9ea9ead8fb5c2791cd71b9", + "web/wlstatbl.htm": "a37f69ff59dc3443c2c896013f56e80cdd6831a7497034e6e6d860ab551a2441", + "web/wlstatbl_vap.htm": "7e3d4f04f66a6665aa0ade7b32a50f57116ef7011de40e1850f9888f9e8aad48", + "web/wlsurvey.htm": "90273cb11ca78519ca079a2d87f3aa143b415f2ae53d0ff7f924ad5d4c5d4c08", + "web/wlwds.htm": "ba327360f7963117f850a1096c0379e505218e6c9c38f877f4105cb1451d7885", + "web/wlwdsenp.htm": "5da00b29e79db94a6382698b70bdfb2e2dd2b0703b0fef79ffb549fc5873db15", + "web/wlwdstbl.htm": "7a53914672c15459ef3e8875e30b84f03ad967222fb61f0e3355701c7abc113d", + "web/wlwps.htm": "ca071459c523b9af21f3104cb92fe0a1c8dd38a76c731e64e3e11707bb9e1e05" + }, + "protected_ranges_all_ff": true, + "credential_files_absent": true + } + }, + "physical_device_access": false +} From 47a679e8732088fd6c8fa746bda999a7aba0f88a Mon Sep 17 00:00:00 2001 From: lee98064 <46412433+lee98064@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:38:29 +0800 Subject: [PATCH 05/13] Record verified original-device boot and local control recovery --- verification/public-audit-v0.2.2-preview.json | 4 ++-- verification/recovery-results.json | 17 +++++++++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/verification/public-audit-v0.2.2-preview.json b/verification/public-audit-v0.2.2-preview.json index 5851e22..d08915f 100644 --- a/verification/public-audit-v0.2.2-preview.json +++ b/verification/public-audit-v0.2.2-preview.json @@ -1,8 +1,8 @@ { "status": "pass", "scope": "selected public files and decompressed release rootfs", - "public_file_count": 5053, - "public_bytes": 24838218, + "public_file_count": 5054, + "public_bytes": 24884020, "variants": { "matter": { "sha256": "27bb2d53da8f58b7ba993e65f3bd72b296c4203025dd6040a8ffc1d2e53718f1", diff --git a/verification/recovery-results.json b/verification/recovery-results.json index 2a19105..cee6468 100644 --- a/verification/recovery-results.json +++ b/verification/recovery-results.json @@ -78,8 +78,21 @@ "initial_identity_body_checksum_valid": true, "initial_identity_commit_missing": true, "repair_installation": "write_verified_with_independent_full_chip_readback", - "repair_boot_validation": "pending", - "journal_and_identity_preserved": true + "repair_boot_validation": "pass_for_listed_checks", + "journal_and_identity_preserved": true, + "owner_confirmed_physical_button_repeated_on_off": true, + "network": "reconnected to previously configured home Wi-Fi; setup AP intentionally inactive", + "authenticated_diagnostics_received": true, + "matter_started": true, + "mcu_transport_connected": true, + "cold_off_confirmed": true, + "relay_state_fresh": true, + "meter_samples_fresh": true, + "update_health_confirmed": true, + "confirmed_software_version": 4, + "rescue": false, + "bootbank": 1, + "initial_import_recovered": true }, "matter_image_sha256": "27bb2d53da8f58b7ba993e65f3bd72b296c4203025dd6040a8ffc1d2e53718f1", "original_hardware_power_cut_test": false, From 48a15c90c35098cc06904ff0f17861386015efba Mon Sep 17 00:00:00 2001 From: lee98064 <46412433+lee98064@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:10:53 +0800 Subject: [PATCH 06/13] Add live power and energy monitoring to outlet management page --- .github/workflows/ota.yml | 1 + README.md | 5 +- docs/measurements.md | 10 + docs/ota-release-notes.md | 4 +- docs/updates.md | 8 +- .../matter/src/matter-app-device/BUILD.gn | 1 + .../ElectricalMeasurements.cpp | 15 + .../ElectricalMeasurements.h | 1 + .../matter/src/matter-app-device/Version.h | 4 +- firmware/matter/src/matter_device_init.c | 3 + firmware/matter/src/update/ck_meter.c | 71 +++ firmware/matter/src/update/ck_meter.h | 24 + firmware/matter/src/update/ck_updated.c | 11 + firmware/matter/src/update/update.html | 26 +- firmware/matter/src/update/update.js | 30 +- firmware/matter/src/update/update_page.h | 2 +- .../tools/build_matter_device_fixture.py | 1 + firmware/matter/tools/build_updates.sh | 2 +- firmware/matter/tools/test_device_vm.py | 21 +- tests/test_monitor.js | 16 + tests/test_updates.py | 57 +- verification/README.md | 3 + verification/monitor-results.json | 114 ++++ verification/public-audit-v0.2.3-preview.json | 492 ++++++++++++++++++ version.json | 4 +- 25 files changed, 902 insertions(+), 24 deletions(-) create mode 100644 firmware/matter/src/update/ck_meter.c create mode 100644 firmware/matter/src/update/ck_meter.h create mode 100644 tests/test_monitor.js create mode 100644 verification/monitor-results.json create mode 100644 verification/public-audit-v0.2.3-preview.json diff --git a/.github/workflows/ota.yml b/.github/workflows/ota.yml index e59b5d1..daedc00 100644 --- a/.github/workflows/ota.yml +++ b/.github/workflows/ota.yml @@ -20,6 +20,7 @@ jobs: - run: python3 tests/test_legacy_mtd.py - run: python3 tests/test_startup_recovery.py - run: node tests/test_update_crypto.js + - run: node tests/test_monitor.js - 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: diff --git a/README.md b/README.md index 933337a..cd06c1a 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ | 內容 | 入口 | |---|---| | 網頁/Matter OTA 與發布新版 | [更新說明](docs/updates.md) | +| 手機網頁即時查看 V/A/W/kWh | [即時監測頁](docs/measurements.md#使用手機查看即時讀值) | | 標準電壓、RMS 電流、功率及累積 kWh | [Matter 電力量測](docs/measurements.md) | | 使用、配網、Matter、診斷 | [使用說明](docs/usage.md) | | 備份、限定區域燒錄、讀回、復原 | [燒錄說明](docs/flashing.md) | @@ -23,7 +24,7 @@ | 測試與發布交付狀態 | [驗證索引](verification/README.md) | | 上傳 GitHub 與建立 Release | [發布說明](docs/publishing.md) | -目前來源為 `v0.2.2-preview` 候選版;修正設定介面名稱、首次身分初始化及 Flash 同步相容性,新增 MCU 控制退路,見[修復驗證](verification/recovery-results.json)。v0.1 Release 尚無永久 OTA,第一次升級仍須限定區域燒錄。 +目前來源為 `v0.2.3-preview` 候選版;在既有管理網址新增手機即時監測頁,顯示 V/A/W/估算累積 kWh。v0.2.2 的配網、Flash 同步及 MCU 控制修復繼續保留,見[修復驗證](verification/recovery-results.json)。v0.1 Release 尚無永久 OTA,第一次升級仍須限定區域燒錄。 ## 兩種韌體 @@ -32,7 +33,7 @@ | `matter` | 開放設定熱點、Wi-Fi 掃描、裝置專屬 Matter 碼/QR、On/Off/Toggle、MCU 確認、Identify、診斷 | v0.2 已加入持久 fabrics、簽章 OTA、標準電力量測;使用開發測試 attestation;原機驗收待完成 | | `exec-test` | 依序執行三支附件 MIPS 程式、逾時/資源/hash 限制、認證診斷下載 | 不啟動 Matter/HomeKit;不提供軟體繼電器控制;附件只有執行檔,沒有其原始碼 | -Matter v0.2.2 已完成原機限定區域燒錄與獨立全片讀回;開機及操作驗收另記於修復結果。診斷版尚未完成本版原機驗收。歷史版本的原機平台/協議測試與本次 VM 測試分開記錄;不可用模擬機結果宣稱原機 RF、家庭 app、負載切換或長期穩定性通過。 +Matter v0.2.2 已完成原機限定區域燒錄、獨立全片讀回及按鍵驗收;v0.2.3 已在同一台插座透過 OTA 安裝,確認版本 5 健康、診斷認證沿用,以及手機網頁可取得即時讀值,見[監測頁驗證](verification/monitor-results.json)。診斷版尚未完成本版原機驗收。歷史版本的原機平台/協議測試與本次 VM 測試分開記錄;不可用模擬機結果宣稱家庭 app、負載切換或長期穩定性通過。 ## 目錄 diff --git a/docs/measurements.md b/docs/measurements.md index 951c594..14e8a66 100644 --- a/docs/measurements.md +++ b/docs/measurements.md @@ -19,6 +19,16 @@ v0.2 在同一個 endpoint 1 宣告 On/Off Plug-in Unit 與 Electrical Sensor, 只取得 RMS 電流,不能據此聲稱得到 ActiveCurrent(同相分量);此屬性不列入 AttributeList。未取得的 reactive/apparent power、頻率及功率因數不宣告。v0.2.1 新增由功率積分的軟體累積電量,透過標準 ElectricalEnergyMeasurement 回報,Accuracy.Measured=false 表示估算,不宣稱 MCU 有獨立硬體能源計數器。 +## 使用手機查看即時讀值 + +安裝 v0.2.3-preview 後,手機與插座連上同一個區網,在 Safari 開啟 `http://插座IP:18082/`。頁面上方顯示電壓 V、電流 A、即時功率 W、估算累積 kWh,以及插座開關狀態;下方保留 OTA 更新入口。IP 可從路由器的裝置清單取得,不需要 Apple 家庭先完成配對。 + +網頁每 2 秒重新整理資料;MCU 原有量測週期仍約 5 秒,因此連續幾次看到相同數字是正常的。累積 kWh 顯示已保存的總量,約每 30 秒變動一次,保留 6 位小數。沒有有效讀值時顯示「—」,有效的零顯示為 0。網頁背景暫停輪詢;連線中斷後清除舊讀值並自動重試。 + +`GET /measurements` 只讀取 Matter 程式每秒在 RAM 原子發布的固定大小快照,不開啟 MCU 串列、不發控制命令,也不為網頁請求寫入 Flash。電壓/電流/功率資料超過 15 秒或快照發布停止超過 5 秒時回傳 `null`。API 使用 `voltage_mv`、`current_ma`、`power_mw`;`energy_mwh` 是十進位字串,避免瀏覽器遺失大整數精度。它沿用管理頁的區網、數字 Host 與同源限制。 + +診斷版或應用尚未啟動時仍可開啟管理頁,但沒有 Matter 發布的量測快照,讀值會顯示「—」。 + ## 更新、過期與通知 單一 MCU owner 依序查詢,電力量測約每 5 秒一次;繼電器確認命令優先。每次只有一個待回覆查詢,拆段回應逐 byte 解析。數值回應沒有 transaction ID,因此無法從內容判別任意延遲跨查詢的回覆;逾時後加入安靜間隔並忽略非待查詢數值,實際最大回應延遲仍需原機確認。 diff --git a/docs/ota-release-notes.md b/docs/ota-release-notes.md index 6d4548d..515bf48 100644 --- a/docs/ota-release-notes.md +++ b/docs/ota-release-notes.md @@ -1,4 +1,6 @@ -v0.2.2 修正原機 `wlan0` 介面名稱、身分產生前啟動 MCU 的順序,以及 Linux 2.6.30 MTD 不支援 `fsync` 的相容性。初始化失敗時保留本機 MCU 通訊及使用裝置原有 bootstrap 身分的設定/診斷入口;不將 RAM 退路當作持久 fabrics。 +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 持久儲存。 diff --git a/docs/updates.md b/docs/updates.md index 99fd717..160dadd 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -1,11 +1,11 @@ # 安裝更新與發布新版 -v0.2.2-preview 提供區網網頁更新、線上更新索引、Ed25519 簽章,以及 Matter OTA Requestor,並修正舊 MTD 同步、`wlan0` 設定介面及身分產生的啟動順序。Matter fabrics、平台設定及裝置身分改存入 Flash journal。**第一次仍須以 CH341A 安裝支援 OTA 的版本;v0.1.0-preview 沒有永久更新入口。**原機 Flash driver、斷電、RF 及家庭平台驗收尚待完成;新增檢查見[修復驗證](../verification/recovery-results.json),電量檢查見 [v0.2.1 驗證](../verification/energy-results.json)。 +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/`。 +2. 配網後,從路由器取得插座 IPv4 位址,在瀏覽器開啟 `http://插座IP:18082/`;即時監測與更新共用此頁。 3. 按「檢查新版本」及「下載並更新」,或選擇已下載的 `.ota`,按「驗證並更新」。 4. 等候簽章核對、安裝、重開及健康確認。保持供電;更新暫停 Matter 控制,現有啟動邏輯會要求 MCU 確認 OFF。 @@ -30,7 +30,7 @@ python3 tools/update.py "$OUTLET_IP" \ --package artifacts/releases/v0.2.1-preview/matter.ota --check-only ``` -第一次安裝 v0.2.2-preview 後,必須發布 `software_version` 大於 4 的更新才能安裝。`--check-only` 不傳送更新,也不重新啟動。 +安裝 v0.2.3-preview(`software_version=5`)後,後續更新的 `software_version` 必須大於 5 才能安裝。`--check-only` 不傳送更新,也不重新啟動。 ## 更新包與保留範圍 @@ -75,7 +75,7 @@ Matter 必須連續 30 秒取得新鮮 MCU 狀態才確認新版本;診斷版 維護者的私鑰保存在自己的 `private/ota-signing.key`,公鑰是 `third_party/ota-signing.pub`。發布既有裝置更新時必須沿用原金鑰;不要重新執行 keygen。fork 的維護者須自行產生金鑰,替換其來源公鑰,並首次安裝自己的信任來源。 -1. 修改 `version.json`,增加 `software_version` 並更新版本名稱;例如數值 5、名稱 `0.3.0-preview`。 +1. 修改 `version.json`,增加 `software_version` 並更新版本名稱;例如數值 6、名稱 `0.3.0-preview`。 2. 執行完整建置;工具也會產生 Matter 執行檔的版本紀錄,拒絕混用舊執行檔或測試 backend: ```bash diff --git a/firmware/matter/src/matter-app-device/BUILD.gn b/firmware/matter/src/matter-app-device/BUILD.gn index 39e0079..0e21216 100644 --- a/firmware/matter/src/matter-app-device/BUILD.gn +++ b/firmware/matter/src/matter-app-device/BUILD.gn @@ -12,6 +12,7 @@ config("app_config") { executable("matterd-test") { sources = [ "main.cpp", "DeviceIdentity.cpp", "MatterOta.cpp", "ElectricalMeasurements.cpp", "../update/ck_store.c", "../update/ck_energy.c", "../update/ck_common.c", "../update/ck_flash.c", "../vendor/qrcodegen/qrcodegen.c", "../platform_probe.cpp", "../sdk-overrides/TLVReader.cpp", "../sdk-overrides/CHIPLinuxStorage.cpp", "../relay/RelayEngine.cpp", "../relay/RelayEngine.h", "../relay/McuController.cpp", "../relay/McuController.h" ] sources += [ + "../update/ck_meter.c", "${chip_root}/src/app/clusters/ota-requestor/BDXDownloader.cpp", "${chip_root}/src/app/clusters/ota-requestor/DefaultOTARequestor.cpp", "${chip_root}/src/app/clusters/ota-requestor/DefaultOTARequestorDriver.cpp", diff --git a/firmware/matter/src/matter-app-device/ElectricalMeasurements.cpp b/firmware/matter/src/matter-app-device/ElectricalMeasurements.cpp index 35017f3..d5e4692 100644 --- a/firmware/matter/src/matter-app-device/ElectricalMeasurements.cpp +++ b/firmware/matter/src/matter-app-device/ElectricalMeasurements.cpp @@ -1,5 +1,6 @@ #include "ElectricalMeasurements.h" #include "../update/ck_energy.h" +#include "../update/ck_meter.h" using namespace chip; using namespace chip::app; namespace EPM = Clusters::ElectricalPowerMeasurement; @@ -59,6 +60,20 @@ void ElectricalMeasurements::Poll(const incipio::RelaySnapshot & s, uint64_t now } if (current!=mCurrent) { mCurrent=current; mPowerCluster.Changed(EPM::Attributes::RMSCurrent::Id); } if (power!=mPower) { mPower=power; mPowerCluster.Changed(EPM::Attributes::ActivePower::Id); } + if (now >= mNextPublish) { + mNextPublish=now+1000; + ck_meter sample{}; + sample.published_ms=now; + sample.voltage_ms=s.voltageAt;sample.current_ms=s.currentAt; + sample.power_ms=s.powerAt;sample.relay_ms=s.relayAt; + if (!mVoltage.IsNull()) { sample.flags|=CK_METER_VOLTAGE;sample.voltage_mv=uint32_t(mVoltage.Value()); } + if (!mCurrent.IsNull()) { sample.flags|=CK_METER_CURRENT;sample.current_ma=uint32_t(mCurrent.Value()); } + if (!mPower.IsNull()) { sample.flags|=CK_METER_POWER;sample.power_mw=uint32_t(mPower.Value()); } + if (mEnergyReady && mPersistent) { sample.flags|=CK_METER_ENERGY;sample.energy_mwh=mSavedMwh; } + if (s.known && now-s.relayAt<=10000) { sample.flags|=CK_METER_RELAY;if(s.on)sample.flags|=CK_METER_ON; } + // Failure only makes the monitor unavailable; relay control stays independent. + (void)ck_meter_publish(&sample); + } } CHIP_ERROR ElectricalMeasurements::InitEnergy(bool persistent) { diff --git a/firmware/matter/src/matter-app-device/ElectricalMeasurements.h b/firmware/matter/src/matter-app-device/ElectricalMeasurements.h index a6d3a29..4f21924 100644 --- a/firmware/matter/src/matter-app-device/ElectricalMeasurements.h +++ b/firmware/matter/src/matter-app-device/ElectricalMeasurements.h @@ -22,6 +22,7 @@ class ElectricalMeasurements final : public chip::app::Clusters::ElectricalPower incipio::EnergyCounter mCounter; bool mPersistent=false, mEnergyReady=false, mFirstEnergyReport=true; uint64_t mSavedMwh=0, mLastSaveAttempt=0, mReportedMwh=0; + uint64_t mNextPublish=0; uint32_t mSavedRemainder=0; Range mEnergyRange{.rangeMin=0,.rangeMax=int64_t(incipio::EnergyCounter::kMaxMwh)}; Accuracy mEnergyAccuracy{.measurementType=chip::app::Clusters::detail::MeasurementTypeEnum::kElectricalEnergy, diff --git a/firmware/matter/src/matter-app-device/Version.h b/firmware/matter/src/matter-app-device/Version.h index 4c3945e..bd058d5 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 4 -#define CK_VERSION_STRING "0.2.2-preview" +#define CK_SOFTWARE_VERSION 5 +#define CK_VERSION_STRING "0.2.3-preview" diff --git a/firmware/matter/src/matter_device_init.c b/firmware/matter/src/matter_device_init.c index 63e5d23..ce1bdd2 100644 --- a/firmware/matter/src/matter_device_init.c +++ b/firmware/matter/src/matter_device_init.c @@ -64,6 +64,9 @@ int main(void) { puts("MATTER_TLV_VM_NETWORK_READY"); sleep(2); restart_application: +#ifdef CK_TESTING + if (!fork()) { execl("/bin/ck-updated","ck-updated",NULL); _exit(127); } +#endif int child = fork(); if (!child) { struct rlimit limit = {8*1024*1024,8*1024*1024}; diff --git a/firmware/matter/src/update/ck_meter.c b/firmware/matter/src/update/ck_meter.c new file mode 100644 index 0000000..ba7048f --- /dev/null +++ b/firmware/matter/src/update/ck_meter.c @@ -0,0 +1,71 @@ +#include "ck_meter.h" +#include "ck_common.h" +#include +#include + +#define METER_PATH "/var/run/ck-meter.bin" +#define METER_SIZE 72 +#define ENERGY_MAX (UINT64_C(1) << 62) + +int ck_meter_publish(const struct ck_meter *s) { + uint8_t b[METER_SIZE] = {0}; + if (ck_ram_storage()) + return -1; + memcpy(b, "CKMETER1", 8); + ck_put64(b + 8, s->published_ms); + ck_put32(b + 16, s->flags); + ck_put32(b + 20, s->voltage_mv); + ck_put32(b + 24, s->current_ma); + ck_put32(b + 28, s->power_mw); + ck_put64(b + 32, s->energy_mwh); + ck_put64(b + 40, s->voltage_ms); + ck_put64(b + 48, s->current_ms); + ck_put64(b + 56, s->power_ms); + ck_put64(b + 64, s->relay_ms); + return ck_atomic(ck_path(METER_PATH), b, sizeof(b), 0600); +} +static int fresh(uint64_t at, uint64_t published, uint64_t now, unsigned limit) { + return at <= published && at <= now && now - at <= limit; +} +static void number(char *out, size_t size, int valid, uint64_t value, int quoted) { + if (!valid) + snprintf(out, size, "null"); + else + snprintf(out, size, quoted ? "\"%llu\"" : "%llu", + (unsigned long long)value); +} +int ck_meter_json(char *out, size_t capacity, uint64_t now) { + uint8_t b[METER_SIZE] = {0}; + 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]; + const char *relay = "null"; + if (!ck_exact_file(ck_path(METER_PATH), b, sizeof(b)) && + !memcmp(b, "CKMETER1", 8)) { + published = ck_be64(b + 8); + flags = ck_be32(b + 16); + available = !(flags & ~63u) && published <= now && now - published <= 5000; + voltage = ck_be32(b + 20); + current = ck_be32(b + 24); + power = ck_be32(b + 28); + energy = ck_be64(b + 32); + } + number(v, sizeof(v), available && (flags & CK_METER_VOLTAGE) && + voltage <= 300000 && fresh(ck_be64(b + 40), published, now, 15000), voltage, 0); + number(i, sizeof(i), available && (flags & CK_METER_CURRENT) && + current <= 15000 && fresh(ck_be64(b + 48), published, now, 15000), current, 0); + number(p, sizeof(p), available && (flags & CK_METER_POWER) && + power <= 2000000 && fresh(ck_be64(b + 56), published, now, 15000), power, 0); + /* A decimal string preserves all 64-bit checkpoint digits in JavaScript. */ + number(e, sizeof(e), available && (flags & CK_METER_ENERGY) && energy <= ENERGY_MAX, energy, 1); + 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"; + 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); + return n < 0 || (size_t)n >= capacity ? -1 : 0; +} diff --git a/firmware/matter/src/update/ck_meter.h b/firmware/matter/src/update/ck_meter.h new file mode 100644 index 0000000..b50797a --- /dev/null +++ b/firmware/matter/src/update/ck_meter.h @@ -0,0 +1,24 @@ +#ifndef CK_METER_H +#define CK_METER_H +#include +#include +#ifdef __cplusplus +extern "C" { +#endif +enum { + CK_METER_VOLTAGE = 1, CK_METER_CURRENT = 2, CK_METER_POWER = 4, + CK_METER_ENERGY = 8, CK_METER_RELAY = 16, CK_METER_ON = 32 +}; +/* RAM-only snapshot published by the existing Matter/MCU owner. Integer units + * match Matter; energy is the last successfully saved checkpoint. */ +struct ck_meter { + uint64_t published_ms, energy_mwh; + uint64_t voltage_ms, current_ms, power_ms, relay_ms; + uint32_t flags, voltage_mv, current_ma, power_mw; +}; +int ck_meter_publish(const struct ck_meter *sample); +int ck_meter_json(char *out, size_t capacity, uint64_t now_ms); +#ifdef __cplusplus +} +#endif +#endif diff --git a/firmware/matter/src/update/ck_updated.c b/firmware/matter/src/update/ck_updated.c index 3e458d4..c4bb67c 100644 --- a/firmware/matter/src/update/ck_updated.c +++ b/firmware/matter/src/update/ck_updated.c @@ -2,6 +2,7 @@ #include "ck_common.h" #include "ck_ota.h" #include "ck_store.h" +#include "ck_meter.h" #include "update_page.h" #include #include @@ -203,6 +204,16 @@ static void handle(int fd, int server) { json(fd, 200, b); return; } + if (!strcmp(path, "/measurements")) { + struct timespec now; + char b[512]; + if (clock_gettime(CLOCK_MONOTONIC, &now) || + ck_meter_json(b, sizeof(b), (uint64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000)) + json(fd, 503, "{}"); + else + json(fd, 200, b); + return; + } json(fd, 404, "{}"); return; } diff --git a/firmware/matter/src/update/update.html b/firmware/matter/src/update/update.html index 605c393..6fd02ca 100644 --- a/firmware/matter/src/update/update.html +++ b/firmware/matter/src/update/update.html @@ -1,14 +1,26 @@ -CommandKit 更新 +CommandKit 插座用電與更新 -
COMMANDKIT / DEVICE CARE

讓插座保持更新

目前版本 讀取中… · CMNDKT-004

-

線上更新

檢查已發布的版本,下載後由插座驗證簽章。

-

使用已下載的更新包

更新會暫停控制並重新啟動插座,請保持供電。裝置設定與已保存的配對會保留。

-

可檢查新版本,或選擇已下載的更新包。

-
更新完成並重新連線通常需要數分鐘。若應用程式無法啟動,可透過此頁安裝修正版。
+
COMMANDKIT / CMNDKT-004

插座用電

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

交流有效值

+
電流
—A

交流有效值

+
即時功率
—W

目前消耗的功率

+
累積用電
—kWh

功率估算 · 約 30 秒保存

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

韌體更新

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

+
使用已下載的更新包

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

+

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

+
+
讀值無效或連線中斷時顯示「—」。累積用電從啟用電量紀錄起計算,不含先前的使用量。
diff --git a/firmware/matter/src/update/update.js b/firmware/matter/src/update/update.js index a3f5477..d94a8ac 100644 --- a/firmware/matter/src/update/update.js +++ b/firmware/matter/src/update/update.js @@ -19,10 +19,37 @@ 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(''); -if (typeof module !== 'undefined') module.exports={sha256,hex}; +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:{}; + 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')){ + const digits=d.energy_mwh.padStart(7,'0');energy=digits.slice(0,-6)+'.'+digits.slice(-6); + } + const values={voltage:reading(d.voltage_mv,300000,2),current:reading(d.current_ma,15000,3),power:reading(d.power_mw,2000000,1),energy}; + const complete=live&&Object.values(values).every(v=>v!=='—'); + return {...values,live,complete,relay:d.relay_on===true?'插座已開啟':d.relay_on===false?'插座已關閉':'插座狀態未知', + 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); + } + 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)); @@ -30,6 +57,7 @@ if (typeof document !== 'undefined') (async () => { 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))); diff --git a/firmware/matter/src/update/update_page.h b/firmware/matter/src/update/update_page.h index 466f29e..f18dd95 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 / DEVICE CARE

讓插座保持更新

目前版本 讀取中… · CMNDKT-004

\n

線上更新

檢查已發布的版本,下載後由插座驗證簽章。

\n

使用已下載的更新包

更新會暫停控制並重新啟動插座,請保持供電。裝置設定與已保存的配對會保留。

\n

可檢查新版本,或選擇已下載的更新包。

\n
更新完成並重新連線通常需要數分鐘。若應用程式無法啟動,可透過此頁安裝修正版。
\n\n"; +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"; diff --git a/firmware/matter/tools/build_matter_device_fixture.py b/firmware/matter/tools/build_matter_device_fixture.py index 993f5b7..717b919 100644 --- a/firmware/matter/tools/build_matter_device_fixture.py +++ b/firmware/matter/tools/build_matter_device_fixture.py @@ -23,6 +23,7 @@ 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: + 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) config=json.loads((Path('/repo/version.json')).read_text()) diff --git a/firmware/matter/tools/build_updates.sh b/firmware/matter/tools/build_updates.sh index f748d64..d93724c 100644 --- a/firmware/matter/tools/build_updates.sh +++ b/firmware/matter/tools/build_updates.sh @@ -10,7 +10,7 @@ flags='-static -Os -std=c11 -Wall -Wextra -Werror -Wno-misleading-indentation -W 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' ${cross}gcc $flags $common src/update/ck_updater.c -o "$dest/ck-updater" -${cross}gcc $flags $common src/update/ck_updated.c -o "$dest/ck-updated" +${cross}gcc $flags $common src/update/ck_meter.c src/update/ck_updated.c -o "$dest/ck-updated" 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" diff --git a/firmware/matter/tools/test_device_vm.py b/firmware/matter/tools/test_device_vm.py index c85d661..ef4a9e6 100644 --- a/firmware/matter/tools/test_device_vm.py +++ b/firmware/matter/tools/test_device_vm.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """Real MIPS Matter stack and kernel TTY, with a simulated MCU; no outlet access.""" -import hashlib,json,re,socket,subprocess,threading,time,os,sys +import hashlib,json,re,socket,subprocess,threading,time,os,sys,http.client from datetime import datetime from pathlib import Path @@ -55,12 +55,13 @@ def main(): build=json.loads((OUT/'evidence/chip-tool-macos/build.json').read_text()) binary=Path(build['binary']);assert sha(binary)==build['binary_sha256'] cmd=['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', '-v',str(OUT)+':/work:ro','-v',os.environ.get('CK_SDK_DIR',str(OUT/'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/'+ART+'/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','-device','pcnet,netdev=n,romfile=,mac=52:54:00:12:34:56'] + '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','-device','pcnet,netdev=n,romfile=,mac=52:54:00:12:34:56'] report={'status':'failed','environment':'MIPS Malta/24Kc Linux 4.19 VM, 64 MiB RAM, 8 MiB daemon AS; simulated serial MCU', 'binary_sha256':sha(OUT/ART/'matterd-test'),'source_sha256':{str(p.relative_to(OUT)):sha(p) for p in [OUT/'src/relay/RelayEngine.cpp',OUT/'src/relay/McuController.cpp',OUT/'src/matter-app-device/main.cpp',Path(__file__)]}, @@ -85,6 +86,12 @@ def tool(name,arguments,timeout=45): stdout=sink,stderr=subprocess.STDOUT,timeout=timeout) report.setdefault('commands',[]).append({'name':name,'returncode':child.returncode,'log':str(log.relative_to(OUT)),'sha256':sha(log)}) return child.returncode,log.read_text(errors='replace') + def web_meter(): + c=http.client.HTTPConnection('127.0.0.1',18184,timeout=5) + c.request('GET','/measurements',headers={'Host':'10.0.2.15:18082'}) + r=c.getresponse();data=r.read();c.close() + assert r.status==200,'MIPS monitor HTTP endpoint unavailable' + return json.loads(data) with console.open('w') as sink: vm=subprocess.Popen(cmd,stdin=subprocess.PIPE if PERSISTENT else None,stdout=sink,stderr=subprocess.STDOUT) def mcu_when_ready(): @@ -133,6 +140,11 @@ def mcu_when_ready(): rc,text=tool('meter-'+attribute,['electricalpowermeasurement','read',attribute,'12344321','1']) assert rc==0 and re.search(r':\s*'+str(expected)+r'\b',text),'meter value mismatch: '+attribute report['checks'].append('standard ElectricalPowerMeasurement: 109000 mV, 1500 mA RMS and 500000 mW over CASE') + if PERSISTENT: + web=web_meter() + assert web['available'] and web['voltage_mv']==109000 and web['current_ma']==1500 and web['power_mw']==500000,web + assert isinstance(web['energy_mwh'],str) and web['energy_estimated'] is True,web + report['checks'].append('actual MIPS HTTP monitor reads RAM published by Matter under 8 MiB AS; units agree with CASE attributes') rc,text=tool('meter-descriptor',['descriptor','read','server-list','12344321','1']) assert rc==0 and '144 (ElectricalPowerMeasurement)' in text,'ElectricalPowerMeasurement missing from Descriptor' rc,text=tool('meter-accuracy',['electricalpowermeasurement','read','accuracy','12344321','1']) @@ -176,6 +188,10 @@ def mcu_when_ready(): 'log':str(subscription_log.relative_to(OUT)),'sha256':sha(subscription_log)}) report['checks'].append('standard active-power and cumulative-energy subscriptions report MCU sample and durable energy changes') mcu.meter_drop=True;time.sleep(16) + if PERSISTENT: + web=web_meter() + assert all(web[k] is None for k in ('voltage_mv','current_ma','power_mw')),web + report['checks'].append('MIPS HTTP monitor clears stale MCU readings while retaining the saved cumulative total') for attribute in ['voltage','rmscurrent','active-power']: rc,text=tool('meter-stale-'+attribute,['electricalpowermeasurement','read',attribute,'12344321','1']) assert rc==0 and re.search(r':\s*null\b',text,re.I),'stale measurement must be null' @@ -207,6 +223,7 @@ def read_energy(name): time.sleep(.1) else:raise RuntimeError('energy restart failed') assert read_energy('energy-after-restart')==frozen,'durable energy changed across process reboot' + assert web_meter()['energy_mwh']==str(frozen),'monitor disagrees with durable Matter energy after restart' report['checks'].append('standard cumulative imported mWh is estimated, pauses during missing samples and survives reboot') mcu.meter_drop=False diff --git a/tests/test_monitor.js b/tests/test_monitor.js new file mode 100644 index 0000000..77b3f94 --- /dev/null +++ b/tests/test_monitor.js @@ -0,0 +1,16 @@ +const assert=require('node:assert/strict'); +const {meterView}=require('../firmware/matter/src/update/update.js'); +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']); +assert.equal(v.complete,true);assert.equal(v.relay,'插座已關閉'); +assert.equal(meterView({...sample,energy_mwh:'0'}).energy,'0.000000'); +assert.equal(meterView({...sample,energy_mwh:'4611686018427387904'}).energy,'4611686018427.387904'); +for(const d of [null,{...sample,available:false},{...sample,sample_age_ms:5001},{...sample,sample_age_ms:-1}]){ + v=meterView(d);assert.equal(v.live,false); + assert.deepEqual([v.voltage,v.current,v.power,v.energy],['—','—','—','—']); +} +v=meterView({...sample,voltage_mv:null,current_ma:-1,power_mw:2000001,energy_mwh:'4611686018427387905'}); +assert.equal(v.complete,false);assert.deepEqual([v.voltage,v.current,v.power,v.energy],['—','—','—','—']); +for(const invalid of [1234567,'1e6','-1','1.5','001','\n"; +static const char portal_middle[] = "\">

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

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

設定管理密碼

之後登入插座後台、切換協議及重設設定時使用,與 Wi-Fi 密碼分開。

至少 8 個字元。忘記密碼時,實體長按會恢復出廠並清除設定與用電紀錄。

正在讀取管理設定…

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

Matter 配對

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

\"Matter"; +static const char portal_after[] = "此設定碼屬於這台插座,重開機後維持相同。請保存設定碼或 QR Code。請不要使用插座原本的 HomeKit 碼。配對資料會保存在插座,重開機與 OTA 更新後沿用。

HomeKit/Matter 切換

完成 Wi-Fi 設定後,可在插座管理面板選擇協議。一次只啟用一種,切換時重新啟動。

本機設定 · 不需要雲端帳號
開發測試版 · 依所選的 HomeKit 或 Matter 協議控制插座。
\n"; diff --git a/firmware/matter/src/setup/portal-device.html b/firmware/matter/src/setup/portal-device.html index 25ffa0e..d805371 100644 --- a/firmware/matter/src/setup/portal-device.html +++ b/firmware/matter/src/setup/portal-device.html @@ -1,4 +1,4 @@ 設定 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 · 設定

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

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

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

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

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

設定管理密碼

之後登入插座後台、切換協議及重設設定時使用,與 Wi-Fi 密碼分開。

至少 8 個字元。忘記密碼時,實體長按會恢復出廠並清除設定與用電紀錄。

正在讀取管理設定…

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

Matter 配對

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

Matter 配對 QR Code{{MATTER_MANUAL}}此設定碼屬於這台插座,重開機後維持相同。請保存設定碼或 QR Code。請不要使用插座原本的 HomeKit 碼。配對資料會保存在插座,重開機與 OTA 更新後沿用。

HomeKit/Matter 切換

完成 Wi-Fi 設定後,可在插座管理面板選擇協議。一次只啟用一種,切換時重新啟動。

本機設定 · 不需要雲端帳號
開發測試版 · 依所選的 HomeKit 或 Matter 協議控制插座。
diff --git a/firmware/matter/src/setup/portal-scan-script.h b/firmware/matter/src/setup/portal-scan-script.h index c488253..7dddd43 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 var protocolPanel = document.getElementById('protocol-management');\n if (protocolPanel) { protocolPanel.href = 'http://' + location.hostname + ':18082/'; protocolPanel.hidden = false; }\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 var adminState=null,setting=false;\n var adminMessage=document.getElementById('admin-setup-status');\n async function adminStatus(){try{\n var response=await fetch('/admin/status',{cache:'no-store'});adminState=await response.json();\n if(!adminState.ready){setTimeout(adminStatus,1500);return;}\n document.getElementById('admin-setup').hidden=adminState.configured;\n document.getElementById('admin-password').required=!adminState.configured;\n document.getElementById('admin-confirm').required=!adminState.configured;\n adminMessage.textContent=adminState.configured?'管理密碼已設定。':'請建立管理密碼,不需要下載金鑰檔案。';\n if(!adminState.wifi_allowed){form.hidden=true;adminMessage.textContent='請開啟管理面板,登入後修改 Wi-Fi。';}\n }catch{adminMessage.textContent='管理服務尚未就緒,正在重試…';setTimeout(adminStatus,1500);}}\n adminStatus();\n form.addEventListener('submit',async function(event){\n event.preventDefault();if(setting)return;\n try{\n if(!adminState?.ready)throw Error('請稍候管理服務準備完成');\n setting=true;save.disabled=true;\n if(!adminState.configured){\n var password=document.getElementById('admin-password').value;\n if(password!==document.getElementById('admin-confirm').value)throw Error('兩次管理密碼不相同');\n adminMessage.textContent='正在保護管理密碼,請稍候…';\n var salt=CKPassword.salt(),key=await CKPassword.derive(password,salt);\n document.getElementById('admin-password').value='';document.getElementById('admin-confirm').value='';password='';\n 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='';\n if(!enroll.ok)throw Error('管理密碼未保存,請重新開啟設定頁再試');\n adminState.configured=true;\n }\n adminMessage.textContent='密碼已保存,正在連線…';HTMLFormElement.prototype.submit.call(form);\n }catch(error){adminMessage.textContent=error.message;setting=false;save.disabled=false;}\n });\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"; diff --git a/firmware/matter/src/setup/portal-scan.js b/firmware/matter/src/setup/portal-scan.js index 2b26626..948b095 100644 --- a/firmware/matter/src/setup/portal-scan.js +++ b/firmware/matter/src/setup/portal-scan.js @@ -1,5 +1,7 @@ 'use strict'; (function () { + var protocolPanel = document.getElementById('protocol-management'); + if (protocolPanel) { protocolPanel.href = 'http://' + location.hostname + ':18082/'; protocolPanel.hidden = false; } var form = document.getElementById('wifi-form'); var list = document.getElementById('network'); var manual = document.getElementById('manual-network'); @@ -11,6 +13,36 @@ var retry = document.getElementById('scan-again'); var save = document.getElementById('save'); var networks = [], scanning = false; + var adminState=null,setting=false; + var adminMessage=document.getElementById('admin-setup-status'); + async function adminStatus(){try{ + var response=await fetch('/admin/status',{cache:'no-store'});adminState=await response.json(); + if(!adminState.ready){setTimeout(adminStatus,1500);return;} + document.getElementById('admin-setup').hidden=adminState.configured; + document.getElementById('admin-password').required=!adminState.configured; + document.getElementById('admin-confirm').required=!adminState.configured; + adminMessage.textContent=adminState.configured?'管理密碼已設定。':'請建立管理密碼,不需要下載金鑰檔案。'; + if(!adminState.wifi_allowed){form.hidden=true;adminMessage.textContent='請開啟管理面板,登入後修改 Wi-Fi。';} + }catch{adminMessage.textContent='管理服務尚未就緒,正在重試…';setTimeout(adminStatus,1500);}} + adminStatus(); + form.addEventListener('submit',async function(event){ + event.preventDefault();if(setting)return; + try{ + if(!adminState?.ready)throw Error('請稍候管理服務準備完成'); + setting=true;save.disabled=true; + if(!adminState.configured){ + var password=document.getElementById('admin-password').value; + if(password!==document.getElementById('admin-confirm').value)throw Error('兩次管理密碼不相同'); + adminMessage.textContent='正在保護管理密碼,請稍候…'; + var salt=CKPassword.salt(),key=await CKPassword.derive(password,salt); + document.getElementById('admin-password').value='';document.getElementById('admin-confirm').value='';password=''; + 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; + } + adminMessage.textContent='密碼已保存,正在連線…';HTMLFormElement.prototype.submit.call(form); + }catch(error){adminMessage.textContent=error.message;setting=false;save.disabled=false;} + }); function passwordMode() { var open = security.value === 'open'; password.required = !open; 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..a924b07 100644 --- a/firmware/matter/src/update/update.html +++ b/firmware/matter/src/update/update.html @@ -1,26 +1,36 @@ -CommandKit 插座用電與更新 +CommandKit 插座管理 -
COMMANDKIT / CMNDKT-004

插座用電

韌體版本
讀取中…
-
-
-
正在連線…插座狀態讀取中
-
+:root{font:15px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#17324d;background:#f2f5f7;color-scheme:light;--line:#d7e0e6;--muted:#647789;--teal:#087e8b}*{box-sizing:border-box}[hidden]{display:none!important}body{margin:0}header{background:#17324d;color:#fff;padding:20px max(22px,calc((100vw - 1120px)/2));display:flex;align-items:center;justify-content:space-between;gap:16px}.brand{font:700 17px/1.4 ui-monospace,monospace;letter-spacing:.06em}.model{font-size:11px;letter-spacing:.1em;opacity:.65;margin-top:3px}.account{display:flex;align-items:center;gap:12px;font-size:13px}.shell{max-width:1164px;margin:0 auto;display:grid;grid-template-columns:170px 1fr;gap:28px;padding:30px 22px}nav{display:flex;flex-direction:column;gap:5px}nav button{text-align:left;background:transparent;color:#647789;border-radius:6px;padding:12px 14px;border:0;font-size:14px}nav button[aria-current=page]{color:#17324d;background:#dce9ed;font-weight:700}.sidebar-note{font:11px/1.8 ui-monospace,monospace;color:#647789;padding:14px}.heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:0 0 20px}h1{font-size:27px;font-weight:650;letter-spacing:-.04em;margin:0}h2{font-size:17px;margin:0 0 12px}h3{font-size:14px;margin:0 0 8px}.live{display:flex;align-items:center;gap:7px;font-size:12px;color:#647789}.led{height:7px;width:7px;border-radius:50%;background:#8b9ca6}.live[data-state=live] .led{background:#087e8b;box-shadow:0 0 0 4px #087e8b14}.panel{background:#fff;border:1px solid var(--line);border-radius:10px;padding:22px;margin-bottom:20px}.meter-head{display:flex;justify-content:space-between;gap:12px;font-size:13px;color:var(--muted);padding-bottom:15px;border-bottom:1px solid var(--line)}dl{margin:0;display:grid;grid-template-columns:1fr 1fr}.meter{padding:26px 18px 24px 0;min-width:0}.meter:nth-child(2n){padding-left:24px;border-left:1px solid var(--line)}.meter:nth-child(-n+2){border-bottom:1px solid var(--line)}dt{font-size:13px;color:var(--muted);margin-bottom:12px}dd{margin:0;display:flex;align-items:baseline;flex-wrap:wrap;gap:8px}.reading{font:500 clamp(26px,4.5vw,42px)/1.2 "SFMono-Regular",Consolas,monospace;letter-spacing:-.06em;color:var(--teal);font-variant-numeric:tabular-nums;overflow-wrap:anywhere}#energy{font-size:clamp(22px,3.5vw,33px)}.unit{font-size:13px;color:var(--muted)}.meter-note{color:var(--muted);font-size:11px;margin:8px 0 0}.meter-foot{border-top:1px solid var(--line);padding-top:14px;display:flex;justify-content:space-between;gap:10px;font-size:12px;color:var(--muted)}.device-facts{display:grid;grid-template-columns:repeat(3,1fr);gap:15px}.device-facts small{display:block;color:var(--muted);font-size:12px}.device-facts strong{font:500 14px/1.8 ui-monospace,monospace;overflow-wrap:anywhere}.quiet{color:var(--muted);font-size:13px}p{margin:8px 0 14px}button{font:inherit;font-size:14px;font-weight:600;line-height:1.5;cursor:pointer;border:1px solid transparent;border-radius:6px;padding:10px 16px;background:#087e8b;color:#fff;margin:4px 6px 4px 0}button.secondary{background:#edf3f6;color:#17324d;border-color:#d7e0e6}button.ghost{background:transparent;border-color:#ffffff50;color:#fff}button.danger{background:#fff3eb;border-color:#e7c5af;color:#925224}button:disabled{opacity:.5;cursor:wait}:focus-visible{outline:3px solid #087e8b;outline-offset:3px}label{display:block;font-size:13px;font-weight:600;margin-top:12px}input,select{font:inherit;border:1px solid #bfccd5;border-radius:6px;padding:11px 12px;width:100%;max-width:100%;background:#fff;color:#17324d;margin-top:6px}input[type=file]{font-size:13px}fieldset{border:1px solid var(--line);border-radius:7px;margin:0 0 14px;padding:5px 16px 16px}fieldset label{display:flex;align-items:center;gap:9px;font-size:14px}fieldset input{width:auto;margin:0;accent-color:#087e8b}.form-row{display:grid;grid-template-columns:1fr 1fr;gap:16px}.actions{margin-top:16px}.notice{padding:13px 16px;border-left:3px solid #087e8b;background:#e5f0f2;font-size:13px;margin-bottom:20px;white-space:pre-wrap}.login{max-width:460px;margin:0 0 20px}#login-note,#protocol-message,#settings-message,#message{white-space:pre-wrap;font-size:13px;color:#647789}.section-head{display:flex;justify-content:space-between;gap:15px}.tag{font:11px/1.8 ui-monospace,monospace;color:#647789}.divider{margin:22px 0;border:0;border-top:1px solid var(--line)}summary{cursor:pointer;font-size:14px;font-weight:600}details[open] summary{margin-bottom:12px}#matter-code{font:500 22px/1.8 ui-monospace,monospace;letter-spacing:.1em}dialog{max-width:440px;width:calc(100% - 36px);border:1px solid var(--line);border-radius:10px;padding:25px;color:#17324d}dialog::backdrop{background:#17324d70}footer{font-size:11px;color:var(--muted);padding:8px 0 24px}@media(max-width:680px){header{padding:17px 18px}.shell{display:block;padding:16px}nav{flex-direction:row;margin-bottom:20px}.sidebar-note{display:none}.panel{padding:18px}.device-facts{grid-template-columns:1fr 1fr}.form-row{grid-template-columns:1fr}.account span{display:none}.meter:nth-child(2n){padding-left:16px}h1{font-size:24px}.meter-foot{flex-wrap:wrap}.reading{font-size:34px}} +#settings-message:empty{display:none} +
COMMANDKIT
CMNDKT-004 / SMART OUTLET
+
+ + +

狀態總覽

正在連線
+
正在讀取量測…—
電壓
—V

交流有效值

電流
—A

交流有效值

即時功率
—W

目前消耗的功率

-
累積用電
—kWh

功率估算 · 約 30 秒保存

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

韌體更新

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

-
使用已下載的更新包

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

-

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

-
-
讀值無效或連線中斷時顯示「—」。累積用電從啟用電量紀錄起計算,不含先前的使用量。
- diff --git a/firmware/matter/src/update/update.js b/firmware/matter/src/update/update.js index d94a8ac..67ec4be 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,108 @@ 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); +if (typeof module !== 'undefined') module.exports={sha256,hex,hmac256,meterView}; +if(typeof document!=='undefined')(async()=>{ + const $=id=>document.getElementById(id),enc=s=>new TextEncoder().encode(s); + let auth={authenticated:false},csrf='',status=null,protocol=null,busy=false,latest=null,protocolDirty=false,ledDirty=false; + $('device-address').textContent=location.hostname; + document.querySelectorAll('input[name=protocol]').forEach(input=>input.onchange=()=>{protocolDirty=true;}); + function view(name){$('overview').hidden=name!=='overview';$('settings').hidden=name!=='settings'||!auth.authenticated; + $('nav-overview').setAttribute('aria-current',name==='overview'?'page':'false');$('nav-settings').setAttribute('aria-current',name==='settings'?'page':'false');} + function drawAuth(data){auth=data;csrf=data.csrf||'';$('account-state').textContent=data.authenticated?'管理員':'訪客檢視'; + $('login-open').hidden=data.authenticated;$('logout').hidden=!data.authenticated;$('nav-settings').hidden=!data.authenticated; + $('guest-note').hidden=data.authenticated;$('setup-notice').hidden=data.configured!==false;$('login-open').disabled=data.configured===false; + if(!data.authenticated)view('overview');else{$('login-panel').hidden=true;}} + 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){$('account-state').textContent='管理設定準備中';return;}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});$('login-panel').hidden=false;throw Error('請先登入,或重新登入後操作');} + throw Error(r.status===409?'裝置正在處理其他操作,請稍候再試':r.status===503?'裝置或儲存尚未準備好,請稍候再試':'操作遭拒絕,請確認輸入與更新檔案');}return data; } + function ask(text){return new Promise(resolve=>{const dialog=$('confirm-box');$('confirm-text').textContent=text; + $('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);$('settings').querySelectorAll('button').forEach(b=>b.disabled=value);} + async function run(job){if(busy)return;setBusy(true);$('settings-message').textContent='處理中…';try{await job();}catch(e){$('settings-message').textContent=e.message;}finally{setBusy(false);}} + $('nav-overview').onclick=()=>view('overview');$('nav-settings').onclick=()=>{view('settings');settingsRefresh().catch(()=>{});}; + $('login-open').onclick=()=>{$('login-panel').hidden=false;$('login-password').focus();};$('login-cancel').onclick=()=>{$('login-panel').hidden=true;$('login-password').value='';}; + $('logout').onclick=async()=>{try{await api('/auth/logout');drawAuth({authenticated:false,configured:true});}catch(e){$('settings-message').textContent=e.message;}}; + $('login-form').onsubmit=async event=>{event.preventDefault();$('login-submit').disabled=true;$('login-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});$('login-note').textContent='';view('settings');await settingsRefresh(); + }catch(e){$('login-note').textContent=e.message;}finally{$('login-submit').disabled=false;}}; + function drawMeter(data,offline=false){const v=meterView(data); + for(const [id,value]of[['voltage',v.voltage],['current-amps',v.current],['power',v.power],['energy',v.energy],['relay',v.relay],['meter-updated',v.note]])$(id).textContent=value; + $('meter-state').textContent=offline?'連線中斷,正在重試':v.state; + $('live-state').dataset.state=offline?'offline':v.complete?'live':'waiting';$('live-state').lastElementChild.textContent=offline?'連線中斷':v.complete?'即時連線':'已連線,等待量測'; + if(protocol?.active==='homekit'){$('meter-state').textContent='HomeKit 模式';$('meter-updated').textContent='自訂即時量測及 kWh 累計暫停;顯示已保存的用電紀錄';if(!offline){$('live-state').lastElementChild.textContent='管理連線正常';$('live-state').dataset.state='live';}}} + async function refresh(){const r=await fetch('/status',{cache:'no-store'});if(!r.ok)throw Error('裝置尚未準備好');status=await r.json();$('current').textContent=status.version; + const p=await fetch('/protocol',{cache:'no-store'});if(p.ok){const previous=protocol?.selected;protocol=await p.json();const name=protocol.active==='homekit'?'HomeKit':protocol.active==='matter'?'Matter':'維修模式';$('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 ledDescriptions={smart:'正常時跟隨開關,設定、連線或配對時提供提示。',relay:'插座開啟時亮,關閉時滅;可使用主動尋找。',inverse:'插座關閉時亮,方便在暗處找到;可使用主動尋找。',load:'連續兩次讀值達 5 W 開始閃爍,降至 2 W 以下停止。',on:'持續亮燈;夜間排程可暫時熄燈。',off:'平常熄燈,設定、配對、異常或主動尋找時仍會亮燈。'}; + $('led-settings').addEventListener('input',()=>{ledDirty=true;$('led-description').textContent=ledDescriptions[$('led-mode').value];}); + 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'); + 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(); + const reasons={normal:'依平常模式顯示',night:'夜間熄燈中',pairing:'等待家庭配對',joining:'正在連接 Wi-Fi',setup:'等待初次設定',error:'服務或 MCU 通訊異常',identify:'尋找插座中'}; + $('led-status').textContent=d.service_ready?(reasons[d.reason]||'正在讀取狀態'):'指示燈服務正在準備中'; + $('led-clock').textContent=d.time_valid?'時間已校準 · '+(d.time_source==='ntp'?'時間伺服器':'手機時間')+(d.night?' · 目前為熄燈時段':''):'排程等待校時:可使用下方按鈕校準'; + if(!ledDirty){$('led-mode').value=d.mode;$('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;$('led-description').textContent=ledDescriptions[d.mode];}} + $('led-settings').onsubmit=e=>{e.preventDefault();run(async()=>{const 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 '+$('led-mode').value+' '+Number($('led-schedule').checked)+' '+start+' '+end+' '+offset+' '+ntp+'\n');ledDirty=false;$('settings-message').textContent='燈號設定已保存,會立即套用。';await ledRefresh();});}; + $('led-find').onclick=()=>run(async()=>{await api('/admin/led-find');$('settings-message').textContent='插座將閃燈 15 秒,之後恢復原本燈號。';}); + $('led-phone-time').onclick=()=>run(async()=>{await api('/admin/led-time',String(Math.floor(Date.now()/1000)));$('settings-message').textContent='已送出手機時間,本次開機的排程可離線繼續計時。';await ledRefresh();}); + 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 data=await r.json();const m=data.matter; + $('pairing-state').textContent=protocol?.active==='homekit'?'HomeKit 請使用原廠標籤配對碼。':m?'Matter 已配對 '+m.fabrics+' 個家庭;'+(m.window_open?'配對視窗開啟中':'配對視窗已關閉'):'Matter 正在準備中'; + const onboarding=await fetch('/admin/onboarding',{cache:'no-store'});if(onboarding.ok){const d=await onboarding.json();$('matter-code').textContent=d.manual_code||'尚未就緒';}await ledRefresh();} + async function waitReceipt(id){const until=Date.now()+60000;while(Date.now()setTimeout(r,1000));const r=await fetch('/admin/state',{cache:'no-store'});if(!r.ok)continue;const {matter}=await r.json(); + if(matter?.request_id===id){if(matter.result==='complete')return;if(['storage-error','window-error','failsafe-timeout'].includes(matter.result))throw Error('裝置未完成操作:'+matter.result);}} + throw Error('尚未收到完成回應,請檢查目前狀態,勿重複操作');} + async function restartMessage(text){$('settings-message').textContent=text+'。請保持供電;重啟後可使用同一管理網址登入。';drawAuth({authenticated:false,configured:true});} + $('protocol-save').onclick=()=>run(async()=>{const mode=document.querySelector('input[name="protocol"]:checked').value;const data=await api('/protocol',mode);protocolDirty=false; + if(data.status==='unchanged'){$('settings-message').textContent='目前已使用此協議。';return;}await restartMessage('協議選擇已保存,正在重新啟動');}); + $('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);$('settings-message').textContent='累積用電已歸零並保存。';drawMeter(null);await settingsRefresh();}); + $('commissioning-open').onclick=()=>run(async()=>{const r=await api('/admin/open-commissioning');await waitReceipt(r.request_id);$('settings-message').textContent='Matter 配對視窗已重新開啟。';await settingsRefresh();}); + $('pairing-reset').onclick=()=>run(async()=>{if(!await ask('清除目前協議的家庭配對?Wi-Fi、管理密碼和累積用電會保留。'))return; + const r=await api('/admin/pairing-reset');if(r.status==='restarting')await restartMessage('正在重設配對並重新啟動');else{$('settings-message').textContent='正在重設配對,請稍候再查看狀態。';}}); + $('reboot').onclick=()=>run(async()=>{await api('/admin/reboot');await restartMessage('正在重新啟動');}); + $('factory-reset').onclick=()=>run(async()=>{if(!await ask('恢復出廠將清除管理密碼、Wi-Fi、HomeKit/Matter 配對與全部累積用電。請確認要清除並重新設定。'))return; + await api('/admin/factory-reset');$('settings-message').textContent='正在恢復出廠。重啟後連接 CommandKit-Setup 設定熱點重新設定。';drawAuth({authenticated:false,configured:false});}); + $('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='';$('settings-message').textContent='已送出 Wi-Fi 設定。請切回相同網路,並使用插座的新 IP 開啟管理頁。';});}; + $('password-settings').onsubmit=event=>{event.preventDefault();run(async()=>{const password=$('new-password').value;if(password!==$('confirm-password').value)throw Error('兩次密碼不相同'); + $('settings-message').textContent='正在保護新密碼,請稍候…';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});$('login-panel').hidden=false;$('login-note').textContent='密碼已更新,請使用新密碼登入。';});}; + async function install(bytes,expected){if(bytes.length!==status.max_size)throw Error('請選擇完整 .ota 更新包'); + $('message').textContent='正在驗證並上傳…';const digest=hex(sha256(bytes));await api('/upload',bytes,digest); + $('message').textContent='驗證完成,正在安裝。請保持供電…';await api('/apply','',digest); + const until=Date.now()+240000;while(Date.now()setTimeout(r,4000));try{await refresh();if(status.software_version===expected&&(status.healthy||status.rescue)){ + drawAuth({authenticated:false,configured:true});$('login-panel').hidden=false;$('login-note').textContent=status.rescue?'新版已啟動,目前為維修模式。':'更新完成,請重新登入。';return;}}catch{}} + throw Error('尚未收到新版回應,請確認插座網路位址;勿重複上傳');} + $('manual').onclick=()=>run(async()=>{const file=$('package').files[0];if(!file)throw Error('請選擇 .ota 更新包');const b=new Uint8Array(await file.arrayBuffer());if(b.length<256)throw Error('更新檔案不完整');await install(b,new DataView(b.buffer).getUint32(20));}); + $('check').onclick=()=>run(async()=>{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=c.updates.filter(x=>x.model==='CMNDKT-004'&&x.variant==='matter'&&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').textContent=latest?'有新版本 '+latest.version:'目前已是最新版本。'; + }catch(e){$('message').textContent='無法檢查線上版本:'+e.message+'。仍可使用手動更新。';}$('settings-message').textContent='';}); + $('online').onclick=()=>run(async()=>{if(!latest?.url.startsWith('https://'))throw Error('更新來源不符');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);}); + 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();}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{$('account-state').textContent='連線中斷';}monitor();statePoll(); })(); diff --git a/firmware/matter/src/update/update_page.h b/firmware/matter/src/update/update_page.h index f18dd95..95b80a6 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
COMMANDKIT
CMNDKT-004 / SMART OUTLET
訪客檢視
\n
\n\n\n

狀態總覽

正在連線
\n
正在讀取量測…—
\n
電壓
—V

交流有效值

\n
電流
—A

交流有效值

\n
即時功率
—W

目前消耗的功率

\n
累積用電
—kWh

功率估算 · 約 30 秒保存

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

裝置資訊

韌體版本—
目前協議—
網路位址—

登入後可變更設定、更新韌體與重設累積用電。

\n\n
CMNDKT-004 · 讀值無效或連線中斷時顯示「—」。實體長按至重啟,可恢復出廠設定。
\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_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()7qC?<-nAw?`*{^eP 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

HexwL_i>wkv{#=h(@}#6tgL&B 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 CNo9%v literal 0 HcmV?d00001 diff --git a/verification/admin-ui/homekit-guest-fixture.png b/verification/admin-ui/homekit-guest-fixture.png new file mode 100644 index 0000000000000000000000000000000000000000..213dbde44429a6eb5877491fddf5285a02bdd2a9 GIT binary patch literal 38496 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 literal 0 HcmV?d00001 diff --git a/verification/admin-ui/homekit-settings-fixture.png b/verification/admin-ui/homekit-settings-fixture.png new file mode 100644 index 0000000000000000000000000000000000000000..e270ff6d746706e91fcfc1cb3cf6d624e6172fd7 GIT binary patch literal 44475 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 P`Q82hnYG%tUq}BhfS0G` literal 0 HcmV?d00001 diff --git a/verification/admin-ui/returned-matter-fixture.png b/verification/admin-ui/returned-matter-fixture.png new file mode 100644 index 0000000000000000000000000000000000000000..35c47b3fffa9ec8fef2558c2bbbdadde904ab942 GIT binary patch literal 42150 zcmeFZcU)85vM?OGVxVhIE#G$}y=!RHY|FD7IMAq12Tp(stH*&dOEW`d!EN=+fb z1Og@q7C<^k2^|HbH$edrQD4rv=iGbWd(M5obMAM}eee7GJ!}7wwbsm@*=yF!T5HeD z&d=VT!=OWEMy5ufEn7gKEt?nU=K#n6v~&B8ojbPg+__`tu3bBK3mp;?+OtPU?7+c& zha|)fAC?dk7nhWQ9+Q-kl@=F2u5?^h{-gp-;m9%N)5@n#Lr*E3`U8!drgUfTTAca_iP@n@{?$W!v^GJ9q5b zy>%0cJ_OpbZR^$@JB4;{+rE4I&TX5c+PZDK@D7okq9@dlV!xtDMAvVC3fTSm# z>Y5i^uHDXIgJn758t9VUa$0Ac1A{swFn8`k;Rdg&M}i+NZc;t6Y}qQjO=OeXuiMoj$Sc=wYrGPB!fqeYyx>e)`q>ZKxB2Xw z7{Z|Qpy}gVma`B2JF5Sqt1|gH@Af_ID>X_oDXlY=eK|FoU6hvyWPS2QH&=wn*Ww0> z$_*0V{siq(n--_IbXmT+oVe{3H<4N^95bP#MbFWuh2Jm{;8qP0@7uB$d+dUzbahQP zB&zQ__bWZKQXe$f+98xWJ`DB|5(VgAcE({XCd*T>>2OD`g8 zQT;XfRoC@ey){%KAN@MGaW<*rX{FNCAdeEan%r@?#_4Z}KIzIof3`S?h-sgZOWd(1Uq3c7WGg~+fvo^7v=Xa7l ze}Wdx8l@c{iN9R(!tR4FYA9zic-Y{H7w7xX*h|-0Bi&I$p$A5b7M1JOHoVUnOO5gM ze(U7ev9%Sdk_Nlb@kK4%kflkhV1s*$R~{c$$wYUdSY-Yko_wI!be3t>r<(Yx;7>)F z-&88f6INPutUfSbRgT)9%+<6Hw!V84pSq#!97tC?YC?Trmi7~rTvYWwXQ=#}W3FrG z!5!&ma>MkFLRHesj0GJ!^gGZem`@D2bQhwzta5-Gny?t0auKv&{l8arx2|%#f*#IV z@t|o8**xn!LYjR{Y|AomQ{%%L<@#Fxqp*cV`mOQ}vx*I0>&ok)KcKiDT7~Z_o(ug` zI?GbC+&1`WZvMK^fJ(+%&ZEP>`4DLhl5Q^PeZpsD(-X?auPWZ!`j;}&{bI80FS1A& zPWn~8{cM_@yoBg9%xggL5;Hhe05nrGKD-!S#?@MHyX3Hh3blM&$Cs;<&$|SPKbjo= z!SvD?!qKPA->&m)h+@nY;z~7sW~$q;q&wTgI884E%@c!DPGg`Ba8YA)f^A-NSZ;e$ zTSjDSq<-h0w~ja>cR%P!(Z8i)O~!Dx~!sYxn$0Sn8a-&{eShYtJpp8QQR z{rz6+w>`*QthpT$BAE5#sL6(thyy;HO0D;E3+J+c7>uAB>IS$Ou6R;u=g7P^zMn&5 zd2a-V&!phdjkEz&`SS)yZqH8;=99Jf_o4ug&bMLO1su;~cO15X-9&8F*0RtCZ@UR5 z5P^fS8L+-K_m;^Z8uYI_gwiElN!0tG7nt8`(3&Q_k%{btXWchvjn-7gHfeV^+ahI zo-5n|AatQ_=RVamV3Up5GhJa_mp=e~PhlyMS@EU9or@dyQ{H&5N6dDt zpErm5-#foothA^NsV7t`bbX&g!u8Q|{sVKQW@pi-7TF1aUq1=sZ}>kq zKGxQMJ?!&q>8}23xV)%?+1;-QznX%Bw-dj~aUeKksK0mM9N_3e`w40iK>z%jWv&`x3~Vg)|p1N#@aA=03=6|$p+u35|XZiGXvAdjG_JX zjE>d5t`OF3IBvM)D9KZnnxK93i+e8FNY5ov18DQH^2X|xSC%0rEEx@6rPnZ157DJ! zIy!x)H?)#(oXXgb0#N*X7r% z>^c2L$JouqVbY%i?OF*r8W$zjyL9R&=w6$hu9ISL&|3(nM_|-U#vLBxjyGKQZ6-YJ zTy{>#DSjPCp5D2~5@C&q?oxx=VMFRRYK(O+QhW5U@&L!rn=ap!mSD}Dnv!PKvl@9w zABJzrPtZm*kYLh2(<3WQkD2uW%yFt;3+<*iWOuyo+?My+`R-l|71s8k(k1RE1I5gz zj_82KgeaWHmlTAz(JYj8f6p+79I9!vi^j-(of~cTrHaHPNu9I2l$k(^VDMT2LJYx; zo026KwsF+|++E&=Mpb^0?g8H@-pTO=Oxg8DRuc*7TNIw$gRsm{xo;t(sF!P3>N+~) zFQA?uQ~jC<6Q7M@{`wPi;%4)Wi&S3pNbp*EYpSmd0{b}LopLo=ASm1Hhh1e!c;+$YIml?Z2NC!Y#1EA73DQ!LS=7WpP`bP)pc`xV9Im#u3CvZ%|;S@1UZLD zku|iZ2?uoLCeK3bv;z69xF)uwwds6nP+U*O)UwMlRzX5R?6z8%!k9Vd5=}1LAv@oY zLxG(xGRhEkKZg9(791{M`f7{>x&~BC<-k!hjL1xD?3AyHjOpVr)g2dpRbH%@x4Os# zyz9Hda&9Vw;F?;4U;Ng0wfR_eRKh4%prQVV^y?*7g1F^%?k6}C4@kzul4#*RxEJ?| zm6|6+E#iZ>r_1J%;4&(SqjTPLIJgT16#&AbAuE94SqwsU+{eNtOEHUbm=FvM;jigE z`(oB2>vOsTb3<4^o@vJr0lOs6j7L4eW?ZK{a$^QIg#pq!z=(nElWUDVK#SC6!f~Iy z+uDV}q7c@b{+XX3R524hmJyjxi$5bBy>hzyh6xm?#?qSHQPaC|(Z$|0My`~5AUi!vU6F*<>y{%C z>fBwiegiMRnqKW?#~nDQ!v_~w=+Kx!9B!;=o~fWHjNT`m)~?n6?Q5Nvx+Hon(pL|w z;H?0I(86-GPS#9RHdctVp!tJe-M}{eJ?iP#W%IhYC^)v^t@T_uOkP6MA)4U&$%ltSnMy3b_Q&(AFA zN~C%v94OyaGj*j0Ga@Kma_pP9-R!%DyT~-R)N`1-lYHc4+BYd_Ut8_>cTTi?prAh& zY)!KG`Xu3AeA?4`;uy{~#VW|y&gQ0w!%j059O2dd`t>NA3u~8y(M<1IusxkPACqDj z;5&+2v$*Q@>m}|WLBU(RL# z-?7Q9$=Uk@ie8XS_AmhUJ!l?)af}!aDdq*WD+=+3&(mOVzb?fU&u>00_<<#M%k=ZN z=nrab*4QRsxpck3*IxnRt-Q(_N^#s9G$H%uh2ZHqae|h%OQb8|CrB)T6P4QkA}v_0 z4t0eqcwi2Pf-M$nzke7dwBpZ$4SO#G znN#$_e7AF8)g*_;u0m>F7238B(`HF@stY=rRmFWBJqrs+ zTt$rxvGg}@CSW`jsvy7(?HCNm#Cacl%@)US;+Mg`j4<3&B^6_yo+Y;@U`1$7^-y(x zo$XrziaYCG^p+R%@%2=oq$So`U*e9uVt8~fc* zjhSJ_)f(=xfRJqZMne!G=7kulrqSZ;%s#CO;lPeN2?exEDFFtek5ZO6hd$k4s?WHV zo`0tTe9{_d94UA2bgj;iKaukmd1d9{^2%vJl~-=^lc$piyt*E^F@_gV;l5n?wP}Tm z?oB?K_pnQFQHgu?m)?BRw3Iv<`oKV;xxbUXi6 z%{Me-QyZ*~0m2$?*qG8F3SKB5zLz}wj6u&b8`R=@Um9E>9FGd}2b(+AY2=Fi1XZ)c zQW>r}Kr#{S4GprBrY9TCO3pEXa-G5AZ3CwHz2QT&0z%2_N$;5fUCF_QVA~veM96-% z>K-KU){JC}YO{outE+noh_p9NEo`&#B74h_lbGeGkfLG?{|myR=@}jU2k+EBt4Kw~ zdlfHQ997cm)dL4gLCyH4V-y)i_Sp}l`1&!!3cE^VcU2SCC$uW{h47Qw^qIrCtlE4$ zeFlX?*ERwS0_2QQ+0p}ld7VVK$gT)|!f||oW zCZB-_)nM}?DR^p!j}>r(%%U1fv8ylcD!b)W1(!oy{Zci(s{j_{LOqsg8a7H z_-Nutto6RVrjpuDX{8^nCri{}%40a*m;6Bj+vnP|11TE2tG%X^gzn^baXGe~d@rzl zgTos*ooSt)0-a;HO|Hz`3cg!f7GYp6{sJ-jdgc@)5-Tdp$Xv1IrBBP3;p%z$!9Dll zFK?}JwwU?%XD+Rdi`r+rGY8gJ60i|AMbw>5!=KG;4T5r{uPA6c*GZG|>;=THz04|C z1e*JC&_DQxl(*tlAMC-59U=DX+Flwg=(+Ug^Gz9wg8H%-v`EjOH4RNd7A4jq!XTvm zb!RDlL*oPc?UmH1_?ki4gVpT>DRF>Gn(E@f zKZdOK=@TK+?n!IE6m&3;SF1%HZ(s8+EWCZ0O5l>~q`wJ>U5Mu)0KkC_q2K;O#ONn< zELd;g@2|afe$;n~=Z&Bl{{)FVQgPr#CAL?VxpTj}KQS*<8&Qa3#a({*%%V3|O4P%r z!%|uEhR-VdBY?J5PX5+O@4qN)_Nw3QsVC;4z>{@qJqALglXKvASj3>`!D=e2yz_1o zvxs500DWU#5nhr`kN2H>k8<+*wBKV~Q+f2JoySJZ%_gslOyaQ z3_AXQvW3VN%_K#P*D(l}T4Sq?X))xS942t;UjCo%`RBm4hQhMM@KUJ_&Onu^FrZqi z>}K6ys*OHBtWbfv`3}7W^Yzd5f9nKc$1nZ_-71(j{Rs;Fx9W|HTl^^oKelFYPk#UK z=gRmmJF0+#YeIfqkG>@Qxk3Sn|5dVP-D-gdV@s*dz$9JfSMA0}TN#6sm91fn%yB;b zBXIiPyJ%L<#C8+I45YubM?4u_$;p0Dr?>2MYJA?-TJap`piO9{XJMXNTckweQB`|| z>3xCk5ufgJe~0}pm)~{bcYFAKE%<$n{C$7;eQ*4~cP`M41BJeO@XtBa{o|<$>pzJL zPM)dCoup{ojU_zIJQPrH_3F6A@JRIQtG_m;y{g{qkCxi=Y8T97*8z0vfx|v0w@cG9 zrL}F&p1ty4MqhZcNj`!-wK~KI(vNxkj~~~6k>UZ>&>ay*ES-J&1)QLOT5Hk#Ft|#Y zH;h>szW-U#Hnd;nmEzY+VXMDlo{l_R>}n)-54DixNYwX=krdT%UoijrZ|%eIvw1Ca zqgSZXSSz+YkP-$LS?$k8DbM?stJ=Z2ca0KXM{R?Lp=|16x=bkx=sToOiU7-y!!5^A z$Iw{&!Hv2zw)vNQp&`f2QPYq}H(ff?)f*0Rb}I`45C|XuF0vWq(`jIZN?J*bXc)7V z5DLeG1B1y1(i-{+h6!#dE=py+DSg-NH+n7zeH1#ZcU0B(ev>hQF`ZAAFzZb%vegJX z)6~p(r5rrMHn#g=`t<#;MV<+1Z@tcSZ2BJT#Mnk;vm`a)!}PL7I1Y(K%;_+07d5+K5j6u0Xq%&FzkBs2IzjuD|&1vH4n5Oafhp%oCC?w})oz|+`l zrhn5d6K0_FfUZVarDRN(xt%{AXC=sM zj}uRl3Q@JZUwsrZ)u9~Vb87SU9fk*_?cWF zF-qnbw3^zDwfIs8(e@#B!_$PBh9sno%Fy%r`~DZFcE-K!dZni?^Xkyy)cIrV{_ zhlk0C#WYaJ>eJ)Ccvk#-yzCsoRr}!XWpwY&-z>b?TFJ_Re#Kecfh)~FoFPMJ={|$8 zv$`FR3`)Zui3CSnvQ!6jy?IDLwZt190-3X3RQ8$|5+$ zWzg=w$x4lq(I88hUrcmK zePgn03sEjw&EZNo4>4NWN__X7FT+xH8xECH6!%=F=h*K*XP z$FR98;l6%*W*RMSw>Ym`5^R_^p*eVMJp&g6)u_Ko+|~M{s5k%p9hGt1{darvxZGaM z3G8{O>uGi((Sk^Dvvtz2|DxJ%R1Bv8TK(fXEk|jrB(cbI>UcRi!bf1Ec(e#FwH%Rh zL%X=z`S9!0N=C}y8kh8B&1`y>bUoRUT2^I2?=o8Cc2|GcrAly4%|CF=20eW>P2_Ml z!o}emzad<{0|%*deL^2A-6PFKcADEN`5~N6Fdb+mSq`+XkOuYs2&d_yVtgvM?@b>cIhngDBYL0ekVyEAbmFQzwZ3m_;v}Yc- zcTvk>mq>_$$+Vz!sw<2wz9-ob>(6Ik-BvD*A(8%WgBM?&I(8qaz6-7I&J~vIQHwW% zmP6F2_lFJ~14Au+<^WrP3HiJmNhyZl$D9DHC5vhl#IJW$-L?}`Ns~>{Vk2qX5S4{b z7Wxq=Gv7#yphSF5oAgyi`glMN48kar#+>7lo#8rh>Ku&nzS5TP06T|q1fqWo>m&Ed z^Rd|(WZiGTm4sw5*Ah0HGef9l0abzt)nQ}nytBV?HB2|OpU2DjYTj(~r@#d=Zj+V` zYdOhkgWeSfa|)73-s8y#XKt%cOa>UKpW#Q3VU*lSUmr=XzPf&MlpfEW7Eqp^UaBspH+T>kO9KtTk?UGmF14eR&F62IzD;;pn3#Z-^|VA-L4DA}ieSUEZK|%$38XTtR0w7#JtG;AL!} z99{>v+@UcR&u$#*22_j^+-2kfM!*y%y_#M?l&|j(3kEPw9uLb0Dz%fYVe}N~z6LH4 zO6f*&Xs2iH zJ@@g6i%_aS3V%}>=WejhbEFgYn*&W#GRiWBW)#JX8}ohAzJ)i*i%49SIT$ z4q0C8VT=q>W@2b*GH!8HFi%9N`zG9g zvtimHvf#$ppnf7DXrrFCXsU49>|{|e)BELE_HK_fj)zmuTv0IPM)GvUBe(l6$LZ`Z zWf=!teeZDvoIVn}3F7KauWShNE+!KRY%MzY>UVi6wMOA`r1VsS2Q}UnMPnzH-1wR_ zeK<;N#zzrkRE;XRFxI0@FQyQvB(N}MCOM!_Gys-QTz&?{>0X`b$#-KNZ!Un64ISN%se8M#~Oj!Wv z2JH4ltKRqWyke4cOp3QWy1Dy39teS!5t^k$VUjaEg?4goYImh$5!(6PH{k(Fh~HV| zQ+2SX{8UK6ZE3O^F0BP$>}K8vkH@80m1>8QOV&zfR;+%dd#iy(zAlubvG2OeK09=p znGqY;-R>mcfUEI%y>EDoG}QFCTEnW%ZP|Idc0IYGTKZ!*v}Nhj*?>CHOYuWxvoLx= zmvv4?=QWI_YLTF_Nm{LPX2Rx8QPp)1qe99f_wU^sjeMzJvC#rl6Y^ISS)FnPjo}J| zk&Nb=NoS}4%`m==;iN#_!Fm_Q!ev|ulB_`FW}@ut!;zzB&k0satikx{C)7M&-QM1I2ikS|dv%}Q4ggntpCBk$vewX{ zLN)x|Dq=7IZjZQ_8g&dQiF(bose%TQP3;G%vvZ9-j`L;n(a%$HuI!QB{ri4xxs#j* zYflUkij|ZlNsV)*t5BbDU@hfUscom187cds6C~GRX4fq>^1?Iwgctho{24m^i*nOh zVrs}kpv&5#r@?TH%=8k(-NEqdSNvQ7Y@p*i&W&r$fCtsYnc(c&VKi7uHf6Ig^XLHR zDtP*8Iu)85Z*z#$5YwPD8Kz{?f)_|vam`gHESk4^^Y3?=?w8_NqX}SNeKtLjBAsB! z4?J_TDB^&P@e-`DpZE-)CWVnVS{)~}GqWjv`cK-n;~bk=*fJFTYiFMsIbR< zMb{wDSvveiSwSb|N6Y1GV(u3c)7oqO#)*QF-qN7ur!{K>QaFg7G@Um{C8i;L2o%7- z>f0IcB2J#gDt6VVw%!*=FR~kub=cf-V?zeu>8$q^iy8%ob@Y|g&*|I)v+Jp2MDYqm zWC8~U%dww%zA+vtUhR=rJ8t)M7XDT-L+(MDrZLwiCfODO%N;6@j9rRZE9w|DlC*=m z2B~6{raeiY-UF>=Tv8~j=0ay_f#+4)Ntz2*123{2eS7{Bk{L$lVy5UsYDNgX^n+U5 zR?azkAo`h_xfkPN6j%kgO9@WUgLz=JmJ(yNKWxpwsii98hIq)00u6^aoQg$ zHoi>0D^521I&wz=lD)Gd4~hGvEm<6(4FJ?3*1&Rk*s8_tri(5}bN!RsJ3I3cO2wsi z?JSe_O;-}G2RFeLPZ{OdRC&^JNYo-D2&yq{AvO((bRfn%RHQU=2GXqjcPC>C4 z+OXN}hEu^5F#&fs#HKCrA#)Ra$LVLqIcp4dg!X5%I8zr&dj$jrDOlwRhxFMqotp1F zfq73o$$6ZgOxAfAQguTzF37hsi!Fg4YUybgEjRxN8Cwd9f@#jeKrj9X2>1hW$zTe|>5l;IKQM^bX}qQSN3PO;U|^K> zA2}xw#M>Nw?}Px)wLG@ zY277y;US`r)YZspSXuj3)^Y$2G|RVeE?Jv5{5(1>BRWc;j=N}ZdHF`Xma>{&dWA~N z>id8P(@^*08}{4>Ef*gswQHSGOZegOU<^}$p%3|$4aVX>pXl9JtssR{w*RFisL03f z6RO*r7q-EDF@|BiJf&C=LD?5hX=$R!xEC~2shLPHQX+lEzZ9>nyfZzgs?e}7O90<1 z4Jbe=S>X;b6JN!91%R9E;^kR6~ zRH^@g`v<5`2;{QO%;3x+d~&x#Hv&g1oBc{#G0yFcgrd+F8i1)fV~_gD9By}2?Z)1I zQD_gBvYEnaH7PJ@Te)wv+Th61bzSA;7C7g)^}TKg&tC|!N|-%=>k9wNEY!@ieW5k6 zf|K8)4twvf*A>k$p7QYV!Moi#C7}1x-m%$W@hKjb^W}SjS+?ArorXPn^<5mrK^Cw9 zSTxTm8Oec;c>agU8FlNh5P=D$Pdax=C!|eFcWQ<3NZNp~aAPdnkH$8?o4iB&n$D@G z`88v7!s+E$r$&t%k`+$yHLP#_;-JU=TG`uck-=~Sk9fD(%?-aYh6mJV2!unNVqVp6 zOL(*NTw`ltLWDCvn|MUdB=zgNcyHcyXwM?bYy)%WPIOpf<1a% zy!0w)Gr7C7r@h+NSW&(pjUX4G=C|qGZNK=nhB1A*Q9)5nGL=SiDZ7y$0B7=Zpn~hZ z=;NH-(-Xvk-4lfra%>d(wZgJ>~wrs}VYIb&$P@)tkO4-e=L;M@Y{)j1STWF{_5+lG3Dc5C~e{gnSf zL?x-p7OoQKoTFH28f-XgGnbH0Hno;LLAgR|aIcRyc5~Fi-MTtgHx?OG z>ho%L2q`Kh+nac|!5Ef<3(j1{-Lt@2Y|5ukA3f%hvpvUh6zWfr2n zuR8B-m$QPUxw(x7xY>_)nA+7GQ{k7P92G8^bu_`Yd}|G@J4KFR){{Tt0(@*7$K-H@ zZT;2Xr)JB@ur@R32h&Z(wRznFOp#&_rK(n4R6urtBmOs@>7!ESC;5 zBOWp>eVl}JO#TD}6hEk#XCtlr_Y^0J=J;ADk+m3^0-xk&B9Tfh1FZbylW@1JwN#Zg zJoB>e*ol9#=(6BtQh4EfkC`NV>><<0jSdYzrtN5IfBCIu*`CJ8^< zE2?rCbmj5CsrS<8wEQvS$LaW6R@0(~ifgb+r{XFACWT(^=-U|fQJ5Thb)DupYD%{g z-OQL=eHT+^-QsgUx%7*r+VjBebx+ghMXOKAm`r{3SuFqhK!sg45Eaac^6-;TF?yd6 zYSBIw0saYccY<0kAzoc6Y<$47nBC?(ezo6g{aEbOPY~>_>D3Lhh%+Xhd1m%pGY`F_ zH`6libGR4gs>2`GF20M9LyxR^+;TXgt$cn-+K8BteKOCt^+l6QW;ypqEO1^^`DSk3 zRgHabPVcji&Qgnjumj~tT*vN;JL{P}zAH~u6~w6LjbUQ|ZP>-1mcWeHa6T6J)+ z(&3+~Y6Vx$gobNaj+w!t|ineslti)VlT20!3g1*vLRCBrJRGN7)nyhO_T`|N~ zO;wzQ+%Re@3>8<%&lW3Xg-MG@_Fi3+z1PBwAjzKl4Nc;K=^t}uq1{vEob(W4RRqdX zA)Z_ZXr@X)3r+f&cA0gr=i^uD0(?CrvHcuv;rjx*L`I5ikG6ZG=zH3+sPOduw4|2tw44PMNQBD-=!fyd(f9xrnlP%dylTagm=VuU+}RQ5zmwL! z{3KZPY7t6^4vR1|#6#j;NWpe!4m1r8OJX{c1MG!TP>elz4QnG6L!`foWf^%LBF7w=&O}~mi^!)z%WJ6tz@6sOCq7pS;qfag^9Z^7qbjg`YDhW*# zU}cB6n5LFzvu5>=ld$I~hZwRMmfwyf4+j>PQ2v5JdIQ?_ougD7Ep9X|F;9m;iYHz28N$M9s^^bE=Wi6fF@!bcb)Dw>y1?w~lU?oLQD#LZ z<6)4WpsDM~Rlc!-v?@8I>vd5Iswn6{;AiiZM+hpzr^3%OG%jfRsInHz!-PR_4_81+ z)}kqr2}#O>MB)p)drPKvTu3r2h?cCQGi&ZRsm6xL3TQ?}EiXb`(JO_czs(+~P$s3D z-q&Y8Y>t@uQlg`K@tgfrrryn4HKhsn_ebAQJc4eq$rgCqRQ5Ec^iq>KIr$Vol;%q} zFwj+NRwnbavzdZD?mAFgz8s;gW%;8|iy@9MesX_owU^jw*=z1E*F)6Y(>({LI>eIa z8mR+3UQxLr=G_dD9vfMvxU-3KB=K#-h5lzlu$pPCkUOEB&I7bF32fD1TsLAkLc?V7 zOHY8iPH;tsX4>(vno0TqOuxHwGc-jjZ9ir$-LJP_5qih&n0+yD59L5D8eq%eGL*`3 zjnA!#yNBc-*q}|f`y$d#Qf8Wz@M%@{Wql-YUj{xWa6#tsyh=Andf;zY90RNdZE7<6 ztSdF*8gD*Y*PVn^i8r6)FPv+BW~2ZN7g{Z6h~*{2o(FJt)d{f!Wu-0>=>&ZL0{SPY zBP>FSxnANu+xp|+QwtAk&5Uh~x~cr!mqC8y-It>=bbOwV4;okT`G$%U3a-82<9O(5 zk(1$b=t#d?E*jNc?KsgsD)ykDhu%k9!wGMG4l{fVHzQIxVGT(@7r}6Qdu%uzYRYNIbo2enQr=97vF>}6owAt zGGII`cODm0ZY7k>)eG1wowjCL@O;EcKRMJgQL?D|5y1vIyv{FD*M^upQeqnNZ|H)X zD6N)X0i-ZeY^?N6%4U#Fv0qL<>~ z^-ELjt`XkLoFNHkt*IKzik~1+`coOPH)_NQo2lR(0dHGiRrZ5U`){2s*q3CEB_NsSXfPh8!9%;OzsvA_rPbWl8(Qj@iLP^?ZAq8^c2)bk zF%RC0CzfO8^-01a^~naux8}fLFq{}@r&3BKdBdU)v~71x#sfQH04XwCX>sfCCjUS9 zhFy)?4)i#DHmz##eJc5@PplhYl(We1t(20I5@wo1wpc4wUHR_&@rc{!gR}lOg7&MK z|K9PtJbqV)|LN9X)baKHO8N8GIx|c(hNn$Y%Ks$Po%3??JGE0?bz%GQzhh0(cUslk z4CSIE8V`OuI&hP=chm&*-|g)n7SAMwANWo%q5q(rdHS(eXn6Az!N0X^8~+J%8Sq~@ z5wV%{%APM@e^4cNYjip1$#TD5Ow&;|z4zGo8)~eio0Dpo-}@4;MlrR1Q~Y9jx(P

}vua}E5kd+X5le(&*ky(75oDq?~;^P?Q64EI|Uo8@;-nc-K z@8V*m1TDd1=7pr|vLuPQ?{ZFSR(e6Z4h4PIw=mFfi)Zr~&;>$<^H(XG#vwoXSA?m# zHKwH|rNf3t;+z%m0^JNlZh*_pagDv5T`^U;dCmAtL+-qE&C;Hk#om*&w5k3KH&QDC z`>Csh5|RI7K0~GgCheyj-=*czt}C2;zwV+M3*hK}89N(`04CDF6Vp zAOd`cN|xl`u&mDVi?D~RcP%h|R$!babq_nqxw-mf3s&!#5nX<0mU83a3m~Saib!gu z(Y;Md7eYci6+fbC0GIv>u<)k4WQyh&uvNvRYRHv#R5lys#Y{rr08%B5T%Y;jazn(l&KWF*)IEB-tBh!tEN9~u zuWond8vn`C?aI$1fan!UXuQ#hhKT4MEu*?3%n&^-J*L7Y{o5WjpM#?GqO@f$`cNmI zvg{MzwE+0|aaVKORg(KSbtmqjMxPqhE5bZo3tjC?yI!v9PbDMxl{Uj3i!V}eJwBi52&v9Z6bV0us?(#d`UN7JNY_~c zKkWSbKnM4G)sk-&gAbC0PQH>OPvTq|j1mELtZ$4z9rVuU8A5Vo4S-7J2esajf5XvM zDn^?-l%=MwDT?mBylwyAs_K6)-Yy(wG&u2%nwcgm;f9Qjlyd^b-8pCGqiZ0zn**We z3`J0vsp+hGW(Be6 z8OcyF(OJ2O;sC`?7m}PGRyk|gFXcj9>hlJyTu* z?WFohFqjXI!U&(>V^zlb%DbXhk2KiTFd)HxGNRsWVvJj^T?Sl|&S;k&&~9l_6aMU-AM zp>u%q`d-c<_#8U4Khd|J**_aNHz|cBpr*#La&(%T@siPn*lbhjZY7HlbTfS+80+}0 z)oA|?Je5QtU-&v;y+aoqGiKP}(~7GAe0>{S4JUa}_ZR`@3g?%v(=LJZk*7*H4if(5KgIrM z0Ji?=!J&T|$z{b`Lbv{AMh8>AC;GnVy7%eskGit=GZ)s#+a`>(3`;tupRaF6JEW`J zuQisBeX@kQxc&FWKUfAkFt3g&ka!`olGGGOl?CWndyqa%YK5m4{bKG1;HfaPjmn8fPYW*PvfY``_sU7 zT;1}w$MCj%`twop;-8i@kzcM!;);H>0etPRx|iZ3$k2t}_WEKj?^Dh|k4wt&8ifQ4-hVsR2Y#x6eL*H6{}KX;HTzA zcofapOl4{M0=`Fuv z%Q&x?p2I+)^ zb1~lvYV2hEUk_*v4h9vbAdvPC6Vip!hPJsxz++k3`$*k6&qE*^0!ABbp2w0$^Y%6iJi8uO|`LAB>cSTRzo0Q6q!6*VUjj@7&;F zI8lH`n-AG^M;F=-w-z4l2%9rDxuzf<-&uM2&Kbo=jAkBcmQC|>MQp@hf2F61juKl6 z^A2HLxknLZgZ07OCkvp>pXrq@>ljj$bN$ z;=MQjJ1|sIMRvr=$a5TnfC9KO#mDzOu$28x5;^YBdzg#$U(_h4t0c55+NA~Ng?6uo z`*-DrqEFR7<==Sk%K_lffb|U~;Va~DvD5d~!R!)k3xnM3L!~#5(cR#1^I>{e9|^4i z;e|qmkhX&GjH4%GP*;Lbz$jR_v(tDvFRbs-bld(gwfmPB-#VvS2)8%L@oBR6)VY`* zV~G5?xynamuh{bb&hxw0e9o4V%O6(n+YE5XX42en8@@|$Z(B(F@_1cSL)nM&h5KM& zJ(CR1*ZD4SJTB}5eE&Zix%80g5WMwc*p!sW&Q+F+UWcDw(}(; zEzZJ4Gw_N91Zcny*etE{hv?!nOvKD_wLIScyb2;#7$1h;>-*hyO^UTE zZe(p-1Q-DB^#QQD>9l73Vrp3Vl+U+{!IyeU9y%-%}G*ZsJ9`cGUcC?KWgH zpNFw%1<>xZeOPYPNk^C79Y^CstzMqqIDd&PS6XaqGRteV3!#4wlpnFu8CZz2$#|5U zv~)}=@dD_8>gE5<+Wvauuk5m0u%ZmNoic!(>X)D;ecP!02;lo|jYle?W|}?Q+i4=w zM){b0)OrjyYbw(=?K~Xj`Qrf55Qn2`zK#8 z&#M|<>j{f;(!XibAhxBbJjwp9y;s&+pY>T2#b=XFyx)q>UT7l~+z^6BzRJj?uzN*O}r^|~P zxo;dGZ&Kpl!?X|QgcpN-XM0-GOBygl=XKMp1FX+gp@OGZZ_1WRqpoK88z2XE@(#XW zA+ISFgtr>2;_okTWE-cLlYJGrvlo>d1ZD%`Ax}lH9LbHC>VNZWf1DeRMTTG~5SW z7uL6cu@sFa&Ks%d$`w7&drRU^$<;|IgfU}h1{BjF^Qa{V@ybQ-5Z+kU?WOz_pW+hR zknYw7QzcVdfgnGpk5_`p^|JQ+Mw)UGH#t`U8=H#!)VqKTaIG{#m*nqZ`n-HzHsf}< zj#4py8O;nI!bfxZ624rarz25+;ww*EiQfE&gXGIsjIOMJ8z3p&Z$YMj|>b;j7?TFt^qD~Bg6p$?=q&6nCWsr>mkO7z{? zV!YCTXtlv*MXzg{Q37uZ3J*k#%%>6gGSR}#+sdkWd2-etD0@8Cy_#p5Ud-w`-ozKQ zrq+Px7%<4X!On$lq`kFg!xPb|sSi__Aosz1iypFVBAE zsoM)%Cw}Dt^Db+fzRvwJ))FOF{_)mw%NO5`$ozeCy_J=o86)b&5orn%;Hr={3&Qhh z5=%G#qMaqNnQZgS9$f&XC*DKd%>d?T6t#u<%^o}#M}vhQTX$ye6zZ5(D=P-JZ0-Gm zdkTZzwdlEgX;@k9PB_@1XUL;``bGn`toJL=C;#RAVhk=(3=ujNBdnnT*2+M;o9Clh zlHz%+_zTHUv)cOPO5i=KTFh6TZF9F2QfUk>#V(`NR*(<;S1YAI3tAqUY`E2OTB0W) zYDCX_U3=G8H5$R3w?Cy0>iATfuCs4+^L8-JiN9*SzsbQo;fH*bB{_7O+uk67L#%~A z{>pP`rJlsMR$Aa=@G}+Sy$qivPY}ajP(}O+5 z)ge3gf`9*o_Fg+|#yUdiz^Wi_u%kGSzG@ra@+c{xC9!&B;9c%^SbDb;U&GkBBg@@i zc`}Mavt=g4_t(drMwCAjy`Frs*bdqET1r^uUFLb70E4n_Z-qcDHEhRSTk$mqR*cafhspk`@ep^2eEqJaUM-QIXjo*qG5KR&G}2%IuTcCA53&DI!Nt z-qBm;#W!7Z1eN#*v8^Bszig{EWcaq!?$c%EQ~mK!JO14(t%)3y*8avZ0ucw>yOM0t zc^X!)jQ^yZ+9LV8=k{*dO`}g&eqg|_WyCRn?)p2D(LM*qyw6{Ib{FM=pc~_Rh~Zh& z)(4pq*4cNBL*v{?PhtZ1%h2r8fG_FQ2Sn$p3Y^;-Vq&6f?8AJ2@vW!=wbZ2B zeqVr}2~px|b>>a)hXaF?jC=wOil^GO>%g`jhZNYgOIer2oS!g{zRQQg;t@ovUfYAB zI#6U4BGN5x=bn^|9z=IXmP@iII@~>e`5b!O$uaVR*$H=MgCQ7UEAsd=S8)v{5u?I) z#QenbS%b$-71B>;w1GVEpZta!a^Ex$jT;^Et?Hha^ewf7EAPJ4bZ8e4i)A+)f$Cel z*7Z6z;W3}EWANq$0>)AFZguu!IDB#p&-Bo-3fgyD-`)sn9EO??j=bHlm+|Nz zM{oX@35z3Nd8T^LZ*t`(VxN8ksTHwq>a?{h$*3yjoj53tdXp=Q(kh$0 z`C(7NC+?C2XeUD8qbu9jnnoTI5OJ-Gc+j>u)Zz8|9J(z(O<_SHJ@pKkIa?9d+_($c z3niArRT_6R-VbJA*xeEGof-fg+KVQjpVv`_R%$d#ZrZyk&6LP~xA9fegslK)Xz6RC$z$VA?{QM2I{|}-+|Co1-C<;G| z`(U#~Nb}c|Ii-`vDY*EVkIzzr(o|7)Z9GZ5xHLjv?3x7ZbiI#lHYH@6%od&vK2#7i z2)Hm)J%4vuxA=f8aQOY6U4o%XncA6VVQTn{0)Vh`KT(GU$qsnefe4N~7RVF|XZakG zXK!~ByE-@@Jan>toUQ^jn6~(iy)B7a>od{&=w83=^wFm|_?KFN_hP<3I2ce!t~B@3 zBsKgt%<<)(Hn#WJbQL+u%%rg~GKy4GdPT_Rh=i%+xkQ;O2nL}-+fZg_Krr_!&#B=2 zuRI#2s~)R5cSQy}qF%3a89}MIbQ~t<;^~}pizjHFmjnid-g;_XKI!cbL zy1dsrtbXX{l1&TUUzoJJ@mS_OfniA=@15C;aA=-6WuMX`I&0Bmm9qRvk4^O1#!ZG_ zKO%l93$}xLjv_4dX)#!qpiOv2^JBf}oBrV`(02uVZMWJz2%p)V^j<1)%w>cJp2CiQ zJhiWRUV6K-d{g0iV8>~3KTnwiV@Gp5BZF|CtyG(e=wCWwT9}I9@#JKhw-=w62yBME znxiC8exzYOZ!H2?PnYPFAI7AkQoKU4)()q06IK5osn37V%>VsTe~jF=r1>dOULdks z?#0+h<1-$fkQBlWI455sR4s2O+GVResC7omfivs4g3P2TccnNkK%T9+VJ7cZ!7{w! zWsrdCjr{vL6my)1J~PWVd)D_+F}d>Gy?teguG#%GH*s-p`Hfg}A)Ze$HjB4v?cw-j zx$)-1Kv9M9hj9XKt=m9K``Vs)#}7!KvL)@HKzIuGR*Y8x4ksA}dnTR8@HCSKZow;r z_)xlz?{>0^ToP=SOW1r>AEl6!@GfHs`l@z}*hQMZv6ypM2F5 zDZPnC&5I~UF$|`(#ihdg^dpY<7Ha*9IVshZQkPq;7`AJIsfD#yBi1#?{qOmy;K2LJ zl3uP?vt-jh4cig5;!OL{=`9Sq41i1rPwl>>DPL*45ggIf2h$B>lH z*09Sm_dw3EP8K~qDWP4IS69ya@Jhzr;@<2;GcyZ?b8{hyww(sM8Zi1q(ZMNX)f3|d z6}tx}qGzSl01|z`Y6VNpVzN?btTMS8av*|3_N^_P-vf6i>*k9| zZ40Jx(NyT?EQGJJz%$~TY-zI?&1(wMgbgpOr6M{V4$8thrp#*mF_|zIUV>EYYT$%&8!${4#pAc)%}P$^CxM*a}q)7uMhd ztK){cGzAh?IQU@Qx)ZTfvpn{OX;aV*w>ScgGZnWt6{f?cPf=xuoTEXhB_T;J6_3ml zZURNOjbJgxWGp4(W}udu3f)vkfk3ZeG~kB(t(uh!voJ*z?eNgfwoGxSp;}dw&7gcf zF7lL#EnPI*`QqFF8A#R97 zT>9!A2{ZG|jj&(Xn8#yG+i7c#ET@pg7h8g%z%25SOHgQC90%f3^T@h6xODT3%L*n% zUbq(6ShHzOv{y^Ti4Em3gB^cAZqehUAf~Pm=EazQ3|B zZqkiO;wRSTtp{9Fp)(9OLYH+uT$KiyV}vS)Oa3w0Wvi(6Al|Azv7e?FF@IL>h1n_ZS4 zQ!zKgsJJEw*cTn)_WRn#5BnyC0wO@_VhsMH zb;2;etR{j(rXp#7(yvDRsj`pu+lTL$v`HtP<>XK=4rF-U=txvp!Ju0LG=;RrkExN) z^ZY|~wcl_M0@L;ti1YRWmv-&?fJ7;1t%92YBCj@xG7TMQoO&fze>%F)g&iLeAlD>8 zWKRbHsWw40yV%(38krILT)oxk0;Uqfzms31VA?4MQA2r?{`_D9*mmMUIkh>1 zu4X&s4`mW#QyhnG4L#H`#sU)zNpp+yKsOU(E%7oUksRR)>amqtF``M(>sG2qf~%V~ z^9frv@wlR*hdzw!g?=;EzBYLtWkaZnd!6GVRoO0$b7V=w^ACHvVHNQyifx{Hsz67W z@ZwI^iyyqEfBxpq|F-bYPAmUihyORe%6EVKeJC*79nN_h6~_PTH)j1EqCeZPTy^&h zrh92}*cF>Ume9`MSN29+-F9MK{2pX9+CC_jP?uU|!V_otiMkz=zv{8f|EpAQ#8Tb! zk}m`I^`HIt&o}&yRRYq_Z>0;^%Dwj=9|7JC#f&X0Q|B`N@EfxP# z@uvMN&!2C$cntoIefW(j-}vwGjamP>=*NErQzrlGPy3a^1N_h5^~a2Fz(RQiZZ6C` zXK~p~@6lAjtfER)O`syyV6dblQz&%Er8CPD&w{#&-b~)~M^~2N>qwxD&#GW9xM(T% zi`;y%%Vk0+z=mn<+Jlg^-j;r0CP5|=0+!2eci50Nz(Eb zW){+yx`;Rea~5pLO!o`T_X~l+#u|5W4L#lQ^yBG7*F=8^jOj_IEkjXuSY!}Qs1-v( z4$y~!e0>r>HwMNVdm{*oM~)as`UY<-6dLmzGUCIYmb*HRb(p1?4uodV1qy7c*x*YP z#WDh0SU-J@WGOT^l#iE4w*)aFlgiL_W?iX=N{D_p`;$qg5U?{4VWlfcs`A4MsAbZ8 zN9NEk3$H&9Y_W_jV~5BKQb(`U&^ns1Scg#~ZJO0E-wA=kjn#+y(IE|GqOKm?j3vj^ zF|0Hx^Y$Mq@ONFmk>`IiAJ2yFav8i%)|960u$|M%m0;{U+R#;Om-l2Lbe)xdc`kM-6plR-({dL9~a>g_~HeqzG$H!~i( z>UeFf4~7Nhumpvy6811QatQd@i6$=Q{(HQIXwwB&Jdlh`NtZ~Jt~4e%eadHvwae*N zf_l7?7B9{nXvRTZH+z?xZEd!)qmn|4?smfv1D1Z9e9ntzeHy=SI=a8QQzO41oP*FT zi(T-jw1i2dn6)Fd?)qQ$)iDT+*E{yqS+sV}1 zJiLCt{#{z|FO&ZK%>Mst7$P32QrCXk52ww_8y6-D(blcp=y3aMf$;M2z~8OYy|)#h zgW)HwbBu@SNZ}>8^5G72ROsQ^!9=%Meu>Eq^Z#Q#l!x~T))j9f8?*n1C z?)e{Ldpe<85l~*cdk(e3WNKbH*%I=RQ7z#o=_RvNIJp&PG{rL>JbO0uyS(3HE=bZ? zm5T6rMi4DZ#{S?JfY${-?=rLEwJ*)V<=?;d>YcoseMK$6W`zTW+VkfO^u0yg*OJ-0m`_kt<5`EeCRxYV8dqJmkb68s?Kki3hbn0>i9jtxzL7~i6xYAJ;@FV#fm-FfpYbMY z{=&g;+M~2Z0eB{6FR2aOun~0oP*QhyRplEY-tRC51En0Sbz7M7y+cG>v~injn^*#U<#0jfNyV zY3Xiz>;f(6jg;PvYvdG;Bmwi2KEeV%1XyzL_BY|@%zdUlpr@9%cA964!fj3Ss;V~F zkY4E3=L@X^$EAowIo-aOY=Jk8w~WOrbk8@I$~$pOrBio+GP_-`-i!x}!cHLhC?(N9 zfL9%fIKw=xfX`wYIX-k8Y_9&obu*Y`gc;OS@K$3}SW1JzP+5w-@{~NE-dR#7(po3% z-Q3uV!3AHF7%}Eqd$4+EM@w}!ua2pDOoHK^Cw+aMD9hGn!*)-6dq?V)V-z5E4e|sw zA;xFSE7f-$$k(nsd0{lFdb{nm9 zEH)ljgY$8+fStnraLIx@K89z0f2^qbR_w+cwGBA}dbUavGFM*3BPvv0%6ET6G4;kX zG@sL#0B5W4-W?W5?TvcjxnXTM{W&`Q{mtQW{SS4PkTnRoBHsDrSDvOU(=**7qjATv z;)hL!nV$uK0LzzO96m+rWBlqf8`S%7vLJ!V?SYGX@+w>E%Vn%Rla!n=T6}tF5u2*i ztG|=Ak~UwUtzvuRN`Hul9k*;o@(`E$Z;%<7=KNUld?V7<2qNUj&n&@)K>Z9spByc% z$1qDWgXq0tK&wOGhoPsN=}$9>Nwu8B6mrX;AY?01<8tW)H*)+@vmLhowDZW}!2nsjk2OfFP*AeW$WUB>Yd6g1J?fGx4=a^Qk*r+E8CIQY2Zp!+Ze5f0+RyRofbd1@aWzH|R`v=6{rs@d8Eu6?FMtW5B_w%C`48U>jJStg0WZ#h@k98MvLU4-_3=dDoXQ*vZX zPe8zUy8nosQfJnzPwafgdN*e)GbAf3rai&uoIw6=!UrL2QQr)E2AQ1+DrbmbLQ>Ys z43mVPVX9B3%N3@4mez;X5iG-&M=?G*LcyA6x>qNn-g*|qB`%^io@iBfCUPp_c}1_E zrGX6-<_dYw+U(?BIr~vt28NiqpOpgBcVuQY%PMf>A{|MQ#?u)cQKvsf_(UeMyzBXQ z2iYB48!nGUn1o>gcGErV^=s<>)gny|v>Iq?Sz=c~QIPNBm+k%mR*^{Bjo=U0suCo+ zxzx?b7n{X#au24#+>1QpW29w1uvpw-QVFzw+YL9K0UxDSi?09Npb=Mag;^WeHPCr53%3ka`_VEy_HW`(yL- z;zplR7U9O?!faw^Etvob2T?{N-JBZloc=LwS*Jur(LDON{FQWT9ogX7T~N(|_?b?? zRoBjqAv(uLbz-9XN|?Z!{?7c6CESLV6>SV~%awYYq)Hsx@Y_-55BF2x!qnRCAp8WH zTpr6i3l!al@vm@K>}3v+7#bQox33XDerKYWV#`M?Pc)KAF`{f$YpO>6_;?sW*y`5} zkv8c~yeo|+F1Jz;dCu&r$~MQ29alYzHjb>{$Fzm=?=NpwSiOBDd)8{lgG(|*;|uY1 zNTn`~O$|k9OEQkA3ofC2#xb%CSeK!S7=~15Jv>7m|6)iAMj3d%Man*8pT@5u$5DjR zbUpz@9%x%^$p-!#^T(|-uAAUM+2(pvt=24ri2n;cz9$1*fM5Rqr0};-X29GE1m`x0w!PRKg0pPvOErqNuYnnYf<0}uI zI_DW}&HB*QvYEr6^%KXWR2MkF!txOPA-~wzSah##PxeVtlS;Tlm=Wo$QG8F4eS@33 z{s_ID&Mc(~32BJ0if8V02CY}ML^fo69@<-|jId&N~|kWRdFNQhs}#$E&YzHkc6Q^SV2JNmSGjnYEd(PfEo|$&@1t*`dyX z{aaN%P0^jEj~4Q>UnoKc*p~4QbiXAG-7MQk$K6_iG90MZ=6|!b#b%;?zkIDl7vA%B z&2!r82+F!`m)&InF&|d{%JW0GoD#-`%fZ}slE&tr#jgWNL3y^NA^J`VwOPZ*+Emo= zTc-|klI)i2XQvw{f3I=bA)gdbyjhqbKb-<6Q>DGx3meIWkb&^V243 zQPPDR(u0Unc_U)EyHA@_yoV}wJ9Kxa9w(CtmaU|8s*-^lnbC?FBFdkW|FzGx@-|H< z2x0`*o+kSuhyyk_Fi3T|}9gg@oqtZ7WGq0b>x@CMyEu^R00-`3dx?;^e2DUyU zQ#T+_S^F3An*opUH%g&%;%aFXy0Inn8k$22irWL&0FqF|Djg@c%y1g#76HM**qX0A z3Rk`KG`hlSz9?6msIt*tSxQreZ?|Z$4h1RJ>J=)Hq&WOGR6uEi@ZFgB0sFD1%yO747&a*lB-f+#=mYf}I?=lx<^laonsZV_QT-w3&hLXCh?k@uqN%^V5!G1w?RHt3*C5=Ccu2prMb{_ z?@*ar%lzqtV2Cmcm4+$pOy`!H7IQltZ#uH-4mDTk%6RG&^{8|kPTiMhwN3{Wvkcm& z@*xpP_lABN{%HF~&-g|z zbl4yk4^O`ryP!__aS>SRl3!MmAq=G98;RvXz&!f!P?G<0VVhY@Bd@UIPmilQa)9Az zo0%KahIxR@q=-Rc95S!)lhlB4--W{Nn&YXx1;BFmQx6hQ5}4TL>A_k^1a~j_(O)O} zYVaU>cRkE>K_};p6~y72=cQR1>QqPnrQxy1`vzsECf;*EJ50jzRUEk3I+`i-vz{vj5{!+#{W@PGK`Jd?qRh??+=h1`?34siiJkZ&|{Ucq*) z6O{sVy&NJd-K<(8n4wXts={QzBDk@F?DoU7U0+kdxN{}Z&wDo@+IiaC4P7hb3LoL; zjCeq|GCFFm#Z?MCj*B&)dcbQvg%v7yV8)hYkUpCxwtMVGR@KiN=i__o(}fFHW8@{( z@>@BJ($m{}_}1YgP!%R00PKN0c(tR1IuIajPDofGQeI{Im*eI5Lw%M_a_TIS&h z3`$G<(R)a3!SiMXRCl2{FAuEjUf-EHvH*m95PfX;WKy+;Dp*qV&?CGlT~@y~Wz@=6 z$dVXKw-2E9_p6*bk0}&=+O_MBjdxR((y~vnZpEZF=xD7NDJ5u!!jbDhhC2~+nHERD zaeIo?ssbz{Ggf!7k7SB(ryZ!l6I|Xy*&JG89Uz<}r5^-vSRT+M=K0eb_qv!7`YOE! zho+F81c@0HI1*2%mzm*S7mwToYrL8Bn2^CAJa<=5;efE&!dS1$wW+B!bu4 zCDv6s;rSnnlQE>~)}0jh*RH9;CZFD24G#6Jpj1NK?lBH9+sUcCZxeAu%Jc5BL?|%g zHw3tZi+(?PrMzN~M-|n9jxEjUkV*3e)$u)dOMl2R*Ivs1XS(6ABI-!D4dC z1z{Fez`^D_@w`bV2=2wft3ifOTgZ54B35{=c`PVQ?z=mMK6YQaqXp^s*wRSWqZ1++#Nl?uu5?=bI6H)o0?$xUTbHG- zQt!+TUxf<~{7LJI`(4&}Hgp&*h6K$@+}b&~K({ z(BcucV!hN0oBx(D@^7Ele}1ZMa0z6E348RtW|p-QmXRmTJCKU$Z$h zDmmeM5~*c6gPP$9xoV_m%k(G19%BuV5EQ<(-pa4v|JTsugGipu5nE1Ylp(Xyh$B5=(IH^nrcD(W4$M zPQBdBgshs$tVv6?rR=>Bs-MFW%)oJu((z?*NV%rXZzWpmd0w=3Iygxswy&X}w9EXM zMmOLe!7Kb|oTP8GTe@M=0zAGWTap99%hP{~|c(^J@2R zTtLNNu8;mI;=(TeIrJy52LD;Uh5b;rzQG10V}F)Ian{|vmHeIXU$2Pik7I?$e&+k@ z^@o2B{mH9AP>PKug-r7N*_-M6B8AxQ38<etk!Uyl7EBWa#?J9+EW<1HL2n;n7pN|(30==2u?Lb6hoUp( zMEtu_k3&gUV%hW>D03u^+ByDmu`8fJZO}eKBXI&O>Puk3DsMFdMl#!=2E&>6I78^O z2@{Fhk1Jp;av<0ZSX{brYa9&{x@q=uA=cYQXTnjkrG$pPxf&yy&jC^A zVYUA<@tUmi6Qd_|##V2e;Z~(5N7hLBuv5t8EYG}z;{~L$%oBKbKYQon5ujiOpV%gq z0_A4J{Cr6-CgoFd;&!fHP=A@1O@r~XZ?-zb<9x(}p8NgUu zb$(TwoY(NwNuu(qxF9*GdYzi}0}hh!?c)(CuGT+~lzHT; z2@#k=Wv~&%2w9>nsIvD?kH*bk!s#yZ(pU%Cyd2Scs;{a{N`@=SRk@9t>@z{1TOjsb z=h5#cjpf#Mnc>-X1G}Ys15x5aDw01vds~PnJgCU1^+b6Wp|}Ncjqy;&J!YWI;rDQ# zlVH(c3lBw6#5I`@)&S>hp|U|VtpqpFiLzTukU-+doa~j-n2Q+{l#rWs9ZG@HqF~AQ z3ASO?TDyJ0wf6GOJ>-4=r?)_gY+k#xNz6bB!q&C!lav?2c|$6}_yxli`@UVh94}-q z2;I>A0=iG*gK^iA^wvN~gX>YK7GZ+PrEvgxStF;l z^r7qR(((AYS?zmL_FRl6s}<*%scY=Q5vZyS6rR81sxjqeAL3Ag4g;~H1Z2hkwsrik zj~ut5%NswwE=Z#)h)ZM9bORV%x%9FOJK{!R&#j(?v&E5;S1dIQsSY@3VM>^akPwrO z4IseC1_KeLRTbj$O5sW3Z;1pjwAibM)({7WmZtUT(}N|xgC4dPCp!y}e1wn}HD`g@ z0y+J+VH?#_Bk=T>+)i;|@aIPKj&I0T)uvzqk(eZX(v;E44r!2PSX~KvxOgLjjYX7` zjO{8YUu0MuXkB;TSi}?YLczh2<0-kPk-6vP^xfDC!|{guGQ&i=+3+IrV@A_e(LN(H zqr>kxt`Kl%hKAO=8vvA8FDm7eA6|$9tM{M=O6$9*frj|HkDt`GJqvF?OQL&eV7$}< z_HHy8jCB;*7YLp(1z%4ox|~P%$Vjm%k7!LFOx?qtcq6S+EygU5hC{EN1fhnTP=9fd z7b}>(l@!ucAwF#B`qLN;&`+Y#)x%71-X0=sXmt};x1qwnp0CyYg-=7I%r!LIk8fT> zMA5S5tS4bZisXvPF>3HA56uG>Qc|@F>32FmKhii4^+(=m< zl7fQ(sh;nwY?gl?!ejl|L3uL6)v!Hd?C}pHeIpu09~$JS z>|w@?_#P0kBQNa9QjU&u(<9$4FzjeUzq5Z8c@;9R5-&^gr|X(WS6B##Scg$NWTC z?P#-|CzxH4jQo=mxxYcqTXdo_%sfHpgm0nuBI2`!;WYEv27kXMzvwk@(C@TP&Mg#ix!)}I z`|4XkSF9Iq}0g(^SJ#)Wv@0@%8|NH)P?){(V&YS1i?|ye#duOk**IIkM@4@iF zJHQWyx(2#{BS!#$BZoi0!5BaXaPs(xlP8X!JbB{esZ%FUpZ($N*)wO(a$dN2{s(SO z9v*H^E-qdHk*mCXLi}7@KT7>5Bq}BWlDK?T`nt6EbrEq1@$W#6oH}*t?CG-{XU}qo z^K$Wu{~uomuK?_)kM^7Z96KTiILdzH82gcfCIJ88B#$0D@_oL)U&oFgJ#q5%sqeM$ z9{@*=9XoRD)ag^FjvhOC5^xG|EC)h8YvO)xrMQzLT)U>?{n1rOGi?mU!k{It}76)n*G27%WNUdFkn z8Ry3iaV&vc$0RN2{yZ$?KwjFXg|*;ovhX$*BaotzyW%i$$xbxKLNMw|Qpk*f#Vo`& z%G4|9Q>x$Klsk%ER>z2PG}d&T_YIC)ZOGNn%BMUk1Wo0osO4a5MIBgIqHWXBJE|Yk zI(_H(3Utuob0g`=>w3l9jtL5sA{aI|w#9rdrJZk1X!gR4#&&F~6H@Nkj>mKj2 zf}5)wNZ6`&&0JU53oC0K&4VKI@7p@GvruP8tD5qu`FR<1AW$q8rMhF{G(gKZDaD{U zS+V4({sRmjIsLq?1Hk%%@R#yAhxW{iH&^CI+h5){aOXCMiTGUgp+5X5Rb8vk2*LZg zqV26B-`M46CAw+6*b&d8Lf!U6)t|lZZl>!U00NdwmxCx<@VfMuT0u6)& zSsyRU;a?AdC7DR|w9D?Krl(iFS_sk}>4>afOO#}j`na$>a3h}(x$VUJq5kxf_;=Ji zvSU6u!y;MIw{1|>$HIKM$|SRn3f;Duo>y4*LTw7z7Nd$MYp)e=7Mt(D z*9(mdJN-C%@?quTDQWZe9FmRc(33;G+D;lt?p(}?oQP|#{vOs#!<)e^o!Dz?58hy0 zl2PXJzfHl$X~YbJX*$($JyexX65q8qq-u5sBbwruVVQG}S!u~XhjJ;zXa*Q6zGTZlnB*5s5`(F@aB%xVf;o6Xw)6u^ zh=U^kChdo}m$&4E1TT!tn*)SzkJ7k^(tu;=$l>GsA(yy$v&G)974^0OG$6% zk01Ergf`-4+nWs`zyeILl@1P83`h%*ogFCWJUGM4a$|ZgJSpt0HX5C;el!I?d;jXa z^+La#u!It$5?@wVp8J~zB92e-&R`_g(`t5N$-yeJYe-B>&R;=s^V6#I`lF1}=vYt{ zNk7Y0A1^C`&YNVXY8w?*F4}4(Tz4q)@n&4vur)h1DRmJyH%Gf8nYYZF|ERC^J(4h* z7+}h=FWy_wh5lfW7a8Au%xO+5ePc(ZmPST;XieW!GaNyp?hV>=Wg+>#5eqIzKECEs z@k}LnDBZ~W!IYx*#`L}%@Z7&<1w_JrHVhP@QMTkVS;S;)0er3tB#3P*7u={7R-cp+4?Vt0pbLfI`Nk5-p zt1k|hwYW|179!qm@2(yP_O84cTwGkf3Wc8c%()jUOUKAtVT=pmX5Pr@TDO)u%gjbn zjrY~WHc1b{fJSD5ndwFGho!P^w{t7f(=^paD{Mt{`cRyOq^@QISHIDs4V%Ixh!=SR z++x5%j5C^C&{U{CO{Fxe+-;xlCwa6-qK-~)q z9=e=0HD$<1(xOleoNm=hWw5u?Iilse`&12{Pm{|B`&mT^#8@IIkt%fio{8Hy?e%q* zF~isrg4iy7lbhbQq%4hyLdgY3eEU$G_I`;fMBE=GRiOG!;V0U~d`8rrJTyT0?+PI1 zWKLv*%n#%;MZ^$~D|NMB4isp+sLJ(8o}-9X<7I>&UtX&_*Q3x9UDoZB^(55VlTMCQ z8B)05;KL&PTq>=wL{!11N$ENLb) z{?%``To>AbP>q4&y0b|+;m$Gw{88qWv~6NKSv%t8nmfwqew|5SJsyNjdE; z1en~V)pr>~@9|(ew}zC$Zz7~_w>7;jWU-k-xX&#KVUruCeBY&1%u*@joa7#sjQ8>~n17VJ!N0jxQ)*{qT9e4L87oR$ zzS#Kbtvi;J0<0nkJp|MdYQp9T0c;gG-PJ&M*A@VV+cx~irm+0=13b1 zU52S<(EL#?*l1WY)z-w{$m;>azoUVA( z6zZbMna5z{J4&P2YJ#*judsSOE$S*?813ZK+O zJI+7zrR%jsV{*$1g<0g{Zgfb?HpuBYTd}oUEfOxdqmZ+Ji-~qtb ztchu?IKVafJ~AVo(@)W`sUwyrZD0HT4^QGcBNBZ?e>wobd<(K#uOy?mUk*(2b2Wv(4C(UD`DIwiFX%_7jo*Ykx*>qs)(W@;+!=42PFx|VBPkd zD2KtS^v9b71_IggAdl55V(Lk!B&aOqEj!2RdhrZT`w%*rxjA4HiFic!unWw!qTjd; zBMzEQUPUR!{ybmV7C}HjOvIn!+{_pr>!8Mfh$LdfK8fs*WKn_V(9zV@#Y{ zpz*-F9dm+Hax9(p*@QwN!g{F?!bezl*RZ02n#-*zWQ>jXB`6l>C?GZv#*}n+S=*J+ zHcO9$&c}4AsDj95RWqJhJwn?8(_+CsRR$9$! zex?V3+_eeiZ{<28%du=OrX^6v+PRUjzsIX<{f?Yh)~*nBufQrN&i0vLnT=&jv=h^N z-XuLLj@daHuGf8cuAH~^y`JG-{3lshr}+UOw^>(^cRdF+2 zK^-YirfQ8{1J_ zDZ+&}G+IJ#`rwEQ9!_0^yedkj{Is5oicBsDWFL3{(6K$L)qVhIsd(Tn(*N09#tQEV zI{=iB`^hGm*_%GmyoH%1^ZC`G?j`bfe#sp%Jly=2Oww=|ig2`I1QJWFiz}jK<|NtH zIsAGu5Xr#qPH-ov{8DYrxa1y6aw+d$H*{&1dUg*cgdT81B4O<89bJ)K>r|#aJx?3B;Bc^xkv=r>b0}y`>_XjNextM>F%Ab69D31PQgg+VK zPe%A3&j@=wfG=11@kk?RUy-3_D3j0dcCRbYcV`--wHkQo)n)x?kc4n9gLOV8 zs!xcQj#5tN=wt2=w+FVre1DTTGLf58*Y;)UcAg`?@X97W@9EoO@tOm`6m&`S`VQdu zt-rzWshPBAm`Y|8Mihy>`}9czrlwBMpcKV3;WcNi$}uPok{GRStz=iB((n~%Rd~4f zZ>!~CJ!??!8@FEjU`g}Fg>&++~TP;;IrFcli%M27L!moAk zP$OD-x|J_Dl;a9LhO|X;wXQmjn#!Cx5m7YizIELuhvEn`8y7@O%3t=u^H$o1lSgY+ z%-m3k7|@`c(#q;QCPBzrYg(x?KX_wvjzhfW&}$=D^QJTF(PlNwGqx_B{YT%DI8D5u zD2I``hNzm#-32wx_))jj`a7;`N~uv*B$**D9ot3 z7VK_~UX(5=Gn#;wWxIGD`kNyphBq68nM1A}@?DXQwP%ZWoGVSSL}IbKpN$O^sn+#e z%g>Px4jHZV4BL|bEYZH>K4yrC5l>ZV=`k<=e80AffTVoArzQ5_V$9VSHT!p9qa6c$ zH|I>o{5_UwPWF^Cp;*f5H(?7@E&+^KXtfl?<5h}X-zJ(~O$;eXpr*zt$*#S~#S5nR z=G&YPo?$~yYpZ^adNRGJ|e~T745@yfbN{~Iniz@-=d(#!4ndlJwe1oy*23> zb*QlEoJVu^xm=3e+vx)SyL*X?nfB|+(wbK~-Hq2MBTNHQ*`Z(ky}K1zf3QdqjLDI+ z=P>(-4wcCeoK#y0)~}hV8O{VLdE^xLFHxed)!_TP!GoH4L$a0rKse7-{B#ZQf3 z!Nuabm5E5a$FuvZEHq`TB;W#Xd0;oEDxV*d{MqLY$U|8(#`jZ0TTVvo;IidUIMmvB z-d0!|%1q4k{Kqb~EyNzzTOnEzr?utJ1SUPXzn#Cjypzsf)5%m0XZL>dv_kA`)7NVf znH?+T0tGKhR%~SYY{rB+i*?R3++AzT^PeV}Jo{wp^Q0;$V8pkKC0&FGW9W~+6y9V^ z&_2Bu2!pg3hNu=N)>f_?NfQ@B%b~^#O-xXg1z}Zx8Vynf@=z;1<&fh$H?vYg{8EOB z2=NIAfOoQM`hyS$4mO{$0&(io$gubFz=AnS$+t@@PZF!agXS_U#g8`ZYkDHW7c5wn zn@Bk-rSXaO%}IAue8|ZWv>36-z-(zM^4@F$@U|;cOnWl#Ube$_4D-s6wQz-{l)AjM zyod$=!mB}<95B*YayRWsPfhxYW`#T{Ck3Q=_IUKB<`j*Sp)1yNAR6ZR5W@(v)=h*WWkF+3H zfy`)LDn(T)z8wiO;=80QG(4}k+zMK5#QWOA&vZFCj7mn0P6-Igo#u`;sKGm+Zf8Z` zmf?^{uwS*kpMm`pXY*9$@u(3=8c3zm;!J_yqWmR(!{%twyI68gC8D8v&6taNO{B)Gz_}l zfPMF6^CB3{K;^U;RyU!7M+3}ZRUnKr>n;f^iDz#1r!QR0>fYYqVDo)7BbAC&7$6XU zSonaBDR`9Je%>U;e+y#MH;0mJe3T{mLD^}Cs|G>E+ErsOt$^fATwe;YXyqgfP8++9k-#Kh8Kij$^n=ERwVxwr=$?(2n~Ok;{fpJYZ*$?iW0^IkvWb1 zxb@v6vgkPpDU=A|OQjau*9@;CGIDij^BxE@t2&HD3y{RpaS|_=ozFJB7`k)GV%y`8ee{O7Oe; z?((F|iuAsx5fnJj`@M_V zW-C##34x^W$O{pEvI6qsg9TTdX#_RYi?Xp3q?*B9ch5d4Np=|TQ_ADzcMD=$W*d)Z zu0>mj|AO1{%(DtFN+xhFjwC1xIhxm2nrL*S`D_SPJufYL4$&)9R%&`)9GHwUtg)%_ zB8PpWeZGo(;=J;4kdF5Ma>;LoV4NH1-S83eF8z4?HlI7No9+ZsqcG!jq>N2k>Bu{>DSwl-Q)JVd;CmtQs~5kb;_VIq zFYWdhd=3MCsLQ-Y&D-sq0geUuOGSPaH-^*3L83r*W~I%Y8YuYyaMI?`mzJt`$kB&R z*4Fl+IEgZKH->V+q+>-Z(zF7)hYIOx?nuwj))Q!14eoU2!M!nBxNqg(_10&sGtntl zEoHYF=o<+;cH0Np?T%-J&&kn}#*<%HkE*3-*CFBKqU+W-(L0o3>TU@HpZ4w1a>K{H z=X*%$I$Uf}E-9NF?wVGRpg-+qBb1nq&Pzjm(DEqJGH|J}lVnJbj@5z?%q6qa1mD(- z$D5TA+y#wr%X@b1>55&}9@rjtf%-cZ1WpC<)w&7T+1*+@C3aR25QxK$~}jWMu_UB-eZPSXY}yo+mgcynud zR<;CM_AE7&atKA5D{ly=cNF(U?DY7@J6;V;7OTB6@8#(YQp=$;#yrTw`vpeGhLp3q zQc<}?9G522VisK(g>1=LSkjpb0OyV*y!BTyLNca&{j4BHzR25EO6r#(0dg|3T@(^> zWL{?#X$uy=vvj31zRpHkzAKLz!3A{{MlX*m8*WSmeR?(A6Le|htlROyrKNdFhOG=E zC718TP@>OHxJh)(|skeH9ssW$+z#1zJ3rr z<2|NcIxQ;OX^2VG{+XI9SBY<{*b7_b`bvn1XM)hx*{>(o8rO#W)*jAxAypT_7u5ueL8Y`%%GS%^H z;qtXQ7H`M#La2h=Ym;4n%@pj@)yn~#i0&7eF51U#pJY1d-7NOqj4&KxArVE%juvA| z&+HfTW^JU6$w0Hy+a@V|PMRka{d|A8sWzZ&*s>tGC4@(!o97Wz`|G~HxF@qgLkVta zQF#^SYN=AFSE;pfl1LyBWda91rG@?6R5t9GPo?KtdT>F*x;OelfTiHd+%Z>AM31cN();0OvEs0N zH%%ah2ImLDGc%Lf+W16ukp4vwZ+sCpCB;?QwDZ!`5*CF6faP|FN!QB|^T310oc_dQX3Vc%XfDL>>D_&o`tAG-ie_>@r{((*m&yu*WaFMqZ* zHmUqQal6qN=TzgunEdoT*|0jvpDI10>Hj(Edy>*~eRkw81N|i#eE3APBe2`1cIJCB zUvmE64_a~pBL*sMt7)?of~f(H{`wW0ZAGnNS4nO)S>ixM`>+S4ygdM5PX3q}D?b$X z>4JW@t!dNo8-G6ixe|X;LEI;HBfcyI(m#*oO;|9k`Flcy&BFFj^Eg?6p@3xg`!e0gy7{sg}bNmFVj9j3M3Powa5dQuok|MHHcM7cSVIOkc{0_>C#c^ zk0}zs2u^&{>f4-IG_@8@ZDwv<+f?h07FzEX8zYULQ!tYhskR>8TyUdW;4=F~75y9u;*2vnj;rs4M?` z{yKvv+Wl!72TnA@zCoES^y5{TbyMfEvJWV~^1(hMsk_pW)7hky(;q)8w#`jasntD* zsm4(kwzTKr8 z0Ks4X9vS@8%8ny5nb2)n)ZQgL@&F*AT=Ajp?7O2c{xIG#bAZD1j^m{MFDX@3q|X*C zmZIE%{a~B0`Fm_NZ$>n-CHMe96c8x*g^qVmFKP}r0Lo=u=cfO~W zY$QJdPWN-~n}*g?&dr_rm;1E-d9f6=K;e{tG8G3*?FZ}>?|?Ctx^+3tG$HU^YJ2l2 zs5({pxh79cUWn~%KOJZt{>jFoKc)oiJ|&N5ls&48oiYGI*$|4H>*Z)Hc+&@+ekadj zU>vk;ET+xT%w@s8nTVLMUcyK&IVIq$(+;E8TzguDargq0M6$9er&i@GF*Zyo<-^^= zk$R`m5qra+bc^jZn_JNxX$wN>;Z%Fn3gn$%d8D=!s=fZnn0>9)JT)VQkHAm(LCD-9 zRUO$&%!Qk|NW@P&-SjU<+ewN`E@3jyN8@xGG;3chpK_-{;*fBMBaxN~j@g2|69mTykA&mQ?fno?J%e+$xNX4= zP9n)9;OFxxz4o-`4t3 z&Dg@CsZffe_~5u0xTN?rB_0K;K24+By#~UM$6Wbl?iFJ-fa-%I`}ziuH-SjA${)i% zfbUhoW%|OHxp%LM)%RlQB$}VdxiM1#v!XkA23D|b1;R^hZ61eT zW-{cgz7QV>@5rulRq_w`Ac53mh)LY$tOmZ4u^!~odvZ!aRRMznG0L4#;z>v}L$*<6 zQEel>#YCCoCE)ZQ<=5|A`+t9DLzgF%F>*6NpwTDrS|NO|E^Yu@%4k8tv)c5udir!c z9u?lEg^2D-MW53ADiFpLg~@*2yxZP|mablXQDdZ7elI2+*Dm1UxC3o6c-@7Vinrw- zX}2mHfopY*yf9Qg08C70=#}<_UQq$}XMIJXj)UhRGp{Cf)MXmp-Xk&Sp$afo;ZieA zT7AYn;xQ6|G;!=wnTaSgaIj1e4pGhd4 zqnvYc!JZ^zmoHxLa22lyaXjHQ30)UAM0#?+iFQEmc^Ijwu~1L zfZs_=5lDYuncBI(Qz22NSBPwj_O#xN(|8-y#9GNOHbU7{1rMrK#yBXJ8)X}YYuimm z4ebNrM~9pXY;vQijJMShT@(+)>|fl?6ql>+Ms2>$I9s(FeC~OwOqnEszeLVlPu)C4 zGdQy~oPY?paNpPL4plHAy|_p>-vo?RS23387|oY<3SV+hV4auKbk=3kqb!xkwGM~| z>aI9t6EE9?pcf4wELYi!MidngMW6w*hWO=$>Hv~3*gtQgwIIe+?fh>}W0k8Ir!KI` zJ!&Rdo{bk=uW~;n&0^*)RY$K<1UX$A*QJH-T_2dG-Y2k15 znqn`DCbOUB-cP>r5Qfn*U8>b`a}z+}Y7=TzDlSN5tXN^6DBm-&3!cx?%VSpw)gwyL zlH|3~n-ELk=SCealgR#3O458sT7{ldr+Imd+}y1_3*jAShtoOW_g!OpF9UW!)V0s& zkmhR!<4sP|KabN`R9e(UJPa?NdI2NZFVWq!Sd+NoJhWm%>=dc83TU*LuX^v6@2?DE z*iD1CH&ic^rDvMeg0;Gi?}$2FV6WPU9#|0_wq4^!~h;s3dW(tyzPg62Z5s!qpEWUrz z`juko|7fn%tkbEOZtW&@pEoZ6?>{$KI4NzX*_@&!sB_nTJbM7#QWN8CV}dcWj^tv9L_ z#`l+HUu)sMq;5#_-BL&0B+}OHO8iV}5WOoiX$!`aCasM?d8=o-m0djrSgQKj0WezK z9L3+7f5f5ldfZHe_c#4Ts&t*{sxQ)i$K%7pD^1}DaB#Ub;mSPuB&|4I!#l%I9dN-% zU2R-f7jRVkk1X(yEP>NP@5Pt{$_2h1MQ(t2PVeqCEqo1(c zedF?P&q<@fBioON5oOzVbQKG!T~AqYa+t=#RyCmP0pQ~}s&*9`s@PQ)8_T8Iqoq`E z;(kIUj=FZ{O(Y4W9bmwaOUoMj?V%^jM!2vltFW1W$t+(_OWD;t8wt-eJ%Lu%X359@ z9HP~SUS6Yz`#n<0q@8JJA+M)#KTcy0EHF_KGd*?Lybo{XUkr$*fflQj) zgq|On5)})%)P6Mo8LDm5fj1fh0?`N@k3M$u^BK`wqWf=3dvxW3tn>>RmNq0hlPHQ> zMS{O&3$tEg>%W3W&d+?tjm0m%{i%2Cy(>fE7OSU|f3sNeDt{rvYP-NtJSk~MNkru& z@l(m5wITv>cp@mRiLS6%8sD_E#Z^Fb#d0XG17}_&Q^$Tw-v2rhmZvXR1MgO2TvB20 ztIp)_gxLD{Nq6CR+g+V0(W=v7AYr$m)59zzJCoLU8L}p0=q_^%={2gZ*b>mF31mE(gW#N65y$qnI9L7&&OHca`mW2 z-mV!)3B2gX|t;U|kb&We(3vY1Oz+okLEwp7y8@ z%&%-+4Y~hd>+ppvVU}`8sN^OB*~nLM*QaWydOf_%vdw2RL_|cUYjAW-Q|&`Wvs<5l z25(+ntV_)CR&CuNmcdGhvL<7u-UA{h+pDH5d`n1xfM{5VQQ{o0`rzfHX0qF6eYVmn z24xQnekM`i&!kub6+<R43W%IH)6wIEs|pD5g<&NVwOLGRJ!x?sNJttG$kov(!zGE9LrdDn&S_fJnaj3wbJxP?P*dy_3<;~8SDG|% zf2mqB1~D0)a^G%qb$p21^ufS7n7~y#VpYl@QA~-TM*G(FZK5D|Kg(KhWUBA8byAa? z7@ihyDbO`fzNquaNv?8U`@t^;Mu7zdB|~a42~De8>e&W@mLH$C_sS(<1?}6?1Ri$* zo_y(gFuXrw@=8^ z%KXzM-oJM})W#FPu!Yp)nlqGSG=&4QCgE*|X+BBr*B>VB5B z_%5%qy$>xV-|k;fImR#{(y63Jgex!0%nJ+a^jyw$(P|Lip4yC58=Lc;I6*ygRm%5y znebttgu$?hU#n$uiLn&8T&wi zC0_4r=BIbCENeI6S-3;zr54kbF(`_%p(}oBYZJ}N^V1;+LM)4q;{`!;s^!T`^Q|TZ z)?m|yH^wz1iZ)grd^!5_Z_Bt_o_`SSQvF$xW8Vfn{$SrX({OCQ`}U~yDl;W_&TKJ1 zKSZMbiLh;aY$@-2WsOB-*b=(HRQ6>+tdn13g7lJdb!j9&-a8Dvuy5j%Gb$bBw>Hmg zm+=Zy{-Ie48U`#{%h=(g#q&H;(29OW3UTeg_Z?sbU1<`V(4msN+>jZZ19BL8O6SNNm z&G#5 zZ{}IpCP%p&k^E<1>Q4SqGHwL8iKdt;FmQ_Fp(@HmK3cL}P+D}P|L0cyEyB8iXdV^G zQ>0t;U`x0!U4mv<{lvuYYA}k4%fmaVZT-|}sgZiqX%u5!%Er_!;>ve}w$Hc)-19A^ z4nQ?fGDrx@A^pOa=6O+nzI;c)Nu%K}&%H!e?&%wzSBMO{<}iJ;Cb96-PvS?VXKQ03 zPOjUwHa&iEx;%DUOPH@cz@J_IhppI8ZzUhFqAcE8i|1tqUB?QgI|hgTT5t~X0Ta5WzJ9afaUna2TkZ?|K$lr-P?3=gSBCQx&cv~6yD z^QWo`i68yIkvbegJl*6Q$%JAfnci{gdxGp^j46CDnD@a` zTR-rTK7?4lMtXPbyG@I!0L^h3dtH}IcqjQ;CvzGsdA#nzwiMIAbXDBAH)#=T~b|4z*!g0ydDUfyq? z17DrE@o)P4XAl1`|KZ70x8()m%Qw3Q%J2>;g*S??uU&jax3|-Ze5M*FCnI#Ki50U?o3CK3ncS zu@|Gi0yQo&L-j$0i^{@j-W>DjS_g}4Lf+hxc}@xtglwYa{d_Cfa*oEJ587>U(;j{p1B8wmJXn11-TuzTe>2P--4&;k|dVt<$@5snNO! z{cZ42LzDlfL(eWQl9sA6)!pRR-gVXLxB@yG|GL9Wm^x8@?>UvGIMdgkq0;3Y>%jJ( zq4gh8cN~$))LBzr7ZJ?)W_H!P{`vnttc(AvD82D_oss|1;D59^Alkd}-*&$xUc&#* zRtSD`+T0MXM5! zYKL!JgbmYzSJ~!Q2*6J38vcwjf(W!J?^#cD|Fdj-ja+13&4m}-@oDxA#qIPuNhl-w zTnfnT8wcSDg)IgP_Be zsPm&B{9R z0W$=Vt0%kHtyT@6;v6~ zn4X8LF={cn_5Rh#zvtxq2hq!SFn<*!c!|GmV!!?_NB$pFvj1ob%?AMZTd(DxcW(YQ z^CkAE!~tM{{CQ}zA|Cf(OJaX(Uq+B5Zs#gYFEaqDm5R9~D_B($R)RJ2>Yc5^KAudj zhUgEvX7ILA<1&(6Cl?kd*KyH%z24h#7LGn1F0^LHpH%EXEN69p*m&p94ob@@k%?= ztaujke4?5#KE~CfTB~|hJ2$B1HFm6mPoJOZavuc4cm5; zAHF!WliBffL1Ks64&xEQ@ zL~+ZIB$saGFZaoU#PVqV8lZf$%1^8wE3d<^>PZ3eYaNu z@$7#wW|hC&t4D7^zT2`t{>8>UbCc`4E&JPd6ZeM5aqI84?4Mo!T(SQKf-wV`&i(Y6 zZ|7?CWE+l!(Pb^5=N@Fq2`aL)UypbVVE;W->vyUB|5f|@ua7$W7rXx-WhwEd)2+f+ zSe*mW4vvOFK;+K56qHJp5iBDe_?qxS$_Mr~zOa1;m9oRXUo8rqL$$b3a zAfm`BTytG6mT_meP)-5-F8W^!%c}Qb*i=-l`5HQjo zn;J=>KYG6pSPspt+BC!yepCd9sLQCi^Fmj%$pNS9B zU5dl8{K{38cnzsca^(_YI4S>)r%yn22}+aBTo7N$=8$MSC5fR>Jy?G26tgOxlm#ul ze9Y>oU~Wa>Ynu$vWM(01(Y4s8+-b>!o%L#>pJ4kLeCVkrA;7wAEEC@)TL+}kO9E%< zC8z*1)jG!j{_TS@G4E-btoEKytDyAprqaE`6Mt*WGY{h^qfWy_sngpw-R;wOn;y|vsfX(#rAy02zcuNSErmwMWhp)R#M7D=-~&kx9uWmlb* zJ-=OvlE`qhU?J7~BtR>TW(mO-RN!+1PuyfiG&%}XFS!q!aX6oT*H>}tLa2}c4D@r+ zjop;l5XXC<$j2EXqr&lFJ6Bj2qzw(JLEq)eM z1y=S8S<%9Yprt+di8IB%}Y~x{Iu*H^r5mG9U6+g2{;J(`PVU6&~ z+BFEG<7lx}8d>d0f|`IKcEzAO5=vG?Zdu`qGIOa%XSp=3P7lQ(SCCycQOp zGtTx?glmuYcN9H`!uU(!2Dccf{4V_61;O(3Zb(@YUvG4V##bZDr=p_hwUI%SrKLep zDC~SZH1=eEx1(2xUE*?-O0_uM)IfGgj8kqzbtZpQEl48MJY_JLWB|IdI4A;k;~6;g zQOg}hoN{OYp<8dAohytyu|spNKgNm~CC|84MbQNiA87WIsyY zT%71DMS;3NRRk@vnNOkvhZy`YnAlpnPc6a`NetzDU%fcl@^teXb;4ku%_(}pFL^K( z>G$)uJ{;B7Y7^X%?Vu=j{w`#)=?U~+vDsu+F0R}*Nz6&dS4M7r;nDwU@6Dr{%<{ES zyGpB^uo?kD1}zyS0hA#~K(rJBG8>W*ga9RjNJ0=on1X0aDMEly3<)x8fQ%%_5HL)Z z$RJag66QgWFoZ!x21T)M?mDN>{Z^mTXPv&?eb;xt)qlO&``vqIuXoSC{XEZamq=th z_e~5IKAmP6tk|yeXMzo8b3>B_Nu-G8z)33$CBC_6WQ#P>1uCbwg#`T&rOMd9LO zUqjX=1Vv*AZP#IE6kE=-gi8KvcI3Kn#AbYMF)(6ZMzxydLZE z=Q0C4*%-zGe<*MhVL zfwJY9AJO6vGCcgWQ_S(G^mWAGQm*ngyOab;CbIa?gd{g=xcd7U z0U9ul!%Ezh$207?j98_4A-^-p0?&owxysM&ELG2(06gq_GCCrzg7$f#by)UMXq*(9 zrwNawvEKI{QF_xCBp%5*8D?M2sL@F0q784VGWc**`TOYdBX@ZY$L>F(Jj5F4CoF`O z7B&SMu2(1x3E8foezrYNH8Hgd3Ifz74~z7m6ExCv1iO%0L%1+JO`2a!_d0uF0Be@!^8oQ*SM;7);lK5GQ=-68Q$>HVL0NA22FxSZY7e(;ly; z5aD6;OQ4p2hMoQ7hf<6eFqhQ|K}0@y^T0OF;J`qz+SC4^a7x{^`3+T3K@uGn5ndej z0^#|>5230<+s~pq&g3iUmZfcEZNCMK7v9400Sf({NKafSo!>DRKJJ*?dIw zyO5ZV1^Ibum(+$Rb@z^c`y>`salzy^=D~t<0H#Uk(!S;Fe1>C&1JVE1 zW2u)m^0hya$g=u-_0XnzaIL!2uC#k9ZSb#fspquHJp;IP9*+&+vA_~jnU};#vsLyDCL}|&}Gi=-nRBQkc5dOJvolJ z72vqhU%M4jf`tg|@uI9*l6BYQHo3;UbYMbXhQ=zry?#|HDI28=P%R(m)=R-%PbPaG zD9l>4u(z-%&JB_T`b%#`)qv3SrC==DAe6{zKy)@BpX}$<=mr5e{|`VF5h z;aI#xZaB~2&cnUdj)4VsoyLc=yJ>NkT~u7_7PmDfTHMFAbMjLnIp@u&V7VQ<_75d1 z+MB`p8P}#QPO6t-)B%>v`?jE04V`JWoA27?yznFT;F1K7?@Nq~Y_H6bRk!%6$yu)% z%+_#0$d<22KX*!UEzrNBSkzx>3iMPyqhQ_1;vx{y<#t%Zv(}CdTkWyLx0e3Umzo&I zIW(KB2u5lv9(plFLa*kh6elz(C*QAZhZRd{niYQS7Ze0I+MN%cxRGeI%3-)Gba3FeinVbq<9UJxLeaj_ z-;uiM_h|UEu%oS!93d~Pw{*Iu$2MY@*k4ng2e1)q!x{zcG8lx1s0|?pyssM!*`i^_ zKp#3_s+&i7-BZ?S5R~@&9*jt^W7e!qsU^Xe(UF8}920onPGnuK-HBv*=TxGU8KADN zv0fwzJiSJWFQxlS^buK`Cbh>dMo>zQHnZh}aa6v#(>33BOUNjh6eEwF<&8e9$pBL8 z8-4XM{5x~LQc7o7Y@-4KN2STB%H9#VdU!p~$ust}9GI!I4>D8;kjpXZP9|R&gq8i?fvYsl-Sq9a*v+LI#Xw7P<&U{7{22@+0oS51p$(K{`0 z!vVO>5QUSyq(gm=z{IYyv`S$UAj#9H<0@lb)V&RanWI=tvZJ?Gz zGf1JYPHE{_u3NDeD*$)BQ;hUe^&%a&Mg{J_AB9bgi)*qsTmCU#r^Dj+J^cihCJABf zWCSu(-=IE;v7tGG+b4A%`pwuQm&n6rBk46-u?3|oC>p-}0(g)FH4D^ITE3C-=6)Qe ze{*r;_dSh#t{eSbL=bY>kzTqH-rpTnU2pOcdp^TH@uUV?BPXLqheoNnGR4^_6u+r= z4zQko?Hcm3CGpcI2RD9K(Z+B%UD(fdGY@BjmlkDjqOykVB|}rvi4=>%7`U05PIBsC z_JHCKt#YmbcLqq^w$PAKNF5wirLvf#QhY}v_?GWY0Z;T$%F%9xnyKut4@uVa*trWH zqEs%)>~+5~h&#Y_ToUr#&e0#Yky`cj13Ys)#81*I*O>Sj4LnJT&<<}W0_aDS^8@`9 zEt{hJ*yWs9mGhgV(hNF06qjwYU1OKO@(zO|IjS2(hrLc*(H^SdQV%(I8<~r}=3MQW z0C7~ueJ|(RWj+vZMp7aE^B>2?x70n5Fg91lYh3+?Z>sy-OGqMsAN_veYny-`K=ta? zDOkmxH!{+$vy(HrvJpEaC5&-9siA^Hrm0twZkoDQ)}Xg~pomZg_VVEKvvuFKF&3{q zNauRpT)B==$&%=LVU}-@#7H9{)CpvrCFzmyG?IShbz`b|6G+$cDY3aqWP67d$b!l$ z#u@5v+Dp#&e9<;!rQRursHR9zXjg}tP6wO^qrm-DkObg$b)jAP?IU7ag~u?uIY61g zhjgAE8PrkTtg9)FG+L=dIIi}XC~M-^Y-ln43LeCXa04T>WckP7+WVx+k;G27IyJ?m z{KPL8fy!?NqxNnfSC%i_U$%^cXq;xVm&7Ih>ELtt&USto&cdNl%h~Lfy!F!SJCngM z#D#f@$Fs)~xAo|Eo}J6}5E7&qs@b|vJBHoGS;v8C9DB7QtUu>AQ(h)EyaXOcFH?Jh(Eb`x)0fBIiY!~az~ z=D*+8N^Rl)hVcIrcMssNCjL(^4DSlJ$9H~hy87==%|Gpd{HH|!)QJBfDD6 zDcsSenFgrcC8*DtcK~-OccTfOB=FTh7&Vw}Mkq&A!<|B|FoG-ldR)*98Uumxe4gG< z1u+;+MJ2?Qaw(X3PP^;5hr}9!VEwXx^$URdE1DTpC9yxwNkubr(NJKGjO!Q( zQ?M{Rwo?^_et3D*^_Z{ro4BRWZT_%(gN0WmD43o@4=*(Ilg~ACs|r_%n?rOGN#p$#mq;e zz7M#uxpfm?{Ne#pO5LHN=XpupJ$}zF-QiCMUDM=UMQBjz1X|LpyZi8xgfJ8Oe6`(> zJUbvJiTy-A=dsWOMsv38BPCUmv_O^$M7MBU3pD^#K$Y6F~&^GlSmu zU3kd-MKL|1)fRljy8*;iekQ@rNDFb1I%hrKo%HfT4f(VAp>eF;KJQM)rBgnyd0I)t zM+0mAuM4iQUw%;~!!(M5ScuJtevkAIVA0i|Mm21&q&gjU&pz~G6oIgwc>qBjY_T&o z;4W9MQ7Ol4?T)gST$wQtm@nLMSH2mn`W*MVxtE9Wfw`zwKW*z^7P%(N*gMY`7>Omr3^^CeDX-sslC+UsS8K)EE>~Ch8R~^Bt2HSIeYxR z^@1Sg*RWL6u;;v0SJ41c*lUvxv32Gh1!b^{x>-r39#eSCvxUb@?eUO#r1>ZS#v^jQ zp+`z0k@4D1x{J>(PczV1VTAmUMZIQx2m)XGQ~FXW9?-~|h$9X=v?>-K8cvnLR&oHU z+mNfGoVR!n)Y7rygRCJiOKgqqC{>IP^=W|oJQ}6ATDhC9pfdNwx2{z2<1Ho{{7fV% zPKWg`Ko;PazaZ;cBO2HHe#(+k2sCm5oV#|cIaH7%{YVvwQHt^OOa0X)@#fR5%Hzsq z*{;0h1nVyVKz%$-kwdsKdcw)Y&Z=`sr%;mNTJxQyrZwi6Qf|4kGE|wk`IY!f%Js_8 z!B0PxQ0B8(uVg`dBJ=7nAZRimI(!#wNQzShe+-tx_-2rnpmxr@0}u;e8!B62HQe*T zMrIL=Y`7Ii2RS%O3OLLgTUF^DOiw+Bi_~oiCflMxedc&ROC1jo+bz&MDLs>#_)0gB zL8!f$9$1Vh{S`EordoTNeShiZD^pKT(g*^IEY(;&axKCzIY~28b)KDW^lBpc#RVxV zOOk;PIC+}U;&f&6Ip7)E4=6}%xG;C&q8FO1j&*X{+h?k%5#b_l|0p=ddgBf5Ju=8< z;UF_V^aA_xPU04>VBHYx8Kk@ba%bfB=ffOe7W0Hc-bKRq*j^=WVy8HW#1y%HZV*jv4l9QZST9mx`qYz$9Ko}LrPqFDI~ zu!8;9$J!k_N_WZbSvZjFpqQAX&Q~6yNePTBT6pjMB@(?BXYJaw!jwq@~67X0l+{ozE3-XA9q+_Y(ctM^$RF;!@DpgCwNlNX#83*l^USsu%y7-JfGu7FsPwcxyb?M%kpv0 z;1C_9R$)fWrPS(QJG0V@OQ_=083@}GJ_#(4Y$uUP#s#Y4-$Bexr`&v zUinp*q;|oDhY=@E-yo~h1t+H2TFBBkryCSxp|M?{`$?3cXD zynm6`gH*5T@8rXXG_x~CVh~&Whty|ou%>%>Sf#B-hPQMJY%A^YjI7!aRz`1hhVD%^ zwD#QP#cLY1Bux6zbISx^=IJ)xm*uQpyI0=Wlp9i&LMJD6!%9<2_d+MTC#oyl27?F| z;LPE>P}#lR^^YQHI@h?MbtCyXX0w?%oGkE`L9=)a5@P0lFW4DdO(}e`QrLery_wii zII2A7pU8X(ZUcX0(QUs)rFa&$OayWts96BLjG;>N*FRYc$lj&iIP#623)m+SN9#ze zO1BP+h48aw%`|cpwE>AdV)>1_Om=aEn-M1{W0E}4%kOuviupq5>cOsai5wxm^K=T5?b6S;k@5On#8 zi+$~Z#R2+~#&@un%36(GiK`iXRF*~Q2^RNrUyf3}`FP)|qo^7tgn7(KeS5q$G?tZR zuuGxgtq3_l!Ctb;)iF=3nitjxhGWWEodkfRJR-nfNimr@U=c#Dq;EwO9#n>uf=%01 zW>Vc3S}kx36;nh_F0TBXd8n%svzWnrIudj};mKglv3f@*>u^+Jn>w4U;Q4@X=J!1$ z!gn?_fu3D0K{M7hL2H1!;YR;qfH)_j+kQrt)W?kxlM%tf)tZ{pZkt9G)p8^o#Y$u) zDO1DGxLnQ;U)_{>?9rJZNOEm&m6NS_M|kyx*HMaMzjy}sZZdm~-AW*DTRzQ8|4H)v zwUG|Eg6FC7s2pJn?rDzGoXL3qOTfU$+W{1X>Ej@;Pe%hQfTB@ve|We`Z+p_?kSRBP zfYWDLXlD-rY#*3sHs8AiT$u}|m5eE2mZqIQX<+j9_7x-WR2uYbDqP5!eRwP@&7RU$ z4P!?sA*N{xpIjWk?HhV-N3mzhytb0IB>Pzz$+z2EM%8HkPUB>#tm-3yS-Cu9_R$Ns znrsfF`HXYeFAmW}^W=Rr3Mq#TaHTOlk4i+Gq!ScvK z5&R7)-qA+SaXG>%GCmqmV#BXbnszB2>6)5Kc>ks_zRHv`wJ@5Sb|<78Irz?bf2u73 z6&+5En~xV^%Bf>P7#4FHW~cAJno7(Mim=cH=-6B2APda!{_GRI6B;DRS7+Ae*vu`D zz~r$}O0do}rRw)RE&eJ7rS-PflD6OKMnr`-k0EEuY^@WysD(UWTBH1g;c9wntSSkU zd0KjWvmzBgZS<@<%q

IOt^QYRQXYsp7gAz} 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 O()(}k@J{|d`hNl02bP2Y literal 0 HcmV?d00001 diff --git a/verification/automatic-identity-results.json b/verification/automatic-identity-results.json new file mode 100644 index 0000000..52d1754 --- /dev/null +++ b/verification/automatic-identity-results.json @@ -0,0 +1,66 @@ +{ + "status": "pass", + "release": "v0.2.5-preview", + "software_version": 7, + "public_first_install_image_sha256": "14e9161f6148eed114c3662fa3f6a118ec204ad7c9c29bf5a34b33d48c9ae0a6", + "public_image_size": 3997728, + "preset_device_credentials_present": false, + "identity_generation": "32 bytes of kernel-credited /dev/random; domain-separated HMAC-SHA256", + "atomic_initial_commit_and_marker_retirement": true, + "tests": { + "native_updates": 32, + "actual_mips_updates": 32, + "deployment": 12, + "startup_recovery": 7, + "setup_control_and_downloaded_profile": 3, + "publication_profile_leak_guard": 2, + "actual_mips_setup_portal": { + "status": "pass", + "checks": [ + "mobile page and per-boot CSRF", + "initial page shows SDK manual code and exact device Matter QR SVG before Wi-Fi submit", + "captive HTTP probe redirect", + "unauthenticated setup rejected", + "management key export only in setup portal with valid CSRF/origin; absent on LAN and initial page", + "HMAC state and replay rejection", + "DNS A synthesis and AAAA NODATA", + "CSRF/origin/SSID/password/control-byte validation", + "scan start/poll/cached results and open network metadata", + "null origin with valid token and MIME charset accepted; evil origins and duplicate headers rejected", + "same-origin local JavaScript and textContent for network labels", + "Wi-Fi submission confirmation retains same manual code and QR", + "duplicate guard and fixture transition" + ], + "binary_sha256": "37a9b0effb27682a51cb140442f71bbef1e5b0c4ec680575279355892e96083f", + "environment": "actual MIPS ELF under qemu-user; fixture backend; no RF/Flash", + "real_softap_station_and_commissioning": "pending" + }, + "mcu_identity_wait_vm": { + "status": "pass", + "binary_sha256": "e76ed46878219d5e30507f2602d3b55f001eea8d949629b32fd9c392b3c3e7cd", + "identity_missing": true, + "cold_off_confirmed": true, + "startup_identity_wait_and_mcu_only_mode": 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": 8388608 + }, + "browser_management_key_download": "pass; successful file download and visible confirmation" + }, + "production_binaries": { + "ck-updater": "a85aee3c6fd66d01a26b58f6eb408a937aecc5a84bb22c36e81072ead7e926b5", + "ck-updated": "730889a5b7bbb46c2f5ebe9cd851935a5b6769166766952967224e07064ecf03" + }, + "production_matter_sha256": "e76ed46878219d5e30507f2602d3b55f001eea8d949629b32fd9c392b3c3e7cd", + "public_factory_ota_report": "public-factory-ota-results.json", + "original_boa_bootloader_report": "public-factory-boa-results.json", + "physical_device_access": false, + "physical_first_install_tested": false, + "original_hardware_entropy_quality_and_collection_time": "not independently validated", + "github_publication_performed": false, + "public_factory_reproduced_from_public_template_without_private_dump": true +} diff --git a/verification/factory-boa-results.json b/verification/factory-boa-results.json new file mode 100644 index 0000000..98a489d --- /dev/null +++ b/verification/factory-boa-results.json @@ -0,0 +1,44 @@ +{ + "status": "pass", + "method": "original MIPS Boa executed in Unicorn 2.1.4; every external operation stubbed", + "boa_sha256": "175c66efa68eabda38c8ca6990e824ddcc41dfec0219bc739bce7b8ffe0a587a", + "image_sha256": "f9cb04be891a3ea725aa9628cfb4d22e9da37f5826b9fdeaea827c3550d343d9", + "physical_flash_access": false, + "actual_mtd_driver_tested": false, + "factory_boot_tested": false, + "stock_bootbank": 2, + "dual_firmware": true, + "writes": [ + { + "device": "/dev/mtdblock0", + "seek": 65536, + "length": 1179648, + "upload_offset": 260 + }, + { + "device": "/dev/mtdblock1", + "seek": 0, + "length": 2818048, + "upload_offset": 1179924 + } + ], + "kernel_bankmark": "0x8000004d", + "canonical_factory_kernel_sha256": "6c72f554c78d0e71e9dfbd1c1b466eef4700f965b8a21e6958567750be933308", + "preserved_ranges": [ + [ + 0, + 65536 + ], + [ + 4063232, + 8388608 + ] + ], + "journal_erased": true, + "damaged_kernel_rootfs_trailer_rejected": true, + "decompressed_kernel_unchanged": true, + "decompressed_kernel_sha256": "f160b5df1bac550b4703ee030db147c08406e740e3171c04c688f7dddfa14f8f", + "bootloader_sha256": "6bd88f63f7c9b48266d3f512024e188d3b65163b67b0abc54838c0b7eb430e89", + "original_boot_selector_and_checksums_passed": true, + "corrupt_bank1_rootfs_selects_bank2_in_emulator": true +} diff --git a/verification/factory-ota-results.json b/verification/factory-ota-results.json new file mode 100644 index 0000000..5256e47 --- /dev/null +++ b/verification/factory-ota-results.json @@ -0,0 +1,18 @@ +{ + "status": "pass", + "environment": "actual MIPS test updater under qemu-user; temporary regular-file NOR fixture", + "image_sha256": "f9cb04be891a3ea725aa9628cfb4d22e9da37f5826b9fdeaea827c3550d343d9", + "updater_sha256": "9d4e106a0e35a8ac4101d7f7b955d8f01d5b2bcd9bb20934d263341d76f3981a", + "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" + ], + "physical_device_access": false, + "factory_boot_tested": false +} diff --git a/verification/factory-results.json b/verification/factory-results.json new file mode 100644 index 0000000..2fc37e6 --- /dev/null +++ b/verification/factory-results.json @@ -0,0 +1,31 @@ +{ + "status": "pass", + "release": "v0.2.4-preview", + "software_version": 6, + "target_confirmed_by_user": { + "stock_version": "10219-1531", + "active_bank": 2, + "dual_firmware": true + }, + "factory_image_size": 3997728, + "factory_image_sha256": "f9cb04be891a3ea725aa9628cfb4d22e9da37f5826b9fdeaea827c3550d343d9", + "checks": { + "native_update_tests": 25, + "mips_update_tests": 25, + "deployment_tests": 12, + "setup_control_tests": 2, + "original_boa_and_bootloader": "factory-boa-results.json", + "factory_to_next_ota": "factory-ota-results.json", + "public_rootfs_audit": "pass", + "production_mips_build_and_isa_audit": "pass" + }, + "production_binaries": { + "ck-updater": "0bc3580c7833c1068ad664aad505b808f043b89ef25fdfc7ab90a0f05ff255ea", + "ck-updated": "44871971b20c9c6dcbedb223279c48aaddc1599f6192b92199e8fbccb922563b" + }, + "production_matter_sha256": "3463308fe83048ff316ae4bf0041565575610e8cc08bc85c7fbc2d9ea562cbb7", + "physical_factory_install_tested": false, + "device_requests_sent": false, + "private_image_uploaded_to_github": false, + "keys_from_existing_outlet_reused": false +} diff --git a/verification/homekit-factory-v0.2.11-results.json b/verification/homekit-factory-v0.2.11-results.json new file mode 100644 index 0000000..403efe9 --- /dev/null +++ b/verification/homekit-factory-v0.2.11-results.json @@ -0,0 +1,14 @@ +{ + "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": "2d50339436759f3d602c150fb4f1a42f0fdf122fc18e9a4f54f6e6b07d7a0c62", + "patched_sha256": "c5f5484772e80a28421cda71e7e182ac4db527a0b4409e0898da0e16f0ed18ad", + "length": 2164760, + "changed_bytes": 2643 +} diff --git a/verification/homekit-factory-v0.2.9-results.json b/verification/homekit-factory-v0.2.9-results.json new file mode 100644 index 0000000..798db95 --- /dev/null +++ b/verification/homekit-factory-v0.2.9-results.json @@ -0,0 +1,14 @@ +{ + "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": "2d50339436759f3d602c150fb4f1a42f0fdf122fc18e9a4f54f6e6b07d7a0c62", + "patched_sha256": "f27336a5aa672169b88d81e3a215a4a4dea59badf31c56e912aedd43ad870ee6", + "length": 2116412, + "changed_bytes": 2643 +} diff --git a/verification/homekit-led-v0.2.11-results.json b/verification/homekit-led-v0.2.11-results.json new file mode 100644 index 0000000..db37a31 --- /dev/null +++ b/verification/homekit-led-v0.2.11-results.json @@ -0,0 +1,16 @@ +{ + "status": "pass", + "real_outlet": false, + "original_sha256": "2d50339436759f3d602c150fb4f1a42f0fdf122fc18e9a4f54f6e6b07d7a0c62", + "test_payload_sha256": "1a51a42a68b058ac0d81e79804067f27f7d3dffdcc2aaff7ac771617934f8ca2", + "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" + ] +} diff --git a/verification/host-factory-recovery-results.json b/verification/host-factory-recovery-results.json new file mode 100644 index 0000000..33f7e53 --- /dev/null +++ b/verification/host-factory-recovery-results.json @@ -0,0 +1,15 @@ +{ + "status": "pass", + "physical_device_access": false, + "accepted_known_wrapper_marks": 3, + "rejected_code_header_padding_bank2_or_invalid_mark": 11, + "legacy_kernel_sha256": "f081b4e0aafad65b071bcff0e0ca475fe2aa6ead608f5c7afe3a731a378b8ae8", + "factory_kernel_sha256": "6c72f554c78d0e71e9dfbd1c1b466eef4700f965b8a21e6958567750be933308", + "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" + ] +} diff --git a/verification/led-delivery-v0.2.11-results.json b/verification/led-delivery-v0.2.11-results.json new file mode 100644 index 0000000..03db6bf --- /dev/null +++ b/verification/led-delivery-v0.2.11-results.json @@ -0,0 +1,18 @@ +{ + "status": "software_verified_hardware_pending", + "software_version": 13, + "version": "0.2.11-preview", + "signed_ota_sha256": "0f1c58cc8620fa5f0626570957cf2f4a28f05a3dade7e790b7f538b48588f194", + "rootfs_sha256": "6e1597fb8ad7cee75731b714c4ae5ba31845b799224d03a5f285b459267040d2", + "signing_key_unchanged": true, + "native_tests": 55, + "mips_tests": 55, + "deployment_passed": 12, + "diagnostic_skipped": 1, + "mcu_engine_checks": 574, + "mcu_engine_commands": 100, + "real_device_access": false, + "real_outlet_ota_installation": "user will install from GitHub after publication", + "protection": "existing journal, per-device identities, original pairing code and energy retained by upgrade", + "publication_requested": true +} diff --git a/verification/led-imports-v0.2.11.json b/verification/led-imports-v0.2.11.json new file mode 100644 index 0000000..2df413e --- /dev/null +++ b/verification/led-imports-v0.2.11.json @@ -0,0 +1,45 @@ +{ + "format": "commandkit-led-imports-v1", + "original_sha256": "2d50339436759f3d602c150fb4f1a42f0fdf122fc18e9a4f54f6e6b07d7a0c62", + "architecture": "MIPS-I BE o32", + "import_count": 207, + "file_io": [ + "fprintf", + "write", + "fopen", + "fclose", + "read", + "open" + ], + "clock": [ + "sigtimedwait", + "localtime", + "time", + "sem_timedwait", + "gmtime", + "setitimer", + "mq_timedreceive", + "gettimeofday", + "clock_gettime", + "strftime" + ], + "threading": [ + "pthread_setcancelstate", + "pthread_mutex_unlock", + "pthread_cancel", + "pthread_exit", + "pthread_cond_init", + "pthread_self", + "pthread_create", + "pthread_attr_destroy", + "pthread_mutex_destroy", + "pthread_kill", + "pthread_sigmask", + "pthread_mutex_lock", + "pthread_attr_init", + "pthread_mutex_init", + "pthread_attr_setdetachstate", + "pthread_setcanceltype" + ], + "provenance": "rabin2 -I/-ij against pinned public release base; reverse/decompiled/530000/homekitserver/functions" +} diff --git a/verification/led-mcu-vm-v0.2.11-results.json b/verification/led-mcu-vm-v0.2.11-results.json new file mode 100644 index 0000000..745527b --- /dev/null +++ b/verification/led-mcu-vm-v0.2.11-results.json @@ -0,0 +1,15 @@ +{ + "status": "pass", + "binary_sha256": "6912e221c16dca073935bfad570c827812df704f148f5e8286e42139cf5714ce", + "identity_missing": true, + "cold_off_confirmed": true, + "startup_identity_wait_and_mcu_only_mode": 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, + "vm_memory_mib": 64, + "address_space_limit": 8388608 +} diff --git a/verification/led-release-v0.2.11-results.json b/verification/led-release-v0.2.11-results.json new file mode 100644 index 0000000..90d30e4 --- /dev/null +++ b/verification/led-release-v0.2.11-results.json @@ -0,0 +1,51 @@ +{ + "status": "pass", + "version": "0.2.11-preview", + "software_version": 13, + "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" + ], + "rootfs": { + "bytes_used": 2488453, + "checksum_extent": 2488834 + }, + "ota_sha256": "0f1c58cc8620fa5f0626570957cf2f4a28f05a3dade7e790b7f538b48588f194", + "original_homekit_sha256": "2d50339436759f3d602c150fb4f1a42f0fdf122fc18e9a4f54f6e6b07d7a0c62", + "homekit_sha256": "c5f5484772e80a28421cda71e7e182ac4db527a0b4409e0898da0e16f0ed18ad", + "homekit_elf_bytes": 2164760, + "production_sha256": { + "bin/ck-updater": "1442c6010feeb5566ba852f43a1e3513bd3113d2a316b6d0456b9bd7af8ada28", + "bin/ck-updated": "b1bb065bd811525bcd6d4d1c4dcde6ab9bc16d7603d3d5856d387b23b3fef365", + "bin/ck-boot-guard": "37565ba17daec3f26d855b9501d534162f4fea57d3927fa7645dde7990c5d7c4", + "bin/matterd-test": "6912e221c16dca073935bfad570c827812df704f148f5e8286e42139cf5714ce", + "bin/ck-setupd": "5947428dae9c093d99204a44006ad5e33a569b620fc721cd4a211ed35574d49f", + "bin/ck-protocol-start": "c4c480e842af76719eb6906e4c273fe1e6601496daaa2307b9bea8a6cab218a4", + "bin/matter-test-start": "81b58c484ea70d27607b587cf12153d287b79309ebd49d5cc790bc9c4d8d234e", + "bin/ck-ota-start": "a31d0039bd6e7cd601b3e1f57391c570e16accddefc6c69772656e2a005c6d87", + "bin/ck-management-start": "d2291c2a7be779f7e14f46ead4756c96325ee818a7c36b53bd01fd28ee3eab82", + "bin/ck-reset": "6377d813a108e6977069ab4c7e87dff7750b60499c0e100465f8f0c82b403259" + }, + "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" + ], + "rootfs_free_bytes": 198142, + "tests": { + "native_storage_ota_http_admin_protocol": 55, + "mips_storage_ota_http_admin_protocol": 55, + "deployment_and_hap_layout_passed": 12, + "diagnostic_variant_not_shipped_skipped": 1, + "test_log_sha256": { + "led-v0.2.11/full-combined.log": "65a51687f922bf63c5ea456f0de6f78289efb9297327fdc158de12286b9addbf", + "led-v0.2.11/deployment.log": "2928a8994f45082b76d9f57fbd07381271c0388babfb3c20efa4e0df7d9d82e1" + } + } +} diff --git a/verification/led-ui-v0.2.11-results.json b/verification/led-ui-v0.2.11-results.json new file mode 100644 index 0000000..3ccac11 --- /dev/null +++ b/verification/led-ui-v0.2.11-results.json @@ -0,0 +1,15 @@ +{ + "status": "pass", + "environment": "isolated C HTTP/NOR/MCU-state fixtures in desktop browser", + "real_outlet": false, + "checks": [ + "visitor cannot see LED settings", + "login exposes six-mode selector and schedule", + "off mode and 23:00-07:00 schedule saved", + "phone clock calibration acknowledged and night state shown", + "15-second find acknowledged", + "switch Matter to HomeKit; login and LED preferences retained" + ], + "screenshot": "verification/led-ui/homekit-led-panel.png", + "screenshot_sha256": "dc62a42b19eb8118d3f4c1b5a0ee55cc70284a8e17a72a497cfb150cb8454dd3" +} diff --git a/verification/led-ui/homekit-led-panel.png b/verification/led-ui/homekit-led-panel.png new file mode 100644 index 0000000000000000000000000000000000000000..86a988a5cdd254771cb5d6a96a7151d475f7a8b1 GIT binary patch literal 43046 zcmeFZcU+Unx;P%hUT^^eN|808CKTyXEf9J!q|s5D5}I_RS{9MeHb6kS(n%mlNe~cF z>7hyQs6gmQkq*L_v%Bs&_uRdA_x$eO@A-aypMCR3=ACEenR%ai=9ziQ%zpp=N5FSl zYMN>Q1_l6t;ot|@9|EWVjvhX8^vL0(M~@smcI@c!li!^@dE&&$b4+JXf5(3A{CW0s zY-}9dd^{YSU@kVc?}fhy^9u+;gf8%iTow_$%qJ)$_%|dB$BrF4dHf{n$&;*t9Bdqd z|MIi{2Ecs$(EB3*Mg|bz5HkZKGsAu@fa{=>hZq_D*57Xr<3VqaF&sL60M-2tz`%Hj zk@48^qsNaPVmPP^U^v8hnE43HxuciFSq%_Kd{pN#&~>=$i^3uasT;Rl(tuz|8MlYg z#cb@-?vK-XE#DsJQ+d=O>#=lzj^m)6pPp|`9T4dd;{jz3V1mpC5Wt~BhX6+z51RSg z!@zuqh4B)r_~CQF>+stk3CSZ5m-Yt%rx_2*F*7m)t^&SZ0YLta>$g+N7VApX-9`(+ ztMk5&TR&C_F~U}wLwjy>a*$NOX|Y7luDoy)xqNh4v(3)R&{kqct73J)6nr00eP?|_ zdMvT+${zo~7~Se2d96GJ;G^FTXwfY;Y+=fF==Ym9qC1F>u(tW)fvkmat*B2P%gFNN zRS&LWH?t59;ueYE?V^gLG!>E>Vs>!XXlRE$5w*<$crcREZe@+-#V$%4%+v&bte|zj zaqD+<**rcOlVdwALPOOUL0g^Cw9Qe%0Y&Pk4&@ zKZA9-VN5`b6pOQUc(`kYf2kn#h6~+a#h1=hNI zG0L+tR$SQ$f*J4Z(V85QRz@t<5)d=x@UIsid8RgX#Vyq^ipaECT}BGC-X=ICe{W0( zw=!+w5Fzxg@!WK1lU0Jw-hOAFe2SS!)-bE&ehU}z*u!vVuej59FMoXX%m(|`NG28; z5pFyfgdU1QnSkVXWivyF_Y@6cagcJ{$!9YY>QM9P-d^m0^X#5Yf3ovZef0aD?whDO zgu3LXoREQdFdGeb?U2(|(~rQ&+Izw>R8_UAtOU8Fkhj2Am_>+Rjf2H!&m(*9&MuU+ zq}c2PBnFL2JUArv+YIn$gR84~}`CV^g z9Hn4Jx9?5Y@FAL4^O;d}b#A-)><;V2#G5W>St99P< zQ5GXTFXdd4jEZ|L`#t;>*rek_b;C?z%181Y?oOzErmi6`U)1L%JIRJLptyAN9#u&v z7mG)0H_?badKjPl*WJU^(QMiDhN#Fp(0i{0Z#ht69eS`+(4MCgt}=Z^&U!Ke1C_*R zfP=km&~3MFwcUlB6xqC|)i^m;uaC}SWv1C<-iw@p&U<^@Ja?fs0dJLampWiNgBYXm z%!p>mNq4*0nTl4J+0ce*zhXl7tc)9@dz9K1T7c3R9zq;~;#nG>em2>drBl#w676!qu;&GwLEVFl$Ms^*1*5zQ0q40Ll*J9vMo#;r+ZJz z)+}=gDEDsHx0kX|g|%(QGoy~j8rQi$t&5O`Ihj`ZIij>DU7e~wI&Maf=xk0Vcgiqn z`IN!!`B3Gx^DS7pr|~A`LmdYD02q@B?!G3umIucB%%4X{QvnOYlw;E5l<6+!)wGLZ zm|$JC`H*wnx*2$k4>65wr%ZQ+q-_hQkI}8KN{){^_D(%A__%6*bLL}K_(D!pNU_>> zd0g2dVF6}uJh?PX@-X=f8+J}q900!&9DwqwOC0BfSO=`>XWCQe_5ojOLf#os*Itgb z*XG*grsWwM&RF4F+X}%BxXBM5JuJ-?oz|8skq|`txo6TG8M8eEaDpv}gr2ch-+JxM z7c9mO@vdea|5Ux>uCkgRorQtCse;a*xm>dtsq4 zK~u)+e0&QYk3?wH()qA5SEYeO5{=|Hm&T8*`htH;XJkpwjy)}(5}P2z@7|^@w}C>7 z5KJrff=ai6`ow*Jpm(wj|x93o^Dc8rs1OG$vWo!ktX+y{r;N&s=34Px2{w z$*`u_H~Tg8YulUZ{_9_<@zG}r1Izl^caC4Bc1hg#tD0M_y1MoN5dPo~Pw;=W;9*oC zGO^$5O$@GJHrkhIlfe{#{Np(Ms()U{ZU*+R4}H1hXK?Jw%9qno^QLF5P^~yhq)+yM zPNd_GX~=F{1k6bHh>dVbkBh`zJ}hOqQZz#U^=ml~I`pDBkHD#htpLSi(@lOc^A!_r ziTVb$s48^ZoPgbzJ%mF84D;1Pxw_@v{j1qqf!R|x0}h0e5Y;M;v{xRvl4vUrsh-OX z6o2#eYV+5t@{doV zH6LL0a62$=JHX@Ebiuc7x}8{YEH-p>@PNas?5s>%=o1(Ud!N&(-KMPw-hDvBme;6c zg}~{U9g)W>lBlQ#*r|&>W4JI?q{MR`?uv!^<@n10=4*d^g#N{nhkjX9v|yMu&B|au z0QvWKHGgm7+x|v;MI-_DnzdB!a%EpGJ`_4*IT&kENetN} zfBYT*{Nqg@(awx}IPcoTVpF71`^lpydmqsC=5RvxDs=QZG4Mv^1Q?+C(?7@GvTj`d z3-lK?{(s6FaerfQfwGk{Hf<39DmHM%=Z{aLgOZF55NRT*jP?Eow?c+ar(6r{WA8p- z<+*Aslwmckp=6YgPCO`{xYZWD_P-|=-yrV4o5U1*PfefV0OMw?&qr;Izw zdP~fQ{;Kj9#r|Jqn5UWH?}?BKDJ->oS-Cd!I{jBxP7};TK_M?O(hyy8`o~2lG0| zpP`K5BBBB?3Cx|&6*tOYSgJN(TpJ~Jy)x|K=04z*KhU<;p<=FZSUF2k2K%IfsX#L~ zo<}frQpPYbzPapT#a^etsTW;}5y%dHXlQfsE8Dgzviz|!&g*6dTYR3jr1SK6;a*}k z&yD`M=DG;ClnRAq2f0W$i>C(CttJrRRv2u7FEc-rgy9p>VTF1T)j{$j!BdrK4(FKL zpN^$yUsj(YY4H1sKhcRq7CQc*u6R#z(ZRR5qIu>Ve#yJzUf&+Y_x+-Rp@Y`*1PVUu-fw0X~ss3JelmX4h zK?{cs&a$oJT5wBBDN!}a-SR9iwp(6bK=X-4#U7JA^HrGOzEI5T5MbXEVfDHei9%u_ zWZS_RSI3Yv_aVoku(z}K_RQl8yL7n^7dJ?&I-q(n5Ixk@+NQqYaW#J!c;XX`vXf{}y; zUmh-dj{5o84GLFpx}%4Q*jcrmB?QWe0ili2BG*7|Geg#L!qP|%qF7A@GmZ~2wsfrM z+&1}#eZULor$LS|&)5W1d_fR6D;8a5!BTr*@xVa99f%o=024E~lLV&;i&eM11wyTV zjx8;jxWy3@1u^j_u#3cQp?k+2`;^`l(sT^&gEDQ*@AkmwM1=7aVr&JocUUXt zOrCCjtw1zWPDIpQ%b@78WvDYpBay03$}8&}Dwr#@2V|t+`xCk)1w>4EI~HLTC{CM^ zO(EgX1%g?V$M?@kPpTpyL=5yJK~kKCi;wQBet&ZinUnLv<_Off-FWrkN+DZ^S`Kjo zvaQ&S)UUirQ{lW6f>-UfcEC#K>m3hydpu7kQ8P$whGRQJT^%_nz^`sW@H)D*u{4~q z56HS3TU1VnQr1e~r+k5NIl1Av;0Rj)fu>P}dpE+M+Tdz5H&hzA` zDnS=^ssPkH_qxOY+#z?>J3B`bdvB@>(N^DEMqpB_!OA&{C6Fr+B0Umm`CTp>4?Bax5>EPL5gaySZTNF9yS9jY2Lx@wc~<1b1# z3;q|I9^@4u=QpOG5zL(gjF$;zsg-3Ye|)q*`dY>bRmkpYa^RgZes4( zVAxfM^+#0&XX*@IY#ONR>{{kWXGEowd-DpjO#FMxofQma{bE}z?=_sSqY9j>@r&(* zyGmfjDTXCiBEElFDv?LVbFGa`V~xZ%Ooy`v26wO0tUQB{Svx%^r@LRtkXs>**1p!q z-oyAO&pr*=Ow8e%!mdTOzlvydG&5(87?1AgX@ZxKBq1+tp{Vy1kt|mt(VvTy=s!Q9 z?NV9+3|c^49Cp~_3%zBc4F`%yyams>9L~|0jB52Ah#z&h?=|G}Ss%WAn0=z`S@QP) zIqH8w$v-*!PdCv5jz9mCyZGOuV*pz8@UL?(@qtL&8KX5-0L;IlKJ$p}Tpaqx?aaeA zzY3(TZjn@Ec7jFNM-?AhR-m@Q^{VV$?`us(cKZhZ#KYfi>iV?Rp;OQ?OxPjiRdiu?X z^vhI`82lf~W+prO&W3#C*PW}Smpa@2;iZ~=r{Qc}U_-PAfH_-curj~l-b^7sqV%l& z<*nQ=!gsfLBpScI2RnNBP-)bU5il4ZX^2aANIVRK$b=v}nhpzgXXV%@OjSP3nbfK$ zZWX|^LCY#Uq4U-W!#2-6nWcJwz(JgR88HyUICAbjJ-`O#V|(BOAeY-ZO&CnFKNv$Q zTv9&1D>8$omHPyDH%M9lb+au=i5}{*(DNY#gsU_&P_HU}y;hg1U`)H4@=__^2y0xl z7ar$p*a(bsB*$TzE8${spC$8#jHmP;bTw$r5V4onQQefBZfyE^YpTx(2d?k)7*$m~ zr0`i5cZr163H2i?MrI}`tQV|VqbbVT-e6g}PY+ev3dy1u-8)bP81E*-9`8j&oW#Bs zJ7y!nyJboXG4G|!d&EwMshP2R^o+*5TJ^nNsNjaPe3q{$-K-DiMkx|Zlf-KyL!rhw z_4MS~*O-}fOyG(}V8dpnqqXk4kIAd9C|lzn(%lwXx z>`+3I>4VexvI+C~hAW@V7CcvV(s$k0+Z@*WEax7IZ)S1LhUxaqs5W-6C7Tm_d=#Kv zt&Y*2YpB%$FVLBn^|`4fl4Iw=$(q-Zy%3o!g*QD<1fYTIqiY`ffc4Vn(KenIh$8n_ zo^mj=Yung{o^&Hc7h+kP@9Vy6xwcszx&z+#3NP}!L?U_|&!IkK8tE%pp^pVbCA&2= z-oO!YYwBCmt6%WmCXX&%ZNLmY2QXp(d*S~-hhESKv~0IIZ4?u{F1&s-DRFH3mv!WD z#xL%MX6(m9℘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 Date: Wed, 21 Oct 2026 00:49:21 +0800 Subject: [PATCH 10/13] Record verified v0.2.11 OTA publication and Pages deployment --- docs/led-analysis/scope.md | 2 ++ docs/led-analysis/timeline.md | 1 + .../led-publication-v0.2.11-results.json | 18 ++++++++++++++++++ 3 files changed, 21 insertions(+) create mode 100644 verification/led-publication-v0.2.11-results.json diff --git a/docs/led-analysis/scope.md b/docs/led-analysis/scope.md index e402513..d144b78 100644 --- a/docs/led-analysis/scope.md +++ b/docs/led-analysis/scope.md @@ -3,3 +3,5 @@ 使用者於 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 index fc7a3b5..0ed2947 100644 --- a/docs/led-analysis/timeline.md +++ b/docs/led-analysis/timeline.md @@ -4,3 +4,4 @@ - 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/verification/led-publication-v0.2.11-results.json b/verification/led-publication-v0.2.11-results.json new file mode 100644 index 0000000..d94ba23 --- /dev/null +++ b/verification/led-publication-v0.2.11-results.json @@ -0,0 +1,18 @@ +{ + "status": "published_feed_verified", + "version": "0.2.11-preview", + "software_version": 13, + "source_commit": "376b9eb61e644558266e274edc3c66603eabe1e0", + "release_url": "https://github.com/lee98064/incipio-commandkit/releases/tag/v0.2.11-preview", + "feed_url": "https://lee98064.github.io/incipio-commandkit/updates/index.json", + "ota_url": "https://lee98064.github.io/incipio-commandkit/updates/matter.ota", + "ota_sha256": "0f1c58cc8620fa5f0626570957cf2f4a28f05a3dade7e790b7f538b48588f194", + "zip_sha256": "96e66b940b4bc661bd12902a39d51f588794b97771b0d797219e3c5c6314b68d", + "feed_cors": "*", + "ota_cors": "*", + "pages_workflow": "https://github.com/lee98064/incipio-commandkit/actions/runs/39460447519", + "pages_conclusion": "success", + "real_outlet_installation": "user will install using existing OTA; not performed by agent", + "github_native_test_conclusion": "success", + "duplicate_rebuild": "cancel requested after verified artifacts were published; no second release needed" +} From 35f9fe17616767a2c293792a738eea2658648326 Mon Sep 17 00:00:00 2001 From: lee98064 <46412433+lee98064@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:30:05 +0800 Subject: [PATCH 11/13] Redesign management console and setup portal Management page (:18082): live power gauge with a two-minute trend, tabbed navigation (bottom bar on phones), automatic online update check on load with an install stepper and download progress, LED mode cards with a night-schedule timeline, copyable Matter code, dark mode, toasts and modal login. All device APIs, headers and body formats are unchanged. Setup portal: three-step wizard (Wi-Fi, admin password, join home) with a tappable network list and signal bars. Wi-Fi is submitted with fetch using the same form fields, so the success screen stays on the page. Co-Authored-By: Claude Opus 5.5 --- .../matter/src/setup/portal-device-page.h | 6 +- firmware/matter/src/setup/portal-device.html | 102 ++++- .../matter/src/setup/portal-scan-script.h | 2 +- firmware/matter/src/setup/portal-scan.js | 214 ++++++----- firmware/matter/src/update/update.html | 356 ++++++++++++++++-- firmware/matter/src/update/update.js | 225 +++++++---- firmware/matter/src/update/update_page.h | 2 +- 7 files changed, 715 insertions(+), 192 deletions(-) diff --git a/firmware/matter/src/setup/portal-device-page.h b/firmware/matter/src/setup/portal-device-page.h index 6ad820d..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 個人網路與開放網路。

設定管理密碼

之後登入插座後台、切換協議及重設設定時使用,與 Wi-Fi 密碼分開。

至少 8 個字元。忘記密碼時,實體長按會恢復出廠並清除設定與用電紀錄。

正在讀取管理設定…

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

Matter 配對

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

\"Matter"; -static const char portal_after[] = "此設定碼屬於這台插座,重開機後維持相同。請保存設定碼或 QR Code。請不要使用插座原本的 HomeKit 碼。配對資料會保存在插座,重開機與 OTA 更新後沿用。

HomeKit/Matter 切換

完成 Wi-Fi 設定後,可在插座管理面板選擇協議。一次只啟用一種,切換時重新啟動。

本機設定 · 不需要雲端帳號
開發測試版 · 依所選的 HomeKit 或 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 d805371..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 個人網路與開放網路。

設定管理密碼

之後登入插座後台、切換協議及重設設定時使用,與 Wi-Fi 密碼分開。

至少 8 個字元。忘記密碼時,實體長按會恢復出廠並清除設定與用電紀錄。

正在讀取管理設定…

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

Matter 配對

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

Matter 配對 QR Code{{MATTER_MANUAL}}此設定碼屬於這台插座,重開機後維持相同。請保存設定碼或 QR Code。請不要使用插座原本的 HomeKit 碼。配對資料會保存在插座,重開機與 OTA 更新後沿用。

HomeKit/Matter 切換

完成 Wi-Fi 設定後,可在插座管理面板選擇協議。一次只啟用一種,切換時重新啟動。

本機設定 · 不需要雲端帳號
開發測試版 · 依所選的 HomeKit 或 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 7dddd43..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 protocolPanel = document.getElementById('protocol-management');\n if (protocolPanel) { protocolPanel.href = 'http://' + location.hostname + ':18082/'; protocolPanel.hidden = false; }\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 var adminState=null,setting=false;\n var adminMessage=document.getElementById('admin-setup-status');\n async function adminStatus(){try{\n var response=await fetch('/admin/status',{cache:'no-store'});adminState=await response.json();\n if(!adminState.ready){setTimeout(adminStatus,1500);return;}\n document.getElementById('admin-setup').hidden=adminState.configured;\n document.getElementById('admin-password').required=!adminState.configured;\n document.getElementById('admin-confirm').required=!adminState.configured;\n adminMessage.textContent=adminState.configured?'管理密碼已設定。':'請建立管理密碼,不需要下載金鑰檔案。';\n if(!adminState.wifi_allowed){form.hidden=true;adminMessage.textContent='請開啟管理面板,登入後修改 Wi-Fi。';}\n }catch{adminMessage.textContent='管理服務尚未就緒,正在重試…';setTimeout(adminStatus,1500);}}\n adminStatus();\n form.addEventListener('submit',async function(event){\n event.preventDefault();if(setting)return;\n try{\n if(!adminState?.ready)throw Error('請稍候管理服務準備完成');\n setting=true;save.disabled=true;\n if(!adminState.configured){\n var password=document.getElementById('admin-password').value;\n if(password!==document.getElementById('admin-confirm').value)throw Error('兩次管理密碼不相同');\n adminMessage.textContent='正在保護管理密碼,請稍候…';\n var salt=CKPassword.salt(),key=await CKPassword.derive(password,salt);\n document.getElementById('admin-password').value='';document.getElementById('admin-confirm').value='';password='';\n 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='';\n if(!enroll.ok)throw Error('管理密碼未保存,請重新開啟設定頁再試');\n adminState.configured=true;\n }\n adminMessage.textContent='密碼已保存,正在連線…';HTMLFormElement.prototype.submit.call(form);\n }catch(error){adminMessage.textContent=error.message;setting=false;save.disabled=false;}\n });\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 948b095..c2fcdcf 100644 --- a/firmware/matter/src/setup/portal-scan.js +++ b/firmware/matter/src/setup/portal-scan.js @@ -1,94 +1,125 @@ 'use strict'; (function () { - var protocolPanel = document.getElementById('protocol-management'); - if (protocolPanel) { protocolPanel.href = 'http://' + location.hostname + ':18082/'; protocolPanel.hidden = false; } - 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; - var adminState=null,setting=false; - var adminMessage=document.getElementById('admin-setup-status'); - async function adminStatus(){try{ - var response=await fetch('/admin/status',{cache:'no-store'});adminState=await response.json(); - if(!adminState.ready){setTimeout(adminStatus,1500);return;} - document.getElementById('admin-setup').hidden=adminState.configured; - document.getElementById('admin-password').required=!adminState.configured; - document.getElementById('admin-confirm').required=!adminState.configured; - adminMessage.textContent=adminState.configured?'管理密碼已設定。':'請建立管理密碼,不需要下載金鑰檔案。'; - if(!adminState.wifi_allowed){form.hidden=true;adminMessage.textContent='請開啟管理面板,登入後修改 Wi-Fi。';} - }catch{adminMessage.textContent='管理服務尚未就緒,正在重試…';setTimeout(adminStatus,1500);}} - adminStatus(); - form.addEventListener('submit',async function(event){ - event.preventDefault();if(setting)return; - try{ - if(!adminState?.ready)throw Error('請稍候管理服務準備完成'); - setting=true;save.disabled=true; - if(!adminState.configured){ - var password=document.getElementById('admin-password').value; - if(password!==document.getElementById('admin-confirm').value)throw Error('兩次管理密碼不相同'); - adminMessage.textContent='正在保護管理密碼,請稍候…'; - var salt=CKPassword.salt(),key=await CKPassword.derive(password,salt); - document.getElementById('admin-password').value='';document.getElementById('admin-confirm').value='';password=''; - 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; + 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 || '設定未送出,請重新開啟設定頁'); } - adminMessage.textContent='密碼已保存,正在連線…';HTMLFormElement.prototype.submit.call(form); - }catch(error){adminMessage.textContent=error.message;setting=false;save.disabled=false;} + 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(); @@ -105,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) { @@ -117,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([]); @@ -126,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/update/update.html b/firmware/matter/src/update/update.html index a924b07..90c4618 100644 --- a/firmware/matter/src/update/update.html +++ b/firmware/matter/src/update/update.html @@ -1,36 +1,332 @@ - + CommandKit 插座管理 -
COMMANDKIT
CMNDKT-004 / SMART OUTLET
-
- - -

狀態總覽

正在連線
-
正在讀取量測…—
-
電壓
—V

交流有效值

-
電流
—A

交流有效值

-
即時功率
—W

目前消耗的功率

-
累積用電
—kWh

功率估算 · 約 30 秒保存

等待量測資料每 2 秒更新
-

裝置資訊

韌體版本—
目前協議—
網路位址—

登入後可變更設定、更新韌體與重設累積用電。

- -
CMNDKT-004 · 讀值無效或連線中斷時顯示「—」。實體長按至重啟,可恢復出廠設定。
-

確認操作

+:root{--bg:#eef2f5;--bg-glow:#d9eef0;--surface:#fff;--surface-2:#f4f7f9;--ink:#0f2233;--ink-2:#4b5f70;--ink-3:#7a8c9b;--line:#dde5eb;--line-2:#c5d1da;--accent:#087e8b;--accent-ink:#fff;--accent-2:#06646e;--accent-soft:#e1f2f3;--ok:#16835a;--ok-soft:#e3f4ec;--warn:#9a5b00;--warn-soft:#fdf1dc;--danger:#b8372b;--danger-soft:#fcebe8;--shadow:0 1px 2px #0f22330a,0 10px 30px -18px #0f223340;--hero-1:#14506a;--hero-2:#0f2a3d;--hero-3:#0a1a27;--radius:16px;--tab-h:64px; + font:15px/1.6 -apple-system,BlinkMacSystemFont,"SF Pro Text","PingFang TC","Noto Sans TC","Microsoft JhengHei","Segoe UI",system-ui,sans-serif;color:var(--ink);background:var(--bg);-webkit-text-size-adjust:100%} +@media(prefers-color-scheme:dark){:root{--bg:#0a131b;--bg-glow:#0f2b33;--surface:#101c26;--surface-2:#162532;--ink:#e5eef4;--ink-2:#a2b4c2;--ink-3:#6f8597;--line:#1f303d;--line-2:#2b4050;--accent:#2ec5cf;--accent-ink:#04252a;--accent-2:#5fd6de;--accent-soft:#0f3940;--ok:#4fd39a;--ok-soft:#10352a;--warn:#f2ad4b;--warn-soft:#3a2a10;--danger:#ff7a6b;--danger-soft:#3d1916;--shadow:0 1px 2px #0006,0 12px 32px -18px #000c;--hero-1:#155a72;--hero-2:#0f2d40;--hero-3:#0b1d2a}} +*{box-sizing:border-box}[hidden]{display:none!important} +body{margin:0;min-height:100vh;background:radial-gradient(1100px 520px at 12% -12%,var(--bg-glow),transparent 70%),var(--bg);background-attachment:fixed} +svg.i{width:20px;height:20px;flex:none;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round} +button,input,select{font:inherit;color:inherit} +:focus-visible{outline:3px solid var(--accent);outline-offset:2px} +h1,h2,h3{margin:0;letter-spacing:-.01em}h1{font-size:26px;font-weight:650;letter-spacing:-.03em}h2{font-size:17px;font-weight:650}h3{font-size:15px;font-weight:650} +p{margin:6px 0 12px}small,.muted{color:var(--ink-2)}code,.mono{font-family:"SF Mono",ui-monospace,Menlo,Consolas,monospace} +.bar{position:sticky;top:0;z-index:20;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px max(18px,env(safe-area-inset-left));padding-top:max(12px,env(safe-area-inset-top));background:color-mix(in srgb,var(--bg) 78%,transparent);backdrop-filter:saturate(1.6) blur(14px);-webkit-backdrop-filter:saturate(1.6) blur(14px);border-bottom:1px solid color-mix(in srgb,var(--line) 70%,transparent)} +.brand{display:flex;align-items:center;gap:11px;min-width:0}.brand b{display:block;font-size:16px;letter-spacing:-.01em}.brand small{display:block;font-size:11.5px;letter-spacing:.04em;color:var(--ink-3);white-space:nowrap} +.logo{width:36px;height:36px;border-radius:11px;display:grid;place-items:center;background:linear-gradient(145deg,var(--hero-1),var(--hero-3));color:#7ef0d8;box-shadow:inset 0 0 0 1px #ffffff1a} +.bar-end{display:flex;align-items:center;gap:8px} +.pill{display:inline-flex;align-items:center;gap:7px;height:30px;padding:0 11px;border-radius:99px;font-size:12.5px;color:var(--ink-2);background:var(--surface);border:1px solid var(--line);white-space:nowrap} +.pill i{width:8px;height:8px;border-radius:50%;background:var(--ink-3)} +.pill[data-state=live] i{background:var(--ok);box-shadow:0 0 0 0 color-mix(in srgb,var(--ok) 50%,transparent);animation:ping 2s infinite} +.pill[data-state=waiting] i{background:var(--warn)}.pill[data-state=offline] i{background:var(--danger)} +@keyframes ping{70%{box-shadow:0 0 0 7px transparent}100%{box-shadow:0 0 0 0 transparent}} +.account{display:flex;align-items:center;gap:4px;font-size:13px;color:var(--ink-2)} +.chip{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 10px;border-radius:99px;background:var(--accent-soft);color:var(--accent-2);font-size:12.5px;font-weight:600} +.layout{max-width:1180px;margin:0 auto;display:grid;grid-template-columns:200px minmax(0,1fr);gap:32px;padding:26px 24px 40px} +.tabs{position:sticky;top:84px;align-self:start;display:flex;flex-direction:column;gap:4px} +.tabs button{position:relative;display:flex;align-items:center;gap:12px;width:100%;padding:11px 14px;border:0;border-radius:12px;background:none;color:var(--ink-2);font-size:14.5px;cursor:pointer;text-align:left} +.tabs button:hover{background:color-mix(in srgb,var(--surface) 70%,transparent);color:var(--ink)} +.tabs button[aria-current=page]{background:var(--surface);color:var(--ink);font-weight:650;box-shadow:var(--shadow)} +.tabs button[aria-current=page] svg{color:var(--accent)} +.tabs .lock{display:none;width:14px;height:14px;margin-left:auto;opacity:.6}body:not(.is-admin) [data-admin] .lock{display:block} +.tabs .dot{position:absolute;top:9px;left:30px;width:8px;height:8px;border-radius:50%;background:var(--danger);box-shadow:0 0 0 2px var(--bg)} +.rail-note{margin:18px 14px 0;font-size:12px;line-height:1.7;color:var(--ink-3)} +main{min-width:0}.view{animation:rise .35s ease both}@keyframes rise{from{opacity:0;transform:translateY(6px)}} +.head{display:flex;align-items:flex-end;justify-content:space-between;gap:14px;margin:4px 0 18px}.head p{margin:4px 0 0;color:var(--ink-2);font-size:14px} +.card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:22px;margin-bottom:18px;box-shadow:var(--shadow)} +.card-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:6px} +.card-head h2{display:flex;align-items:center;gap:9px}.card-head h2 svg{color:var(--accent)} +.grid2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}.grid2>.card{margin:0} +.stack{display:grid;gap:18px;margin-bottom:18px} +/* Hero instrument */ +.hero{position:relative;overflow:hidden;color:#e6f5f7;border-radius:22px;padding:22px 24px 24px;margin-bottom:18px;background:radial-gradient(120% 140% at 0% 0%,var(--hero-1),var(--hero-2) 48%,var(--hero-3));box-shadow:0 24px 48px -28px #0a1a27cc,inset 0 0 0 1px #ffffff12} +.hero:after{content:"";position:absolute;inset:auto -80px -120px auto;width:340px;height:340px;border-radius:50%;background:radial-gradient(closest-side,#2ec5cf33,transparent);pointer-events:none} +.hero-top{display:flex;justify-content:space-between;align-items:center;gap:12px;font-size:13px;color:#a9c8d0} +.relay{display:inline-flex;align-items:center;gap:8px;height:30px;padding:0 12px;border-radius:99px;background:#ffffff14;color:#c4d6dd;font-weight:600;font-size:13px} +.relay i{width:8px;height:8px;border-radius:50%;background:#6c8592} +.relay[data-on=true]{background:#7ef0d824;color:#8af3dc}.relay[data-on=true] i{background:#7ef0d8;box-shadow:0 0 10px #7ef0d8} +.hero-body{display:grid;grid-template-columns:240px minmax(0,1fr);gap:30px;align-items:center;margin-top:8px} +.gauge{position:relative;width:240px;aspect-ratio:1}.gauge svg{width:100%;height:100%;transform:rotate(135deg)} +.g-track,.g-value{fill:none;stroke-width:12;stroke-linecap:round}.g-track{stroke:#ffffff14;stroke-dasharray:395.84 527.79} +.g-value{stroke:url(#g-arc);stroke-dasharray:0 527.79;transition:stroke-dasharray .8s cubic-bezier(.2,.8,.2,1);filter:drop-shadow(0 0 6px #2ec5cf88)} +.g-tick{stroke:#ffffff30;stroke-width:2} +.gauge-read{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center} +.gauge-read small{color:#9dbcc5;font-size:12.5px;letter-spacing:.06em} +.gauge-read strong{font-size:48px;line-height:1.1;font-weight:300;letter-spacing:-.04em;font-variant-numeric:tabular-nums;color:#fff} +.gauge-read span{font-size:13px;color:#9dbcc5;letter-spacing:.08em} +.gauge-scale{position:absolute;left:0;right:0;bottom:6px;display:flex;justify-content:space-between;padding:0 30px;font-size:11px;color:#7fa1ab} +.trend-head{display:flex;justify-content:space-between;gap:10px;font-size:13px;color:#a9c8d0} +.trend svg{display:block;width:100%;height:120px;margin:10px 0 6px;overflow:visible} +#trend-line{fill:none;stroke:#5fe3d3;stroke-width:2;vector-effect:non-scaling-stroke;stroke-linejoin:round}#trend-area{fill:url(#g-area)} +.trend-grid line{stroke:#ffffff12;vector-effect:non-scaling-stroke} +.trend-empty{fill:#9dbcc5;font-size:13px} +.trend-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;border-top:1px solid #ffffff14;padding-top:14px} +.trend-stats small{display:block;color:#8fb0b9;font-size:12px}.trend-stats b{font-weight:500;font-size:17px;font-variant-numeric:tabular-nums} +.tiles{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin-bottom:10px} +.tile{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:18px 18px 16px;box-shadow:var(--shadow);min-width:0} +.tile small{display:flex;align-items:center;gap:7px;font-size:13px}.tile small svg{width:16px;height:16px;color:var(--accent)} +.tile p{display:flex;align-items:baseline;gap:6px;margin:10px 0 2px;min-width:0} +.tile strong{font-size:30px;line-height:1.15;font-weight:400;letter-spacing:-.035em;font-variant-numeric:tabular-nums;overflow-wrap:anywhere} +.tile p>span{color:var(--ink-3);font-size:13px}.tile em{font-style:normal;font-size:12px;color:var(--ink-3)} +#energy-fine{font-size:.55em;color:var(--ink-3);letter-spacing:0} +.meter-foot{display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap;font-size:12.5px;color:var(--ink-3);margin:0 2px 22px} +.facts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:4px 18px;margin-top:10px} +.facts div{padding:10px 0;min-width:0}.facts small{display:block;font-size:12.5px;color:var(--ink-3)}.facts strong{display:block;font-weight:550;font-size:15px;overflow-wrap:anywhere} +.status-chip{display:inline-flex;align-items:center;gap:6px;font-size:13px;font-weight:600;padding:3px 10px;border-radius:99px;background:var(--surface-2);color:var(--ink-2);margin-top:2px} +.status-chip[data-tone=ok]{background:var(--ok-soft);color:var(--ok)}.status-chip[data-tone=new]{background:var(--accent-soft);color:var(--accent-2)}.status-chip[data-tone=warn]{background:var(--warn-soft);color:var(--warn)} +.banner{display:flex;align-items:center;gap:14px;width:100%;padding:14px 16px;margin-bottom:18px;border-radius:var(--radius);border:1px solid color-mix(in srgb,var(--accent) 35%,var(--line));background:linear-gradient(90deg,var(--accent-soft),var(--surface));color:var(--ink);text-align:left;cursor:pointer} +.banner .bi{width:38px;height:38px;border-radius:12px;display:grid;place-items:center;background:var(--accent);color:var(--accent-ink);flex:none} +.banner b{display:block}.banner small{display:block}.banner .go{margin-left:auto;color:var(--accent-2)} +.notice{display:flex;gap:12px;align-items:flex-start;padding:14px 16px;border-radius:14px;background:var(--surface-2);color:var(--ink-2);font-size:13.5px;margin:0 0 16px} +.notice svg{color:var(--accent);margin-top:1px}.notice.warn{background:var(--warn-soft);color:var(--warn)}.notice.warn svg{color:var(--warn)} +.notice b{color:var(--ink)}.notice.warn b{color:inherit} +/* Controls */ +.btn{position:relative;display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;padding:0 18px;border-radius:12px;border:1px solid transparent;background:var(--accent);color:var(--accent-ink);font-weight:650;font-size:14.5px;cursor:pointer;transition:transform .12s,filter .15s,background .15s;white-space:nowrap} +.btn:hover{filter:brightness(1.06)}.btn:active{transform:scale(.98)} +.btn.secondary{background:var(--surface-2);border-color:var(--line);color:var(--ink)} +.btn.danger{background:var(--danger-soft);color:var(--danger);border-color:color-mix(in srgb,var(--danger) 25%,transparent)} +.btn.solid-danger{background:var(--danger);color:#fff} +.btn.ghost{background:none;border-color:var(--line);color:var(--ink)} +.btn.small{min-height:36px;padding:0 12px;font-size:13.5px;border-radius:10px} +.btn:disabled{opacity:.5;cursor:not-allowed;transform:none}.btn.loading:disabled{opacity:1} +.btn.loading{color:transparent!important}.btn.loading:after{content:"";position:absolute;width:18px;height:18px;border-radius:50%;border:2px solid var(--ink-3);border-top-color:var(--accent);animation:spin .8s linear infinite} +@keyframes spin{to{transform:rotate(1turn)}} +.icon-btn{display:inline-grid;place-items:center;width:36px;height:36px;border-radius:10px;border:0;background:none;color:var(--ink-2);cursor:pointer}.icon-btn:hover{background:var(--surface-2);color:var(--ink)} +.actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:18px} +label.field{display:block;font-size:13.5px;font-weight:600;margin:14px 0 6px} +input:not([type=radio]):not([type=checkbox]):not([type=file]),select{width:100%;min-height:46px;padding:10px 13px;border-radius:12px;border:1px solid var(--line-2);background:var(--surface);color:var(--ink);transition:border-color .15s,box-shadow .15s} +input:focus,select:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 4px var(--accent-soft)} +.pw{position:relative}.pw input{padding-right:48px!important}.pw button{position:absolute;right:4px;top:50%;transform:translateY(-50%)} +.form-row{display:grid;grid-template-columns:1fr 1fr;gap:0 16px} +.hint{font-size:12.5px;color:var(--ink-3);margin:6px 0 0} +.switch{appearance:none;-webkit-appearance:none;position:relative;width:46px;height:28px;border-radius:99px;background:var(--line-2);cursor:pointer;flex:none;transition:background .2s;margin:0} +.switch:after{content:"";position:absolute;top:3px;left:3px;width:22px;height:22px;border-radius:50%;background:#fff;box-shadow:0 1px 3px #0003;transition:transform .2s} +.switch:checked{background:var(--accent)}.switch:checked:after{transform:translateX(18px)} +.toggle-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:4px 0} +.toggle-row b{display:block;font-size:14.5px}.toggle-row small{display:block;font-size:12.5px} +.segmented{display:inline-grid;grid-auto-flow:column;grid-auto-columns:1fr;gap:4px;padding:4px;border-radius:14px;background:var(--surface-2);border:1px solid var(--line);margin:8px 0 10px;min-width:280px} +.segmented label{position:relative;cursor:pointer}.segmented input{position:absolute;opacity:0;pointer-events:none} +.segmented span{display:flex;align-items:center;justify-content:center;gap:8px;min-height:40px;padding:0 16px;border-radius:10px;font-weight:600;color:var(--ink-2);transition:background .15s} +.segmented input:checked+span{background:var(--surface);color:var(--ink);box-shadow:var(--shadow)}.segmented input:focus-visible+span{outline:3px solid var(--accent)} +details.more{border-top:1px solid var(--line);margin-top:18px;padding-top:4px} +details.more summary{list-style:none;cursor:pointer;display:flex;align-items:center;justify-content:space-between;padding:12px 0;font-weight:600;font-size:14px} +details.more summary::-webkit-details-marker{display:none}details.more summary:after{content:"";width:8px;height:8px;border-right:2px solid var(--ink-3);border-bottom:2px solid var(--ink-3);transform:rotate(45deg);transition:transform .2s;margin-right:4px} +details.more[open] summary:after{transform:rotate(-135deg)} +/* LED */ +.led-status{display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:14px;background:var(--surface-2);font-size:14px;margin:8px 0 6px} +.led-status small{display:block;font-size:12.5px} +.modes{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:10px} +.mode{position:relative;display:block;padding:14px 14px 13px;border-radius:14px;border:1px solid var(--line);background:var(--surface);cursor:pointer;transition:border-color .15s,box-shadow .15s} +.mode:hover{border-color:var(--line-2)}.mode input{position:absolute;opacity:0;pointer-events:none} +.mode:has(input:checked){border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}.mode:has(input:focus-visible){outline:3px solid var(--accent);outline-offset:2px} +.mode b{display:block;font-size:14.5px;margin-top:10px}.mode small{display:block;font-size:12.5px;line-height:1.5;margin-top:2px} +.mode .tag{position:absolute;top:12px;right:12px;font-size:11px;font-weight:650;color:var(--accent-2);background:var(--accent-soft);padding:1px 7px;border-radius:99px} +.lamp{display:block;width:14px;height:14px;border-radius:50%;background:var(--line-2);box-shadow:inset 0 0 0 1px #0000001a} +.lamp.lit,.lamp[data-anim=smart],.lamp[data-anim=relay],.lamp[data-anim=inverse],.lamp[data-anim=load],.lamp[data-anim=on]{--on:#38d9b9} +.lamp[data-anim]{animation:var(--a) infinite} +.lamp.lit,.lamp[data-anim=on]{background:var(--on);box-shadow:0 0 0 3px #38d9b92e,0 0 12px #38d9b9b3} +.lamp[data-anim=smart]{--a:breathe 3.2s ease-in-out}.lamp[data-anim=relay]{--a:follow 4s steps(1)}.lamp[data-anim=inverse]{--a:follow 4s steps(1) -2s}.lamp[data-anim=load]{--a:follow .9s steps(1)} +@keyframes breathe{0%,100%{background:var(--on);box-shadow:0 0 0 3px #38d9b92e,0 0 12px #38d9b9b3}50%{background:color-mix(in srgb,var(--on) 55%,var(--line-2));box-shadow:0 0 0 0 transparent}} +@keyframes follow{0%{background:var(--on);box-shadow:0 0 0 3px #38d9b92e,0 0 12px #38d9b9b3}50%{background:var(--line-2);box-shadow:inset 0 0 0 1px #0000001a}} +.timeline{position:relative;height:30px;margin:16px 0 4px;border-radius:9px;background:linear-gradient(90deg,#f6d36533,#ffd38a55 50%,#f6d36533);overflow:hidden;border:1px solid var(--line)} +.timeline .night{position:absolute;top:0;bottom:0;background:linear-gradient(180deg,#1d3557,#0f2233);opacity:.9} +.timeline .now{position:absolute;top:-2px;bottom:-2px;width:2px;background:var(--danger);border-radius:2px} +.timeline-scale{display:flex;justify-content:space-between;font-size:11px;color:var(--ink-3);font-variant-numeric:tabular-nums} +.legend{display:grid;grid-template-columns:auto 1fr;gap:9px 12px;align-items:center;font-size:13px;color:var(--ink-2);margin:4px 0 6px} +.legend .lamp{--on:#38d9b9} +.l-setup{--a:follow 1s steps(1)}.l-join{--a:short1 2s}.l-pair{--a:short2 2s}.l-error{--a:short3 3s}.l-find{--a:follow .5s steps(1)} +@keyframes short1{0%,8%{background:var(--on)}9%,100%{background:var(--line-2)}} +@keyframes short2{0%,8%,16%,24%{background:var(--on)}9%,15%,25%,100%{background:var(--line-2)}} +@keyframes short3{0%,6%,12%,18%,24%,30%{background:var(--on)}7%,11%,19%,23%,31%,100%{background:var(--line-2)}} +/* Pairing */ +.code-box{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:16px 18px;border-radius:14px;background:var(--surface-2);border:1px dashed var(--line-2);margin:12px 0 4px} +#matter-code{font-size:26px;font-weight:600;letter-spacing:.08em;font-variant-numeric:tabular-nums} +.state-line{display:flex;align-items:center;gap:8px;font-size:14px;color:var(--ink-2)} +.state-line i{width:8px;height:8px;border-radius:50%;background:var(--ink-3)}.state-line[data-tone=ok] i{background:var(--ok)}.state-line[data-tone=open] i{background:var(--accent);box-shadow:0 0 0 4px var(--accent-soft)} +/* Update */ +.version-big{font-size:30px;font-weight:600;letter-spacing:-.03em;margin:4px 0 2px} +.update-state{display:flex;align-items:center;gap:14px;padding:16px;border-radius:14px;background:var(--surface-2);margin:12px 0 4px} +.update-state .ui{width:42px;height:42px;border-radius:13px;display:grid;place-items:center;flex:none;background:var(--surface);color:var(--ink-2);border:1px solid var(--line)} +.update-state[data-tone=ok] .ui{background:var(--ok-soft);color:var(--ok);border-color:transparent} +.update-state[data-tone=new]{background:linear-gradient(90deg,var(--accent-soft),var(--surface-2))}.update-state[data-tone=new] .ui{background:var(--accent);color:var(--accent-ink);border-color:transparent} +.update-state[data-tone=warn] .ui{background:var(--warn-soft);color:var(--warn);border-color:transparent} +.update-state[data-tone=busy] .ui svg{animation:spin 1.2s linear infinite} +.update-state b{display:block;font-size:15px}.update-state small{display:block;font-size:13px} +.drop{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;text-align:center;padding:24px 16px;border-radius:14px;border:1.5px dashed var(--line-2);background:var(--surface-2);cursor:pointer;margin-top:12px;transition:border-color .15s,background .15s} +.drop:hover,.drop.over{border-color:var(--accent);background:var(--accent-soft)}.drop svg{width:28px;height:28px;color:var(--accent)} +.drop b{font-size:14.5px;overflow-wrap:anywhere}.drop small{font-size:12.5px}.drop input{position:absolute;width:1px;height:1px;opacity:0} +.steps{list-style:none;display:grid;grid-template-columns:repeat(5,1fr);gap:6px;padding:0;margin:18px 0 6px;counter-reset:s} +.steps li{position:relative;padding-top:30px;text-align:center;font-size:12.5px;color:var(--ink-3)} +.steps li:before{counter-increment:s;content:counter(s);position:absolute;top:0;left:50%;transform:translateX(-50%);width:24px;height:24px;border-radius:50%;display:grid;place-items:center;font-size:12px;font-weight:700;background:var(--surface-2);border:1px solid var(--line-2);color:var(--ink-3);z-index:1} +.steps li:after{content:"";position:absolute;top:11px;left:calc(50% + 14px);right:calc(-50% + 14px);height:2px;background:var(--line)}.steps li:last-child:after{display:none} +.steps li[data-state=done]{color:var(--ink-2)}.steps li[data-state=done]:before{content:"✓";background:var(--ok);border-color:var(--ok);color:#fff}.steps li[data-state=done]:after{background:var(--ok)} +.steps li[data-state=active]{color:var(--ink);font-weight:650}.steps li[data-state=active]:before{background:var(--accent);border-color:var(--accent);color:var(--accent-ink);box-shadow:0 0 0 5px var(--accent-soft)} +.steps li[data-state=skip]{opacity:.45} +#install-note{font-size:13.5px;color:var(--ink-2);text-align:center;margin:8px 0 0} +.danger-zone{border-color:color-mix(in srgb,var(--danger) 30%,var(--line))}.danger-zone .card-head h2 svg{color:var(--danger)} +.row-action{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 0;border-top:1px solid var(--line)} +.row-action:first-of-type{border-top:0}.row-action b{display:block;font-size:14.5px}.row-action small{display:block;font-size:13px} +/* Dialogs & toast */ +dialog{width:min(440px,calc(100% - 32px));border:1px solid var(--line);border-radius:22px;padding:26px;background:var(--surface);color:var(--ink);box-shadow:0 30px 80px -20px #0a1a2799} +dialog[open]{animation:pop .22s cubic-bezier(.2,.9,.3,1.2)}@keyframes pop{from{opacity:0;transform:translateY(10px) scale(.97)}} +dialog::backdrop{background:#08131c8c;backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px)} +.dlg-icon{width:46px;height:46px;border-radius:14px;display:grid;place-items:center;background:var(--accent-soft);color:var(--accent);margin-bottom:14px} +.dlg-icon.danger{background:var(--danger-soft);color:var(--danger)} +dialog h2{font-size:19px}dialog p{color:var(--ink-2);font-size:14px} +.dlg-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:20px} +#login-note{min-height:22px;font-size:13px;color:var(--ink-2);margin:10px 0 0}#login-note[data-tone=error]{color:var(--danger)} +.toast{position:fixed;left:50%;bottom:calc(24px + env(safe-area-inset-bottom));transform:translateX(-50%);z-index:50;display:flex;align-items:flex-start;gap:10px;max-width:min(520px,calc(100% - 32px));padding:12px 16px;border-radius:14px;background:#0f2233;color:#eef6f8;font-size:14px;box-shadow:0 18px 40px -12px #0a1a27b3;animation:pop .25s ease} +.toast svg{margin-top:1px;color:#7ef0d8}.toast[data-kind=error] svg{color:#ff9b8f}.toast[data-kind=success] svg{color:#7ef0d8} +@media(prefers-color-scheme:dark){.toast{background:#e5eef4;color:#0f2233}.toast svg{color:var(--accent-ink)}.toast[data-kind=error] svg{color:#b8372b}} +footer{font-size:12px;color:var(--ink-3);padding:6px 2px 0} +@media(max-width:899px){ + .layout{display:block;padding:18px 16px calc(var(--tab-h) + 32px + env(safe-area-inset-bottom))} + .tabs{position:fixed;top:auto;left:0;right:0;bottom:0;z-index:30;flex-direction:row;gap:0;padding:6px 6px calc(6px + env(safe-area-inset-bottom));background:color-mix(in srgb,var(--surface) 86%,transparent);backdrop-filter:saturate(1.6) blur(16px);-webkit-backdrop-filter:saturate(1.6) blur(16px);border-top:1px solid var(--line)} + .tabs button{flex:1;flex-direction:column;gap:3px;padding:7px 2px;font-size:11.5px;text-align:center;border-radius:12px} + .tabs button[aria-current=page]{background:none;box-shadow:none;color:var(--accent)} + .tabs .lock{position:absolute;top:5px;right:calc(50% - 20px);margin:0;width:12px;height:12px}.tabs .dot{left:calc(50% + 6px);top:6px} + .rail-note{display:none}.toast{bottom:calc(var(--tab-h) + 18px + env(safe-area-inset-bottom))} + .hero{padding:18px 18px 20px}.hero-body{grid-template-columns:1fr;gap:14px}.gauge{width:min(250px,78vw);margin:0 auto} + .tiles{grid-template-columns:1fr 1fr}.tiles .tile:last-child{grid-column:1/-1} + .facts{grid-template-columns:1fr 1fr}.grid2{grid-template-columns:1fr}.modes{grid-template-columns:1fr 1fr} + h1{font-size:23px}.card{padding:18px}.brand small{display:none}#login-open span{display:none}#login-open{padding:0 11px} +} +@media(max-width:520px){.form-row{grid-template-columns:1fr}.segmented{display:grid;min-width:0}.steps li{font-size:11px}.account span{display:none}.pill{padding:0 9px}.pill span{display:none}.pill i{margin:0}} +@media(prefers-color-scheme:dark){.timeline{background:linear-gradient(90deg,#f6d36512,#ffd38a2b 50%,#f6d36512)}.timeline .night{background:linear-gradient(180deg,#2a4a7a,#16264a)}} +@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition:none!important}} + + +
CommandKitCMNDKT-004 智慧插座 · 本機管理
+
正在連線 + +
+
+ +
+ + + +
+

狀態總覽

即時電力與裝置狀態

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

—V

交流有效值
+
電流

—A

交流有效值
+
累積用電

—kWh

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

等待量測資料每 2 秒更新

+

裝置資訊

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

管理員登入

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

+
+

+

確認操作

+ diff --git a/firmware/matter/src/update/update.js b/firmware/matter/src/update/update.js index 67ec4be..47ff187 100644 --- a/firmware/matter/src/update/update.js +++ b/firmware/matter/src/update/update.js @@ -39,108 +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,hmac256,meterView}; +/* 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); - let auth={authenticated:false},csrf='',status=null,protocol=null,busy=false,latest=null,protocolDirty=false,ledDirty=false; + 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 view(name){$('overview').hidden=name!=='overview';$('settings').hidden=name!=='settings'||!auth.authenticated; - $('nav-overview').setAttribute('aria-current',name==='overview'?'page':'false');$('nav-settings').setAttribute('aria-current',name==='settings'?'page':'false');} - function drawAuth(data){auth=data;csrf=data.csrf||'';$('account-state').textContent=data.authenticated?'管理員':'訪客檢視'; - $('login-open').hidden=data.authenticated;$('logout').hidden=!data.authenticated;$('nav-settings').hidden=!data.authenticated; - $('guest-note').hidden=data.authenticated;$('setup-notice').hidden=data.configured!==false;$('login-open').disabled=data.configured===false; - if(!data.authenticated)view('overview');else{$('login-panel').hidden=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){$('account-state').textContent='管理設定準備中';return;}drawAuth(data);} + 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});$('login-panel').hidden=false;throw Error('請先登入,或重新登入後操作');} + 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){return new Promise(resolve=>{const dialog=$('confirm-box');$('confirm-text').textContent=text; + 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);$('settings').querySelectorAll('button').forEach(b=>b.disabled=value);} - async function run(job){if(busy)return;setBusy(true);$('settings-message').textContent='處理中…';try{await job();}catch(e){$('settings-message').textContent=e.message;}finally{setBusy(false);}} - $('nav-overview').onclick=()=>view('overview');$('nav-settings').onclick=()=>{view('settings');settingsRefresh().catch(()=>{});}; - $('login-open').onclick=()=>{$('login-panel').hidden=false;$('login-password').focus();};$('login-cancel').onclick=()=>{$('login-panel').hidden=true;$('login-password').value='';}; - $('logout').onclick=async()=>{try{await api('/auth/logout');drawAuth({authenticated:false,configured:true});}catch(e){$('settings-message').textContent=e.message;}}; - $('login-form').onsubmit=async event=>{event.preventDefault();$('login-submit').disabled=true;$('login-note').textContent='正在驗證密碼,請稍候…'; + 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});$('login-note').textContent='';view('settings');await settingsRefresh(); - }catch(e){$('login-note').textContent=e.message;}finally{$('login-submit').disabled=false;}}; - function drawMeter(data,offline=false){const v=meterView(data); - for(const [id,value]of[['voltage',v.voltage],['current-amps',v.current],['power',v.power],['energy',v.energy],['relay',v.relay],['meter-updated',v.note]])$(id).textContent=value; + 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; - $('live-state').dataset.state=offline?'offline':v.complete?'live':'waiting';$('live-state').lastElementChild.textContent=offline?'連線中斷':v.complete?'即時連線':'已連線,等待量測'; - if(protocol?.active==='homekit'){$('meter-state').textContent='HomeKit 模式';$('meter-updated').textContent='自訂即時量測及 kWh 累計暫停;顯示已保存的用電紀錄';if(!offline){$('live-state').lastElementChild.textContent='管理連線正常';$('live-state').dataset.state='live';}}} - async function refresh(){const r=await fetch('/status',{cache:'no-store'});if(!r.ok)throw Error('裝置尚未準備好');status=await r.json();$('current').textContent=status.version; - const p=await fetch('/protocol',{cache:'no-store'});if(p.ok){const previous=protocol?.selected;protocol=await p.json();const name=protocol.active==='homekit'?'HomeKit':protocol.active==='matter'?'Matter':'維修模式';$('active-protocol').textContent=name;$('protocol-status').textContent='目前:'+name+(protocol.restart_pending?' · 等待重啟':''); + 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 ledDescriptions={smart:'正常時跟隨開關,設定、連線或配對時提供提示。',relay:'插座開啟時亮,關閉時滅;可使用主動尋找。',inverse:'插座關閉時亮,方便在暗處找到;可使用主動尋找。',load:'連續兩次讀值達 5 W 開始閃爍,降至 2 W 以下停止。',on:'持續亮燈;夜間排程可暫時熄燈。',off:'平常熄燈,設定、配對、異常或主動尋找時仍會亮燈。'}; - $('led-settings').addEventListener('input',()=>{ledDirty=true;$('led-description').textContent=ledDescriptions[$('led-mode').value];}); + 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(); - const reasons={normal:'依平常模式顯示',night:'夜間熄燈中',pairing:'等待家庭配對',joining:'正在連接 Wi-Fi',setup:'等待初次設定',error:'服務或 MCU 通訊異常',identify:'尋找插座中'}; - $('led-status').textContent=d.service_ready?(reasons[d.reason]||'正在讀取狀態'):'指示燈服務正在準備中'; - $('led-clock').textContent=d.time_valid?'時間已校準 · '+(d.time_source==='ntp'?'時間伺服器':'手機時間')+(d.night?' · 目前為熄燈時段':''):'排程等待校時:可使用下方按鈕校準'; - if(!ledDirty){$('led-mode').value=d.mode;$('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;$('led-description').textContent=ledDescriptions[d.mode];}} - $('led-settings').onsubmit=e=>{e.preventDefault();run(async()=>{const start=ledMinutes($('led-start').value),end=ledMinutes($('led-end').value),offset=Number($('led-offset').value)*60,ntp=$('led-ntp').value.trim(); + $('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 '+$('led-mode').value+' '+Number($('led-schedule').checked)+' '+start+' '+end+' '+offset+' '+ntp+'\n');ledDirty=false;$('settings-message').textContent='燈號設定已保存,會立即套用。';await ledRefresh();});}; - $('led-find').onclick=()=>run(async()=>{await api('/admin/led-find');$('settings-message').textContent='插座將閃燈 15 秒,之後恢復原本燈號。';}); - $('led-phone-time').onclick=()=>run(async()=>{await api('/admin/led-time',String(Math.floor(Date.now()/1000)));$('settings-message').textContent='已送出手機時間,本次開機的排程可離線繼續計時。';await ledRefresh();}); + 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 data=await r.json();const m=data.matter; - $('pairing-state').textContent=protocol?.active==='homekit'?'HomeKit 請使用原廠標籤配對碼。':m?'Matter 已配對 '+m.fabrics+' 個家庭;'+(m.window_open?'配對視窗開啟中':'配對視窗已關閉'):'Matter 正在準備中'; - const onboarding=await fetch('/admin/onboarding',{cache:'no-store'});if(onboarding.ok){const d=await onboarding.json();$('matter-code').textContent=d.manual_code||'尚未就緒';}await ledRefresh();} - async function waitReceipt(id){const until=Date.now()+60000;while(Date.now()setTimeout(r,1000));const r=await fetch('/admin/state',{cache:'no-store'});if(!r.ok)continue;const {matter}=await r.json(); + 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'){$('settings-message').textContent='目前已使用此協議。';return;}await restartMessage('協議選擇已保存,正在重新啟動');}); - $('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);$('settings-message').textContent='累積用電已歸零並保存。';drawMeter(null);await settingsRefresh();}); - $('commissioning-open').onclick=()=>run(async()=>{const r=await api('/admin/open-commissioning');await waitReceipt(r.request_id);$('settings-message').textContent='Matter 配對視窗已重新開啟。';await settingsRefresh();}); - $('pairing-reset').onclick=()=>run(async()=>{if(!await ask('清除目前協議的家庭配對?Wi-Fi、管理密碼和累積用電會保留。'))return; - const r=await api('/admin/pairing-reset');if(r.status==='restarting')await restartMessage('正在重設配對並重新啟動');else{$('settings-message').textContent='正在重設配對,請稍候再查看狀態。';}}); - $('reboot').onclick=()=>run(async()=>{await api('/admin/reboot');await restartMessage('正在重新啟動');}); - $('factory-reset').onclick=()=>run(async()=>{if(!await ask('恢復出廠將清除管理密碼、Wi-Fi、HomeKit/Matter 配對與全部累積用電。請確認要清除並重新設定。'))return; - await api('/admin/factory-reset');$('settings-message').textContent='正在恢復出廠。重啟後連接 CommandKit-Setup 設定熱點重新設定。';drawAuth({authenticated:false,configured: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='';$('settings-message').textContent='已送出 Wi-Fi 設定。請切回相同網路,並使用插座的新 IP 開啟管理頁。';});}; + 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('兩次密碼不相同'); - $('settings-message').textContent='正在保護新密碼,請稍候…';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});$('login-panel').hidden=false;$('login-note').textContent='密碼已更新,請使用新密碼登入。';});}; - async function install(bytes,expected){if(bytes.length!==status.max_size)throw Error('請選擇完整 .ota 更新包'); - $('message').textContent='正在驗證並上傳…';const digest=hex(sha256(bytes));await api('/upload',bytes,digest); - $('message').textContent='驗證完成,正在安裝。請保持供電…';await api('/apply','',digest); - const until=Date.now()+240000;while(Date.now()setTimeout(r,4000));try{await refresh();if(status.software_version===expected&&(status.healthy||status.rescue)){ - drawAuth({authenticated:false,configured:true});$('login-panel').hidden=false;$('login-note').textContent=status.rescue?'新版已啟動,目前為維修模式。':'更新完成,請重新登入。';return;}}catch{}} + 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'||irun(async()=>{const file=$('package').files[0];if(!file)throw Error('請選擇 .ota 更新包');const b=new Uint8Array(await file.arrayBuffer());if(b.length<256)throw Error('更新檔案不完整');await install(b,new DataView(b.buffer).getUint32(20));}); - $('check').onclick=()=>run(async()=>{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=c.updates.filter(x=>x.model==='CMNDKT-004'&&x.variant==='matter'&&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').textContent=latest?'有新版本 '+latest.version:'目前已是最新版本。'; - }catch(e){$('message').textContent='無法檢查線上版本:'+e.message+'。仍可使用手動更新。';}$('settings-message').textContent='';}); - $('online').onclick=()=>run(async()=>{if(!latest?.url.startsWith('https://'))throw Error('更新來源不符');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);}); + async function installJob(load,online){installing=true;$('install-card').hidden=false;steps(online?'download':'upload',online);$('install-card').scrollIntoView({block:'center'}); + try{const [bytes,expected]=await load();await install(bytes,expected,online);} + catch(e){$('install-note').textContent=e.message;document.querySelector('#install-steps li[data-state=active]')?.removeAttribute('data-state');throw e;} + finally{installing=false;drawUpdate();}} + async function readAll(response,size,progress){if(!response.body?.getReader)return new Uint8Array(await response.arrayBuffer()); + const reader=response.body.getReader(),out=new Uint8Array(size);let at=0,shown=-1; + for(;;){const {done,value}=await reader.read();if(done)break;if(at+value.length>size)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();}catch{}}setTimeout(statePoll,10000);} + 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);}); - try{await refresh();await authRefresh();}catch{$('account-state').textContent='連線中斷';}monitor();statePoll(); + 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 95b80a6..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 / SMART OUTLET
訪客檢視
\n
\n\n\n

狀態總覽

正在連線
\n
正在讀取量測…—
\n
電壓
—V

交流有效值

\n
電流
—A

交流有效值

\n
即時功率
—W

目前消耗的功率

\n
累積用電
—kWh

功率估算 · 約 30 秒保存

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

裝置資訊

韌體版本—
目前協議—
網路位址—

登入後可變更設定、更新韌體與重設累積用電。

\n\n
CMNDKT-004 · 讀值無效或連線中斷時顯示「—」。實體長按至重啟,可恢復出廠設定。
\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"; From 1a264ac5c170d5434123c87437dfe5b978644490 Mon Sep 17 00:00:00 2001 From: lee98064 <46412433+lee98064@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:40:40 +0800 Subject: [PATCH 12/13] Compile LED and energy sources in the PID1 updater test ck_updater.c has called ck_led_* and ck_energy_reset since v0.2.11, but the native PID1 fixture still linked only the original updater sources. Use the same source list as tools/build_updates.sh. Co-Authored-By: Claude Opus 5.5 --- tests/test_update_pid1.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From bfb98845fa581de3cfa213a421714b3adfffdee7 Mon Sep 17 00:00:00 2001 From: lee98064 <46412433+lee98064@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:40:40 +0800 Subject: [PATCH 13/13] Release v0.3.0-preview (software version 14) Bump the version, add release notes, and record the public audit and release validation for the redesigned management console and setup portal. Kernel, Bank 2, signing key and update feed URL are unchanged from v0.2.11. Co-Authored-By: Claude Opus 5.5 --- docs/ota-release-notes.md | 9 + .../matter/src/matter-app-device/Version.h | 4 +- verification/public-audit-v0.3.0-preview.json | 177 +++++ verification/release-v0.3.0-results.json | 659 ++++++++++++++++++ version.json | 4 +- 5 files changed, 849 insertions(+), 4 deletions(-) create mode 100644 verification/public-audit-v0.3.0-preview.json create mode 100644 verification/release-v0.3.0-results.json diff --git a/docs/ota-release-notes.md b/docs/ota-release-notes.md index 94d29bc..e77d93e 100644 --- a/docs/ota-release-notes.md +++ b/docs/ota-release-notes.md @@ -1,3 +1,12 @@ +## 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) diff --git a/firmware/matter/src/matter-app-device/Version.h b/firmware/matter/src/matter-app-device/Version.h index dd1eded..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 13 -#define CK_VERSION_STRING "0.2.11-preview" +#define CK_SOFTWARE_VERSION 14 +#define CK_VERSION_STRING "0.3.0-preview" diff --git a/verification/public-audit-v0.3.0-preview.json b/verification/public-audit-v0.3.0-preview.json new file mode 100644 index 0000000..958289d --- /dev/null +++ b/verification/public-audit-v0.3.0-preview.json @@ -0,0 +1,177 @@ +{ + "status": "pass", + "scope": "selected public files and decompressed release rootfs", + "public_file_count": 5172, + "public_bytes": 26048230, + "variants": { + "matter": { + "sha256": "2117aea5d5a2e61e77bd6d9c1d19f984bd5b78e09ce3f26ad07b18771365ef9c", + "size": 8388608, + "regular_files": 159, + "file_sha256": { + "bin/WACServer": "fc7fad2d10fd83927f82f5471bcf70fcb853ab54a140aa6dad27682efd02e63f", + "bin/acltd": "1f3cd73bed035ad5a7b1215a7a31dd43ec441231cafa05c6017fbfc2e2af2cb7", + "bin/auth": "af1f4e5f83842233b9c3ba499e1fe019bf45e682787220faab58500f3d855031", + "bin/avahi-autoipd": "e3ca507667b99a4fcbc0ed8d0d640abc3998332e24691ee50e04d5c3e1c63c8b", + "bin/brctl": "f4acc1905b421d52f5e8e6e24f483080f64194d3e40896f52659214569521218", + "bin/busybox": "3cd0ec8270214a74879ee4b67e592be285e23d6a9cca964b64a86b88c821e619", + "bin/ck-boot-guard": "37565ba17daec3f26d855b9501d534162f4fea57d3927fa7645dde7990c5d7c4", + "bin/ck-identity-start": "ff96d267f2e7d4e66568308e6b19c7284f03adca76c9279be2b2b4d2c176740e", + "bin/ck-lease": "d8d95da23cfdf66b9402c66025453fd56e7b29af2499e2fab04d834f796bb065", + "bin/ck-led-start": "41c881f8e8ba3eaa30228e3c8b980fe25b40b896f3073a9253f31aa0fd9a7658", + "bin/ck-management-start": "d2291c2a7be779f7e14f46ead4756c96325ee818a7c36b53bd01fd28ee3eab82", + "bin/ck-onboarding-start": "2138c37ad2d61db676b95a0fd244da542126825848828a1e68037a5d8c6eab1e", + "bin/ck-ota-start": "a31d0039bd6e7cd601b3e1f57391c570e16accddefc6c69772656e2a005c6d87", + "bin/ck-protocol-start": "c4c480e842af76719eb6906e4c273fe1e6601496daaa2307b9bea8a6cab218a4", + "bin/ck-ram-probe": "4ed68954f369f55288a559697fdaa52a0c980f944a135feb8877c9b14d8e40eb", + "bin/ck-reset": "6377d813a108e6977069ab4c7e87dff7750b60499c0e100465f8f0c82b403259", + "bin/ck-setup-backend": "9ffd29dda953921efad6e3c5460557253187b9469d15f25e0fefa0bc6a7d3535", + "bin/ck-setup-start": "e039cc1fc369e0935c0a39fc329a4c61f9f4e58635676f122de31d10abb2e9d5", + "bin/ck-setupd": "f3a66e406e32e6a2f4f4587305700132da83cea99a059de4ffc8c7aebd1d799f", + "bin/ck-udhcpc": "97eb31d69d3514a5a552b075a5089527ef95917e29ee08105a830996d3ac6b85", + "bin/ck-updated": "137bcaa8f7b83c4f4ce4d223044509dde8bc9f6e04fe086182a78f778ae10f29", + "bin/ck-updater": "1442c6010feeb5566ba852f43a1e3513bd3113d2a316b6d0456b9bd7af8ada28", + "bin/ck-wificfg": "dc4ce172132174445e5391e88481778099eceaa9e6a8d99711c7400124028383", + "bin/ck-wifiscan": "efe0312be9ea9c961f75faa0785d0281c8239ebec9cf2faa81398994d52b8268", + "bin/connect.sh": "116a58d9caa45a8a83a43b7db5e42505b3e7640e4cdee06e62c6dcc7b576b433", + "bin/connect6.sh": "97e8946dc7c28bdbde8beda01e87315e3bedf07e1704db140c8c889a036dd0aa", + "bin/ddns_inet": "d1d3bc14909540e6f17f07d72ae6d208fefc4bfb9aa33c97fbc784d2986affb4", + "bin/dhcp6c": "50cc13f2cb4b10d2a44a90750760cf8bc7227b40d39a8bb1c1a6cec45d1873cb", + "bin/dhcp6cRcv.sh": "72d0f7430a95c2e5405d3b469a44cc788cac215eb8e5d91f7182e366f595e206", + "bin/dhcp6ctl": "c391c1a0828c2e5c7a2e4892fa7dce1dd28018f2a0610bdb9ac1c623f9cc77ae", + "bin/dhcp6s": "794c877af3c4bef3d3f457e1afc7ae9bd635bc775ec8bb4f4152176e73b26823", + "bin/disconnect.sh": "ec469e947e3e2e4c9bf0aa0a6d95a4a24d9bb7521ed467d5bd183a74dfef6798", + "bin/dnrd": "55c99a83c659cbd127e75b7c34c13f73853f9c6e612271bd311f81a1a711ebd8", + "bin/dnsmasq": "541c75037f62bc3a104989ece61b044463370f15219c4c4ae21e7f68ceb159b6", + "bin/dw": "5ef838655702ff0ab7cb8f5993a31e5903afc1e437a17e44f4c4842124d72266", + "bin/ecmh": "b56eb2a6e88722ba361042b329d67b3d5bc3634aa083690c949596c239e7f81f", + "bin/ew": "1a3e6bc1afc8345387dc9a38887e6af4adde619bc8743c25f0366df6928a649a", + "bin/firewall.sh": "5688eba3b9e7f322c8e7b1e2e831f6e999d6fcec125283fc8de75d144e42e7f1", + "bin/flash": "28aca13b5d8df12040dd2c6ffb3dc69e61bfd1e0c860d25447f3a08e66963523", + "bin/fwd": "698eb8a02fcc0f2dcfca6702ed766f7338dfe51f4df6f85c72a2a78d789a190c", + "bin/getmib": "e3c872196f6afe6501514101614cc8b8ece902e6f8b6b9a1ad352a10c2a61e25", + "bin/getmib1": "89bc956fe6fd4e5e134c8769e611253fecf50272e2396ae65015e44d5d3d8b44", + "bin/gigadevice_chk": "f555ae6b974c2e7303072a1441820b171907993dd389e062557c7ac9d5191a19", + "bin/homekitserver": "c5f5484772e80a28421cda71e7e182ac4db527a0b4409e0898da0e16f0ed18ad", + "bin/iapp": "0dda7f960b1b0340f03f1823dc457b0b2fef84dd887d3b07d2158e5845f78bdb", + "bin/ib": "23540feab73c9f0f287e4f91284343ec8677aac9e5b1f51bb2bcf0db1075e697", + "bin/ib1": "439aeeffb8b3253384713b53f95120a2206f97911c64ebb90366c487ec1f2024", + "bin/id1": "cead109ab787def7dc01e63c4cdbb782493f588cecd709aee61a298b263d6dbd", + "bin/idd": "fd0a6763249a6064351ba787fbadce230b9ccfdeacff4f3de662b0c40b46df50", + "bin/idd1": "cead109ab787def7dc01e63c4cdbb782493f588cecd709aee61a298b263d6dbd", + "bin/init.sh": "37a89df20b2efa6b9e4268320fae90e29774dc894168a257f25b89f452e7bda2", + "bin/ip_qos.sh": "0bbaa983ce78b27b561a9c780c3ba9264563d4a38b61209f1898fc83a5473ee0", + "bin/irf": "848040ed72207dc5a47d3f67d6f54f8ce6eb3f36c38104089aa438d55114bfcc", + "bin/irf1": "9e7d8c249b22c9e65e9dc3050bff451f3bdeaf9b4eb8db6bbec5b9b39899eb0d", + "bin/iw": "82e621203cfa87228dc1bb520f2837bf7833346a0fc9f16757970f8ed07f11da", + "bin/iw1": "0ee09886704fa633ecd6111c1fe8ce373054e2c5feed8924a006255017ff8987", + "bin/iwcontrol": "5df1e0c326ef676d8dcab9885e079b9b25bc956b5aba6cb1e99af5dc11c5ae0a", + "bin/iwpriv": "0d54f6042b99bfbecbbeb68152a462f8a54663407a7ecf804c2c8bca2d6da6d9", + "bin/killsh.sh": "402bc7a2845df34d9c15049c38dac2688cd448995a908def3a27438fc762c019", + "bin/l2tp.sh": "075ba0c8e3a533efcbe14d42f66d3ef1163571fc22759e249ffcca17496ec0bb", + "bin/matter-diag": "9509d9cf53a517628fc1030e345b88376947540bac6327509ed02720db1212ec", + "bin/matter-diag-start": "d214053c374cbe396097f76a8fa97927a767fad3620bfc40ab1116e38fb5c31a", + "bin/matter-test-start": "81b58c484ea70d27607b587cf12153d287b79309ebd49d5cc790bc9c4d8d234e", + "bin/matterd-test": "e431cf10fd82700dc6968953d2da22cb9ca06d42aba684c0851c0f20a80569f3", + "bin/mdnsd": "7d5243636c0870e9cf32f4ecda0d494c6b47091da70cd470911e5961430ca177", + "bin/mldproxy": "eed2c2737eeefc4e808ce088b5c2ee7611fb93e414c826d4bf116218b51d48c3", + "bin/mmd_cmdr": "b71745208ba972d3a02991af759d4827b81c2e5156995c47e09b558b640cb163", + "bin/mmd_cmdw": "aeb2542634581cccaf6a9e76a41b69147c840663b2aff43b652ad5a1c4a5a346", + "bin/mp.sh": "e41e053700105de8511c57a984cbd03347381bcc48497ac391bde8bf6b68cb99", + "bin/ntp_inet": "79d56c4369c418fb4a4920f0394aec7c1e7ccccc9dbe119bfb0518f2395abcfa", + "bin/ntpclient": "e736240aab44bdd9d701bafe6eeaf383fa98878e57cb2913126e524185678aec", + "bin/ob": "452a3031a31802e8a7a79e200d2ef1c149ddb045a83ca9faea72133f1d10d4bd", + "bin/ob1": "c3f3f85889c443c69b17daa93fe0aa27e1e2dca99fa7e0f36b5579e749c1dc7b", + "bin/od": "be0f8099e8e1440e10b1b7cf6bf4ed063ff926887c4334fd47c941fe05c5635d", + "bin/od1": "aaae9dc1b71c3ee083efb9fc3b4862be9a32deb117a8b2edac5a3a057226e213", + "bin/orf": "6de1b42f0975076302439227c5542c353faff0293ff49c27932bec9b94d373c8", + "bin/orf1": "0d83ffdf74e8b6153062b24c765cb562bae115bb6e94ceccf40e198a26268b99", + "bin/ow": "3e06231474fe83569add90f05af1fbd478e243da89fa7767a6f1e27efab5b2ac", + "bin/ow1": "cb4d076d2a8efc2168e5a5d67b3da4ac3b0eec54b9fecf0735d3a4dc5602b07e", + "bin/phyr": "0ea4bf45ead3a0da7d55feaeef12bcfb095e63f69a70a842672f36b008065e80", + "bin/phytrex_tools": "a4733e1b2b8c52e48c109c9c40a2b9af532248e4b4116eb72260b1f0f20e6fc2", + "bin/phyw": "830bfdb9473fae12ab9aa8298d7aa3f9831bff63e7393a7f2b29dd530f134545", + "bin/ppp_inet": "44b1db22dc2b786e90ed558175915a9eef926a7818bd43f1c675724a06b77109", + "bin/pppoe.sh": "28d59a56e07f42c240ec59a7ac5a784371d00cef92f1984943232b3050ca011b", + "bin/pppoe_conn_patch.sh": "b6da8ac9633bf1a4a2bc665945247882ef8181e1fca9e65f3a8e7af631cc1e7e", + "bin/pppoe_disc_patch.sh": "025aac686216515eb57a2a26f5bb7b7c507f66b48c5404d8502dc0f414f1cb93", + "bin/pptp.sh": "c79acbc24a203398e6858f464065d65dc83eb0087bb3d6fd0ac47ed294a7754b", + "bin/radvd": "9db7fe7c307562b562fc1a96a33ece6b53be4cafe7bc63648d09a8c4567135b6", + "bin/radvdump": "c4c9694b057105652c461308b901dfe02da3bdf8901175dfcd8d112e6a21d71e", + "bin/reload": "22f689f717c259ecbeb628cc051fa4d82a3011146f2f4e48fd37cd5380184be3", + "bin/rssi": "091d5107b5c27b266252585b7c9992df4b2270704f81dc939eb1bbccc7a0ee6d", + "bin/rssi1": "1cca70b5b1cca70afdfa9637b180d904e0236cc487ac55d8ab1395283dcb8c67", + "bin/rtk_cmd": "89dbb16d504b668c48abae30b0d99724c20df7ce1a35faa55cc18f887bc7d7bb", + "bin/rtk_eventd": "1638b8ace10a21145ce26df5c835e8e8aef72bfbfd4f6ff13a91f3894ba9be38", + "bin/setmib": "2025b4ed81e89dcad0a58d4411c101ca67f0d6fdb85b1eb73539049f8675a3a3", + "bin/setmib1": "89ef32a13b8888d9e2b2f1e6a84c849f0d35e73e03c94c82d5be11f68f59209b", + "bin/snmpd.sh": "014ca345c401ea958db53e7dcb7c4dc050198f680e9a746410268a7a84a0875b", + "bin/startup.sh": "d620040cff0aa1ada99a9d68d1fc8436cd335ace89481880c5c2daf70a7e8fdb", + "bin/sysconf": "a88ffd520ea3382bcd1b656c1c2b4e02a416619da2fc87d4fe108ee08e76c9a0", + "bin/timelycheck": "cf7afc7be127701b340c4ddcfacfeb690af40041b6c93fc37e29e63c0ce41511", + "bin/udhcpd": "6c65f86b575cc3956504e37b9688fe6b0cafae8a38938aab56a2973b0802a699", + "bin/wfaudio": "ba269a6c1565c02b8322d18e3d40e89de6f7f98097a90d0ce06e3452218d5d22", + "bin/wlanapp.sh": "a45b6d40a8b126283a575f0bbf1bc97f076f39c58f1ca74f27bcb0997aaa563f", + "etc/avahi-autoipd.action": "6e14205c720227f5294bfb9348dc1071dcdcc914723695619a10d5e11c395176", + "etc/boa/boa.conf": "6236c22da5f177dcaad1bd6ec70ce3dca62cb1a56daf83b668933cd8c0aef6e1", + "etc/boa/mime.types": "1e3e9fe650ec67e99cafeddcfbaf52a11f910666ec11d0f2c7a264392622b9bb", + "etc/ck-admin-policy": "da10a76262a660a0a95545435532276129b611ae942186171a9668eb1d8737ce", + "etc/ck-homekit-led.json": "e324e4ed566292c37480a4fb7efb493e0d98ddbc8ce0c3e297ac9bc33c9245f9", + "etc/ck-identity-policy": "1961bf519033e4b89758257819c4efd74bda7ff2e7c0df43e1afc8b6bdd06f85", + "etc/ck-udhcpd.conf": "53e4458bbbb97de591d528efeeaa146eeaba2d5c7612d7b2389cadb4b8123819", + "etc/ck-update.meta": "ea9ce55198a94ebf37a3ab18078e2eadd8f41dd768be478117d59576e38ab499", + "etc/ck-update.pub": "386eb1742a602b5e9bd2007bcffd3a39c0a467500f92a1856d7c6059c88f5a2c", + "etc/ck-variant": "acbc154cdaa2d015cd86d03b5b1ce12da9accf509e6c00ddb6c7316b760f6209", + "etc/ck-version": "df46fcec1373bd6b1ac6e8ce5699d4e495149d824bd33796bd5e696c82956d2e", + "etc/customize.version": "44f87003455f39f4bb1e8fd1be8342c591b7c7fff44143d6949f6adbee4600b1", + "etc/dnsmasq.conf": "0ae2408b285a62bce4c8b0c7fab85e350889699d22feff1b6adcc54253bb95d4", + "etc/group": "5148fde8988f4762c1a14039adf6ca7cfcf3b201c71cd48332da60ca69ff49a8", + "etc/host.conf": "313728062c7ae797e11187c7f1d66b559f0098115320166bca6100486c167824", + "etc/hosts": "f80e3d5c3a0e4517e650bccee94c8033131e263d8458b37a18dcee440cb3819b", + "etc/init.d/rcS": "cf3a210f7de1e3b1f28081e0ec5b697afcbba247ef8d48e0570bc61dffeb65e7", + "etc/init.d/rcS_32M": "54b23c1e0a0a9a4f02484f6287e21f60588d0a98a790e76e9d9b4af3578237a0", + "etc/inittab": "6bdff20e7616da25dda86be1b685ee00fd1176a068716c5d0e9503635d9c9e65", + "etc/mime.types": "8d0a3ce3b74daccb330e158471cab898d40d4e40ed8a74ff9d710909a82cdf6e", + "etc/motd": "3b727708cd2efa362aa751c12c8d2e9e0e2050cb07b3d646dae2c6f4bcdd1cb3", + "etc/passwd": "8e619aab9bdf9ddd2644bed3ac319e862e908c4664b862120c2f4209c401bd32", + "etc/radvd.conf": "9ef8db8aa53148d8671e93b9d2879f18e1aec399ccba92aae324271796b5868e", + "etc/samba/smb.conf": "a56cfd4403599a01be6a78bfadcb1e6cc5177f7b4022e3a17e2ca861d2eb7c6d", + "etc/samba/smbpasswd": "2187a31165dc5cb2190130abdc04d6c5fef3d78ffc609b390b74a444f8e76c94", + "etc/services": "6ae28a7390a44890f3709f772dd5a2d8246880b40a734f1317cb117eea6de748", + "etc/shadow.sample": "106bf188d92eae79b255c106cb9f05777b5ba225ddef95132fb1da552b81caac", + "etc/svn.version": "9b3e52b747c1564c1a2b4eb757b829ae5d5095a0f591b8bcb49549364e75d461", + "etc/ushare.conf": "8e1b479172c4f1c7478a6658bae1a1049b607e7aacd24f2c40ff415b6e3da057", + "etc/version": "de64bb7381aaec500c2765ea8b939c7f1cc1f806609a932ba410e9e30a8957a8", + "etc/vsftpd.conf": "c86e2c41c3c17be5510db27e4998fbf372751727c340dce68362012d47744e92", + "lib/ld-uClibc-0.9.30.3.so": "35051ff978746f071c792d3d262b14f7e78dbbc461a9fd5c1e56a73966b0cb30", + "lib/libapmib.so": "e88721af68971ac96e949e7a918ae59e339cea1a3e7ecc9d40d59d5024e62824", + "lib/libdaemon.so.0": "a6ba44569b6a17ae9806aa89eb0bca7018946cc3aa4e167cf6b67e1f17ed8910", + "lib/libdl-0.9.30.3.so": "fd211b05beb2139a4e97a2441001be7554170e2602ac3c5298b228571cd1e3cf", + "lib/libdns_sd.so": "b0175db146f751120165c1d93a46be5e9da19f3e4201f13dbf0365fc053fddad", + "lib/libgcc_s.so.1": "c8f9860a3518dc0d6b78ff6640240aeb68e9b38a59aaf37bdc86bc1c0cd361d1", + "lib/libm-0.9.30.3.so": "bd31abbb878d32393b26573ae5b6055d1e73fa8126b4ade9a76465458d8bff2f", + "lib/libpthread-0.9.30.3.so": "7cde98eebc3ee088f150497282edb955b9d9aa1fb72f65603b0f6505d4919872", + "lib/librt-0.9.30.3.so": "ba9ebba8c75a71046716ac9ed2c37a25534ccf0647e0b123d8aea010738db2df", + "lib/libuClibc-0.9.30.3.so": "3bdde33cb95c8a4ce9a786d654f3e87f7a86e667201f14cea4fa33ee6749c278", + "usr/share/udhcpc/br0.renew": "0f225c0c1b9feeb416f46c86f729eabde73518f6920f26bf4dfaa8d501d54221", + "usr/share/udhcpc/br0.sh": "8be90d840672e3038b1e60ab59c206672479a28fb7168c9e2d9ffb267f3f787e", + "usr/share/udhcpc/eth0.bound": "394691277bad5f0f1a6b708d95e7ed57d6a0432ead294af299727f08c8c4904e", + "usr/share/udhcpc/eth0.sh": "f663605c21f57feadba977443b7b2ee298803752664b7b64207af0a75a27a17f", + "usr/share/udhcpc/eth1.bound": "e721f2ad88cbdfa3f37c5a7053a05c15766955f4b2d5ee9f7c7f7a195a7ad89b", + "usr/share/udhcpc/eth1.renew": "0f225c0c1b9feeb416f46c86f729eabde73518f6920f26bf4dfaa8d501d54221", + "usr/share/udhcpc/eth1.sh": "87e4066153462feec83c9ff7666812eb258bc4d38550e3498b31c33617441e7b", + "usr/share/udhcpc/wlan0-vxd.bound": "cf1ad0dc6a1fd3fd71b839a47e2c8ccce076db67608d7b87e412aa92010c02d3", + "usr/share/udhcpc/wlan0-vxd.sh": "092105e71c24ea2a1ecd4e8516b8bf32cd0468343bc803516f8b2e7baa54ec2e", + "usr/share/udhcpc/wlan0.bound": "cf1ad0dc6a1fd3fd71b839a47e2c8ccce076db67608d7b87e412aa92010c02d3", + "usr/share/udhcpc/wlan0.sh": "b60b564cf829f019db892c4b2c05d0950557f45e0cb8c33d8b1a0b11f4fd289c", + "usr/share/udhcpc/wlan1-vxd.bound": "cf1ad0dc6a1fd3fd71b839a47e2c8ccce076db67608d7b87e412aa92010c02d3", + "usr/share/udhcpc/wlan1-vxd.sh": "ad88d272a3ae0f31bfcff86a1e08be1342f57511615693782a7fb11a93b50189", + "usr/share/udhcpc/wlan1.bound": "cf1ad0dc6a1fd3fd71b839a47e2c8ccce076db67608d7b87e412aa92010c02d3", + "usr/share/udhcpc/wlan1.sh": "f4f1548b05bef9b69c13c32c2f157017b9623a946870774fd32e2246f8192d4c" + }, + "protected_ranges_all_ff": true, + "credential_files_absent": true + } + }, + "physical_device_access": false +} diff --git a/verification/release-v0.3.0-results.json b/verification/release-v0.3.0-results.json new file mode 100644 index 0000000..97c813c --- /dev/null +++ b/verification/release-v0.3.0-results.json @@ -0,0 +1,659 @@ +{ + "format": "commandkit-local-release-validation-v1", + "version": "0.3.0-preview", + "software_version": 14, + "created_at": "2026-11-12T08:35:53.495603+00:00", + "status": "validated", + "build_status": "pass", + "all_required_tests_passed": true, + "source": { + "commit": "35f9fe17616767a2c293792a738eea2658648326", + "branch": "feat/ui-v0.3", + "note": "version.json and docs/ota-release-notes.md contain the owner-provided uncommitted v0.3.0-preview changes; preserved byte-for-byte. Version.h was regenerated by the official generator.", + "constraints": { + "status": "pass", + "version_and_notes_preserved": true, + "ui_sources_preserved": true, + "generated_headers": [ + { + "path": "firmware/matter/src/update/password_script.h", + "changed_vs_head": false, + "sha256": "97003b1631ad4c7a708712fd639381af518c5f67fc7fcbaadc59f5b95aa5e73e" + }, + { + "path": "firmware/matter/src/update/update_page.h", + "changed_vs_head": false, + "sha256": "0d4d25308bf5441272dc3d3fcdcb6ce8e7cc0d4687fbb00e9004051732d7179e" + }, + { + "path": "firmware/matter/src/setup/portal-device-page.h", + "changed_vs_head": false, + "sha256": "e8b8f007c983567438d5a79f517d887ca73c7d483f2fedfd4995feefc35d6034" + }, + { + "path": "firmware/matter/src/setup/portal-scan-script.h", + "changed_vs_head": false, + "sha256": "3af9e14286c112d40fb92a7da8f1772b42675c3e779bdaa5fc24a558f02749b0" + } + ], + "tracked_modified_files": [ + "docs/ota-release-notes.md", + "firmware/matter/src/matter-app-device/Version.h", + "version.json" + ], + "source_commit": "35f9fe17616767a2c293792a738eea2658648326" + } + }, + "build": { + "full_official_compile": true, + "skip_compile": false, + "generator_duration_seconds": 0.121, + "release_duration_seconds": 84.073, + "matter_application_binary_sha256": "e431cf10fd82700dc6968953d2da22cb9ca06d42aba684c0851c0f20a80569f3" + }, + "release_directory": "artifacts/releases/v0.3.0-preview", + "artifacts": [ + { + "name": "SHA256SUMS", + "bytes": 495, + "sha256": "1c8fa368eb8d3adb9d5dadcd856e98d5aeeea9be4b06a8365b7f4e6c53d0b11f" + }, + { + "name": "index.json", + "bytes": 801, + "sha256": "5054a652230e0cb1849a946eb105ad1e8514359d3bdc5c802abd3c431fcecc18" + }, + { + "name": "matter-8m-region.bin", + "bytes": 8388608, + "sha256": "2117aea5d5a2e61e77bd6d9c1d19f984bd5b78e09ce3f26ad07b18771365ef9c" + }, + { + "name": "matter-8m-region.json", + "bytes": 676, + "sha256": "c99c09e3c036dab460ad3984aea2e337f5997a213d8930eaa520f8d097be847f" + }, + { + "name": "matter-matter-ota.bin", + "bytes": 2687321, + "sha256": "ac19113fb8d23f2f79cf4c1db6cce0f4611d6dc28a9ccd8f305aeeda9fe8182b" + }, + { + "name": "matter.json", + "bytes": 608, + "sha256": "871865af8a178c9bf55f2793b2d154421fc9650f4c9939d619c88a695ef09184" + }, + { + "name": "matter.ota", + "bytes": 2687232, + "sha256": "f39236d44bfe65ede0dc9004463ef011a5324356e5cbac0ac68f15e1948871a4" + } + ], + "commands": [ + { + "id": "01-generate", + "command": "python3 tools/generate_update.py", + "started_at": "2026-11-12T08:32:19.125711+00:00", + "duration_seconds": 0.121, + "exit_code": 0, + "status": "pass", + "output_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "counts": { + "passed": 1, + "failed": 0, + "skipped": 0, + "total": 1 + }, + "count_unit": "command execution" + }, + { + "id": "02-release", + "command": "python3 tools/release.py --private private/ota-signing.key --output artifacts/releases/v0.3.0-preview", + "started_at": "2026-11-12T08:32:19.519965+00:00", + "duration_seconds": 84.073, + "exit_code": 0, + "status": "pass", + "output_sha256": "7ced56ee74090a6e8767a016b5ec5ad47a2b7f9175cc34ceeb8d95757db97d37", + "counts": { + "passed": 1, + "failed": 0, + "skipped": 0, + "total": 1 + }, + "count_unit": "command execution" + }, + { + "id": "03-startup", + "command": "python3 tests/test_startup_recovery.py", + "started_at": "2026-11-12T08:32:37.774902+00:00", + "duration_seconds": 2.574, + "exit_code": 0, + "status": "pass", + "output_sha256": "12a12a355b2143d6d078364d0da61f60c9045bd523d3881e761ff7635af9f5d5", + "environment": "macOS native", + "counts": { + "passed": 9, + "failed": 0, + "errors": 0, + "skipped": 0, + "total": 9 + }, + "count_unit": "unittest cases" + }, + { + "id": "04-boot-native", + "command": "python3 tests/test_boot_guard.py", + "started_at": "2026-11-12T08:32:37.784173+00:00", + "duration_seconds": 0.243, + "exit_code": 0, + "status": "skipped", + "output_sha256": "c91248c29238cc570713bd758c2fda4758576b8b362164d2bf4453650f1dee9b", + "environment": "macOS native (Linux-only suite skips)", + "counts": { + "passed": 0, + "failed": 0, + "errors": 0, + "skipped": 6, + "total": 6 + }, + "count_unit": "unittest cases" + }, + { + "id": "05-feed", + "command": "python3 tests/test_update_feed.py", + "started_at": "2026-11-12T08:32:37.786290+00:00", + "duration_seconds": 0.275, + "exit_code": 0, + "status": "pass", + "output_sha256": "cdc1097f317abe58433919b6f3745752de1d5e53537dcee7688439d401f570e4", + "environment": "macOS native; mocked GitHub calls", + "counts": { + "passed": 7, + "failed": 0, + "errors": 0, + "skipped": 0, + "total": 7 + }, + "count_unit": "unittest cases" + }, + { + "id": "06-setup", + "command": "python3 tests/test_setup_control.py", + "started_at": "2026-11-12T08:32:37.781074+00:00", + "duration_seconds": 0.282, + "exit_code": 0, + "status": "pass", + "output_sha256": "cb4b0893c1ad095466f5a4fff264cb5c39aa41cb3196ed219c998fc981e85d18", + "environment": "macOS native", + "counts": { + "passed": 5, + "failed": 0, + "errors": 0, + "skipped": 0, + "total": 5 + }, + "count_unit": "unittest cases" + }, + { + "id": "07-management", + "command": "python3 tests/test_public_management.py", + "started_at": "2026-11-12T08:32:37.776807+00:00", + "duration_seconds": 0.242, + "exit_code": 0, + "status": "pass", + "output_sha256": "6f283ee6225d2cef4c0127d95456a6f954c45141c92b97dd89016cb1c630e8f4", + "environment": "macOS native", + "counts": { + "passed": 2, + "failed": 0, + "errors": 0, + "skipped": 0, + "total": 2 + }, + "count_unit": "unittest cases" + }, + { + "id": "08-js-update", + "command": "node tests/test_update_crypto.js", + "started_at": "2026-11-12T08:32:37.774621+00:00", + "duration_seconds": 0.169, + "exit_code": 0, + "status": "pass", + "output_sha256": "2647f50f6654294d733bb0ea310c30c645d9458f2a066ae739422398905a948e", + "counts": { + "passed": 9, + "failed": 0, + "skipped": 0, + "total": 9 + }, + "count_unit": "independent SHA-256 comparisons" + }, + { + "id": "09-js-password", + "command": "node tests/test_password_crypto.js", + "started_at": "2026-11-12T08:32:37.777514+00:00", + "duration_seconds": 3.063, + "exit_code": 0, + "status": "pass", + "output_sha256": "08fc8706d01105f34eae9854bc436d1da16fafd4d8e96a55342701fb0e28fd49", + "counts": { + "passed": 10, + "failed": 0, + "skipped": 0, + "total": 10 + }, + "count_unit": "assertions: 5 PBKDF2 vectors, 3 invalid iteration checks, HMAC, browser global coexistence" + }, + { + "id": "10-js-monitor", + "command": "node tests/test_monitor.js", + "started_at": "2026-11-12T08:32:37.775100+00:00", + "duration_seconds": 0.073, + "exit_code": 0, + "status": "pass", + "output_sha256": "90e96f673ac3314490dbb4a1580b7bdf1db9acfbd80bcb36be5364eba6d24474", + "counts": { + "passed": 24, + "failed": 0, + "skipped": 0, + "total": 24 + }, + "count_unit": "assertions including 2 HMAC vectors and meter display validation" + }, + { + "id": "11-energy", + "command": "c++ -std=c++11 -Wall -Wextra -Werror firmware/matter/tests/energy_counter_tests.cpp -o /tmp/ck-energy-tests && /tmp/ck-energy-tests", + "started_at": "2026-11-12T08:32:37.774732+00:00", + "duration_seconds": 0.668, + "exit_code": 0, + "status": "pass", + "output_sha256": "c3d6682a34568c04173824eb2b22c7bb161aca1c2432019e30e2884d0af9bf09", + "counts": { + "passed": 14, + "failed": 0, + "skipped": 0, + "total": 14 + }, + "count_unit": "C++ check() calls" + }, + { + "id": "12-relay", + "command": "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", + "started_at": "2026-11-12T08:32:37.776124+00:00", + "duration_seconds": 0.764, + "exit_code": 0, + "status": "pass", + "output_sha256": "ee708fbf113ae0c0bd1f47b05ad67190e31e6da1c8aef683c2dbee7c2eb2448a", + "counts": { + "passed": 574, + "failed": 0, + "skipped": 0, + "total": 574 + }, + "count_unit": "C++ check() calls; 100 commands" + }, + { + "id": "13-linux-updates", + "command": "docker run --rm -v \"$PWD:/repo:ro\" -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'", + "started_at": "2026-11-12T08:32:54.855140+00:00", + "duration_seconds": 24.758, + "exit_code": 0, + "status": "pass", + "output_sha256": "9819e7e82551e2ab4bdbbcf087847eef97830d6cf8a3dd2f8401d1b11c63b2f5", + "environment": "Linux native in incipio-public-build with venv", + "counts": { + "passed": 55, + "failed": 0, + "errors": 0, + "skipped": 0, + "total": 55 + }, + "count_unit": "unittest cases" + }, + { + "id": "14-linux-legacy", + "command": "docker run --rm -v \"$PWD:/repo:ro\" -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_legacy_mtd.py'", + "started_at": "2026-11-12T08:32:54.855139+00:00", + "duration_seconds": 5.202, + "exit_code": 0, + "status": "pass", + "output_sha256": "edb469a5aa9af9a2f64b37f3503f1dce13cafcd64b51070c16f0dcf5a031baff", + "environment": "Linux native in incipio-public-build with venv", + "counts": { + "passed": 5, + "failed": 0, + "errors": 0, + "skipped": 0, + "total": 5 + }, + "count_unit": "unittest cases" + }, + { + "id": "15-linux-boot", + "command": "docker run --rm -v \"$PWD:/repo:ro\" -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_boot_guard.py'", + "started_at": "2026-11-12T08:32:54.855204+00:00", + "duration_seconds": 17.14, + "exit_code": 0, + "status": "pass", + "output_sha256": "f0bc55d240adbbae3e2b8e32faaf7ff66fb40ee4b418b59288e94a19e45099ea", + "environment": "Linux native in incipio-public-build with venv", + "counts": { + "passed": 6, + "failed": 0, + "errors": 0, + "skipped": 0, + "total": 6 + }, + "count_unit": "unittest cases" + }, + { + "id": "16-mips-build", + "command": "docker run --rm -e CK_TESTING=1 -v \"$PWD/firmware/matter:/work\" incipio-public-build:v0.1.0-preview sh tools/build_updates.sh", + "started_at": "2026-11-12T08:32:54.855535+00:00", + "duration_seconds": 4.739, + "exit_code": 0, + "status": "pass", + "output_sha256": "8cad360a43cdf3214ba80f5855e7325ef907b120de1322330634d5c25b101425", + "counts": { + "passed": 1, + "failed": 0, + "skipped": 0, + "total": 1 + }, + "count_unit": "command execution" + }, + { + "id": "17-mips-updates", + "command": "docker run --rm -e CK_TEST_RUNNER=qemu-mips -e CK_TEST_BIN=/repo/firmware/matter/artifacts/update-test -v \"$PWD:/repo:ro\" -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'", + "started_at": "2026-11-12T08:33:34.829024+00:00", + "duration_seconds": 24.773, + "exit_code": 0, + "status": "pass", + "output_sha256": "ff3fa81d2cbd691f9406f8f1c2938ed4e9b7828a112905148bcc04ac0863af81", + "environment": "actual MIPS binaries under qemu-mips in Linux container with venv", + "counts": { + "passed": 55, + "failed": 0, + "errors": 0, + "skipped": 0, + "total": 55 + }, + "count_unit": "unittest cases" + }, + { + "id": "18-mips-boot", + "command": "docker run --rm -e CK_TEST_RUNNER=qemu-mips -e CK_TEST_BIN=/repo/firmware/matter/artifacts/update-test -v \"$PWD:/repo:ro\" -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 && 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'", + "started_at": "2026-11-12T08:33:34.829144+00:00", + "duration_seconds": 16.074, + "exit_code": 0, + "status": "pass", + "output_sha256": "170d1bfcaf6ab429c6480a2b04ccd3b0b860a66cd6aedaa07fa66e8917d0741d", + "environment": "actual MIPS boot guard under qemu-mips in Linux container with venv", + "counts": { + "passed": 6, + "failed": 0, + "errors": 0, + "skipped": 0, + "total": 6 + }, + "count_unit": "unittest cases" + }, + { + "id": "19-pid1", + "command": "python3 tests/test_update_pid1.py", + "environment": "MIPS import/stage then native Linux PID1 container fixture", + "count_unit": "script execution", + "first_attempt": { + "started_at": "2026-11-12T08:33:34.829033+00:00", + "duration_seconds": 1.972, + "exit_code": 1, + "status": "fail", + "output_sha256": "b480246127e04526d6e31d8db139ee141e2a20bb8725c14c2c8c5db011133aed", + "counts": { + "passed": 0, + "failed": 1, + "skipped": 0, + "total": 1 + }, + "failed_stage": "native static compile before PID1 execution", + "failure_summary": "The test compiler source list omits ck_energy.c and ck_led.c; undefined references include ck_energy_reset and ck_led_* functions. Test and production sources were left unchanged.", + "undefined_symbols": [ + "ck_energy_reset", + "ck_led_default", + "ck_led_evaluate", + "ck_led_load_config", + "ck_led_now", + "ck_led_set_time" + ] + }, + "status": "pass", + "exit_code": 0, + "counts": { + "passed": 1, + "failed": 0, + "skipped": 0, + "total": 1 + }, + "rerun_at": "2026-11-12T08:40:00+00:00", + "fix": "tests/test_update_pid1.py now compiles ck_energy.c and ck_led.c, matching tools/build_updates.sh; the omission predates v0.3.0 (ck_updater.c gained LED/energy calls in v0.2.11)." + }, + { + "id": "20-version-tag", + "command": "RELEASE_TAG=v0.3.0-preview python3 -c 'import json,os; assert os.environ[\"RELEASE_TAG\"] == \"v\"+json.load(open(\"version.json\"))[\"version\"]; print(\"Version matches v0.3.0-preview\")'", + "started_at": "2026-11-12T08:33:34.829040+00:00", + "duration_seconds": 0.154, + "exit_code": 0, + "status": "pass", + "output_sha256": "d2e62beca28fad40163987047dcbab4a649e61fdc17cc0ef9ebfa958b0237f12", + "counts": { + "passed": 1, + "failed": 0, + "skipped": 0, + "total": 1 + }, + "count_unit": "command execution" + }, + { + "id": "21-deployment", + "command": "python3 tests/test_deployment.py --release-dir artifacts/releases/v0.3.0-preview -v", + "started_at": "2026-11-12T08:34:08.353339+00:00", + "duration_seconds": 7.727, + "exit_code": 0, + "status": "pass", + "output_sha256": "cd3f8152b78ea59ed9c66ea926496c2b18c122c4673e9306565860431f1177d1", + "environment": "macOS native; actual release image", + "counts": { + "passed": 12, + "failed": 0, + "errors": 0, + "skipped": 1, + "total": 13 + }, + "count_unit": "unittest cases" + }, + { + "id": "22-public", + "command": "python3 tools/check_public.py --repository . --release-dir artifacts/releases/v0.3.0-preview", + "started_at": "2026-11-12T08:34:08.353338+00:00", + "duration_seconds": 3.16, + "exit_code": 0, + "status": "pass", + "output_sha256": "7c1275e4fc66be0bd8c933370198a65266e88c7a3b718d10c4d9830f4726eb9f", + "counts": { + "passed": 1, + "failed": 0, + "skipped": 0, + "total": 1 + }, + "count_unit": "command execution" + }, + { + "id": "23-signature", + "command": "python3 tools/ota.py verify artifacts/releases/v0.3.0-preview/matter.ota --public third_party/ota-signing.pub", + "started_at": "2026-11-12T08:34:08.353330+00:00", + "duration_seconds": 0.164, + "exit_code": 0, + "status": "pass", + "output_sha256": "871865af8a178c9bf55f2793b2d154421fc9650f4c9939d619c88a695ef09184", + "counts": { + "passed": 1, + "failed": 0, + "skipped": 0, + "total": 1 + }, + "count_unit": "command execution" + }, + { + "id": "24-portal", + "command": "python3 firmware/matter/tests/test_device_portal.py", + "started_at": "2026-11-12T08:34:08.353339+00:00", + "duration_seconds": 6.874, + "exit_code": 0, + "status": "pass", + "output_sha256": "f66e4365c21047d21dfac50b12f60626a6148cf6a143b17d0c2a3fa1f525ebba", + "counts": { + "passed": 14, + "failed": 0, + "skipped": 0, + "total": 14 + }, + "count_unit": "named integration checks" + }, + { + "id": "25-compatibility-ui", + "command": "python3 \"${SCRATCH}/compatibility_ui.py\"", + "started_at": "2026-11-12T08:34:39.092867+00:00", + "duration_seconds": 0.188, + "exit_code": 0, + "status": "pass", + "output_sha256": "0e8fd2e9d9c04a916381987e0dac1a2e07ae2617e30b79df5adf33c7be52a931", + "counts": { + "passed": 1, + "failed": 0, + "skipped": 0, + "total": 1 + }, + "count_unit": "command execution" + } + ], + "compatibility": { + "status": "pass", + "live_feed": { + "url": "https://lee98064.github.io/incipio-commandkit/updates/index.json", + "retrieved_at": "2026-11-12T08:33:12.577187+00:00", + "sha256": "5f300d7ff6d456c9d9769252353db4bae519a6c664247349dd67c44eb5c9deba", + "http_status": 200, + "etag": "\"6ad79b15-322\"", + "last_modified": "Tue, 20 Oct 2026 16:47:17 GMT" + }, + "checks": [ + { + "field": "bank1_kernel_sha256", + "live": "f081b4e0aafad65b071bcff0e0ca475fe2aa6ead608f5c7afe3a731a378b8ae8", + "new": "f081b4e0aafad65b071bcff0e0ca475fe2aa6ead608f5c7afe3a731a378b8ae8", + "status": "pass" + }, + { + "field": "bank2_executable_sha256", + "live": "84da34569ed333e546ac1417042ae81cb6d42777f8847c59b39d1365a494407f", + "new": "84da34569ed333e546ac1417042ae81cb6d42777f8847c59b39d1365a494407f", + "status": "pass" + }, + { + "field": "signing_public_key", + "live": "037c3a41f2177cde32f211149d3ca6f987e09089009352b5b79961af9e3978c7", + "new": "037c3a41f2177cde32f211149d3ca6f987e09089009352b5b79961af9e3978c7", + "status": "pass" + }, + { + "field": "model", + "live": "CMNDKT-004", + "new": "CMNDKT-004", + "status": "pass" + }, + { + "field": "variant", + "live": "matter", + "new": "matter", + "status": "pass" + }, + { + "field": "size", + "live": 2687232, + "new": 2687232, + "status": "pass" + }, + { + "field": "url", + "live": "https://lee98064.github.io/incipio-commandkit/updates/matter.ota", + "new": "https://lee98064.github.io/incipio-commandkit/updates/matter.ota", + "status": "pass" + } + ], + "software_version": { + "live": 13, + "new": 14, + "status": "pass" + }, + "version": { + "live": "0.2.11-preview", + "new": "0.3.0-preview", + "status": "pass" + }, + "url_pattern": "https://lee98064.github.io/incipio-commandkit/updates/{variant}.ota", + "validate_catalog_upgrade": { + "status": "pass", + "return_value": null + } + }, + "ui_string_checks": { + "status": "pass", + "source": "verified matter.ota payload after its 256-byte signed header; binaries decompressed by tools/fwlib.py read_file()", + "checks": [ + { + "binary": "bin/ck-updated", + "string": "近 2 分鐘功率走勢", + "status": "pass", + "binary_sha256": "137bcaa8f7b83c4f4ce4d223044509dde8bc9f6e04fe086182a78f778ae10f29" + }, + { + "binary": "bin/ck-updated", + "string": "CK_CATALOG_URL = \"https://lee98064.github.io/incipio-commandkit/updates/index.json\"", + "status": "pass", + "binary_sha256": "137bcaa8f7b83c4f4ce4d223044509dde8bc9f6e04fe086182a78f778ae10f29" + }, + { + "binary": "bin/ck-setupd", + "string": "建立管理密碼", + "status": "pass", + "binary_sha256": "f3a66e406e32e6a2f4f4587305700132da83cea99a059de4ffc8c7aebd1d799f" + }, + { + "binary": "bin/ck-setupd", + "string": "step-3", + "status": "pass", + "binary_sha256": "f3a66e406e32e6a2f4f4587305700132da83cea99a059de4ffc8c7aebd1d799f" + } + ], + "rootfs": { + "bytes_used": 2502531, + "checksum_extent": 2502658, + "budget": 2686976, + "headroom": 184445, + "status": "pass" + } + }, + "public_audit": { + "path": "verification/public-audit-v0.3.0-preview.json", + "method": "tools.check_public.audit(Path(\".\"), Path(\"artifacts/releases/v0.3.0-preview\")), the same audit() used by tools/package_public.py; source/release archives not generated" + }, + "bundle_path": "artifacts/releases/v0.3.0-preview-bundle/commandkit-v0.3.0-signed-ota.zip", + "physical_installation": "pending — performed by the owner via OTA", + "limitations": [ + "tests/test_update_pid1.py failed to link its native fixture executable; no PID1 branch or namespace reboot validation completed by that test.", + "The native macOS boot-guard invocation skipped all six Linux-only cases; the native Linux and qemu-mips invocations each passed all six.", + "Deployment skipped the diagnostic exec-test variant because this release only contains the Matter channel.", + "Real SoftAP/station RF behavior, commissioning and physical installation remain pending." + ], + "local_only": true +} diff --git a/version.json b/version.json index f5f11d7..d06b4da 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "software_version": 13, - "version": "0.2.11-preview", + "software_version": 14, + "version": "0.3.0-preview", "catalog_url": "https://lee98064.github.io/incipio-commandkit/updates/index.json" }