aboutsummaryrefslogtreecommitdiff

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: 1.0.0 released (2026-10-28) after two real-watch rounds on the Forerunner 70 (docs/TESTING.md). The Store listing text and images are in docs/STORE_LISTING.md. The history below is kept as written at M8.

M8 (polish, performance, release candidate) done in Docker as version 0.8.0. The plan below was approved on 2026-10-18. M0 built the Docker toolchain and pipeline, M2 the crypto and OTP core, M3 the encrypted vault (docs/SECURITY.md), M4 the screens, M5 import (otpauth:// and Aegis JSON via the phone settings), M6 all 12 barcode symbologies and M7 the phone settings forms. M8 added:

  • The favourite code in the glance (opt-in), and favourite marks in Manage entry.
  • Brightness for barcodes: full brightness on AMOLED watches, the backlight on MIP watches, for about a minute.
  • Memory and speed: a 100-entry import peaks at 45 % of the Forerunner 255S's memory instead of 56 %; QR mask scoring on packed rows; two job steps per tick on the 768 KB watches.
  • Watchdog safety: every 2D encoder now handles the largest inputs the app accepts without being stopped by the watchdog, proven by make watchdog at twice the normal work per event.
  • Docs: the user guide with the known limitations.
  • Your next steps: docs/YOUR_STEPS.md, everything left for you before 1.0.0, in order.

Waiting for you: the settings spike (make spike, docs/SETTINGS_SPIKE.md) and the real-watch checklist with the 0.8.0 beta. The Phase 0 research, with sources, is in docs/FEASIBILITY.md; the daily workflow is in docs/DEVELOPER_GUIDE.md.

"OTP Wallet" is a working title.

Contents

  1. Scope
  2. Decisions and open questions
  3. Feasibility findings
  4. How the app reaches your watch
  5. Architecture
  6. Data model
  7. Security design
  8. Threat model
  9. Time handling
  10. Settings design
  11. Import design
  12. Barcodes and QR codes
  13. UX design
  14. Target devices and API levels
  15. Docker and sandbox
  16. Build plan
  17. Test plan
  18. Performance budgets
  19. Risks and mitigations
  20. Documentation
  21. Repository layout
  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. 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)
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.

# 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

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 ByteArrays, 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), pending IDs Not secret
dk Device key, 32 random bytes Stored as is (7.4)
w KDF salt, iteration count and the wrapped locked master key The PIN key
lk Wrong-PIN count and delay Not secret
e.<id> One entry record Device key, or the locked master key for locked entries
x.<id> Cached barcode matrix for a code entry The same key as its entry
p.<id> Pending locked entry, waiting for your PIN Device key (7.7)
imp, imp.<n> Partial import text, kept between syncs, in chunks below 8 KB Device key
q Phone changes to locked entries, waiting for your PIN (7.9) Device key
qm.<n> The new secret or contents of a waiting "Replace", stored apart from q Device key
res, fh, cs, st Latest settings results; keyed hashes of applied forms; signature of the last About update; date of the last self-test Not secret

Limits: at most 8 KB per value and about 100 KB in total (the stricter of Garmin's two documented figures). M3 measured 239 bytes for a typical OTP record, so the entry cap stays at 150 (byte formats in docs/SECURITY.md). 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

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 exactly 4 digits, entered like the watch's own lock screen: the 4th digit submits it, with no OK key (changed after the real-watch test, 0.9.0). A PIN set by 0.8.0 or earlier (4 to 10 digits) still unlocks; the watch then asks for a new 4-digit PIN.
  • 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

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 ByteArrays, 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, set the counter, delete) wait in a queue until you confirm them with the PIN on the watch, even if the watch happens to be unlocked. The menu lists them under "Phone changes", where you confirm (the PIN is always asked) or reject them. "Require PIN" waits too while the watch is locked, because it needs the master key; it applies by itself at the next PIN entry, and what it did is listed under the menu's results.
  • A replacement secret or code contents is checked when it arrives: an invalid one is refused at once, and only a valid one waits.
  • 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 4 digits means 10,000 guesses. Each costs one ~1 s PBKDF2 run on the watch but microseconds on a PC, so a copied storage falls in seconds. The PIN protects against someone using the watch (delays after wrong PINs, optional erase), not against a storage copy. Garmin's object-store encryption for Store apps may stop the copy; it's undocumented, so it isn't relied on. The debug build's files were visible over USB on the Forerunner 70 (part 4 of docs/YOUR_STEPS.md)
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)
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, Mark as favourite (glance), Remove favourite (M8)
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 settings spike (M1) measured 8000 characters per field and 64,000 in total arriving complete
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. After a problem only the secret field is cleared, so the other fields can be corrected and sent again.
  3. Idempotency: Garmin doesn't document whether the phone shows those cleared values (FEASIBILITY 3e). So the watch also remembers a keyed 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.

10.5 XML excerpt

The full files are in resources/settings/ (written in M7). make lint validates them against the SDK's resources.xsd and checks that the form, the properties and the strings agree (tools/check_resources.py).

<!-- resources/settings/properties.xml (excerpt) -->
<properties>
    <property id="otpType" type="number">0</property>
    <property id="otpIssuer" type="string"></property>
    <property id="otpSecret" type="string"></property>
    <property id="otpDigits" type="number">6</property>
    <property id="otpPeriod" type="number">30</property>
    <property id="otpCounter" type="long">0</property>
    <property id="otpLocked" type="boolean">false</property>
    <property id="otpAdd" type="boolean">false</property>
    <property id="otpStatus" type="string"></property>
</properties>
<!-- resources/settings/settings.xml (excerpt) -->
<settings>
    <group id="addOtp" title="@Strings.GroupAddOtp" description="@Strings.GroupAddOtpHelp">
        <setting propertyKey="@Properties.otpType" title="@Strings.OtpType">
            <settingConfig type="list">
                <listEntry value="0">@Strings.TypeTotp</listEntry>
                <listEntry value="1">@Strings.TypeHotp</listEntry>
                <listEntry value="2">@Strings.TypeSteam</listEntry>
            </settingConfig>
        </setting>
        <setting propertyKey="@Properties.otpIssuer" title="@Strings.OtpIssuer">
            <settingConfig type="alphaNumeric" maxLength="40" />
        </setting>
        <setting propertyKey="@Properties.otpSecret" title="@Strings.OtpSecret" prompt="@Strings.OtpSecretPrompt">
            <settingConfig type="password" />
        </setting>
        <setting propertyKey="@Properties.otpDigits" title="@Strings.OtpDigits">
            <settingConfig type="list">
                <listEntry value="6">@Strings.Digits6</listEntry>
                <listEntry value="7">@Strings.Digits7</listEntry>
                <listEntry value="8">@Strings.Digits8</listEntry>
            </settingConfig>
        </setting>
        <setting propertyKey="@Properties.otpPeriod" title="@Strings.OtpPeriod">
            <settingConfig type="numeric" min="1" max="300" errorMessage="@Strings.OtpPeriodError" />
        </setting>
        <setting propertyKey="@Properties.otpLocked" title="@Strings.RequirePin">
            <settingConfig type="boolean" />
        </setting>
        <setting propertyKey="@Properties.otpAdd" title="@Strings.AddThisEntry">
            <settingConfig type="boolean" />
        </setting>
        <setting propertyKey="@Properties.otpStatus" title="@Strings.Status">
            <settingConfig type="alphaNumeric" readonly="true" />
        </setting>
    </group>
</settings>

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 {.
  • Deferred: Google Authenticator otpauth-migration://offline?data=... exports. This was the stretch goal. M5 ran out of the time set aside for it: the format is reverse-engineered (Base64 and protobuf), and its fixtures would have to be built from scratch. The importer recognises these links and explains that they aren't supported yet. Exporting from Aegis (which imports Google Authenticator) works today.

11.2 Getting the text into settings

  • Fields: the eight import fields are joined in order. If the text is longer than one field accepts, continue in the next one.
  • Several syncs: if the JSON is still incomplete after joining, the watch keeps what it got (encrypted under the device key) and reports: "Waiting for more: received 3,214 characters. Paste the next part into the import fields and turn 'Import now' on again." "Cancel pending import" discards it. The buffer is capped at 32 KB and expires after 24 hours. M5 measured an Aegis export at about 190 bytes per entry compact, and 400 to 500 pretty-printed. Exports larger than the cap need to be split into several imports.
  • Size reality: pretty-printed Aegis JSON takes about 400 to 500 characters per entry without icons. Icons add several KB each and are the main reason a paste might not fit. An otpauth:// URI takes about 80 to 200 characters.
  • Spike results (M1, Android, 2026-10-21): one field carried 8000 characters and the eight together 64,000, so the 32 KB buffer, not Garmin Connect, limits one sync. otpauth:// URIs with & save as they are. A test string mixing < > \ " { } %, accented letters and an emoji was refused by the phone. Follow-up (round 2, 2026-10-27): only the backslash \ is refused; every other character group and a complete unencrypted Aegis export without backslashes saved, and a real Aegis export was imported on the Forerunner 70. Aegis JSON can be pasted when it has no \ (Android Aegis writes / as \/ in icons): replace \/ with / or remove icons first (docs/SETTINGS_SPIKE.md).
  • iPhone: Garmin Connect on iOS reportedly rejects & (present in every URI with parameters) and \ (used in JSON escapes). The spike ran on Android only. If it's confirmed on iOS, 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).
  • 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 for 2D codes. Linear codes may use 1 px: an EAN-13 needs that on a 218 px screen, and the Forerunner 255S screenshot decodes at 1 px. Your phone and real scanners still need to confirm it (manual checklist).
  • 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.<id>) 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: the EAN/UPC family, Code 39, ITF, Codabar, QR with forced version and mask, and Data Matrix in ASCII mode. Code 128 is the exception, because it has several equally short encodings. zxing-cpp 3 writes through zint, which breaks those ties differently from the zxing rules the watch uses. For Code 128 the module count must match (it did for 5,000 random strings), and the code must decode (check 2). For QR there are two deliberate differences from segno: - Padding. segno adds a whole zero byte when the bit stream already ends on a codeword boundary (always the case in byte mode). ISO/IEC 18004 7.4.10 adds no padding there, and the watch follows the standard. The exact references therefore use payloads that segno pads correctly. - Segmentation. segno encodes one mode per symbol, while the watch segments optimally. The exact references use single-mode payloads, and mixed content is covered by decoding.

Data Matrix is compared with ppf.datamatrix in ASCII mode. ppf turns a pad codeword of exactly 254 into 0 ((R + 130) % 254), while ISO/IEC 16022 5.2.3 keeps 254, so the references avoid payloads whose padding hits that value. PDF417 is compared with pdf417gen at fixed columns and level. The watch mirrors pdf417gen's compaction rules, so its rows match exactly. Aztec is compared with aztec_code_generator for payloads of one character class (capitals, small letters or digits, with spaces). For those, the reference's optimal mode sequence and the watch's simpler encodation give the same bits, and the symbols match exactly, from compact to full range with reference grids. Mixed content is checked by decoding. 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. M6 result: all 12 are implemented, with the comparison limits described above. The real-world check is your phone and scanners (manual checklist).

13. UX design

13.1 Screens

  • List (home): entries in your order. With "Show groups" on, each row shows its group in front of the account ("Work · alice"). Separate group header lines were planned, but they need every entry's header at start-up, and M3 measured that as too slow for long lists.
  • 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 4 digits twice.
  • 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 code of the first favourite TOTP or Steam entry without the PIN, with a progress bar, instead (docs/SECURITY.md).

13.2 Flow

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)

 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        |     |      * * [5] 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         /       \  BACK delete      /
   '-----------------'         '-----------------'

13.4 Input

Action Touch Buttons
Scroll the list Swipe up and down UP and DOWN
Open an entry Tap it (START opens the focused row on watches that also have buttons) START
Back Swipe right, or BACK BACK
Next HOTP code Tap the code START
Menu Long-press the list, or MENU MENU (hold UP on most watches)
Enter a PIN digit Tap the keypad; the delete key deletes UP/DOWN choose a digit, START adds it, BACK deletes
Confirm the PIN Automatic at the 4th digit Automatic at the 4th 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: by default every test target runs on the Forerunner 70 only (the owner's watch), to keep runs and screenshot reviews short. make <target> ALL=1 uses the full set below (TEST_DEVICES, SPOT_DEVICES and WATCHDOG_DEVICES in the Makefile), for example before a release:
  • 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.

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: - Add your account to the docker group using your operating system's user management tools, then log out and back in. Membership in that group is equivalent to root on the 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 fr70) 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 Scripted UI flow with a screenshot of every screen on TEST_DEVICES and SPOT_DEVICES (barcode decoding from M6) 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 Memory after the first frame and in the glance, on TEST_DEVICES no
make watchdog The largest code of every 2D symbology encoded, packed and drawn, and the worst PIN-unlock and phone-change work, at twice the steps per event, on WATCHDOG_DEVICES (Forerunner 70; with ALL=1 also the 255S); fails on a watchdog trip or if the last code doesn't decode 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, watchdog, 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) 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: remove your account from the group using your operating system's user management tools.
  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.
  • Status (2026-10-20): everything passes in Docker and the XML validates; the phone and watch check with the beta is yours (docs/TESTING.md).

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.
  • Status (2026-10-21): done in Docker as 0.8.0, the release candidate: make ci is green, also from a clean checkout; the signed .iq and the release .prg files are built by make package; the known limitations are in docs/USER_GUIDE.md. Your on-watch checklist (docs/TESTING.md) came before the 1.0.0 tag. Status (2026-10-28): 1.0.0 tagged after the second real-watch round (0.9.0 on the Forerunner 70) and the settings spike follow-up.

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: the UI-test build (uitest.jungle, source/util/TestMode.mc) starts with fake entries, the PIN 1234 and a frozen clock. tools/ui_flow.py drives it through xdotool: it clicks the buttons from the device files, and taps and swipes on touch screens. It waits for each screen's DIAG shown line instead of sleeping.
  • Screenshots of every screen on the 4 test devices and the 3 spot-check devices are compared with approved goldens in test/golden/<device>/. At most 0.01 % of pixels may differ.
  • 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. make perf and make memtest fail the build when a budget is exceeded. make watchdog (M8) runs the largest input of every 2D encoder with twice the normal steps per event, so each step is proven to use at most half the watchdog budget, and draws the largest codes each screen fits. Since 0.9.0's review it also runs the work after a PIN (150 pending entries, 16 waiting "Require PIN" changes), reading the change queue and a 4 KB code replacement.

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, watchdog, 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 in the user guide's known limitations; the PIN is fixed at 4 digits by the owner's choice (easier entry on the watch); 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 Public-facing project overview and setup
docs/PROJECT_PLAN.md Original design plan and project history
docs/FEASIBILITY.md Phase 0 findings with sources
docs/YOUR_STEPS.md Your steps before 1.0.0: the settings spike, the on-watch tests and the results to send back
docs/STORE_LISTING.md, docs/store/ Connect IQ Store listing: title, description, What's New, and the hero, cover and screen images (python3 tools/store_assets.py)
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

.
├── README.md                  public overview and setup
├── docs/PROJECT_PLAN.md       original plan and design history
├── CHANGELOG.md
├── Makefile                   host entry point; every target runs in Docker
├── manifest.xml
├── monkey.jungle              debug, release and test configurations (+ test, uitest, stress .jungle)
├── 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.