Public Access
Completes the client half of SSH keys: they sync alongside hosts, appear in their own list, and can be selected to authenticate a connection instead of typing a password. The reconciler and the repository were Host-typed throughout, so the choice was to generalise them or to keep a second copy per item type. Generalised, because ItemReconciler's whole premise is that the pull and the push paths must answer the same collision the same way — two copies would drift the first time one of them was fixed. What is genuinely per-type now arrives through IItemKind<TSecret>: the cipher, the merge, the plaintext columns, and the noun to use when telling a person what happened to their item. Generic where the server's IItemKind is not, and for the reason that reverses there — the client needs the concrete type, because it merges field by field. The pull filter is derived from the same registry that builds the reconcilers. That is the specific failure being designed out: an item type that encrypts, merges and lists perfectly and is never once requested from the server, so it works on the machine that made it and exists nowhere else. No client cache migration. The item table's primary key and the outbox's unique index already carry the entity type, and AadResourceTypes already mapped SshKey — so a host and a key may share an id and never see each other's rows, which SshKeySyncTests now arranges deliberately. A key hands the server nothing in plaintext. There is a public_key_fingerprint column and it would be accepted; leaving it null is deliberate. A fingerprint is not secret but it is a stable identifier for a key pair, so filling it would let an operator tell which of their users hold the same key and correlate one across vaults, for a column nothing reads. The design allows itself one plaintext concession — the relay address, which the relay cannot work without — and this is not that. A key is chosen per connection rather than bound to a host, which works the way ssh -i does. Binding one needs a field on HostSecret and therefore a payload schema bump, which makes every host written afterwards read-only on an older build; worth doing deliberately rather than as a side effect of adding keys. Three things this found, all of them by being falsified rather than by review: - Making the reconciler generic silently turned a record comparison into reference equality, because == on a type parameter is not value equality. The effect would have been a conflict recorded on every pass for an unacknowledged create that had in fact landed. Sabotaging the fix left all 73 tests passing — nothing covered that branch — so ConflictMatrixTests now has AnUnacknowledgedCreateThatDidLand_IsDroppedQuietly, which fails without it. - A test asserting that a blank passphrase reaches SSH.NET as null was vacuous: it exercised the editor, not the credential path, and passed with the guard deleted. Resolved by making SshKeySecret.Passphrase normalise an empty string to null, so there is one spelling of one state — which also keeps two clients from producing different payload bytes for an identical key. That exposed a wider gap: SshKeySecret, its codec and its merge had no direct unit tests at all. They have 25 now. - The reason first given for that normalisation was false. It claimed SSH.NET rejects a passphrase supplied for an unprotected key; measured against a real sshd it ignores it and authenticates anyway. Corrected everywhere it was stated and recorded in docs/platform-flags.md. The same test file also closes a real hole: SshPrivateKeyCredential had never been exercised against a server, because the existing key test builds SSH.NET's auth method directly and bypasses the path a vault-held key actually takes. Only one editor may be open at a time. Both sit in the same 340-pixel column as Auto rows and their heights together exceed it at the window's minimum size, so two open editors put the lower one's Save and Cancel past the bottom edge — the same failure this window already shipped once with the setup screens. Expressed as a state rule because that is the only form of it this repository can check: nothing here loads a .axaml. The refusal keeps what was typed, since in the key editor that is a pasted private key the user may have nowhere else. The end-to-end slice now carries a key as well as a host, so both item types go through the real API, the real PostgreSQL and the real crypto in one pass — the three hand-kept mappings between enums that do not line up are the reason that is worth doing rather than trusting the unit suites. 735 tests green, including the container-backed SSH and end-to-end suites. Zero warnings, dotnet format clean.
262 lines
10 KiB
C#
262 lines
10 KiB
C#
namespace DodoSSH.Client.Domain.Tests;
|
|
|
|
/// <summary>
|
|
/// The SSH key record, its codec and its merge.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
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 };
|
|
}
|