Files
DodoSSH/docs/adr/0007-device-key-protection.md
jaap-jan 890a5f2246
ci / android head (pull_request) Canceled after 0s
ci / desktop nightly (pull_request) Canceled after 0s
ci / api image (pull_request) Canceled after 0s
ci / build and test (pull_request) Canceled after 1m21s
Give the desktop a macOS head, signed from the first release
The same application, the same Velopack and the same two-phase person-run
release as Windows, with four things forced to differ. Signing is a
precondition rather than an improvement: Gatekeeper refuses an
un-notarized download outright instead of warning about it, so there was
never the "unsigned for now" that ADR 0013 decision 8 argues for on
Windows, and release-macos.sh refuses to start without the identities.

The packaging split is narrower than it first looked, and the old claim
at the foot of ci.yml is why it was worth checking rather than assuming.
vpk cross-compiles when told to: 'vpk [osx] bundle' builds a real .app on
any platform, and CI now publishes osx-arm64 and bundles it on every main
and tag build, which is what catches a restore graph with no macOS native
asset. There is no '[osx] pack' off a Mac, and that part is correct — pack
drives codesign, notarytool and stapler, which exist nowhere else.

The dylib signing loop in the script looks redundant beside vpk's own
pass and is not. vpk signs with 'codesign --deep', which is the shape
Apple documents as wrong for nested code, and platform-flags has recorded
a notarization rejection that names no file since before any of this
existed. Signing each native binary inside-out first leaves that pass
nothing to get wrong.

MacDeviceKeyStore reaches ADR 0007's conclusion through different
hardware: a P-256 key in the Secure Enclave under an access control
requiring user presence, so the platform enforces the gate rather than
this process — which is the whole point of that ADR's amendment. The
enclave holds no other kind of key, hence ECIES where Windows uses
RSA-OAEP, and the shape that falls out is better than the Windows one:
sealing needs only the public half and is silent, so only unlock prompts.
IsSupported probes rather than infers, because three ordinary Macs answer
no — an Intel machine without a T2, one with no login password, and every
unsigned development build, since enclave keys need a signing identity.

Two decisions worth stating because they are reversible. arm64 only: a
second channel is small work and nobody here has an Intel Mac to walk
Phase 18 on, and an x64 package would be the only artefact in this
repository reaching users unverified. And the pack id stays
DodoSSH.Desktop even though vpk names the bundle after it, so
/Applications holds DodoSSH.Desktop.app: decision 2's reasoning binds
harder here, because a pack id of DodoSSH would put Velopack's install
root on top of ClientPaths.DataDirectory and let an uninstall take the
user's un-synced outbox with it. CFBundleDisplayName puts the product
name back in front of a person.

Measured rather than assumed, since none of it is obvious: the publish
and the bundle were both run, LSMinimumSystemVersion is 12.0 because that
is the minos in the apphost's own LC_BUILD_VERSION, and vpk copies a
custom Info.plist verbatim with no substitution at all — which is why the
plist is a template the script renders and not a committed file.

What is not done is the half that needs the hardware. There is no macOS
runner, so nothing past "it bundles" has ever run. Phase 18 is the whole
of the verification, and the two checks most likely to fail are the
terminal against WKWebView and the enclave interop, neither of which has
executed once.
2026-08-10 10:43:28 +02:00

173 lines
11 KiB
Markdown

# ADR 0007 — What protects the device key on the desktop
**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.
- **macOS reaches the same decision through different hardware, and the argument transfers intact.**
`MacDeviceKeyStore` puts the wrapping key in the Secure Enclave under an access control requiring user
presence, so Touch ID or the login password is a condition of *using* it and the enforcement is the
platform's rather than the process's — which is the entire point of the 2026-07-30 amendment above, and
the thing a self-drawn prompt over a protected file would fail to be. The mechanical differences are
incidental: P-256 with ECIES because the enclave holds no other kind of key, and no prompt when sealing
because the public half needs no consent. See docs/platform-flags.md for the three ordinary Macs where the
probe answers no, one of which is every unsigned development build.
- **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](0001-e2ee-trust-model.md) 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.