namespace DodoSSH.Client.Domain.Tests; /// /// The known-host record, its codec and its merge. /// /// /// Shorter again than , because three of the four fields are what the item /// is about rather than content that gets edited. What is specific to this type and worth pinning: /// that its name is derived rather than stored, that the derived name stays out of the payload and out of /// equality, that a mangled fingerprint is refused rather than stored to fail comparisons for ever, and that a /// fingerprint clash is reported with both values — the opposite of what the password merge does. /// public sealed class KnownHostSecretTests { [Fact] public void APinIsNamedAfterWhatItPins() { // Read by the conflict log, which is the only place a person meets one of these. "db.internal:22" is // something they can match against a host in their list; an item id is not. Pin().Label.ShouldBe("db.internal:22 (ssh-ed25519)"); Pin(port: 2222).Label.ShouldBe("db.internal:2222 (ssh-ed25519)"); } [Fact] public void TwoPinsOfTheSameKey_AreEqual() { // The derived label is get-only, so it stays out of the record's equality — which is what makes // "these are the same pin" a question about the host, port, algorithm and fingerprint alone. The // reconciler compares secrets this way to recognise its own create coming back. Pin().ShouldBe(Pin()); Pin(fingerprint: "SHA256:something-else").ShouldNotBe(Pin()); } [Theory] [InlineData("", 22, "ssh-ed25519", "SHA256:aaa", "needs the host")] [InlineData(" ", 22, "ssh-ed25519", "SHA256:aaa", "needs the host")] [InlineData("db.internal", 0, "ssh-ed25519", "SHA256:aaa", "Port must be between")] [InlineData("db.internal", 65536, "ssh-ed25519", "SHA256:aaa", "Port must be between")] [InlineData("db.internal", 22, "", "SHA256:aaa", "needs the key algorithm")] [InlineData("db.internal", 22, "ssh ed25519", "SHA256:aaa", "needs the key algorithm")] [InlineData("db.internal", 22, "ssh-ed25519", "", "needs a fingerprint")] [InlineData("db.internal", 22, "ssh-ed25519", "SHA256:aaa bbb", "needs a fingerprint")] public void AnInvalidPin_SaysWhatIsWrongWithIt( string host, int port, string algorithm, string fingerprint, string expected) { var pin = new KnownHostSecret { Host = host, Port = port, Algorithm = algorithm, Fingerprint = fingerprint, }; pin.TryValidate(out var reason).ShouldBeFalse(); reason.ShouldNotBeNull().ShouldContain(expected); } [Fact] public void APinWithSpaceInItsFingerprint_IsRefusedRatherThanStored() { // A pasted "SHA256:… comment@host" or a stray newline would compare unequal to the same key on every // future connection, which the user would read as a permanently changed host key. Refusing it at the // codec means the bad value never becomes a stored pin. var mangled = Pin(fingerprint: "SHA256:aaa bbb"); Should.Throw(() => KnownHostSecretCodec.Encode(mangled)); } [Fact] public void APin_SurvivesARoundTrip() { var pin = Pin(host: "bastion.internal", port: 2222, algorithm: "rsa-sha2-512"); var encoded = KnownHostSecretCodec.Encode(pin); KnownHostSecretCodec.TryDecode(encoded, out var document).ShouldBeTrue(); document.ShouldNotBeNull(); document.KnownHost.ShouldBe(pin); document.SchemaVersion.ShouldBe(KnownHostSecretCodec.CurrentSchemaVersion); document.IsReadOnly.ShouldBeFalse(); } [Fact] public void EncodingIsDeterministic() { // An unchanged pin must not look like a change to the sync engine, or every pass would push every // host the user has ever approved. KnownHostSecretCodec.Encode(Pin()).ShouldBe(KnownHostSecretCodec.Encode(Pin())); } [Fact] public void TheDerivedLabel_IsNotInThePayload() { // It is a function of the three fields that are, so writing it would put a value on the wire that a // reader could disagree with — and a merge could then take the label from one side and the address // from the other. var json = System.Text.Encoding.UTF8.GetString(KnownHostSecretCodec.Encode(Pin())); json.ShouldNotContain("label"); json.ShouldNotContain("(ssh-ed25519)"); } [Theory] [InlineData("not json")] [InlineData("{}")] [InlineData("""{"schemaVersion":1,"host":"db.internal","port":22,"algorithm":"ssh-ed25519"}""")] [InlineData("""{"schemaVersion":1,"host":"db.internal","port":22,"fingerprint":"SHA256:aaa"}""")] [InlineData("""{"schemaVersion":1,"port":22,"algorithm":"ssh-ed25519","fingerprint":"SHA256:aaa"}""")] [InlineData( """{"schemaVersion":1,"host":"db.internal","algorithm":"ssh-ed25519","fingerprint":"SHA256:aaa"}""")] [InlineData( """{"schemaVersion":0,"host":"db.internal","port":22,"algorithm":"ssh-ed25519","fingerprint":"SHA256:a"}""")] public void APayloadThatIsNotAPin_DoesNotDecode(string json) { KnownHostSecretCodec .TryDecode(System.Text.Encoding.UTF8.GetBytes(json), out var document) .ShouldBeFalse(); document.ShouldBeNull(); } [Fact] public void APinFromANewerClient_IsReadableButNotWritable() { // Readable matters here more than for the other types: an unreadable pin means a host looks unvisited // and the user is asked again. The four fields a pin needs are all present, so a newer schema is // usable for comparison even though this build must not re-encode it. var payload = System.Text.Encoding.UTF8.GetBytes( """ {"schemaVersion":99,"host":"db.internal","port":22,"algorithm":"ssh-ed25519", "fingerprint":"SHA256:aaa","approvedBy":"someone using a later build"} """); KnownHostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue(); document.ShouldNotBeNull(); document.IsReadOnly.ShouldBeTrue(); document.KnownHost.Fingerprint.ShouldBe("SHA256:aaa"); } [Fact] public void OnlyOneSideReApproving_TakesThatSide() { var ancestor = Pin(); var local = ancestor with { Fingerprint = "SHA256:the-rebuilt-server" }; var merged = KnownHostSecretMerge.Merge(ancestor, local, ancestor); merged.HasConflicts.ShouldBeFalse(); merged.Merged.Fingerprint.ShouldBe("SHA256:the-rebuilt-server"); } [Fact] public void BothSidesApprovingADifferentKey_ReportsBothFingerprints() { // Deliberately the opposite of the password merge. An operator publishes a fingerprint so that it can // be compared, and a notice that withheld the value it dropped would leave the user nothing to check. var ancestor = Pin(); var local = ancestor with { Fingerprint = "SHA256:seen-from-the-laptop" }; var remote = ancestor with { Fingerprint = "SHA256:seen-from-the-desktop" }; var merged = KnownHostSecretMerge.Merge(ancestor, local, remote); var conflict = merged.Conflicts.ShouldHaveSingleItem(); conflict.Field.ShouldBe(nameof(KnownHostSecret.Fingerprint)); conflict.Kept.ShouldBe("SHA256:seen-from-the-desktop"); conflict.Discarded.ShouldBe("SHA256:seen-from-the-laptop"); // The server's value wins, as it must for every replica to converge on the same answer. merged.Merged.Fingerprint.ShouldBe("SHA256:seen-from-the-desktop"); } [Fact] public void APortClash_IsReportedAsANumberRatherThanAsNothing() { // Not reachable from this client — the store never re-addresses a pin — but a payload from elsewhere // is untrusted input, and a conflict entry with an empty value in it would be a notice about nothing. var ancestor = Pin(); var local = ancestor with { Port = 2222 }; var remote = ancestor with { Port = 2022 }; var merged = KnownHostSecretMerge.Merge(ancestor, local, remote); var conflict = merged.Conflicts.ShouldHaveSingleItem(); conflict.Field.ShouldBe(nameof(KnownHostSecret.Port)); conflict.Kept.ShouldBe("2022"); conflict.Discarded.ShouldBe("2222"); } private static KnownHostSecret Pin( string host = "db.internal", int port = 22, string algorithm = "ssh-ed25519", string fingerprint = "SHA256:aaa") => new() { Host = host, Port = port, Algorithm = algorithm, Fingerprint = fingerprint, }; }