diff --git a/README.md b/README.md index 982cebb..86edae5 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # lnurl-vault -**LNURLvault** — an ESP32-S3 hardware vault for [LNURLcash](../luds/25.md) bearer notes — the -offline "banknote" scheme [lnurl-wallet](../lnurl-wallet) and -[lnurl-mint](../lnurl-mint) implement on top of plain LUD-03 +**LNURLvault** — an ESP32-S3 hardware vault for [LNURLcash](https://github.com/lnurl/luds/pull/301) bearer notes — the +offline "banknote" scheme [lnurl-wallet](https://github.com/dni/lnurl-wallet) and +[lnurl-mint](https://github.com/dni/lnurl-mint) implement on top of plain LUD-03 `withdrawRequest`. The device generates note secrets from a hardware RNG, discloses only their SHA-256 hash until a mint confirms a rotate/split/merge succeeded, stores notes through their `pending → confirmed → spent` @@ -14,19 +14,22 @@ protocol. It also works fully offline, with nothing paired at all: tap either button to browse `CONFIRMED` notes, hold both together for ~200ms to unveil the selected note's `lnurlw://` URL as an on-screen QR code — the offline -banknote handoff [LUD-25](../luds/25.md) itself describes. See +banknote handoff [LUD-25](https://github.com/lnurl/luds/pull/301) itself describes. See [`docs/PROTOCOL.md`](docs/PROTOCOL.md)'s "On-device note browsing" section. -Browser-side integration into `lnurl-wallet` (a `device.ts` client, pairing -UI) is a deliberate follow-up, not part of this repo yet — this is the -firmware and its protocol, designed to be consumed by that later. +Browser-side integration lives in +[lnurl-wallet](https://github.com/dni/lnurl-wallet), not here: `src/device.ts` +(the client for the protocol below), `deviceQueue.ts`, `deviceOrchestration.ts`, +`DeviceContext.tsx` and `pages/Vault.tsx`, with `device.test.ts` and +`deviceOrchestration.test.ts` alongside them. This repo is the firmware and +its wire protocol; that one consumes it. ## Status: builds, flashes, and the serial command protocol is confirmed working on real hardware This firmware **has been built successfully end to end** — bootloader, partition table, and `firmware.bin` all produced by a real `pio run -e t-display-s3` against ESP-IDF 6.0.1 (GCC 15.2.0, -`xtensa-esp-elf` toolchain), 30.1% RAM / 25.6% flash used. That build also +`xtensa-esp-elf` toolchain), 36.3% RAM / 27.2% flash used. That build also found and fixed several real, non-obvious bugs along the way, worth knowing about even though they're now fixed: @@ -141,11 +144,14 @@ knowing about even though they're now fixed: call plain `nvs_flash_init()` (which — per its own doc comment — already handles whichever scheme Kconfig selects internally) and pinning `CONFIG_NVS_SEC_HMAC_EFUSE_KEY_ID`; the now-unused `nvs_keys` partition - was removed from `partitions.csv`. (NVS encryption has since been turned - off entirely — the HMAC scheme burns an eFuse on first boot. See the - security posture section below, and `sdkconfig.defaults`. The - `nvs_storage.c` simplification and the partition removal both still - stand.) + was removed from `partitions.csv`, and there is no such partition in this + repo today. (NVS encryption has since been turned off entirely — the HMAC + scheme burns an eFuse on first boot, irreversibly, as a silent side effect + of first boot. **Note storage is therefore not encrypted at rest: a + physical flash dump recovers every secret.** Physical possession is the + protection model. See the security posture section below, and + `sdkconfig.defaults`, which spells out the whole call. The `nvs_storage.c` + simplification and the partition removal both still stand.) - Two mechanisms that would have auto-fetched the QR library instead of vendoring it by hand were tried and empirically confirmed *not* to work for this framework/library combination — see `platformio.ini`'s comment. @@ -271,15 +277,21 @@ knowing about even though they're now fixed: the [device console](webinstaller/console.html) too. Not yet run against real hardware. -**What's still unverified**: this firmware *has* been flashed to a real -T-Display S3 and the serial command protocol (get_info/list_notes/error -handling) is confirmed working end to end over USB-CDC. On-screen -rendering, physical button timing, BLE pairing against a real central, and -NVS persistence across real power cycles are all still open questions — a -compiling, serial-responsive program can still be wrong about display -timing or BLE electrical behavior even with the serial path solid. A -different installed ESP-IDF/PlatformIO version than the one used here could -also still turn up something new; each fix above is documented in the +**What has and has not been run on hardware** is tracked per area, with +dates and firmware versions, in +[`docs/HARDWARE-TEST-CHECKLIST.md`](docs/HARDWARE-TEST-CHECKLIST.md) — that +file, not this paragraph, is the authority, and it marks anything unrun as +`NOT YET BENCH-RUN` in those words rather than leaving it implied. In +outline, as of 2026-08-17: the serial and BLE command protocols, the note +lifecycle, the export confirm gate's timeout path, display orientation, the +button state machine at rest, QR rendering and scanning, NVS persistence +across resets, and crash reporting have all been exercised on a classic +T-Display. The approval gesture itself, granting a wipe, an actual OTA +transfer, and the ESP32-S3 target's display and native-USB paths have not. + +`test/hardware/bench.py` runs every check that does not need a finger on the +board. A different installed ESP-IDF/PlatformIO version than the pinned one +could still turn up something new; each fix above is documented in the relevant file's own header comment as a starting point if it does. `src/vault/` and `src/proto/` (the note state machine, SHA-256, hex, base64, @@ -412,8 +424,10 @@ and links in a real `pio run -e t-display-s3` build. **None of it has run against a real device** — no actual OTA transfer over USB has happened. The physical-approval gate (`ui_task_request_ota_confirm`, reusing `ui_task.c`'s existing request queue) is architecturally identical to -`export_secret`'s, which itself is unverified on hardware (see "Known -limitations"). `src/ota/release_key.c`'s `OTA_RELEASE_PUBKEY` now holds a +`export_secret`'s. That one's timeout path *is* confirmed on hardware — an +unanswered request returns `{"ok":false,"error":"timeout"}` after ~31s over +both transports, with the BLE link intact throughout — but nobody has yet +pressed a button to approve an OTA image. `src/ota/release_key.c`'s `OTA_RELEASE_PUBKEY` now holds a real generated key (`python3 tools/ota_push.py keygen`), not the all-zero placeholder — `ota_begin` will accept images signed with the matching seed instead of failing closed against everything. That seed needs to actually @@ -620,7 +634,6 @@ step's `find` patterns are the first thing to check. - **OTA exists but is untested on hardware and unusable until a release key is generated** — see the "OTA firmware updates" section above for what's built, what's cross-checked, and what still needs a real device. -- **No `lnurl-wallet` integration yet** — see the top of this file. - BLE pairing/bonding is unauthenticated in this v1 (any nearby device can connect and issue commands, though it still can't extract a secret without a physical gesture on the vault itself). Consider BLE diff --git a/docs/HARDWARE-TEST-CHECKLIST.md b/docs/HARDWARE-TEST-CHECKLIST.md new file mode 100644 index 0000000..47283b0 --- /dev/null +++ b/docs/HARDWARE-TEST-CHECKLIST.md @@ -0,0 +1,263 @@ +# Hardware test checklist + +Nothing past `dispatcher_handle()` is reachable by the native tests. This is +the record of what has actually been run on a board, and — just as +importantly — what has not. + +**How to read a bench record.** Every section ends with one. A record names +the date, the board and the firmware version `get_info` reported, because +"it worked" is worth very little without those three. A section with no +record says **NOT YET BENCH-RUN** in those words, so nothing quietly counts as +verified by having been written down. + +**Run the machine-driven parts first:** + +``` +pip install pyserial bleak +python3 test/hardware/bench.py --port /dev/ttyUSB0 --ble +``` + +`bench.py` covers every check that does not need a finger on the board, and +exits non-zero if any fails. It never grants a wipe and never approves an +export — an automated test one press away from erasing a device is not a test +worth having. Those rows are human-run, and marked as such. + +Two things about the serial port that cost real time to learn, both handled +inside `bench.py` and worth knowing if you drive the device by hand: + +- Opening the port drives DTR/RTS into the board's auto-reset circuit, so the + device **reboots when you connect**. A script that opens the port per + command reboots between every command, which silently invalidates anything + measuring uptime or persistence. Hold one connection open. +- Boot chatter, and any enabled diagnostics, write plain text to the same + UART that carries the protocol on the classic board. Match responses by + looking for a line starting with `{`, not by taking the next line. + +--- + +## 1. Build and flash + +Both environments build, and a flashed device boots and answers. + +``` +python3 test/native/… # make test in test/native/ — must be green first +pio run -e t-display-s3 +pio run -e t-display +pio run -e t-display -t upload --upload-port /dev/ttyUSB0 +``` + +Pass: both builds succeed, and `{"cmd":"get_info"}` answers after flashing. + +> **Bench record — 2026-08-17.** Classic LilyGo T-Display (ESP32-D0WDQ6), +> firmware `0.0.2-25-g0b3477c`. Both environments build; `t-display` flashed +> and answered. 319/319 native assertions green. ESP-IDF 6.0.1 via +> PlatformIO 6.1.19, `espressif32@7.0.1`. +> S3 target: **builds only.** Not bench-run in this pass — its port was held +> by another process on the test machine, so the i80 display path and native +> USB-CDC were not exercised. Treat the S3 as unverified for anything below +> that says "classic" in the record. + +## 2. Host transport — serial + +`get_info`, `list_notes` and a note mint all round-trip; the device reports a +real firmware version and a board identifier. + +Pass: `fw_version` is **not** `0.1.0` — that was a hardcoded literal every +release reported regardless of what it was built from (issue #11). It should +be a tag, or a `git describe` string for a local build. + +> **Bench record — 2026-08-17.** Classic T-Display, `0.0.2-25-g0b3477c`. +> `get_info` round-trips; `fw_version='0.0.2-25-g0b3477c'`, +> `board='t-display'`, `storage='ok'`. Via `bench.py`. + +## 3. Host transport — BLE + +Connect, subscribe, and exercise both a read and a command that **writes to +flash**. + +Pass: the write works and the link survives it. This is not a formality — a +write over BLE was impossible before #29, because `dispatcher_handle()` ran on +the NimBLE host task: the link dropped and the note was never created, while +reads worked fine, which is why it went unnoticed. + +> **Bench record — 2026-08-17.** Classic T-Display, `0.0.2-25-g0b3477c`. +> Advertises as `lnurl-vault`; ATT MTU negotiated to 256; `get_info` in +> 0.05s; `new_secret` over BLE succeeded in 0.05s with the link intact. +> Via `bench.py --ble`. + +## 4. Note lifecycle + +Mint, confirm, list, rename, mark spent, delete. Counts move as expected. + +> **Bench record — 2026-08-17.** Classic T-Display, `0.0.2-25-g0b3477c`. +> `new_secret` + `confirm` succeeded, note count 11 → 12, and the new id +> appeared in `list_notes`. Rename/mark-spent/delete not exercised in this +> pass. + +## 5. The two-phase commit property + +The security claim the whole design rests on: the `h` disclosed to the mint at +mint time must equal `sha256` of the secret the device later exports. + +Mint a note, record `h`, confirm it, export the secret, and hash it +independently — not with the device's own SHA-256. + +> **Bench record — 2026-08-17.** Classic T-Display. `sha256(exported k1)` +> matched the `h` disclosed at mint time, hashed with Python's `hashlib` +> rather than the firmware's own implementation. The hand-written +> `src/vault/sha256.c` was separately cross-checked against `hashlib` on +> device-generated data. + +## 6. The disclosure gate + +`export_secret` must not disclose anything without a physical approval, and an +unanswered request must time out cleanly with the transport still usable. + +Pass: `{"ok":false,"error":"timeout"}` after roughly the confirm window, over +both transports, with the BLE link still up. + +> **Bench record — 2026-08-17.** Classic T-Display, `0.0.2-25-g0b3477c`. +> Serial: timed out at 31.0s, device responsive after. BLE: timed out at +> 31.0s with the link intact across the whole window. Via `bench.py --ble`. +> This is the specific thing issue #4 predicted would fail — it predicted +> link supervision would tear the connection down during the wait. It does +> not, on this controller; measured on unfixed firmware too. + +## 7. The approval gesture — NOT YET BENCH-RUN + +Needs a finger on the board. Send `export_secret`, then: + +| Do | Expect | +|---|---| +| Hold button 1 | a bar fills, and it approves at about 2s | +| Tap button 1 | nothing at all | +| Press button 2 | declines immediately | +| Hold button 1, let go at ~1s, hold again | it does **not** deny — a slipped finger is not a decision | +| Let it lapse | a distinct "expired" screen, not the decline colour | + +**NOT YET BENCH-RUN.** The logic is covered by +`test/native/test_approval.c` a tick at a time, including contact bounce +mid-hold, but no press has been made on hardware. + +## 8. Display and orientation + +Pass: the image is the right way up, not mirrored, with no offset band at any +edge. + +> **Bench record — 2026-08-17.** Classic T-Display. Orientation resolved +> empirically over three flash cycles using the colour/geometry self-test +> (`-DLNURLVAULT_DISPLAY_SELFTEST`), landing on `invert_color(true)`, +> `swap_xy(true)`, `mirror(true, true)`, `set_gap(40, 52)`. The walk that got +> there — including the finding that toggling `mirror_Y` moved the image +> *horizontally*, because esp_lcd mirrors in panel coordinates before +> `swap_xy` transposes the axes — is recorded in +> `src/board/board_t_display.c`. + +## 9. On-device browsing and QR + +Tap to cycle CONFIRMED notes, chord to unveil one as a QR, and scan it with a +phone. + +Pass: the code scans. Note that "does it scan" collapses the encoder, the +renderer, the optics and the phone's decoder into a single bit — if it fails, +the QR density ladder (`-DLNURLVAULT_QR_SELFTEST`) is what separates them. + +> **Bench record — 2026-08-17.** Classic T-Display. Codes render and a phone +> camera decodes them. **But** nothing opened them: the URL is a +> `lnurlw://` (LUD-17) scheme with no handler on the phones tried — issue +> #26, a product decision, not a rendering fault. +> +> Do not enable `-DLNURLVAULT_QR_SELFTEST` in a shipping build: `app_main()` +> runs the diagnostics before `ui_task_start()`, and the ladder waits for a +> press per code, so while it runs `ui_task` does not exist and no approval +> can be serviced at all. Leaving that flag set was enough to make +> `export_secret` fail on hardware. + +## 10. Buttons + +Pass: no spurious events at rest, and both buttons register. + +> **Bench record — 2026-08-17.** Classic T-Display. `button_fsm` produced +> zero spurious events across 31s at rest. GPIO35 is input-only with no +> internal pull, and the board's external pull-up was confirmed present. + +## 11. Persistence across power cycles + +Mint and confirm notes, power-cycle the board, and confirm they are still +there with the right states. + +> **Bench record — 2026-08-17.** Classic T-Display. Notes survived repeated +> reboots (software resets and EN-pin resets) with counts and states intact. +> A full unplug/replug cycle was not specifically isolated in this pass. + +## 12. Storage exhaustion, and wipe + +The device must **never** erase to recover. On a full partition it reports +`storage: "full"` and leaves every note on flash. + +`wipe` must refuse everything except a deliberate, confirmed request: + +| Send | Expect | +|---|---| +| `{"cmd":"wipe"}` | `bad_request`, immediately, no prompt | +| `{"cmd":"wipe","confirm":"yes"}` | `bad_request` | +| `{"cmd":"wipe","confirm":"WIPE"}`, press nothing | `timeout`, nothing erased | +| `{"cmd":"wipe","confirm":"WIPE"}`, approve | `{"ok":true,"wiped":true}`, then a reboot into an empty vault | + +> **Bench record — 2026-08-17.** Classic T-Display, `0.0.2-25-g0b3477c`. +> The three refusals all behaved: bare wipe refused in 0.1s with no prompt, +> wrong phrase refused, correct phrase prompted then timed out. Note count +> unchanged at 13 throughout. Via `bench.py`. +> +> **Granting a wipe: NOT YET BENCH-RUN**, deliberately — kept out of the +> automated bench so no test run is one press from erasing a device. Also +> **not bench-run**: reaching a genuinely full NVS partition to see +> `storage: "full"` in the wild. `list_notes` returning +> `response_too_large` at around 30 notes *was* reproduced, which is issue #7 +> and the nearest thing to it. + +## 13. Crash reporting and the watchdog + +After an unexpected reset, `get_info` must say what happened. + +> **Bench record — 2026-08-17.** Classic T-Display. Verified by crashing a +> board on purpose — a null dereference behind a temporary command, built and +> flashed for the test only and not committed: +> +> ``` +> before reason='poweron' boot=1 unexpected=False +> after reason='panic' boot=2 unexpected=True +> last_cmd_in_flight='__crash_for_test' +> ``` +> +> Also confirmed: a software reset reports `reason='sw'` with the boot count +> incrementing and no command named (correct — `reset` returns before the +> delayed restart fires). And an EN-pin reset **loses** the breadcrumb and +> reads as `poweron`, because it resets the RTC domain too. That is the +> intended lifetime, not a gap. +> +> **The watchdog firing: NOT YET BENCH-RUN.** Staging a >60s stall in +> `ui_task` needs code that would then have to be removed. The subscribe and +> feed calls run every boot; the timeout path itself is ESP-IDF's. + +## 14. OTA — NOT YET BENCH-RUN + +Sign an image, push it with `tools/ota_push.py`, approve on the device, and +confirm it boots the new firmware and reports the new version. + +**NOT YET BENCH-RUN.** Needs the release signing seed, which is a CI secret +and not in this repo. The signature verification and session sequencing are +covered by `test/native/test_ota_dispatch.c` and `test_ota_sign.c` against a +fake in-memory flash; nothing about the real `esp_ota_*` writes, the partition +switch, or a rollback has been exercised on hardware. + +## 15. Release artefacts — NOT YET BENCH-RUN + +Cut a tag, then check the published release carries `firmware.bin`, +`firmware.bin.sig`, `SHA256SUMS` and `manifest.json`; that the checksums +match; that `firmware.bin.sig` verifies against the committed public key; and +that the web installer flashes `merged-firmware.bin` onto a blank board which +then boots and reports the tag as its `fw_version`. + +**NOT YET BENCH-RUN** since the release pipeline was pinned and given +checksums. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 959cb0c..f7e0c49 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -9,7 +9,7 @@ knows nothing about LNURL/mint HTTP calls. It only ever generates secrets, discloses their SHA-256 hash, and tracks note state. See "Orchestration" below for how a browser client (e.g. a future `lnurl-wallet` integration) composes these commands with calls to a mint to implement rotate/split/ -merge/melt, per [LUD-25](../../luds/25.md). +merge/melt, per [LUD-25](https://github.com/lnurl/luds/pull/301). ## Transports @@ -100,7 +100,7 @@ except `export_secret`. ``` `state` is one of `pending`, `confirmed`, `spent`. `sig` is present only if -the note carries an offline-verification signature ([LUD-25](../../luds/25.md)). +the note carries an offline-verification signature ([LUD-25](https://github.com/lnurl/luds/pull/301)). ### `new_secret` @@ -311,7 +311,7 @@ has a real chance to leave the TX buffer first. ## Orchestration How a browser client composes these commands with mint HTTP calls to -implement each [LUD-25](../../luds/25.md) operation. The device never talks +implement each [LUD-25](https://github.com/lnurl/luds/pull/301) operation. The device never talks to the mint itself. **Rotate** (burn one note, mint a fresh one of the same value): @@ -343,7 +343,7 @@ to the mint itself. **Minting** (paying a `payRequest` to create a brand-new note): happens entirely off-device — the browser pays the invoice and gets a payment preimage `P`. To bring `P` under this device's custody: `import_secret(P, -...)`, then immediately **rotate** it (per [LUD-25](../../luds/25.md)'s +...)`, then immediately **rotate** it (per [LUD-25](https://github.com/lnurl/luds/pull/301)'s security considerations — a payment preimage was also seen by the mint itself as a prior holder). @@ -356,7 +356,7 @@ in which the previous holder could also redeem it. Independent of the commands above and not exposed over serial/BLE at all — this is a purely physical, local interaction (`src/ui/ui_task.c`) for handing a note to someone else in person, the "offline circulation" case -[LUD-25](../../luds/25.md) itself describes. No browser or paired host is +[LUD-25](https://github.com/lnurl/luds/pull/301) itself describes. No browser or paired host is involved in this flow. | Gesture | Effect | diff --git a/sdkconfig.defaults b/sdkconfig.defaults index b8f60b4..31d7746 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -1,11 +1,18 @@ # lnurl-vault sdkconfig defaults. # -# NOTE: this file was authored without a working ESP-IDF/PlatformIO install or -# attached board in the environment that built this project (see README.md, -# "Status: unverified by compilation"). Config option names have drifted -# between ESP-IDF releases before; if `pio run` reports an unknown option, -# reconcile the affected line against `pio run -t menuconfig` on your -# installed IDF version rather than assuming this file is wrong wholesale. +# Every option here is applied by a real build: CI builds both environments +# on every push and pull request, and both have been flashed to and run on +# real hardware (see docs/HARDWARE-TEST-CHECKLIST.md for what that does and +# does not cover). An earlier version of this comment said the file had been +# authored without a working ESP-IDF install and pointed at a README section +# called "Status: unverified by compilation", neither of which is true any +# more. +# +# Option names have still drifted between ESP-IDF releases before, so if +# `pio run` reports an unknown option on a different installed version, +# reconcile the affected line against `pio run -t menuconfig` rather than +# assuming this file is wrong wholesale. platformio.ini pins the platform +# (espressif32@7.0.1) precisely so that does not happen by surprise. # --- NVS encryption: deliberately OFF ------------------------------------ # Note storage is NOT encrypted at rest. A physical flash dump recovers every diff --git a/test/hardware/bench.py b/test/hardware/bench.py new file mode 100644 index 0000000..e1da5f1 --- /dev/null +++ b/test/hardware/bench.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python3 +"""Every hardware check that does not need a finger on the board. + + python3 test/hardware/bench.py --port /dev/ttyUSB0 + python3 test/hardware/bench.py --port /dev/ttyUSB0 --ble + +Prints a pass/fail table and exits non-zero if anything failed, so it can +gate a release. What it deliberately does NOT do is anything that needs a +physical press, and in particular it never grants a wipe -- an automated test +one press away from erasing a device is not a test worth having. Those rows +stay human-run; see docs/HARDWARE-TEST-CHECKLIST.md. + +Two things about the serial port, both learned the hard way: + + - It is opened ONCE and held for the whole run. Opening it drives DTR/RTS + into the board's auto-reset circuit, so a script that opens per command + reboots the device between every check -- which silently invalidates + anything measuring uptime or persistence. + - Boot chatter and any enabled diagnostics write plain text to the same + UART that carries the protocol, so responses are matched by looking for a + line that starts with '{' rather than by taking the next line. + +BLE needs `pip install bleak`, and is skipped unless --ble is passed. +""" + +import argparse +import json +import struct +import sys +import time + +try: + import serial +except ImportError: + sys.exit("needs pyserial: pip install pyserial") + +# docs/PROTOCOL.md, and src/transport/ble_gatt.c for the byte order. +BLE_NAME = "lnurl-vault" +BLE_RX = "407e0f1b-2c3d-118e-d64b-1b534e601a9c" +BLE_TX = "407e0f1c-2c3d-118e-d64b-1b534e601a9c" + +CONFIRM_WINDOW_S = 30 + + +class Bench: + def __init__(self): + self.rows = [] + + def check(self, name, ok, detail=""): + self.rows.append((name, bool(ok), detail)) + print(f" {'PASS' if ok else 'FAIL'} {name}" + (f" -- {detail}" if detail else "")) + return ok + + def skip(self, name, why): + self.rows.append((name, None, why)) + print(f" SKIP {name} -- {why}") + + def report(self): + passed = sum(1 for _, ok, _ in self.rows if ok is True) + failed = [n for n, ok, _ in self.rows if ok is False] + skipped = sum(1 for _, ok, _ in self.rows if ok is None) + print(f"\n{passed} passed, {len(failed)} failed, {skipped} skipped") + for n in failed: + print(f" FAILED: {n}") + return 1 if failed else 0 + + +class Device: + """One held-open serial connection to the vault.""" + + def __init__(self, port, baud=115200): + self.port = serial.Serial() + self.port.port = port + self.port.baudrate = baud + self.port.timeout = 45 + self.port.dtr = False + self.port.rts = False + self.port.open() + # Opening reset the board. Wait it out and drop the boot chatter. + time.sleep(3.5) + self.port.reset_input_buffer() + + def cmd(self, payload, wait=45): + self.port.reset_input_buffer() + self.port.write((json.dumps(payload) + "\n").encode()) + deadline = time.monotonic() + wait + while time.monotonic() < deadline: + line = self.port.readline().decode(errors="replace").strip() + if line.startswith("{"): + return json.loads(line) + # Diagnostics and boot banners share this UART; keep looking. + return None + + def close(self): + self.port.close() + + +def confirmed_note(dev, label): + """Mints a note and confirms it, so export_secret reaches the UI gate.""" + r = dev.cmd({"cmd": "new_secret", "label": label}) + if not r or not r.get("ok"): + return None + dev.cmd( + {"cmd": "confirm", "id": r["id"], "amount_msat": 2100, "host": "example.com"} + ) + return r["id"] + + +def run_serial(dev, b): + print("\n-- transport and protocol --") + info = dev.cmd({"cmd": "get_info"}) + b.check("get_info round-trips", info is not None and info.get("ok")) + if not info: + return + + b.check( + "reports a real firmware version", + info.get("fw_version") not in (None, "", "0.1.0"), + f"fw_version={info.get('fw_version')!r}", + ) + b.check("reports a board identifier", bool(info.get("board")), f"board={info.get('board')!r}") + + # Anything but "ok" means note_count is not a statement about how many + # notes exist -- see docs/PROTOCOL.md's get_info. Absent entirely on a + # firmware that predates the field, which is a skip rather than a failure: + # this driver is meant to be usable against an older build to find out + # what it does, not only against the newest. + storage = info.get("storage") + if storage is None: + b.skip("storage state is reported", "no `storage` field in this firmware") + else: + b.check("storage is healthy", storage == "ok", f"storage={storage!r}") + + if info.get("last_reset_reason") is None: + b.skip("boot report is present", "no boot report in this firmware") + else: + b.check( + "boot report is present", + True, + f"reason={info.get('last_reset_reason')!r} boot={info.get('boot_count')!r}", + ) + + print("\n-- note lifecycle --") + before = info.get("note_count", 0) + nid = confirmed_note(dev, "bench") + b.check("new_secret + confirm", nid is not None) + after = dev.cmd({"cmd": "get_info"}) + b.check( + "the new note is counted", + after is not None and after.get("note_count", 0) == before + 1, + f"{before} -> {after.get('note_count') if after else '?'}", + ) + + listed = dev.cmd({"cmd": "list_notes"}) + if listed and listed.get("ok"): + ids = [n["id"] for n in listed["notes"]] + b.check("list_notes includes it", nid in ids, f"{len(ids)} notes listed") + else: + # Reproduces #7 rather than hiding it: the listing is a single + # response with no paging, so a device with enough notes cannot list + # them at all. + b.check( + "list_notes fits in one response", + False, + f"got {listed.get('error') if listed else None} -- see issue #7", + ) + + print("\n-- the disclosure gate --") + nid2 = confirmed_note(dev, "bench-timeout") + t0 = time.monotonic() + r = dev.cmd({"cmd": "export_secret", "id": nid2}) + dt = time.monotonic() - t0 + b.check( + "an unanswered export_secret times out", + r is not None and r.get("error") == "timeout", + f"{dt:.1f}s", + ) + b.check( + "and takes about the confirm window, not instantly", + CONFIRM_WINDOW_S - 2 < dt < CONFIRM_WINDOW_S + 15, + f"{dt:.1f}s vs a {CONFIRM_WINDOW_S}s window", + ) + b.check("the device still answers afterwards", dev.cmd({"cmd": "get_info"}) is not None) + + print("\n-- wipe refuses everything it should --") + count_before = dev.cmd({"cmd": "get_info"}).get("note_count") + + t0 = time.monotonic() + r = dev.cmd({"cmd": "wipe"}, wait=10) + dt = time.monotonic() - t0 + if r is not None and r.get("message") == "unknown cmd": + b.skip("wipe refuses a bare request", "no `wipe` command in this firmware") + b.skip("wipe refuses a wrong phrase", "no `wipe` command in this firmware") + b.skip("wipe times out unanswered", "no `wipe` command in this firmware") + b.skip("granting a wipe", "needs a physical press; never automated on purpose") + return + b.check( + "a bare wipe is refused, with no prompt", + r is not None and r.get("error") == "bad_request" and dt < 5, + f"{dt:.1f}s", + ) + + r = dev.cmd({"cmd": "wipe", "confirm": "yes"}, wait=10) + b.check("a wrong confirmation phrase is refused", r is not None and r.get("error") == "bad_request") + + r = dev.cmd({"cmd": "wipe", "confirm": "WIPE"}) + b.check("an unanswered wipe times out", r is not None and r.get("error") == "timeout") + + count_after = dev.cmd({"cmd": "get_info"}).get("note_count") + b.check( + "no note was lost to any of that", + count_before == count_after, + f"{count_before} -> {count_after}", + ) + b.skip("granting a wipe", "needs a physical press; never automated on purpose") + + print("\n-- gestures --") + b.skip("hold-to-approve grants an export", "needs a physical press") + b.skip("a tap does not approve", "needs a physical press") + b.skip("button 2 declines", "needs a physical press") + b.skip("browse and unveil a QR on screen", "needs a physical press and eyes") + + +class Frame: + """Mirror of src/proto/ble_frame.c, for the notify direction.""" + + def __init__(self): + self.buf = bytearray() + self.want = None + self.msgs = [] + + def feed(self, data): + self.buf += data + while True: + if self.want is None: + if len(self.buf) < 2: + return + self.want = struct.unpack(" 23, + f"mtu={client.mtu_size}") + + r, dt = await send(client, frame, {"cmd": "get_info"}, 10) + b.check("get_info over BLE", r is not None and r.get("ok"), f"{dt:.2f}s") + + # A command that writes to NVS. This is the one that could not be + # issued over BLE at all before #29, because dispatcher_handle() + # ran on the NimBLE host task: the link dropped and the note was + # never created. + r, dt = await send(client, frame, {"cmd": "new_secret", "label": "bench-ble"}, 15) + b.check("a command that writes to flash, over BLE", r is not None and r.get("ok"), + f"{dt:.2f}s") + b.check("the link survived it", not dropped.is_set()) + + nid = r["id"] if r and r.get("ok") else None + if nid: + await send(client, frame, + {"cmd": "confirm", "id": nid, "amount_msat": 2100, + "host": "example.com"}, 15) + # The confirm window blocks a transport task for 30s. The link + # must stay up and the answer must arrive as a response. + r, dt = await send(client, frame, {"cmd": "export_secret", "id": nid}, 60) + b.check("an unanswered export over BLE times out", + r is not None and r.get("error") == "timeout", f"{dt:.1f}s") + b.check("the link survived the whole confirm window", not dropped.is_set()) + + r, _ = await send(client, frame, {"cmd": "get_info"}, 10) + b.check("BLE still works afterwards", r is not None) + + asyncio.run(main()) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--port", required=True, help="e.g. /dev/ttyUSB0") + ap.add_argument("--baud", type=int, default=115200) + ap.add_argument("--ble", action="store_true", help="also exercise the BLE transport") + args = ap.parse_args() + + b = Bench() + dev = Device(args.port, args.baud) + try: + run_serial(dev, b) + if args.ble: + run_ble(dev, b) + else: + b.skip("BLE transport", "pass --ble to include it") + finally: + dev.close() + return b.report() + + +if __name__ == "__main__": + sys.exit(main())