Files
DodoSSH/docs/adr/0007-device-key-protection.md
T
jaap-jan f86791e817 Finish revoking a device, instead of half of it
ForgetDeviceAsync stopped this machine unlocking without a passphrase and left
the server's row exactly where it was, so the account went on listing a device
nobody could account for. ADR 0007 recorded that as a deliberate gap needing an
endpoint. This is the endpoint, and the two things that turned up behind it.

DELETE /api/v1/me/devices/{id}. The device row is not the dangerous half: a
kind=device wrap is the user's identity bundle sealed to a key somebody may be
holding, and that is what has to go. It goes on the foreign key's cascade rather
than a second statement, and RevokeDevice_TakesItsWrapWithIt asserts the cascade
rather than trusting the configuration to keep saying so.

Scoped to the caller's own account, which is the only authorisation check there
is. The id is an unguessable v7 GUID, but unguessable is not a permission —
without the scope one user could withdraw another's device key by pasting an id
they saw once, and the victim's next launch would ask for a passphrase with no
explanation. 404 rather than 403 for somebody else's device, so a stranger does
not learn the id exists.

Never refused for being the last device. ADR 0001 makes an enrolled device a
recovery path, so removing the last one does cost the user something — but the
machine being revoked is most likely the one they have just lost, and a server
that argued about it would be refusing the one request that has to work
immediately. The passphrase wrap is untouched either way, which
RevokeDevice_LeavesThePassphraseWrapAlone pins.

--- Two things found on the way ---

Registering twice from one machine left two devices on the account. The server
is idempotent on the public key, but the client generates a fresh key pair every
call and the keystore holds one — so the second registration orphaned a wrap
whose private half had just been overwritten, which is precisely the leftover
this change exists to remove. Registering now withdraws the previous device.
Found by a test that asserted the property and failed.

And the fakes were lying about it. FakeAccountServer's comment claimed the real
service's idempotence while handing back a fresh Guid on every call, which is
invisible until something revokes by id — at which point a test would be
revoking an id the server never issued, and passing. Both fakes now issue one id
per public key and drop the wrap with the device, as the cascade does.

--- Reachable at all ---

ForgetDeviceAsync had exactly one caller and it was a test, so "Stop unlocking
here" now sits in the account bar where "Use Windows Hello here" was. Its own
flag rather than the negation of that one: a machine with no TPM and a machine
that is already registered are both "cannot register", and only the second has
anything to take back.

No confirmation prompt, deliberately. The cost of pressing it by accident is one
passphrase and one re-registration; the cost of a dialog is a moment's
hesitation at the point somebody has realised a machine is in the wrong hands.

Offline it does the local half and says so rather than refusing. Whether this
machine may unlock itself is decided entirely by the local cache and the local
keystore — the unlock path never asks the server — so forgetting here is what
actually revokes, and "you are offline, so this machine will go on unlocking
itself" would be the worst available answer. DeviceRevocation.LocalOnly is what
the interface reports and the status line explains what is left to do.

The local half runs first for the same reason, and the keystore call is the
first thing in the method that can yield: on Windows it raises a consent dialog,
and a dialog wants the thread it was called from. That ordering is currently
load-bearing and shakier than it looks — see the open device-unlock hang.

Four mutations, all caught: dropping the user scope from the server query
(1 test), skipping the stale-device revoke on re-registration (2), skipping the
server call in ForgetDeviceAsync (2), and the earlier version of the client that
never called it at all.

930 tests green across 16 projects, 13 of them new. Zero warnings, format clean.
2026-07-30 17:33:31 +02:00

10 KiB

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) 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/v1v2, crypto.md §3.2 — 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 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 deletes the server row, through DELETE /api/v1/me/devices/{id}, and the wrap goes with it on the foreign key's cascade. The device row is not the dangerous half: a kind=device wrap left behind is the user's identity bundle still sealed to a key somebody may hold. It is never refused for being the last device — ADR 0001 makes an enrolled device a recovery path, so removing the last one does cost something, but the machine being revoked is most likely the one just lost and a server that argued would be refusing the one request that has to work immediately. The passphrase wrap is untouched, so this can never lock anyone out.
  • Offline revocation does the local half and says so. What decides whether a machine may unlock itself is entirely local — the unlock path never asks the server — so the useful half always happens, and only the account being told can be out of reach.
  • A machine is a device, so registering again replaces rather than adds. The server is idempotent on the public key, but the client generates a fresh key pair each time and the keystore holds one, so a second registration left the account listing a device whose private half had just been overwritten — an orphaned wrap of exactly the kind revocation exists to remove. Registering now withdraws the previous device.