Files
DodoSSH/tests/DodoSSH.Client.App.Tests/LinuxDeviceKeyStoreTests.cs
jaap-jan c2c860b4f2
ci / build and test (push) Successful in 3m0s
ci / android head (push) Successful in 3m24s
ci / desktop nightly (push) Successful in 1m4s
ci / api image (push) Successful in 24s
Give the seated user the TPM, so nobody has to join the tss group
ADR 0007 decision 4 refused this a commit ago, on the grounds that widening
access to the machine's TPM is a system-wide decision an application package
should not make for an administrator. That refusal is overturned here, and the
reasoning it is replaced with is narrower rather than louder — because the
refusal had treated "widen access" as one thing when it is two.

Adding a person to `tss` is what the refusal was really about, and it stays the
wrong answer: permanent, effective over SSH, applying whether or not they are at
the keyboard, and needing an administrator and a fresh login before the feature
appears at all.

Tagging /dev/tpmrm0 with `uaccess` is a different thing. It is systemd's own
mechanism for this exact question — the one /dev/dri, webcams, scanners and FIDO
keys already use — and logind grants the ACL to the user of the *active local
session*, then takes it back when that session ends. Not over SSH, not on an
inactive VT, not to daemons. The rule changes neither the mode nor the group, so
everything that worked before still works and the ACL is layered on top.

tpmrm and never tpm, which is what makes it defensible at all: /dev/tpm0 is the
raw device and is exclusive, where /dev/tpmrm0 is the kernel's resource manager,
which virtualises handles and isolates callers precisely so that more than one
unprivileged program can use the chip.

◆ THE COST IS A DENIAL OF SERVICE AND NOT A DISCLOSURE, AND IT IS WRITTEN DOWN.

TPM objects are protected by their own auth values, so this lets nobody read
anybody else's sealed data. What it allows is *failed* attempts — and a TPM
answers enough of those by locking out everyone, which is the same anti-hammering
this project relies on to make a four-digit PIN safe. On a machine that unlocks
its disk with the TPM at boot, somebody sitting at it can trip that and stop the
next boot from unlocking until it recovers.

An administrator who does not accept that masks the file:

    sudo ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules

which is the standard override, survives upgrades, and puts DodoSSH back to
behaving as though the machine had no TPM. That is in the rule's own comments,
because the person who needs it is reading the file rather than the ADR.

Numbered 71 because 60-tpm-udev.rules is what creates the node's ownership and
73-seat-late.rules is what turns the tag into an ACL; verified in that order on
Fedora 44, along with `udevadm verify` accepting the file and the mask being
reported as "masked by previous entry". %post reloads and replays it against the
tpmrm subsystem, because a rule that waits for a reboot is a feature that does
not work on the day it is installed.

Both channels install the same path deliberately: this is about the machine's
TPM rather than about either build, the file is byte-identical from both, and
giving each its own copy would mean removing one package silently revoked the
other's access.

Phase 19 grew a check that the ACL exists for the local session and does *not*
exist for an SSH one, and another that masking the rule really does put the
machine back — then tells you to undo it before the checks that follow.

Also recorded, after it cost five confusing failures: swtpm serves exactly one
client, so a shell readiness probe sitting on the port makes the whole device-key
suite fail with "The TPM closed the connection" and nothing pointing at why.
2026-08-22 20:38:22 +02:00

231 lines
10 KiB
C#

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>swtpm serves exactly one client, and anything else holding the port makes this suite fail in a
/// way that reads like a bug in the store.</b> A second connection is accepted and then closed
/// immediately, so five tests fail at once with <c>IOException: The TPM closed the connection</c> and
/// nothing points at the cause. It was a shell readiness probe — <c>exec 3&lt;&gt;/dev/tcp/…</c> — sitting
/// on the socket. Wait for the container by looking at the container, not by opening the port.
/// </para>
/// <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.");
}