Public Access
The last of ADR 0007's three pieces, and it does not implement what that ADR originally decided — because writing it exposed a flaw in the decision. The ADR said "a Windows Hello gesture gating a protected blob". That does not deliver what the rest of the document claims for it: a gate inside the process is not a gate. A store that showed a prompt and then read a DPAPI blob would be bypassed by malware that skipped the prompt, read the file and called CryptUnprotectData itself — which is exactly the attacker the whole decision was made against, and exactly the reason DPAPI alone was rejected. The presence requirement has to be a condition of using the key, enforced below the application, or it is decoration. So the device key is encrypted to an RSA key created in the Microsoft Platform Crypto Provider — the TPM — under CngUIProtectionLevels.ProtectKey. Windows requires consent to use that key, so the prompt is not something this code can be talked out of showing. Malware can ask for the key; it cannot answer the dialog. That is strictly stronger than the ADR described, and most of what option D was being saved for: the wrapping key genuinely never leaves hardware. The X25519 device key still lands in memory to open the wrap, because DSH1 fixes that wrap at a curve the TPM cannot do — the remaining gap, and now a smaller step than it was. CngKey is in-box, so this needed no WinRT projection and no Windows target framework. Which is worth stating plainly because the opposite was planned: the piece was scoped as "where the Windows TFM lands", and it turned out a platform guard on one class was enough. Client.App and its two test projects stay on net10.0. Two things were measured on real hardware rather than assumed, and the second changed the shape of the work. The platform provider works here and holds an RSA key — confirmed by creating and deleting one before writing anything that depended on it. And ProtectKey prompts at key *creation*, not only at use. The comment in the first draft of this file said the opposite, with a confident explanation: sealing uses only the public half, so it should be silent. It is not. CngKey.Create blocks on a dialog, because the policy means "protect this key with a PIN" and Windows asks the user to set that up there and then. Found by writing tests around save and forget and watching the suite hang for ten minutes waiting for somebody to type one. That has two consequences worth knowing before touching this file. SaveAsync is user-facing code — it belongs on a UI thread, behind a button somebody pressed, never on a background pass. And almost nothing in the store can be covered automatically: two tests remain, availability and the empty-blob case, both of which provably reach no dialog. Disabling the UI policy to make the rest testable would remove the one property worth having. The interface offers two things and hides both where they cannot work. "Use Windows Hello" appears on the unlock screen only when this machine has a cached wrap and a keystore still willing to release the key; "Use Windows Hello here" appears in the account bar only when the machine can keep a key and has not already registered one, so it is spent once used. Absent rather than disabled, in both cases: a greyed-out button on a machine that never had a TPM reads as something broken, and the passphrase box beside it is not a fallback — it is the ordinary way in. Both unlock paths now share AdoptAsync rather than each opening the known-host store, building the vault and starting auto-sync. The ordering in there is load-bearing and a second copy would be a second chance to get it wrong. The shell's tests drive a fake keystore. Not for speed: the real one prompts on every save and load, so a suite using it would block forever. What the shell has to get right is which buttons appear and what happens when one is pressed, and a fake answers exactly that. It is shared from Client.Session.Tests by source link rather than reimplemented. 882 tests green, 6 of them new. Zero warnings, dotnet format clean. Not verified, and not verifiable here: the dialogs. Whether the consent prompt appears at the right moments, reads sensibly, and returns to a usable window when declined needs the application run by a person on a machine with a TPM. That is the remaining half of outstanding item #7, and it is now the only thing between this feature and being finished.
154 lines
9.2 KiB
Markdown
154 lines
9.2 KiB
Markdown
# 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 TPM-resident key whose use requires the user's consent, with the passphrase kept as a permanent
|
|
fallback.**
|
|
|
|
User presence per unlock is what carries the security value. What changed between this decision and its
|
|
implementation is *who enforces the presence*, and the change was a correction rather than a refinement.
|
|
|
|
> **Amended 2026-07-30.** This section originally read "a Windows Hello gesture gating a protected blob".
|
|
> That design does not deliver what the rest of this document claims for it, and the flaw is worth keeping
|
|
> on the record: **a gate inside the process is not a gate.** A store that showed a Hello prompt and then
|
|
> read a DPAPI blob would be bypassed by malware that skipped the prompt, read the file and called
|
|
> `CryptUnprotectData` itself. The presence requirement has to be a condition of *using the key*, enforced
|
|
> below the application, or it is decoration.
|
|
|
|
So the device key is encrypted to an RSA key created in the **Microsoft Platform Crypto Provider** — the
|
|
TPM — under `CngUIProtectionLevels.ProtectKey`. Windows requires consent to use that key, so the prompt is
|
|
not something this code can be talked out of showing. Malware can ask for the key; it cannot answer the
|
|
dialog, and the attempt is visible. `System.Security.Cryptography.CngKey` is in-box, so this needs no WinRT
|
|
projection and **no Windows target framework** — a plain platform guard is enough.
|
|
|
|
RSA rather than an agreement algorithm because the payload is 32 bytes and OAEP over 2048 bits carries 190.
|
|
That also keeps the DSH1 device wrap unchanged at X25519: the TPM key protects the device key, it does not
|
|
replace it.
|
|
|
|
Availability is probed by creating a throwaway key and deleting it, not by asking whether the provider is
|
|
registered — it is registered on machines with no usable TPM too, and reports itself present right up to
|
|
the point where creating a key fails.
|
|
|
|
### What was measured, and what it cost
|
|
|
|
Two things were verified on real hardware rather than assumed, and one of them changed the design's shape:
|
|
|
|
- **The platform provider works** and holds an RSA key: confirmed by creating and deleting one.
|
|
- **`ProtectKey` prompts at key *creation*, not only at use.** `CngKey.Create` blocks on a dialog, because
|
|
the policy means "protect this key with a PIN" and Windows asks the user to set that up there and then.
|
|
|
|
The second has consequences. Registering a device shows a setup dialog and every unlock shows a consent
|
|
dialog, which is the right shape for an opt-in feature — but it means **`SaveAsync` is user-facing code**
|
|
that belongs on a UI thread behind a button somebody pressed, and it means almost nothing in the store can
|
|
be covered by an automated test. That was found by writing those tests and watching a suite hang for ten
|
|
minutes waiting for a PIN. Two tests remain: availability, and the empty case that provably reaches no
|
|
dialog.
|
|
|
|
### 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 device *wrapping* key now genuinely never leaves the TPM, which is most of what option D promised. What
|
|
remains is that the X25519 device key itself is reassembled in process memory to open the wrap, because DSH1
|
|
fixes that wrap at a curve the TPM cannot do.
|
|
|
|
Closing that last gap means adding a `SealTo` algorithm over a curve the TPM can do — `alg_id = 4` over
|
|
P-256 — so the device key never exists outside hardware at all. That is **the recorded target**, not this
|
|
decision, and it is now a smaller step than it was: the keystore plumbing, the endpoint and the unlock path
|
|
would all be unchanged.
|
|
|
|
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
|
|
|
|
- **A TPM is not always there.** A machine without one gets a store that reports itself unavailable, so
|
|
unlock keeps asking for the passphrase and neither affordance appears in the interface. The passphrase path
|
|
is therefore required, not a nicety.
|
|
- **The stored key must be treated as losable at any time** — a reset PIN, a cleared TPM, a replaced key.
|
|
Every loss degrades to a passphrase prompt and never to a locked-out vault, which is why every failure in
|
|
the store returns null rather than throwing and why the three unlock statuses all end in the same advice.
|
|
- **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.
|