# OTP Wallet for Connect IQ 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: M2 (crypto and OTP 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 SHA-512, HMAC, PBKDF2, HKDF, AES-256 records, Base32, HOTP, TOTP and Steam codes, verified against the official test vectors on four simulated watches. The settings spike (`make spike`, [docs/SETTINGS_SPIKE.md](docs/SETTINGS_SPIKE.md)) waits for your test; M3 (storage) continues meanwhile. 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. ## Contents 1. [Scope](#1-scope) 2. [Decisions and open questions](#2-decisions-and-open-questions) 3. [Feasibility findings](#3-feasibility-findings) 4. [How the app reaches your watch](#4-how-the-app-reaches-your-watch) 5. [Architecture](#5-architecture) 6. [Data model](#6-data-model) 7. [Security design](#7-security-design) 8. [Threat model](#8-threat-model) 9. [Time handling](#9-time-handling) 10. [Settings design](#10-settings-design) 11. [Import design](#11-import-design) 12. [Barcodes and QR codes](#12-barcodes-and-qr-codes) 13. [UX design](#13-ux-design) 14. [Target devices and API levels](#14-target-devices-and-api-levels) 15. [Docker and sandbox](#15-docker-and-sandbox) 16. [Build plan](#16-build-plan) 17. [Test plan](#17-test-plan) 18. [Performance budgets](#18-performance-budgets) 19. [Risks and mitigations](#19-risks-and-mitigations) 20. [Documentation](#20-documentation) 21. [Repository layout](#21-repository-layout) 22. [Versioning](#22-versioning) ## 1. Scope ### What it will do - **One-time passwords**: TOTP (RFC 6238) and HOTP (RFC 4226) with SHA-1, SHA-256 or SHA-512, 6 to 8 digits, and any period from 1 to 300 seconds (default 30). Steam Guard codes are a separate, optional entry type. - **Barcodes and QR codes**: QR, Code 128, Code 39, EAN-13, EAN-8, UPC-A, UPC-E, ITF, Codabar, Data Matrix, PDF417 and Aztec. The watch stores the text and the symbology, then encodes and draws the code itself. A symbology ships only once it passes the checks in [section 12.4](#124-how-each-symbology-is-proven-scannable). Anything that doesn't pass is listed as deferred. - **Per-entry lock**: each entry has its own "Require PIN to display" setting. One app PIN unlocks a session. - **Encryption at rest** for every entry. - **Manual entry from your phone**: you add, edit, reorder and delete entries in the Connect IQ app settings, using an "add" form for each kind and a "manage" form. - **Import**: paste `otpauth://` URIs or an unencrypted Aegis JSON export into the app settings. - **Simple, fast watch UI**: a list of entries, a big code with a countdown ring, tap to advance an HOTP counter. It works with touch screens and with buttons only. ### 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 | | Everyday use as a sideloaded app | Sideloaded apps get no phone settings, so you couldn't add entries. Sideloaded builds are for testing only | | Exporting or backing up entries from the watch | Deliberate: secrets only flow *into* the watch. Your Aegis export and your own records are the backup | | Scanning codes with the watch | There is no camera | | Very long linear codes and large 2D codes on small screens | They don't fit at a scannable size. The watch tells you instead of drawing an unreadable code | | Strong protection of locked entries if someone copies the data off the watch | A short PIN can be guessed offline ([section 8](#8-threat-model)) | | Any network feature | The app requests no permissions | ## 2. Decisions and open questions 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 | 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 | | --- | --- | --- | | 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) | | 4 | Storage limits | Partially | Garmin's pages disagree. Design for at most 8 KB per value and about 100 KB in total | | 5 | Memory | Supported on the targets | 512 to 768 KB per app on API 5.x and 6.x watches | | 6 | Barcode rendering | Partially | All 12 symbologies can be drawn. Big codes don't fit small round screens. Scanner type matters | | 7 | App type | Supported | Device app with a glance | | 8 | Privacy of settings | Partially (undocumented) | Treat anything typed into settings as stored by Garmin. A consequence of D2 and D3 | | 9 | Encrypted Aegis export on the watch | Not possible | Out of scope (D2) | | 10 | TOTP after 2038 | Partially | The clock value is 32-bit. It's reinterpreted as unsigned, which is correct until 2106 | | 11 | Headless SDK, simulator and tests in Docker | Partially | Your Garmin login is needed once for device files. Ubuntu 22.04 base image | ## 4. How the app reaches your watch Garmin's app settings only work for apps installed from the Connect IQ Store, so: 1. `make package` builds a signed `.iq` file in Docker. 2. You upload it on Garmin's developer website as a **beta app**, which only your account can see. Every new version is a new upload. 3. You install it on your watch from the Connect IQ Store app. 4. You manage entries in the Garmin Connect app on your phone: your watch, then Connect IQ apps, then OTP Wallet, then Settings. Development and automated tests use the simulator in Docker. For speed and rendering checks on a real watch, a sideloaded debug build contains fake fixture data and needs no settings. ## 5. Architecture ```mermaid flowchart TB subgraph Phone["Phone: Garmin Connect app settings"] F["Add OTP / Add code / Manage entry / Import / Options"] end subgraph Watch["Watch app (Monkey C)"] APP["app: lifecycle, glance entry"] UI["ui: list, OTP, code, PIN, menu, results, glance"] SET["settings: form reader, inbox, manage, options, status"] IMP["import: otpauth, Aegis JSON, JSON tokenizer"] ST["storage: vault, records, keys, session, lockout, migrations"] OTP["otp: Base32, HOTP, TOTP, Steam"] COD["codes: encoders, Reed-Solomon, validators, renderer"] CR["crypto: hashes, HMAC, SHA-512, PBKDF2, HKDF, AES-CBC + HMAC"] UT["util: clock, jobs, bytes, errors, debug log"] end F -- "values, via Garmin" --> SET APP --> UI APP --> SET SET --> IMP SET --> ST SET --> COD IMP --> ST UI --> ST UI --> OTP UI --> COD ST --> CR OTP --> CR SET --> UT ST --> UT UI --> UT ``` | 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 | | `codes/` | Bit-matrix and bar-pattern types, Reed–Solomon over GF(2^m) and GF(929), one encoder per symbology, validators, layout and renderer | | `storage/` | Vault (records and index), record codec, key manager (device key, locked master key, PIN wrapping), session, lockout, migrations, quotas, pending queue | | `import/` | `otpauth://` parser, streaming JSON tokenizer, Aegis reader (database versions 1 to 3), import service (duplicates, partial imports, multi-part buffer, report) | | `settings/` | Reads settings into typed forms; processes the inbox, manage, import and options forms; writes status fields and the catalog | | `ui/` | Views and input delegates; layouts for round, rectangular and semi-octagon screens | | `util/` | Clock (UTC, unsigned widening, offset, trusted-clock check), job scheduler, byte and string helpers, error types, debug-only log | Design rules: - **Thin UI.** Views draw view models. The logic lives in plain modules that tests can run without a screen. - **No network, no permissions.** Anyone can check this in `manifest.xml`. - **No watchdog trips.** Long work (key derivation, SHA-512 batches, 2D encoding, large imports) runs as resumable jobs on a timer, and the UI shows progress. - **No allocations in hot paths.** The 1 Hz countdown redraw reuses precomputed strings and geometry. Each code is computed once per period. - **Caches**: encoded barcode matrices (encrypted, in storage), drawn bitmaps per screen, and computed codes with the time they expire. - **Secrets are `ByteArray`s**, zeroed after use. The only secret-derived `String` is the short-lived code on screen. - **Debug and test code is excluded from release builds**: debug-only code (fixtures, performance overlay, logging) is annotated `(:debug)`, and tests are annotated `(:test)`. - **Code style**: heavy comments (purpose, inputs, outputs, errors, security notes), small functions, named constants, and every external input validated. ## 6. Data model ### 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 | | `symbology` | code | header | One of the 12 | | `name` | all | header, or encrypted when "Encrypt names" is on | The issuer for OTP entries, the card name for codes | | `account` | OTP | same as `name` | The account label | | `group` | all | same as `name` | Optional | | `position` | all | index | Display order | | `locked` | all | header | "Require PIN to display" | | `source` | all | header | Manual or import. Used by "Replace imported entries" | | `favorite` | all | header | Comes from Aegis. The first favourite unlocked TOTP entry can appear in the glance | | `counter` | HOTP | header, covered by the record's MAC | Changes on every "next". Not secret | | `algorithm`, `digits`, `period` | OTP | encrypted | Not secret, but kept next to the secret so tampering is detected | | `secret` | OTP | encrypted | Raw bytes, decoded from Base32 on input | | `payload` | code | encrypted | UTF-8 text | | `options` | code | encrypted | Error-correction level, human-readable text, check-digit mode | | `created` | all | header | Used in reports | ### 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 | | `x.` | Cached barcode matrix for a code entry | The same key as its entry | | `p.` | Pending locked entry, waiting for your PIN | Device key ([7.7](#77-pending-locked-entries)) | | `imp` | Partial import text, kept between syncs | Device key | Limits: at most 8 KB per value and about 100 KB in total (the stricter of Garmin's two documented figures). The entry cap, initially 150, is set after M3 measures the real record size. A full store produces a clear message, never a crash. ### 6.3 Schema versions and migration - `m.schema` holds the data version. Each record also carries its own format version. - At start-up: an older schema runs migrations step by step (v1 to v2 to v3...). Each migration is a small, idempotent, unit-tested function. Before overwriting anything, a record is written under a temporary key and the index is switched to it, so a crash or power loss mid-migration loses nothing. - A newer schema (after a downgrade) is never modified. The app shows "Data from a newer version — update the app". - Unreadable or damaged records show as "Damaged entry K7F2 — re-add it" instead of crashing. - Garmin doesn't document what happens to storage on app updates. The manual test checklist verifies that an update keeps your data. ## 7. Security design ### 7.1 Keys ```mermaid flowchart TD PIN["PIN typed on the watch"] --> KDF["PBKDF2-HMAC-SHA256: random salt, iterations calibrated to about 1 s"] KDF --> PK["PIN key: RAM only"] PK -- "unwraps: AES-CBC + HMAC-SHA256" --> LMK["Locked master key: 32 random bytes, stored wrapped"] DK["Device key: 32 random bytes, stored"] LMK -- "HKDF" --> LK["encryption key + MAC key"] DK -- "HKDF" --> DKK["encryption key + MAC key"] LK --> LR["Locked entries"] DKK --> UR["Unlocked entries, pending items, import buffer"] ``` ### 7.2 PIN and key derivation - The PIN is 4 to 10 digits. The app warns below 6. - `PIN key = PBKDF2-HMAC-SHA256(PIN digits as ASCII, 16-byte random salt, N iterations, 32 bytes)`. - N is calibrated on your watch when you create or change the PIN, so one derivation takes about 1 s. It runs as a resumable job and is stored next to the salt. The lower and upper bounds for N are set from the M2 measurements. - The PIN key unwraps the locked master key. A correct PIN is exactly "the wrap's MAC verifies". No PIN hash is stored anywhere. - Changing the PIN only re-wraps the master key. Entries are not re-encrypted. ### 7.3 Record encryption ```text keys: Kenc, Kmac = HKDF-SHA256(master key, info = "OTPW enc v1" / "OTPW mac v1") record: v = 1 record format version h = TLV(header fields) readable metadata (6.1) kr = 0 | 1 0 = device key, 1 = locked master key iv = 16 random bytes new on every write ct = AES-CBC(Kenc, iv, PKCS7(TLV(sensitive fields))) tag = HMAC-SHA256(Kmac, "OTPW-REC-1" || id || v || kr || h || iv || ct) ``` - **Encrypt-then-MAC.** The tag is checked in constant time *before* anything is decrypted, so tampering anywhere (header, IV, ciphertext) is detected. - **TLV** is a small deterministic byte encoding, so the MAC input is canonical. - **AES-256** if every target passes the M2 tests with it, otherwise AES-128. The PIN, not the cipher, is the weakest link either way. - **The PIN wrap** uses the same scheme, with its own keys derived from the PIN key and the label "OTPW-WRAP-1". The salt and iteration count are included in the MAC. - Only standard constructions are used: PBKDF2, HKDF, AES-CBC, HMAC, encrypt-then-MAC. Each is tested against official vectors (section 17.2). ### 7.4 What stays readable - **The device key.** It must be on the watch so unlocked entries open without a PIN. Unlocked entries are therefore only obscured against someone reading raw storage. Garmin says app storage isn't accessible to users, and that Store-signed apps get an encrypted object store. The design doesn't rely on either. - **Record headers**: ID, kind, type or symbology, locked flag, HOTP counter, position, creation date, and names unless "Encrypt names" is on. This lets the list draw without decrypting anything. - **"Encrypt names"** (off by default) moves name, account and group into the ciphertext. Unlocked entries' names are then decrypted at start-up with the device key (the cost is measured), and locked entries show as "Locked entry K7F2" until you unlock. ### 7.5 Session and auto-lock - **Unlocking**: PIN, then PIN key, then the master key is unwrapped and held in RAM. - **Locking happens** when you leave the app (`onStop`), after the inactivity timeout, or through "Lock now" in the menu. The timeout setting offers Immediately, 15 s, 30 s, 1 min (default), 2 min and 5 min. "Immediately" locks as soon as you leave a locked entry, which gives you the "PIN every time" behaviour whenever you want it. - **On lock**: the master key and derived keys are zeroed and dropped, cached codes and bitmaps of locked entries are discarded, and any open locked entry closes back to the list. ### 7.6 Wrong PINs - The failure count and the next allowed attempt time are written to storage *before* each check (`Storage.setValue` writes to disk immediately) and reset on success. Restarting the app or the watch doesn't help an attacker. - Four free attempts. From the 5th failure, delays of 30 s, 1 min, 2 min, 4 min and so on, capped at 1 hour. - Delays are timed with the watch's trusted clock (`Time.CURRENT_TIME_RTC`, synced from GPS; the user can't change it). If that clock isn't synced, the delay counts app uptime instead and starts again in full after a restart: never shorter, sometimes longer. - The optional "Erase locked entries after 10 wrong PINs" setting is off by default. It deletes the wrapped master key, all locked entries and all pending items. - Forgotten PIN: "Reset PIN" erases the locked entries (they can't be recovered, by design) and sets a new PIN. ### 7.7 Pending locked entries A locked entry can arrive from settings or an import while the watch is locked, or before any PIN exists. It can't be encrypted with the master key yet. It's stored under the device key as **pending**, which is no worse than the plain text that just came through settings, and hidden from the list. On the next open, the app asks for the PIN (or to create one), re-encrypts the entry under the master key, and zeroes and deletes the pending copy. ### 7.8 Memory hygiene - Keys and decrypted secrets are `ByteArray`s, overwritten with zeros right after use. An unlocked session holds only the master key. - To show a code, the app decrypts the secret, computes the code and zeroes the secret. Only the short code string is kept, together with the time it expires. - **Limit**: Monkey C gives no guarantee that the VM didn't copy data (for example inside native crypto objects), and the system doesn't clear freed memory. This is best effort, and documented as such. - **No secrets in logs.** Release builds contain no logging. Debug logging goes through one function that refuses byte arrays and secret fields, and a lint rule enforces it. ### 7.9 Integrity of changes coming from the phone Anyone who controls your phone or Garmin account can change the settings. So: - Actions on locked entries (edit, replace secret, unlock, delete) wait in a queue until you confirm them with the PIN on the watch. - Deleting or editing *unlocked* entries applies directly, and the watch shows what changed. - New entries always apply, and they're visible in the list. ### 7.10 Start-up self-test On the first start after an install or update, and then once a day, the app runs known-answer tests: SHA-1, SHA-256 and SHA-512, HMAC, AES, and PBKDF2 with a low iteration count. If one fails, the app shows "Crypto self-test failed on this watch" and refuses to show codes rather than show wrong ones. ## 8. Threat model **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 | | Lost or stolen watch, attacker extracts the storage | Unlocked entries: no. Locked entries: only as strong as the PIN | 6 digits means 1,000,000 guesses, each costing one ~1 s PBKDF2 run on the watch but far less on a GPU: minutes to hours. Longer PINs help. Garmin's object-store encryption for Store apps may stop this; it's undocumented, so it isn't relied on | | Garmin's servers and the Garmin Connect app | No, for anything typed or pasted into settings | A consequence of D2 and D3: secrets travel in the clear. The watch clears the fields after reading them, but Garmin may keep copies | | Malware on your phone (clipboard, accessibility services) | No | It can read what you paste. Clear the clipboard and delete the Aegis export after importing | | Someone controlling your phone or Garmin account changes settings | Partly | They can add, or delete unlocked, entries. Changes to locked entries need your PIN on the watch ([7.9](#79-integrity-of-changes-coming-from-the-phone)) | | Other Connect IQ apps on the watch | Yes | Garmin sandboxes each app's storage | | Network attackers | Yes | No network permission | | Shoulder surfing | Partly | A code is visible by nature. PIN digits show as dots | | Changing the watch clock to harvest future codes | Partly | Locked entries still need an unlocked session. The app warns when the clock disagrees with the trusted clock | | Compromised build tools | Partly | SDK from Garmin; versions pinned; images pinned by digest; Python packages pinned with hashes | | Out of scope | | Compromised watch firmware, RAM forensics on a running watch, hardware attacks, Garmin itself | ## 9. Time handling - **Codes use UTC.** `Time.now()` gives UTC seconds, plus your offset setting (−120 to +120 s, default 0) for skew. - **2038**: Garmin stores time as a 32-bit signed integer. The app reinterprets negative values as unsigned 64-bit, which is correct until 2106. The OTP maths uses 64-bit time throughout, so the RFC 6238 test vector for the year 2603 passes. - **Trusted clock**: `Time.getCurrentTime({:currentTimeType => Time.CURRENT_TIME_RTC})` is synced from GPS and can't be changed by the user. If it isn't synced, a small clock icon warns that codes may be off. If it differs from the displayed time by more than 30 s, the app warns "Watch time differs from GPS time". - **If the time is wrong, codes are wrong.** The app can't fix that, but the warnings and the offset setting make it visible and correctable. - **HOTP**: the stored counter is the one whose code is on screen. "Next" saves counter + 1 first, then shows the new code, so a crash never reuses a code. If you also use the same HOTP account elsewhere, the counters drift apart. - **Refresh**: on the detail screen, a 1-second timer moves the countdown ring. The code is recomputed only at the period boundary, and in the last 5 seconds the next code is shown as a preview. ## 10. Settings design ### 10.1 Why inbox and manage forms Garmin's "array settings" (growable lists) are broken today. A failed save resets all of the app's settings (CIQQA-4751). Instead, there is one **Add OTP** form and one **Add code** form that the watch reads and then clears, plus one **Manage entry** form that acts on an entry by its ID. The watch is the database. The forms are how you send it requests. ### 10.2 Groups and fields #### Add OTP | Property | Type | Field | Notes | | --- | --- | --- | --- | | `otpType` | number | list | TOTP, HOTP, Steam | | `otpIssuer` | string | alphaNumeric (max 40) | | | `otpAccount` | string | alphaNumeric (max 64) | | | `otpSecret` | string | password | Base32. Masked on screen only | | `otpAlgorithm` | number | list | SHA1, SHA256, SHA512 (ignored for Steam) | | `otpDigits` | number | list | 6, 7, 8 (Steam always shows 5 characters) | | `otpPeriod` | number | numeric (1 to 300) | TOTP. Steam is always 30 | | `otpCounter` | long | numeric (≥ 0) | HOTP starting counter | | `otpGroup` | string | alphaNumeric (max 32) | Optional | | `otpLocked` | boolean | switch | Require PIN to display | | `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 | Property | Type | Field | Notes | | --- | --- | --- | --- | | `codeName` | string | alphaNumeric (max 40) | | | `codeSymbology` | number | list | The 12 symbologies | | `codePayload` | string | alphaNumeric | The code's text or digits | | `codeEcLevel` | number | list | Low, Medium (default), Quartile, High. Mapped per symbology (QR L/M/Q/H; Aztec 10/23/36/50 %; PDF417 auto or levels 2/4/5/6) | | `codeCheckDigit` | number | list | Add or verify (default), Fix if wrong, Off | | `codeShowText` | boolean | switch | Human-readable text under linear codes | | `codeGroup` | string | alphaNumeric (max 32) | | | `codeLocked` | boolean | switch | Require PIN to display | | `codeAdd` | boolean | switch | **Add this entry** | | `codeStatus` | string | read-only | | #### 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 | | `mgPosition` | number | numeric (≥ 1) | | | `mgSecret` | string | password | New secret (Base32) or new code contents | | `mgCounter` | long | numeric | | | `mgApply` | boolean | switch | **Apply** | | `mgStatus` | string | read-only | | #### 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 | 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 | | `optCodeSize` | list: Normal, Large, Huge (code font) | Large | | `optTheme` | list: Dark, Light (barcodes are always black on white) | Dark | | `optGroups` | switch: Show groups | on | | `optEncryptNames` | switch | off | | `optGlanceCode` | switch: Show the favourite code in the glance | off | | `optBrightness` | switch: Full brightness while showing a barcode | on | **About** (all read-only, written by the watch): `catalog` (entry IDs and names, shortened to fit), `version`, `storageUse`. ### 10.3 Processing rules 1. **When**: from `onSettingsChanged` while the app runs, and at every app start. Processing runs *after* the first screen is drawn, so it never slows start-up. 2. **Switches**: a form is processed only when its switch is on. Afterwards the watch turns the switch off and clears the form, secret fields first. 3. **Idempotency**: Garmin doesn't document whether the phone shows those cleared values ([FEASIBILITY 3e](docs/FEASIBILITY.md#3e-writing-values-back-unknown)). So the watch also remembers a hash of each of the last 32 applied forms and ignores a form it has already applied. Re-sending the same form does nothing; changing anything makes it a new request. Manage actions use stable IDs and absolute positions, so applying one twice can't hit a different entry. 4. **Results** go to the form's status field and to a "Settings results" screen on the watch. That screen opens by itself after processing if the app is open, and is always available from the menu. 5. **Status texts are short and specific**, for example: - "Added K7F2 GitHub (alice)" - "Secret: '1' is not a Base32 character (position 5)" - "EAN-13: check digit should be 7, got 3. Choose 'Fix if wrong' to correct it" 6. **Order**: Import, then Add OTP, then Add code, then Manage, then Options. ### 10.4 Validation (OTP form) - **Secret**: Base32 (`A`–`Z`, `2`–`7`, any case). Spaces and hyphens are ignored, and `=` padding is optional. It must decode to 1 to 128 bytes, with a warning below 10 bytes (80 bits). Errors name the character and its position. - **Digits**: 6 to 8. Steam always uses 5 characters. - **Period**: 1 to 300 s. Steam always uses 30. - **Counter**: 0 to 2^63−1. - **Algorithm**: SHA1, SHA256 or SHA512. Steam always uses SHA1. - **Issuer and account**: at least one must be filled; no control characters. Code validation, per symbology, is in [section 12.1](#121-symbologies). ### 10.5 XML excerpt The full files are written in M7. ```xml 0 6 30 0 false false ``` ```xml @Strings.TypeTotp @Strings.TypeHotp @Strings.TypeSteam @Strings.Digits6 @Strings.Digits7 @Strings.Digits8 ``` ## 11. Import design ### 11.1 Accepted formats - **`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. ### 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. - **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. ### 11.3 Parsing rules #### `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. - `secret` is required and follows the Base32 rules in 10.4. - Defaults: `algorithm` SHA1, `digits` 6, `period` 30. `counter` is required for `hotp`. - A `steam` type, or the `encoder=steam` parameter, means Steam. - 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 - **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. - **Fields used**: `name` (the account), `issuer`, `info.secret`, `info.algo`, `info.digits`, `info.period`, `info.counter`, `favorite` and groups (UUIDs resolved through the top-level `groups` list in database version 3, or the older `group` string). If an entry has several groups, the first is used. - **Ignored**: `icon`, `icon_mime` and `icon_hash` (skipped without loading them into memory) and `note` (may hold private text). - **Robust parsing**: the JSON tokenizer streams over the UTF-8 bytes, handles escapes and `\u` sequences, and enforces depth and length limits. It never builds large intermediate objects, and runs as a resumable job. ### 11.4 Duplicates, partial imports and the report - **Duplicate**: the same type and the same secret as an existing entry. It's skipped and reported ("duplicate of K7F2"). - **Same issuer and account, different secret**: imported as a new entry and flagged ("possible replacement for K7F2 — delete the old one with Manage entry"). - **Partial imports**: every valid entry is stored, and invalid ones are listed with reasons. Entries are stored one at a time, so a crash or power loss leaves only whole entries. - **Modes**: Append, or Replace imported entries (deletes entries whose source is "import", then imports). Manual entries are never touched by an import. - **Locking**: "Require PIN for imported entries" locks every entry in that import. They wait as pending items until you enter your PIN ([7.7](#77-pending-locked-entries)). - **Report**, shown on the watch and in the Import status field, for example: "Imported 12 · 2 duplicates skipped · 1 error (entry 7 'GitHub': 9 digits not supported) · 1 skipped (mOTP)". - **Clean-up**: after a successful import, the watch clears the import fields and deletes the buffer. ### 11.5 After an import Imported entries behave exactly like manual ones. You rename, regroup, move, lock, unlock, replace the secret of, or delete them with the Manage entry form, using the ID shown on the watch. The user guide also tells you to delete the exported JSON file from your phone and to keep it out of cloud-synced folders. ## 12. Barcodes and QR codes ### 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 | | EAN-13 | 12 or 13 digits | Check digit: add or verify, fix, off; text | Digits only; check digit | EAN-2/EAN-5 add-ons deferred | | EAN-8 | 7 or 8 digits | As EAN-13 | As EAN-13 | | | UPC-A | 11 or 12 digits | As EAN-13 | As EAN-13 | | | UPC-E | 6, 7 or 8 digits (number system 0 or 1) | As EAN-13 | Must convert to UPC-A; check digit from the expanded form | | | ITF | An even number of digits | Mod-10 check digit on or off | Digits; an odd length needs a check digit or a leading 0 | Bearer bars (ITF-14 frames) deferred | | Codabar | `0–9 - $ : / . +` between start/stop characters `A–D` | | Characters. Missing start/stop characters are added as `A…A`, and the status says so | | | Data Matrix | Any text | | Capacity; fits | ECC 200, square sizes 10×10 to 144×144, ASCII (with digit pairs) and Base256 encoding. Rectangular sizes and C40/Text/X12/EDIFACT deferred | | PDF417 | Any text | Error correction (automatic by default) | Capacity; fits | Text, byte and numeric compaction; columns chosen to fit the screen. Compact, Micro and Macro PDF417 deferred | | Aztec | Any text | Error correction % (default 23 %) | Capacity; fits | Compact (1 to 4 layers) and full range (up to 32 layers). Structured append and runes deferred | ### 12.2 Rendering - **Area**: 2D codes use the largest centred square (round screens) or rectangle. Linear codes and PDF417 use the widest horizontal band. - **Module size**: the largest whole number of pixels per module that fits, including the quiet zone (QR 4 modules, Data Matrix 1, PDF417 2, linear codes 10). The minimum is 2 px to start with. Scan tests in M6 may raise it, or allow 1 px for linear codes on 218 px screens. - **Colours**: always black modules on a white area that includes the quiet zone, whatever the theme. - **Human-readable text**: digits under linear codes when enabled and there's room. - **Brightness**: on AMOLED watches the app asks for full brightness while a code is shown, for up to about a minute. That's the burn-in protection limit; a tap boosts it again. On MIP watches the backlight is turned on. - **Caching**: the encoded matrix is stored encrypted (`x.`) when the entry is saved, so opening it is instant. The drawn bitmap is cached for the session. - **Too big**: the watch says, for example, "Too large for this screen: needs 2 px per module, a 300 px square". The same warning goes to the status field when the entry is added. ### 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 | | 454 px round (Forerunner 965) | 321 px | version 20, 666 bytes | version 33 | Linear: EAN-13 needs 113 modules, which means 1 px per module on a 218 px screen, 2 px on 260 px and 3 px on 390 px and larger. A 20-character Code 128 needs about 275 modules and doesn't fit 218 to 260 px round screens at all. ### 12.4 How each symbology is proven scannable 1. **Reference comparison**: Python reference encoders in Docker (zxing-cpp's writer, segno for QR with forced version and mask, and others) generate expected bar patterns and matrices for fixed parameters. The watch encoder must match them exactly wherever the output is deterministic: all linear codes, QR with forced version and mask, and Data Matrix in ASCII mode. 2. **Decoding**: the simulator tests dump every encoder output. It's rendered at each device's module size and decoded with zxing-cpp, which reads all 12 symbologies. The decoded text must equal the input, for hundreds of seeded random payloads per symbology. 3. **Screenshots**: simulator screenshots of the code screen on Forerunner 255S, Venu Sq 2 and Forerunner 965 must decode with zxing-cpp. 4. **Real world** (manual checklist): your phone's camera, plus any real shop scanner you can try, indoors and outdoors, on MIP and AMOLED. A symbology counts as implemented only when checks 1 to 3 pass. Otherwise it's listed as deferred, with the reason, in this README and the changelog. ## 13. UX design ### 13.1 Screens - **List (home)**: entries in your order, with group headers if enabled. - Unlocked TOTP and HOTP entries show their current code and a small countdown. - Locked entries show a lock icon and their name (unless names are encrypted), and no code. - Codes show a barcode or QR icon and their name. - A banner appears when there are new settings results. - **OTP detail**: issuer and account, the code in large digits (grouped 3+3, 3+4 or 4+4), and a countdown ring along the bezel (a bar on rectangular screens) with the seconds left. In the last 5 seconds, the next code is shown. HOTP shows "Next" and the counter. Steam shows 5 characters. The entry ID is in small print at the bottom. - **Code detail**: the code centred on white, the name in small print if there's room, or a "Too large" message. - **PIN**: a keypad on touch screens; a digit selector on button-only watches. Digits show as dots. A wrong PIN vibrates (where available) and shows any waiting time. - **Create or change PIN**: enter it twice. Warns below 6 digits. - **Menu**: Lock now, Change PIN, Reset PIN (erases locked entries), Settings results, Entry IDs, Clock status, About (version, storage use, self-test result). - **Glance**: "OTP Wallet · 12 entries". If you turn it on, it shows the favourite unlocked TOTP code with a progress bar instead. ### 13.2 Flow ```mermaid stateDiagram-v2 [*] --> List List --> OtpDetail: select unlocked OTP List --> CodeDetail: select unlocked code List --> Pin: select locked entry, or pending items exist Pin --> OtpDetail: correct PIN, OTP entry Pin --> CodeDetail: correct PIN, code entry Pin --> List: back, or waiting after wrong PINs OtpDetail --> List: back, or auto-lock CodeDetail --> List: back, or auto-lock List --> Menu: menu Menu --> Pin: change or reset PIN Menu --> Results: settings results Results --> List: back Menu --> List: back List --> [*]: back, which locks everything ``` ### 13.3 Wireframes (round screen) ```text List OTP detail Code detail .-----------------. .-----------------. .-----------------. / OTP Wallet \ / GitHub \ / +-------------+ \ | GitHub | | alice | | | ## # ### # # | | | alice 482 913 o | | | | | # ## # ### # | | | Google | | 482 913 | | | ### # # ## # | | | bob 705 228 o | | | | +-------------+ | | [lock] Bank | | next 119 004 | | Gym card | \ [bar] Gym card / \ 18 s / \ / '-----------------' '-----------------' '-----------------' (ring along the bezel = time left) PIN, touch PIN, buttons only Glance .-----------------. .-----------------. +----------------------+ / Enter PIN \ / Enter PIN \ | OTP Wallet | | * * * o o o | | * * [5] o o o | | 12 entries | | 1 2 3 | | ^ | +----------------------+ | 4 5 6 | | UP/DOWN digit | (opt-in: favourite code | 7 8 9 | | START next | and a progress bar) \ < 0 OK / \ BACK delete / '-----------------' '-----------------' ``` ### 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 | | Next HOTP code | Tap the code | START | | Menu | Long-press the screen, or MENU | MENU (hold UP on most watches) | | Enter a PIN digit | Tap the keypad | UP/DOWN change the digit, START confirms it, BACK deletes | | Confirm the PIN | OK | Hold START, or START after the last digit | ## 14. Target devices and API levels - **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** (`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). ## 15. Docker and sandbox ### 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 | The Garmin SDK and device files are never baked into an image (Garmin's licence). They live in `.cache/`. ### 15.2 How containers run - **Non-root**: as your user and group ID, with `--cap-drop ALL` and `--security-opt no-new-privileges`. No privileged mode, no host networking, and a read-only root filesystem with a tmpfs `/tmp` wherever the tools allow it. - **One mount**: only the project folder, at `/work`. `HOME` inside the container is `/work/.cache/home`, so the SDK, device files and login token stay inside the project. - **Network**: `--network none` for build, test, lint, package and docs runs. The network is used only to build images and in `make sdk` (Garmin downloads). `make sim` publishes noVNC on 127.0.0.1 only. That needs a bridge network for the port, but the app itself has no network permission. - **Removable build cache**: a dedicated buildx builder named `garmin-otp`, so its cache can be removed without touching other projects. - **Labels**: every container, image and the builder carry `project=garmin-otp`. ### 15.3 First-time setup (your steps) 1. **Docker access** (D5). Either: - `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`** (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 | | `make build` | Debug build for `DEVICE` (default `fr255s`) | no | | `make test` | Unit tests in the headless simulator on the 3 test devices | no | | `make sim` | Run the app in the simulator; view it at http://127.0.0.1:6080/vnc.html | localhost only | | `make screenshots` | UI screenshot tests and barcode decoding from screenshots | no | | `make screenshots-approve` | Save the current screens as the approved images, after you've reviewed them | no | | `make icons` | Regenerate the per-size launcher icons after the product list changes | no | | `make perf` | Performance measurements against the budgets | no | | `make memtest` | Peak memory on Forerunner 255S, app and glance | no | | `make lint` | Strict type check (`-l 3`) with warnings as errors, project lint rules, settings XML schema check, Python lint for the tools | no | | `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 vectors` | Regenerate the cross-checked crypto and OTP test vectors | no | | `make spike`, `make spike-sim` | Build the M1 settings spike; run it headless and screenshot it | no | | `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 | ### 15.5 Watching the simulator Run `make sim DEVICE=fr965`, then open http://127.0.0.1:6080/vnc.html in your browser. There's nothing to install. Forwarding your own X display into the container would also work, but it gives the container access to your desktop session, so it isn't the default. ### 15.6 Developer key - **What**: an RSA-4096 key in PKCS#8 DER format, generated with openssl inside the container into `.cache/keys/developer_key.der` (plus `.pem`), mode 600. It's ignored by git and never baked into an image. - **Back it up**: copy `.cache/keys/` somewhere encrypted, such as a password-manager attachment or an encrypted USB drive. - **If you lose it**: Garmin rejects updates signed with a different key. You would have to create a new beta app (a new app ID), install it, and add every entry again. The watch data belongs to the old app ID and can't be exported. - `make nuke` deletes it, and asks you to confirm you have a backup first. ### 15.7 Uninstall and cleanup 1. **`make nuke`** asks for confirmation, then: - Stops and removes containers labelled `project=garmin-otp`. - Removes the images, and the buildx builder with its cache. - Deletes `.cache/` (SDK, device files, login token, developer key) and `build/`. - Removes the Ubuntu and Node base images too, unless another image on your machine still uses them. 2. **Delete the project folder.** 3. **If you joined the `docker` group** for this project: `sudo gpasswd -d cirakg docker`. 4. **On the watch**: uninstall the app in the Connect IQ Store app, which deletes its data. Optionally delete the beta from your developer account. Nothing else is installed on your host: no packages, no `~/.Garmin`, no shell profile changes. ## 16. Build plan The order follows your brief: crypto and OTP core, storage, UI, import, barcodes, settings, then polish. M1 (the settings spike) runs in parallel because it needs your phone and watch. **After every milestone**: `make ci` runs green in Docker, the docs are updated, `CHANGELOG.md` gets an entry, and you get a short status report with the test output. ### M0: Scaffolding (Phase 2) - **Deliverables**: - `docker/Dockerfile`, `Makefile` and the container scripts (SDK setup, headless simulator, test runner that parses the `monkeydo` output). - `.gitignore`, `CHANGELOG.md`. - `manifest.xml` with the generated product list, and `monkey.jungle` with debug, release and test configurations. - A minimal app that shows "OTP Wallet", plus one unit test. - **Tests**: - `make test` passes headless on the 3 test devices. - `make lint` passes with strict type checking. - `make package` produces an `.iq`. - `make nuke` leaves no project containers, images, builder or files behind; checked by listing before and after. - **Done when**: everything works from a clean checkout plus the first-time setup. The host has nothing besides Docker, and the network was used only for images and SDK downloads. - **Your part**: Docker access (D5); accepting the SDK licence and logging in (D6). ### M1: Settings spike (needs you; runs alongside M2) - **Deliverables**: - A tiny spike app with its own beta app ID and a few test fields (long text, password, read-only status, switch, list, numeric). It shows on the watch what it received. - Test strings of increasing length, with markers. - `docs/SETTINGS_SPIKE.md`, recording the results. - **What we measure**: - Maximum length per field and in total, on your phone. - Which characters are accepted (`& < > \ " { } %`, non-ASCII). - Whether values the watch writes back (status, cleared fields) show up on the phone. - When `onSettingsChanged` fires. - How password fields behave. - **Done when**: the limits are recorded, and the number of import fields, the buffer size and the write-back assumptions in sections 10 and 11 are updated. - **Your part**: create a developer account, upload the spike as a beta, run the phone tests (about 30 minutes). ### M2: Crypto and OTP core - **Deliverables**: - `crypto/`: hash wrappers, HMAC, SHA-512, PBKDF2, HKDF, AES-CBC with PKCS#7, encrypt-then-MAC records, constant-time comparison, zeroing, random, self-test. - `otp/`: Base32, HOTP, TOTP, Steam. - `util/clock` and `util/jobs`. - **Tests**: every vector listed in 17.2; the 2038 widening; worst-case inputs with no watchdog trip; speed of each primitive on the 3 devices; PBKDF2 calibration. - **Done when**: all vectors pass on the 3 devices, the numbers are in `docs/PERFORMANCE.md`, and AES-256 vs AES-128 is decided. ### M3: Storage, keys and PIN logic - **Deliverables**: `storage/` (vault, TLV codec, key manager, session, lockout, migrations, quotas, pending queue). - **Tests**: - Round trips for every entry type. - Tamper detection: every byte of a record flipped in turn. - Wrong PIN; the lockout schedule with an injected clock, including after a restart; the erase option. - PIN change and reset. - Migration from a v0 test fixture to v1, and refusal of a newer schema. - A full store; zeroed buffers after use. - **Done when**: everything passes, the record size is measured, the entry cap is set, and the record format is in `docs/SECURITY.md`. ### M4: UI core - **Deliverables**: - Views: list, OTP detail (ring, HOTP next, next-code preview), PIN entry (touch and buttons), menu, results screen, basic glance. - Layouts for round, rectangular and semi-octagon screens; theme and code-size options. - Debug-only fixture loader and performance overlay. - **Tests**: - Screenshot goldens of every screen on the 3 test devices, plus the spot checks. - Scripted input flows: open and close, correct PIN, wrong PIN, lockout display, auto-lock. - Start-up time and memory checkpoints. - **Done when**: you've reviewed the screenshots (attached to the status report), and start-up and memory are within budget in the simulator. ### M5: Import - **Deliverables**: - `import/`: `otpauth://` parser, streaming JSON tokenizer, Aegis reader, import service with duplicate detection, partial imports, multi-part buffer and report. - Google migration import if time allows. - Wiring of the Import form. - **Tests**: parser edge cases, the Aegis fixtures and the fuzz corpus (17.2, 17.3), and an end-to-end import that's then checked in screenshots. - **Done when**: everything passes, and a 100-entry fake vault imports within the time and memory budgets. ### M6: Barcodes Four parts: - **M6a**: the linear codes (EAN-13/8, UPC-A/E, Code 128, Code 39, ITF, Codabar), the renderer (fit, quiet zones, text, brightness) and validators. - **M6b**: QR. - **M6c**: Data Matrix and PDF417. - **M6d**: Aztec. Tests: the four checks in 12.4 for each symbology, plus validation tests (valid and invalid content, check digits added and fixed, capacity per error-correction level, fit warnings) and encode time and memory. Done when: each symbology is either marked implemented with every check green, or marked deferred with the reason. ### M7: Settings - **Deliverables**: - `properties.xml` and `settings.xml` with every group. - Settings reader; inbox processor (Add OTP, Add code); manage processor; pending queue for locked entries. - Options applied live; status writer, results screen and catalog. - **Tests**: - Form parsing and validation for every OTP type and every symbology, valid and invalid. - Idempotency: the same form applied twice. - Manage actions on unlocked and locked entries. - End-to-end: a manually entered entry, set through a debug fixture that takes the same path as the phone, shows the right TOTP code or a barcode that decodes correctly, on screenshots. - **Done when**: everything passes, the XML validates against the SDK schema, and you've confirmed it on your phone and watch with the beta. ### M8: Polish, performance, release - **Deliverables**: - Tuning to the budgets; the memory check on Forerunner 255S; the glance favourite code (opt-in); AMOLED brightness handling. - Final `docs/` and the manual test checklist. - `make ci` green; signed `.iq` and per-device `.prg` files; `CHANGELOG` 1.0.0; the final list of known limitations. - **Done when**: `make ci` is green from a clean checkout, you've completed the on-watch checklist, and 1.0.0 is tagged. ## 17. Test plan 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 | | Performance and memory | Debug-build instrumentation in the simulator | Dev container | | Static checks | `monkeyc -l 3`, project lint, XML schema, ruff and mypy | Dev container | | Manual | The checklist in `docs/TESTING.md` | Your watch and phone | ### 17.2 Unit test coverage - **crypto**: - FIPS 180-4 examples for SHA-1, SHA-256 and SHA-512: empty, "abc", the 448- and 896-bit messages, and one million "a" fed in chunks. - RFC 2202 (HMAC-SHA1); RFC 4231 (HMAC-SHA256 and HMAC-SHA512, all cases). - RFC 7914 §11 (PBKDF2-HMAC-SHA256); RFC 5869 (HKDF cases 1 to 3); NIST SP 800-38A (AES-128 and AES-256 CBC). - PKCS#7 edge cases; record tamper tests (every byte flipped); constant-time comparison; zeroing. - **otp**: - RFC 4226 (the 10 HOTP values) and RFC 6238 (6 times × 3 algorithms, 8 digits). - Truncation to 6, 7 and 8 digits; periods 15, 30, 60, 90 and odd values. - Rollover at each period boundary (k·p − 1, k·p, k·p + 1); the 2038 boundary (0x7FFFFFFF, and 0x80000000 read as unsigned); the year-2603 vector. - Steam, cross-checked against a Python reference. - Base32: the RFC 4648 §10 vectors, lowercase, whitespace, hyphens, padding, invalid characters, overlong input. - **storage**: the M3 list. - **import**: - `otpauth://`: percent-encoded labels, missing issuer, issuer mismatch, missing secret, invalid parameters, `hotp` without a counter, Steam variants, unknown parameters, `;` separators. - Aegis fixtures, all fake data: TOTP, HOTP, Steam, mOTP and Yandex; SHA1, SHA256 and SHA512; 6, 7 and 8 digits; periods 20, 30, 50 and 60; groups in database versions 1, 2 and 3; icons, notes and favourites; an encrypted vault; malformed and truncated (multi-part) JSON; huge strings, deep nesting, Unicode, duplicate keys. - Duplicates, partial imports, replace mode. - **settings**: every field of every form, valid and invalid, per OTP type and per symbology; idempotency; manage actions. - **codes**: section 12.4. - **util**: clock widening, offset and trusted-clock fallback; the job scheduler staying within a step budget; string helpers. ### 17.3 Fuzz and property tests - **Inputs**: Python generates seeded random and mutated inputs (URIs, JSON documents, Base32 strings, settings forms, barcode contents), which are compiled into the test build. - **Monkey C assertions**: no unhandled exception; the result is either valid or a typed error with a message; the work stays within the watchdog step budget. - **Python side**: hypothesis checks the reference implementations used as test oracles. ### 17.4 Simulator UI tests - **Setup**: a debug build with a fake fixture vault and a fixed clock, driven by scripted input through xdotool. - **Screenshots** of every screen on the 3 test devices and the spot-check devices are compared with approved goldens, with a pixel tolerance. - **Barcode screens** must decode with zxing-cpp. - **TOTP screens** are checked against the expected codes, from the debug build's view-model log and the golden image. ### 17.5 End-to-end - **Manual entry**: a debug fixture sets the properties with `Properties.setValue` and calls the settings handler, the same path the phone triggers. The entry then goes through storage and into the list and detail screenshots, which must show the expected TOTP code or a barcode that decodes correctly. - **Imports**: the same path, with the import fixtures. ### 17.6 Performance and memory See [section 18](#18-performance-budgets). `make perf` and `make memtest` fail the build when a budget is exceeded. ### 17.7 Static checks - **Compiler**: `monkeyc -l 3` (strict) for every product, and any compiler warning fails the build. - **Project lint rules**: - No logging outside `(:debug)` code. - No secret-looking literals outside the fake-fixture folder. - Every string resource is used and defined. - No `TODO` in release code. - **Schema**: the settings and manifest XML are validated against the SDK's schemas. - **Python tools**: ruff and mypy. ### 17.8 `make ci` Runs lint, test, screenshots, perf, memtest, package and docs in that order, and stops at the first failure. ### 17.9 Manual checklist on the real watch The full checklist will be in `docs/TESTING.md`. It covers what the simulator can't verify: - Installing the beta from the Store. - Adding and managing entries from your phone; the settings spike results. - Real clock and GPS sync; codes matching Aegis on your phone for the same accounts. - Crypto and start-up speed, using the performance overlay in a sideloaded debug build with fake data. - Button feel and touch targets. - Scanning with your phone and with real scanners, on MIP and AMOLED, indoors and outdoors. - Auto-lock and locking on exit; the system passcode, if your watch has one. - An app update keeps the data; uninstalling removes it; the glance. ## 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 | | Countdown redraw | No frame over 100 ms; no allocations | Allocation counter in the debug build | Timer and `System.getSystemStats()` | | PIN unlock (key derivation) | 0.7 to 1.5 s, calibrated | Calibration logic tested | Timer | | Opening a code entry (cached matrix) | ≤ 300 ms | ≤ 2× baseline | Timer | | Encoding a QR version 10 without cache | ≤ 3 s, in chunks, with a progress indicator | ≤ 2× baseline | Timer | | Peak memory, app on Forerunner 255S | ≤ 70 % of 512 KB | Hard gate | `usedMemory` checkpoints and the simulator memory view | | Peak memory, glance | ≤ 70 % of 64 KB | Hard gate | Same | | Storage | ≤ 100 KB | Hard gate | Sum of record sizes | The simulator runs on your PC's CPU, so its timings don't represent a watch. In CI they only catch regressions against a baseline. Real numbers come from the debug overlay on your watch (manual checklist) and are reported in `docs/PERFORMANCE.md` with the method used. ## 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 | | Array settings fixed later, or other settings bugs | | The inbox design doesn't depend on them | | Watchdog or slowness (SHA-512, PBKDF2, 2D encoders, big imports) | Crashes or lag | Resumable jobs; measured early (M2); caches | | Memory on 512 KB watches with every encoder loaded | Out of memory | Measured in M6 and M8; lean encoders; code size tracked per build | | The simulator's crypto differs from a real watch's (for example AES-256 support) | Wrong codes on the watch | Start-up self-test; manual device checks | | Ubuntu 22.04 support ends in April 2027; the simulator needs `libwebkit2gtk-4.0` | Image can't be rebuilt someday | Images pinned by digest; snapshot mirror; revisit when Garmin updates its requirements | | The third-party SDK-manager CLI breaks after a Garmin login change | Can't download device files | Fallback: Garmin's own SDK Manager inside the container, through noVNC | | Shop scanners can't read the watch screen (laser on AMOLED, MIP indoors) | Codes unusable at some tills | Brightness boost, quiet zones, minimum module size, manual scan tests; documented | | Large codes don't fit small screens | Some cards can't be shown | Validation and warnings at entry time | | Developer key lost | No updates possible | Backup instructions; `make nuke` confirmation | | Garmin changes the beta process | Can't install | Documented manual steps; checked in M1 | | Short PINs can be broken offline | Locked secrets exposed if storage is extracted | Documented; longer PIN option; Garmin's object-store encryption as a bonus | | The ~1 s PIN derivation feels slow | UX | Calibrated, with progress; one PIN per session (D1) | | Strict typing friction or compiler bugs | Slower development | SDK version pinned; issues documented | ## 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 | | `docs/DEVELOPER_GUIDE.md` | Repository layout, build, test and debug in Docker, adding a symbology or a device, coding conventions | | `docs/SECURITY.md` | Threat model, crypto choices and parameters, record formats, known limitations, how to report issues | | `docs/TESTING.md` | Test strategy, how to run everything, the manual checklist, results | | `docs/PERFORMANCE.md` | Budgets, methods and measured numbers | | `docs/SETTINGS_SPIKE.md` | The settings limits measured in M1 | | `CHANGELOG.md` | Every change, by version | ## 21. Repository layout ```text . ├── README.md plan and overview ├── CHANGELOG.md ├── Makefile host entry point; every target runs in Docker ├── manifest.xml ├── monkey.jungle debug, release and test configurations ├── docker/ │ ├── Dockerfile garmin-otp/dev │ ├── docs.Dockerfile garmin-otp/docs │ └── scripts/ SDK setup, headless simulator, test runner, VNC ├── icons.jungle generated: each product's launcher-icon folder ├── source/ │ ├── app/ crypto/ otp/ codes/ storage/ import/ settings/ ui/ util/ ├── test/ Monkey C (:test) modules, mirroring source/ │ ├── data/ generated vectors and fake fixtures │ └── golden/ approved simulator screenshots ├── resources/ │ ├── settings/ properties.xml, settings.xml │ ├── strings/ drawables/ fonts/ ├── resources-icons/ generated: launcher icon at each size the products need ├── tools/ Python: vector generation, reference encoders, decoding, screenshots, reports, lint ├── docs/ └── .cache/ build/ git-ignored: SDK, device files, keys, outputs ``` ## 22. Versioning Semantic versioning, with `CHANGELOG.md` in the "Keep a Changelog" style. Versions are 0.x during the milestones and 1.0.0 at M8. The app version is shown in the About screen and in the settings.