using System.Security.Cryptography; using DodoSSH.Client.App.Platform; using DodoSSH.Client.Session; using Tpm2Lib; namespace DodoSSH.Client.App.Tests; /// /// The TPM-backed store, driven end to end against a software TPM. /// /// /// /// This is the only device key store in the repository with real coverage, and the reason is worth /// knowing. WindowsDeviceKeyStoreTests records that the Windows store cannot be exercised at /// all: the consent policy prompts at creation as well as at use, so anything calling SaveAsync /// 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. /// /// /// Skipped unless a software TPM is running, which CI does not start. 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: /// /// /// docker run --rm -p 2321:2321 debian:bookworm-slim sh -c \ /// 'apt-get update -qq && apt-get install -y -qq swtpm >/dev/null && \ /// 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 /// /// /// ◆ 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. A second connection is accepted and then closed /// immediately, so five tests fail at once with IOException: The TPM closed the connection and /// nothing points at the cause. It was a shell readiness probe — exec 3<>/dev/tcp/… — sitting /// on the socket. Wait for the container by looking at the container, not by opening the port. /// /// /// What this cannot cover is the transport. Production opens /dev/tpmrm0 through TSS.Net's /// LinuxTpmDevice and these tests open a socket, so everything above the device is proved here and /// the device itself is proved by Phase 19 of docs/manual-checks.md 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. /// /// 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; /// Where a software TPM is listening, if the person running this started one. private static string? Simulator => Environment.GetEnvironmentVariable("DODOSSH_TPM_TCP"); /// /// Clears the profile directory, and clears the TPM's lockout counter behind it. /// /// /// ◆ The second half is not tidiness, and leaving it out made this suite lie. A software TPM is /// one piece of shared mutable state and its dictionary-attack counter is the most global thing in it: /// 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. /// 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(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(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."); }