Security and storage format
This document records how the watch stores your data, byte for byte. The design rationale is in PROJECT_PLAN section 7 and the threat model in section 8. The code is in source/storage/ and source/crypto/. The storage and crypto behavior is tested in test/storage/ and test/crypto/. For private vulnerability reports, see the root security policy.
Storage keys
Everything lives in the app's private Application.Storage:
| Key | Type | Content | Written by |
|---|---|---|---|
m |
dictionary | schema (data version, now 1), ids (entry IDs in display order), pending (IDs of pending locked entries) |
Vault, Migration |
dk |
32 bytes | Device key, random, created on first use | KeyManager |
w |
bytes | PIN wrap of the locked master key (format below); absent until a PIN is set | KeyManager |
lk |
dictionary | Wrong-PIN state: f failures, d delay in seconds, t trusted time of the last attempt; absent after a success |
Lockout |
e.<id> |
bytes | One entry record (format below) | Vault |
p.<id> |
bytes | Pending locked entry: a record sealed with the device key | Vault |
x.<id> |
bytes | Encoded 2D barcode: a SealedBox (aad OTPW-X-1 + ID) with the entry's own key (device key, or the master key for locked entries) of the packed matrix, format 2: 2, width and height (2 bytes each), quiet zone, row height, then the modules 8 per byte. Format 1 (0.6 and 0.7, one byte of width) is no longer read: such a cache is re-encoded once. Deleted when the entry is updated, removed or erased |
Vault, Codes.Packed |
g |
bytes | The glance code (below): a SealedBox (device key, aad OTPW-GL-1) of one favourite entry's label, type, algorithm, digits, period and secret. Only while "Show the favourite code in the glance" is on |
GlanceStore |
imp, imp.<n> |
dictionary, bytes | Waiting multi-part import: imp = {"n": chunks, "t": saved at}, imp.<n> = chunks of one SealedBox (device key, aad OTPW-IMP-1) of the text |
ImportBuffer |
res |
dictionary | Latest settings results (l lines, u unseen) |
Results |
fh |
array | Keyed hashes of the last 32 applied settings forms (below) | Forms |
q |
array | Phone changes to locked entries waiting for the PIN, at most 16. Each item is [action, box] (plus the material number for a replacement): a SealedBox of the change (device key, aad OTPW-Q-2 and the action code), so the action can be counted without decrypting and a changed code fails to open. Items written by 0.9.0 are bare SealedBoxes (aad OTPW-Q-1) and are still read |
ChangeQueue |
qm.<n> |
bytes | The validated new secret or contents of a waiting replacement: a SealedBox (device key, aad OTPW-QM-1.<n>.<id>), deleted with its item |
ChangeQueue |
cs |
string | Keyed hash of what the About fields and the glance code were last written from (IDs, pending IDs, "Encrypt names", the glance option, waiting changes, version); absent while names still need converting | AboutScan |
st |
array | Version and day of the last passed self-test | AppState |
The PIN wrap and the lockout state have their own keys instead of living in m as PROJECT_PLAN 6.2 first planned. Each wrong PIN rewrites the lockout state before the check, and a small separate value makes that write cheap and keeps it from risking the entry index.
Keys
device key (32 random bytes, "dk") --HKDF--> Kenc, Kmac seals unlocked entries and pending items
PIN --PBKDF2-HMAC-SHA256--> PIN key (RAM only)
PIN key --HKDF--> Kenc, Kmac seals the master key in "w"
master key (32 random bytes, only in "w", wrapped) --HKDF--> Kenc, Kmac seals locked entries
The HKDF labels are OTPW enc v1 and OTPW mac v1 (empty salt). Every sealed value is a SealedBox:
SealedBox = 0x01 | IV (16 random bytes) | AES-256-CBC(Kenc, IV, PKCS#7(plaintext)) | tag (32 bytes)
tag = HMAC-SHA256(Kmac, "OTPW-REC-1" || len(aad) as 4 bytes, big-endian || aad || 0x01 || IV || ciphertext)
The tag is checked in constant time before anything is decrypted. Any failure (wrong key, changed byte, truncation, unknown version) gives the same "can't open" result.
Entry record, version 1
offset 0 v 1 byte record format version (1)
offset 1 kr 1 byte 0 = sealed with the device key, 1 = with the master key
offset 2 hlen 2 bytes header length, big-endian
offset 4 header hlen TLV of the header fields, readable without a key
then box SealedBox of the TLV of the sensitive fields
aad = UTF-8 ID || v || kr || hlen || header
TLV field: tag (1 byte) | type (1 byte) | length (2 bytes, big-endian) | value. The types are 1 bytes, 2 UTF-8 text, 3 64-bit signed integer (8 bytes, big-endian) and 4 boolean (1 byte, 0 or 1). Fields are written in tag order, so the encoding is canonical. Decoding is strict: truncated fields, unknown types, wrong lengths, duplicate tags, invalid UTF-8 and NUL characters all make the record count as damaged.
| Tag | Field | Type | Where |
|---|---|---|---|
| 1 | ID | text | header |
| 2 | kind (0 OTP, 1 code) | int | header |
| 3 | type (0 TOTP, 1 HOTP, 2 Steam) or symbology | int | header |
| 4, 5, 6 | name, account, group | text | header, unless "Encrypt names" is on |
| 7 | locked | bool | header |
| 8 | source (0 manual, 1 import) | int | header |
| 9 | favourite | bool | header |
| 10 | HOTP counter | int | header |
| 11 | created (unix seconds) | int | header |
| 12 | names encrypted | bool | header |
| 13 | fingerprint (8 bytes, see below) | bytes | header |
| 20, 21, 22 | algorithm (0 SHA-1, 1 SHA-256, 2 SHA-512), digits, period | int | box (OTP) |
| 23 | secret (raw bytes) | bytes | box (OTP) |
| 24 | payload (UTF-8 bytes) | bytes | box (code) |
| 25 | options | int | box (code) |
| 26, 27, 28 | name, account, group | text | box, when "Encrypt names" is on |
What the aad gives you:
- The ID is bound. A record copied or swapped under another
e.<id>key doesn't open (vaultDetectsSwappedRecords). - The header is bound. Changing the HOTP counter, the locked flag, a name or
krmakes the record fail to open. Every single-byte change of a record is detected (recordDetectsEveryTamperedByte). - The header is not verified when only the list is drawn. Reading headers needs no key, so the list can show names without decrypting anything. Someone who can write to app storage could change a displayed name, but could also read the device key stored next to it, so a header MAC with that key would add nothing. The record fails to open as soon as its code is shown.
- The lock state must agree. The vault reports a record as damaged unless the locked flag in its header matches
kr.
Sizes and the entry cap
Measured by recordSizesAreSmall with fake data (M3):
| Record | Size |
|---|---|
| OTP entry ("GitHub", an account, a group, 20-byte secret) | 239 bytes |
| Code entry (a 24-byte payload) | 208 bytes |
| Code entry with the largest allowed payload (4096 bytes) | 4,279 bytes |
The per-value limit is 8 KB, and records over 8,000 bytes are refused. The entry cap stays at 150. That many OTP entries take about 36 KB, well under the conservative 100 KB total (PROJECT_PLAN 6.2). Large barcode payloads can fill the store before the cap is reached. The vault then reports "storage full" cleanly, and nothing is half-written (vaultHandlesAFullStore). The field limits are 64 bytes for name and account, 32 bytes for the group, 128 bytes for an OTP secret and 4,096 bytes for a payload.
PIN wrap, version 1
"w" = salt (16 random bytes) | n (4 bytes, big-endian) | SealedBox(Keys(PIN key), aad, master key)
aad = "OTPW-WRAP-1" || salt || n
PIN key = PBKDF2-HMAC-SHA256(PIN as ASCII digits, salt, n, 32 bytes)
- A correct PIN is exactly "the box opens". No PIN hash is stored. Changing the salt or
nmakes the box fail to open (pinWrapIsAuthenticated). nmust lie between 2,000 and 200,000 or the wrap counts as damaged before any derivation. A tampered huge count can't hang the watch.- New PINs are exactly 4 digits (0.9.0), stored with the marker
wl= 4 next to the wrap. A wrap without the marker is a PIN from 0.8.0 or earlier (4 to 10 digits): it still unlocks, and the PIN screen then asks for a new 4-digit PIN (pinLegacySixDigitWrapStillUnlocks,pinSetRequiresFourDigits).nis calibrated on the watch to about one second; the Forerunner 70 manages 2,000 iterations per second, the minimum. - What 4 digits mean. 10,000 possible PINs. On the watch, the delays after wrong PINs and the optional erase after 10 make guessing impractical. Against a copy of the app's storage they don't help: 10,000 guesses of 2,000 PBKDF2 iterations take seconds on a PC. The debug build's storage files showed up over USB on the Forerunner 70, so treat locked entries as protected from someone holding the watch, not from someone who copies its files.
- Changing the PIN re-wraps the same master key. Entries are not re-encrypted (
pinChangeKeepsEntries). - Reset PIN deletes things the PIN protected: the wrap, every locked entry and every pending item. Unlocked entries stay (
pinResetErasesOnlyLockedData).
Wrong PINs
Lockout implements PROJECT_PLAN 7.6:
- Attempts are counted before the check. Each attempt is written to
lkbefore the PIN is checked, so killing the app mid-check still costs an attempt. A success deleteslk. - Delays: four free attempts, then 30 s after the 5th failure, doubling up to a 1 hour cap (
lockoutDelaySchedule). An attempt during a delay is refused and not counted. - Delays use the trusted (GPS-synced) clock when it is available, so restarting the app doesn't shorten them (
lockoutBlocksAndSurvivesRestart). - Without a synced trusted clock, a delay counts system uptime. It restarts in full when the app restarts or the system timer goes backwards: never shorter, sometimes longer (
lockoutUsesUptimeWithoutTrustedClock). - An unreadable
lkis not a reset: no free attempts remain (lockoutTreatsDamagedStateConservatively). Deletinglkdoes reset the count, but only someone who can write app storage can do that. Other Connect IQ apps can't; someone with the watch and a USB cable may be able to (the debug build's storage files were visible over USB on the Forerunner 70), and could then also guess the PIN offline (threat model). - With "Erase locked entries after 10 wrong PINs" on, the 10th failure deletes the wrap, locked entries and pending items (
pinEraseAfterTenFailures).
Session
- Unlocking puts the master key in RAM and derives its two keys there.
lock()zeroes all three and drops them (sessionAutoLocksAndWipesKeys).- Auto-lock:
checkExpiry()locks after the inactivity time, default 1 minute, where 0 means immediately. A system timer that goes backwards counts as expired.
Pending locked entries
A locked entry that arrives while the session is locked, or before a PIN exists, is sealed with the device key under p.<id> and listed in m.pending. It doesn't appear in the list. After the next unlock, the PIN screen's ChangeQueue.LockJob promotes them one at a time (Vault.promoteNext): it re-seals each with the master key, writes e.<id>, updates the index and only then deletes p.<id> (vaultQueuesLockedEntriesWhileLocked). Then it applies the waiting "Require PIN" changes, one per step, before the app continues.
Write order and crashes
- Adding: the record is written first, the index second. If the index write fails, the record is deleted again. A crash between the two leaves an unreferenced record, never an index entry without a record.
- Deleting: the index is written first, then the record is deleted.
- Store full: a failed write becomes VaultError FULL with nothing half-added (tested at many fill levels, so that both the record write and the index write fail in turn).
Migration
m.schemais the data version.Migration.run()creates fresh metadata on first start and migrates older schemas step by step. It refuses newer schemas (VaultError NEWER_SCHEMA) and damaged metadata (DAMAGED) without changing them (migrationRefusesNewerAndDamagedMetadata).- Version 0 never shipped. It is a fixture layout (
m = {"order": [...]}) that tests the migration machinery (migrationV0ToV1). - The 0-to-1 step changes only the metadata, in one write. Future migrations that rewrite records must write each new record under a temporary key and switch the index before deleting the old one (PROJECT_PLAN 6.3).
Memory hygiene and its limits
- Keys and decrypted secrets are ByteArrays and are zeroed after use:
Entry.wipeSecrets(),Session.lock(),SealedBox.Keys.wipe(), and the PBKDF2 job's buffers. The tests check that the original buffers are all zeros afterwards. - What can't be wiped:
- Monkey C strings (names, and the PIN as typed).
- The native HMAC object's internal copy of its key.
- Any copy the VM makes.
This is best effort, as PROJECT_PLAN 7.8 says.
- Invalid UTF-8 is checked before decoding. StringUtil.convertEncodedString stops the app with an uncatchable error on invalid UTF-8, so Bytes.toUtf8 validates first, and damaged text can't crash the app.
Test builds and fixtures
- The UI-test build (
uitest.jungle) wipes the store at every start and fills it with fake entries. It sets the PIN 1234 by storing a precomputed wrap of a fixed fake master key (tools/gen_fixture_wrap.py), and freezes the clock. All of this is(:uitest)or(:debug)code; release builds, and therefore the Store package, contain none of it. - The PIN screen hands the derivation a copy of the typed digits and wipes both the copy and its own buffer once the derivation is done. The native HMAC object may keep its own copy (see above).
Fingerprints (duplicate detection)
Imports skip an entry that has "the same type and the same secret" as an existing one (PROJECT_PLAN 11.4). That has to work for locked entries while the watch is locked. So every record carries an 8-byte keyed fingerprint in its readable header, tag 13:
fingerprint = first 8 bytes of HMAC-SHA256(Kfp, "OTPW-FP-1" || kind || type || secret or payload)
Kfp = HKDF-SHA256(device key, info = "OTPW fp v1")
- Without the device key it reveals nothing. Equal fingerprints only show that two entries share a type and secret.
- With the device key, it allows checking guesses of a locked entry's secret. An attacker who could read app storage could compare a guessed secret against the fingerprint. Real TOTP secrets are random and at least 80 bits long, so guessing them is infeasible; a user-chosen, guessable secret would be weakened.
- The fingerprint is part of the header, so the record's MAC covers it. It is recomputed on every add and update (
importerFingerprintsIdentifySecrets).
Import text
- Secrets in the phone settings. Text pasted into the Import fields sits in Garmin Connect's settings until the watch processes it. The watch then clears the fields (they hold secrets) and turns "Import now" off. Whether the phone shows the cleared fields is the open question of the M1 spike.
- Unfinished imports. A multi-part import waiting for its next part is kept sealed with the device key under
imp/imp.<n>. It is deleted when the import completes, fails, is cancelled or is 24 hours old. A test checks that the secret's text isn't readable in storage (importerJoinsMultiPartJson). - What the parsers touch. They never decode icons or notes: long strings are skipped unread, and notes aren't kept. Parsed secrets are wiped once stored or rejected.
Settings forms
- Form hashes. The watch remembers the last 32 applied forms (PROJECT_PLAN 10.3) as keyed hashes, because a form's content includes its secret:
text
hash = first 8 bytes of HMAC-SHA256(Kfp, "OTPW-FH-1" || 0x1F || form name || 0x1F || field 1 || 0x1F || ...)
Kfp is the fingerprint key above. Without the device key a stored hash reveals nothing. With it, an attacker can only test guesses of a whole form, as with fingerprints. The fields go to the native HMAC one by one; the short ones (every secret field) are zeroed afterwards. An import form's text is too long to zero byte by byte within the watchdog limit; it stays in memory as the property's string anyway.
- Secrets in the forms. Add OTP's secret, Add code's contents and Manage entry's new secret are cleared first when the watch processes a form, also after a problem (the other fields stay so you can correct them). Messages never quote a secret.
- Changes from the phone to locked entries (PROJECT_PLAN 7.9). Edit, replace, set counter, "Don't require PIN", the favourite mark and delete of a locked entry are not applied when they arrive. Each one is sealed with the device key under q, which is no weaker than the settings text it came in. They are applied only after someone enters the PIN on the watch under "Phone changes", which always asks for the PIN, even while the session is unlocked. A replacement is validated when it arrives (Base32 decoded, or the code contents checked), and its material is sealed apart under qm.<n>, bound to its item number and entry ID. "Confirm all" and "Reject all" act only on the changes the menu showed (identified by their SealedBox IVs); any that arrive while the PIN is typed keep waiting (queueStoresMaterialApart, queueChecksReplaceFirst, queueActsOnWhatWasShown). Moving an entry is applied at once: the order isn't protected, and positions are absolute. Changes to unlocked entries apply at once and are listed on the results screen (manageQueuesLockedEntryChanges, manageQueueIsSealedAndLimited).
- The About catalog sends entry IDs and names to the phone's settings, where your entries came from in the first place. With "Encrypt names" on it sends only IDs and types.
- "Encrypt names" puts names and accounts into the encrypted part of each record. Turning it on or off re-seals every entry the watch can reach: unlocked ones at once, locked ones the next time the PIN is entered (settingsAppliesEncryptNames). The barcode cache is kept.
Glance code
With "Show the favourite code in the glance" on (off by default), the glance in the watch's glance loop shows the current code of one entry without opening the app:
- Which entry. The first favourite (from Aegis, or "Mark as favourite" in Manage entry) that is a TOTP or Steam entry and doesn't require the PIN. Locked entries never appear, whatever their mark; HOTP entries don't either (showing a code would need the counter to advance).
- Why a copy. The glance gets 64 KB and can't open the vault, so after every change to the entries or the option the app's About scan seals that one entry for it under
g:
text
g = SealedBox(device keys, aad "OTPW-GL-1",
1 | type | algorithm | digits | period (2 bytes) | label length | label (UTF-8) | secret)
This is the protection the entry itself has (unlocked entries are sealed with the same device key), so the copy adds no new way in. The glance opens it with the same SealedBox code and checks its MAC first (glanceItemRefusesTamperingAndOtherKeys).
- Turning it off. The glance reads the option itself, so the code disappears as soon as the phone saves the change; the app deletes g at its next start or settings change. Removing the favourite mark, requiring the PIN for the entry, or deleting it also removes or replaces g (glanceScanChoosesTheFirstQualifyingFavourite).
- What it means for you. Anyone who can see your watch's glance loop sees that one code, exactly as if they opened the app to that entry. Choose an entry for which that's acceptable, or leave the option off.
- Memory. The glance computes the code once per period and redraws the countdown every second; the secret is wiped when the glance is hidden.
