# Security and storage format This document records how the watch stores your data, byte for byte. The design rationale is in [README section 7](../README.md#7-security-design) and the threat model in [section 8](../README.md#8-threat-model). The code is in `source/storage/` and `source/crypto/`. Every claim below is covered by a unit test in `test/storage/` or `test/crypto/`. ## 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.` | bytes | One entry record (format below) | `Vault` | | `p.` | bytes | Pending locked entry: a record sealed with the device key | `Vault` | `x.` (barcode cache, M6) and `imp` (import buffer, M5) are added by later milestones. The PIN wrap and the lockout state have their own keys instead of living in `m` as README 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 ```text 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: ```text 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 ```text 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 | | 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.` key doesn't open (`vaultDetectsSwappedRecords`). - **The header is bound.** Changing the HOTP counter, the locked flag, a name or `kr` makes 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 (README 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 ```text "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 `n` makes the box fail to open (`pinWrapIsAuthenticated`). - **`n` must 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. - **PINs are 4 to 10 digits.** `n` is calibrated on the watch to about one second (UI in M4). - **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 README 7.6: - **Attempts are counted before the check.** Each attempt is written to `lk` before the PIN is checked, so killing the app mid-check still costs an attempt. A success deletes `lk`. - **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 `lk` is not a reset:** no free attempts remain (`lockoutTreatsDamagedStateConservatively`). Deleting `lk` does reset the count, but only someone who can already write app storage can do that, and app storage isn't reachable from outside the app (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.` and listed in `m.pending`. It doesn't appear in the list. After the next unlock, `promotePending()` re-seals it with the master key, writes `e.`, updates the index and only then deletes `p.` (`vaultQueuesLockedEntriesWhileLocked`). ## 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.schema` is 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 (README 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 README 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 123456 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).