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)),
];
}
}