From 45525385330d8f832febf87dc541ddb747dde290 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Sat, 22 Aug 2026 20:02:30 +0200 Subject: [PATCH] Let the Linux head check for updates, and seal its device key in the TPM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/ci.yml | 37 +- Directory.Packages.props | 22 + README.md | 75 ++- build/linux/dodossh.spec | 40 +- docs/adr/0007-device-key-protection.md | 57 +- .../0013-desktop-distribution-and-updates.md | 73 ++- docs/manual-checks.md | 100 +++- scripts/ci-linux-rpm.sh | 48 +- src/DodoSSH.Client.App/App.axaml.cs | 19 +- .../DodoSSH.Client.App.csproj | 8 + .../Platform/LinuxDeviceKeyStore.cs | 485 +++++++++++++++++ .../Platform/LinuxUpdateChannel.cs | 495 ++++++++++++++++++ src/DodoSSH.Client.App/Platform/RpmVersion.cs | 213 ++++++++ .../Platform/VelopackUpdateChannel.cs | 47 +- .../Platform/WindowsDeviceKeyStore.cs | 45 +- .../Views/DevicePinWindow.axaml | 62 +++ .../Views/DevicePinWindow.axaml.cs | 203 +++++++ src/DodoSSH.Client.App/packages.lock.json | 6 + .../packages.lock.json | 7 + .../LinuxDeviceKeyStoreTests.cs | 223 ++++++++ .../LinuxUpdateChannelTests.cs | 243 +++++++++ .../RpmVersionTests.cs | 105 ++++ tests/DodoSSH.Client.App.Tests/SoftwareTpm.cs | 115 ++++ .../WindowsDeviceKeyStoreTests.cs | 16 +- .../packages.lock.json | 7 + 25 files changed, 2646 insertions(+), 105 deletions(-) create mode 100644 src/DodoSSH.Client.App/Platform/LinuxDeviceKeyStore.cs create mode 100644 src/DodoSSH.Client.App/Platform/LinuxUpdateChannel.cs create mode 100644 src/DodoSSH.Client.App/Platform/RpmVersion.cs create mode 100644 src/DodoSSH.Client.App/Views/DevicePinWindow.axaml create mode 100644 src/DodoSSH.Client.App/Views/DevicePinWindow.axaml.cs create mode 100644 tests/DodoSSH.Client.App.Tests/LinuxDeviceKeyStoreTests.cs create mode 100644 tests/DodoSSH.Client.App.Tests/LinuxUpdateChannelTests.cs create mode 100644 tests/DodoSSH.Client.App.Tests/RpmVersionTests.cs create mode 100644 tests/DodoSSH.Client.App.Tests/SoftwareTpm.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95c22e1..390eabd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1254,24 +1254,39 @@ jobs: exit 1 fi + # ◆ THE PACKAGE FIRST AND THE MANIFEST LAST, WHICH IS THE ORDER THE CLIENT DEPENDS ON. + # + # LinuxUpdateChannel reads linux-.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 }}' - rpmName='${{ steps.linuxpack.outputs.rpmName }}' - echo "Uploading $rpmName ($(wc -c < "$rpm") bytes)" - curl -fsS -X POST -H "$auth" \ - -F "attachment=@$rpm" \ - "$api/releases/$release/assets?name=$rpmName" >/dev/null + manifest='${{ steps.linuxpack.outputs.manifest }}' + + linuxNames="" + for file in "$rpm" "$manifest"; do + 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 # android job once created a release, uploaded nothing, and reported success for every # upload it never made. withRpm="$(curl -fsS -H "$auth" "$api/releases/$release")" - if ! printf '%s' "$withRpm" | grep -qF "\"name\":\"$rpmName\""; then - echo "The release was created but $rpmName is not on it:" >&2 - printf '%s\n' "$withRpm" >&2 - exit 1 - fi + for name in $linuxNames; do + if ! printf '%s' "$withRpm" | grep -qF "\"name\":\"$name\""; then + echo "The release was created but $name is not on it:" >&2 + printf '%s\n' "$withRpm" >&2 + exit 1 + fi + done - echo "Published nightly desktop $VERSION as $TAG, with $rpmName" + echo "Published nightly desktop $VERSION as $TAG, with$linuxNames" image: name: api image diff --git a/Directory.Packages.props b/Directory.Packages.props index 84d631c..8d8fd23 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -173,6 +173,28 @@ chosen user to a known-vulnerable build. See docs/adr/0013-desktop-distribution-and-updates.md. --> + + + + + + diff --git a/src/DodoSSH.Client.App/Platform/LinuxDeviceKeyStore.cs b/src/DodoSSH.Client.App/Platform/LinuxDeviceKeyStore.cs new file mode 100644 index 0000000..37a5ea5 --- /dev/null +++ b/src/DodoSSH.Client.App/Platform/LinuxDeviceKeyStore.cs @@ -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; + +/// Why the store is asking for a PIN. +/// +/// 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. +/// +public enum DevicePinPurpose +{ + /// A PIN is being chosen, as part of registering this machine. + Choosing, + + /// A PIN is being asked for, to unlock. + Entering, +} + +/// +/// Keeps the device key sealed inside the TPM, released only against a PIN the TPM itself checks. +/// +/// +/// +/// The Linux answer to and MacDeviceKeyStore, and ADR 0007's +/// bar is the one it has to clear: the enforcement must be the platform's rather than this process's. +/// 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. +/// +/// +/// Here the TPM holds the key and the TPM checks the PIN. The 32-byte device key is sealed into a +/// keyedhash object whose userAuth 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. +/// +/// +/// And the TPM counts the failures, which is what makes a short PIN safe. The sealed object +/// deliberately does not carry NoDA, 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. +/// +/// +/// ◆ What this does NOT match, stated because the difference is real and invisible. 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. +/// +/// +/// Availability is narrower than "the machine has a TPM". On Fedora /dev/tpmrm0 is owned by +/// root:tss and mode 0660, and desktop users are not in tss — 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. +/// +/// +[SupportedOSPlatform("linux")] +public sealed class LinuxDeviceKeyStore : IDeviceKeyStore +{ + /// What the blob on disk starts with, so a file from elsewhere is refused rather than parsed. + private static readonly byte[] Magic = "DSHTPMv1"u8.ToArray(); + + /// + /// Domain separation for the PIN, so the value handed to the TPM is this application's and not the + /// user's PIN as typed. + /// + /// + /// 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. + /// + private const string PinLabel = "dodossh/devicekey/pin/v1"; + + private readonly ClientPaths paths; + private readonly Func> askForPin; + private readonly Func openDevice; + + /// Creates a store against this machine's TPM. + /// Where the sealed blob lives. + /// + /// Shows the PIN prompt and answers with what was typed, or null if the user declined. Declining is + /// ordinary — see — and lands on the passphrase. + /// + public LinuxDeviceKeyStore( + ClientPaths paths, + Func> askForPin) + : this(paths, askForPin, static () => new LinuxTpmDevice()) + { + } + + /// Creates a store against a named TPM, which is how the tests reach a simulator. + /// + /// 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 — WindowsDeviceKeyStoreTests 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. + /// + internal LinuxDeviceKeyStore( + ClientPaths paths, + Func> askForPin, + Func openDevice) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(askForPin); + ArgumentNullException.ThrowIfNull(openDevice); + + this.paths = paths; + this.askForPin = askForPin; + this.openDevice = openDevice; + } + + /// + /// The parent every sealed object hangs off, as a template rather than as a stored handle. + /// + /// + /// + /// Recreated from a fixed template on every use, and not made persistent. A persistent handle + /// would occupy one of the TPM's small number of NV slots forever, would have to be cleaned up by + /// , and would collide with whatever else on the machine had chosen the same + /// index. Deriving it costs a command and leaves nothing behind. + /// + /// + /// ECC P-256 rather than RSA-2048, and that is a user-visible choice. 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. + /// + /// + /// NoDA is set here 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. + /// + /// + 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()); + + /// + /// The sealed object: bytes the TPM holds, released against an authValue the TPM checks. + /// + /// + /// Keyedhash with a null scheme is TPM 2.0's "just keep these bytes" object. UserWithAuth is + /// what makes the authValue the thing that opens it, and the deliberate absence of + /// NoDA 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. + /// + private static TpmPublic SealedTemplate => new( + TpmAlgId.Sha256, + ObjectAttr.FixedTPM | ObjectAttr.FixedParent | ObjectAttr.UserWithAuth, + null, + new KeyedhashParms(new NullSchemeKeyedhash()), + new Tpm2bDigestKeyedhash()); + + /// Whether this machine has a TPM this process may use. + /// + /// Probes by creating and flushing a primary rather than by looking for the device node, for the + /// reason WindowsDeviceKeyStore.IsSupported 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. + /// + internal static bool IsSupported() => IsSupported(static () => new LinuxTpmDevice()); + + /// + internal static bool IsSupported(Func 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; + } + } + + /// + public ValueTask IsAvailableAsync(CancellationToken cancellationToken) => + ValueTask.FromResult(IsSupported(openDevice)); + + /// + /// + /// Asks for a PIN first and writes nothing if the user declines, which matters more here than the + /// symmetry suggests: SaveAsync is called as part of registering this machine, and a half-done + /// registration would leave a wrap on the server that nothing can open. + /// + public async ValueTask SaveAsync( + ReadOnlyMemory 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); + } + + /// Seals the key into the TPM and answers with the blob to store. + 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); + } + } + + /// + /// + /// + /// One attempt per call, and a wrong PIN is not retried here. 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. + /// + /// + /// A locked-out TPM is deliberately indistinguishable from a wrong PIN. + /// 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. + /// + /// + public async ValueTask 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); + } + + /// Asks the TPM to release the key, and answers null however it declines. + 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); + } + } + + /// + /// + /// 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 + /// — 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. + /// + public ValueTask ForgetAsync(CancellationToken cancellationToken) + { + if (File.Exists(paths.DeviceKeyFile)) + { + File.Delete(paths.DeviceKeyFile); + } + + return ValueTask.CompletedTask; + } + + /// Derives the TPM authValue from the PIN as typed. + 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 _); + + /// Puts the two halves of the sealed object in one file. + /// + /// 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. + /// + 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; + } + + /// Reads back what wrote, or answers false. + 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(publicPart); + priv = Marshaller.FromTpmRepresentation(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; + } + } +} diff --git a/src/DodoSSH.Client.App/Platform/LinuxUpdateChannel.cs b/src/DodoSSH.Client.App/Platform/LinuxUpdateChannel.cs new file mode 100644 index 0000000..f64f3d2 --- /dev/null +++ b/src/DodoSSH.Client.App/Platform/LinuxUpdateChannel.cs @@ -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; + +/// What a channel's release says it is publishing. +/// +/// +/// Written by scripts/ci-linux-rpm.sh 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. +/// +/// +/// The comparison is and never . That is the string +/// rpm will order the install against, so comparing anything else would let this offer an update +/// PackageKit then refuses — the same argument AndroidChannelManifest makes for versionCode. +/// The two differ exactly where it matters: 0.0.1-alpha.0.143 and 0.0.1~alpha.0.143 are one +/// build, and only the second sorts below 0.0.1. +/// +/// +/// The version-release string rpm knows the package by. +/// What that build calls itself, for a human. +/// The asset on the same release that holds it. +/// The package's digest, lower-case hex. +internal sealed record LinuxChannelManifest( + [property: JsonPropertyName("rpmVersion")] string RpmVersion, + [property: JsonPropertyName("version")] string Version, + [property: JsonPropertyName("rpm")] string Rpm, + [property: JsonPropertyName("sha256")] string Sha256); + +/// One release as the forge describes it. Only the assets are read. +internal sealed record LinuxForgeRelease( + [property: JsonPropertyName("assets")] IReadOnlyList? Assets); + +/// One file attached to a release. +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; + +/// +/// Fetches a newer RPM from the project's own forge and asks the system's package manager to install it. +/// +/// +/// +/// Nothing here replaces anything. That is the difference from +/// 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: is false, and the answer may be no. +/// +/// +/// The feed is a constant and the deployment is never asked. 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. +/// +/// +/// The digest is checked and 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 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. +/// +/// +[SupportedOSPlatform("linux")] +internal sealed class LinuxUpdateChannel : IUpdateChannel +{ + /// The project's own forge, and the one address in this file. + /// + /// The owner half of the path matters for the reason AndroidUpdateChannel 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. + /// + 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 installedVersion; + private readonly Action handToInstaller; + + private (string Version, string RpmVersion, string Url, string Sha256)? found; + private string? fetched; + + /// Creates a channel for the running build. + internal LinuxUpdateChannel(string channel) + : this( + channel, + ClientPaths.ForChannel(DesktopChannel.Name).DataDirectory, + new HttpClient(), + InstalledRpmVersion, + HandToPackageKit) + { + } + + /// Creates a channel with its outside world supplied, which is how the tests reach it. + internal LinuxUpdateChannel( + string channel, + string dataDirectory, + HttpClient http, + Func installedVersion, + Action 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"; + } + + /// + /// + /// True unconditionally, because whether this copy is one that can be updated was already decided by + /// — a build run from a checkout never reaches this class + /// at all. The same split Velopack's side uses, where IsInstalled is checked once at + /// composition rather than at every call site. + /// + public bool IsSupported => true; + + /// + public string CurrentVersion => ClientVersion.Current; + + /// + /// + /// 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. + /// + public bool ApplyingEndsTheProcess => false; + + /// + /// The forge could not be reached, or refused. + /// The release is there and does not carry a usable build. + public async Task 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); + } + + /// + public async Task DownloadAsync( + AvailableUpdate update, + IProgress 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; + } + + /// Streams one URL to one path, reporting progress as it goes. + private async Task FetchAsync( + string url, + string path, + IProgress 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)); + } + } + } + } + + /// + /// + /// + /// This installs nothing and returns immediately. 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. + /// + /// + /// The process is not restarted and must not be. 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. + /// + /// + 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); + } + + /// Which release this build's channel reads. + /// + /// releases/latest skips prereleases, which keeps the nightly — published as one — out of the + /// release channel's answer even though both live on the same forge. + /// + 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 ReadAsync( + string url, + JsonTypeInfo 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 Sha256OfAsync(string path, CancellationToken cancellationToken) + { + var stream = File.OpenRead(path); + + await using (stream.ConfigureAwait(false)) + { + return await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// What rpm records for the installed package, which is the number a newer build must beat. + /// + /// + /// + /// Read from the package database rather than from the assembly, for the reason + /// AndroidUpdateChannel.ReadInstalledVersionCode 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 + /// scripts/ci-linux-rpm.sh performs, and two copies of one rule is a pair that can disagree. + /// + /// + /// A machine that cannot answer gets null, which the caller reads as 0 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. + /// + /// + 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; + } + } + + /// Asks PackageKit to install a local file, and lets polkit do the asking. + /// + /// + /// Over gdbus rather than a D-Bus library, and that is a dependency argument rather than a + /// stylistic one. gdbus 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. BrowserLauncher settles the same trade the + /// same way one layer down. + /// + /// + /// Two calls, because PackageKit's interface is a factory. CreateTransaction returns an + /// object path and the work is asked of that; a transaction is single-use, so this cannot be cached. + /// + /// + /// The flags word is zero, and zero is load-bearing. Bit 0 is ONLY_TRUSTED. 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 package-install-untrusted action, which asks for an administrator + /// rather than proceeding quietly. The user sees what they are agreeing to. + /// + /// + 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}']"); + } + + /// Runs one gdbus call and answers with its output. + 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; + } +} diff --git a/src/DodoSSH.Client.App/Platform/RpmVersion.cs b/src/DodoSSH.Client.App/Platform/RpmVersion.cs new file mode 100644 index 0000000..12ec446 --- /dev/null +++ b/src/DodoSSH.Client.App/Platform/RpmVersion.cs @@ -0,0 +1,213 @@ +namespace DodoSSH.Client.App.Platform; + +/// +/// Compares two RPM version strings the way rpm itself does. +/// +/// +/// +/// Why this exists rather than a SemVer comparison. AndroidUpdateChannel 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 versionCode; here it is the RPM version, and the authority that reads it is +/// rpm — so this is rpmvercmp and not an approximation of it. +/// +/// +/// 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. +/// +/// +/// The algorithm is rpm's, including the parts that look like bugs. Segments of digits and segments +/// of letters are compared separately and never against each other; everything that is neither is a +/// separator, so 1.0_1 and 1.0.1 are equal. Leading zeros are stripped, so 1.01 and +/// 1.1 are equal too. ~ sorts before anything including the end of the string, which is what +/// makes 0.0.1~alpha.0.143 older than 0.0.1 and is the whole reason +/// scripts/ci-linux-rpm.sh translates a SemVer prerelease to one. ^ sorts after everything +/// except the end of the string. +/// +/// +/// Verified against rpm's own answers rather than against a reading of the source: the cases in +/// RpmVersionTests were generated by running rpm.vercmp in a container, so a disagreement is +/// a disagreement with the program that will actually decide. +/// +/// +internal static class RpmVersion +{ + /// + /// Orders two RPM version strings. + /// + /// The left version. + /// The right version. + /// -1, 0 or 1, as rpmvercmp would answer. + /// + /// Null and empty are accepted and treated as equal to each other and older than anything else, which + /// rpm'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. + /// + 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; + } + + /// Advances past anything that divides segments rather than taking part in them. + private static void Skip(string value, ref int at) + { + while (at < value.Length && !IsVersionCharacter(value[at])) + { + at++; + } + } + + /// + /// Orders the two sides when either is at a ~ or a ^. + /// + /// + /// 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. + /// + /// + /// ~ sorts before everything including the end of the string, which is what carries the whole + /// prerelease ordering. ^ sorts after everything except 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. + /// + 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; + } + + /// + /// Takes one segment from each side and orders them, advancing past both. + /// + /// The ordering, or null if the two segments were equal and the walk should continue. + 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; + } + + /// Advances over one run of digits, or one run of letters. + 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++; + } + } + + /// Whether this character takes part in the comparison rather than dividing it. + private static bool IsVersionCharacter(char value) => + char.IsAsciiLetterOrDigit(value) || value == '~' || value == '^'; +} diff --git a/src/DodoSSH.Client.App/Platform/VelopackUpdateChannel.cs b/src/DodoSSH.Client.App/Platform/VelopackUpdateChannel.cs index 677297e..346be69 100644 --- a/src/DodoSSH.Client.App/Platform/VelopackUpdateChannel.cs +++ b/src/DodoSSH.Client.App/Platform/VelopackUpdateChannel.cs @@ -1,3 +1,4 @@ +using System.Runtime.Versioning; using DodoSSH.Client.Session; using Velopack; using Velopack.Sources; @@ -31,11 +32,18 @@ internal static class UpdateChannels /// internal static IUpdateChannel ForThisMachine() { - // Two platforms now, and the check is a list rather than a negation for a reason: Linux reaches - // 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 - // feed carrying nothing it could apply. Naming the platforms that are packaged keeps a future - // AppImage an addition here rather than a thing that silently already half-happened. + // ◆ Linux is answered first and separately, because it is not a Velopack platform at all. + // + // This used to be the place that said so — a negation over the two packaged platforms, with a note + // that a future AppImage should be an addition here rather than something that had silently + // 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()) { return new UnavailableUpdateChannel(); @@ -57,6 +65,35 @@ internal static class UpdateChannels return new UnavailableUpdateChannel(); } } + + /// The Linux channel, if this copy is one a package manager put here. + /// + /// + /// The question Velopack answers with IsInstalled, answered by looking at where this process + /// lives. The RPM owns /opt/DodoSSH or /opt/DodoSSH.Nightly — the spec puts it there + /// and nothing else writes to /opt 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. + /// + /// + /// 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. + /// + /// + [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(); + } } /// diff --git a/src/DodoSSH.Client.App/Platform/WindowsDeviceKeyStore.cs b/src/DodoSSH.Client.App/Platform/WindowsDeviceKeyStore.cs index e98d584..7639534 100644 --- a/src/DodoSSH.Client.App/Platform/WindowsDeviceKeyStore.cs +++ b/src/DodoSSH.Client.App/Platform/WindowsDeviceKeyStore.cs @@ -9,17 +9,26 @@ namespace DodoSSH.Client.App.Platform; /// /// /// -/// 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 and therefore -/// keeps asking for the passphrase — which is the honest answer rather than a degraded one. +/// One place decides, so nothing above has to carry a platform guard. A machine with no secure hardware +/// gets and therefore keeps asking for the passphrase — which is +/// the honest answer rather than a degraded one. /// /// -/// Both real stores are asked whether they work rather than told that they do. Each -/// IsSupported probes by doing the thing — creating a throwaway key and deleting it — because on -/// both 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 -/// unsigned development build, since enclave keys need a signing identity. Inferring from the OS would -/// mean each of those discovering the truth at the moment somebody tried to unlock. +/// Every real store is asked whether it works rather than told that it does. Each +/// IsSupported probes by doing the thing — creating a throwaway key and discarding it — because on +/// 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, on macOS also every unsigned +/// development build since enclave keys need a signing identity, and on Linux the ordinary case of a +/// 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. +/// +/// +/// The three are not equally strong, and the ordering here does not imply they are. 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 checking is the TPM's. That is a real difference, it is +/// recorded in ADR 0007 rather than smoothed over, and it is why is +/// tried last rather than first among platforms that could in principle overlap. /// /// /// "Desktop", because the choice belongs to a head rather than to the session layer. This file used @@ -33,9 +42,20 @@ namespace DodoSSH.Client.App.Platform; public static class DesktopDeviceKeyStores { /// The best store this machine supports. - public static IDeviceKeyStore ForThisMachine(ClientPaths paths) + /// Where a stored key lives. + /// + /// 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 App.axaml.cs. + /// + public static IDeviceKeyStore ForThisMachine( + ClientPaths paths, + Func> askForPin) { ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(askForPin); if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported()) { @@ -47,6 +67,11 @@ public static class DesktopDeviceKeyStores return new MacDeviceKeyStore(paths); } + if (OperatingSystem.IsLinux() && LinuxDeviceKeyStore.IsSupported()) + { + return new LinuxDeviceKeyStore(paths, askForPin); + } + return new UnavailableDeviceKeyStore(); } } diff --git a/src/DodoSSH.Client.App/Views/DevicePinWindow.axaml b/src/DodoSSH.Client.App/Views/DevicePinWindow.axaml new file mode 100644 index 0000000..8c6ec8f --- /dev/null +++ b/src/DodoSSH.Client.App/Views/DevicePinWindow.axaml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + +