summaryrefslogtreecommitdiff
path: root/README.md
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-18 20:28:09 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-18 20:28:09 +0200
commit8b0e632526d9504c5c0e09bab72ab83db1c46f9b (patch)
tree164954f84d24316f41f7aa62774291157806a17a /README.md
parent5396ace6a695ecf530c2ecc9793dfe01f523e70d (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'README.md')
-rw-r--r--README.md88
1 files changed, 49 insertions, 39 deletions
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.<id>` | 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 |