Public Access
Closes the largest remaining M1 gap in the data layer: a username and password can live in the vault, sync between machines, and be named by a host as how it authenticates. What is not here is the interface for creating one — see the end of this message. The third item type, and the first one that cost almost nothing to add. Server: a VaultCredential row, an EF configuration, a migration, and a CredentialKind. Client: a secret, a codec, a merge, a cipher, a kind, a repository facade and a session property. No new reconciliation logic, no change to the sync engine, no client cache migration. That was the whole point of the item-kind seam, and this is the evidence it holds. The narrowest type of the three on plaintext, and not for symmetry. A host has a deliberate concession — the relay needs an address it can resolve. A key has a fingerprint, public by nature, which this client still declines to send. A password has no part that is safe to expose: not its length, not a hash, not a hint. So CredentialKind refuses every plaintext field there is, hydrates none, and the table has no column to put one in. HostSecret.CredentialId is the password counterpart of SshKeyId, and the two are mutually exclusive. SSH itself would happily try a key and fall back to a password, but a host naming both leaves "how does this authenticate?" without a single answer — the interface, the connect path and the user would each be free to guess differently. TryValidate refuses it. One consequence was not anticipated: "a full host" stops being a coherent idea, which is what broke AFullHost_RoundTrips and is now written into that test. The schema version became a ladder rather than a maximum: credential-bound is 3, key-bound is 2, neither is still 1. Adding credentials therefore does not drag every key-bound host in every vault onto a version that clients understanding keys perfectly well would refuse to edit. A test pins exactly that, because it is the property the whole content-dependent-version rule exists to provide, and the obvious implementation would quietly lose it. Two tests had become false and said so: - Push_AnUnsupportedEntityType_IsInvalidNotAFailedBatch used Credential as its example of a type this server does not implement. It now asks the server's own registry what is still missing, so it cannot go stale again, and skips with a reason if that set ever empties. - ThePullFilterNamesEveryTypeThisBuildSynchronises pinned the exact list, which is what it is for. Also fixes ten nullable warnings — eight in SyncEndpointTests, two in a test file added earlier today. Neither set was introduced here; both were invisible until an unrelated change forced their project to recompile, which means the zero-warning claims made earlier in this work only ever covered what happened to be rebuilt. 777 tests green. Zero warnings, dotnet format clean. Not done, and deliberately: the credential interface. The vault column is 340 pixels wide and already holds two lists and two editors, kept from clipping its own buttons at the window's minimum height only by the one-editor-at-a-time rule added earlier today. A third list and a third editor would recreate that defect rather than avoid it, so the column needs a shape decision first. Credentials sync; they cannot yet be created in the interface.
269 lines
11 KiB
C#
269 lines
11 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.
|
|
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 };
|
|
}
|