1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
|
<!--
Author: cirakg & claude ai
Summary: Project overview, design and build plan for the OTP Wallet Connect IQ app.
-->
# 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: M6a (linear barcodes) done; M6b-M6d (QR, Data Matrix, PDF417, Aztec) next; M1 (settings spike) ready for your phone test.** The plan below was approved on 2026-10-18. M0 built the Docker toolchain and pipeline, M2 the crypto and OTP core, M3 the encrypted vault ([docs/SECURITY.md](docs/SECURITY.md)), M4 the screens and M5 import (`otpauth://` and Aegis JSON via the phone settings). M6a added the code screen and the EAN-13, EAN-8, UPC-A, UPC-E, Code 128, Code 39, ITF and Codabar encoders. They're checked against zxing-cpp reference rows and decoded at 1 to 3 px per module, and a screenshot of the code screen decodes on seven simulated watches. The settings spike (`make spike`, [docs/SETTINGS_SPIKE.md](docs/SETTINGS_SPIKE.md)) waits for your test. The Phase 0 research, with sources, is in [docs/FEASIBILITY.md](docs/FEASIBILITY.md); the daily workflow is in [docs/DEVELOPER_GUIDE.md](docs/DEVELOPER_GUIDE.md).
"OTP Wallet" is a working title.
## 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), pending IDs, hashes of applied forms (M7) | Not secret |
| `dk` | Device key, 32 random bytes | Stored as is ([7.4](#74-what-stays-readable)) |
| `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](#77-pending-locked-entries)) |
| `imp`, `imp.<n>` | Partial import text, kept between syncs, in chunks below 8 KB | Device key |
| `res`, `fh`, `st` | Latest settings results; hashes of applied forms; date of the last self-test | Not secret |
Limits: at most 8 KB per value and about 100 KB in total (the stricter of Garmin's two documented figures). M3 measured 239 bytes for a typical OTP record, so the entry cap stays at 150 (byte formats in [docs/SECURITY.md](docs/SECURITY.md)). A full store produces a clear message, never a crash.
### 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
<!-- 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>
```
```xml
<!-- 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.
- **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 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).
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 "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 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 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 | OK on the keypad, or START | UP/DOWN to "OK", then START |
## 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` | 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` | 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**: the UI-test build (`uitest.jungle`, `source/util/TestMode.mc`) starts with fake entries, the PIN 123456 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](#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.
|