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!;
}
}