Vault format

How credentials are sealed on disk, in full. The application is what it is because of this document; if the two ever disagree, this one is wrong and the code is right.

# r-shell Encrypted Credential Vault — Authoritative Spec (Phase 0) Status: implementation-ready. This revision incorporates fixes for every valid finding raised in review. Where a finding was a non-issue or partially wrong, that is noted inline (search "Finding note"). The core the review endorsed is unchanged: **one DEK, many KEK slots, one atomic file**. The three structural bugs the review correctly caught — (a) wrap-AD binding the mutable header, (b) vclock/tombstone bound in record AD, (c) a scalar `generation` doubling as both multi-writer version and rollback anchor — are removed by construction below. Byte notation: `name[N]` = N raw bytes; `u16/u32/u64` = little-endian; `||` = concatenation of raw bytes in the exact written order. All AAD/AD strings are ASCII with no trailing NUL unless shown. --- ## 0. Decision log (only changes vs. candidate shown) | Point | Candidate | **Final** | Why it changed | |---|---|---|---| | Wrap AD | `BLAKE3(header params)‖slot_id‖kind‖vault_uuid`; "binds whole header" | **Immutable slot-local bytes only** (§2.3). Header/slot-vector/version integrity lives solely in the DEK-sealed manifest AAD. | Candidate bricked passphrase unlock after the first keychain-only save and on every add-device (Findings: wrap-AD ×4). | | Record AD | `vault_uuid‖record_id‖vclock‖dek_generation‖deleted_flag` | **`vault_uuid‖record_id‖dek_generation` only.** vclock + tombstone authenticated by the manifest. | vclock/tombstone in AD forced decrypt+re-encrypt on every merge and failed-closed on the just-merged winner (Findings: record-AAD ×3). | | Version / rollback anchor | scalar `generation` (manifest-authenticated) + keychain HWM + time grace window | **Whole-vault version vector `VV` for causal reasoning; keychain + local sidecar HWM (device-only storage); causal (not numeric, not time-boxed) acceptance; no grace window.** Scalar `save_seq` retained display/tiebreak-only. | A scalar can't be both a multi-writer version and a replay anchor; manifest auth ≠ replay defense; time window = post-rotation downgrade oracle (Findings: scalar-generation, anti-rollback-collapse, grace-window ×2, .bak, provider-drop). | | Passphrase change | O(1) slot rewrite = "revocation" | **Two distinct ops:** *rotate (convenience)* = O(1); *compromised* = **mandatory DEK rekey** (O(N)) + `.bak` wipe + rotation nudge. | O(1) rewrite leaves every historical synced copy decryptable by the old passphrase (Finding: passphrase-no-revocation, critical). | | Keychain item accessibility | `keyring` default (`WhenUnlocked`) | **Device-only, non-migratory** (`…ThisDeviceOnly` / DPAPI-local), likely via `security-framework` directly. | KEK otherwise rides Time Machine / iCloud device backups → passphrase gate bypassed (Finding: backup-overclaim, high). | | Commitment | `BLAKE3_keyed(commit_key,"kcv")` (KEK-only) | **Binds DEK + wrap ciphertext** (§2.3). | KCV over KEK alone isn't key-commitment of the wrap (Finding: commitment-KCV). | | Device write authority | "delete slot" (not real revocation) | **Per-device Ed25519 write signatures**; slot deletion revokes future writes; **rekey mandatory (not optional) on compromise**. | A DEK-holder could forge dominating vclocks (Finding: revoked-device-forges). | | Suite 2 activation | "adding a suite is a minor bump" | **Suite 2 (AES-256-GCM-SIV) = format_major bump**; nonce field width is suite-dependent. | 12-byte GCM-SIV nonce can't inhabit the hardcoded 24-byte fields back-compat (Finding: suite-2-unusable). | | Nonce wording | "misuse resistance" | **"misuse *tolerance* (collision-bounded)"**; true NMR reserved to suite 2. | XChaCha random nonce ≠ NMR (Finding: nonce-wording). | | format_major width | u8 in MAGIC + u16 in header | **u16 only; MAGIC is a pure literal**; locator check derives from authenticated header. | Two copies could disagree; >255 unrepresentable (Finding: major-dup). | | Reveal / connect | "distinct re-auth'd command" (unenforced) | **OS user-presence / biometric gate is a hard requirement**, not an open item; connect resolution is foreground-gated; reveal rate-limited + audited. | Transparent DEK made reveal a plain IPC call any webview compromise can drain (Finding: reveal-no-reauth). | | Merge: tombstone vs edit | edit always wins | **Deletion is durable.** Tombstone vs *meta-only* edit → tombstone wins (shadow preserved). Tombstone vs *secret* edit → **blocking surfaced conflict, stays deleted** until explicit restore. | Edit-wins silently resurrects deleted/compromised credentials (Findings: tombstone-resurrect ×3). | | device_id lifetime | per-install keychain UUID | **Stable, authenticated device identity** (Ed25519 pubkey fingerprint) persisted device-only + re-adopted on restore; **clock/tombstone GC specified** (§4.7). | Reinstall/re-sign forked clocks and grew them unbounded (Finding: reinstall-device_id). | | Corrupt inbound file | "refuse to open AND refuse to write" | **Quarantine inbound; continue from last-good local; buffer edits to side journal.** Refuse-to-write only when no trusted base exists at all. | Blanket refuse-to-write stranded unsaved edits / DoS (Finding: refuse-to-write-strands). | | Provider mirror | "mirror the identical file" | **Atomic tmp→rename inside provider dir; only the single canonical file is synced (`.bak`/`.tmp` excluded); load merges all copies and re-pushes if local dominates.** | Half-written mirror + synced `.bak` fed stale/truncated inputs; load authority undefined (Findings: mirror-atomicity, provider-drop). | | Unknown minor | "open, take upgrade path" | **Lossless round-trip of unknown fields mandatory; else strict read-only.** | Old client silently dropped a newer minor's fields on re-save (Finding: minor-skew-loss). | --- ## 1. Envelope format One file **`vault.rvlt`**, written atomically, with one retained prior generation **`vault.rvlt.bak`**. All lengths validated against actual file size *before* any slice; over/under-run rejects **that blob** (fail closed), never truncates the vault. ``` [0..8) MAGIC = "RSHVLT\x01\x00" // pure literal, 8 bytes, NO numeric version [8..10) format_major : u16 // unknown major -> refuse open AND refuse write [10..12) format_minor : u16 // unknown minor -> read-only unless lossless round-trip (§8) [12..16) header_len : u32 [16 .. 16+header_len) HEADER // postcard, canonical, plaintext-but-authenticated [.. ] BODY = MANIFEST blob then RECORD blobs // order irrelevant; manifest is the index ``` The 8-byte MAGIC is a locator only. The authoritative `format_major/minor` are the authenticated u16 fields at `[8..12)`; they are folded into the manifest AAD (§1, MANIFEST). A one-byte MAGIC flip therefore reduces to ordinary corruption → quarantine (§4.6), not a special DoS. *Finding note (major-dup): resolved — single u16 width, no duplicated u8.* ### HEADER (postcard, deterministic field order) ``` format_version : u16 // == format_major.format_minor mirror, authenticated cipher_suite_id : u8 // 1 = XChaCha20-Poly1305 + Argon2id + HKDF-SHA256 + BLAKE3 + Ed25519 // 2 = AES-256-GCM-SIV (reserved; activation = format_major bump, §3/§8) vault_uuid : [u8;16] // lineage; scopes all AD; blocks cross-vault splice dek_generation : u32 // +1 only on DEK rekey version_vector : Map<device_id:[u8;16], u64> // VV: causal state of the whole vault (§4) save_seq : u64 // display / .bak naming / deterministic tiebreak ONLY — never a rollback anchor created_at : i64 // unix-ms modified_at : i64 // unix-ms devices : Vec<DeviceRec> // authenticated device roster (Ed25519 pubkeys) — write-authority set key_slots : Vec<KeySlot> manifest_ref : { nonce:[u8;NONCE], len:u32 } // NONCE = 24 (suite 1) | 12 (suite 2) ``` `DeviceRec`: ``` device_id : [u8;16] // = BLAKE3(ed25519_pub)[..16], stable identity label : String ed25519_pub : [u8;32] // write-signature verification key added_at : i64 active : bool // false => writes from this device_id are rejected (revocation), see §2.4 ``` `KeySlot` (each independently wraps the *same* DEK; §2): ``` slot_id : [u8;16] label : String kind : u8 // 0=keychain, 1=passphrase, 2=recovery kdf : Option<{ alg:u8=0x13 /*Argon2id v1.3*/, m_kib:u32, t:u32, p:u32, salt:[u8;16] }> // kinds 1,2 only keyring_ref : Option<{ service:String, account:String }> // kind 0 only wrap : { nonce:[u8;NONCE], ct:[u8;48] } // AEAD(wrap_key, nonce, DEK[32], AD_wrap) = 32B DEK + 16B tag commitment : [u8;32] // binds DEK + wrap.ct (§2.3) ``` The header carries only parameters and *wrapped* DEKs — no secrets — so it is not separately encrypted. Its canonical bytes (incl. `version_vector`, `devices`, `key_slots`, `save_seq`) are bound into the **manifest AAD**, which is re-sealed with the DEK (always in hand on save) on every write. Therefore any header edit — deleting a slot, deactivating a device, editing VV, lowering Argon2 cost — is caught at open time when the manifest fails to authenticate. **No mutable header field is ever bound into a slot wrap AD** (that was the candidate's fatal bug). ### MANIFEST blob (AEAD under `k_manifest`, §2.1) ``` nonce = manifest_ref.nonce AD_manifest = "r-shell/v1/manifest" || MAGIC[8] || format_major(u16) || format_minor(u16) || cipher_suite_id(u8) || vault_uuid[16] || BLAKE3( canonical HEADER with manifest_ref field zeroed ) // binds devices, key_slots, VV, save_seq, dek_generation plaintext (CBOR) = { version_vector : Map<device_id,u64>, // authoritative copy (mirrors header; header copy is the pre-auth hint) dek_generation : u32, entries: Vec<{ record_id : [u8;16], vclock : Map<device_id,u64>, // per-record vector clock (authenticated HERE, not in record AD) state : u8, // 0=live, 1=tombstone, 2=security-tombstone class : u8, // bitflags: has_secret_edit | meta_only (drives merge, §4.4) updated_at : i64, updated_by:[u8;16], offset : u64, len:u32, // 0/0 for tombstones (no record blob) ct_hash : [u8;32], // BLAKE3 of record ciphertext blob len_bucket : u16, // padded-size bucket author_sig : [u8;64], // Ed25519 over (record_id‖vclock_canon‖state‖ct_hash) by updated_by (§2.4) unknown : CBOR-map // verbatim-preserved unknown fields (forward compat, §8) }> } ``` *Finding note (record-AAD ×3, resolved):* the vclock, tombstone `state`, and `class` live only in this encrypted+authenticated manifest. Normalizing a clock on merge rewrites **only the manifest**; the record ciphertext is genuinely relocated, not re-encrypted. The "merge moves whole verified ciphertexts" invariant is now true for both the dominance and the conflict paths. ### RECORD blob (per connection/profile/folder; AEAD under a per-record subkey, §2.1) ``` nonce : [u8;NONCE] AD_record = "r-shell/v1/record" || vault_uuid[16] || record_id[16] || dek_generation(u32) // IMMUTABLE only ct = AEAD(record_key, nonce, padded(record_plaintext), AD_record) record_plaintext (CBOR) = { id, type: connection|profile|folder, payload: { password?, passphrase?, vncPassword?, proxyPassword?, jumpPassword?, jumpPassphrase?, privateKey? /* raw key material */, host, username, port, name, folder, tags?, color?, ... }, unknown : CBOR-map // verbatim-preserved unknown payload fields (§8) } ``` - **Per-record** encryption is required for per-connection merge (req 3) and single-secret resolution at connect time (req 6). - `privateKeyPath` (a filesystem path) is **not** a secret and stays in the record's non-secret metadata; raw `privateKey` material **is** a secret and lives in `payload`. See §6 for the honest caveat that path-referenced keys are *outside* vault protection. - **Padding:** secret string fields padded to the next **256-byte** bucket; raw key blobs to the next **1 KiB** bucket. Only `len_bucket` is observable. --- ## 2. Key hierarchy **One data key, many wraps.** ### 2.1 DEK and subkeys - **DEK** — 256-bit, `OsRng` at creation. Never touches ciphertext directly. Lives only wrapped in slots or transiently in `Zeroizing`/`ZeroizeOnDrop` RAM. - `record_key = HKDF-Expand(DEK, "r-shell/v1/record\x00" || record_id, 32)` - `k_manifest = HKDF-Expand(DEK, "r-shell/v1/manifest\x00", 32)` - `k_author_ref = HKDF-Expand(DEK, "r-shell/v1/authoridx\x00",32)` // indexes the device roster only; write auth is Ed25519 (§2.4) ### 2.2 KEKs (the slots) - **Keychain-KEK** — 256-bit random **per device**; **one** OS-keychain entry (never per-connection — honors CredMan's ~2560-byte blob cap and raw-key size). Transparent local unlock. **Stored device-only, non-migratory** (§2.5). Never enters the file. - **Passphrase-KEK** — `Argon2id(passphrase, slot.salt) → 32B` (§3). Portable unlock on a second device / on export. Multiple allowed. - **Recovery slot** — identical to passphrase but wrapping under a generated high-entropy printable recovery code kept offline. Covers "forgot passphrase AND lost keychain." ### 2.3 Wrap construction (identical per slot, key-committing, DEK-bound) ``` wrap_key = HKDF-Expand(KEK, "r-shell/v1/wrap\x00" || slot_id, 32) commit_key = HKDF-Expand(KEK, "r-shell/v1/commit\x00" || slot_id, 32) AD_wrap = "r-shell/v1/wrap" || vault_uuid[16] || slot_id[16] || kind(u8) || cipher_suite_id(u8) || alg(u8) || m_kib(u32) || t(u32) || p(u32) || salt[16] // kdf bytes present only for kinds 1,2 // (keychain slot: kdf fields omitted entirely) wrap.ct = AEAD(wrap_key, wrap.nonce, DEK, AD_wrap) commitment = BLAKE3_keyed(commit_key, "r-shell/v1/kcv" || wrap.nonce || wrap.ct || DEK) ``` `AD_wrap` contains **only immutable, slot-local** bytes: never `version_vector`, `save_seq`, `dek_generation`, `modified_at`, the device roster, or any other slot. Consequences, all now consistent (Findings: wrap-AD ×4, resolved): - A routine keychain-only save changes VV/`save_seq` in the header but **does not touch any slot's `AD_wrap`** → passphrase/recovery slots keep authenticating → the vault still opens on a second device and on export. Req 1 holds. - **Change passphrase (convenience):** unwrap DEK via any slot → derive new KEK from new passphrase + fresh salt → overwrite only that slot's `{kdf, wrap, commitment}` → re-seal manifest → atomic save. **No records re-encrypted, no other slot touched.** O(1). See §2.6 for the compromise case. - **Add device:** unlock DEK → mint device keychain-KEK + Ed25519 keypair → append a `DeviceRec` and a keychain `KeySlot` wrapping the same DEK. Other slots untouched (their `AD_wrap` didn't reference the roster). **Unlock:** derive `commit_key`, recompute `commitment` over the stored `wrap.nonce‖wrap.ct` *and the candidate DEK produced by the trial unwrap*, `subtle`-compare **before** trusting the DEK. Because the committed input includes both `wrap.ct` and the DEK, this is genuine key-commitment of the wrap, not a bare KCV (Finding: commitment-KCV, resolved). *Note:* suite 2 (GCM-SIV) is itself non-committing; the external BLAKE3 commitment supplies commitment for both suites, and §6 states GCM-SIV's non-commitment explicitly. ### 2.4 Per-device write authenticity (real revocation) Every manifest entry is signed by its author device: `author_sig = Ed25519_sign(device_sk, record_id‖vclock_canon‖state(u8)‖ct_hash)`. Readers **reject** any entry whose signature fails, whose author's key differs from the one they pinned, or whose author they have revoked. Revoking a device therefore **removes its future write authority** even though it still holds the DEK — which is the point, since nothing can take a key back out of a machine you do not control. The Ed25519 secret key is stored device-only (§2.5), so a synced or backed-up vault cannot impersonate a device. **The roster in the header decides nothing, and this is a correction.** The original text said readers reject an entry whose `updated_by` is "absent or `active:false` in the roster". That cannot work: the header is authenticated by the manifest AEAD, which is keyed from the DEK, so **every device that can read the vault can rewrite the roster and have it verify perfectly.** Trusting it hands a revoked device three ways back in — mark itself active again; mark everyone *else* revoked, which is an irreversible fleet-wide kill switch since nothing ever sets the flag back; or simply enrol a fresh device id with a fresh key and sign as that. None of them needs a cryptographic break, only a text editor and the DEK the device already had. So the decision is **device-local**: a trust list sealed under a subkey of the DEK, stored beside the vault (`devices.trust`, same construction as the rollback anchor §4.5.3). It records, per device, the public key **this machine pinned** and whether **this machine** revoked it. The roster travelling in the header is a *claim* — useful for showing a device list and for offering a key when a device introduces itself, never for deciding. Consequences, stated rather than discovered later: - **A revocation does not travel.** It must be made on each machine the user owns. A revocation that travelled would be a weapon any DEK holder could point at every device at once. - **First contact waits for a person.** A device signing with a key its own roster offers is recorded as *pending* and its writes are **refused** until someone on this machine says it is theirs. Trust-on-first-use was the earlier answer and it left revocation hollow: a machine that still holds the DEK can mint a fresh identity — new key, new id — and would be pinned like any other, so revoking the identity it had been using cost it nothing. The same bargain this app already makes with SSH host keys, and for the same reason: the first sight of a key is the only moment the question can be asked. - **An unsigned entry is accepted only from a device with no pinned key.** That is the compatibility window for vaults written before signing existed. Once a device has a key here, an unsigned entry in its name is a downgrade and is refused. The check order matters and is the difference between a signature and a suggestion: deciding on `author_sig.is_empty()` *first* made not signing strictly better for an attacker than signing. - **Merge results are re-authored.** A merge winner and a conflict shadow are written by the device that resolved them, so they are re-signed by it. Leaving them unsigned left a permanently unsigned record in every copy after every conflict. - **The roster is bounded** (64 entries). It arrives from a file, and a file can say anything; unbounded, one crafted copy grows every device's header until the vault stops fitting the format's size cap and nobody can save. **What this costs.** Both machines must approve each other before anything merges, and neither the approval nor the revocation travels — each is asked on each machine the user owns. A decision that travelled would be one that any holder of the DEK could make for everyone. **What this still does not solve.** Approval answers "is this device yours", and the honest limit is that the person answering has only a label the device chose for itself. Someone who holds the DEK *and* can persuade the user to approve an unfamiliar name is not stopped by any of this. A key fingerprint shown side by side on both machines would narrow it further; that is not built. ### 2.5 Device-only keychain storage (backup protection made true) The keychain-KEK, the device Ed25519 secret key, the device_id, and the rollback HWM (§4.5) are stored with **device-only, non-migratory** accessibility: - **Apple (macOS/iOS):** `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`. Stock `keyring` does **not** set this and defaults to `WhenUnlocked`, so we call `security-framework` directly (or a thin wrapper) to set the attribute. Verify at the pinned `keyring`/`security-framework` version. - **Windows:** DPAPI `CRYPTPROTECT_LOCAL_MACHINE`-scoped-to-user / `CredProtect` local; do not use roaming credential flags. - **Linux:** Secret Service default (no cross-device migration exists); document that headless boxes fall back to passphrase. Result (Finding: backup-overclaim, resolved): a Time Machine / APFS snapshot / iCloud device backup that contains `vault.rvlt` does **not** contain a usable KEK, so a restored or backed-up copy requires the **passphrase**. The transparent keychain path deliberately does not survive to another device — that is the whole point. §6 states this precisely instead of claiming unconditional backup protection. #### 2.5.1 What actually ships on this fork (deviation, measured) The design above is the target. It is **not** what this fork can build, and the difference changes a security claim, so it is recorded here rather than left to be discovered. `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` is an attribute of the macOS **data-protection** keychain, and reaching that keychain requires the `keychain-access-groups` entitlement — i.e. a real Apple Developer signing identity. This fork ships ad-hoc-signed binaries; requesting it returns `errSecMissingEntitlement`. The choice was therefore between no transparent unlock at all, or the ordinary login keychain. **The ordinary login keychain is what ships**, because a mandatory master password on every launch was itself driving users toward weaker habits, and the recovery code it requires is a way to lose every credential at once. Measured, not assumed (two separately ad-hoc-signed binaries, plus `/usr/bin/security`): - The item writes and reads back with no entitlement and no prompt. - A **different** binary reads it, also with no prompt — macOS appends its cdhash to the ACL. So a re-signing updater does **not** orphan the KEK, which had been the stated reason for avoiding the keychain entirely. - Any process running as the same user can read it. `security find-generic-password -w` prints it on the first try. Items are still written non-synchronizing, so the KEK stays out of iCloud Keychain and off the user's other devices. That costs no entitlement, and a test asserts it. **The claim in §6 must therefore be read as follows.** A backup containing `vault.rvlt` alone still yields nothing — the KEK is not in it. A backup that also contains `login.keychain-db` yields the KEK **only to someone who has the user's macOS login password**, since the login keychain is encrypted under it. That is a real gate, but it is the same secret that protects everything else on the machine, and it is weaker than a dedicated Argon2id passphrase. What is lost outright is protection against anything running as the user before first unlock. A passphrase slot remains available and is what restores the original claim in full; it is an independent wrap of the same DEK, so turning it on costs no re-encryption and turning it off invalidates nothing. The app exposes it as "require a master password at startup", default off. ### 2.6 O(1) vs O(N): rotate vs rekey - **Rotate passphrase (convenience):** §2.3, O(1), DEK unchanged. - **Passphrase/DEK compromised (security):** **mandatory DEK rekey** — mint DEK′, `dek_generation++`, re-encrypt every record + manifest under DEK′, re-wrap DEK′ into **surviving slots only**, deactivate the compromised device(s). Old-generation ciphertext becomes undecryptable (bound via `dek_generation` in `AD_record`). Then **wipe/overwrite `vault.rvlt.bak`** and surface plainly: *historical synced copies (provider version history, git objects, snapshots) remain decryptable by the old passphrase→old DEK; the only true remedy for already-exposed secrets is credential rotation.* This split removes the candidate's false equivalence of "change passphrase" with revocation (Finding: passphrase-no-revocation, resolved). --- ## 3. KDF + AEAD (concrete) **AEAD, suite 1 (default) = XChaCha20-Poly1305** (`chacha20poly1305::XChaCha20Poly1305`) for records, manifest, and wraps. - 24-byte nonce, **fresh `OsRng` per encryption**. No counters (uncoordinated multi-device writers can't share a counter authority). This is **misuse *tolerance*, collision-bounded**: at 24 bytes, collision probability stays < 2^-32 well past 2^80 encryptions. Per-record subkeys mean any cross-record nonce repeat lands under a different key. **This is not nonce-misuse *resistance*** — a genuine repeat under the *same* `record_key` still breaks confidentiality + enables a Poly1305 forgery; true NMR is reserved to suite 2 (Finding: nonce-wording, resolved). - Encrypt path asserts RNG success → **fail closed on RNG error**. **AEAD, suite 2 (reserved) = AES-256-GCM-SIV** (`aes-gcm-siv`): genuine SIV/NMR. 12-byte nonce → the envelope's nonce field width is **suite-dependent** (`NONCE = 24 | 12`) and validated against `cipher_suite_id` before any slice. Activating suite 2 on an existing vault changes AD/nonce layout and is therefore a **format_major bump**, not a minor one (Finding: suite-2-unusable, resolved). Not default: no AES-NI guarantee on iOS ARM. **KDF = Argon2id v1.3** (`argon2`). Params stored **per slot**; the creating device dictates cost, any device reproduces it exactly. - **Mobile-compatible profile** (default when sync/iOS is enabled): `m = 64 MiB, t = 4, p = 1`, salt 16B, out 32B. - Change vs candidate: **t raised 3→4 and p set to 1**. Argon2's output is bit-identical regardless of `p` value (lane parallelism is algorithmic, not thread-count dependent), so `p=1` costs us nothing in portability while keeping the full 64 MiB as a *single* memory-hard region instead of four ~16 MiB lanes — materially better GPU/ASIC resistance for a store of root/SSH creds (Finding: argon2-underparam, resolved). - **Desktop-only profile** (user opts out of mobile sync): `m = 256 MiB, t = 4, p = 1`. - **Floor:** `m ≥ 64 MiB, t ≥ 4`. `46 MiB` is sanctioned **only** if oldest-supported-iPhone jetsam benchmarking forces it, and **only** paired with a mandatory high-entropy passphrase/recovery-code (below). Never below 46 MiB. - On unlock, if a slot's params are below current policy, **transparently re-wrap** that one slot to upgrade (O(1)). **Passphrase strength gate (mandatory before enabling sync):** the exported/synced copy's entire at-rest protection is `Argon2id(passphrase)`. Before sync can be turned on, the vault must have **either** a passphrase slot meeting a hard entropy bar (zxcvbn-style estimate ≥ configured threshold) **or** a generated high-entropy recovery-code slot. "Keychain-only, no passphrase" is **not** permitted once sync is enabled (§5). **Subkeys** = HKDF-SHA256 (`hkdf`+`sha2`). **Content hashes / keyed seal / commitment** = BLAKE3 (`blake3`). **Write signatures** = Ed25519 (`ed25519-dalek`). **Constant-time compares** = `subtle`. All key material in `zeroize`/`secrecy`. --- ## 4. Sync, merge, rollback ### 4.1 Layout Canonical `vault.rvlt` in `app_local_data_dir` (mode 0600, keychain-unlockable). `vault.rvlt.bak` = previous good generation, kept **beside the canonical file and NOT inside any synced folder**. Sync = user configures a target path in **their own** iCloud Drive / Dropbox / git / WebDAV; Rust writes **only the single canonical file** there. Only a passphrase/recovery slot makes the synced copy openable elsewhere (keychain-KEK is device-local, §2.5). ### 4.2 Atomic write (local and provider) Serialize → `…/vault.rvlt.tmp-<rand>` **in the same directory as the target** → `fsync(file)` → rename current to `.bak` (local dir only) → rename tmp over target → `fsync(dir)`. Windows: `ReplaceFileW` / `atomic-write-file`. The **provider mirror uses the identical tmp-in-same-dir + atomic rename dance inside the provider folder** so iCloud/Dropbox never capture a half-written file; `.bak`/`.tmp-*` never live in a synced dir (Finding: mirror-atomicity, resolved). Advisory `fs4` lock serializes local writers. Never mutate in place. ### 4.3 Identity, VV, and vclocks - `device_id` = `BLAKE3(ed25519_pub)[..16]`, **stable** across reinstall/restore because the Ed25519 keypair is persisted device-only and re-adopted on restore via authenticated identity confirmation (not a fresh per-install UUID). This kills the "reinstall forks its own clock" storm (Finding: reinstall-device_id, resolved). - **Per-record vclock** (`Map<device_id,u64>`) drives merge. **Whole-vault version vector `VV`** = elementwise max of all record vclocks; it drives causal rollback reasoning (§4.5). - Local edit of record R: `vclock[device_id] += 1`, re-sign entry (§2.4), recompute `VV`. - **In-flight grace** is defined *causally*, not by wall clock: an entry authored by a device whose slot was removed is honored only if its vclock is a direct successor of a state the trusted VV already dominates; otherwise rejected. ### 4.4 Merge (per-connection, vector clocks) On load, gather **all** candidate copies (local canonical, provider copy, provider "conflicted copies", git-unmerged siblings — excluding `.bak`/`.tmp`/any non-validated artifact). For each `record_id` over the union: - **one side dominates** (≥ every component, > on one) → take it (pure ciphertext move, no re-encryption); - **equal** → identical; - **concurrent, both live** → deterministic winner by `(updated_at, then higher device_id)`; **loser preserved as a flagged `*(conflict …)` shadow** — never dropped; - **concurrent tombstone vs meta-only edit** (`class = meta_only`) → **tombstone wins** (deletion durable); the edit is kept as an inert flagged shadow requiring explicit restore; - **concurrent tombstone vs secret edit** (`class` has `has_secret_edit`, i.e. a re-add or changed secret) → **do not auto-resolve**: record stays deleted, raise a **blocking, surfaced conflict** the user must explicitly act on, tied to the "mark for rotation" flow. A compromised/deleted credential is therefore **never silently resurrected** (Findings: tombstone-resurrect ×3, resolved); - `security-tombstone` (`state=2`, from the compromise/rekey path) **wins all concurrent conflicts** unconditionally. Merged winner vclock = elementwise `max` + own component bump (so the decision dominates and won't re-conflict); this rewrites **only the manifest entry**, per §1. If two sides differ in `dek_generation`, unwrap both (needs a local or passphrase slot) and re-encrypt surviving losers under the newer generation before merging. After merge: recompute `VV`, `save_seq = max(all)+1`, re-seal, atomic save, re-anchor HWM. ### 4.5 Rollback / downgrade protection (causal, not scalar, not time-boxed) 1. **Causal anchor.** Track a per-device **HWM = the last trusted `VV`** (as a compact digest plus the raw VV). A candidate file is **rejected as a rollback iff its VV is strictly causally dominated by the HWM** (older-or-equal on every component present *and* introduces no component the HWM lacks) — i.e. it is a pure ancestor carrying no new information. A file that is concurrent-or-newer is accepted into merge (§4.4). This replaces the scalar `generation` comparison entirely; `save_seq` is display/tiebreak-only and **is not a rollback anchor** (Findings: scalar-generation, anti-rollback-collapse, resolved). 2. **Benign "provider served the older file first" race** → the older file is a pure ancestor of local; merge keeps local and **re-pushes local (repair)**. No wall-clock grace window exists (Findings: grace-window ×2, provider-drop, resolved). If the *only* copy visible is a strict ancestor (newer not yet arrived), **retry/poll** briefly, then continue from last-good local and re-push — never accept the ancestor as truth, never fail-closed on a copy that local already dominates. 3. **HWM durability.** HWM is stored **device-only** in the keychain (§2.5) **and** redundantly in an authenticated local sidecar `vault.hwm` (MAC under a keychain-held key). On load, cross-check keychain HWM, sidecar HWM, and the `.bak` lineage's VV; take the causal max of the three. 4. **HWM loss = security event.** If keychain identity changes (auto-updater re-sign, OS migration, keychain reset) and the HWM cannot be recovered from sidecar or `.bak`, do **not** silently drop to trust-on-first-use. Require a **passphrase-gated trusted re-import** that shows the candidate's `save_seq`, VV, and connection count for explicit user confirmation, then re-anchors HWM. Manifest authentication is **not** counted as replay defense (an old file is validly self-sealed) — only the causal HWM is (Finding: anti-rollback-collapse, resolved). 5. **`.bak` restore** goes through this **same passphrase-gated re-anchor path**, never an automatic silent fallback — otherwise `.bak` (whose VV ⊂ current) would be an always-available rollback vector. Restoring `.bak` re-anchors HWM to the restored VV (Finding: .bak-contradiction, resolved). 6. **Authenticated deliberate downgrade** (restore an old backup on purpose) is the passphrase-gated path in (4)/(5): the passphrase re-anchors HWM to the restored VV — no silent weakening. 7. `cipher_suite_id`, `format_major/minor`, `dek_generation` are authenticated in the manifest AAD → refuse unknown suites, refuse writing older format over newer, refuse rotated-out generations. 8. **Fresh-device bootstrap gap (documented honestly):** a device that has never seen any prior VV cannot detect a rollback below its first observed state; first import is an explicit trusted-import confirmation (same UI as (4)). ### 4.6 Fail-closed vs. don't-strand Distinguish **"the file I was handed is bad"** from **"my own trusted base is bad"** (Finding: refuse-to-write-strands, resolved): - A corrupt/truncated/malicious *inbound* copy (AEAD tag fail, hash mismatch, missing manifest, unknown major, malformed) is **quarantined and ignored**; the client continues from its last-good local canonical / in-memory state and **can still write**. - Local edits made while inbound is unusable are buffered to a **side journal** (`vault.journal`, encrypted under the DEK) so nothing is lost. - **Refuse to write** only when **no** trusted base state can be established at all (local canonical, `.bak`, and in-memory all unavailable/failed); in that case the journal retains edits for later replay. Open sequence for an accepted copy: check MAGIC + major → parse header → unwrap DEK via a slot (**verify commitment first**) → decrypt+verify manifest (AAD binds header) → verify each `DeviceRec`/`author_sig` → for each entry verify `BLAKE3(record ct) == ct_hash` **and** that the live record set exactly matches the manifest (no orphans, none missing) → causal HWM check (§4.5) → only then expose. ### 4.7 Clock + tombstone GC (deferred — see the correction below) **This section was wrong as originally written, and is corrected here rather than quietly implemented around.** The original text said: *"Each device records a seen-vector `SV[device_id] = min over records of (that device's last-acknowledged component)` committed in the manifest per device. A tombstone whose vclock is dominated by every active device's `SV` may be garbage-collected"*, and called itself "specified, not deferred". Two things are wrong with that formula. - **Wrong rank.** A scalar per device cannot carry what is needed. Safe collection requires knowing, for each pair (observer *i*, author *j*), how far *i* has seen *j* — a matrix, one full vector published per observer. - **Wrong axis.** The minimum that confers safety is taken over *observers*, not over *records*. A record's vclock records **authorship**, not observation; nothing in this format records what a device has read. Taken literally, and with this format's absent-component-is-zero rule, any record a device never wrote pins that device's component to zero, so in any vault with more than a couple of records the published `SV` is the zero vector, nothing is ever dominated, and the predicate never fires. The section as written is a permanent no-op — which no test catches, because a collector that never collects passes every safety test. The construction actually wanted is **causal stability over a signed per-observer acknowledgment matrix** (Wuu–Bernstein time-tables; Golding's TSAE; "reap only once every replica has seen it"). Each observer publishes a signed vector of what it has merged and durably persisted; the collection frontier is the elementwise minimum over observers; a tombstone is collectible once its vclock is at or below that frontier, because from then on no participant can produce anything concurrent with it. **Explicitly unsound, and forbidden:** `SV := the header's version_vector`. It has the same type and the same shape as a correct `SV`, and it is already computed on every save, so it is one line away — but §4.3 counts per *(device, record)*. A device holding an unrelated record at `{E:9}` scores above a tombstone at `{E:5}` while never having seen that record at all. **Status: deferred. Tombstones are retained indefinitely.** Three things must exist first: 1. **A real device roster.** Until every device that opens the vault enrols itself, a quorum over "active devices" is a quorum of one — the machine that created the file — and collection fires immediately, blind to the second machine, the `.bak`, a provider's conflicted copy and any restored snapshot. 2. **Device identity that is actually populated and verified.** `ed25519_pub` and `author_sig` exist in the format and are written empty. An unsigned acknowledgment row is a remote data-destruction primitive: publish "everyone has seen everything", drive collection of one chosen tombstone, then replay a retained pre-deletion copy — targeted, deniable resurrection of one credential with no cryptographic break. 3. **The §8 forward-compatibility machinery.** Unknown manifest fields are dropped by an older client's next save rather than preserved, and the state that would be dropped is exactly the state whose loss causes resurrection. **Collection is not a feature that can be added beside the merge.** It changes what an *absent* record means. Today absence means "the other side has never seen this", which is what lets the one-sided merge arms adopt a record verbatim; after collection it could also mean "deleted and reaped". Every consumer of that meaning changes in the same commit, or a device that was offline across a deletion re-adopts the record verbatim, bypassing §4.4 entirely — no surfaced conflict, no security-tombstone override, no shadow. The deletion is undone on the deleter's own machine, silently and permanently, because the resurrected clock sits above the frontier. **Vclock component pruning must never ship in any form.** §2.4's `author_sig` covers the canonical vclock, so removing a component invalidates a signature the pruning device cannot re-mint. And a pruned `{A:5}` against an unpruned `{A:5,X:3}` reads as "the other side dominates" under absent-is-zero, so §4.4's first rule moves the ancient ciphertext over the current one with no conflict and no shadow. **Why none of this is urgent.** A personal SSH client with a few dozen connections deleting, generously, a hundred a year produces single-digit kilobytes of tombstones a year. Revisit only if tombstones ever become a visible fraction of the manifest. --- ## 5. Migration (plaintext localStorage → vault) Frontend-driven over IPC (platform-agnostic; Windows WebView2 has no `localstorage.sqlite3`). Per record, journaled, idempotent: **write → read-back-verify → erase**. 0. **Preflight (Rust):** probe keychain with a write+read+delete canary using the **device-only** accessibility (§2.5). If unavailable (headless Linux w/o secret service, locked keychain, broken/unsigned ACL) → **abort**: create nothing, erase nothing, warn that secrets remain in plaintext (req 4). 1. **Create vault:** random DEK; device keychain-KEK + Ed25519 keypair (device-only); keychain slot + `DeviceRec`. **Passphrase (or recovery-code) slot is mandatory if the user intends to sync** and must pass the strength gate (§3); a purely local, sync-off vault may be keychain-only but is warned (no second-device / post-wipe recovery). Write header. 2. **Per record** in `r-shell-connections` and each `ConnectionProfile` (incl. raw `privateKey`): JS `invoke('vault_put_record', {recordId, secretBundle})`. Rust writes + fsyncs. 3. **Read-back-verify:** decrypt the just-written record, byte-compare secret fields to source, proceed only on exact match, and record `record_id` in a **migration journal** for crash-safe resume. 4. **Erase:** JS strips secret fields from that record's localStorage JSON (keep `id` + non-secret metadata + `vaulted:true`), writes back. One record at a time. 5. **IPC cutover (req 6):** once all records verify, connect calls stop sending secrets; frontend passes only `connectionId`; Rust resolves at connect time. **Reveal becomes a distinct, user-presence-gated command** (§6). **Secret-bearing IPC handling (Finding: ipc-zeroize, resolved):** `vault_put_record`, the passphrase/recovery inputs, and `reveal` receive each secret field via a **custom deserializer straight into a `Zeroizing<Vec<u8>>`**, bypassing intermediate `serde_json`/`String` copies where possible. We do **not** claim full zeroization: the JS-side value, the WebView→core channel, and freed-but-unwiped heap in serde intermediates are unavoidably non-wiped; §6 states this. Post-cutover, secret-bearing IPC is minimized to reveal + migration only. **Residual-plaintext caveat (surface in UI):** WebKit SQLite has `secure_delete` OFF + a WAL; WebView2 uses ESE — old plaintext pages persist on disk and in prior backups/snapshots and are **not reliably erasable**. Logical erase is best-effort. **The only true remedy for already-leaked secrets is credential rotation.** Offer a "mark for rotation" list (the ~6 connections with passwords today, plus any synced/backed-up ones). --- ## 6. Threat model (honest) **Protects against:** - **Theft of the synced `vault.rvlt` through the sync provider** (iCloud/Dropbox/WebDAV/git) — they see only ciphertext plus coarse metadata (below). Strength of the synced copy = `Argon2id(passphrase)` alone (the keychain-KEK is device-local, §2.5), which is why the strength gate (§3) is mandatory before enabling sync. - **Device backups & snapshots** (Time Machine, iCloud/iTunes device backup, APFS local snapshots, disk images) — **conditionally, and only because** the keychain-KEK / device Ed25519 key / HWM are stored device-only, non-migratory (§2.5). A backup or restored image therefore contains the vault but **not** a usable KEK, so opening it requires the passphrase. The transparent keychain path **deliberately does not survive to another device or a restored backup.** (This corrects the candidate's unconditional claim — Finding: backup-overclaim.) - **Accidental disclosure** — screenshots of the folder, committing the file, sharing it. - **Offline attacker holding the file but not the passphrase** — bounded by Argon2id cost + passphrase entropy. - **Tampering / truncation / record-splicing / cross-vault grafting / algorithm-downgrade / rollback** — per-record AEAD, the BLAKE3-sealed encrypted manifest, `vault_uuid` + immutable per-record AD, key-committing DEK-bound wraps, per-device Ed25519 write signatures, and the causal keychain+sidecar HWM. Bad inbound files are **quarantined, not merged** (§4.6); the client is not stranded. **Does NOT protect against (plainly):** - **Malware / any process running as the same OS user on an unlocked device** — it can request the keychain-KEK exactly as r-shell does and unwrap the DEK, scrape decrypted secrets or the DEK from process memory while unlocked, or key-log the passphrase. This is the deliberate price of transparent local unlock. macOS Keychain ACL is bound to the app's code signature (raises the bar vs *other* apps) but does not stop code injected into our own signed process, a user-approved prompt, or a same-signed/compromised binary. **Because this fork ships a self-hosted re-signing auto-updater, a stable Developer ID / keychain access-group identity is a hard prerequisite for enabling keychain unlock** — see §9. - **A compromised frontend/webview** (XSS, malicious JS dependency, rogue WebView extension) can invoke `reveal`/`connect`. Mitigations, **required not optional** (Finding: reveal-no-reauth): (a) an **OS user-presence / biometric gate** (Touch ID / Face ID via `LocalAuthentication`, Windows Hello) on `reveal` **and** on the first DEK unlock of each session, so the DEK is not silently resident at launch; (b) **connect-time secret resolution scoped to an explicit foreground user action**; (c) reveal calls **rate-limited and audited**. If a platform cannot prompt for presence, **`reveal` does not ship** on it. - **Path-referenced private keys are outside vault protection** (Finding: path-keys). A `privateKeyPath` points at an on-disk key file (e.g. `~/.ssh/id_*`) that stays plaintext on disk and in every backup/snapshot; the vault protects only the pointer. We **offer to import raw key material** into the vault and drop the path — **mandatory for iOS-synced profiles** (no filesystem shell there). - **IPC-transit / JS-side secret copies** are not zeroized (§5). Only vault-resident and Rust-core-resident key material is wiped. - Root/kernel compromise, malicious OS updates, attached debuggers, a device seized while unlocked, RAM scraping, coercion, and a weak passphrase (Argon2id raises but doesn't eliminate offline guessing). - **Secrets already leaked before migration** (non-secure-delete localStorage, old plaintext backups) — only server-side rotation fixes those. - **Suite 2 (AES-256-GCM-SIV), if ever enabled, is not key-committing** on its own; commitment for both suites comes from the external BLAKE3 commitment (§2.3), and this must be verified before enabling suite 2. **Disclosed metadata leakage (corrected — Finding: metadata-precision):** a file thief who holds `vault.rvlt` but not any key observes **only**: the **record-blob count**, each record's **padded size bucket** (256 B for secrets, 1 KiB for key blobs), and each record blob's **plaintext 24-byte nonce**. `record_id`s, timestamps, vector clocks, folders, tags, hosts, usernames, and the device roster are all inside the **encrypted** manifest and records and are **not** observable. (The candidate both over-claimed — listing ids/clocks as visible — and under-claimed by omitting blob count/size; both fixed.) --- ## 7. Rust crates Pure-Rust, iOS/ARM-reusable (same core as the planned Tauri mobile target). No crypto in JS. | Crate | Use | Status | |---|---|---| | `chacha20poly1305` | XChaCha20-Poly1305 (records, manifest, wraps) | already transitive via russh/ironrdp | | `aes-gcm-siv` | reserved suite 2 (NMR); activation = major bump | optional | | `argon2` | Argon2id v1.3 passphrase/recovery KEK | **new** | | `hkdf` + `sha2` | DEK → record/manifest/wrap/commit subkeys | sha2 present | | `blake3` | ct hashes, header seal, DEK-bound commitment | **new** | | `ed25519-dalek` | per-device write signatures (real revocation) | **new** | | `subtle` | constant-time commitment / tag / HWM compares | **new** | | `zeroize` (+ `secrecy`) | wipe DEK/KEK/subkeys/passphrase/SecretBundle | present | | `getrandom` / `rand_core` `OsRng` | DEK, KEKs, salts, nonces | present | | `keyring` **+ `security-framework`** | one device-only OS-keychain entry (KEK, device sk, device_id, HWM). Pin exact non-yanked `keyring` (4.1.3 yanked; MSRV ~1.88); features `apple-native`(mac+iOS)/`windows-native`/`sync-secret-service`+`keyutils`. **Call `security-framework` directly to set `…ThisDeviceOnly`** (stock keyring defaults to `WhenUnlocked`). | **new** | | `zxcvbn` | passphrase strength gate before enabling sync | **new** | | `postcard` | deterministic canonical header/envelope for stable AD | **new** — never authenticate re-serialized serde_json/CBOR | | `serde` + `ciborium` | record/manifest bodies (with verbatim unknown-field preservation, §8) | ciborium new; serde present | | `tempfile` + std rename / `atomic-write-file` (Win `ReplaceFileW`) | atomic writes (local + provider) | new | | `fs4` | advisory write lock | optional | | platform biometric (`LocalAuthentication` / Windows Hello via bindings) | user-presence gate for reveal + session unlock | **new** (required per §6) | **Explicitly not used:** `iota-stronghold` (unmaintained); per-connection keychain entries (CredMan ~2560-B cap + raw keys don't fit) — single DEK + single file instead. --- ## 8. Versioning & upgrade - **`format_major` (u16):** breaking on-disk changes; unknown → **refuse open + refuse write**. Bumped when byte layout or auth rules change incompatibly — **including enabling `cipher_suite_id = 2`** (nonce width / AD change). - **`format_minor` (u16):** additive/back-compatible. Unknown minor is **openable read-write only if lossless round-trip is guaranteed** — unknown additive fields in a record's `payload` and in a manifest entry are preserved **verbatim** in `unknown` CBOR maps, so an older-minor client re-serializes them unchanged. **Implemented** (`ConnectionSecrets::unknown`, `ManifestEntry::unknown`), which is why a higher minor is opened read-write rather than read-only. The header cannot grow additively at all — it is positional postcard, so a new field there is a MAJOR change and is refused. Unknown fields are outside `author_sig` on purpose: a build cannot act on a field it does not know, so signing it buys nothing, and a build that does act on one will know it and sign it (Finding: minor-skew-loss, resolved). - **`cipher_suite_id` (u8):** authenticated → downgrade detected. Suite 2 = major bump (above); the "adding a suite is a minor bump" claim is removed. - **`dek_generation` (u32):** rekey lineage; old-generation records rejected via `AD_record`. - **Per-slot KDF params:** upgraded transparently on unlock when below policy (re-wrap that one slot; O(1)). Lets us raise Argon2 cost over time without a rekey. - **Major migration:** on open, if `format_major < current`, run a one-shot in-place upgrade (read all under old rules, re-serialize under new, single atomic write, VV/`save_seq` bump), keeping `.bak`. Never auto-downgrade. --- ## 9. What we build first (ordering) Ship in strict dependence order; each stage is independently testable. 1. **Crypto core (no I/O):** DEK/subkey derivation, wrap/unwrap with DEK-bound commitment, record + manifest AEAD, canonical postcard header + AD builders. Property tests + KATs. *No storage, no keychain.* 2. **Envelope read/write + atomic file dance** (local only): serialize/parse, tmp→fsync→rename→`.bak`, length/fail-closed validation, quarantine + side-journal. Fuzz the parser (truncation/flip/splice). 3. **Keychain integration, device-only:** `security-framework` direct path for `…ThisDeviceOnly`; canary preflight; store KEK + device Ed25519 key + device_id + HWM. Verify a Time Machine snapshot does **not** expose the KEK. Add the **startup KEK self-test** that, on failure (e.g. auto-updater re-sign orphaned the item), prompts for passphrase and re-creates the keychain slot rather than locking out. 4. **Passphrase + recovery slots + strength gate;** rotate (O(1)) and the separate rekey (O(N)) path; multi-slot open. *Now req 1 is real and testable single-device.* 5. **Migration (frontend-driven, journaled):** put→read-back-verify→erase; Zeroizing IPC deserializer; "mark for rotation" list. Verify against the real ~24-connection dataset. 6. **IPC cutover + user-presence gate:** connect resolves from vault; `reveal` behind biometric/Hello + rate-limit + audit; foreground-scoped connect. *Req 6.* 7. **Sync: atomic provider mirror + causal load/merge:** VV/vclock merge, delete-durable rules, conflict shadows, HWM (keychain+sidecar+`.bak` cross-check), passphrase-gated re-anchor, GC. *Req 3; Pro-gated.* 8. **iOS/Tauri-mobile bring-up:** benchmark Argon2 on oldest supported iPhone; raw-key-in-vault; biometric via `LocalAuthentication`. Stages 1–5 deliver at-rest protection + migration for the existing desktop app without any sync surface; sync (7) and mobile (8) layer on without changing the format. --- ## Open questions (for the human) 1. **iOS Argon2id ceiling** — confirm `m=64 MiB, t=4, p=1` survives jetsam on the oldest supported iPhone under real memory pressure; `46 MiB` fallback only if forced, and only with a mandated high-entropy passphrase. Also decide: when a slot must open on both desktop and iOS, do we store a single mobile-profile slot, or a desktop-cost slot **plus** a mobile-cost slot over the same DEK? 2. ~~**Stable signing identity**~~ — **ANSWERED 2026-08-02 by measurement, during stage 3.** Device-only accessibility (`kSecAttrAccessibleWhenUnlockedThisDeviceOnly`) is an attribute of macOS's **data-protection keychain**, and reaching that keychain requires the `keychain-access-groups` entitlement — i.e. a real Apple Developer signing identity. This fork's ad-hoc-signed binaries get `errSecMissingEntitlement` ("A required entitlement isn't present") when they try. **Decision: transparent keychain unlock does not ship. Passphrase unlock is the only unlock path**, and `vault::keychain` reports `Unavailable` rather than falling back to an ordinary keychain item. The fallback was rejected on purpose: an ordinary item would look like it worked while being copied into Time Machine and iCloud device backups, silently voiding the only thing §6 promises about a stolen backup. `DeviceKeychain::self_test()` proves availability by a real write-read-compare rather than by asking the platform, because on macOS the API is present on every machine and only the code signature decides whether it works. To revisit: obtain a Developer ID **and** the entitlement, **and** confirm the self-hosted updater preserves that identity across updates (otherwise every update orphans the stored KEK, HWM and device key — which is the failure the self-test exists to catch). 3. **Biometric coverage** — is a user-presence prompt available on all target desktops (Touch ID Macs, Windows Hello availability, Linux)? On platforms without one, do we ship `reveal` disabled, or fall back to a passphrase re-prompt? 4. **Delete-durability UX** — confirm the "tombstone-vs-secret-edit → blocking conflict, stays deleted" default is acceptable product behavior, and design the shadow/restore + "mark for rotation" surfacing. 5. **Recovery-code policy** — mandatory recovery-code slot whenever sync is on (belt-and-suspenders against "lost keychain AND forgot passphrase"), or optional? 6. **Sidecar HWM trust** — the local `vault.hwm` sidecar is MAC'd under a keychain-held key, so it dies with the keychain identity too; is the `.bak`-lineage cross-check enough of an independent anchor, or do we want an additional out-of-band anchor (e.g. a small value the user is asked to confirm on first multi-device link)? --- ### Findings dispositions (quick index) Wrap-AD binds mutable header (×4, incl. "self-contradictory O(1)" and "ambiguous bytes"): **fixed §2.3 + §1** — wrap AD is immutable slot-local bytes only; header/slot/VV integrity via manifest AAD. Record-AD binds vclock/tombstone (×3): **fixed §1/§4.4** — moved to manifest. Scalar-generation vs multi-writer; anti-rollback collapse; grace-window (×2); .bak contradiction; provider-drop; refuse-to-write-strands: **fixed §4.5/§4.6** — causal VV, keychain+sidecar+`.bak` HWM, no time window, quarantine + journal, repair re-push. Passphrase-no-revocation: **fixed §2.6** — rotate vs mandatory rekey + `.bak` wipe. Backup-overclaim + keychain accessibility: **fixed §2.5/§6** — device-only `ThisDeviceOnly` via security-framework. Tombstone-resurrect (×3): **fixed §4.4** — delete durable, security-tombstone wins, blocking conflict for re-adds. Revoked-device-forges: **fixed §2.4** — Ed25519 write auth + mandatory rekey. Reveal-no-reauth: **fixed §6/§9** — biometric hard requirement. Path-keys; ipc-zeroize: **fixed §6/§5** — stated as outside-scope / partial, with import offer + Zeroizing deserializer. Auto-updater orphan: **fixed §2.5/§4.5/§9** — stable identity gate + self-test + passphrase re-anchor. Commitment-KCV: **fixed §2.3** — binds DEK+ct. Suite-2-unusable: **fixed §3/§8** — suite-dependent nonce width, major bump. Argon2-underparam: **fixed §3** — t↑, p=1, strength gate. Nonce-wording + metadata-precision: **fixed §3/§6** — "misuse tolerance"; corrected disclosed-metadata line. Major-dup: **fixed §1** — single u16, literal MAGIC. Minor-skew-loss: **fixed §8** — verbatim unknown-field round-trip. Reinstall-device_id + GC: **fixed §4.3/§4.7** — stable Ed25519-derived device_id, specified GC.