using DodoSSH.Client.Storage; using DodoSSH.Contracts; using static DodoSSH.Client.Sync.Tests.SyncHarness; namespace DodoSSH.Client.Sync.Tests; /// /// SSH keys through the same two-machine harness as hosts. /// /// /// /// Deliberately not a copy of with the nouns changed. The six collision /// outcomes are decided by ItemReconciler<TSecret>, which is one implementation shared by both /// item types, so re-asserting all of them per type would test the same code twice and grow with every type /// added. What is tested here is what is genuinely different about a key: its cipher, its merge, the fact /// that it hands the server nothing in plaintext, that the reconciler's messages call it a key, and that its /// items cannot be confused with a host's. /// /// /// The two collision cases that are repeated — resurrection and an abandoned delete — are here /// because they are the two that touch key material: one re-seals it under a new id, the other decides /// whether a private key survives a deletion. /// /// public sealed class SshKeySyncTests : IAsyncLifetime { private SyncHarness harness = null!; private static CancellationToken Token => TestContext.Current.CancellationToken; /// public async ValueTask InitializeAsync() => harness = await CreateAsync(); /// public ValueTask DisposeAsync() { harness.Dispose(); return ValueTask.CompletedTask; } // ---- The uncontested paths ---- [Fact] public async Task AKeyCreatedOnOneMachine_ReachesTheOther() { var entityId = await harness.First.CreateKeyAsync( Key("deploy", material: "LAPTOP-MATERIAL", passphrase: "hunter2", notes: "rotate in June")); await harness.SettleAsync(); var seen = await harness.Second.FindKeyAsync(entityId); seen.Secret.Label.ShouldBe("deploy"); seen.Secret.PrivateKeyPem.ShouldContain("LAPTOP-MATERIAL"); seen.Secret.Passphrase.ShouldBe("hunter2"); seen.Secret.Notes.ShouldBe("rotate in June"); seen.HasUnsyncedChanges.ShouldBeFalse(); } [Fact] public async Task ThePull_AsksForKeysAsWellAsHosts() { await harness.First.SyncAsync(); var asked = harness.Server.LastPullTypes.ShouldNotBeNull(); asked.ShouldContain(SyncEntityType.Host); asked.ShouldContain( SyncEntityType.SshKey, "a type the engine can reconcile but never requests would work in every unit test and never " + "sync"); } [Fact] public async Task AHostAndAKeyQueuedTogether_BothGoInOnePush() { var hostId = await harness.First.CreateAsync(Host("prod-db")); var keyId = await harness.First.CreateKeyAsync(Key("deploy")); await harness.First.SyncAsync(); harness.Server.PushCount.ShouldBe(1, "one outbox, one batch, whatever the item types in it"); await harness.Second.SyncAsync(); (await harness.Second.FindAsync(hostId)).Secret.Label.ShouldBe("prod-db"); (await harness.Second.FindKeyAsync(keyId)).Secret.Label.ShouldBe("deploy"); } [Fact] public async Task AKeyList_DoesNotShowHosts() { await harness.First.CreateAsync(Host("prod-db")); await harness.First.CreateKeyAsync(Key("deploy")); await harness.SettleAsync(); (await harness.Second.ListKeysAsync()).Items.ShouldHaveSingleItem() .Secret.Label.ShouldBe("deploy"); (await harness.Second.ListAsync()).Items.ShouldHaveSingleItem() .Secret.Label.ShouldBe("prod-db"); } // ---- What the server is told ---- [Fact] public async Task AKeyHandsTheServerNothingInPlaintext() { // The public half is supplied, which is the case where a fingerprint could have been derived and // sent. The server has a column for one and would accept it; this client does not fill it, because a // fingerprint is a stable identifier for a key pair and nothing in the product reads the column. var entityId = await harness.First.CreateKeyAsync( Key("deploy", publicKey: "ssh-ed25519 AAAAC3Nz deploy@laptop")); var queued = await harness.First.Outbox .FindAsync(VaultId, SyncEntityType.SshKey, entityId, Token); queued.ShouldNotBeNull(); queued.Fields.ShouldBeNull("a key sends no plaintext fields at all, not an empty set of them"); await harness.SettleAsync(); var row = harness.Server.Find(entityId, SyncEntityType.SshKey).ShouldNotBeNull(); row.Fields.PublicKeyFingerprint.ShouldBeNull(); row.Fields.RelayEnabled.ShouldBeFalse(); row.Fields.Hostname.ShouldBeNull(); } [Fact] public async Task AHostAndAKeyWithTheSameId_AreDifferentItems() { // Not reachable through the repositories, which mint UUIDv7s, so it is arranged on the server. The // point is that two things defend the separation independently: the item table is keyed on the type // as well as the id, and the payload's AAD binds a resource type — so neither payload can be opened // as the other even if a lookup did confuse them. var sharedId = Guid.CreateVersion7(); harness.First.Keyring.TryGet(VaultId, out var vaultKey, out var generation).ShouldBeTrue(); harness.Server.ExternalUpsert( sharedId, HostCipher.Seal(Host("prod-db"), vaultKey.Span, sharedId, generation, itemVersion: 1), new SyncPlaintextFields(), SyncEntityType.Host); harness.Server.ExternalUpsert( sharedId, SshKeyCipher.Seal(Key("deploy"), vaultKey.Span, sharedId, generation, itemVersion: 1), null, SyncEntityType.SshKey); await harness.Second.SyncAsync(); var hosts = await harness.Second.ListAsync(); var keys = await harness.Second.ListKeysAsync(); hosts.Items.ShouldHaveSingleItem().Secret.Label.ShouldBe("prod-db"); keys.Items.ShouldHaveSingleItem().Secret.Label.ShouldBe("deploy"); hosts.Unreadable.ShouldBe(0); keys.Unreadable.ShouldBe(0); } // ---- Merging ---- [Fact] public async Task TwoMachinesEditingDifferentFieldsOfAKey_BothSurvive() { var entityId = await harness.First.CreateKeyAsync(Key("deploy", material: "SHARED")); await harness.SettleAsync(); await harness.First.UpdateKeyAsync(entityId, Key("deploy-laptop", material: "SHARED")); await harness.Second.UpdateKeyAsync( entityId, Key("deploy", material: "SHARED", notes: "from the desktop")); await harness.SettleAsync(); var first = (await harness.First.FindKeyAsync(entityId)).Secret; var second = (await harness.Second.FindKeyAsync(entityId)).Secret; first.ShouldBe(second); first.Label.ShouldBe("deploy-laptop"); first.Notes.ShouldBe("from the desktop"); first.PrivateKeyPem.ShouldContain("SHARED"); (await ConflictKindsAsync()).ShouldBeEmpty(); } [Fact] public async Task BothReplacedTheKeyMaterial_NeitherKeyIsWrittenToTheConflictLog() { // The reason SshKeySecretMerge redacts. A host conflict records the value that lost so the user can // put it back; doing that with a private key would copy a secret into a log that is designed to be // read and is deliberately kept after acknowledgement. var entityId = await harness.First.CreateKeyAsync(Key("deploy", material: "ORIGINAL")); await harness.SettleAsync(); await harness.First.UpdateKeyAsync(entityId, Key("deploy", material: "LAPTOP-SECRET")); await harness.Second.UpdateKeyAsync(entityId, Key("deploy", material: "DESKTOP-SECRET")); await harness.SettleAsync(); (await ConflictKindsAsync()).ShouldContain(kind => kind == ConflictKind.FieldOverridden); var details = await ConflictDetailsAsync(); details.ShouldContain( detail => detail.Contains("PrivateKeyPem", StringComparison.Ordinal), "the user still has to be told which field clashed"); foreach (var detail in details) { detail.ShouldNotContain("LAPTOP-SECRET"); detail.ShouldNotContain("DESKTOP-SECRET"); } } [Fact] public async Task BothChangedThePassphrase_ThePassphraseIsNotInTheLogEither() { var entityId = await harness.First.CreateKeyAsync(Key("deploy", passphrase: "original")); await harness.SettleAsync(); await harness.First.UpdateKeyAsync(entityId, Key("deploy", passphrase: "laptop-passphrase")); await harness.Second.UpdateKeyAsync(entityId, Key("deploy", passphrase: "desktop-passphrase")); await harness.SettleAsync(); foreach (var detail in await ConflictDetailsAsync()) { detail.ShouldNotContain("laptop-passphrase"); detail.ShouldNotContain("desktop-passphrase"); } } // ---- Deletes, where key material can be lost ---- [Fact] public async Task AKeyDeletedElsewhereWhileEditedHere_KeepsTheMaterialUnderANewName() { var entityId = await harness.First.CreateKeyAsync(Key("deploy", material: "IRREPLACEABLE")); await harness.SettleAsync(); await harness.First.DeleteKeyAsync(entityId); await harness.Second.UpdateKeyAsync( entityId, Key("deploy", material: "IRREPLACEABLE", notes: "still in use")); await harness.SettleAsync(); var restored = (await harness.First.ListKeysAsync()).Items.ShouldHaveSingleItem(); restored.EntityId.ShouldNotBe(entityId); restored.Secret.Label.ShouldBe("deploy (restored)"); restored.Secret.Notes.ShouldBe("still in use"); restored.Secret.PrivateKeyPem.ShouldContain( "IRREPLACEABLE", Case.Sensitive, "a resurrection that lost the key would rescue nothing"); (await ConflictKindsAsync()).ShouldContain(kind => kind == ConflictKind.RemoteDeleteResurrected); } [Fact] public async Task AKeyEditedElsewhereAfterBeingDeletedHere_SurvivesAndIsCalledAKey() { var entityId = await harness.First.CreateKeyAsync(Key("deploy")); await harness.SettleAsync(); await harness.First.UpdateKeyAsync(entityId, Key("deploy", notes: "still in use")); await harness.Second.DeleteKeyAsync(entityId); await harness.SettleAsync(); (await harness.First.FindKeyAsync(entityId)).Secret.Notes.ShouldBe("still in use"); (await ConflictKindsAsync()).ShouldContain(kind => kind == ConflictKind.LocalDeleteOverridden); // The noun matters: someone told a host was edited elsewhere goes looking in the host list. var details = await ConflictDetailsAsync(); details.ShouldContain(detail => detail.Contains("This SSH key was edited", StringComparison.Ordinal)); details.ShouldNotContain(detail => detail.Contains("This host was edited", StringComparison.Ordinal)); } // ---- Helpers ---- private async Task> ConflictKindsAsync() { var first = await harness.First.ConflictsAsync(); var second = await harness.Second.ConflictsAsync(); return [.. first.Concat(second).Select(conflict => conflict.Kind)]; } /// /// The raw stored detail, decoded as text rather than parsed. The redaction claims are claims about what /// is absent from the bytes, and reading through the JSON model would only prove the material /// is absent from the fields the model happens to name. /// private async Task> ConflictDetailsAsync() { var first = await harness.First.ConflictsAsync(); var second = await harness.Second.ConflictsAsync(); return [ .. first.Concat(second) .Select(conflict => System.Text.Encoding.UTF8.GetString(conflict.Detail)), ]; } }