using DodoSSH.Client.Domain; using DodoSSH.Client.Ssh; using DodoSSH.Client.Storage; using DodoSSH.Contracts; using DodoSSH.Crypto; namespace DodoSSH.Client.Session.Tests; /// /// Host key trust that outlives the process, and the snapshot the SSH handshake reads it from. /// /// /// /// The first test is the whole point of the feature: approve a fingerprint, lock the vault, unlock it again, /// and the host is still trusted. It runs with no server at all — the pin is in the outbox and the local /// cache, which is exactly the situation a user is in on a laptop that has not been online since. /// /// /// The rest are the properties that would each be a security bug rather than an inconvenience: that a pin /// answers for the one algorithm it was recorded for, that a locked vault answers "not pinned" rather than /// something optimistic, and that withdrawing trust reaches every pin for the endpoint. /// /// public sealed class VaultKnownHostStoreTests : IAsyncLifetime { private const string Passphrase = "correct horse battery staple"; private const string ServerUrl = "https://dodossh.example"; /// /// Far below the shipped profile, as in : nothing here attacks a wrap, /// and every test in this suite pays for at least one unlock. /// private static readonly Argon2Profile CheapProfile = Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1); private readonly FakeAccountServer server = new(); private readonly StubKeyBinding keyBinding = new(); private ClientCacheFactory caches = null!; private static CancellationToken Token => TestContext.Current.CancellationToken; /// public async ValueTask InitializeAsync() { caches = ClientCacheFactory.ForMemory($"known-hosts-{Guid.CreateVersion7():N}"); await caches.MigrateAsync(TestContext.Current.CancellationToken); await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token); } /// public ValueTask DisposeAsync() { caches.Dispose(); return ValueTask.CompletedTask; } [Fact] public async Task AHostTrustedOnceIsStillTrustedAfterALockAndUnlock() { // The gap this closes. With trust in memory the user is asked to check the same fingerprint on every // launch, which is how people learn to approve host keys without reading them. var store = new VaultKnownHostStore(); await using (var first = await UnlockAsync()) { await store.OpenAsync(first, Token); await store.TrustAsync(Presented(), Token); } store.Close(); await using var second = await UnlockAsync(); await store.OpenAsync(second, Token); (await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)) .ShouldBe("SHA256:approved"); } [Fact] public async Task AnUnvisitedHost_HasNoPin() { await using var session = await UnlockAsync(); var store = new VaultKnownHostStore(); await store.OpenAsync(session, Token); (await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull(); } [Fact] public async Task APinAnswersForOneAlgorithmOnly() { // A server legitimately offers several host keys, and which one is negotiated can change between // connections. A pin that answered for all of them would either accept a key nobody approved or // report a mismatch for an ordinary server. await using var session = await UnlockAsync(); var store = new VaultKnownHostStore(); await store.OpenAsync(session, Token); await store.TrustAsync(Presented(algorithm: "ssh-ed25519"), Token); (await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBe("SHA256:approved"); (await store.FindAsync("db.internal", 22, "rsa-sha2-512", Token)).ShouldBeNull(); (await store.FindAsync("db.internal", 2222, "ssh-ed25519", Token)).ShouldBeNull(); (await store.FindAsync("other.internal", 22, "ssh-ed25519", Token)).ShouldBeNull(); } [Fact] public async Task AHostNameIsMatchedWithoutRegardToCase() { // DNS is case-insensitive, so DB.internal and db.internal are one machine. Treating them as two would // ask the user to approve the same server twice, and leave two pins where a withdrawal has to find // both. await using var session = await UnlockAsync(); var store = new VaultKnownHostStore(); await store.OpenAsync(session, Token); await store.TrustAsync(Presented(host: "DB.internal"), Token); (await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBe("SHA256:approved"); // And the value stored is the one that was dialled, not a lower-cased rewrite of it. var listing = await session.KnownHosts.ListAsync(session.ActiveVaultId, Token); listing.Items.ShouldHaveSingleItem().Secret.Host.ShouldBe("DB.internal"); } [Fact] public async Task ReApprovingTheSameKey_QueuesNothingFurther() { // Re-trusting an identical fingerprint used to be a dictionary write that cost nothing. It is now an // outbox operation, and queueing one would push a modification to every other machine for a decision // that had not changed. await using var session = await UnlockAsync(); var store = new VaultKnownHostStore(); await store.OpenAsync(session, Token); await store.TrustAsync(Presented(), Token); var listing = await session.KnownHosts.ListAsync(session.ActiveVaultId, Token); var entityId = listing.Items.ShouldHaveSingleItem().EntityId; var before = await session.Outbox .FindAsync(session.ActiveVaultId, SyncEntityType.KnownHostKey, entityId, Token); var operationId = before.ShouldNotBeNull().OperationId; await store.TrustAsync(Presented(), Token); // Asserted on the operation rather than on a count of pending changes, and the difference is the // whole test: queueing an identical write coalesces onto the same outbox row, so a count stays at one // either way. What would move is the operation id, which is re-minted whenever the payload is // rewritten — so this is what tells a redundant write from no write at all. var after = await session.Outbox .FindAsync(session.ActiveVaultId, SyncEntityType.KnownHostKey, entityId, Token); after.ShouldNotBeNull().OperationId.ShouldBe(operationId); (await session.KnownHosts.ListAsync(session.ActiveVaultId, Token)).Items.ShouldHaveSingleItem(); } [Fact] public async Task ApprovingANewKeyForAKnownHost_ReplacesThePinRatherThanAddingOne() { // What happens after a server is rebuilt and its old pin has been forgotten. Two items for one // endpoint would leave the endpoint's trust depending on which of them a lookup happened to see. await using var session = await UnlockAsync(); var store = new VaultKnownHostStore(); await store.OpenAsync(session, Token); await store.TrustAsync(Presented(), Token); await store.TrustAsync(Presented(fingerprint: "SHA256:rebuilt"), Token); var listing = await session.KnownHosts.ListAsync(session.ActiveVaultId, Token); listing.Items.ShouldHaveSingleItem().Secret.Fingerprint.ShouldBe("SHA256:rebuilt"); (await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBe("SHA256:rebuilt"); } [Fact] public async Task ForgettingAHost_TakesEveryAlgorithmWithIt() { // The user's decision is about the machine, not about one of the keys it offers. Leaving one behind // would mean a rebuilt server that still refuses to connect for a reason they believe they have // already dealt with. await using var session = await UnlockAsync(); var store = new VaultKnownHostStore(); await store.OpenAsync(session, Token); await store.TrustAsync(Presented(algorithm: "ssh-ed25519"), Token); await store.TrustAsync(Presented(algorithm: "rsa-sha2-512"), Token); await store.TrustAsync(Presented(host: "other.internal"), Token); (await store.ForgetAsync("db.internal", 22, Token)).ShouldBe(2); (await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull(); (await store.FindAsync("db.internal", 22, "rsa-sha2-512", Token)).ShouldBeNull(); // And nothing else was touched. (await store.FindAsync("other.internal", 22, "ssh-ed25519", Token)).ShouldBe("SHA256:approved"); } [Fact] public async Task ForgettingAHostThatWasNeverApproved_SaysSoRatherThanFailing() { await using var session = await UnlockAsync(); var store = new VaultKnownHostStore(); await store.OpenAsync(session, Token); (await store.ForgetAsync("db.internal", 22, Token)).ShouldBe(0); } [Fact] public async Task AForgottenPinStaysForgottenAcrossALockAndUnlock() { // The refresh that follows a withdrawal replaces the snapshot rather than merging into it. Merging // would have made a forgotten pin reappear, which is the failure that matters here: the user would be // told the host key had changed after explicitly saying it had. var store = new VaultKnownHostStore(); await using (var first = await UnlockAsync()) { await store.OpenAsync(first, Token); await store.TrustAsync(Presented(), Token); await store.ForgetAsync("db.internal", 22, Token); (await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull(); } store.Close(); await using var second = await UnlockAsync(); await store.OpenAsync(second, Token); (await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull(); } [Fact] public async Task ALockedVaultAnswersNotPinnedRatherThanSomethingOptimistic() { // Reachable when a vault is locked while a handshake is in flight. Refusing the connection is the safe // direction; the alternative would be answering a host key question out of a vault that is closed. var store = new VaultKnownHostStore(); await using var session = await UnlockAsync(); await store.OpenAsync(session, Token); await store.TrustAsync(Presented(), Token); store.IsOpen.ShouldBeTrue(); store.Close(); store.IsOpen.ShouldBeFalse(); (await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull(); } [Fact] public async Task ALockedVaultRefusesToRecordOrWithdrawTrust() { // Loudly rather than silently. A pin accepted into nothing would leave the user believing they had // approved a host, and a withdrawal accepted into nothing would leave one they believe is gone. var store = new VaultKnownHostStore(); await Should.ThrowAsync( async () => await store.TrustAsync(Presented(), Token)); await Should.ThrowAsync( async () => await store.ForgetAsync("db.internal", 22, Token)); // And a refresh with nothing behind it is a no-op rather than a throw: a synchronisation pass may // finish after the vault was locked, and that is ordinary rather than exceptional. await store.RefreshAsync(Token); } [Fact] public async Task ARefreshPicksUpAPinRecordedElsewhere() { // Stands in for a pin arriving from another machine: something reached the vault that this store's // snapshot predates. Written through the repository directly, which is what a pull followed by a // listing amounts to. await using var session = await UnlockAsync(); var store = new VaultKnownHostStore(); await store.OpenAsync(session, Token); await session.KnownHosts.CreateAsync( session.ActiveVaultId, new KnownHostSecret { Host = "bastion.internal", Port = 22, Algorithm = "ssh-ed25519", Fingerprint = "SHA256:approved-on-the-desktop", }, Token); (await store.FindAsync("bastion.internal", 22, "ssh-ed25519", Token)) .ShouldBeNull("the snapshot is only re-read when it is told to be"); await store.RefreshAsync(Token); (await store.FindAsync("bastion.internal", 22, "ssh-ed25519", Token)) .ShouldBe("SHA256:approved-on-the-desktop"); } [Fact] public async Task ARefreshKeepsWhatWasApprovedHere() { // The mistake that would matter. A pass that re-read the vault must not drop a pin recorded on this // machine, or the user would be asked again about a host they had just approved. await using var session = await UnlockAsync(); var store = new VaultKnownHostStore(); await store.OpenAsync(session, Token); await store.TrustAsync(Presented(), Token); await store.RefreshAsync(Token); (await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBe("SHA256:approved"); } // ---- Helpers ---- private static HostKeyPresentation Presented( string host = "db.internal", int port = 22, string algorithm = "ssh-ed25519", string fingerprint = "SHA256:approved") => new(host, port, algorithm, fingerprint); private SessionOpener Opener() => new(caches, TimeProvider.System); private AccountProvisioner Provisioner() => new(server, keyBinding, caches, TimeProvider.System, CheapProfile); private async Task UnlockAsync() { var outcome = await Opener().UnlockAsync(Passphrase, Token); outcome.IsUnlocked.ShouldBeTrue(outcome.Message); return outcome.Session!; } }