namespace DodoSSH.Client.Domain.Tests; /// /// The SSH key record, its codec and its merge. /// /// /// The codec is the point at which a private key becomes bytes and comes back, so a bug here is a key that /// either does not survive a round trip or survives it in a form SSH.NET will not load. The sync suite /// exercises all of this through two devices and a server, which is the right place for the reconciliation /// rules — but it cannot say which of these types was wrong when it fails. /// public sealed class SshKeySecretTests { private const string Material = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEA\n-----END OPENSSH PRIVATE KEY-----\n"; // ---- The record ---- [Fact] public void AnEmptyPassphrase_IsTheSameAsNone() { // One spelling of one state. The two that follow are what it buys: identical keys encode // identically, so they cannot produce a spurious merge conflict, and "is this key protected?" has a // single reliable answer for the interface to read. Key(passphrase: string.Empty).Passphrase.ShouldBeNull(); Key(passphrase: null).Passphrase.ShouldBeNull(); Key(passphrase: "hunter2").Passphrase.ShouldBe("hunter2"); } [Fact] public void APassphraseOfSpaces_IsKept() { // Whitespace is a legal passphrase, so this is deliberately not IsNullOrWhiteSpace. Trimming it // would silently change the passphrase of a key someone can still open elsewhere. Key(passphrase: " ").Passphrase.ShouldBe(" "); } [Fact] public void AnEmptyPassphraseAndNone_AreEqual() { // Follows from the normalisation, and it is the property the merge depends on: it compares the two // sides for equality to decide whether anything changed at all. Key(passphrase: string.Empty).ShouldBe(Key(passphrase: null)); } [Theory] [InlineData("", Material, "needs a name")] [InlineData(" ", Material, "needs a name")] [InlineData("deploy", "", "private key material")] [InlineData("deploy", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 deploy@laptop", ".pub")] [InlineData("deploy", "ecdsa-sha2-nistp256 AAAAE2VjZHNh deploy@laptop", ".pub")] [InlineData("deploy", "not a key at all", "-----BEGIN")] public void AnInvalidKey_SaysWhatIsWrongWithIt(string label, string material, string expected) { var key = new SshKeySecret { Label = label, PrivateKeyPem = material }; key.TryValidate(out var reason).ShouldBeFalse(); reason.ShouldNotBeNull().ShouldContain(expected); } [Fact] public void AKeyWithLeadingWhitespace_IsStillRecognised() { // A paste out of a terminal or an editor arrives with a newline in front of it more often than not. var key = new SshKeySecret { Label = "deploy", PrivateKeyPem = "\n " + Material }; key.TryValidate(out var reason).ShouldBeTrue(reason); } // ---- The codec ---- [Fact] public void AKey_SurvivesARoundTrip() { var key = Key(passphrase: "hunter2") with { PublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 deploy@laptop", Notes = "rotate in June", }; var encoded = SshKeySecretCodec.Encode(key); SshKeySecretCodec.TryDecode(encoded, out var document).ShouldBeTrue(); document.ShouldNotBeNull(); document.Key.ShouldBe(key); document.SchemaVersion.ShouldBe(SshKeySecretCodec.CurrentSchemaVersion); document.IsReadOnly.ShouldBeFalse(); } [Fact] public void TheMaterialIsNotReformatted() { // Verbatim, including the trailing newline. OpenSSH, PKCS#1 and PKCS#8 all round-trip untouched // because nothing here parses them, and a client that normalised the armour would eventually // normalise a format it did not fully understand. var awkward = "-----BEGIN RSA PRIVATE KEY-----\r\nMIIBOgIBAAJB\r\n-----END RSA PRIVATE KEY-----"; var encoded = SshKeySecretCodec.Encode(Key() with { PrivateKeyPem = awkward }); SshKeySecretCodec.TryDecode(encoded, out var document).ShouldBeTrue(); document.ShouldNotBeNull().Key.PrivateKeyPem.ShouldBe(awkward); } [Fact] public void EncodingIsDeterministic() { // An unchanged key must not look like a change to the sync engine, which compares ciphertext-bearing // payloads derived from these bytes. SshKeySecretCodec.Encode(Key(passphrase: "hunter2")) .ShouldBe(SshKeySecretCodec.Encode(Key(passphrase: "hunter2"))); } [Fact] public void AnEmptyPassphraseIsNotWrittenAtAll() { // The normalisation reaches the wire: a key saved with a blank box is byte-identical to one saved // with no passphrase, so the two cannot diverge into a spurious conflict on another machine. SshKeySecretCodec.Encode(Key(passphrase: string.Empty)) .ShouldBe(SshKeySecretCodec.Encode(Key(passphrase: null))); } [Fact] public void AnEmptyPassphraseWrittenByAnotherClient_DecodesAsNone() { var payload = System.Text.Encoding.UTF8.GetBytes( $$""" {"schemaVersion":1,"label":"deploy","privateKeyPem":{{System.Text.Json.JsonSerializer.Serialize(Material)}},"passphrase":""} """); SshKeySecretCodec.TryDecode(payload, out var document).ShouldBeTrue(); document.ShouldNotBeNull().Key.Passphrase.ShouldBeNull(); } [Theory] [InlineData("not json at all")] [InlineData("{}")] [InlineData("""{"schemaVersion":0,"label":"deploy","privateKeyPem":"x"}""")] [InlineData("""{"schemaVersion":1,"label":"deploy"}""")] [InlineData("""{"schemaVersion":1,"privateKeyPem":"-----BEGIN X-----"}""")] public void APayloadThatIsNotAKey_DoesNotDecode(string json) { // False rather than a throw, and rather than a half-built key. A decode failure is what a rotated // vault key and a server handing back the wrong bytes both look like from here, and neither must // abort a sync pass. SshKeySecretCodec .TryDecode(System.Text.Encoding.UTF8.GetBytes(json), out var document) .ShouldBeFalse(); document.ShouldBeNull(); } [Fact] public void AKeyFromANewerClient_IsReadableButNotWritable() { var payload = System.Text.Encoding.UTF8.GetBytes( $$""" {"schemaVersion":99,"label":"deploy","privateKeyPem":{{System.Text.Json.JsonSerializer.Serialize(Material)}},"certificate":"something this build has never heard of"} """); SshKeySecretCodec.TryDecode(payload, out var document).ShouldBeTrue(); document.ShouldNotBeNull(); document.SchemaVersion.ShouldBe(99); document.IsReadOnly.ShouldBeTrue( "re-encoding would drop the field, leaving a key that still decrypts and no longer works"); } // ---- The merge ---- [Fact] public void EachSideEditingADifferentField_KeepsBoth() { var ancestor = Key(); var local = ancestor with { Label = "deploy-laptop" }; var remote = ancestor with { Notes = "from the desktop" }; var merged = SshKeySecretMerge.Merge(ancestor, local, remote); merged.HasConflicts.ShouldBeFalse(); merged.Merged.Label.ShouldBe("deploy-laptop"); merged.Merged.Notes.ShouldBe("from the desktop"); merged.Merged.PrivateKeyPem.ShouldBe(ancestor.PrivateKeyPem); } [Fact] public void BothSidesReplacingTheMaterial_ReportsTheClashWithoutQuotingEitherKey() { var ancestor = Key(); var local = ancestor with { PrivateKeyPem = Armour("LAPTOP-SECRET") }; var remote = ancestor with { PrivateKeyPem = Armour("DESKTOP-SECRET") }; var merged = SshKeySecretMerge.Merge(ancestor, local, remote); merged.HasConflicts.ShouldBeTrue(); var conflict = merged.Conflicts.ShouldHaveSingleItem(); conflict.Field.ShouldBe(nameof(SshKeySecret.PrivateKeyPem)); // Named, so the user knows what clashed. Not quoted, because the conflict log is stored to be read // and is deliberately kept after acknowledgement. conflict.Kept.ShouldNotContain("LAPTOP-SECRET"); conflict.Kept.ShouldNotContain("DESKTOP-SECRET"); conflict.Discarded.ShouldNotBeNull().ShouldNotContain("LAPTOP-SECRET"); conflict.Discarded.ShouldNotContain("DESKTOP-SECRET"); // And the surviving key is a real one — redacting the report must not redact the value. merged.Merged.PrivateKeyPem.ShouldBeOneOf(local.PrivateKeyPem, remote.PrivateKeyPem); } [Fact] public void BothSidesChangingThePassphrase_IsAlsoRedacted() { var ancestor = Key(passphrase: "original"); var local = ancestor with { Passphrase = "laptop-passphrase" }; var remote = ancestor with { Passphrase = "desktop-passphrase" }; var merged = SshKeySecretMerge.Merge(ancestor, local, remote); var conflict = merged.Conflicts.ShouldHaveSingleItem(); conflict.Field.ShouldBe(nameof(SshKeySecret.Passphrase)); conflict.Kept.ShouldNotContain("passphrase-"); conflict.Kept.ShouldNotContain("laptop-passphrase"); conflict.Discarded.ShouldNotBeNull().ShouldNotContain("desktop-passphrase"); } [Fact] public void ALabelClash_IsShownInFull() { // The counterpart to the redaction: a label is not a secret, and hiding it would leave the user // unable to tell which name was discarded. var ancestor = Key(); var local = ancestor with { Label = "deploy-laptop" }; var remote = ancestor with { Label = "deploy-desktop" }; var merged = SshKeySecretMerge.Merge(ancestor, local, remote); var conflict = merged.Conflicts.ShouldHaveSingleItem(); conflict.Field.ShouldBe(nameof(SshKeySecret.Label)); new[] { conflict.Kept, conflict.Discarded } .ShouldBe(["deploy-laptop", "deploy-desktop"], ignoreOrder: true); } [Fact] public void BothSidesMakingTheSameEdit_IsNotAConflict() { var ancestor = Key(); var edited = ancestor with { Notes = "rotate in June" }; var merged = SshKeySecretMerge.Merge(ancestor, edited, edited); merged.HasConflicts.ShouldBeFalse(); merged.Merged.ShouldBe(edited); } private static string Armour(string body) => $"-----BEGIN OPENSSH PRIVATE KEY-----\n{body}\n-----END OPENSSH PRIVATE KEY-----\n"; private static SshKeySecret Key(string? passphrase = null) => new() { Label = "deploy", PrivateKeyPem = Material, Passphrase = passphrase }; }