Public Access
ADR 0007 decision 4 refused this a commit ago, on the grounds that widening
access to the machine's TPM is a system-wide decision an application package
should not make for an administrator. That refusal is overturned here, and the
reasoning it is replaced with is narrower rather than louder — because the
refusal had treated "widen access" as one thing when it is two.
Adding a person to `tss` is what the refusal was really about, and it stays the
wrong answer: permanent, effective over SSH, applying whether or not they are at
the keyboard, and needing an administrator and a fresh login before the feature
appears at all.
Tagging /dev/tpmrm0 with `uaccess` is a different thing. It is systemd's own
mechanism for this exact question — the one /dev/dri, webcams, scanners and FIDO
keys already use — and logind grants the ACL to the user of the *active local
session*, then takes it back when that session ends. Not over SSH, not on an
inactive VT, not to daemons. The rule changes neither the mode nor the group, so
everything that worked before still works and the ACL is layered on top.
tpmrm and never tpm, which is what makes it defensible at all: /dev/tpm0 is the
raw device and is exclusive, where /dev/tpmrm0 is the kernel's resource manager,
which virtualises handles and isolates callers precisely so that more than one
unprivileged program can use the chip.
◆ THE COST IS A DENIAL OF SERVICE AND NOT A DISCLOSURE, AND IT IS WRITTEN DOWN.
TPM objects are protected by their own auth values, so this lets nobody read
anybody else's sealed data. What it allows is *failed* attempts — and a TPM
answers enough of those by locking out everyone, which is the same anti-hammering
this project relies on to make a four-digit PIN safe. On a machine that unlocks
its disk with the TPM at boot, somebody sitting at it can trip that and stop the
next boot from unlocking until it recovers.
An administrator who does not accept that masks the file:
sudo ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules
which is the standard override, survives upgrades, and puts DodoSSH back to
behaving as though the machine had no TPM. That is in the rule's own comments,
because the person who needs it is reading the file rather than the ADR.
Numbered 71 because 60-tpm-udev.rules is what creates the node's ownership and
73-seat-late.rules is what turns the tag into an ACL; verified in that order on
Fedora 44, along with `udevadm verify` accepting the file and the mask being
reported as "masked by previous entry". %post reloads and replays it against the
tpmrm subsystem, because a rule that waits for a reboot is a feature that does
not work on the day it is installed.
Both channels install the same path deliberately: this is about the machine's
TPM rather than about either build, the file is byte-identical from both, and
giving each its own copy would mean removing one package silently revoked the
other's access.
Phase 19 grew a check that the ACL exists for the local session and does *not*
exist for an SSH one, and another that masking the rule really does put the
machine back — then tells you to undo it before the checks that follow.
Also recorded, after it cost five confusing failures: swtpm serves exactly one
client, so a shell readiness probe sitting on the port makes the whole device-key
suite fail with "The TPM closed the connection" and nothing pointing at why.
250 lines
17 KiB
Markdown
250 lines
17 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.
|
|
|
|
### 4. Linux: sealed into the TPM behind a PIN the TPM checks
|
|
|
|
Added after the Linux head was packaged ([ADR 0013](0013-desktop-distribution-and-updates.md) decision 11).
|
|
The third desktop platform reached this file the way the first two did — by having to answer the amendment
|
|
above — and it is the only one where the answer is not a clean yes.
|
|
|
|
**The mechanism.** The 32-byte device key is sealed into a TPM 2.0 keyedhash object whose `userAuth` is
|
|
derived from a PIN. Unsealing is a command the TPM refuses without it. The blob on disk is encrypted to the
|
|
machine's storage hierarchy, so it is useless on any other machine, and nothing in this application ever
|
|
compares the PIN. The parent is an ECC P-256 primary rederived from a fixed template on every use rather
|
|
than made persistent — a persistent handle would occupy an NV slot forever, need cleaning up on withdrawal,
|
|
and collide with whatever else on the machine chose the same index. ECC rather than RSA because creating an
|
|
RSA primary on a discrete TPM takes seconds and this runs on the unlock path.
|
|
|
|
**The sealed object deliberately does not carry `NoDA`, and that single attribute is what makes a
|
|
four-digit PIN defensible.** Every wrong answer advances the TPM's dictionary-attack counter and the chip
|
|
stops answering entirely once it trips — measured against a software TPM at four wrong tries, after which
|
|
the *correct* PIN gets nothing either. That is the property a short secret has here and cannot have in a
|
|
file, and it is asserted by a test rather than left to a reading of the template, because removing the
|
|
attribute would leave every other assertion in that file passing.
|
|
|
|
◆ **This is weaker than Windows Hello and Touch ID, and the difference is invisible to the user.** Both of
|
|
those raise their prompt from inside the platform — a secure desktop, a system sheet — so the process asking
|
|
never sees the secret. Linux offers nothing to borrow, so the PIN is typed into a window this application
|
|
draws, and an attacker already running as the user could draw a convincing copy of it. Two things bound
|
|
that, and neither erases it: the same attacker could counterfeit the passphrase screen, which is a much
|
|
larger prize, so the feature creates no capability that did not exist; and what they would obtain is a PIN
|
|
that is worthless without this machine's TPM, where the passphrase is worth every machine. **The parity is
|
|
not claimed anywhere in the interface**, which is why `DesktopDeviceKeyStores` documents the ordering and
|
|
why the prompt is deliberately plain rather than dressed to look like a system dialog — teaching people
|
|
that a convincing DodoSSH PIN box is a system dialog is the one thing this design must not do.
|
|
|
|
**Reaching the TPM at all is the other half, and the package now arranges it.** On Fedora `/dev/tpmrm0` is
|
|
`root:tss` mode 0660 and desktop users are not in `tss`, so without help the ordinary case is a working TPM
|
|
this process may not open — which reports unavailable and keeps asking for the passphrase, exactly as a
|
|
machine with no TPM does.
|
|
|
|
◆ **This originally said shipping a udev rule to widen that was refused, on the grounds that it is a
|
|
system-wide decision an application package should not make. That refusal was overturned deliberately, and
|
|
the reasoning it was replaced with is narrower rather than louder.** The refusal treated "widen access" as
|
|
one thing. It is two, and they are not close:
|
|
|
|
- *Adding a person to `tss`* is what the refusal was really about, and it remains the wrong answer. It is
|
|
permanent, it applies whether or not they are at the machine, it applies over SSH, and it needs an
|
|
administrator and a fresh login before the feature appears at all.
|
|
- *Tagging `/dev/tpmrm0` with `uaccess`* is the mechanism systemd already uses for `/dev/dri`, webcams,
|
|
scanners and FIDO keys: logind grants a POSIX ACL to the user of the **active local session** and removes
|
|
it when that session ends. Not over SSH, not on an inactive VT, not to daemons. `build/linux/71-dodossh-tpm.rules`
|
|
is that one line, and it changes neither the mode nor the group, so anything that worked before still does.
|
|
|
|
**`tpmrm` and never `tpm`, which is what makes it defensible at all.** `/dev/tpm0` is the raw device and is
|
|
exclusive; a process holding it can leave the chip in a state that breaks every other user. `/dev/tpmrm0` is
|
|
the kernel's resource manager, which virtualises handles and isolates callers — it exists *because* the TPM
|
|
is meant to be usable by more than one unprivileged program. Granting a seat's user the manager is the use
|
|
it was designed for.
|
|
|
|
**The cost is a denial of service and not a disclosure, and it is stated because it is easy to miss.** TPM
|
|
objects are protected by their own auth values, so this lets nobody read anybody else's sealed data. What it
|
|
does allow is *failed* authorisation attempts — and the chip answers those by counting them and eventually
|
|
refusing everyone, which is the same lockout decision 4 relies on. On a machine whose disk is unlocked by
|
|
the TPM at boot (`systemd-cryptenroll`, Clevis), somebody sitting at it can trip that lockout and stop the
|
|
next boot from unlocking until it recovers. An administrator who does not accept that masks the file with
|
|
`ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules`, which is the standard override, survives upgrades,
|
|
and puts the behaviour back to the passphrase-only path. The rule says so in its own comments, because the
|
|
person who needs to know is reading the file rather than this document.
|
|
|
|
**What was rejected, and why each would have failed the amendment above.** The Secret Service API —
|
|
gnome-keyring or KWallet — is out of process and OS-managed and has *no per-use consent*: once the session
|
|
keyring is unlocked anything running as the user reads the key silently, which is DPAPI's shape exactly and
|
|
what "Why not DPAPI alone" already refuses. `fprintd` is an authentication oracle rather than a key store:
|
|
it answers a question and holds nothing, so skipping the call skips the gate — the amendment's mistake with
|
|
a fingerprint reader attached. A polkit-gated root helper would genuinely work and was declined on surface
|
|
area: it puts a privileged component in a package that installs nothing setuid, to reach a property the TPM
|
|
already provides.
|
|
|
|
## 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, and on Linux it is often there and out of reach.** 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 — and on Linux it is the
|
|
default rather than the exception, because the device node is not readable by an ordinary desktop user
|
|
until somebody says it should be. See decision 4.
|
|
- **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.
|