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

+26 -11
View File
@@ -1254,24 +1254,39 @@ jobs:
exit 1 exit 1
fi fi
# ◆ THE PACKAGE FIRST AND THE MANIFEST LAST, WHICH IS THE ORDER THE CLIENT DEPENDS ON.
#
# LinuxUpdateChannel reads linux-<channel>.json and then fetches what it names, exactly as the
# phone reads android-nightly.json. A manifest published before its package is a window in
# which every installed client is told to download something that is not there yet — and the
# client's answer to a missing asset is to throw, which a pressed CHECK NOW reports as a
# failure. Same rule, same reason, and the android job's comment says it first.
rpm='${{ steps.linuxpack.outputs.rpm }}' rpm='${{ steps.linuxpack.outputs.rpm }}'
rpmName='${{ steps.linuxpack.outputs.rpmName }}' manifest='${{ steps.linuxpack.outputs.manifest }}'
echo "Uploading $rpmName ($(wc -c < "$rpm") bytes)"
curl -fsS -X POST -H "$auth" \ linuxNames=""
-F "attachment=@$rpm" \ for file in "$rpm" "$manifest"; do
"$api/releases/$release/assets?name=$rpmName" >/dev/null name="$(basename "$file")"
echo "Uploading $name ($(wc -c < "$file") bytes)"
curl -fsS -X POST -H "$auth" \
-F "attachment=@$file" \
"$api/releases/$release/assets?name=$name" >/dev/null
linuxNames="$linuxNames $name"
done
# Asked for back, exactly as the Velopack assets above are and for the same history: the # Asked for back, exactly as the Velopack assets above are and for the same history: the
# android job once created a release, uploaded nothing, and reported success for every # android job once created a release, uploaded nothing, and reported success for every
# upload it never made. # upload it never made.
withRpm="$(curl -fsS -H "$auth" "$api/releases/$release")" withRpm="$(curl -fsS -H "$auth" "$api/releases/$release")"
if ! printf '%s' "$withRpm" | grep -qF "\"name\":\"$rpmName\""; then for name in $linuxNames; do
echo "The release was created but $rpmName is not on it:" >&2 if ! printf '%s' "$withRpm" | grep -qF "\"name\":\"$name\""; then
printf '%s\n' "$withRpm" >&2 echo "The release was created but $name is not on it:" >&2
exit 1 printf '%s\n' "$withRpm" >&2
fi exit 1
fi
done
echo "Published nightly desktop $VERSION as $TAG, with $rpmName" echo "Published nightly desktop $VERSION as $TAG, with$linuxNames"
image: image:
name: api image name: api image
+22
View File
@@ -173,6 +173,28 @@
chosen user to a known-vulnerable build. See docs/adr/0013-desktop-distribution-and-updates.md. chosen user to a known-vulnerable build. See docs/adr/0013-desktop-distribution-and-updates.md.
--> -->
<PackageVersion Include="Velopack" Version="1.2.0" /> <PackageVersion Include="Velopack" Version="1.2.0" />
<!--
Microsoft's own TPM 2.0 stack, TSS.MSR, and the only way LinuxDeviceKeyStore reaches the chip.
Windows needs nothing here: CNG is in the framework and the Platform Crypto Provider is the TPM
behind it, and macOS reaches the Secure Enclave through the Security framework that
MacSymbols.cs binds by hand. Linux has neither — the platform's own stack is tpm2-tss, a C
library with a large surface — so this is the one head that needs a package to talk to its own
hardware.
Chosen over P/Invoking libtss2-esys for two reasons that both hold at the same time. It is
managed all the way down, so there is no native dependency to add to the RPM and nothing to
resolve at load time; and it works against a software TPM over a socket exactly as it does
against /dev/tpmrm0, which is what makes LinuxDeviceKeyStoreTests able to run the whole seal,
unseal and lockout path unattended. Neither of the other two device key stores can be tested at
all — see WindowsDeviceKeyStoreTests for the ten minutes that took to find out.
It carries a runtimes/linux-x64 asset, like SkiaSharp and libsodium, so the RID-specific publish
picks it up and the RID-free locked restore does not. See the RuntimeIdentifiers note in
DodoSSH.Client.App.csproj for why that distinction is load-bearing here.
-->
<PackageVersion Include="Microsoft.TSS" Version="2.1.1" />
</ItemGroup> </ItemGroup>
<ItemGroup Label="Versioning"> <ItemGroup Label="Versioning">
<!-- <!--
+52 -23
View File
@@ -71,7 +71,7 @@ src/
DodoSSH.Client.ObjectStore S3-compatible buckets, behind the same interface as SFTP DodoSSH.Client.ObjectStore S3-compatible buckets, behind the same interface as SFTP
DodoSSH.Client.Import reading ~/.ssh/config, and the keys it names when asked DodoSSH.Client.Import reading ~/.ssh/config, and the keys it names when asked
DodoSSH.Client.Shell the view models both heads drive, the renderer's files, the palette DodoSSH.Client.Shell the view models both heads drive, the renderer's files, the palette
DodoSSH.Client.App the desktop head: its views, and its Windows integration DodoSSH.Client.App the desktop head: its views, and its Windows, macOS and Linux integration
DodoSSH.Client.Android the phone head: its views, and its Android integration DodoSSH.Client.Android the phone head: its views, and its Android integration
tests/ one test project per source project tests/ one test project per source project
docs/adr/ architecture decision records docs/adr/ architecture decision records
@@ -81,7 +81,9 @@ docs/manual-checks.md what no test can reach, and what to look for when che
docs/android-port.md the Android head: what was decided, what is built, what is left docs/android-port.md the Android head: what was decided, what is built, what is left
scripts/ release-windows.ps1 — builds, packs and publishes the Windows client scripts/ release-windows.ps1 — builds, packs and publishes the Windows client
release-macos.sh — the same, signed and notarized, on a Mac release-macos.sh — the same, signed and notarized, on a Mac
ci-linux-rpm.sh — builds the Linux .rpm, then interrogates it
build/macos/ the entitlements and Info.plist template the macOS bundle is built from build/macos/ the entitlements and Info.plist template the macOS bundle is built from
build/linux/ the RPM spec the Linux package is built from
``` ```
Everything under `src/DodoSSH.Client.*` except the two heads and `Shell` is deliberately free of Avalonia. Everything under `src/DodoSSH.Client.*` except the two heads and `Shell` is deliberately free of Avalonia.
@@ -189,19 +191,38 @@ sudo dnf install ./dodossh-nightly-*.x86_64.rpm
Everything above about where a client may come from applies unchanged. Four things differ, and the first Everything above about where a client may come from applies unchanged. Four things differ, and the first
two are the ones to read before installing. two are the ones to read before installing.
**There is no self-updater, and that is the design rather than a gap.** The Windows and macOS builds carry **It checks for updates and it installs nothing.** The Windows and macOS builds carry Velopack and replace
Velopack and replace themselves; this one carries nothing of the sort and never contacts an update service. their own files; this one never does. It checks the project's forge every six hours, and when there is a
On Linux the package manager is already an updater the person running it trusts, audits and controls, and newer build it downloads it, checks its digest, and hands the file to **PackageKit** — which raises the
adding a second one that answers somewhere else would be handing back exactly the capability usual "authentication is required to install software" prompt. You may say no, and nothing is replaced until
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) rule 4 spends a page keeping out of reach. you say yes. Nothing restarts either: replacing files under a running process is ordinary here, so the build
The cost is real and worth stating plainly: this project publishes no package repository, so nothing tells you have keeps running until you close it.
you when a newer build exists. You install the next one the way you installed this one.
**Your passphrase is asked for every time, because there is no hardware key store here.** Windows has a TPM That split is the point rather than a compromise. Installing software is your package manager's job and it
behind a consent dialog and a Mac has the Secure Enclave behind Touch ID; the Linux head has neither wired already answers to you; noticing that a build exists is the part `dnf` cannot do for a package that is not
up, so the "unlock without the passphrase" path is simply not offered — the application knows it has no in a repository. What DodoSSH must never become is a second thing that can put software on your machine on
store and does not pretend otherwise. See somebody else's say-so, which is why the address it checks is a constant in the binary and can never be a
[docs/unlocking-without-the-passphrase.md](docs/unlocking-without-the-passphrase.md). DodoSSH server — [ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) rules 4 and 11.
The gap that remains: there is **no package repository**, so `dnf upgrade` alone will never bring you a
newer DodoSSH. If you turn the check off on PREFERENCES → UPDATES, nothing else will tell you.
**Your PIN can stand in for your passphrase, if this machine has a TPM you can reach.** The device key is
sealed into the TPM and released only against a PIN that **the TPM itself checks** — it counts wrong answers
and stops responding after a few, which is what makes four digits enough and what makes a stolen disk
useless. Two honest caveats, both in
[ADR 0007](docs/adr/0007-device-key-protection.md) decision 4:
- **On most Fedora installs this is not offered**, because `/dev/tpmrm0` belongs to `root:tss` and your
account is not in `tss`. DodoSSH will not widen that for you — granting every local user access to the
machine's TPM is the administrator's call. If you want it: `sudo usermod -aG tss $USER`, then log out and
back in.
- **It is weaker than Windows Hello or Touch ID**, and the difference does not show. Those draw their prompt
from inside the operating system; this one is a window DodoSSH draws, so something already running as you
could imitate it. It could imitate the passphrase screen too, which is worth far more — but the guarantee
is not the same and this page will not pretend it is.
See [docs/unlocking-without-the-passphrase.md](docs/unlocking-without-the-passphrase.md).
**It needs WebKitGTK, because the terminal is a WebView.** Specifically `webkit2gtk4.1`, which `dnf` pulls **It needs WebKitGTK, because the terminal is a WebView.** Specifically `webkit2gtk4.1`, which `dnf` pulls
in for you. Fedora 44 is the distribution the head has actually been run on. RHEL 9 and its rebuilds ship in for you. Fedora 44 is the distribution the head has actually been run on. RHEL 9 and its rebuilds ship
@@ -969,21 +990,29 @@ keychain plus a terminal — and the spike that gates all of it.
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) decision 10 and [ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) decision 10 and
[Installing on macOS](#installing-on-macos). [Installing on macOS](#installing-on-macos).
**The Linux half is a package rather than an updater.** An `.rpm` for x86_64, built and checked on every **The Linux half checks, and the package manager installs.** An `.rpm` for x86_64, built and checked on
push to `main` and published to the nightly release, installing to `/opt` with a menu entry and a `dodossh` every push to `main` and published to the nightly release, installing to `/opt` with a menu entry and a
launcher. It carries no self-updater at all and contacts no update service — on this platform `dnf` is `dodossh` launcher. It carries no Velopack and replaces nothing itself: it notices a newer build on the
already an updater the user controls, and a second one answering somewhere else is the capability the project's forge, downloads it, verifies its digest and hands the file to PackageKit, which asks before it
trust model exists to keep out of reach. It also has no hardware-backed device key store, so the installs. Splitting it that way keeps the part that can put software on your machine where it already
passphrase is asked for every unlock. See was — with your package manager — while fixing the half `dnf` cannot do for a package that is in no
repository. The device key is sealed into the TPM behind a PIN **the TPM checks and counts failures
against**, which is what makes four digits enough; on most Fedora installs it is not offered at all,
because an ordinary user may not open `/dev/tpmrm0` and this package will not widen that for them. See
[ADR 0007](docs/adr/0007-device-key-protection.md) decision 4,
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) decision 11 and [ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) decision 11 and
[Installing on Linux](#installing-on-linux). [Installing on Linux](#installing-on-linux).
Still to do here: Windows signing (the first Windows release is unsigned, and the trigger for buying a Still to do here: Windows signing (the first Windows release is unsigned, and the trigger for buying a
certificate is the first release aimed at strangers), macOS on Intel, a `v*`-tagged Linux release and a certificate is the first release aimed at strangers), macOS on Intel, a `v*`-tagged Linux release and a
package repository to serve it from, a Linux device key store, and Phases 18 and 19 of the manual signed package repository to serve it from, and Phases 18 and 19 of the manual checks — neither the macOS
checks — neither the macOS nor the Linux build has been walked through by a person. There is no macOS nor the Linux build has been walked through by a person. There is no macOS runner in CI at all. The Linux
runner in CI at all, and the Linux package is verified only as far as what it *says* about itself: CI package is verified as far as what it *says* about itself — CI builds it and interrogates its
builds it and interrogates its dependencies, its file list and its modes, and installs nothing. dependencies, its file list and its modes, and installs nothing — and the two pieces of Linux behaviour
that are code rather than packaging do have tests: the update channel's version comparison and digest
check run in CI, and the TPM store's seal, unseal, wrong-PIN and lockout paths run against a software TPM
anybody can start with one `docker run`. What no runner covers is `/dev/tpmrm0`, PackageKit and polkit,
which is what Phase 19 is for.
- **M5 — multi-provider OIDC**, identity key rotation, per-item content keys. - **M5 — multi-provider OIDC**, identity key rotation, per-item content keys.
## Licence ## Licence
+28 -12
View File
@@ -11,17 +11,18 @@
# a newer build and swaps itself over. ADR 0013 rule 4 is what makes that safe to hold — the feed # a newer build and swaps itself over. ADR 0013 rule 4 is what makes that safe to hold — the feed
# address is a const, so no operator can answer "is there a newer version" on the user's behalf. # address is a const, so no operator can answer "is there a newer version" on the user's behalf.
# #
# This package has no updater in it at all, and that is deliberate rather than unfinished. # This package replaces nothing and installs nothing, and that is deliberate rather than unfinished.
# VelopackUpdateChannel.ForThisMachine already refuses every platform that is not Windows or macOS, # On Linux the package manager is already an updater the user trusts, audits and controls, so putting
# and it says why in a comment worth not undoing: a client handed an UpdateManager pointed at a feed # a second one inside the application — answering to this project instead — would be building the
# carrying nothing it can apply is a client that reports itself current forever. On Linux the # capability ADR 0013 rule 4 spends a page making unsettable, on the one platform where nobody had to.
# package manager is the updater — `dnf upgrade` is a mechanism the user already trusts, already
# audits and already controls — so the honest thing is to be an ordinary package and let it do that,
# rather than to bolt on a second one that answers to a different authority.
# #
# The consequence to know: an installed RPM learns about a new version when its repository does, and # What the client does do is the half a package manager cannot: LinuxUpdateChannel notices that a
# this project publishes no repository yet. Somebody who downloaded the file installs the next one # newer build exists on the project's forge, fetches it, checks its digest, and hands the file to
# the same way. # PackageKit — which raises polkit and asks. The user may say no, and nothing is replaced until they
# say yes. See ADR 0013 rule 11.
#
# The consequence to know: discovery is worse here than on the other two desktops, because there is
# no package repository to subscribe to and the in-app check is what stands in for one.
# ================================================================================================== # ==================================================================================================
# ◆ THE THREE THINGS THAT MUST BE TURNED OFF, AND WHAT EACH ONE WOULD DO. # ◆ THE THREE THINGS THAT MUST BE TURNED OFF, AND WHAT EACH ONE WOULD DO.
@@ -150,13 +151,28 @@ Requires: libicu
# again when the last thing using them goes. # again when the last thing using them goes.
Requires: hicolor-icon-theme Requires: hicolor-icon-theme
# ◆ RECOMMENDED RATHER THAN REQUIRED, WHICH IS A JUDGEMENT ABOUT WHAT THE PACKAGE IS FOR.
#
# PackageKit is what LinuxUpdateChannel hands a downloaded update to, so without it the update path
# stops at the last step. It is not required, for two reasons that both point the same way: dnf
# installs weak dependencies by default, so the ordinary Fedora install gets it anyway; and a machine
# deliberately built without it — a minimal or an immutable one — should still be able to install an
# SSH client. The failure is not silent, which is what makes this safe: the channel names the missing
# service and the file it fetched, so the answer is one command the user was going to run anyway.
#
# Nothing is required for the TPM. LinuxDeviceKeyStore talks to /dev/tpmrm0 through a managed stack,
# so there is no library to depend on, and a machine with no TPM — or one whose device node this user
# may not open, which is the ordinary Fedora case — simply keeps asking for the passphrase.
Recommends: PackageKit
%description %description
DodoSSH is an SSH and SFTP client for teams whose credentials are shared and whose infrastructure is DodoSSH is an SSH and SFTP client for teams whose credentials are shared and whose infrastructure is
not. Hosts, keys and passphrases live in a vault that is encrypted on this machine and stays not. Hosts, keys and passphrases live in a vault that is encrypted on this machine and stays
encrypted on the server, so the deployment holding a team's secrets can never read them. encrypted on the server, so the deployment holding a team's secrets can never read them.
This package carries its own .NET runtime and is updated the way every other package on this system This package carries its own .NET runtime. It checks the project's own forge for newer builds and can
is; it contains no self-updater and contacts no update service. download one, but it installs nothing itself: an update is handed to PackageKit, which asks before it
replaces anything. The address it checks is fixed and is never a DodoSSH server.
%install %install
rm -rf %{buildroot} rm -rf %{buildroot}
+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 `DevicePublicKey` to `CryptoSpec.PublicKeySize` (32 bytes; a P-256 public key is 33 or 65), and the
envelope's minimum-length rule. 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 ## Consequences
### The cache key had to move, and the spec changed ### 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 ### Operational
- **A TPM is not always there.** A machine without one gets a store that reports itself unavailable, so - **A TPM is not always there, and on Linux it is often there and out of reach.** A machine without one gets
unlock keeps asking for the passphrase and neither affordance appears in the interface. The passphrase path a store that reports itself unavailable, so unlock keeps asking for the passphrase and neither affordance
is therefore required, not a nicety. 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.** - **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 `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 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 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. 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 **The package is an RPM and nothing inside it replaces anything.** That is the decision, and everything
from it. Velopack does have a Linux path, so the shape of decisions 1 through 9 could have been repeated a else follows from it. Velopack does have a Linux path, so the shape of decisions 1 through 9 could have been
third time — one more channel, one more index, one more client polling the forge. It should not be, because repeated a third time — one more channel, one more index, one more client swapping its own files over. It
this platform already has the mechanism that whole apparatus is a substitute for. `dnf` is an updater the should not be, because this platform already has the mechanism that whole apparatus is a substitute for.
user already trusts, already audits and already controls, and it updates the runtime's own dependencies `dnf` is an updater the user already trusts, already audits and already controls, and it updates the
besides. Adding a second one that answers to this project instead would be building the capability decision runtime's own dependencies besides. Adding a second one that answers to this project instead would be
4 spends a page keeping unsettable, on the one platform where nobody had to. 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 ◆ **The first version of this rule said "there is no updater in it at all", and that went too far.** It
either other desktop.** Windows and macOS clients learn about a new build within six hours without anybody conflated two things the other two platforms happen to do together: *noticing* that a newer build exists,
deciding to look. A Linux user learns when their repository does, and this project publishes no repository, and *installing* it. The second is what the argument above is about, and the first is not — a client that
so today they learn when they next go and look. That is a real regression against the sharpest thing this checks a fixed address and tells you what it found has taken no capability from anybody. Written as a flat
ADR bought, accepted because the alternative regression — a second update path with a different authority refusal, the rule bought its safety by paying the cost in the paragraph below, and the cost was avoidable.
behind it — is the one the trust model cannot afford. A package repository is the fix and is not built.
**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: **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 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` 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. 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 **The device key store is settled separately and is not nothing.** The first version of this rule said this
no hardware-backed device key store — `DesktopDeviceKeyStores` returns `UnavailableDeviceKeyStore`, so the platform had none and that the passphrase was asked for at every unlock. It has one:
passphrase is asked for at every unlock, where Windows has a TPM and a Mac has the Secure Enclave. And there [ADR 0007](0007-device-key-protection.md) decision 4 adds a TPM-sealed key released against a PIN the TPM
is no signature on the package, which is not decision 8's "unsigned for now": nothing on this platform 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 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 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 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 ## 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 The Linux counterpart of phases 16 and 18. One of their questions is off the table — there is no signing,
their questions off the table. There is no updater here, so nothing in this phase asks whether an update because nothing on this platform refuses an unsigned package, which is a distinct statement from "unsigned
arrives; and there is no signing, because nothing on this platform refuses an unsigned package — a distinct is fine"; `build/linux/dodossh.spec` says what stands in for a signature, which is that the package comes
statement from "unsigned is fine", and `build/linux/dodossh.spec` says what stands in for a signature: the from the project's own forge over TLS and from nowhere else.
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 Everything else is here, and two things are here that neither other phase has. **The update does not
every check in `scripts/ci-linux-rpm.sh` is a check on what the package *says* — its dependencies, its file install itself**, so 19.7 is about watching a polkit prompt appear and saying no to it, where phases 16 and
list, its modes. None of them start the application, and the failures below are all of the shape "installs 18 watch an application replace itself. And **the device key's gate is a PIN rather than a gesture**, with
perfectly and then does not work". 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: 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 48-pixel file missing and the 256 being scaled down; `dodossh-icon.ps1` draws four sizes and the spec
installs four. 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. **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 **Failure means:** being offered it is `LinuxDeviceKeyStore.IsSupported` having answered from the presence
other than `UnavailableDeviceKeyStore` on a platform with nothing to back it. Accepting the offer would put of the device node rather than from opening it. Accepting the offer would put a wrap on the server that
a wrap on the server that nothing can ever open. 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. 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 `DodoChannel=nightly` reaching `ClientPaths`, not from the packaging, so check
`~/.local/share/dodossh.nightly` exists rather than looking at the spec. `~/.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 ```bash
find /opt/DodoSSH ! -user root -o -perm -o+w 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 sets every mode absolutely, so a hit here means one of those two was changed — it cannot arrive by accident
from a publish directory. 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`. Register a device, sync something, then `sudo dnf remove dodossh`.
+46 -2
View File
@@ -309,12 +309,56 @@ printf '%s\n' "$requires" | sed 's/^/ /'
echo "==> $(basename "$rpm_file") is a $name $rpm_version package and says so." echo "==> $(basename "$rpm_file") is a $name $rpm_version package and says so."
# The one line the calling job reads. Written to stdout as well as to GITHUB_OUTPUT when there is one, # ◆ THE MANIFEST, WHICH IS WHAT AN INSTALLED CLIENT READS AND NOT WHAT A PERSON READS.
# so the desk use above still tells you where the file went. #
# The same arrangement the android job publishes, for the same reason its comment gives: a release's own
# JSON is a large document that changes shape when the forge is upgraded, and the client fetches this on a
# six-hourly timer. A small file this repository writes is a contract; a large one the forge writes is not.
#
# ◆ THE VERSION IN HERE IS THE RPM VERSION, AND THAT IS THE WHOLE POINT OF THE FILE.
#
# LinuxUpdateChannel compares with rpmvercmp, because rpm is what will accept or refuse the install — the
# same argument AndroidChannelManifest makes for versionCode. Publishing the SemVer instead would mean the
# client comparing one thing and the package manager another, and they disagree exactly where it matters:
# 0.0.1-alpha.0.143 and 0.0.1~alpha.0.143 are the same build, and only the second one sorts below 0.0.1.
#
# The SemVer goes in too and is used for nothing but the banner. A person asked which nightly they are on
# should be able to answer with the string the preferences screen prints, and that screen reads the
# assembly.
#
# ◆ AND THE DIGEST, WHICH THE OTHER TWO CHANNELS HAVE NO NEED OF.
#
# Velopack verifies what it downloads and Android refuses a package whose signature does not match the
# installed one. Nothing about an RPM fetched over HTTPS and handed to PackageKit checks that it is the
# file this release published — TLS authenticates the forge and says nothing about the bytes surviving a
# proxy, a cache or a partial write. So the manifest names the digest and the client refuses anything else.
#
# It is not a signature and must not be read as one: it is written by the same job that writes the package,
# so it proves integrity and not provenance. Provenance here is that the address is a constant pointing at
# the project's own forge — ADR 0013 rule 4 — and a signed repository is what would improve on it.
manifest="$out/linux-$channel.json"
digest="$(sha256sum "$rpm_file" | cut -d' ' -f1)"
cat > "$manifest" <<JSON
{
"rpmVersion": "$rpm_version-$rpm_release",
"version": "$version",
"rpm": "$(basename "$rpm_file")",
"sha256": "$digest"
}
JSON
echo "==> $(basename "$manifest"):"
sed 's/^/ /' "$manifest"
# The lines the calling job reads. Written to stdout as well as to GITHUB_OUTPUT when there is one, so the
# desk use above still tells you where the file went.
if [ -n "${GITHUB_OUTPUT:-}" ]; then if [ -n "${GITHUB_OUTPUT:-}" ]; then
{ {
echo "rpm=$rpm_file" echo "rpm=$rpm_file"
echo "rpmName=$(basename "$rpm_file")" echo "rpmName=$(basename "$rpm_file")"
echo "manifest=$manifest"
echo "manifestName=$(basename "$manifest")"
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
fi fi
echo "$rpm_file" echo "$rpm_file"
+16 -3
View File
@@ -113,11 +113,12 @@ internal sealed partial class DodoSshApp : Application
var browser = new SystemBrowserLauncher(); var browser = new SystemBrowserLauncher();
// Both chosen once, here, because each is a property of the machine rather than of any session. A // Both chosen once, here, because each is a property of the machine rather than of any session. A
// computer with a usable TPM gets the store that keeps a device key behind a Windows consent prompt; // Windows box with a usable TPM gets a consent prompt, a Mac with a Secure Enclave gets Touch ID, a
// anything else gets one that reports itself unavailable, so unlock keeps asking for the passphrase // Linux box whose TPM this user may open gets a PIN the TPM checks — see PinPrompt — and anything
// else gets a store that reports itself unavailable, so unlock keeps asking for the passphrase
// (ADR 0007). The update channel answers the same shape of question about how this copy was // (ADR 0007). The update channel answers the same shape of question about how this copy was
// installed, and a build run from a checkout likewise gets one that says so. See ADR 0013. // installed, and a build run from a checkout likewise gets one that says so. See ADR 0013.
var deviceKeys = DesktopDeviceKeyStores.ForThisMachine(paths); var deviceKeys = DesktopDeviceKeyStores.ForThisMachine(paths, PinPrompt(desktop));
var updates = UpdateChannels.ForThisMachine(); var updates = UpdateChannels.ForThisMachine();
var viewModel = new MainWindowViewModel( var viewModel = new MainWindowViewModel(
@@ -152,6 +153,18 @@ internal sealed partial class DodoSshApp : Application
WireShutdown(desktop, viewModel, workspace, caches); WireShutdown(desktop, viewModel, workspace, caches);
} }
/// <summary>How the Linux device key store asks for a PIN, and the only thing that ever calls it.</summary>
/// <remarks>
/// The lifetime is captured rather than the window, so this can be composed before there is one —
/// composition runs first, and <c>DevicePinWindow.AskAsync</c> answers null when there is nothing to
/// sit over. Windows and macOS never reach it: on those platforms the consent dialog is the operating
/// system's and this process is not in the conversation. See ADR 0007.
/// </remarks>
private static Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> PinPrompt(
IClassicDesktopStyleApplicationLifetime desktop) =>
(purpose, cancellationToken) =>
DevicePinWindow.AskAsync(() => desktop.MainWindow, purpose, cancellationToken);
/// <remarks> /// <remarks>
/// Shutdown is deferred rather than blocked on. Sessions hold SSH connections and a listening socket, and /// Shutdown is deferred rather than blocked on. Sessions hold SSH connections and a listening socket, and
/// blocking the UI thread on their disposal is how an application comes to take several seconds to close — /// blocking the UI thread on their disposal is how an application comes to take several seconds to close —
@@ -151,6 +151,14 @@
interface declared in DodoSSH.Client.Session. interface declared in DodoSSH.Client.Session.
--> -->
<PackageReference Include="Velopack" /> <PackageReference Include="Velopack" />
<!--
Here for the same reason and with the same boundary as Velopack above: LinuxDeviceKeyStore is a
Linux-only implementation of an interface declared in DodoSSH.Client.Session, and it lives in
Platform/ beside the Windows and macOS stores. The Android head has its own keystore and must
never see this one.
-->
<PackageReference Include="Microsoft.TSS" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -0,0 +1,485 @@
using System.Buffers.Binary;
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text;
using DodoSSH.Client.Session;
using Tpm2Lib;
namespace DodoSSH.Client.App.Platform;
/// <summary>Why the store is asking for a PIN.</summary>
/// <remarks>
/// The two cases need different words in front of a person — one is choosing a PIN and the other is
/// entering one — and the store is the only thing that knows which is happening. Passing an enum rather
/// than two delegates keeps the prompt a single seam.
/// </remarks>
public enum DevicePinPurpose
{
/// <summary>A PIN is being chosen, as part of registering this machine.</summary>
Choosing,
/// <summary>A PIN is being asked for, to unlock.</summary>
Entering,
}
/// <summary>
/// Keeps the device key sealed inside the TPM, released only against a PIN the TPM itself checks.
/// </summary>
/// <remarks>
/// <para>
/// The Linux answer to <see cref="WindowsDeviceKeyStore"/> and <c>MacDeviceKeyStore</c>, and ADR 0007's
/// bar is the one it has to clear: <b>the enforcement must be the platform's rather than this process's.</b>
/// A store that drew its own prompt over a file it could read anyway would be the mistake that ADR's
/// 2026-07-30 amendment exists to record — malware running as the user skips the prompt and reads the file.
/// </para>
/// <para>
/// <b>Here the TPM holds the key and the TPM checks the PIN.</b> The 32-byte device key is sealed into a
/// keyedhash object whose <c>userAuth</c> is derived from the PIN, so unsealing is a command the TPM
/// refuses without it. The blob on disk is encrypted to the TPM's storage hierarchy and is useless on any
/// other machine, and the PIN is never compared by anything in this assembly. Malware running as the user
/// can ask; it cannot answer.
/// </para>
/// <para>
/// <b>And the TPM counts the failures, which is what makes a short PIN safe.</b> The sealed object
/// deliberately does not carry <c>NoDA</c>, so every wrong answer advances the dictionary-attack counter
/// and the TPM stops answering at all once it trips. Measured against a software TPM: four wrong PINs and
/// it refuses the right one too, until the lockout recovers. That is the property a four-digit secret has
/// on this platform and cannot have in a file.
/// </para>
/// <para>
/// ◆ <b>What this does NOT match, stated because the difference is real and invisible.</b> Windows Hello
/// and Touch ID raise their prompt from the platform — a secure desktop on one, a system sheet on the
/// other — so the process asking never sees the secret. Linux has no equivalent to borrow, so the PIN is
/// typed into a window this application draws, and a sufficiently determined attacker already running as
/// the user could draw a convincing copy of it. The mitigation is that they could do the same for the
/// passphrase screen, which is a much larger prize, so this is not a capability the feature creates. It is
/// still a weaker guarantee than the other two desktops have and ADR 0007 says so rather than implying
/// parity.
/// </para>
/// <para>
/// <b>Availability is narrower than "the machine has a TPM".</b> On Fedora <c>/dev/tpmrm0</c> is owned by
/// <c>root:tss</c> and mode 0660, and desktop users are not in <c>tss</c> — so the ordinary case is a
/// machine with a working TPM this process may not open. That reports unavailable and the passphrase path
/// continues, exactly as a machine with no TPM does. Widening the device's permissions is deliberately not
/// something this application's package does; see ADR 0007.
/// </para>
/// </remarks>
[SupportedOSPlatform("linux")]
public sealed class LinuxDeviceKeyStore : IDeviceKeyStore
{
/// <summary>What the blob on disk starts with, so a file from elsewhere is refused rather than parsed.</summary>
private static readonly byte[] Magic = "DSHTPMv1"u8.ToArray();
/// <summary>
/// Domain separation for the PIN, so the value handed to the TPM is this application's and not the
/// user's PIN as typed.
/// </summary>
/// <remarks>
/// The TPM compares an authValue of at most one digest in length, so something has to reduce a typed
/// string to bytes; hashing with a label rather than truncating means a PIN reused elsewhere does not
/// arrive at the TPM as the same bytes it does there. It buys nothing against an attacker who has the
/// blob and the machine — they have this constant too — and it costs one line.
/// </remarks>
private const string PinLabel = "dodossh/devicekey/pin/v1";
private readonly ClientPaths paths;
private readonly Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin;
private readonly Func<Tpm2Device> openDevice;
/// <summary>Creates a store against this machine's TPM.</summary>
/// <param name="paths">Where the sealed blob lives.</param>
/// <param name="askForPin">
/// Shows the PIN prompt and answers with what was typed, or null if the user declined. Declining is
/// ordinary — see <see cref="IDeviceKeyStore"/> — and lands on the passphrase.
/// </param>
public LinuxDeviceKeyStore(
ClientPaths paths,
Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin)
: this(paths, askForPin, static () => new LinuxTpmDevice())
{
}
/// <summary>Creates a store against a named TPM, which is how the tests reach a simulator.</summary>
/// <remarks>
/// The seam exists because this class is testable in a way the other two stores are not, and that is
/// worth keeping. Windows and macOS put the prompt inside the platform, so a test that exercised
/// sealing would block forever on a dialog — <c>WindowsDeviceKeyStoreTests</c> records the ten minutes
/// that took to discover. Here the prompt is a delegate and the TPM is a device, so both can be
/// supplied and the whole path runs unattended.
/// </remarks>
internal LinuxDeviceKeyStore(
ClientPaths paths,
Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin,
Func<Tpm2Device> openDevice)
{
ArgumentNullException.ThrowIfNull(paths);
ArgumentNullException.ThrowIfNull(askForPin);
ArgumentNullException.ThrowIfNull(openDevice);
this.paths = paths;
this.askForPin = askForPin;
this.openDevice = openDevice;
}
/// <summary>
/// The parent every sealed object hangs off, as a template rather than as a stored handle.
/// </summary>
/// <remarks>
/// <para>
/// <b>Recreated from a fixed template on every use, and not made persistent.</b> A persistent handle
/// would occupy one of the TPM's small number of NV slots forever, would have to be cleaned up by
/// <see cref="ForgetAsync"/>, and would collide with whatever else on the machine had chosen the same
/// index. Deriving it costs a command and leaves nothing behind.
/// </para>
/// <para>
/// <b>ECC P-256 rather than RSA-2048, and that is a user-visible choice.</b> The two are equally
/// standard as storage roots. Creating an RSA primary on a discrete TPM takes seconds — the key is
/// generated, not derived — and this runs on the unlock path, so it would show up as the application
/// hanging every time somebody opened their vault. The prototype this was built from used RSA and that
/// is exactly what it was changed for.
/// </para>
/// <para>
/// <c>NoDA</c> is set <em>here</em> and deliberately not on the sealed object. The parent has no
/// authValue to guess, so counting failures against it would only give an attacker a way to lock the
/// machine's TPM out by asking for something they were never going to get.
/// </para>
/// </remarks>
private static TpmPublic PrimaryTemplate => new(
TpmAlgId.Sha256,
ObjectAttr.Restricted | ObjectAttr.Decrypt | ObjectAttr.FixedTPM | ObjectAttr.FixedParent
| ObjectAttr.SensitiveDataOrigin | ObjectAttr.UserWithAuth | ObjectAttr.NoDA,
null,
new EccParms(
new SymDefObject(TpmAlgId.Aes, 128, TpmAlgId.Cfb),
new NullAsymScheme(),
EccCurve.TpmEccNistP256,
new NullKdfScheme()),
new EccPoint());
/// <summary>
/// The sealed object: bytes the TPM holds, released against an authValue the TPM checks.
/// </summary>
/// <remarks>
/// Keyedhash with a null scheme is TPM 2.0's "just keep these bytes" object. <c>UserWithAuth</c> is
/// what makes the authValue the thing that opens it, and the deliberate <em>absence</em> of
/// <c>NoDA</c> is what puts every wrong PIN on the dictionary-attack counter. Removing that one
/// attribute would leave a four-digit secret that could be guessed at full speed by anything holding
/// the blob and the machine, which is the whole of the protection this class offers.
/// </remarks>
private static TpmPublic SealedTemplate => new(
TpmAlgId.Sha256,
ObjectAttr.FixedTPM | ObjectAttr.FixedParent | ObjectAttr.UserWithAuth,
null,
new KeyedhashParms(new NullSchemeKeyedhash()),
new Tpm2bDigestKeyedhash());
/// <summary>Whether this machine has a TPM this process may use.</summary>
/// <remarks>
/// Probes by creating and flushing a primary rather than by looking for the device node, for the
/// reason <c>WindowsDeviceKeyStore.IsSupported</c> gives about its own provider: the node can be
/// present on a machine where the first real command then fails. Permissions are the common case here
/// and they fail at open; an owner hierarchy with an authValue set is the rarer one and fails at
/// CreatePrimary. Neither is a fault and both mean the same thing.
/// </remarks>
internal static bool IsSupported() => IsSupported(static () => new LinuxTpmDevice());
/// <inheritdoc cref="IsSupported()" />
internal static bool IsSupported(Func<Tpm2Device> openDevice)
{
ArgumentNullException.ThrowIfNull(openDevice);
try
{
using var device = openDevice();
device.Connect();
var tpm = new Tpm2(device);
var primary = CreatePrimary(tpm);
tpm.FlushContext(primary);
return true;
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Every way this fails means the same thing to the caller: no device key here, so keep asking
// for the passphrase. A missing device node throws IOException, a device this user may not
// open throws UnauthorizedAccessException, and a TPM that refuses the command throws
// TpmException — and on a machine with no TPM at all the constructor itself may throw before
// any of that.
return false;
}
}
/// <inheritdoc />
public ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
ValueTask.FromResult(IsSupported(openDevice));
/// <inheritdoc />
/// <remarks>
/// Asks for a PIN first and writes nothing if the user declines, which matters more here than the
/// symmetry suggests: <c>SaveAsync</c> is called as part of registering this machine, and a half-done
/// registration would leave a wrap on the server that nothing can open.
/// </remarks>
public async ValueTask SaveAsync(
ReadOnlyMemory<byte> devicePrivateKey,
CancellationToken cancellationToken)
{
var pin = await askForPin(DevicePinPurpose.Choosing, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrEmpty(pin))
{
throw new OperationCanceledException("No PIN was chosen, so no device key was stored.");
}
var key = devicePrivateKey.ToArray();
// Off the calling thread, because every command below blocks on the chip. Creating the primary
// alone is hundreds of milliseconds on real hardware, and this is called from the unlock path —
// which on the desktop head is the UI thread.
var packed = await Task.Run(() => Seal(pin, key), cancellationToken).ConfigureAwait(false);
paths.EnsureCreated();
await File.WriteAllBytesAsync(paths.DeviceKeyFile, packed, cancellationToken).ConfigureAwait(false);
}
/// <summary>Seals the key into the TPM and answers with the blob to store.</summary>
private byte[] Seal(string pin, byte[] devicePrivateKey)
{
var auth = PinAuth(pin);
try
{
using var device = openDevice();
device.Connect();
var tpm = new Tpm2(device);
var primary = CreatePrimary(tpm);
try
{
var priv = tpm.Create(
primary,
new SensitiveCreate(auth, devicePrivateKey),
SealedTemplate,
null,
[],
out var pub,
out _,
out _,
out _);
return Pack(pub, priv);
}
finally
{
tpm.FlushContext(primary);
}
}
finally
{
CryptographicOperations.ZeroMemory(auth);
CryptographicOperations.ZeroMemory(devicePrivateKey);
}
}
/// <inheritdoc />
/// <remarks>
/// <para>
/// <b>One attempt per call, and a wrong PIN is not retried here.</b> Every wrong answer advances the
/// TPM's lockout counter, so a loop offering three tries would spend a third of the machine's budget
/// on one mistyped digit. Returning null lands on the passphrase, which always works; somebody who
/// wants to try the PIN again asks for a device unlock again, which is a deliberate act rather than a
/// loop.
/// </para>
/// <para>
/// <b>A locked-out TPM is deliberately indistinguishable from a wrong PIN.</b>
/// <see cref="IDeviceKeyStore.TryLoadAsync"/> asks for exactly that and the reasoning holds here: the
/// caller does the same thing in every case, and "your TPM is in lockout" describes the keystore
/// rather than telling somebody something they can act on — the answer is still to type the
/// passphrase.
/// </para>
/// </remarks>
public async ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken)
{
if (!File.Exists(paths.DeviceKeyFile))
{
return null;
}
var stored = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
.ConfigureAwait(false);
if (!TryUnpack(stored, out var pub, out var priv))
{
return null;
}
// After the blob is known to be readable and before anything is asked of the user, because a
// prompt raised over a file this cannot use is a question with no useful answer.
var pin = await askForPin(DevicePinPurpose.Entering, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrEmpty(pin))
{
return null;
}
return await Task.Run(() => Unseal(pin, pub, priv), cancellationToken).ConfigureAwait(false);
}
/// <summary>Asks the TPM to release the key, and answers null however it declines.</summary>
private byte[]? Unseal(string pin, TpmPublic pub, TpmPrivate priv)
{
var auth = PinAuth(pin);
try
{
using var device = openDevice();
device.Connect();
var tpm = new Tpm2(device);
var primary = CreatePrimary(tpm);
try
{
var loaded = tpm.Load(primary, priv, pub);
try
{
return tpm[auth].Unseal(loaded);
}
finally
{
tpm.FlushContext(loaded);
}
}
finally
{
tpm.FlushContext(primary);
}
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// TpmException covers the wrong PIN and the lockout; the rest covers a TPM that has gone away
// between the availability check and now, which a suspend or a firmware update can do.
return null;
}
finally
{
CryptographicOperations.ZeroMemory(auth);
}
}
/// <inheritdoc />
/// <remarks>
/// Deleting the blob is the whole of it, and that is a property of sealing rather than an omission.
/// The object lives nowhere but in that file — it was never made persistent inside the TPM, see
/// <see cref="PrimaryTemplate"/> — so the bytes are unrecoverable the moment it is gone. There is no
/// TPM state to clean up and nothing left behind for a later install to trip over.
/// </remarks>
public ValueTask ForgetAsync(CancellationToken cancellationToken)
{
if (File.Exists(paths.DeviceKeyFile))
{
File.Delete(paths.DeviceKeyFile);
}
return ValueTask.CompletedTask;
}
/// <summary>Derives the TPM authValue from the PIN as typed.</summary>
private static byte[] PinAuth(string pin) =>
SHA256.HashData(Encoding.UTF8.GetBytes($"{PinLabel} {pin}"));
private static TpmHandle CreatePrimary(Tpm2 tpm) =>
tpm.CreatePrimary(
TpmRh.Owner,
new SensitiveCreate(),
PrimaryTemplate,
null,
[],
out _,
out _,
out _,
out _);
/// <summary>Puts the two halves of the sealed object in one file.</summary>
/// <remarks>
/// Both are needed to load it and neither is secret — the private half is encrypted to this TPM's
/// storage hierarchy, which is what makes the file useless anywhere else. Length-prefixed rather than
/// concatenated because their sizes vary with the TPM's algorithms, and magic-prefixed so that a file
/// left by another version, or by the Windows store on a dual-boot profile directory, is refused
/// rather than fed to the TPM as a malformed structure.
/// </remarks>
private static byte[] Pack(TpmPublic pub, TpmPrivate priv)
{
var publicPart = pub.GetTpmRepresentation();
var privatePart = priv.GetTpmRepresentation();
var packed = new byte[Magic.Length + 4 + publicPart.Length + 4 + privatePart.Length];
var at = Magic.Length;
Magic.CopyTo(packed.AsSpan());
BinaryPrimitives.WriteInt32BigEndian(packed.AsSpan(at), publicPart.Length);
at += 4;
publicPart.CopyTo(packed.AsSpan(at));
at += publicPart.Length;
BinaryPrimitives.WriteInt32BigEndian(packed.AsSpan(at), privatePart.Length);
at += 4;
privatePart.CopyTo(packed.AsSpan(at));
return packed;
}
/// <summary>Reads back what <see cref="Pack"/> wrote, or answers false.</summary>
private static bool TryUnpack(byte[] stored, out TpmPublic pub, out TpmPrivate priv)
{
pub = new TpmPublic();
priv = new TpmPrivate();
try
{
if (stored.Length < Magic.Length + 8 || !stored.AsSpan(0, Magic.Length).SequenceEqual(Magic))
{
return false;
}
var at = Magic.Length;
var publicLength = BinaryPrimitives.ReadInt32BigEndian(stored.AsSpan(at));
at += 4;
if (publicLength < 0 || at + publicLength + 4 > stored.Length)
{
return false;
}
var publicPart = stored.AsSpan(at, publicLength).ToArray();
at += publicLength;
var privateLength = BinaryPrimitives.ReadInt32BigEndian(stored.AsSpan(at));
at += 4;
if (privateLength < 0 || at + privateLength != stored.Length)
{
return false;
}
var privatePart = stored.AsSpan(at, privateLength).ToArray();
pub = Marshaller.FromTpmRepresentation<TpmPublic>(publicPart);
priv = Marshaller.FromTpmRepresentation<TpmPrivate>(privatePart);
return true;
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// A blob that does not parse is a blob this machine cannot use, which is the same answer as no
// blob at all. Throwing here would mean a corrupted file stopped the application from starting.
return false;
}
}
}
@@ -0,0 +1,495 @@
using System.Diagnostics;
using System.Globalization;
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using DodoSSH.Client.Session;
namespace DodoSSH.Client.App.Platform;
/// <summary>What a channel's release says it is publishing.</summary>
/// <remarks>
/// <para>
/// Written by <c>scripts/ci-linux-rpm.sh</c> and read here, and it is a small file this repository
/// controls rather than the forge's own release JSON for the reason the android manifest gives: this is
/// fetched on a six-hourly timer, and a document whose shape changes when the forge is upgraded is not a
/// contract.
/// </para>
/// <para>
/// <b>The comparison is <see cref="RpmVersion"/> and never <see cref="Version"/>.</b> That is the string
/// <c>rpm</c> will order the install against, so comparing anything else would let this offer an update
/// PackageKit then refuses — the same argument <c>AndroidChannelManifest</c> makes for <c>versionCode</c>.
/// The two differ exactly where it matters: <c>0.0.1-alpha.0.143</c> and <c>0.0.1~alpha.0.143</c> are one
/// build, and only the second sorts below <c>0.0.1</c>.
/// </para>
/// </remarks>
/// <param name="RpmVersion">The version-release string rpm knows the package by.</param>
/// <param name="Version">What that build calls itself, for a human.</param>
/// <param name="Rpm">The asset on the same release that holds it.</param>
/// <param name="Sha256">The package's digest, lower-case hex.</param>
internal sealed record LinuxChannelManifest(
[property: JsonPropertyName("rpmVersion")] string RpmVersion,
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("rpm")] string Rpm,
[property: JsonPropertyName("sha256")] string Sha256);
/// <summary>One release as the forge describes it. Only the assets are read.</summary>
internal sealed record LinuxForgeRelease(
[property: JsonPropertyName("assets")] IReadOnlyList<LinuxForgeAsset>? Assets);
/// <summary>One file attached to a release.</summary>
internal sealed record LinuxForgeAsset(
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("browser_download_url")] string? DownloadUrl);
[JsonSourceGenerationOptions(UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(LinuxChannelManifest))]
[JsonSerializable(typeof(LinuxForgeRelease))]
internal sealed partial class LinuxForgeJsonContext : JsonSerializerContext;
/// <summary>
/// Fetches a newer RPM from the project's own forge and asks the system's package manager to install it.
/// </summary>
/// <remarks>
/// <para>
/// <b>Nothing here replaces anything.</b> That is the difference from
/// <see cref="VelopackUpdateChannel"/> and it is the decision ADR 0013 rule 11 records rather than an
/// unfinished half: on this platform the package manager is already an updater the user trusts, audits and
/// controls, so this class does the two things the package manager cannot — notice that a build exists and
/// fetch it — and then hands the file to PackageKit, which raises polkit and asks. The shape is the
/// phone's, not the desktop's: <see cref="ApplyingEndsTheProcess"/> is false, and the answer may be no.
/// </para>
/// <para>
/// <b>The feed is a constant and the deployment is never asked.</b> ADR 0011 rule 2, carried unchanged
/// from both other channels: an operator who could answer the update check could pin a chosen user to a
/// build with a known hole, without holding any key at all.
/// </para>
/// <para>
/// <b>The digest is checked and is not a signature.</b> 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 PackageKit has neither. So the manifest names a digest and this refuses anything else —
/// which catches a truncated download, a caching proxy and a corrupted mirror, and catches nothing at all
/// about who built the file, because the same job wrote both. Provenance here is the constant address; a
/// signed repository is what would improve on it, and ADR 0013 rule 11 says why there is not one yet.
/// </para>
/// </remarks>
[SupportedOSPlatform("linux")]
internal sealed class LinuxUpdateChannel : IUpdateChannel
{
/// <summary>The project's own forge, and the one address in this file.</summary>
/// <remarks>
/// The owner half of the path matters for the reason <c>AndroidUpdateChannel</c> records: the
/// repository moved between organisations and Gitea leaves a 301 behind, so a client naming the old
/// one works until somebody deletes the redirect.
/// </remarks>
private const string RepositoryApi = "https://git.dodotech.cloud/api/v1/repos/DodoTech-Public/DodoSSH";
private readonly string channel;
private readonly string packageName;
private readonly string dataDirectory;
private readonly HttpClient http;
private readonly Func<string, string?> installedVersion;
private readonly Action<string> handToInstaller;
private (string Version, string RpmVersion, string Url, string Sha256)? found;
private string? fetched;
/// <summary>Creates a channel for the running build.</summary>
internal LinuxUpdateChannel(string channel)
: this(
channel,
ClientPaths.ForChannel(DesktopChannel.Name).DataDirectory,
new HttpClient(),
InstalledRpmVersion,
HandToPackageKit)
{
}
/// <summary>Creates a channel with its outside world supplied, which is how the tests reach it.</summary>
internal LinuxUpdateChannel(
string channel,
string dataDirectory,
HttpClient http,
Func<string, string?> installedVersion,
Action<string> handToInstaller)
{
ArgumentException.ThrowIfNullOrEmpty(channel);
ArgumentException.ThrowIfNullOrEmpty(dataDirectory);
ArgumentNullException.ThrowIfNull(http);
ArgumentNullException.ThrowIfNull(installedVersion);
ArgumentNullException.ThrowIfNull(handToInstaller);
this.channel = channel;
this.dataDirectory = dataDirectory;
this.http = http;
this.installedVersion = installedVersion;
this.handToInstaller = handToInstaller;
packageName = string.Equals(channel, "nightly", StringComparison.Ordinal)
? "dodossh-nightly"
: "dodossh";
}
/// <inheritdoc />
/// <remarks>
/// True unconditionally, because whether this copy is one that can be updated was already decided by
/// <see cref="UpdateChannels.ForThisMachine"/> — a build run from a checkout never reaches this class
/// at all. The same split Velopack's side uses, where <c>IsInstalled</c> is checked once at
/// composition rather than at every call site.
/// </remarks>
public bool IsSupported => true;
/// <inheritdoc />
public string CurrentVersion => ClientVersion.Current;
/// <inheritdoc />
/// <remarks>
/// False, and for the phone's reason rather than the desktop's. Applying hands a file to PackageKit
/// and returns; polkit then asks, and the user may say no. A caller that had already torn the vault
/// down would answer "not now" with a locked vault and every shell gone.
/// </remarks>
public bool ApplyingEndsTheProcess => false;
/// <inheritdoc />
/// <exception cref="HttpRequestException">The forge could not be reached, or refused.</exception>
/// <exception cref="InvalidOperationException">The release is there and does not carry a usable build.</exception>
public async Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken)
{
var release = await ReadAsync(
ReleaseUrl(),
LinuxForgeJsonContext.Default.LinuxForgeRelease,
cancellationToken)
.ConfigureAwait(false);
var manifestName = $"linux-{channel}.json";
if (Asset(release, manifestName) is not { } manifestAsset)
{
throw new InvalidOperationException(
$"The {channel} release carries no {manifestName}, so there is nothing saying what it "
+ "publishes.");
}
var manifest = await ReadAsync(
manifestAsset,
LinuxForgeJsonContext.Default.LinuxChannelManifest,
cancellationToken)
.ConfigureAwait(false);
if (manifest is null)
{
throw new InvalidOperationException(
$"The {channel} release's {manifestName} could not be read as a manifest.");
}
// The one place null is returned, and it means what null is documented to mean: this build is
// current. Compared the way rpm compares, because rpm is what accepts or refuses the install.
if (RpmVersion.Compare(manifest.RpmVersion, installedVersion(packageName) ?? "0") <= 0)
{
return null;
}
if (Asset(release, manifest.Rpm) is not { } rpm)
{
throw new InvalidOperationException(
$"The {channel} release advertises {manifest.Version} but carries no {manifest.Rpm}.");
}
found = (manifest.Version, manifest.RpmVersion, rpm, manifest.Sha256);
return new AvailableUpdate(manifest.Version);
}
/// <inheritdoc />
public async Task DownloadAsync(
AvailableUpdate update,
IProgress<int> progress,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(update);
ArgumentNullException.ThrowIfNull(progress);
if (found is not { } target
|| !string.Equals(target.Version, update.Version, StringComparison.Ordinal))
{
throw new InvalidOperationException(
"That update did not come from this channel's last check. Check again before downloading.");
}
// Under the profile directory rather than /tmp, because a package this size on a tmpfs is a
// machine that runs out of memory downloading an update. One name, overwritten, so a client that
// fetched three updates it never installed is not holding three hundred megabytes.
var directory = Path.Combine(dataDirectory, "updates");
Directory.CreateDirectory(directory);
var path = Path.Combine(directory, $"{packageName}-update.rpm");
await FetchAsync(target.Url, path, progress, cancellationToken).ConfigureAwait(false);
// ◆ Verified before it is ever named to the installer, and the file is removed if it does not
// match. Leaving a package that failed its digest on disk would put a file somebody could install
// by hand next to a filename that says it is an update.
var digest = Convert.ToHexStringLower(await Sha256OfAsync(path, cancellationToken).ConfigureAwait(false));
if (!string.Equals(digest, target.Sha256, StringComparison.OrdinalIgnoreCase))
{
File.Delete(path);
throw new InvalidOperationException(
$"The downloaded package is not the one {channel} published: expected {target.Sha256}, "
+ $"got {digest}.");
}
fetched = path;
}
/// <summary>Streams one URL to one path, reporting progress as it goes.</summary>
private async Task FetchAsync(
string url,
string path,
IProgress<int> progress,
CancellationToken cancellationToken)
{
using var response = await http
.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var total = response.Content.Headers.ContentLength ?? 0;
var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var destination = File.Create(path);
await using (stream.ConfigureAwait(false))
await using (destination.ConfigureAwait(false))
{
var buffer = new byte[81920];
long copied = 0;
int read;
while ((read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
{
await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken)
.ConfigureAwait(false);
copied += read;
if (total > 0)
{
progress.Report((int)(copied * 100 / total));
}
}
}
}
/// <inheritdoc />
/// <remarks>
/// <para>
/// <b>This installs nothing and returns immediately.</b> It asks PackageKit — the freedesktop service
/// every RPM distribution runs — to install a local file, which raises a polkit prompt drawn by the
/// desktop rather than by this application. The user may refuse, and refusing is not a failure.
/// </para>
/// <para>
/// <b>The process is not restarted and must not be.</b> Replacing files under a running process is
/// ordinary on Linux: the old inodes stay mapped and this build keeps running until somebody closes
/// it. That is the right behaviour for an application that deliberately keeps shells open across a
/// lock — the same argument ADR 0013 makes for waiting for a restart the user asks for — and it is why
/// the name inherited from the interface reads oddly here. There is no equivalent of Android's
/// "the platform ends the process", so nothing ends it.
/// </para>
/// </remarks>
public void ApplyAndRestart(AvailableUpdate update)
{
ArgumentNullException.ThrowIfNull(update);
if (fetched is not { } path || !File.Exists(path))
{
throw new InvalidOperationException("There is no downloaded update to install.");
}
handToInstaller(path);
}
/// <summary>Which release this build's channel reads.</summary>
/// <remarks>
/// <c>releases/latest</c> skips prereleases, which keeps the nightly — published as one — out of the
/// release channel's answer even though both live on the same forge.
/// </remarks>
private string ReleaseUrl() =>
string.Equals(channel, "nightly", StringComparison.Ordinal)
? $"{RepositoryApi}/releases/tags/nightly-desktop"
: $"{RepositoryApi}/releases/latest";
private static string? Asset(LinuxForgeRelease? release, string name) =>
release?.Assets?.FirstOrDefault(
asset => string.Equals(asset.Name, name, StringComparison.Ordinal))?.DownloadUrl;
private async Task<T?> ReadAsync<T>(
string url,
JsonTypeInfo<T> shape,
CancellationToken cancellationToken)
{
var stream = await http.GetStreamAsync(url, cancellationToken).ConfigureAwait(false);
await using (stream.ConfigureAwait(false))
{
return await JsonSerializer
.DeserializeAsync(stream, shape, cancellationToken)
.ConfigureAwait(false);
}
}
private static async Task<byte[]> Sha256OfAsync(string path, CancellationToken cancellationToken)
{
var stream = File.OpenRead(path);
await using (stream.ConfigureAwait(false))
{
return await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// What rpm records for the installed package, which is the number a newer build must beat.
/// </summary>
/// <remarks>
/// <para>
/// Read from the package database rather than from the assembly, for the reason
/// <c>AndroidUpdateChannel.ReadInstalledVersionCode</c> gives about reading it from the platform: the
/// assembly's version is a SemVer string and this comparison has to be the one the installer will
/// make. Deriving the rpm version from the assembly here would mean a second copy of the translation
/// <c>scripts/ci-linux-rpm.sh</c> performs, and two copies of one rule is a pair that can disagree.
/// </para>
/// <para>
/// A machine that cannot answer gets null, which the caller reads as <c>0</c> and makes every
/// published build look newer. That is the wrong way to fail, chosen deliberately: the failure is then
/// an install PackageKit declines rather than a security fix that never arrives.
/// </para>
/// </remarks>
private static string? InstalledRpmVersion(string packageName)
{
try
{
using var process = Process.Start(new ProcessStartInfo("rpm")
{
ArgumentList = { "-q", "--queryformat", "%{VERSION}-%{RELEASE}", packageName },
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
});
if (process is null)
{
return null;
}
var answer = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit(TimeSpan.FromSeconds(10));
return process.ExitCode == 0 && answer.Length > 0 ? answer : null;
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
return null;
}
}
/// <summary>Asks PackageKit to install a local file, and lets polkit do the asking.</summary>
/// <remarks>
/// <para>
/// <b>Over <c>gdbus</c> rather than a D-Bus library, and that is a dependency argument rather than a
/// stylistic one.</b> <c>gdbus</c> ships in glib2, which this package already depends on twice over —
/// GTK and WebKitGTK are the terminal — so it is present on every machine that can run this at all.
/// Adding a managed D-Bus stack to reach two method calls would be a package, a lock file entry and a
/// second thing to keep current, for no capability. <c>BrowserLauncher</c> settles the same trade the
/// same way one layer down.
/// </para>
/// <para>
/// <b>Two calls, because PackageKit's interface is a factory.</b> <c>CreateTransaction</c> returns an
/// object path and the work is asked of that; a transaction is single-use, so this cannot be cached.
/// </para>
/// <para>
/// <b>The flags word is zero, and zero is load-bearing.</b> Bit 0 is <c>ONLY_TRUSTED</c>. This
/// package is not signed — ADR 0013 rule 11 says why, and why that is not decision 8's "unsigned for
/// now" — so requiring trust would make every update fail at the last step. Clearing it is what routes
/// the request to polkit's <c>package-install-untrusted</c> action, which asks for an administrator
/// rather than proceeding quietly. The user sees what they are agreeing to.
/// </para>
/// </remarks>
private static void HandToPackageKit(string path)
{
var transaction = Gdbus(
"--system",
"--dest", "org.freedesktop.PackageKit",
"--object-path", "/org/freedesktop/PackageKit",
"--method", "org.freedesktop.PackageKit.CreateTransaction");
// gdbus prints a GVariant tuple — (objectpath '/17_beadcfae',) — and the path is the only thing
// in it. Parsed by taking what is between the quotes rather than with a regular expression over a
// format nobody has promised to keep.
var opened = transaction.IndexOf('\'', StringComparison.Ordinal);
var closed = transaction.LastIndexOf('\'');
if (opened < 0 || closed <= opened)
{
throw new InvalidOperationException(
$"PackageKit did not return a transaction to install into: {transaction}");
}
var objectPath = transaction[(opened + 1)..closed];
Gdbus(
"--system",
"--dest", "org.freedesktop.PackageKit",
"--object-path", objectPath,
"--method", "org.freedesktop.PackageKit.Transaction.InstallFiles",
"0",
$"['{path}']");
}
/// <summary>Runs one gdbus call and answers with its output.</summary>
private static string Gdbus(params string[] arguments)
{
var start = new ProcessStartInfo("gdbus")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
foreach (var argument in arguments)
{
start.ArgumentList.Add(argument);
}
using var process = Process.Start(start)
?? throw new InvalidOperationException(
"gdbus could not be started, so the package could not be handed to PackageKit.");
var output = process.StandardOutput.ReadToEnd();
var error = process.StandardError.ReadToEnd();
process.WaitForExit(TimeSpan.FromSeconds(30));
if (process.ExitCode != 0)
{
// The message is shown to the user by UpdateViewModel, so it says what to do rather than what
// failed. A missing PackageKit is the likely cause on a minimal install and is the one thing
// they can fix.
throw new InvalidOperationException(
string.Format(
CultureInfo.CurrentCulture,
"PackageKit would not take the package. Install it with `sudo dnf install PackageKit`, "
+ "or install the file yourself: {0}. ({1})",
arguments[^1],
error.Trim()));
}
return output;
}
}
@@ -0,0 +1,213 @@
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// Compares two RPM version strings the way <c>rpm</c> itself does.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this exists rather than a SemVer comparison.</b> <c>AndroidUpdateChannel</c> settles the same
/// question and its reasoning carries over exactly: the comparison has to be the one the installer will
/// make, because anything else can offer an update the platform then refuses. On the phone that number is
/// Android's <c>versionCode</c>; here it is the RPM version, and the authority that reads it is
/// <c>rpm</c> — so this is <c>rpmvercmp</c> and not an approximation of it.
/// </para>
/// <para>
/// It matters in a specific direction. The nightly channel is rolling and CI replaces it on every push, so
/// the published version normally only rises — but a revert, a force-push or a re-run of an older commit
/// can lower it, and a client comparing "is this different from mine" would then offer a downgrade that
/// PackageKit declines. The user sees an update that fails, every six hours, forever.
/// </para>
/// <para>
/// <b>The algorithm is rpm's, including the parts that look like bugs.</b> Segments of digits and segments
/// of letters are compared separately and never against each other; everything that is neither is a
/// separator, so <c>1.0_1</c> and <c>1.0.1</c> are equal. Leading zeros are stripped, so <c>1.01</c> and
/// <c>1.1</c> are equal too. <c>~</c> sorts before anything including the end of the string, which is what
/// makes <c>0.0.1~alpha.0.143</c> older than <c>0.0.1</c> and is the whole reason
/// <c>scripts/ci-linux-rpm.sh</c> translates a SemVer prerelease to one. <c>^</c> sorts after everything
/// except the end of the string.
/// </para>
/// <para>
/// Verified against <c>rpm</c>'s own answers rather than against a reading of the source: the cases in
/// <c>RpmVersionTests</c> were generated by running <c>rpm.vercmp</c> in a container, so a disagreement is
/// a disagreement with the program that will actually decide.
/// </para>
/// </remarks>
internal static class RpmVersion
{
/// <summary>
/// Orders two RPM version strings.
/// </summary>
/// <param name="left">The left version.</param>
/// <param name="right">The right version.</param>
/// <returns>-1, 0 or 1, as <c>rpmvercmp</c> would answer.</returns>
/// <remarks>
/// Null and empty are accepted and treated as equal to each other and older than anything else, which
/// <c>rpm</c>'s own Lua binding refuses outright. The refusal is right for a packaging tool and wrong
/// here: this compares a version read off a feed, and a feed that answered with an empty string should
/// leave the client on the build it has rather than throwing on a background timer.
/// </remarks>
internal static int Compare(string? left, string? right)
{
var one = left ?? string.Empty;
var two = right ?? string.Empty;
if (string.Equals(one, two, StringComparison.Ordinal))
{
return 0;
}
int i = 0, j = 0;
while (i < one.Length || j < two.Length)
{
// Separators. Anything that is not alphanumeric, ~ or ^ divides segments and is skipped on
// both sides independently — which is why 1.0_1 and 1.0.1 compare equal.
Skip(one, ref i);
Skip(two, ref j);
if (CompareMarkers(one, i, two, j) is { } marker)
{
if (marker != 0)
{
return marker;
}
// Both sides carried the same marker, so step over it and keep going.
i++;
j++;
continue;
}
if (i >= one.Length || j >= two.Length)
{
break;
}
if (CompareSegments(one, ref i, two, ref j) is { } ordering)
{
return ordering;
}
}
// Both ran out together, so every segment matched and the only differences were separators.
if (i >= one.Length && j >= two.Length)
{
return 0;
}
// Whichever still has characters left has more version, and more version is newer.
return i < one.Length ? 1 : -1;
}
/// <summary>Advances past anything that divides segments rather than taking part in them.</summary>
private static void Skip(string value, ref int at)
{
while (at < value.Length && !IsVersionCharacter(value[at]))
{
at++;
}
}
/// <summary>
/// Orders the two sides when either is at a <c>~</c> or a <c>^</c>.
/// </summary>
/// <returns>
/// Null when neither side is at a marker, so the caller should compare segments; 0 when both are at
/// the same one, so the caller should step over it; otherwise the ordering.
/// </returns>
/// <remarks>
/// <c>~</c> sorts before everything including the end of the string, which is what carries the whole
/// prerelease ordering. <c>^</c> sorts after everything <em>except</em> the end of the string, so
/// 1.0^post is newer than 1.0 and 1.2.3 is older than 1.2.3^ — the end-of-string checks coming first
/// is the difference between the two halves of that sentence.
/// </remarks>
private static int? CompareMarkers(string one, int i, string two, int j)
{
var oneTilde = i < one.Length && one[i] == '~';
var twoTilde = j < two.Length && two[j] == '~';
if (oneTilde || twoTilde)
{
return oneTilde && twoTilde ? 0 : oneTilde ? -1 : 1;
}
var oneCaret = i < one.Length && one[i] == '^';
var twoCaret = j < two.Length && two[j] == '^';
if (!oneCaret && !twoCaret)
{
return null;
}
if (i >= one.Length)
{
return -1;
}
if (j >= two.Length)
{
return 1;
}
return oneCaret && twoCaret ? 0 : oneCaret ? -1 : 1;
}
/// <summary>
/// Takes one segment from each side and orders them, advancing past both.
/// </summary>
/// <returns>The ordering, or null if the two segments were equal and the walk should continue.</returns>
private static int? CompareSegments(string one, ref int i, string two, ref int j)
{
var numeric = char.IsAsciiDigit(one[i]);
var start1 = i;
var start2 = j;
Take(one, ref i, numeric);
Take(two, ref j, numeric);
var segment1 = one.AsSpan(start1, i - start1);
var segment2 = two.AsSpan(start2, j - start2);
// The two sides are at segments of different kinds — digits against letters. rpm calls the
// numeric one newer, always, on the reasoning that a version that has started counting is further
// along than one still spelling. segment1 is never empty: its kind was chosen from its own first
// character.
if (segment2.IsEmpty)
{
return numeric ? 1 : -1;
}
if (numeric)
{
segment1 = segment1.TrimStart('0');
segment2 = segment2.TrimStart('0');
// Longer, once the zeros are gone, is larger. Compared before the characters are, because a
// string comparison would call "9" larger than "10".
if (segment1.Length != segment2.Length)
{
return segment1.Length > segment2.Length ? 1 : -1;
}
}
var ordering = segment1.CompareTo(segment2, StringComparison.Ordinal);
return ordering == 0 ? null : ordering > 0 ? 1 : -1;
}
/// <summary>Advances over one run of digits, or one run of letters.</summary>
private static void Take(string value, ref int at, bool numeric)
{
while (at < value.Length
&& (numeric ? char.IsAsciiDigit(value[at]) : char.IsAsciiLetter(value[at])))
{
at++;
}
}
/// <summary>Whether this character takes part in the comparison rather than dividing it.</summary>
private static bool IsVersionCharacter(char value) =>
char.IsAsciiLetterOrDigit(value) || value == '~' || value == '^';
}
@@ -1,3 +1,4 @@
using System.Runtime.Versioning;
using DodoSSH.Client.Session; using DodoSSH.Client.Session;
using Velopack; using Velopack;
using Velopack.Sources; using Velopack.Sources;
@@ -31,11 +32,18 @@ internal static class UpdateChannels
/// </remarks> /// </remarks>
internal static IUpdateChannel ForThisMachine() internal static IUpdateChannel ForThisMachine()
{ {
// Two platforms now, and the check is a list rather than a negation for a reason: Linux reaches // ◆ Linux is answered first and separately, because it is not a Velopack platform at all.
// this too. Velopack has a Linux path — AppImage — but this repository does not build one, so a //
// Linux build is a checkout somebody ran, and handing it an UpdateManager would have it poll a // This used to be the place that said so — a negation over the two packaged platforms, with a note
// feed carrying nothing it could apply. Naming the platforms that are packaged keeps a future // that a future AppImage should be an addition here rather than something that had silently
// AppImage an addition here rather than a thing that silently already half-happened. // half-happened. It is an addition, and it is not an AppImage: the Linux head ships as an RPM and
// is updated by the machine's own package manager, so its channel fetches and hands off rather
// than replacing anything. See LinuxUpdateChannel and ADR 0013 rule 11.
if (OperatingSystem.IsLinux())
{
return LinuxChannel();
}
if (!OperatingSystem.IsWindows() && !OperatingSystem.IsMacOS()) if (!OperatingSystem.IsWindows() && !OperatingSystem.IsMacOS())
{ {
return new UnavailableUpdateChannel(); return new UnavailableUpdateChannel();
@@ -57,6 +65,35 @@ internal static class UpdateChannels
return new UnavailableUpdateChannel(); return new UnavailableUpdateChannel();
} }
} }
/// <summary>The Linux channel, if this copy is one a package manager put here.</summary>
/// <remarks>
/// <para>
/// <b>The question Velopack answers with <c>IsInstalled</c>, answered by looking at where this process
/// lives.</b> The RPM owns <c>/opt/DodoSSH</c> or <c>/opt/DodoSSH.Nightly</c> — the spec puts it there
/// and nothing else writes to <c>/opt</c> under those names — so a build running from anywhere else is
/// a checkout somebody started, an extracted archive, or a tree copied by hand. None of those can be
/// updated by installing a package over them, and offering it would be an offer that cannot be carried
/// out.
/// </para>
/// <para>
/// Checked by path rather than by asking rpm which package owns the executable, which would be the
/// more thorough answer and costs a process launch on every start for a distinction nothing acts on
/// differently. The install root is a constant in two places — here and the packaging script — and
/// they are named in each other's comments.
/// </para>
/// </remarks>
[SupportedOSPlatform("linux")]
private static IUpdateChannel LinuxChannel()
{
var root = DesktopChannel.IsNightly ? "/opt/DodoSSH.Nightly" : "/opt/DodoSSH";
var here = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar);
return string.Equals(here, root, StringComparison.Ordinal)
? new LinuxUpdateChannel(DesktopChannel.Name)
: new UnavailableUpdateChannel();
}
} }
/// <summary> /// <summary>
@@ -9,17 +9,26 @@ namespace DodoSSH.Client.App.Platform;
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// One place decides, so nothing above has to carry a platform guard. A machine with no secure hardware, /// One place decides, so nothing above has to carry a platform guard. A machine with no secure hardware
/// or one that is neither Windows nor macOS, gets <see cref="UnavailableDeviceKeyStore"/> and therefore /// gets <see cref="UnavailableDeviceKeyStore"/> and therefore keeps asking for the passphrase — which is
/// keeps asking for the passphrase — which is the honest answer rather than a degraded one. /// the honest answer rather than a degraded one.
/// </para> /// </para>
/// <para> /// <para>
/// <b>Both real stores are asked whether they work rather than told that they do.</b> Each /// <b>Every real store is asked whether it works rather than told that it does.</b> Each
/// <c>IsSupported</c> probes by doing the thing — creating a throwaway key and deleting it — because on /// <c>IsSupported</c> probes by doing the thing — creating a throwaway key and discarding it — because on
/// both platforms the provider is present and reports itself present on machines where creating a key /// all three platforms the provider is present and reports itself present on machines where creating a key
/// fails: a Windows box with no usable TPM, a Mac with no Secure Enclave, and on macOS also every /// fails: a Windows box with no usable TPM, a Mac with no Secure Enclave, on macOS also every unsigned
/// unsigned development build, since enclave keys need a signing identity. Inferring from the OS would /// development build since enclave keys need a signing identity, and on Linux the ordinary case of a
/// mean each of those discovering the truth at the moment somebody tried to unlock. /// working TPM whose device node this user may not open. Inferring from the OS would mean each of those
/// discovering the truth at the moment somebody tried to unlock.
/// </para>
/// <para>
/// <b>The three are not equally strong, and the ordering here does not imply they are.</b> Windows and
/// macOS put the consent gesture inside the platform — a secure desktop and a system Touch ID sheet — so
/// the process asking never sees the secret. The Linux store's PIN is typed into a window this
/// application draws, and only the <em>checking</em> is the TPM's. That is a real difference, it is
/// recorded in ADR 0007 rather than smoothed over, and it is why <see cref="LinuxDeviceKeyStore"/> is
/// tried last rather than first among platforms that could in principle overlap.
/// </para> /// </para>
/// <para> /// <para>
/// <b>"Desktop", because the choice belongs to a head rather than to the session layer.</b> This file used /// <b>"Desktop", because the choice belongs to a head rather than to the session layer.</b> This file used
@@ -33,9 +42,20 @@ namespace DodoSSH.Client.App.Platform;
public static class DesktopDeviceKeyStores public static class DesktopDeviceKeyStores
{ {
/// <summary>The best store this machine supports.</summary> /// <summary>The best store this machine supports.</summary>
public static IDeviceKeyStore ForThisMachine(ClientPaths paths) /// <param name="paths">Where a stored key lives.</param>
/// <param name="askForPin">
/// Shows the Linux PIN prompt, and is never called on the other two platforms — there the platform
/// raises its own dialog and this process is not in the conversation. A caller with no way to draw one
/// may pass a delegate answering null, which makes the Linux store decline every unlock; that is the
/// right shape for a headless host and the wrong one for the application, which supplies a real dialog
/// in <c>App.axaml.cs</c>.
/// </param>
public static IDeviceKeyStore ForThisMachine(
ClientPaths paths,
Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin)
{ {
ArgumentNullException.ThrowIfNull(paths); ArgumentNullException.ThrowIfNull(paths);
ArgumentNullException.ThrowIfNull(askForPin);
if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported()) if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
{ {
@@ -47,6 +67,11 @@ public static class DesktopDeviceKeyStores
return new MacDeviceKeyStore(paths); return new MacDeviceKeyStore(paths);
} }
if (OperatingSystem.IsLinux() && LinuxDeviceKeyStore.IsSupported())
{
return new LinuxDeviceKeyStore(paths, askForPin);
}
return new UnavailableDeviceKeyStore(); return new UnavailableDeviceKeyStore();
} }
} }
@@ -0,0 +1,62 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="DodoSSH.Client.App.Views.DevicePinWindow"
Width="420" SizeToContent="Height"
CanResize="False" ShowInTaskbar="False"
WindowStartupLocation="CenterOwner"
Background="{StaticResource Surface}"
Title="DodoSSH">
<!--
The PIN that unseals this machine's device key, on Linux and nowhere else.
◆ A WINDOW, WHERE EVERY OTHER QUESTION THIS APPLICATION ASKS IS A CARD.
ConfirmDeleteCard, HostKeyCard and the rest are overlays bound to view-model state, and that is the
right shape for a question about the vault: the shell owns the moment and the Android head shows the
same one. This is not that kind of question. It belongs to a platform integration — LinuxDeviceKeyStore
— and the shell it would otherwise live in is shared with the phone, which has a fingerprint and must
never grow a PIN box. Keeping it here keeps DodoSSH.Client.Shell platform-neutral, which is the
property IDeviceKeyStore exists to protect.
◆ AND IT IS DELIBERATELY PLAIN.
Windows and macOS draw their equivalent themselves — a secure desktop and a system sheet — and this one
cannot be either. Dressing it up to look like a system dialog would be teaching people that a
convincing DodoSSH PIN box is a system dialog, which is the phishing this platform's design cannot
prevent and should not rehearse. It says what it is, it names the application, and it stops there.
ADR 0007 records the difference rather than smoothing it over.
-->
<StackPanel Margin="20" Spacing="12">
<TextBlock Classes="heading" FontSize="15" x:Name="Heading" TextWrapping="Wrap" />
<TextBlock Foreground="{StaticResource MutedText}" FontSize="12" TextWrapping="Wrap"
x:Name="Explanation" />
<!--
RevealPassword is off and there is no eye button. A PIN is short enough to retype and this box is
open on a screen somebody may be sharing.
-->
<TextBox x:Name="Pin" PasswordChar="•" PlaceholderText="PIN" FontSize="14" />
<!--
Only when choosing. A mistyped PIN at registration is not recoverable in any useful sense: the key is
sealed to it, and the only way back is to withdraw the device and register again. Asking twice costs
one box.
-->
<TextBox x:Name="Confirm" PasswordChar="•" PlaceholderText="PIN again" FontSize="14"
IsVisible="False" />
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
x:Name="Problem" IsVisible="False" />
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
<Button x:Name="Cancel" Content="CANCEL" />
<Button x:Name="Accept" Classes="accent" Content="UNLOCK" />
</StackPanel>
</StackPanel>
</Window>
@@ -0,0 +1,203 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using DodoSSH.Client.App.Platform;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// Asks for the PIN that unseals this machine's device key.
/// </summary>
/// <remarks>
/// <para>
/// Raised by <see cref="LinuxDeviceKeyStore"/> through the delegate <c>App.axaml.cs</c> supplies, and by
/// nothing else. See the markup for why this is a window rather than a card, and why it is plain.
/// </para>
/// <para>
/// <b>Cancelling is an ordinary answer, not an error.</b> Escape, the close button and CANCEL all resolve
/// to null, which the store reads as "no PIN" and the unlock path answers with the passphrase screen. That
/// is the same contract <c>IDeviceKeyStore.TryLoadAsync</c> documents for a declined fingerprint.
/// </para>
/// </remarks>
public sealed partial class DevicePinWindow : Window
{
/// <summary>
/// The shortest PIN this will accept, and the reason it is this short rather than longer.
/// </summary>
/// <remarks>
/// Four, because the TPM counts wrong answers and stops — which is exactly the property that makes a
/// short secret safe here and does not exist for anything kept in a file. A PIN long enough to be safe
/// without hardware counting would be a second passphrase, and somebody asked for a second passphrase
/// reuses the first.
/// </remarks>
private const int ShortestPin = 4;
private TaskCompletionSource<string?>? answer;
/// <summary>Creates the window. Avalonia's designer needs this; nothing else should call it.</summary>
public DevicePinWindow()
{
AvaloniaXamlLoader.Load(this);
Accept.Click += OnAccept;
Cancel.Click += OnCancel;
KeyDown += OnKeyDown;
Pin.KeyDown += OnKeyDown;
Confirm.KeyDown += OnKeyDown;
}
/// <summary>
/// Shows the prompt over the main window and answers with what was typed, or null.
/// </summary>
/// <remarks>
/// <para>
/// <b>Marshalled onto the UI thread here rather than at the call site.</b> The store is called from
/// wherever the unlock happens to be running — a background task on launch, a command handler on a
/// button — and none of those should have to know that this ends in a window. Avalonia throws rather
/// than misbehaving if it is wrong, which would turn a device unlock into a crash.
/// </para>
/// <para>
/// A missing owner answers null rather than showing an ownerless window. It means the main window is
/// gone or not up yet, and a modal dialog with nothing behind it is a prompt the user cannot place —
/// which is precisely the shape of the thing this application must not train people to accept.
/// </para>
/// </remarks>
/// <param name="owner">The window to sit over.</param>
/// <param name="purpose">Whether a PIN is being chosen or entered.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public static ValueTask<string?> AskAsync(
Func<Window?> owner,
DevicePinPurpose purpose,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(owner);
return new ValueTask<string?>(
Dispatcher.UIThread.InvokeAsync(
async () =>
{
if (owner() is not { } parent)
{
return null;
}
var window = new DevicePinWindow();
window.Prepare(purpose);
using var registration = cancellationToken.Register(window.Close);
return await window.ShowFor(parent).ConfigureAwait(true);
}));
}
private void Prepare(DevicePinPurpose purpose)
{
var choosing = purpose == DevicePinPurpose.Choosing;
Heading.Text = choosing ? "Choose a PIN for this computer" : "Enter your DodoSSH PIN";
Explanation.Text = choosing
? "Your device key is sealed into this computer's TPM. The PIN is checked by the TPM itself, "
+ "which stops answering after a few wrong tries — so a short one is enough, and it only "
+ "ever works on this computer."
: "This unlocks the key sealed into this computer's TPM. Cancel to use your passphrase "
+ "instead.";
Confirm.IsVisible = choosing;
Accept.Content = choosing ? "SET PIN" : "UNLOCK";
}
private async Task<string?> ShowFor(Window parent)
{
answer = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
// Resolves the pending answer however the window goes away — the close button, Escape, or the
// cancellation registration above. Without it a closed window leaves an await that never returns,
// and the unlock path hangs rather than falling back to the passphrase.
Closed += (_, _) => answer.TrySetResult(null);
Pin.Focus();
await ShowDialog(parent).ConfigureAwait(true);
return await answer.Task.ConfigureAwait(true);
}
private void OnKeyDown(object? sender, KeyEventArgs e)
{
switch (e.Key)
{
case Key.Escape:
Complete(null);
e.Handled = true;
break;
case Key.Enter:
Submit();
e.Handled = true;
break;
default:
break;
}
}
private void OnAccept(object? sender, RoutedEventArgs e) => Submit();
private void OnCancel(object? sender, RoutedEventArgs e) => Complete(null);
/// <summary>
/// Checks what was typed and either answers with it or says what is wrong.
/// </summary>
/// <remarks>
/// Both checks are about a PIN being <em>chosen</em>. Nothing validates a PIN being entered, because
/// the only thing that can judge that is the TPM — refusing a four-character entry here because the
/// stored one was six would be this process deciding something it has no way to know.
/// </remarks>
private void Submit()
{
var pin = Pin.Text ?? string.Empty;
if (!Confirm.IsVisible)
{
if (pin.Length > 0)
{
Complete(pin);
}
return;
}
if (pin.Length < ShortestPin)
{
Show($"A PIN needs at least {ShortestPin} characters.");
return;
}
if (!string.Equals(pin, Confirm.Text ?? string.Empty, StringComparison.Ordinal))
{
Show("Those two do not match.");
return;
}
Complete(pin);
}
private void Show(string problem)
{
Problem.Text = problem;
Problem.IsVisible = true;
}
private void Complete(string? pin)
{
answer?.TrySetResult(pin);
Close();
}
}
@@ -72,6 +72,12 @@
"resolved": "5.6.0", "resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw==" "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
}, },
"Microsoft.TSS": {
"type": "Direct",
"requested": "[2.1.1, )",
"resolved": "2.1.1",
"contentHash": "87DBN+4ANSFO8TDRmFjjTBl6PL7tmrfofmxki4LUnE34gOdyQiBfJK/X1QSu0UfCGUtpoBVv/tD985s4FDpKiA=="
},
"MinVer": { "MinVer": {
"type": "Direct", "type": "Direct",
"requested": "[7.0.0, )", "requested": "[7.0.0, )",
@@ -490,6 +490,7 @@
"DodoSSH.Client.Ssh": "[1.0.0, )", "DodoSSH.Client.Ssh": "[1.0.0, )",
"DodoSSH.Client.Terminal": "[1.0.0, )", "DodoSSH.Client.Terminal": "[1.0.0, )",
"DodoSSH.Client.Transfer": "[1.0.0, )", "DodoSSH.Client.Transfer": "[1.0.0, )",
"Microsoft.TSS": "[2.1.1, )",
"Velopack": "[1.2.0, )" "Velopack": "[1.2.0, )"
} }
}, },
@@ -729,6 +730,12 @@
"SQLitePCLRaw.core": "2.1.11" "SQLitePCLRaw.core": "2.1.11"
} }
}, },
"Microsoft.TSS": {
"type": "CentralTransitive",
"requested": "[2.1.1, )",
"resolved": "2.1.1",
"contentHash": "87DBN+4ANSFO8TDRmFjjTBl6PL7tmrfofmxki4LUnE34gOdyQiBfJK/X1QSu0UfCGUtpoBVv/tD985s4FDpKiA=="
},
"NSec.Cryptography": { "NSec.Cryptography": {
"type": "CentralTransitive", "type": "CentralTransitive",
"requested": "[26.4.0, )", "requested": "[26.4.0, )",
@@ -0,0 +1,223 @@
using System.Security.Cryptography;
using DodoSSH.Client.App.Platform;
using DodoSSH.Client.Session;
using Tpm2Lib;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// The TPM-backed store, driven end to end against a software TPM.
/// </summary>
/// <remarks>
/// <para>
/// <b>This is the only device key store in the repository with real coverage, and the reason is worth
/// knowing.</b> <c>WindowsDeviceKeyStoreTests</c> records that the Windows store cannot be exercised at
/// all: the consent policy prompts at creation as well as at use, so anything calling <c>SaveAsync</c>
/// blocks the suite forever waiting for a person. macOS is the same shape with a Touch ID sheet. Here the
/// PIN arrives through a delegate and the TPM through a device, so both can be supplied and the whole path
/// — seal, unseal, wrong PIN, lockout — runs unattended.
/// </para>
/// <para>
/// <b>Skipped unless a software TPM is running, which CI does not start.</b> The same arrangement the
/// Windows tests have on a machine with no TPM, and for the same reason: a suite that fails because the
/// hardware is absent is a suite people learn to ignore. Start one and they run:
/// </para>
/// <code>
/// docker run --rm -p 2321:2321 debian:bookworm-slim sh -c \
/// 'apt-get update -qq &amp;&amp; apt-get install -y -qq swtpm &gt;/dev/null &amp;&amp; \
/// swtpm socket --tpm2 --tpmstate dir=/tmp --server type=tcp,port=2321,bindaddr=0.0.0.0 \
/// --ctrl type=tcp,port=2322 --flags not-need-init,startup-clear'
/// DODOSSH_TPM_TCP=127.0.0.1 dotnet test tests/DodoSSH.Client.App.Tests
/// </code>
/// <para>
/// <b>What this cannot cover is the transport.</b> Production opens <c>/dev/tpmrm0</c> through TSS.Net's
/// <c>LinuxTpmDevice</c> and these tests open a socket, so everything above the device is proved here and
/// the device itself is proved by Phase 19 of <c>docs/manual-checks.md</c> on a machine with a real chip.
/// The split is deliberate: the commands, the templates and the attributes are where the security argument
/// lives, and they are identical either way.
/// </para>
/// </remarks>
public sealed class LinuxDeviceKeyStoreTests : IDisposable
{
private readonly string directory =
Path.Combine(Path.GetTempPath(), $"dodossh-tpm-{Guid.CreateVersion7():N}");
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <summary>Where a software TPM is listening, if the person running this started one.</summary>
private static string? Simulator => Environment.GetEnvironmentVariable("DODOSSH_TPM_TCP");
/// <summary>
/// Clears the profile directory, and clears the TPM's lockout counter behind it.
/// </summary>
/// <remarks>
/// ◆ <b>The second half is not tidiness, and leaving it out made this suite lie.</b> A software TPM is
/// one piece of shared mutable state and its dictionary-attack counter is the most global thing in it:
/// <see cref="WrongPinsEventuallyLockTheTpmOut"/> deliberately trips it, and every test that ran
/// afterwards then got null from a correct PIN. Six of them assert null and passed for entirely the
/// wrong reason; the one that asserts a successful unseal failed, which is the only reason this was
/// noticed at all.
/// </remarks>
public void Dispose()
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
if (string.IsNullOrEmpty(Simulator))
{
return;
}
try
{
using var device = new SoftwareTpm(Simulator, 2321);
device.Connect();
// The lockout hierarchy has no auth value on a fresh simulator, which is exactly the state a
// real machine is not in — there, recovery is a timer measured in hours and an administrator.
// That difference is the point of Phase 19 rather than of this file.
new Tpm2(device).DictionaryAttackLockReset(TpmRh.Lockout);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// A simulator that has gone away between the test and here. The next test's own connect will
// report it far more clearly than a throw out of Dispose would.
}
}
[Fact]
public async Task WithNothingSaved_LoadingReturnsNullWithoutAsking()
{
// No TPM needed and no prompt allowed: a machine that has never registered must not raise a PIN
// box on the unlock screen. The delegate fails the test rather than answering, which is the only
// way to assert "was not asked" — see the ordering this depends on in TryLoadAsync.
var store = new LinuxDeviceKeyStore(
new ClientPaths(directory),
(_, _) => throw new InvalidOperationException("A PIN was asked for with nothing stored."),
() => throw new InvalidOperationException("The TPM was opened with nothing stored."));
(await store.TryLoadAsync(Token)).ShouldBeNull();
}
[Fact]
public async Task ARoundTripReturnsTheSameKey()
{
SkipUnlessSimulated();
var key = RandomNumberGenerator.GetBytes(32);
var store = Store("1234");
await store.SaveAsync(key, Token);
// A second store over the same directory, because the point is that nothing survives in memory:
// the blob on disk and the TPM are between these two lines, and nothing else.
var loaded = await Store("1234").TryLoadAsync(Token);
loaded.ShouldNotBeNull();
loaded.ShouldBe(key);
}
[Fact]
public async Task TheWrongPinGetsNothing()
{
SkipUnlessSimulated();
await Store("1234").SaveAsync(RandomNumberGenerator.GetBytes(32), Token);
// Null rather than an exception, and rather than the key. This is the assertion the whole design
// exists for: the blob is readable by anyone who has the file, and it opens for nobody without the
// PIN, because the TPM and not this process is what checks it.
(await Store("9999").TryLoadAsync(Token)).ShouldBeNull();
}
[Fact]
public async Task DecliningThePromptFallsBackWithoutTouchingTheTpm()
{
SkipUnlessSimulated();
await Store("1234").SaveAsync(RandomNumberGenerator.GetBytes(32), Token);
// Cancelling is ordinary — IDeviceKeyStore says so — and it must not spend one of the TPM's
// lockout attempts. The device factory throwing is what proves the TPM was never opened.
var declined = new LinuxDeviceKeyStore(
new ClientPaths(directory),
(_, _) => ValueTask.FromResult<string?>(null),
() => throw new InvalidOperationException("The TPM was opened after the prompt was declined."));
(await declined.TryLoadAsync(Token)).ShouldBeNull();
}
[Fact]
public async Task WrongPinsEventuallyLockTheTpmOut()
{
SkipUnlessSimulated();
var key = RandomNumberGenerator.GetBytes(32);
await Store("1234").SaveAsync(key, Token);
// ◆ The property that makes a four-digit PIN safe here, asserted rather than assumed.
//
// The sealed object deliberately carries no NoDA attribute, so every wrong answer advances the
// TPM's dictionary-attack counter. Remove that one attribute and this test still passes every
// other assertion in this file while the PIN becomes guessable at full speed by anything holding
// the blob — which is why the lockout is asserted by observing the *correct* PIN stop working.
for (var attempt = 0; attempt < 8; attempt++)
{
(await Store("9999").TryLoadAsync(Token)).ShouldBeNull();
}
(await Store("1234").TryLoadAsync(Token)).ShouldBeNull(
"the TPM should have stopped answering, so even the right PIN gets nothing");
}
[Fact]
public async Task ForgettingLeavesNothingThatCanBeOpened()
{
SkipUnlessSimulated();
var store = Store("1234");
await store.SaveAsync(RandomNumberGenerator.GetBytes(32), Token);
await store.ForgetAsync(Token);
// Deleting the blob is the whole of it: the sealed object was never made persistent inside the
// TPM, so there is no chip state left to clean up and nothing for a later install to trip over.
File.Exists(new ClientPaths(directory).DeviceKeyFile).ShouldBeFalse();
(await Store("1234").TryLoadAsync(Token)).ShouldBeNull();
}
[Fact]
public async Task ABlobFromSomewhereElseIsRefusedRatherThanParsed()
{
var paths = new ClientPaths(directory);
paths.EnsureCreated();
// What the Windows store writes is a bare RSA ciphertext with no header, and a dual-boot machine
// or a copied profile directory can put one here. Feeding it to the TPM as a marshalled structure
// is the failure this refuses; answering null lands on the passphrase, which works.
await File.WriteAllBytesAsync(paths.DeviceKeyFile, RandomNumberGenerator.GetBytes(256), Token);
var store = new LinuxDeviceKeyStore(
paths,
(_, _) => throw new InvalidOperationException("A PIN was asked for over an unusable blob."),
() => throw new InvalidOperationException("The TPM was opened for an unusable blob."));
(await store.TryLoadAsync(Token)).ShouldBeNull();
}
private LinuxDeviceKeyStore Store(string pin) =>
new(
new ClientPaths(directory),
(_, _) => ValueTask.FromResult<string?>(pin),
() => new SoftwareTpm(Simulator ?? "127.0.0.1", 2321));
private static void SkipUnlessSimulated() =>
Assert.SkipWhen(
string.IsNullOrEmpty(Simulator),
"No software TPM. See the remarks on this class for the one command that starts one.");
}
@@ -0,0 +1,243 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
using DodoSSH.Client.App.Platform;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// The RPM update channel: what it offers, what it refuses, and what it hands on.
/// </summary>
/// <remarks>
/// <para>
/// Driven against a stubbed <c>HttpMessageHandler</c> rather than a server, because every question here is
/// about what this class decides from what the forge said — and a real socket would add a port to bind and
/// nothing to the assertions.
/// </para>
/// <para>
/// <b>The two that matter are the version comparison and the digest.</b> The first is what keeps a rolling
/// nightly from offering a downgrade PackageKit would decline, forever, every six hours. The second is the
/// only thing standing between a truncated download and a file handed to the machine's package manager —
/// Velopack verifies its own downloads and Android checks a signature, and an RPM over HTTPS has neither.
/// </para>
/// </remarks>
public sealed class LinuxUpdateChannelTests : IDisposable
{
private const string ReleaseUrl =
"https://git.dodotech.cloud/api/v1/repos/DodoTech-Public/DodoSSH/releases/tags/nightly-desktop";
private const string ManifestUrl = "https://forge.invalid/linux-nightly.json";
private const string RpmUrl = "https://forge.invalid/dodossh-nightly.rpm";
private readonly string directory =
Path.Combine(Path.GetTempPath(), $"dodossh-updates-{Guid.CreateVersion7():N}");
private static CancellationToken Token => TestContext.Current.CancellationToken;
public void Dispose()
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public async Task ANewerPublishedVersionIsOffered()
{
var package = RandomNumberGenerator.GetBytes(2048);
var channel = Channel(package, published: "0.0.1~alpha.0.200-1", installed: "0.0.1~alpha.0.143-1");
var update = await channel.CheckAsync(Token);
update.ShouldNotBeNull();
update.Version.ShouldBe("0.0.1-alpha.0.200");
}
[Fact]
public async Task TheSameVersionIsNotOffered()
{
var channel = Channel(
RandomNumberGenerator.GetBytes(16),
published: "0.0.1~alpha.0.143-1",
installed: "0.0.1~alpha.0.143-1");
(await channel.CheckAsync(Token)).ShouldBeNull();
}
/// <summary>
/// An older published build is not offered, which is the failure this channel is most exposed to.
/// </summary>
/// <remarks>
/// The nightly release is rolling and CI replaces it on every push, so a revert or a re-run of an older
/// commit publishes a lower version. Comparing "is it different from mine" would offer it, PackageKit
/// would decline the downgrade, and the user would see a failing update every six hours until somebody
/// pushed again.
/// </remarks>
[Fact]
public async Task AnOlderPublishedVersionIsNotOffered()
{
var channel = Channel(
RandomNumberGenerator.GetBytes(16),
published: "0.0.1~alpha.0.99-1",
installed: "0.0.1~alpha.0.143-1");
(await channel.CheckAsync(Token)).ShouldBeNull();
}
/// <summary>
/// A machine that cannot say what it has treats everything as newer.
/// </summary>
/// <remarks>
/// The wrong way to fail, chosen deliberately and for the reason the android channel gives: the cost is
/// an install the package manager declines, and the cost of the other direction is a security fix that
/// never arrives.
/// </remarks>
[Fact]
public async Task AnUnknownInstalledVersionTreatsEverythingAsNewer()
{
var channel = Channel(RandomNumberGenerator.GetBytes(16), published: "0.0.1~alpha.0.1-1", installed: null);
(await channel.CheckAsync(Token)).ShouldNotBeNull();
}
[Fact]
public async Task AGoodDownloadIsKeptAndHandedToTheInstaller()
{
var package = RandomNumberGenerator.GetBytes(4096);
var handed = new List<string>();
var channel = Channel(
package,
published: "0.0.1~alpha.0.200-1",
installed: "0.0.1~alpha.0.143-1",
handToInstaller: handed.Add);
var update = await channel.CheckAsync(Token);
await channel.DownloadAsync(update!, new Progress<int>(), Token);
channel.ApplyAndRestart(update!);
handed.Count.ShouldBe(1);
(await File.ReadAllBytesAsync(handed[0], Token)).ShouldBe(package);
}
/// <summary>
/// A package whose digest does not match is refused, deleted, and never named to the installer.
/// </summary>
/// <remarks>
/// Deleted rather than merely refused, because the alternative is a file called
/// <c>dodossh-nightly-update.rpm</c> sitting in the profile directory that somebody could reasonably
/// install by hand.
/// </remarks>
[Fact]
public async Task ADownloadThatDoesNotMatchIsRefusedAndRemoved()
{
var handed = new List<string>();
var channel = Channel(
RandomNumberGenerator.GetBytes(4096),
published: "0.0.1~alpha.0.200-1",
installed: "0.0.1~alpha.0.143-1",
digest: Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(32)),
handToInstaller: handed.Add);
var update = await channel.CheckAsync(Token);
await Should.ThrowAsync<InvalidOperationException>(
async () => await channel.DownloadAsync(update!, new Progress<int>(), Token));
handed.ShouldBeEmpty();
Directory.Exists(Path.Combine(directory, "updates")).ShouldBeTrue();
Directory.GetFiles(Path.Combine(directory, "updates")).ShouldBeEmpty();
}
[Fact]
public async Task AnUpdateFromNoCheckIsRefused()
{
var channel = Channel(RandomNumberGenerator.GetBytes(16), "0.0.1~alpha.0.2-1", "0.0.1~alpha.0.1-1");
await Should.ThrowAsync<InvalidOperationException>(
async () => await channel.DownloadAsync(
new DodoSSH.Client.Session.AvailableUpdate("0.0.1-alpha.0.2"),
new Progress<int>(),
Token));
}
/// <summary>
/// A release that is reachable and carries no manifest throws rather than answering "up to date".
/// </summary>
/// <remarks>
/// Half a published release is a fact worth an answer — <c>IUpdateChannel.CheckAsync</c> spends several
/// paragraphs on why null must mean exactly one thing, after a private repository turned a total outage
/// into a reassuring sentence on every client for weeks.
/// </remarks>
[Fact]
public async Task AReleaseWithNoManifestThrows()
{
var channel = new LinuxUpdateChannel(
"nightly",
directory,
new HttpClient(new StubForge(new Dictionary<string, byte[]>(StringComparer.Ordinal)
{
[ReleaseUrl] = Encoding.UTF8.GetBytes("""{"assets":[]}"""),
})),
_ => "0.0.1~alpha.0.1-1",
_ => { });
await Should.ThrowAsync<InvalidOperationException>(async () => await channel.CheckAsync(Token));
}
private LinuxUpdateChannel Channel(
byte[] package,
string published,
string? installed,
string? digest = null,
Action<string>? handToInstaller = null)
{
// The SemVer the banner shows is derived from the rpm version the comparison uses, so the two
// cannot disagree in a way this suite would miss.
var semver = published[..published.LastIndexOf('-')].Replace('~', '-');
var manifest = $$"""
{
"rpmVersion": "{{published}}",
"version": "{{semver}}",
"rpm": "dodossh-nightly.rpm",
"sha256": "{{digest ?? Convert.ToHexStringLower(SHA256.HashData(package))}}"
}
""";
var release = $$"""
{"assets":[
{"name":"linux-nightly.json","browser_download_url":"{{ManifestUrl}}"},
{"name":"dodossh-nightly.rpm","browser_download_url":"{{RpmUrl}}"}
]}
""";
return new LinuxUpdateChannel(
"nightly",
directory,
new HttpClient(new StubForge(new Dictionary<string, byte[]>(StringComparer.Ordinal)
{
[ReleaseUrl] = Encoding.UTF8.GetBytes(release),
[ManifestUrl] = Encoding.UTF8.GetBytes(manifest),
[RpmUrl] = package,
})),
_ => installed,
handToInstaller ?? (_ => { }));
}
/// <summary>Answers the three URLs this channel asks for, and 404s everything else.</summary>
private sealed class StubForge(IReadOnlyDictionary<string, byte[]> content) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken) =>
Task.FromResult(
content.TryGetValue(request.RequestUri!.AbsoluteUri, out var bytes)
? new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) }
: new HttpResponseMessage(HttpStatusCode.NotFound));
}
}
@@ -0,0 +1,105 @@
using DodoSSH.Client.App.Platform;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// <c>RpmVersion.Compare</c> against the answers <c>rpm</c> itself gives.
/// </summary>
/// <remarks>
/// <para>
/// <b>Every expected value here was produced by rpm and not by reading the algorithm.</b> They came out of
/// <c>rpm --eval "%{lua:print(rpm.vercmp(a, b))}"</c> in a Fedora-era container, which is the same
/// comparison PackageKit will make when it decides whether to accept the update this class caused to be
/// offered. A test written from the specification would agree with a misreading of the specification.
/// </para>
/// <para>
/// The awkward cases are the point of the list. <c>1a</c> equals <c>1.a</c> because separators are skipped
/// on each side independently; <c>1.01</c> equals <c>1.1</c> because leading zeros are stripped;
/// <c>1.0_1</c> equals <c>1.0.1</c> for the first reason again; and <c>a</c> is older than <c>1</c> because
/// rpm calls a numeric segment newer than an alphabetic one whatever the letters say.
/// </para>
/// <para>
/// The four rows that carry the feature are the <c>~</c> ones. <c>0.0.1~alpha.0.143 &lt; 0.0.1</c> is what
/// makes every nightly older than the first real release, and <c>~alpha.0.9 &lt; ~alpha.0.10</c> is what
/// keeps them in order among themselves — a string comparison would get that backwards and a client would
/// stop offering updates after the ninth nightly of a version.
/// </para>
/// </remarks>
public sealed class RpmVersionTests
{
[Theory]
// Ordinary
[InlineData("1.2.3", "1.2.3", 0)]
[InlineData("1.2.3", "1.2.4", -1)]
[InlineData("1.2.3", "1.2.3.1", -1)]
[InlineData("1.2.10", "1.2.9", 1)]
[InlineData("2.0", "10.0", -1)]
[InlineData("1.0.0", "1.0", 1)]
[InlineData("1.0.0.1", "1.0.0", 1)]
[InlineData("20101121", "20101122", -1)]
// Tilde: the prerelease ordering this feature depends on
[InlineData("0.0.1~alpha.0.143", "0.0.1", -1)]
[InlineData("0.0.1~alpha.0.143", "0.0.1~alpha.0.144", -1)]
[InlineData("0.0.1~alpha.0.143", "0.0.1~alpha.0.99", 1)]
[InlineData("0.0.1~alpha.0.9", "0.0.1~alpha.0.10", -1)]
[InlineData("1.0", "1.0~rc1", 1)]
[InlineData("1~~", "1~", -1)]
[InlineData("1.0~beta", "1.0~alpha", 1)]
// Caret: after everything except the end of the string
[InlineData("1.0^post", "1.0", 1)]
[InlineData("1.0^post", "1.0^post2", -1)]
[InlineData("1.2.3", "1.2.3^", -1)]
// Separators and leading zeros, which are the surprising ones
[InlineData("1a", "1.a", 0)]
[InlineData("1.01", "1.1", 0)]
[InlineData("1.0_1", "1.0.1", 0)]
[InlineData("1.0-1", "1.0-2", -1)]
// Segments of different kinds
[InlineData("1.a", "1.b", -1)]
[InlineData("a", "1", -1)]
[InlineData("1", "a", 1)]
[InlineData("5.5p1", "5.5p2", -1)]
[InlineData("xyz10", "xyz10.1", -1)]
[InlineData("MDK", "MDK1", -1)]
public void ItAgreesWithRpm(string left, string right, int expected) =>
RpmVersion.Compare(left, right).ShouldBe(expected);
/// <summary>
/// The reverse of every case above, which rpm does not need asserting and this does.
/// </summary>
/// <remarks>
/// The marker handling is the half of the algorithm with a left and a right branch for each case, so an
/// asymmetry there would pass every row above and reverse the answer for a client whose installed
/// version happened to be the newer one — which is the direction that offers a downgrade.
/// </remarks>
[Theory]
[InlineData("0.0.1~alpha.0.143", "0.0.1")]
[InlineData("1.0^post", "1.0")]
[InlineData("1.2.3", "1.2.3^")]
[InlineData("a", "1")]
[InlineData("1.2.3", "1.2.4")]
public void ItIsAntisymmetric(string left, string right) =>
RpmVersion.Compare(right, left).ShouldBe(-RpmVersion.Compare(left, right));
/// <summary>
/// Nothing and an empty string are older than anything, and equal to each other.
/// </summary>
/// <remarks>
/// rpm's own binding refuses these outright. Here they arrive off a feed rather than out of a spec
/// file, and the answer that leaves a client on the build it already has is better than an exception
/// thrown on a six-hourly timer.
/// </remarks>
[Theory]
[InlineData(null, "1.0", -1)]
[InlineData("", "1.0", -1)]
[InlineData("1.0", null, 1)]
[InlineData(null, null, 0)]
[InlineData("", null, 0)]
public void ItTreatsNothingAsOldest(string? left, string? right, int expected) =>
RpmVersion.Compare(left, right).ShouldBe(expected);
}
@@ -0,0 +1,115 @@
using System.Buffers.Binary;
using System.Net.Sockets;
using Tpm2Lib;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// Talks to a software TPM over its TCP data channel, so the real store can be driven without hardware.
/// </summary>
/// <remarks>
/// <para>
/// <b>TSS.Net ships a TcpTpmDevice and it cannot be used here.</b> It also opens the simulator's
/// <em>platform</em> socket and exchanges a handshake that the Microsoft reference simulator answers and
/// swtpm does not, so <c>Connect()</c> blocks forever — with no error and no timeout. That was found by
/// watching it hang, and it is the whole reason this file exists.
/// </para>
/// <para>
/// The data channel is the framing <c>tcti-mssim</c> uses and swtpm does implement: a command word, a
/// locality byte, a length and the bytes, answered by a length, the bytes and an acknowledgement word.
/// Power management is unnecessary when swtpm is started with <c>--flags not-need-init,startup-clear</c>,
/// which is the invocation <see cref="LinuxDeviceKeyStoreTests"/> documents.
/// </para>
/// </remarks>
internal sealed class SoftwareTpm : Tpm2Device
{
/// <summary>tcti-mssim's "here is a command for the TPM".</summary>
private const uint SendCommand = 8;
private readonly string host;
private readonly int port;
private TcpClient? client;
private NetworkStream? stream;
internal SoftwareTpm(string host, int port)
{
this.host = host;
this.port = port;
}
public override void Connect()
{
client = new TcpClient();
client.Connect(host, port);
stream = client.GetStream();
}
public override void Close()
{
stream?.Dispose();
client?.Dispose();
stream = null;
client = null;
}
public override void DispatchCommand(CommandModifier active, byte[] inBuf, out byte[] outBuf)
{
ArgumentNullException.ThrowIfNull(active);
ArgumentNullException.ThrowIfNull(inBuf);
if (stream is null)
{
throw new InvalidOperationException("Not connected.");
}
Span<byte> header = stackalloc byte[9];
BinaryPrimitives.WriteUInt32BigEndian(header, SendCommand);
header[4] = active.ActiveLocality;
BinaryPrimitives.WriteUInt32BigEndian(header[5..], (uint)inBuf.Length);
stream.Write(header);
stream.Write(inBuf, 0, inBuf.Length);
stream.Flush();
outBuf = ReadExactly((int)ReadUInt32());
// The trailing acknowledgement word. It has to be consumed even though nothing reads it, or every
// response after this one is misframed by four bytes.
_ = ReadUInt32();
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
Close();
}
base.Dispose(disposing);
}
private uint ReadUInt32() => BinaryPrimitives.ReadUInt32BigEndian(ReadExactly(4));
private byte[] ReadExactly(int count)
{
var buffer = new byte[count];
var read = 0;
while (read < count)
{
var got = stream!.Read(buffer, read, count - read);
if (got <= 0)
{
throw new IOException("The TPM closed the connection.");
}
read += got;
}
return buffer;
}
}
@@ -31,6 +31,18 @@ public sealed class WindowsDeviceKeyStoreTests
{ {
private static CancellationToken Token => TestContext.Current.CancellationToken; private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <summary>
/// The PIN prompt this platform never reaches, because Windows raises its own.
/// </summary>
/// <remarks>
/// <c>ForThisMachine</c> takes one so that the Linux store has somewhere to ask from — see
/// <c>LinuxDeviceKeyStore</c>. On Windows nothing calls it, and answering null rather than throwing is
/// the honest stand-in: if the selection ever did reach for it here, the result would be a store that
/// declines rather than a test that fails somewhere unrelated.
/// </remarks>
private static ValueTask<string?> NoPin(DevicePinPurpose purpose, CancellationToken cancellationToken) =>
ValueTask.FromResult<string?>(null);
[Fact] [Fact]
public async Task OnAMachineWithATpm_TheStoreOffersItself() public async Task OnAMachineWithATpm_TheStoreOffersItself()
{ {
@@ -39,7 +51,7 @@ public sealed class WindowsDeviceKeyStoreTests
// provider reports itself present on machines where creating a key then fails. // provider reports itself present on machines where creating a key then fails.
SkipUnlessSupported(); SkipUnlessSupported();
var store = DesktopDeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath())); var store = DesktopDeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()), NoPin);
store.ShouldBeOfType<WindowsDeviceKeyStore>(); store.ShouldBeOfType<WindowsDeviceKeyStore>();
(await store.IsAvailableAsync(Token)).ShouldBeTrue(); (await store.IsAvailableAsync(Token)).ShouldBeTrue();
@@ -66,7 +78,7 @@ public sealed class WindowsDeviceKeyStoreTests
private static void SkipUnlessSupported() private static void SkipUnlessSupported()
{ {
var supported = OperatingSystem.IsWindows() var supported = OperatingSystem.IsWindows()
&& DesktopDeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath())) && DesktopDeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()), NoPin)
is WindowsDeviceKeyStore; is WindowsDeviceKeyStore;
if (!supported) if (!supported)
@@ -479,6 +479,7 @@
"DodoSSH.Client.Ssh": "[1.0.0, )", "DodoSSH.Client.Ssh": "[1.0.0, )",
"DodoSSH.Client.Terminal": "[1.0.0, )", "DodoSSH.Client.Terminal": "[1.0.0, )",
"DodoSSH.Client.Transfer": "[1.0.0, )", "DodoSSH.Client.Transfer": "[1.0.0, )",
"Microsoft.TSS": "[2.1.1, )",
"Velopack": "[1.2.0, )" "Velopack": "[1.2.0, )"
} }
}, },
@@ -718,6 +719,12 @@
"SQLitePCLRaw.core": "2.1.11" "SQLitePCLRaw.core": "2.1.11"
} }
}, },
"Microsoft.TSS": {
"type": "CentralTransitive",
"requested": "[2.1.1, )",
"resolved": "2.1.1",
"contentHash": "87DBN+4ANSFO8TDRmFjjTBl6PL7tmrfofmxki4LUnE34gOdyQiBfJK/X1QSu0UfCGUtpoBVv/tD985s4FDpKiA=="
},
"NSec.Cryptography": { "NSec.Cryptography": {
"type": "CentralTransitive", "type": "CentralTransitive",
"requested": "[26.4.0, )", "requested": "[26.4.0, )",