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

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

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

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

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

Three details that are load-bearing rather than incidental:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No files matched your search

@@ -490,6 +490,7 @@
"DodoSSH.Client.Ssh": "[1.0.0, )",
"DodoSSH.Client.Terminal": "[1.0.0, )",
"DodoSSH.Client.Transfer": "[1.0.0, )",
"Microsoft.TSS": "[2.1.1, )",
"Velopack": "[1.2.0, )"
}
},
@@ -729,6 +730,12 @@
"SQLitePCLRaw.core": "2.1.11"
}
},
"Microsoft.TSS": {
"type": "CentralTransitive",
"requested": "[2.1.1, )",
"resolved": "2.1.1",
"contentHash": "87DBN+4ANSFO8TDRmFjjTBl6PL7tmrfofmxki4LUnE34gOdyQiBfJK/X1QSu0UfCGUtpoBVv/tD985s4FDpKiA=="
},
"NSec.Cryptography": {
"type": "CentralTransitive",
"requested": "[26.4.0, )",
@@ -0,0 +1,223 @@
using System.Security.Cryptography;
using DodoSSH.Client.App.Platform;
using DodoSSH.Client.Session;
using Tpm2Lib;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// The TPM-backed store, driven end to end against a software TPM.
/// </summary>
/// <remarks>
/// <para>
/// <b>This is the only device key store in the repository with real coverage, and the reason is worth
/// knowing.</b> <c>WindowsDeviceKeyStoreTests</c> records that the Windows store cannot be exercised at
/// all: the consent policy prompts at creation as well as at use, so anything calling <c>SaveAsync</c>
/// blocks the suite forever waiting for a person. macOS is the same shape with a Touch ID sheet. Here the
/// PIN arrives through a delegate and the TPM through a device, so both can be supplied and the whole path
/// — seal, unseal, wrong PIN, lockout — runs unattended.
/// </para>
/// <para>
/// <b>Skipped unless a software TPM is running, which CI does not start.</b> The same arrangement the
/// Windows tests have on a machine with no TPM, and for the same reason: a suite that fails because the
/// hardware is absent is a suite people learn to ignore. Start one and they run:
/// </para>
/// <code>
/// docker run --rm -p 2321:2321 debian:bookworm-slim sh -c \
/// 'apt-get update -qq &amp;&amp; apt-get install -y -qq swtpm &gt;/dev/null &amp;&amp; \
/// swtpm socket --tpm2 --tpmstate dir=/tmp --server type=tcp,port=2321,bindaddr=0.0.0.0 \
/// --ctrl type=tcp,port=2322 --flags not-need-init,startup-clear'
/// DODOSSH_TPM_TCP=127.0.0.1 dotnet test tests/DodoSSH.Client.App.Tests
/// </code>
/// <para>
/// <b>What this cannot cover is the transport.</b> Production opens <c>/dev/tpmrm0</c> through TSS.Net's
/// <c>LinuxTpmDevice</c> and these tests open a socket, so everything above the device is proved here and
/// the device itself is proved by Phase 19 of <c>docs/manual-checks.md</c> on a machine with a real chip.
/// The split is deliberate: the commands, the templates and the attributes are where the security argument
/// lives, and they are identical either way.
/// </para>
/// </remarks>
public sealed class LinuxDeviceKeyStoreTests : IDisposable
{
private readonly string directory =
Path.Combine(Path.GetTempPath(), $"dodossh-tpm-{Guid.CreateVersion7():N}");
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <summary>Where a software TPM is listening, if the person running this started one.</summary>
private static string? Simulator => Environment.GetEnvironmentVariable("DODOSSH_TPM_TCP");
/// <summary>
/// Clears the profile directory, and clears the TPM's lockout counter behind it.
/// </summary>
/// <remarks>
/// ◆ <b>The second half is not tidiness, and leaving it out made this suite lie.</b> A software TPM is
/// one piece of shared mutable state and its dictionary-attack counter is the most global thing in it:
/// <see cref="WrongPinsEventuallyLockTheTpmOut"/> deliberately trips it, and every test that ran
/// afterwards then got null from a correct PIN. Six of them assert null and passed for entirely the
/// wrong reason; the one that asserts a successful unseal failed, which is the only reason this was
/// noticed at all.
/// </remarks>
public void Dispose()
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
if (string.IsNullOrEmpty(Simulator))
{
return;
}
try
{
using var device = new SoftwareTpm(Simulator, 2321);
device.Connect();
// The lockout hierarchy has no auth value on a fresh simulator, which is exactly the state a
// real machine is not in — there, recovery is a timer measured in hours and an administrator.
// That difference is the point of Phase 19 rather than of this file.
new Tpm2(device).DictionaryAttackLockReset(TpmRh.Lockout);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// A simulator that has gone away between the test and here. The next test's own connect will
// report it far more clearly than a throw out of Dispose would.
}
}
[Fact]
public async Task WithNothingSaved_LoadingReturnsNullWithoutAsking()
{
// No TPM needed and no prompt allowed: a machine that has never registered must not raise a PIN
// box on the unlock screen. The delegate fails the test rather than answering, which is the only
// way to assert "was not asked" — see the ordering this depends on in TryLoadAsync.
var store = new LinuxDeviceKeyStore(
new ClientPaths(directory),
(_, _) => throw new InvalidOperationException("A PIN was asked for with nothing stored."),
() => throw new InvalidOperationException("The TPM was opened with nothing stored."));
(await store.TryLoadAsync(Token)).ShouldBeNull();
}
[Fact]
public async Task ARoundTripReturnsTheSameKey()
{
SkipUnlessSimulated();
var key = RandomNumberGenerator.GetBytes(32);
var store = Store("1234");
await store.SaveAsync(key, Token);
// A second store over the same directory, because the point is that nothing survives in memory:
// the blob on disk and the TPM are between these two lines, and nothing else.
var loaded = await Store("1234").TryLoadAsync(Token);
loaded.ShouldNotBeNull();
loaded.ShouldBe(key);
}
[Fact]
public async Task TheWrongPinGetsNothing()
{
SkipUnlessSimulated();
await Store("1234").SaveAsync(RandomNumberGenerator.GetBytes(32), Token);
// Null rather than an exception, and rather than the key. This is the assertion the whole design
// exists for: the blob is readable by anyone who has the file, and it opens for nobody without the
// PIN, because the TPM and not this process is what checks it.
(await Store("9999").TryLoadAsync(Token)).ShouldBeNull();
}
[Fact]
public async Task DecliningThePromptFallsBackWithoutTouchingTheTpm()
{
SkipUnlessSimulated();
await Store("1234").SaveAsync(RandomNumberGenerator.GetBytes(32), Token);
// Cancelling is ordinary — IDeviceKeyStore says so — and it must not spend one of the TPM's
// lockout attempts. The device factory throwing is what proves the TPM was never opened.
var declined = new LinuxDeviceKeyStore(
new ClientPaths(directory),
(_, _) => ValueTask.FromResult<string?>(null),
() => throw new InvalidOperationException("The TPM was opened after the prompt was declined."));
(await declined.TryLoadAsync(Token)).ShouldBeNull();
}
[Fact]
public async Task WrongPinsEventuallyLockTheTpmOut()
{
SkipUnlessSimulated();
var key = RandomNumberGenerator.GetBytes(32);
await Store("1234").SaveAsync(key, Token);
// ◆ The property that makes a four-digit PIN safe here, asserted rather than assumed.
//
// The sealed object deliberately carries no NoDA attribute, so every wrong answer advances the
// TPM's dictionary-attack counter. Remove that one attribute and this test still passes every
// other assertion in this file while the PIN becomes guessable at full speed by anything holding
// the blob — which is why the lockout is asserted by observing the *correct* PIN stop working.
for (var attempt = 0; attempt < 8; attempt++)
{
(await Store("9999").TryLoadAsync(Token)).ShouldBeNull();
}
(await Store("1234").TryLoadAsync(Token)).ShouldBeNull(
"the TPM should have stopped answering, so even the right PIN gets nothing");
}
[Fact]
public async Task ForgettingLeavesNothingThatCanBeOpened()
{
SkipUnlessSimulated();
var store = Store("1234");
await store.SaveAsync(RandomNumberGenerator.GetBytes(32), Token);
await store.ForgetAsync(Token);
// Deleting the blob is the whole of it: the sealed object was never made persistent inside the
// TPM, so there is no chip state left to clean up and nothing for a later install to trip over.
File.Exists(new ClientPaths(directory).DeviceKeyFile).ShouldBeFalse();
(await Store("1234").TryLoadAsync(Token)).ShouldBeNull();
}
[Fact]
public async Task ABlobFromSomewhereElseIsRefusedRatherThanParsed()
{
var paths = new ClientPaths(directory);
paths.EnsureCreated();
// What the Windows store writes is a bare RSA ciphertext with no header, and a dual-boot machine
// or a copied profile directory can put one here. Feeding it to the TPM as a marshalled structure
// is the failure this refuses; answering null lands on the passphrase, which works.
await File.WriteAllBytesAsync(paths.DeviceKeyFile, RandomNumberGenerator.GetBytes(256), Token);
var store = new LinuxDeviceKeyStore(
paths,
(_, _) => throw new InvalidOperationException("A PIN was asked for over an unusable blob."),
() => throw new InvalidOperationException("The TPM was opened for an unusable blob."));
(await store.TryLoadAsync(Token)).ShouldBeNull();
}
private LinuxDeviceKeyStore Store(string pin) =>
new(
new ClientPaths(directory),
(_, _) => ValueTask.FromResult<string?>(pin),
() => new SoftwareTpm(Simulator ?? "127.0.0.1", 2321));
private static void SkipUnlessSimulated() =>
Assert.SkipWhen(
string.IsNullOrEmpty(Simulator),
"No software TPM. See the remarks on this class for the one command that starts one.");
}
@@ -0,0 +1,243 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
using DodoSSH.Client.App.Platform;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// The RPM update channel: what it offers, what it refuses, and what it hands on.
/// </summary>
/// <remarks>
/// <para>
/// Driven against a stubbed <c>HttpMessageHandler</c> rather than a server, because every question here is
/// about what this class decides from what the forge said — and a real socket would add a port to bind and
/// nothing to the assertions.
/// </para>
/// <para>
/// <b>The two that matter are the version comparison and the digest.</b> The first is what keeps a rolling
/// nightly from offering a downgrade PackageKit would decline, forever, every six hours. The second is the
/// only thing standing between a truncated download and a file handed to the machine's package manager —
/// Velopack verifies its own downloads and Android checks a signature, and an RPM over HTTPS has neither.
/// </para>
/// </remarks>
public sealed class LinuxUpdateChannelTests : IDisposable
{
private const string ReleaseUrl =
"https://git.dodotech.cloud/api/v1/repos/DodoTech-Public/DodoSSH/releases/tags/nightly-desktop";
private const string ManifestUrl = "https://forge.invalid/linux-nightly.json";
private const string RpmUrl = "https://forge.invalid/dodossh-nightly.rpm";
private readonly string directory =
Path.Combine(Path.GetTempPath(), $"dodossh-updates-{Guid.CreateVersion7():N}");
private static CancellationToken Token => TestContext.Current.CancellationToken;
public void Dispose()
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public async Task ANewerPublishedVersionIsOffered()
{
var package = RandomNumberGenerator.GetBytes(2048);
var channel = Channel(package, published: "0.0.1~alpha.0.200-1", installed: "0.0.1~alpha.0.143-1");
var update = await channel.CheckAsync(Token);
update.ShouldNotBeNull();
update.Version.ShouldBe("0.0.1-alpha.0.200");
}
[Fact]
public async Task TheSameVersionIsNotOffered()
{
var channel = Channel(
RandomNumberGenerator.GetBytes(16),
published: "0.0.1~alpha.0.143-1",
installed: "0.0.1~alpha.0.143-1");
(await channel.CheckAsync(Token)).ShouldBeNull();
}
/// <summary>
/// An older published build is not offered, which is the failure this channel is most exposed to.
/// </summary>
/// <remarks>
/// The nightly release is rolling and CI replaces it on every push, so a revert or a re-run of an older
/// commit publishes a lower version. Comparing "is it different from mine" would offer it, PackageKit
/// would decline the downgrade, and the user would see a failing update every six hours until somebody
/// pushed again.
/// </remarks>
[Fact]
public async Task AnOlderPublishedVersionIsNotOffered()
{
var channel = Channel(
RandomNumberGenerator.GetBytes(16),
published: "0.0.1~alpha.0.99-1",
installed: "0.0.1~alpha.0.143-1");
(await channel.CheckAsync(Token)).ShouldBeNull();
}
/// <summary>
/// A machine that cannot say what it has treats everything as newer.
/// </summary>
/// <remarks>
/// The wrong way to fail, chosen deliberately and for the reason the android channel gives: the cost is
/// an install the package manager declines, and the cost of the other direction is a security fix that
/// never arrives.
/// </remarks>
[Fact]
public async Task AnUnknownInstalledVersionTreatsEverythingAsNewer()
{
var channel = Channel(RandomNumberGenerator.GetBytes(16), published: "0.0.1~alpha.0.1-1", installed: null);
(await channel.CheckAsync(Token)).ShouldNotBeNull();
}
[Fact]
public async Task AGoodDownloadIsKeptAndHandedToTheInstaller()
{
var package = RandomNumberGenerator.GetBytes(4096);
var handed = new List<string>();
var channel = Channel(
package,
published: "0.0.1~alpha.0.200-1",
installed: "0.0.1~alpha.0.143-1",
handToInstaller: handed.Add);
var update = await channel.CheckAsync(Token);
await channel.DownloadAsync(update!, new Progress<int>(), Token);
channel.ApplyAndRestart(update!);
handed.Count.ShouldBe(1);
(await File.ReadAllBytesAsync(handed[0], Token)).ShouldBe(package);
}
/// <summary>
/// A package whose digest does not match is refused, deleted, and never named to the installer.
/// </summary>
/// <remarks>
/// Deleted rather than merely refused, because the alternative is a file called
/// <c>dodossh-nightly-update.rpm</c> sitting in the profile directory that somebody could reasonably
/// install by hand.
/// </remarks>
[Fact]
public async Task ADownloadThatDoesNotMatchIsRefusedAndRemoved()
{
var handed = new List<string>();
var channel = Channel(
RandomNumberGenerator.GetBytes(4096),
published: "0.0.1~alpha.0.200-1",
installed: "0.0.1~alpha.0.143-1",
digest: Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(32)),
handToInstaller: handed.Add);
var update = await channel.CheckAsync(Token);
await Should.ThrowAsync<InvalidOperationException>(
async () => await channel.DownloadAsync(update!, new Progress<int>(), Token));
handed.ShouldBeEmpty();
Directory.Exists(Path.Combine(directory, "updates")).ShouldBeTrue();
Directory.GetFiles(Path.Combine(directory, "updates")).ShouldBeEmpty();
}
[Fact]
public async Task AnUpdateFromNoCheckIsRefused()
{
var channel = Channel(RandomNumberGenerator.GetBytes(16), "0.0.1~alpha.0.2-1", "0.0.1~alpha.0.1-1");
await Should.ThrowAsync<InvalidOperationException>(
async () => await channel.DownloadAsync(
new DodoSSH.Client.Session.AvailableUpdate("0.0.1-alpha.0.2"),
new Progress<int>(),
Token));
}
/// <summary>
/// A release that is reachable and carries no manifest throws rather than answering "up to date".
/// </summary>
/// <remarks>
/// Half a published release is a fact worth an answer — <c>IUpdateChannel.CheckAsync</c> spends several
/// paragraphs on why null must mean exactly one thing, after a private repository turned a total outage
/// into a reassuring sentence on every client for weeks.
/// </remarks>
[Fact]
public async Task AReleaseWithNoManifestThrows()
{
var channel = new LinuxUpdateChannel(
"nightly",
directory,
new HttpClient(new StubForge(new Dictionary<string, byte[]>(StringComparer.Ordinal)
{
[ReleaseUrl] = Encoding.UTF8.GetBytes("""{"assets":[]}"""),
})),
_ => "0.0.1~alpha.0.1-1",
_ => { });
await Should.ThrowAsync<InvalidOperationException>(async () => await channel.CheckAsync(Token));
}
private LinuxUpdateChannel Channel(
byte[] package,
string published,
string? installed,
string? digest = null,
Action<string>? handToInstaller = null)
{
// The SemVer the banner shows is derived from the rpm version the comparison uses, so the two
// cannot disagree in a way this suite would miss.
var semver = published[..published.LastIndexOf('-')].Replace('~', '-');
var manifest = $$"""
{
"rpmVersion": "{{published}}",
"version": "{{semver}}",
"rpm": "dodossh-nightly.rpm",
"sha256": "{{digest ?? Convert.ToHexStringLower(SHA256.HashData(package))}}"
}
""";
var release = $$"""
{"assets":[
{"name":"linux-nightly.json","browser_download_url":"{{ManifestUrl}}"},
{"name":"dodossh-nightly.rpm","browser_download_url":"{{RpmUrl}}"}
]}
""";
return new LinuxUpdateChannel(
"nightly",
directory,
new HttpClient(new StubForge(new Dictionary<string, byte[]>(StringComparer.Ordinal)
{
[ReleaseUrl] = Encoding.UTF8.GetBytes(release),
[ManifestUrl] = Encoding.UTF8.GetBytes(manifest),
[RpmUrl] = package,
})),
_ => installed,
handToInstaller ?? (_ => { }));
}
/// <summary>Answers the three URLs this channel asks for, and 404s everything else.</summary>
private sealed class StubForge(IReadOnlyDictionary<string, byte[]> content) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken) =>
Task.FromResult(
content.TryGetValue(request.RequestUri!.AbsoluteUri, out var bytes)
? new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) }
: new HttpResponseMessage(HttpStatusCode.NotFound));
}
}
@@ -0,0 +1,105 @@
using DodoSSH.Client.App.Platform;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// <c>RpmVersion.Compare</c> against the answers <c>rpm</c> itself gives.
/// </summary>
/// <remarks>
/// <para>
/// <b>Every expected value here was produced by rpm and not by reading the algorithm.</b> They came out of
/// <c>rpm --eval "%{lua:print(rpm.vercmp(a, b))}"</c> in a Fedora-era container, which is the same
/// comparison PackageKit will make when it decides whether to accept the update this class caused to be
/// offered. A test written from the specification would agree with a misreading of the specification.
/// </para>
/// <para>
/// The awkward cases are the point of the list. <c>1a</c> equals <c>1.a</c> because separators are skipped
/// on each side independently; <c>1.01</c> equals <c>1.1</c> because leading zeros are stripped;
/// <c>1.0_1</c> equals <c>1.0.1</c> for the first reason again; and <c>a</c> is older than <c>1</c> because
/// rpm calls a numeric segment newer than an alphabetic one whatever the letters say.
/// </para>
/// <para>
/// The four rows that carry the feature are the <c>~</c> ones. <c>0.0.1~alpha.0.143 &lt; 0.0.1</c> is what
/// makes every nightly older than the first real release, and <c>~alpha.0.9 &lt; ~alpha.0.10</c> is what
/// keeps them in order among themselves — a string comparison would get that backwards and a client would
/// stop offering updates after the ninth nightly of a version.
/// </para>
/// </remarks>
public sealed class RpmVersionTests
{
[Theory]
// Ordinary
[InlineData("1.2.3", "1.2.3", 0)]
[InlineData("1.2.3", "1.2.4", -1)]
[InlineData("1.2.3", "1.2.3.1", -1)]
[InlineData("1.2.10", "1.2.9", 1)]
[InlineData("2.0", "10.0", -1)]
[InlineData("1.0.0", "1.0", 1)]
[InlineData("1.0.0.1", "1.0.0", 1)]
[InlineData("20101121", "20101122", -1)]
// Tilde: the prerelease ordering this feature depends on
[InlineData("0.0.1~alpha.0.143", "0.0.1", -1)]
[InlineData("0.0.1~alpha.0.143", "0.0.1~alpha.0.144", -1)]
[InlineData("0.0.1~alpha.0.143", "0.0.1~alpha.0.99", 1)]
[InlineData("0.0.1~alpha.0.9", "0.0.1~alpha.0.10", -1)]
[InlineData("1.0", "1.0~rc1", 1)]
[InlineData("1~~", "1~", -1)]
[InlineData("1.0~beta", "1.0~alpha", 1)]
// Caret: after everything except the end of the string
[InlineData("1.0^post", "1.0", 1)]
[InlineData("1.0^post", "1.0^post2", -1)]
[InlineData("1.2.3", "1.2.3^", -1)]
// Separators and leading zeros, which are the surprising ones
[InlineData("1a", "1.a", 0)]
[InlineData("1.01", "1.1", 0)]
[InlineData("1.0_1", "1.0.1", 0)]
[InlineData("1.0-1", "1.0-2", -1)]
// Segments of different kinds
[InlineData("1.a", "1.b", -1)]
[InlineData("a", "1", -1)]
[InlineData("1", "a", 1)]
[InlineData("5.5p1", "5.5p2", -1)]
[InlineData("xyz10", "xyz10.1", -1)]
[InlineData("MDK", "MDK1", -1)]
public void ItAgreesWithRpm(string left, string right, int expected) =>
RpmVersion.Compare(left, right).ShouldBe(expected);
/// <summary>
/// The reverse of every case above, which rpm does not need asserting and this does.
/// </summary>
/// <remarks>
/// The marker handling is the half of the algorithm with a left and a right branch for each case, so an
/// asymmetry there would pass every row above and reverse the answer for a client whose installed
/// version happened to be the newer one — which is the direction that offers a downgrade.
/// </remarks>
[Theory]
[InlineData("0.0.1~alpha.0.143", "0.0.1")]
[InlineData("1.0^post", "1.0")]
[InlineData("1.2.3", "1.2.3^")]
[InlineData("a", "1")]
[InlineData("1.2.3", "1.2.4")]
public void ItIsAntisymmetric(string left, string right) =>
RpmVersion.Compare(right, left).ShouldBe(-RpmVersion.Compare(left, right));
/// <summary>
/// Nothing and an empty string are older than anything, and equal to each other.
/// </summary>
/// <remarks>
/// rpm's own binding refuses these outright. Here they arrive off a feed rather than out of a spec
/// file, and the answer that leaves a client on the build it already has is better than an exception
/// thrown on a six-hourly timer.
/// </remarks>
[Theory]
[InlineData(null, "1.0", -1)]
[InlineData("", "1.0", -1)]
[InlineData("1.0", null, 1)]
[InlineData(null, null, 0)]
[InlineData("", null, 0)]
public void ItTreatsNothingAsOldest(string? left, string? right, int expected) =>
RpmVersion.Compare(left, right).ShouldBe(expected);
}
@@ -0,0 +1,115 @@
using System.Buffers.Binary;
using System.Net.Sockets;
using Tpm2Lib;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// Talks to a software TPM over its TCP data channel, so the real store can be driven without hardware.
/// </summary>
/// <remarks>
/// <para>
/// <b>TSS.Net ships a TcpTpmDevice and it cannot be used here.</b> It also opens the simulator's
/// <em>platform</em> socket and exchanges a handshake that the Microsoft reference simulator answers and
/// swtpm does not, so <c>Connect()</c> blocks forever — with no error and no timeout. That was found by
/// watching it hang, and it is the whole reason this file exists.
/// </para>
/// <para>
/// The data channel is the framing <c>tcti-mssim</c> uses and swtpm does implement: a command word, a
/// locality byte, a length and the bytes, answered by a length, the bytes and an acknowledgement word.
/// Power management is unnecessary when swtpm is started with <c>--flags not-need-init,startup-clear</c>,
/// which is the invocation <see cref="LinuxDeviceKeyStoreTests"/> documents.
/// </para>
/// </remarks>
internal sealed class SoftwareTpm : Tpm2Device
{
/// <summary>tcti-mssim's "here is a command for the TPM".</summary>
private const uint SendCommand = 8;
private readonly string host;
private readonly int port;
private TcpClient? client;
private NetworkStream? stream;
internal SoftwareTpm(string host, int port)
{
this.host = host;
this.port = port;
}
public override void Connect()
{
client = new TcpClient();
client.Connect(host, port);
stream = client.GetStream();
}
public override void Close()
{
stream?.Dispose();
client?.Dispose();
stream = null;
client = null;
}
public override void DispatchCommand(CommandModifier active, byte[] inBuf, out byte[] outBuf)
{
ArgumentNullException.ThrowIfNull(active);
ArgumentNullException.ThrowIfNull(inBuf);
if (stream is null)
{
throw new InvalidOperationException("Not connected.");
}
Span<byte> header = stackalloc byte[9];
BinaryPrimitives.WriteUInt32BigEndian(header, SendCommand);
header[4] = active.ActiveLocality;
BinaryPrimitives.WriteUInt32BigEndian(header[5..], (uint)inBuf.Length);
stream.Write(header);
stream.Write(inBuf, 0, inBuf.Length);
stream.Flush();
outBuf = ReadExactly((int)ReadUInt32());
// The trailing acknowledgement word. It has to be consumed even though nothing reads it, or every
// response after this one is misframed by four bytes.
_ = ReadUInt32();
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
Close();
}
base.Dispose(disposing);
}
private uint ReadUInt32() => BinaryPrimitives.ReadUInt32BigEndian(ReadExactly(4));
private byte[] ReadExactly(int count)
{
var buffer = new byte[count];
var read = 0;
while (read < count)
{
var got = stream!.Read(buffer, read, count - read);
if (got <= 0)
{
throw new IOException("The TPM closed the connection.");
}
read += got;
}
return buffer;
}
}
@@ -31,6 +31,18 @@ public sealed class WindowsDeviceKeyStoreTests
{
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <summary>
/// The PIN prompt this platform never reaches, because Windows raises its own.
/// </summary>
/// <remarks>
/// <c>ForThisMachine</c> takes one so that the Linux store has somewhere to ask from — see
/// <c>LinuxDeviceKeyStore</c>. On Windows nothing calls it, and answering null rather than throwing is
/// the honest stand-in: if the selection ever did reach for it here, the result would be a store that
/// declines rather than a test that fails somewhere unrelated.
/// </remarks>
private static ValueTask<string?> NoPin(DevicePinPurpose purpose, CancellationToken cancellationToken) =>
ValueTask.FromResult<string?>(null);
[Fact]
public async Task OnAMachineWithATpm_TheStoreOffersItself()
{
@@ -39,7 +51,7 @@ public sealed class WindowsDeviceKeyStoreTests
// provider reports itself present on machines where creating a key then fails.
SkipUnlessSupported();
var store = DesktopDeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()));
var store = DesktopDeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()), NoPin);
store.ShouldBeOfType<WindowsDeviceKeyStore>();
(await store.IsAvailableAsync(Token)).ShouldBeTrue();
@@ -66,7 +78,7 @@ public sealed class WindowsDeviceKeyStoreTests
private static void SkipUnlessSupported()
{
var supported = OperatingSystem.IsWindows()
&& DesktopDeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()))
&& DesktopDeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()), NoPin)
is WindowsDeviceKeyStore;
if (!supported)
@@ -479,6 +479,7 @@
"DodoSSH.Client.Ssh": "[1.0.0, )",
"DodoSSH.Client.Terminal": "[1.0.0, )",
"DodoSSH.Client.Transfer": "[1.0.0, )",
"Microsoft.TSS": "[2.1.1, )",
"Velopack": "[1.2.0, )"
}
},
@@ -718,6 +719,12 @@
"SQLitePCLRaw.core": "2.1.11"
}
},
"Microsoft.TSS": {
"type": "CentralTransitive",
"requested": "[2.1.1, )",
"resolved": "2.1.1",
"contentHash": "87DBN+4ANSFO8TDRmFjjTBl6PL7tmrfofmxki4LUnE34gOdyQiBfJK/X1QSu0UfCGUtpoBVv/tD985s4FDpKiA=="
},
"NSec.Cryptography": {
"type": "CentralTransitive",
"requested": "[26.4.0, )",