From 8b0e632526d9504c5c0e09bab72ab83db1c46f9b Mon Sep 17 00:00:00 2001 From: cirakg Date: Sun, 18 Oct 2026 20:28:09 +0200 Subject: docs: record final decisions, test watch and SDK login details - README: Forerunner 70 is the test watch, the phone is Android, Docker access is settled; how `make sdk` logs in and why the browser login can't be used; the new `make products` and `make lock` targets. - FEASIBILITY: final decisions D1-D5. - Space out table separator rows and turn bold-only lines into headings so markdownlint passes. Co-Authored-By: Claude Opus 5.5 --- README.md | 88 +++++++++++++++++++++++++++++++++++---------------------------- 1 file changed, 49 insertions(+), 39 deletions(-) (limited to 'README.md') diff --git a/README.md b/README.md index aa1af67..bdfe0ea 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ A Garmin Connect IQ watch app that shows one-time passwords (TOTP, HOTP, and opt ### What it won't do | Not supported | Why | -|---|---| +| --- | --- | | Unlocking with the watch's own PIN or passcode | Connect IQ has no API for it | | Encrypted Aegis exports | Decrypting one needs 32 MiB of RAM; watch apps get under 0.8 MB. You chose plain JSON import over an off-watch converter (decision D2) | | Aegis icons and notes, mOTP and Yandex entries | The watch doesn't decode images, notes may hold private text, and mOTP/Yandex weren't requested. They're skipped with a message | @@ -62,27 +62,32 @@ A Garmin Connect IQ watch app that shows one-time passwords (TOTP, HOTP, and opt Decided on 2026-10-18: | # | Decision | Choice | -|---|---|---| +| --- | --- | --- | | D1 | PIN policy | Session unlock: enter the PIN once per session. Everything re-locks when you leave the app or after an inactivity timeout | | D2 | Import | Paste `otpauth://` URIs or an unencrypted Aegis JSON export into the settings; the watch parses them. No converter tool, no encrypted Aegis import | | D3 | Manual-entry layout | Inbox forms ("Add OTP", "Add code") plus a "Manage entry" form | | D4 | Targets | Watches with API 5.0 or newer and at least 512 KB for apps | -Still open. None of these block this plan: +Also settled on 2026-10-18, after this plan was approved: + +| # | Question | Answer | +| --- | --- | --- | +| D5 | Docker access | You joined the `docker` group | +| Q1 | Test watch | Forerunner 70 (2026): API 6.0, 390×390 AMOLED, touch plus 5 buttons, 768 KB. It's one of the simulator test devices | +| Q2 | Phone | Android, so the iPhone `&` and `\` problem doesn't affect you | + +Still open: | # | Question | Needed by | -|---|---|---| -| D5 | Docker access: join the `docker` group, or set up rootless Docker? | Phase 2 | -| D6 | Your Garmin account: accept the SDK licence and log in inside the container; create a developer account for the beta | Phase 2 and M1 | -| Q1 | Which watch will you test on? | M1 | -| Q2 | Is your phone Android or iPhone? The iPhone Garmin app reportedly rejects `&` and `\`, which affects both paste formats | M1 | +| --- | --- | --- | +| D6 | Your Garmin account: accept the SDK licence and log in inside the container (`make sdk`); create a developer account for the beta | Phase 2 and M1 | ## 3. Feasibility findings A summary. The details, evidence and sources are in [docs/FEASIBILITY.md](docs/FEASIBILITY.md). | # | Question | Verdict | Consequence | -|---|---|---|---| +| --- | --- | --- | --- | | 1 | Unlock entries with the Garmin device PIN | Not possible | App PIN with session unlock (D1). Garmin's system passcode, where a watch has one, shuts apps down when it locks and complements the app lock | | 2 | SHA-1/256/512, HMAC, PBKDF2, AES on the watch | Partially | Native: SHA-1, SHA-256, AES-ECB/CBC, HMAC-SHA256, random bytes. Built from their standards in Monkey C: HMAC-SHA1, SHA-512, PBKDF2, HKDF, encrypt-then-MAC | | 3 | Import and manual entry through app settings | Partially | Text fields only, no file upload. Settings only work for Store installs, so delivery is a private beta. Array settings are broken (CIQQA-4751), hence the inbox design (D3) | @@ -142,7 +147,7 @@ flowchart TB ``` | Module | Responsibility | -|---|---| +| --- | --- | | `app/` | `AppBase`: lifecycle, initial view, glance view, `onSettingsChanged`, locking in `onStop` | | `crypto/` | Wrappers around the native hashes, HMAC (RFC 2104), SHA-512 (FIPS 180-4), PBKDF2 (RFC 8018), HKDF (RFC 5869), AES-CBC with PKCS#7 padding, encrypt-then-MAC records, constant-time comparison, zeroing, random bytes, start-up self-test | | `otp/` | Base32, HOTP, TOTP, Steam, parameter validation | @@ -169,7 +174,7 @@ Design rules: ### 6.1 Entry fields | Field | Applies to | Where it's stored | Notes | -|---|---|---|---| +| --- | --- | --- | --- | | `id` | all | header | 4 characters from `23456789ABCDEFGHJKMNPQRSTVWXYZ`, random and unique. Shown on the watch and used by the Manage form | | `kind` | all | header | OTP or code | | `type` | OTP | header | TOTP, HOTP or Steam | @@ -191,7 +196,7 @@ Design rules: ### 6.2 Storage layout (`Application.Storage`) | Key | Content | Protected by | -|---|---|---| +| --- | --- | --- | | `m` | Metadata: schema version, entry index (the order of IDs), KDF salt and iteration count, wrapped locked master key, lockout state, hashes of applied forms | The wrapped key is encrypted. The rest is not secret | | `dk` | Device key, 32 random bytes | Stored as is ([7.4](#74-what-stays-readable)) | | `e.` | One entry record | Device key, or the locked master key for locked entries | @@ -299,7 +304,7 @@ On the first start after an install or update, and then once a day, the app runs **Assets**: OTP secrets, barcode contents, the PIN, entry names. | Scenario | Protected? | Notes | -|---|---|---| +| --- | --- | --- | | Someone picks up your watch while the app is closed | Locked entries: yes. Unlocked entries: no, by design | Lock what matters. Turn on Garmin's system passcode if your watch has one | | Someone grabs the watch during an unlocked session | Until auto-lock (default: 60 s without input) | Choose a shorter timeout, or "Immediately" | | Lost or stolen watch, attacker uses the screen | Locked entries: yes | Lockout delays, optional erase | @@ -331,10 +336,10 @@ Garmin's "array settings" (growable lists) are broken today. A failed save reset ### 10.2 Groups and fields -**Add OTP** +#### Add OTP | Property | Type | Field | Notes | -|---|---|---|---| +| --- | --- | --- | --- | | `otpType` | number | list | TOTP, HOTP, Steam | | `otpIssuer` | string | alphaNumeric (max 40) | | | `otpAccount` | string | alphaNumeric (max 64) | | @@ -348,10 +353,10 @@ Garmin's "array settings" (growable lists) are broken today. A failed save reset | `otpAdd` | boolean | switch | **Add this entry**: the watch only acts when this is on | | `otpStatus` | string | read-only | Result, written by the watch | -**Add code** +#### Add code | Property | Type | Field | Notes | -|---|---|---|---| +| --- | --- | --- | --- | | `codeName` | string | alphaNumeric (max 40) | | | `codeSymbology` | number | list | The 12 symbologies | | `codePayload` | string | alphaNumeric | The code's text or digits | @@ -363,10 +368,10 @@ Garmin's "array settings" (growable lists) are broken today. A failed save reset | `codeAdd` | boolean | switch | **Add this entry** | | `codeStatus` | string | read-only | | -**Manage entry** +#### Manage entry | Property | Type | Field | Notes | -|---|---|---|---| +| --- | --- | --- | --- | | `mgId` | string | alphaNumeric | The entry ID shown on the watch, for example `K7F2` (case-insensitive) | | `mgAction` | number | list | Edit details, Move to position, Require PIN, Don't require PIN, Replace secret or contents, Set HOTP counter, Delete | | `mgName`, `mgAccount`, `mgGroup` | string | alphaNumeric | Blank means unchanged. `-` clears the group | @@ -376,20 +381,20 @@ Garmin's "array settings" (growable lists) are broken today. A failed save reset | `mgApply` | boolean | switch | **Apply** | | `mgStatus` | string | read-only | | -**Import** +#### Import | Property | Type | Field | Notes | -|---|---|---|---| +| --- | --- | --- | --- | | `impText1` to `impText8` | string | alphaNumeric | Joined in order. The number of fields is set after the settings spike (M1) | | `impMode` | number | list | Append (default), Replace imported entries, Cancel pending import | | `impLocked` | boolean | switch | Require PIN for every entry in this import | | `impApply` | boolean | switch | **Import now** | | `impStatus` | string | read-only | | -**Options** +#### Options | Property | Field | Default | -|---|---|---| +| --- | --- | --- | | `optAutoLock` | list: Immediately, 15 s, 30 s, 1 min, 2 min, 5 min | 1 min | | `optWipe` | switch: Erase locked entries after 10 wrong PINs | off | | `optTimeOffset` | numeric, −120 to 120 s | 0 | @@ -501,7 +506,7 @@ The full files are written in M7. ### 11.3 Parsing rules -**`otpauth://` URIs** +#### `otpauth://` URIs - The label is percent-decoded and split on the first `:` (or `%3A`) into issuer and account, with surrounding spaces trimmed. - An `issuer` parameter wins over the label's issuer. A mismatch is noted in the report. @@ -511,7 +516,7 @@ The full files are written in M7. - Unknown parameters (`image`, `color` and so on) are ignored. - Errors name the URI's position, for example "URI 3: digits=9 is not supported (6–8)". -**Aegis JSON** +#### Aegis JSON - **Encrypted exports are refused** with a message explaining how to export unencrypted. They're recognised by `header.slots` not being null, or `db` being a string. - **Types**: `totp`, `hotp` and `steam` are imported. `motp`, `yandex` and unknown types are skipped and reported. @@ -538,7 +543,7 @@ Imported entries behave exactly like manual ones. You rename, regroup, move, loc ### 12.1 Symbologies | Symbology | Accepted content | Options | Validation | Scope | -|---|---|---|---|---| +| --- | --- | --- | --- | --- | | QR | Any text (UTF-8) | Error correction L/M/Q/H (default M) | Capacity for the chosen level; fits this screen | Versions 1 to 40, numeric/alphanumeric/byte modes with automatic segmentation, all 8 masks with standard penalty scoring. No Kanji, ECI or Micro QR | | Code 128 | ASCII 32 to 126 | Human-readable text | Characters; fits | Automatic switching between code sets A, B and C for the shortest result. GS1-128 (FNC1) deferred | | Code 39 | `0–9`, `A–Z`, space, `- . $ / + %` | Mod-43 check digit on or off; text | Characters; fits | Full ASCII mode deferred | @@ -565,7 +570,7 @@ Imported entries behave exactly like manual ones. You rename, regroup, move, loc ### 12.3 What fits (QR, byte mode, error correction M) | Screen | Largest centred square | At 3 px per module | At 2 px per module | -|---|---|---|---| +| --- | --- | --- | --- | | 218 px round (Forerunner 255S) | 154 px | version 6, 106 bytes | version 13, 331 bytes | | 260 px round (fēnix 7, Forerunner 255) | 184 px | version 9, 180 bytes | version 16, 450 bytes | | 390 px round (vívoactive 5) | 276 px | version 16, 450 bytes | version 28 | @@ -648,7 +653,7 @@ stateDiagram-v2 ### 13.4 Input | Action | Touch | Buttons | -|---|---|---| +| --- | --- | --- | | Scroll the list | Swipe up and down | UP and DOWN | | Open an entry | Tap it | START | | Back | Swipe right, or BACK | BACK | @@ -661,9 +666,10 @@ stateDiagram-v2 - **Manifest**: type `watch-app`, `minApiLevel` 5.0.0, no permissions, English. - **Products**: generated in M0 from the downloaded device definitions: every watch with API 5.0 or newer and at least 512 KB for watch apps. That includes fēnix 7, 8 and E; epix 2; Forerunner 165, 255, 265, 570, 955, 965 and 970; Venu 2, 3, 4, Sq 2 and X1; vívoactive 5 and 6; Instinct 3 AMOLED; Enduro 3; and related MARQ, quatix, tactix, D2, Descent and Approach models. -- **Simulator test devices**: +- **Simulator test devices** (`TEST_DEVICES` in the Makefile): - Forerunner 255S: the weakest target (218 px round MIP, buttons only, 512 KB). - Venu Sq 2: rectangular AMOLED with touch. + - Forerunner 70: your watch (390 px round AMOLED, touch and buttons). - Forerunner 965: 454 px round AMOLED with touch. - Spot checks: fēnix 7 (MIP with touch and buttons), Instinct 3 AMOLED (buttons only), Venu X1 (the largest rectangle). - **Memory**: watch app 512 to 768 KB; glance 64 KB. Budgets are in [section 18](#18-performance-budgets). @@ -673,7 +679,7 @@ stateDiagram-v2 ### 15.1 Images | Image | Base | Contents | Network while building | -|---|---|---|---| +| --- | --- | --- | --- | | `garmin-otp/dev` | `ubuntu:22.04` pinned by digest, apt from a dated `snapshot.ubuntu.com` mirror | OpenJDK 17; simulator libraries (`libwebkit2gtk-4.0-37`, `libusb-1.0-0`, `libsm6`, GTK); Xvfb, openbox, x11vnc, noVNC, xdotool, ImageMagick; a Python 3.10 virtual environment (pytest, hypothesis, zxing-cpp, segno, Pillow, all exact versions with hashes); `connect-iq-sdk-manager-cli` v0.8.4 (SHA-256 checked); make, openssl | yes | | `garmin-otp/docs` | Node LTS pinned by digest | markdownlint-cli2, mermaid-cli | yes | @@ -693,17 +699,19 @@ The Garmin SDK and device files are never baked into an image (Garmin's licence) - `sudo usermod -aG docker cirakg`, then log out and back in. Membership in that group is equivalent to root on this machine. - Or rootless Docker, which needs the AUR package `docker-rootless-extras`. 2. **`make image`** builds the images. -3. **`make sdk`**: - - Shows Garmin's agreements (`agreement view`). You accept them (`agreement accept`) and log in, both interactively. - - Downloads SDK 9.2.0 and the device files for the manifest's products into `.cache/home/.Garmin/ConnectIQ`. - - Fallback if the third-party CLI's login fails: run Garmin's own SDK Manager inside the container and use it through noVNC. +3. **`make sdk`** (interactive, in a terminal): + - Shows the link to Garmin's agreements and their version hash. You read them in your browser and type `accept`; the hash makes sure you accept exactly the text you read. + - Asks for your Garmin email and password. They're passed only to the SDK manager CLI, which sends them to Garmin's sign-in page; neither is written to disk. The CLI's browser-based login can't be used, because it waits for a redirect to a random port inside the container, which your browser can't reach without host networking. + - Downloads SDK 9.2.0 and the device files and simulator fonts for the manifest's products into `.cache/home/.Garmin/ConnectIQ`. + - Deletes the login token at the end (`KEEP_LOGIN=1 make sdk` keeps it), so no Garmin credential stays in the project. + - **Two-step verification**: password login doesn't support it. Fallback (not built yet): sign in through a browser running inside the container, shown at 127.0.0.1:6080, so the redirect stays inside the container. 4. **`make key`** generates your developer key into `.cache/keys/` and prints backup instructions. 5. **`make test`** and **`make sim`** confirm everything works. ### 15.4 Make targets | Target | What it does | Network | -|---|---|---| +| --- | --- | --- | | `make image` | Build the Docker images | yes | | `make sdk` | Licence, login, download the SDK and device files | yes (Garmin only) | | `make key` | Generate the developer key (never overwrites an existing one) | no | @@ -717,6 +725,8 @@ The Garmin SDK and device files are never baked into an image (Garmin's licence) | `make package` | Signed `.iq` for the Store beta, plus per-device `.prg` files | no | | `make docs` | Markdown lint, link check, Mermaid render check | no (yes when building the docs image) | | `make ci` | lint, test, screenshots, perf, memtest, package, docs | no | +| `make products` | Check the manifest's product list against the downloaded device files | no | +| `make lock` | Regenerate the Python and npm lock files (only releases older than the cutoff date) | yes | | `make shell` | Shell in the dev container | no | | `make clean` | Delete build outputs | no | | `make nuke` | Delete everything this project created ([15.7](#157-uninstall-and-cleanup)) | no | @@ -864,7 +874,7 @@ Tests are written alongside the code, never after. ### 17.1 Layers | Layer | Tool | Runs in | -|---|---|---| +| --- | --- | --- | | Unit tests (Monkey C) | Garmin's Run No Evil framework, `(:test)` functions | Headless simulator, 3 devices | | Test-vector generation, reference encoders, decoding, screenshot analysis | Python: pytest, hypothesis, zxing-cpp, segno, Pillow | Dev container | | UI and screenshot tests | Simulator, Xvfb, ImageMagick, xdotool | Dev container | @@ -947,7 +957,7 @@ The full checklist will be in `docs/TESTING.md`. It covers what the simulator ca ## 18. Performance budgets | Metric | Target on a watch | Simulator gate | How it's measured | -|---|---|---|---| +| --- | --- | --- | --- | | Cold start to the first unlocked TOTP code drawn | ≤ 1.0 s on a mid-range watch (Forerunner 255 or 265) | ≤ 2× the M4 baseline | `System.getTimer()` from `AppBase.initialize` to the end of the first list draw. On the watch, the debug overlay | | TOTP with SHA-1 or SHA-256 | ≤ 30 ms | ≤ 2× baseline | Timer around the computation, averaged over 100 runs | | TOTP with SHA-512 (pure Monkey C) | ≤ 400 ms | ≤ 2× baseline | Same | @@ -964,7 +974,7 @@ The simulator runs on your PC's CPU, so its timings don't represent a watch. In ## 19. Risks and mitigations | Risk | Impact | Mitigation | -|---|---|---| +| --- | --- | --- | | Real settings limits are smaller than assumed, or write-back doesn't work | Pastes don't fit; fields don't clear | Spike in M1, early; multi-part import; idempotent forms; results on the watch | | The iPhone Garmin app rejects `&` and `\` | Paste import fails on iPhone | Confirm in the spike; `;` accepted in URIs; Android workaround documented | | Plain-text secrets in settings (D2, D3) | Garmin and the phone see secrets | Documented in the threat model; fields cleared; advice to delete export files | @@ -985,7 +995,7 @@ The simulator runs on your PC's CPU, so its timings don't represent a watch. In ## 20. Documentation | Document | Content | -|---|---| +| --- | --- | | `README.md` | This plan, kept up to date as the project's overview | | [`docs/FEASIBILITY.md`](docs/FEASIBILITY.md) | Phase 0 findings with sources | | `docs/USER_GUIDE.md` | Install the beta, import from Aegis, add codes, PIN and locking, daily use, moving to a new watch, troubleshooting | -- cgit v1.2.3