aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-20 01:51:05 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-20 01:51:05 +0200
commit0edb4bb6d76939aa456cbbc8deafeed50fd6182d (patch)
tree3f22349f6d64ff0fa33b6ff9bbcc10584f8b152c
parent42f9331e693f5058fcfcd7aef5a92cabc04c20ee (diff)
docs: record M5 results; bump to 0.5.0v0.5.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--CHANGELOG.md38
-rw-r--r--README.md9
-rw-r--r--docs/DEVELOPER_GUIDE.md2
-rw-r--r--docs/PERFORMANCE.md7
-rw-r--r--docs/SECURITY.md26
-rw-r--r--docs/TESTING.md34
6 files changed, 111 insertions, 5 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 75f3850..0e0acca 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,44 @@ All notable changes to this project are documented here. The format follows
## [Unreleased]
+## [0.5.0] - 2026-10-20
+
+Milestone M5: import. You can now add entries by pasting `otpauth://` links or
+an unencrypted Aegis JSON export into the new Import group of the app settings.
+
+### Added
+
+- `source/import/`:
+ - A streaming JSON tokenizer: strict, with depth and length limits; skips
+ icons natively; reports incomplete input.
+ - The Aegis reader: database versions 1-3, groups, encrypted exports
+ refused, duplicate fields rejected.
+ - The `otpauth://` parser: run-together URIs, `;` separators, Steam forms,
+ issuer mismatch notes.
+ - The import job: Replace mode, the duplicate and replacement checks, a
+ multi-part buffer sealed with the device key (32 KB, 24 h), and a report.
+- `source/settings/`:
+ - The settings processor: the Import form, applied after the first frame
+ and on every settings change. It clears the fields, writes the status,
+ remembers form hashes and opens the results screen.
+ - The results store; the list shows "Results ready" until they're read.
+- The Import group in the phone settings (8 text fields, mode, "Require PIN",
+ "Import now", result).
+- An 8-byte keyed secret fingerprint in every record header, to find
+ duplicates of locked entries while locked (docs/SECURITY.md).
+- Seeded fuzz corpus (`tools/gen_fuzz_corpus.py`, `make corpus`); 29 Monkey C
+ and 5 Python tests. The UI flow imports 100 entries through the settings
+ form and checks the peak memory.
+
+### Changed
+
+- Info screens space out their items; the results screen is titled "Results".
+
+### Deferred
+
+- Google Authenticator `otpauth-migration://` exports (README 11.1): the
+ importer explains that they aren't supported yet.
+
## [0.4.0] - 2026-10-19
Milestone M4: the screens. The app is usable in the simulator with demo
diff --git a/README.md b/README.md
index 266919b..5d3dff3 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
A Garmin Connect IQ watch app that shows one-time passwords (TOTP, HOTP, and optional Steam Guard codes) and stores barcodes and QR codes (loyalty cards, tickets) to show at a scanner. Everything lives encrypted on the watch. There is no cloud, no backend and no analytics, and the app requests no permissions, so it cannot use the network.
-> **Status: M4 (UI core) done; M1 (settings spike) ready for your phone test.** The plan below was approved on 2026-10-18. M0 built the Docker toolchain and pipeline. M2 added the crypto and OTP core, verified against official test vectors. M3 added the encrypted vault ([docs/SECURITY.md](docs/SECURITY.md)). M4 added the screens: the list with live codes, OTP detail with countdown ring, PIN entry (keypad or button selector) with lockout, menu, info screens and the glance, tested by a scripted UI flow with screenshots on seven simulated watches. Next is M5, import. The settings spike (`make spike`, [docs/SETTINGS_SPIKE.md](docs/SETTINGS_SPIKE.md)) waits for your test. The Phase 0 research, with sources, is in [docs/FEASIBILITY.md](docs/FEASIBILITY.md); the daily workflow is in [docs/DEVELOPER_GUIDE.md](docs/DEVELOPER_GUIDE.md).
+> **Status: M5 (import) done; M1 (settings spike) ready for your phone test.** The plan below was approved on 2026-10-18. M0 built the Docker toolchain and pipeline, M2 the crypto and OTP core, M3 the encrypted vault ([docs/SECURITY.md](docs/SECURITY.md)) and M4 the screens. M5 added import: `otpauth://` links and unencrypted Aegis JSON pasted into the new Import group of the phone settings, parsed on the watch with a streaming JSON tokenizer. It also detects duplicates, supports multi-part pastes and shows a results screen. It's tested with fixtures, a seeded fuzz corpus, and a 100-entry import through the settings path on seven simulated watches. Google Authenticator exports are deferred. Next is M6, barcodes. The settings spike (`make spike`, [docs/SETTINGS_SPIKE.md](docs/SETTINGS_SPIKE.md)) waits for your test. The Phase 0 research, with sources, is in [docs/FEASIBILITY.md](docs/FEASIBILITY.md); the daily workflow is in [docs/DEVELOPER_GUIDE.md](docs/DEVELOPER_GUIDE.md).
"OTP Wallet" is a working title.
@@ -208,7 +208,8 @@ Design rules:
| `e.<id>` | One entry record | Device key, or the locked master key for locked entries |
| `x.<id>` | Cached barcode matrix for a code entry | The same key as its entry |
| `p.<id>` | Pending locked entry, waiting for your PIN | Device key ([7.7](#77-pending-locked-entries)) |
-| `imp` | Partial import text, kept between syncs | Device key |
+| `imp`, `imp.<n>` | Partial import text, kept between syncs, in chunks below 8 KB | Device key |
+| `res`, `fh`, `st` | Latest settings results; hashes of applied forms; date of the last self-test | Not secret |
Limits: at most 8 KB per value and about 100 KB in total (the stricter of Garmin's two documented figures). M3 measured 239 bytes for a typical OTP record, so the entry cap stays at 150 (byte formats in [docs/SECURITY.md](docs/SECURITY.md)). A full store produces a clear message, never a crash.
@@ -501,12 +502,12 @@ The full files are written in M7.
- **`otpauth://` URIs** for `totp`, `hotp` and `steam`. Paste one or many, separated by spaces or line breaks, or simply run together: a new URI starts at every `otpauth://`.
- **Aegis unencrypted JSON export**: vault format version 1, database versions 1, 2 and 3. Detected by a leading `{`.
-- **Stretch goal**: Google Authenticator `otpauth-migration://offline?data=...` exports. The format is reverse-engineered (Base64 and protobuf). It's built in M5 if time allows; otherwise it's listed as deferred.
+- **Deferred: Google Authenticator** `otpauth-migration://offline?data=...` exports. This was the stretch goal. M5 ran out of the time set aside for it: the format is reverse-engineered (Base64 and protobuf), and its fixtures would have to be built from scratch. The importer recognises these links and explains that they aren't supported yet. Exporting from Aegis (which imports Google Authenticator) works today.
### 11.2 Getting the text into settings
- **Fields**: the eight import fields are joined in order. If the text is longer than one field accepts, continue in the next one.
-- **Several syncs**: if the JSON is still incomplete after joining, the watch keeps what it got (encrypted under the device key) and reports: "Waiting for more: received 3,214 characters. Paste the next part into the import fields and turn 'Import now' on again." "Cancel pending import" discards it. The buffer is capped (initially 32 KB, set after measurements) and expires after 24 hours.
+- **Several syncs**: if the JSON is still incomplete after joining, the watch keeps what it got (encrypted under the device key) and reports: "Waiting for more: received 3,214 characters. Paste the next part into the import fields and turn 'Import now' on again." "Cancel pending import" discards it. The buffer is capped at 32 KB and expires after 24 hours. M5 measured an Aegis export at about 190 bytes per entry compact, and 400 to 500 pretty-printed. Exports larger than the cap need to be split into several imports.
- **Size reality**: pretty-printed Aegis JSON takes about 400 to 500 characters per entry without icons. Icons add several KB each and are the main reason a paste might not fit. An `otpauth://` URI takes about 80 to 200 characters.
- **iPhone**: Garmin Connect on iOS reportedly rejects `&` (present in every URI with parameters) and `\` (used in JSON escapes). The settings spike checks this. If it's confirmed, the parser also accepts `;` in place of `&`, and the user guide explains the workaround.
diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md
index 7b56f3b..d03a8fe 100644
--- a/docs/DEVELOPER_GUIDE.md
+++ b/docs/DEVELOPER_GUIDE.md
@@ -124,5 +124,7 @@ Debug builds print `DIAG first_frame startup_ms=... mem_used=... mem_total=...`
- **A device**: add `<iq:product id="..."/>` to `manifest.xml`, then `make sdk` (downloads its files), `make icons` and `make products` (checks it meets the target rule: a watch, API 5.0 or newer, at least 512 KB).
- **A test device**: add it to `TEST_DEVICES` (unit tests, perf and screenshots) or `SPOT_DEVICES` (screenshots only) in the `Makefile`. Then run `make screenshots`, review `build/screenshots/<device>/`, and run `make screenshots-approve`.
- **A screen in the UI flow**: print `Diag.shown("<name>")` after its first complete draw, add the steps to `run_flow` in `tools/ui_flow.py`, and approve the new goldens after reviewing them.
+- **Import inputs**: new parser cases go into `test/import/` (fixtures are built in `ImportSupport.mc`, all fake). To widen the fuzz corpus, change the seeds or mutations in `tools/gen_fuzz_corpus.py` and run `make corpus`. The Python tests check that the committed corpus matches the generator.
+- **A settings form** (M7 adds the rest): declare its properties in `resources/settings/properties.xml` (reading an undeclared one crashes), add the form to `resources/settings/settings.xml` with its strings, and handle it in `source/settings/SettingsProcessor.mc`. The form is applied only when its switch is on. The processor clears the fields afterwards and remembers the form's hash.
- **On-watch numbers**: sideload a debug build (`make build DEVICE=fr70`, copy `build/debug/fr70.prg` to the watch's `GARMIN/APPS` folder over USB). Then open the menu and choose "Debug: benchmark".
- **A barcode symbology** (from M6): one encoder module under `source/codes/`, its validator, unit tests with reference patterns, and the checks in [README 12.4](../README.md#124-how-each-symbology-is-proven-scannable).
diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md
index 8c5adb4..9a5bcbf 100644
--- a/docs/PERFORMANCE.md
+++ b/docs/PERFORMANCE.md
@@ -25,6 +25,13 @@ The targets are in [README section 18](../README.md#18-performance-budgets). Thi
## Results
+### M5, 2026-10-20 (version 0.5.0, import)
+
+- **Compute.** An Aegis export with 100 entries (19 KB, compact) takes 332 ms of compute in the unit-test benchmark, spread over 159 job steps.
+- **On the simulated watches.** Through the real path (settings form, timer-driven job, 50 ms pause between steps), the UI-test build imports 100 entries in about 7.6 s on the Forerunner 255S. Most of that is the pauses that keep each event below the watchdog limit. Peak memory during the import is 41.5 % of the 255S's 512 KB: the pasted text is held as a string and as bytes, along with the parsed items. The flow fails above 70 %.
+- **Pasted text size.** Aegis exports take about 190 bytes per entry compact, and 400 to 500 pretty-printed. The multi-part buffer is capped at 32 KB, so very large pretty-printed exports must be imported in parts.
+- **Watchdog, again.** Even the UI-test fixture code tripped the watchdog on the 255S, by Base32-encoding 96 secrets in one event. The importer itself works in steps of 40 JSON tokens, one URI, four headers or one stored entry.
+
### M4, 2026-10-19 (version 0.4.0, screens)
- **Start-up.** In-app start-up (object creation to the first complete frame) measured 1 to 18 ms in the simulator; the `startup_ms` budget is now 100 ms. Launch to first frame rose to 16-18 s, because the simulator first loads the glance and the scripts then open the app from it. Its regression guard is now 36 s.
diff --git a/docs/SECURITY.md b/docs/SECURITY.md
index ee2f94d..45faba6 100644
--- a/docs/SECURITY.md
+++ b/docs/SECURITY.md
@@ -18,8 +18,12 @@ Everything lives in the app's private `Application.Storage`:
| `lk` | dictionary | Wrong-PIN state: `f` failures, `d` delay in seconds, `t` trusted time of the last attempt; absent after a success | `Lockout` |
| `e.<id>` | bytes | One entry record (format below) | `Vault` |
| `p.<id>` | bytes | Pending locked entry: a record sealed with the device key | `Vault` |
+| `imp`, `imp.<n>` | dictionary, bytes | Waiting multi-part import: `imp` = `{"n": chunks, "t": saved at}`, `imp.<n>` = chunks of one SealedBox (device key, aad `OTPW-IMP-1`) of the text | `ImportBuffer` |
+| `res` | dictionary | Latest settings results (`l` lines, `u` unseen) | `Results` |
+| `fh` | array | Hashes (SHA-256, first 8 bytes) of the last 32 applied settings forms | `SettingsProcessor` |
+| `st` | array | Version and day of the last passed self-test | `AppState` |
-`x.<id>` (barcode cache, M6) and `imp` (import buffer, M5) are added by later milestones.
+`x.<id>` (barcode cache) arrives with M6.
The PIN wrap and the lockout state have their own keys instead of living in `m` as README 6.2 first planned. Each wrong PIN rewrites the lockout state before the check, and a small separate value makes that write cheap and keeps it from risking the entry index.
@@ -66,6 +70,7 @@ TLV field: `tag (1 byte) | type (1 byte) | length (2 bytes, big-endian) | value`
| 10 | HOTP counter | int | header |
| 11 | created (unix seconds) | int | header |
| 12 | names encrypted | bool | header |
+| 13 | fingerprint (8 bytes, see below) | bytes | header |
| 20, 21, 22 | algorithm (0 SHA-1, 1 SHA-256, 2 SHA-512), digits, period | int | box (OTP) |
| 23 | secret (raw bytes) | bytes | box (OTP) |
| 24 | payload (UTF-8 bytes) | bytes | box (code) |
@@ -153,3 +158,22 @@ A locked entry that arrives while the session is locked, or before a PIN exists,
- **The UI-test build** (`uitest.jungle`) wipes the store at every start and fills it with fake entries. It sets the PIN 123456 by storing a precomputed wrap of a fixed fake master key (`tools/gen_fixture_wrap.py`), and freezes the clock. All of this is `(:uitest)` or `(:debug)` code; release builds, and therefore the Store package, contain none of it.
- **The PIN screen hands the derivation a copy of the typed digits** and wipes both the copy and its own buffer once the derivation is done. The native HMAC object may keep its own copy (see above).
+
+## Fingerprints (duplicate detection)
+
+Imports skip an entry that has "the same type and the same secret" as an existing one (README 11.4). That has to work for locked entries while the watch is locked. So every record carries an 8-byte keyed fingerprint in its readable header, tag 13:
+
+```text
+fingerprint = first 8 bytes of HMAC-SHA256(Kfp, "OTPW-FP-1" || kind || type || secret or payload)
+Kfp = HKDF-SHA256(device key, info = "OTPW fp v1")
+```
+
+- **Without the device key it reveals nothing.** Equal fingerprints only show that two entries share a type and secret.
+- **With the device key, it allows checking guesses of a locked entry's secret.** An attacker who could read app storage could compare a guessed secret against the fingerprint. Real TOTP secrets are random and at least 80 bits long, so guessing them is infeasible; a user-chosen, guessable secret would be weakened.
+- **The fingerprint is part of the header, so the record's MAC covers it.** It is recomputed on every add and update (`importerFingerprintsIdentifySecrets`).
+
+## Import text
+
+- **Secrets in the phone settings.** Text pasted into the Import fields sits in Garmin Connect's settings until the watch processes it. The watch then clears the fields (they hold secrets) and turns "Import now" off. Whether the phone shows the cleared fields is the open question of the M1 spike.
+- **Unfinished imports.** A multi-part import waiting for its next part is kept sealed with the device key under `imp`/`imp.<n>`. It is deleted when the import completes, fails, is cancelled or is 24 hours old. A test checks that the secret's text isn't readable in storage (`importerJoinsMultiPartJson`).
+- **What the parsers touch.** They never decode icons or notes: long strings are skipped unread, and notes aren't kept. Parsed secrets are wiped once stored or rejected.
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 54a331e..593b120 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -26,6 +26,39 @@ Test devices (`TEST_DEVICES`): Forerunner 255S (weakest: 218 px round MIP, butto
## Results
+### M5, 2026-10-20 (version 0.5.0)
+
+Monkey C unit tests: 101 per device (29 new in `test/import/`):
+
+- **JSON tokenizer:** every token kind; escapes including surrogate pairs; integers versus other numbers; 16 kinds of invalid document; incomplete input (8 cases); the depth limit; long strings (icons, including escaped quotes) skipped unread; long keys refused.
+- **`otpauth://`:** URIs pasted run together; percent-encoded labels; a missing issuer; an issuer mismatch; `;` separators; `+` as a space; unknown parameters; HOTP with and without a counter; both Steam forms; nine error cases, each with its message.
+- **Aegis:**
+ - Database versions 1, 2 and 3, with groups listed after the entries.
+ - totp, hotp and steam imported; motp and yandex skipped.
+ - SHA-1, SHA-256 and SHA-512; 6, 7 and 8 digits; periods 20, 30, 50 and 60.
+ - Favourites, icons, notes and Unicode names.
+ - Encrypted exports refused, in both forms.
+ - Bad entries (9 digits, a duplicated `secret` field, invalid Base32, a non-integer digit count), and non-Aegis, newer-version and too-deeply-nested documents.
+ - An incomplete export.
+- **Importer:**
+ - The report text.
+ - Duplicates of an earlier import, of a locked entry while the watch is locked, and within one import.
+ - Possible replacements; "Require PIN" entries going pending.
+ - Replace mode keeping manual entries.
+ - A two-part JSON import, sealed while it waits, and the expiry of a waiting part.
+ - Unusable text: empty, unknown, Google Authenticator links, encrypted, invalid.
+ - A store that fills up mid-import, keeping only whole entries.
+ - Fingerprints.
+ - 100 entries.
+- **Fuzz corpus** (`tools/gen_fuzz_corpus.py`, seed 4242, `make corpus`): 50 mutated Aegis documents and 50 mutated URIs.
+ - The tokenizer always finishes.
+ - The Aegis reader always ends in a definite state, with every item either valid or explained.
+ - The full importer never stores an invalid entry: 21 entries were stored from the inputs that survived mutation.
+
+Python tool tests: 55. The new ones cover the corpus generator, including that the committed corpus matches it.
+
+Scripted UI flow: the UI-test build now puts an Aegis export of 100 entries into the Import form, as the phone would, and the flow starts with the results screen that opens after the import. 11 screens per watch (10 on the touch-only ones). Every watch reports the import's duration and peak memory, and the flow fails above 70 % of the watch-app memory.
+
### M4, 2026-10-19 (version 0.4.0)
`make ci` passed in full.
@@ -102,3 +135,4 @@ Things the simulator can't verify, done by you on the Forerunner 70. The checkli
- [ ] M4: sideload a debug build (`make build DEVICE=fr70`, then copy `build/debug/fr70.prg` to `GARMIN/APPS` over USB). Open the menu, choose "Debug: benchmark", and send me the numbers. They set the real PBKDF2 iteration count and the SHA-512 cost on your watch.
- [ ] M4: with "Debug: demo entries", check the list, a code, the countdown ring, HOTP "next", and the PIN keypad with your finger. Also check a wrong PIN (vibration) and the 30 s wait after five wrong PINs.
- [ ] M4: check that leaving the app and coming back asks for the PIN again for the locked demo entry. Check that the glance in the glance loop shows the number of entries.
+- [ ] M5: in Garmin Connect, paste a few `otpauth://` links of test accounts (not your real ones) into the Import fields, turn "Import now" on and save. Check the result on the watch and in the Result field. Also check that the text fields are emptied afterwards: this answers part of the M1 question.