# ADR 0007 — What protects the device key on Windows **Status:** accepted, 2026-07-30 **Supersedes nothing. Constrains** the device-unlock work described in the client roadmap. ## Context Unlock asks for the vault passphrase on every launch, because no device key is registered. The mechanism for one already exists: enrollment can generate an X25519 key pair, seal the `UserSecretBundle` to it (`kind=device` in [crypto.md §3](../crypto.md)) and register the public half with the server. What was never decided is **where the private half lives on this machine**, and that decision is the whole security content of the feature. The device key is not a convenience token. It opens the same 92-byte bundle the passphrase opens — the Ed25519 identity key plus the X25519 key that unwraps every vault key the user holds. It is passphrase-equivalent, and recovery from its compromise is expensive: a passphrase change re-wraps one row, but rotating the bundle means re-sealing every `VaultKey` to a new member key. Three candidates were considered: DPAPI, Windows Hello, and a TPM-resident key. ### The constraint that reshapes the choice DSH1 fixes the device wrap as `SealTo(device_x25519_pk)`. Neither of the two hardware options can hold that key: - **Windows Hello** (`KeyCredentialManager`) produces an RSA key that only *signs*. No key agreement, no decryption. - **The TPM**, through CNG's Platform Crypto Provider, does RSA and the NIST curves. Not X25519. So none of the three can *be* the device key. All three are ways to protect a stored 32-byte X25519 key that still has to be reassembled in process memory to open the wrap. Any claim that "the key never leaves hardware" would be false under all of them. ## Decision **A Windows Hello gesture gating a protected blob, with the passphrase kept as a permanent fallback.** The gesture is what carries the security value: it requires **user presence** per unlock. Hello cannot decrypt, so it is used to gate release of the wrapping key, and the passphrase path remains available unconditionally. ### Why not DPAPI alone DPAPI would be a **regression against the status quo**, which is worth stating plainly because it is the option that looks like the obvious default. Today the root key exists only in the user's head and enters memory only while unlocked. Malware running as the user must keylog the passphrase or scrape memory during a session. With DPAPI alone it reads a file and calls `CryptUnprotectData` — no user present, no keylogging, at any moment. This is the same reason browser cookie theft is trivial. Convenience would have been bought precisely against the attacker most likely to turn up. DPAPI and a raw TPM key both defend the *stolen disk* case, which BitLocker already largely covers. Neither defends the *local malware* case. The gesture does. ### Why not extend the spec (yet) The only option that delivers what the TPM is usually credited with is to add a `SealTo` algorithm over a curve the TPM can do — `alg_id = 4` over P-256 — so the device private key never exists in process memory at all. That is **the recorded target**, not this decision. It is cheaper than "change a frozen spec" sounds, because a device wrap row is read only by the device that created it: not by another client, and not by the server. The envelope already carries `alg_id` and §5 requires readers to fail closed on what they do not understand, so the interop surface is almost nil. Two things to check when it is taken up: `EnrollmentValidation` pins `DevicePublicKey` to `CryptoSpec.PublicKeySize` (32 bytes; a P-256 public key is 33 or 65), and the envelope's minimum-length rule. ## Consequences ### The cache key had to move, and the spec changed `LocalCacheProtector` derived its key from the passphrase master key. A device unlock produces the bundle and never computes a master key, so it could have opened the identity and still not read the cache it had itself written. The derivation now hangs off the bundle — `dsh1/localcache/v1` → `v2`, [crypto.md §3.2](../crypto.md) — so every door reaches the same cache. Two consequences fell out of that, both improvements, neither planned: - **A passphrase change no longer discards the local cache.** The bundle is unchanged by a re-wrap. - **Recovery-code unlock is fixed before it ships.** It derives a different master key from a different secret and salt, so under v1 it would have silently orphaned every cached row. Existing caches become unreadable on upgrade and are discarded and re-pulled, which is the behaviour already specified for a stale cache. ### A stated guarantee weakened [crypto.md §10](../crypto.md) said locking meant "nothing on disk can be read again without the passphrase." Where a device wrap exists that is no longer true, and it would have been untrue under *either* candidate design. The wording now points here. The honest statement is that whatever guards the device key on a machine is as strong as the passphrase for reading that machine's cache. This is why the enrollment screen's sentence — that the passphrase "is the only thing standing between a stolen copy of the database and every credential in your vault" — stays true under this decision and would have become false under DPAPI alone. A gesture is still something the attacker must produce. ### Operational - **Hello is not always available.** No biometric hardware falls back to a Hello PIN, which is TPM-bound and rate-limited and still satisfies the presence requirement. Some machines have no Hello at all. The passphrase path is therefore required, not a nicety. - **Hello keys are invalidated when the PIN is reset**, so the blob must be treated as losable at any time; losing it degrades to a passphrase prompt and never to a locked-out vault. - **Registering a device is a separate act from enrolling one.** `EnrollmentService.AddDevice` runs only during enrollment, so every already-enrolled account — which is all of them — needs an endpoint to add a device wrap while unlocked. Producing the wrap requires the bundle, so the client proves possession by construction. - **Revocation must delete the server row**, and un-enrolling the machine in front of the user must not be able to lock them out: [ADR 0001](0001-e2ee-trust-model.md) makes an enrolled device a recovery path, so it is now load-bearing for more than convenience.