Let the Linux head check for updates, and seal its device key in the TPM
ci / build and test (push) Successful in 2m55s
ci / android head (push) Successful in 3m28s
ci / desktop nightly (push) Successful in 1m10s
ci / api image (push) Successful in 49s

Two things the RPM shipped without, and the reason they arrive together is
that both are the same question — what may this platform ask of the machine,
and what must it not ask of the user.

◆ UPDATES: THIS CLIENT CHECKS AND FETCHES, AND PACKAGEKIT INSTALLS.

ADR 0013 rule 11 said there was no updater at all, and that went too far. It
conflated noticing that a newer build exists with installing it: the second is
what the trust argument is about, and the first is not — a client that checks
a fixed address and tells you what it found has taken no capability from
anybody. Written as a flat refusal, the rule bought its safety by paying a cost
that was avoidable, and the ADR now says so in its own words.

So LinuxUpdateChannel reads the rolling nightly release, compares versions the
way rpm compares them, downloads the package, verifies its digest and hands the
file to PackageKit over gdbus. Polkit asks; the user may say no; nothing is
torn down before the answer, because ApplyingEndsTheProcess is false and the
shell already knows what that means — it is the phone's shape, and declining an
update must not cost somebody their open shells.

Three details that are load-bearing rather than incidental:

  - The comparison is rpmvercmp and not SemVer, because rpm is what will accept
    or refuse the install. Same argument ADR 0014 makes for versionCode. The
    installed side is read out of the rpm database rather than derived from the
    assembly, so the SemVer-to-rpm translation exists in exactly one place. All
    28 expected values in RpmVersionTests came out of rpm itself rather than out
    of a reading of the algorithm.

  - The digest is checked and is not a signature. Velopack verifies its own
    downloads and Android checks a signature; an RPM over HTTPS has neither. The
    manifest names a SHA-256 and a mismatch deletes the file rather than leaving
    something installable-by-hand in the profile directory.

  - ONLY_TRUSTED is cleared, which is what routes the request to polkit's
    package-install-untrusted action. The package is unsigned, so requiring
    trust would fail every update at the last step.

◆ THE DEVICE KEY: SEALED IN THE TPM, BEHIND A PIN THE TPM CHECKS.

ADR 0007's amendment is the bar: a gate inside the process is not a gate. The
key is sealed into a TPM 2.0 keyedhash object whose userAuth comes from a PIN,
so unsealing is a command the chip refuses; the blob is encrypted to the storage
hierarchy and is useless on any other machine; nothing here ever compares a PIN.

The sealed object deliberately carries no NoDA attribute, and that one omission
is the whole reason four digits is enough: every wrong answer advances the TPM's
dictionary-attack counter and the chip stops answering entirely once it trips.
Measured at four wrong tries against a software TPM, after which the correct PIN
gets nothing either. It is asserted by a test rather than left to a reading of
the template, because removing the attribute leaves every other assertion
passing.

This is weaker than Hello and Touch ID and the difference is invisible, so it is
written down in three places rather than smoothed over: those raise their prompt
from inside the platform and this one is a window we draw. The prompt is
deliberately plain for that reason — teaching people that a convincing DodoSSH
PIN box is a system dialog is the one thing this design must not do.

Rejected, each for failing the amendment: the Secret Service API, which has no
per-use consent and is DPAPI's shape exactly; fprintd, which answers a question
and holds nothing, so skipping the call skips the gate; and a polkit-gated root
helper, which would work and puts a privileged component in a package that
installs nothing setuid.

Availability is narrower than "has a TPM": on Fedora /dev/tpmrm0 is root:tss and
desktop users are not in tss, so the ordinary case reports unavailable and keeps
asking for the passphrase. Shipping a udev rule to widen that is refused — it is
the administrator's decision, not an application package's.

◆ WHAT IS ACTUALLY VERIFIED, SINCE THAT IS THE PART THAT USUALLY ISN'T.

This is the only device key store in the repository with real coverage, and it
is only possible because the PIN is a delegate and the TPM is a device: seal,
unseal, wrong PIN, lockout, decline and a foreign blob all run unattended
against a software TPM. Windows and macOS cannot be tested at all — their
prompts block a suite forever, which WindowsDeviceKeyStoreTests records.

TSS.Net's own TcpTpmDevice cannot reach swtpm: it opens the platform socket too
and exchanges a handshake only the Microsoft reference simulator answers, so
Connect() blocks with no error and no timeout. SoftwareTpm speaks the data
channel and nothing else.

The suite also lied once and was fixed: the lockout test trips shared state, and
without a DictionaryAttackLockReset in Dispose every later test got null from a
correct PIN — six of them assert null and passed for entirely the wrong reason.

End to end on a real Fedora 44: a genuine linux-x64 publish packages, installs,
and every DT_NEEDED in the installed tree resolves except liblttng-ust, which is
the one this spec deliberately excludes. What no runner covers is /dev/tpmrm0,
PackageKit and polkit, and Phase 19 grew five checks for exactly those.

Also fixed: the magic-bytes literal picked up raw control characters, which made
LinuxDeviceKeyStore.cs binary to git — no diffs and no line-ending normalisation.
This commit is contained in:
jaap-jan committed 2026-08-22 20:02:30 +02:00
1 parent 1bf79c7c26
commit 4552538533
25 files changed
+2646 -105

No files matched your search

+54 -3
View File
@@ -107,6 +107,55 @@ 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.
**Availability is narrower than "the machine has a TPM", and that is a deployment fact rather than a bug.**
On Fedora `/dev/tpmrm0` is `root:tss` mode 0660 and desktop users are not in `tss`, so the ordinary case is
a working TPM this process may not open. That reports unavailable and the passphrase path continues,
exactly as a machine with no TPM does. **Shipping a udev rule to widen it is refused**: granting every local
desktop user access to the machine's TPM is a system-wide security decision, some distributions restrict it
deliberately, and an application package has no business making it on the administrator's behalf. One line
of `usermod` is the answer, and the README says so.
**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
@@ -138,9 +187,11 @@ would have become false under DPAPI alone. A gesture is still something the atta
### 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.
- **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
@@ -323,20 +323,54 @@ named the two platforms it serves rather than negating the one it does not, on t
AppImage should be an addition here rather than a thing that silently already half-happened". This is that
addition, and it is not an AppImage.
**The package is an RPM and there is no updater in it.** That is the decision, and everything else follows
from it. Velopack does have a Linux path, so the shape of decisions 1 through 9 could have been repeated a
third time — one more channel, one more index, one more client polling the forge. It should not be, because
this platform already has the mechanism that whole apparatus is a substitute for. `dnf` is an updater the
user already trusts, already audits and already controls, and it updates the runtime's own dependencies
besides. Adding a second one that answers to this project instead would be building the capability decision
4 spends a page keeping unsettable, on the one platform where nobody had to.
**The package is an RPM and nothing inside it replaces anything.** That is the decision, and everything
else follows from it. Velopack does have a Linux path, so the shape of decisions 1 through 9 could have been
repeated a third time — one more channel, one more index, one more client swapping its own files over. It
should not be, because this platform already has the mechanism that whole apparatus is a substitute for.
`dnf` is an updater the user already trusts, already audits and already controls, and it updates the
runtime's own dependencies besides. Adding a second one that answers to this project instead would be
building the capability decision 4 spends a page keeping unsettable, on the one platform where nobody had
to.
The honest cost, recorded because it is the part that will be argued with: **discovery is worse here than on
either other desktop.** Windows and macOS clients learn about a new build within six hours without anybody
deciding to look. A Linux user learns when their repository does, and this project publishes no repository,
so today they learn when they next go and look. That is a real regression against the sharpest thing this
ADR bought, accepted because the alternative regression — a second update path with a different authority
behind it — is the one the trust model cannot afford. A package repository is the fix and is not built.
◆ **The first version of this rule said "there is no updater in it at all", and that went too far.** It
conflated two things the other two platforms happen to do together: *noticing* that a newer build exists,
and *installing* it. The second is what the argument above is about, and the first is not — a client that
checks a fixed address and tells you what it found has taken no capability from anybody. Written as a flat
refusal, the rule bought its safety by paying the cost in the paragraph below, and the cost was avoidable.
**So the split is: this client checks and fetches, and PackageKit installs.** `LinuxUpdateChannel` reads
the same rolling release the Windows nightly feed lives on, compares versions the way `rpm` compares them,
downloads the package, verifies its digest, and hands the file to PackageKit — which raises polkit and asks
the user, who may say no. `ApplyingEndsTheProcess` is false and nothing is torn down before the answer, which
is the phone's shape rather than the desktop's, and is already what `MainWindowViewModel` does with that
flag. Four things follow that are worth stating because each is a property the Velopack channels do not have
to think about:
- **The comparison is `rpmvercmp` and not SemVer.** Decision 7 made one version of record for the whole
repository, and this is the one place it is not the string that decides: `rpm` will accept or refuse the
install, so the client must compare the way `rpm` does or it can offer an update PackageKit then declines.
It is the same argument [ADR 0014](0014-android-updates.md) makes for comparing `versionCode`. The
manifest therefore carries both — the RPM version to compare and the SemVer to show a person — and the
installed side is read out of the rpm database rather than derived from the assembly, so the translation
`scripts/ci-linux-rpm.sh` performs exists in exactly one place.
- **The digest is checked, and it is not a signature.** Velopack verifies what it downloads and Android
refuses a package whose signature does not match the installed one. An RPM fetched over HTTPS and handed
to a package manager has neither, so the manifest names a SHA-256 and the client refuses anything else.
That catches a truncated download and a caching proxy and says nothing whatever about provenance, because
the same job writes the package and the manifest. Provenance here is decision 4's constant address.
- **`ONLY_TRUSTED` is cleared, deliberately.** The package is unsigned, so requiring PackageKit to trust it
would fail every update at the last step. Clearing the flag routes the request to polkit's
`package-install-untrusted` action, which asks for an administrator rather than proceeding quietly — the
user is told what they are agreeing to, which is the honest form of an unsigned package.
- **Nothing restarts.** Replacing files under a running process is ordinary on Linux and the old inodes stay
mapped, so the build keeps running until somebody closes it. That is the same courtesy decision 1's
consequences argue for on Windows, arrived at for free.
The honest cost that remains, recorded because it is the part that will be argued with: **discovery is still
worse here than on either other desktop, for anybody who declines the in-app check.** There is no package
repository to subscribe to, so `dnf upgrade` on its own will never bring a newer DodoSSH; the in-app check is
what stands in for one, and a user who turns it off on PREFERENCES → UPDATES has no other signal. A signed
repository is the fix, it is not built, and decision 11's last paragraph says what it would cost.
**CI publishes it, and only to the nightly channel.** Rule 3 is untouched and applies with its full force:
the token that writes a release is the capability to ship somebody a build, and it stays off runners. What
@@ -360,10 +394,15 @@ different paths cannot replace one another and `dnf` says so, where on Windows t
built out of pack ids. The profile split is unchanged and still belongs to the build: `DodoChannel=nightly`
is what `ClientPaths` reads, and the packaging must not answer that question a second time.
**Two things this platform does not have, and they are stated rather than left to be discovered.** There is
no hardware-backed device key store — `DesktopDeviceKeyStores` returns `UnavailableDeviceKeyStore`, so the
passphrase is asked for at every unlock, where Windows has a TPM and a Mac has the Secure Enclave. And there
is no signature on the package, which is not decision 8's "unsigned for now": nothing on this platform
**The device key store is settled separately and is not nothing.** The first version of this rule said this
platform had none and that the passphrase was asked for at every unlock. It has one:
[ADR 0007](0007-device-key-protection.md) decision 4 adds a TPM-sealed key released against a PIN the TPM
itself checks and counts failures against. It is weaker than Hello and Touch ID in a way that ADR names
precisely, and it is available on fewer machines than have a TPM, because the ordinary Fedora desktop user
may not open `/dev/tpmrm0`.
**What this platform does not have is a signature on the package**, which is not decision 8's "unsigned for
now": nothing on this platform
refuses or warns about an unsigned RPM, so there is no dialog to buy a certificate to remove. What stands in
for one is the same thing that stands in for it on the forge — TLS, and a project that publishes from one
address. An RPM repository would change that, because repository metadata is signed and a repository is a
+83 -17
View File
@@ -2844,16 +2844,23 @@ This is the single reason the bundle is named `DodoSSH.Desktop.app`.
## Phase 19 — Installing the Linux client, and the things dnf does not check
The Linux counterpart of phases 16 and 18, and the shortest of the three because the platform takes two of
their questions off the table. There is no updater here, so nothing in this phase asks whether an update
arrives; and there is no signing, because nothing on this platform refuses an unsigned package — a distinct
statement from "unsigned is fine", and `build/linux/dodossh.spec` says what stands in for a signature: the
package comes from the project's own forge over TLS and from nowhere else.
The Linux counterpart of phases 16 and 18. One of their questions is off the table — there is no signing,
because nothing on this platform refuses an unsigned package, which is a distinct statement from "unsigned
is fine"; `build/linux/dodossh.spec` says what stands in for a signature, which is that the package comes
from the project's own forge over TLS and from nowhere else.
What that leaves is the set nobody else can answer. **CI builds this package and installs nothing**, and
every check in `scripts/ci-linux-rpm.sh` is a check on what the package *says* — its dependencies, its file
list, its modes. None of them start the application, and the failures below are all of the shape "installs
perfectly and then does not work".
Everything else is here, and two things are here that neither other phase has. **The update does not
install itself**, so 19.7 is about watching a polkit prompt appear and saying no to it, where phases 16 and
18 watch an application replace itself. And **the device key's gate is a PIN rather than a gesture**, with
the TPM's own lockout behind it — so 19.6 is the one check in this document that deliberately breaks
something and then tells you how to put it back.
What this phase is for is the set nobody else can answer. **CI builds this package and installs nothing**,
and every check in `scripts/ci-linux-rpm.sh` is a check on what the package *says* — its dependencies, its
file list, its modes. The code does better: `LinuxDeviceKeyStoreTests` runs the whole seal, unseal,
wrong-PIN and lockout path against a software TPM, and `LinuxUpdateChannelTests` covers the version
comparison and the digest. What none of them touch is `/dev/tpmrm0`, PackageKit, polkit and a real desktop
session, and the failures below are all of the shape "installs perfectly and then does not work".
Needs a Fedora 44 machine or newer with a graphical session. Build the package first:
@@ -2902,17 +2909,76 @@ are the package's binary name, so they can only disagree if one was edited. A bl
48-pixel file missing and the 256 being scaled down; `dodossh-icon.ps1` draws four sizes and the spec
installs four.
### 19.4 The passphrase is asked for, and no device key is offered
### 19.4 With no access to the TPM, no device key is offered at all
Open the security settings page.
On a stock Fedora install your account is not in the `tss` group, so this is the state most people are in.
Confirm it — `id -nG | tr ' ' '
' | grep -x tss` prints nothing — then open the security settings page.
**Pass:** registering a device key is **not** offered, and unlocking asks for the passphrase every time.
**Failure means:** being offered it is `DesktopDeviceKeyStores.ForThisMachine` having returned something
other than `UnavailableDeviceKeyStore` on a platform with nothing to back it. Accepting the offer would put
a wrap on the server that nothing can ever open.
**Failure means:** being offered it is `LinuxDeviceKeyStore.IsSupported` having answered from the presence
of the device node rather than from opening it. Accepting the offer would put a wrap on the server that
nothing can ever open — and the failure would arrive at the next unlock rather than now.
### 19.5 The nightly installs beside the release rather than over it
### 19.5 With access to the TPM, the PIN unlocks and the TPM is what checks it
`sudo usermod -aG tss $USER`, log out and back in, then register a device key from the security settings
page and choose a PIN. Lock the vault and unlock it again.
**Pass:** registering asks for a PIN twice and shows no other prompt. Unlocking asks for it once and opens
the vault. Cancelling that prompt lands on the passphrase screen, which works.
**Failure means:** no offer after joining the group is `/dev/tpmrm0` still unreadable — check `ls -l` on it
and that the login really was restarted, because group membership is fixed at session start. A PIN prompt
that opens the vault whatever you type would mean the unseal is not reaching the TPM at all, which is the
one failure that would make this whole feature decorative.
### 19.6 A wrong PIN gets nothing, and the TPM stops answering
With a device key registered, lock the vault and enter a wrong PIN. Do it several times.
**Pass:** each attempt lands on the passphrase screen. After a handful, even the **correct** PIN lands
there too — the TPM has entered lockout and is refusing everyone, which is the point.
**Failure means:** wrong PINs that never stop being accepted for the attempt is the `NoDA` attribute having
been set on the sealed object, which would make a four-digit PIN guessable at full speed by anything holding
the blob and the machine. `LinuxDeviceKeyStoreTests` asserts this against a software TPM; this is the same
assertion against real firmware, where the lockout is real and recovery takes hours.
**Then put it back:** `sudo tpm2_dictionarylockout --clear-lockout`, or wait out the recovery time. Note
which, because a user who trips this will ask — and the honest answer is that it needs an administrator.
### 19.7 An update is noticed, fetched, and installed by PackageKit and not by DodoSSH
With the nightly installed, wait for a newer one to be published — or press **CHECK NOW** on
PREFERENCES → UPDATES once there is one.
**Pass:** the banner names the new version and downloads it. Pressing install raises the desktop's own
*"Authentication is required to install software"* prompt, drawn by polkit and not by DodoSSH. Accepting it
installs the package; declining it changes nothing and leaves the vault unlocked and every shell open.
DodoSSH does **not** restart, and the version on the preferences screen does not change until you close and
reopen it.
**Failure means:** an update that is offered and then refused by PackageKit is the version comparison
disagreeing with rpm — `rpm -q --queryformat '%{VERSION}-%{RELEASE}' dodossh-nightly` against the
`rpmVersion` in the release's `linux-nightly.json` is the comparison being made, and `RpmVersionTests`
covers the algorithm. A message naming PackageKit means it is not installed; it is a weak dependency, so a
minimal install legitimately lacks it. **The vault locking or shells closing when you decline is the
serious one** — it means `ApplyingEndsTheProcess` has been set true, which punishes somebody for saying no.
### 19.8 A tampered package is refused before PackageKit ever sees it
Not reachable without editing the feed, so this is the one to reason about rather than perform unless you
are changing that code: the manifest's `sha256` is checked after the download and the file is deleted if it
does not match.
**Pass:** `~/.local/share/dodossh.nightly/updates/` holds a package only when its digest matched.
**Failure means:** a leftover `.rpm` there after a failed update is a file somebody could reasonably install
by hand, named as though the application had endorsed it.
### 19.9 The nightly installs beside the release rather than over it
With the release package installed, install the nightly one too.
@@ -2925,7 +2991,7 @@ nightly that opens the release's vault already unlocked is the profile separatio
`DodoChannel=nightly` reaching `ClientPaths`, not from the packaging, so check
`~/.local/share/dodossh.nightly` exists rather than looking at the spec.
### 19.6 Nothing in the installed tree is writable by anyone but root
### 19.10 Nothing in the installed tree is writable by anyone but root
```bash
find /opt/DodoSSH ! -user root -o -perm -o+w
@@ -2939,7 +3005,7 @@ model walked around rather than attacked. `ci-linux-rpm.sh` refuses to produce s
sets every mode absolutely, so a hit here means one of those two was changed — it cannot arrive by accident
from a publish directory.
### 19.7 Removing it leaves the vault, and leaves the other channel alone
### 19.11 Removing it leaves the vault, and leaves the other channel alone
Register a device, sync something, then `sudo dnf remove dodossh`.