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.
var kept = conflict.Kept.ShouldNotBeNull();
var discarded = conflict.Discarded.ShouldNotBeNull();
foreach (var reported in new[] { kept, discarded })
{
reported.ShouldNotContain("LAPTOP-SECRET");
reported.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));
var kept = conflict.Kept.ShouldNotBeNull();
kept.ShouldNotContain("passphrase-");
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 };
}