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 @@ + + + + + + + + + + + + + + + + + + + +