using DodoSSH.Contracts; using static DodoSSH.Client.Sync.Tests.SyncHarness; namespace DodoSSH.Client.Sync.Tests; /// /// Known host keys through the two-machine harness. /// /// /// The first test here is the whole reason this item type exists: a host approved on the laptop is approved on /// the desktop. Everything else is what the credential and key suites check per type — the cipher, what the /// server is told, that the items cannot be confused with another type's — plus the two properties that are /// specific to trust: that withdrawing it propagates, and that a clash between two fingerprints is reported /// with both of them, unlike a clash between two passwords. /// public sealed class KnownHostSyncTests : IAsyncLifetime { private SyncHarness harness = null!; private static CancellationToken Token => TestContext.Current.CancellationToken; /// public async ValueTask InitializeAsync() => harness = await CreateAsync(); /// public ValueTask DisposeAsync() { harness.Dispose(); return ValueTask.CompletedTask; } [Fact] public async Task AHostApprovedOnOneMachine_IsApprovedOnTheOther() { // The point of the item type. Without it a user is asked to check the same fingerprint on every // device, which is how people learn to approve host keys without reading them. var entityId = await harness.First.CreateKnownHostAsync( KnownHost("bastion.internal", port: 2222, fingerprint: "SHA256:approved-on-the-laptop")); await harness.SettleAsync(); var seen = await harness.Second.FindKnownHostAsync(entityId); seen.Secret.Host.ShouldBe("bastion.internal"); seen.Secret.Port.ShouldBe(2222); seen.Secret.Algorithm.ShouldBe("ssh-ed25519"); seen.Secret.Fingerprint.ShouldBe("SHA256:approved-on-the-laptop"); seen.HasUnsyncedChanges.ShouldBeFalse(); } [Fact] public async Task TrustWithdrawnOnOneMachine_IsWithdrawnOnTheOther() { // The other half, and not a symmetry argument: a changed host key is refused with no way to continue, // so a withdrawal that did not travel would leave a rebuilt server unreachable from every machine // except the one that forgot its old key. var entityId = await harness.First.CreateKnownHostAsync(KnownHost()); await harness.SettleAsync(); (await harness.Second.ListKnownHostsAsync()).Items.ShouldHaveSingleItem(); await harness.First.DeleteKnownHostAsync(entityId); await harness.SettleAsync(); (await harness.Second.ListKnownHostsAsync()).Items.ShouldBeEmpty(); } [Fact] public async Task ThePull_AsksForKnownHostKeys() { // Derived from the registry rather than listed, so this cannot be forgotten — but a pin that // reconciles perfectly and is never requested would work on one machine and exist nowhere else, // which is exactly the failure the first test would then be unable to see. await harness.First.SyncAsync(); harness.Server.LastPullTypes.ShouldNotBeNull().ShouldContain(SyncEntityType.KnownHostKey); } [Fact] public async Task APinHandsTheServerNothingInPlaintext() { // The address especially. It is the one field here that the server is allowed to hold for a // relay-enabled host, and putting it on this item as well would hand the operator the list of // endpoints every user actually reaches — assembled out of values that are each harmless. var entityId = await harness.First.CreateKnownHostAsync(KnownHost("bastion.internal")); var queued = await harness.First.Outbox .FindAsync(VaultId, SyncEntityType.KnownHostKey, entityId, Token); queued.ShouldNotBeNull(); queued.Fields.ShouldBeNull("a pin tells the server nothing but its ciphertext"); await harness.SettleAsync(); var row = harness.Server.Find(entityId, SyncEntityType.KnownHostKey).ShouldNotBeNull(); row.Fields.RelayEnabled.ShouldBeFalse(); row.Fields.Hostname.ShouldBeNull(); row.Fields.Port.ShouldBeNull(); row.Fields.PublicKeyFingerprint.ShouldBeNull(); } [Fact] public async Task APinAndAHostSharingAnId_AreTwoItems() { // The cache keys on the type as well as the id, and each payload's AAD binds a different resource // type. Arranged on the server because the repositories mint UUIDv7s and would never collide. var sharedId = Guid.CreateVersion7(); harness.First.Keyring.TryGet(VaultId, out var vaultKey, out var generation).ShouldBeTrue(); harness.Server.ExternalUpsert( sharedId, HostCipher.Seal(Host("prod-db"), vaultKey.Span, sharedId, generation, itemVersion: 1), new SyncPlaintextFields(), SyncEntityType.Host); harness.Server.ExternalUpsert( sharedId, KnownHostKeyCipher.Seal( KnownHost(), vaultKey.Span, sharedId, generation, itemVersion: 1), null, SyncEntityType.KnownHostKey); await harness.Second.SyncAsync(); (await harness.Second.ListAsync()).Items.ShouldHaveSingleItem() .Secret.Label.ShouldBe("prod-db"); var pins = await harness.Second.ListKnownHostsAsync(); pins.Items.ShouldHaveSingleItem().Secret.Host.ShouldBe("db.internal"); pins.Unreadable.ShouldBe(0); } [Fact] public async Task BothMachinesApprovedADifferentKey_TheDiscardedFingerprintIsReported() { // The deliberate contrast with the password merge, which reports that something differed and nothing // more. A fingerprint is published by the operator so that it can be compared; a notice that withheld // the value it dropped would leave the user with nothing to check it against. var entityId = await harness.First.CreateKnownHostAsync(KnownHost()); await harness.SettleAsync(); await harness.First.UpdateKnownHostAsync( entityId, KnownHost(fingerprint: "SHA256:seen-from-the-laptop")); await harness.Second.UpdateKnownHostAsync( entityId, KnownHost(fingerprint: "SHA256:seen-from-the-desktop")); await harness.SettleAsync(); var first = (await harness.First.FindKnownHostAsync(entityId)).Secret; // Converged, and on the value that reached the server first: every replica has to resolve a clash the // same way or the two would push against each other for ever. first.ShouldBe((await harness.Second.FindKnownHostAsync(entityId)).Secret); first.Fingerprint.ShouldBe("SHA256:seen-from-the-laptop"); var details = await ConflictDetailsAsync(); details.ShouldContain(detail => detail.Contains("SHA256:seen-from-the-desktop", StringComparison.Ordinal)); details.ShouldContain(detail => detail.Contains("Fingerprint", StringComparison.Ordinal)); } [Fact] public async Task APinEditedElsewhereAfterBeingDeletedHere_IsCalledAKnownHostKey() { // The noun reaches a person. "This host key was edited elsewhere" would send them to look at the // server they are connecting to, rather than at a decision they made about it. var entityId = await harness.First.CreateKnownHostAsync(KnownHost()); await harness.SettleAsync(); await harness.First.UpdateKnownHostAsync( entityId, KnownHost(fingerprint: "SHA256:still-the-one-i-approved")); await harness.Second.DeleteKnownHostAsync(entityId); await harness.SettleAsync(); (await harness.First.FindKnownHostAsync(entityId)).Secret.Fingerprint .ShouldBe("SHA256:still-the-one-i-approved"); var details = await ConflictDetailsAsync(); details.ShouldContain( detail => detail.Contains("This known host key was edited", StringComparison.Ordinal)); } private async Task> ConflictDetailsAsync() { var first = await harness.First.ConflictsAsync(); var second = await harness.Second.ConflictsAsync(); return [ .. first.Concat(second) .Select(conflict => System.Text.Encoding.UTF8.GetString(conflict.Detail)), ]; } }