using DodoSSH.Client.Domain; using DodoSSH.Contracts; using DodoSSH.Crypto; namespace DodoSSH.Client.Sync.Tests; /// /// Sealing and opening a host payload. /// /// /// Mostly negative tests, and deliberately so. docs/crypto.md ยง4.4 claims a server holding every /// ciphertext still cannot move a payload between rows, roll one back to an earlier generation, or pair /// one item's envelope with another's key wrap. Those claims are only worth making if something checks /// them at the layer that actually assembles the AAD. /// public sealed class HostCipherTests { private static readonly Guid HostA = Guid.Parse("0192f0c8-000a-7c3d-8e4f-5a6b7c8d9e0f"); private static readonly Guid HostB = Guid.Parse("0192f0c8-000b-7c3d-8e4f-5a6b7c8d9e0f"); private readonly byte[] vaultKey = VaultKeys.Create(); private readonly byte[] otherVaultKey = VaultKeys.Create(); [Fact] public void AHost_RoundTrips() { var host = Host(); var payload = HostCipher.Seal(host, vaultKey, HostA, keyGeneration: 1, itemVersion: 1); var opened = HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 1); opened.ShouldNotBeNull(); opened.Host.ShouldBe(host); opened.IsReadOnly.ShouldBeFalse(); } [Fact] public void EverySeal_UsesAFreshDataKey() { // One key per item version, so nonce-collision analysis is moot and a rotation re-wraps 32 bytes // rather than rewriting content. var host = Host(); var first = HostCipher.Seal(host, vaultKey, HostA, 1, 1); var second = HostCipher.Seal(host, vaultKey, HostA, 1, 1); first.DataKeyId.ShouldNotBe(second.DataKeyId); first.WrappedDataKey.ShouldNotBe(second.WrappedDataKey); first.Envelope.ShouldNotBe(second.Envelope); } [Fact] public void APayload_CannotBeReadAsAnotherItem() { // The property that stops a server pasting one host's payload onto another row. var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1); HostCipher.TryOpen(payload, vaultKey, HostB, itemVersion: 1).ShouldBeNull(); } [Fact] public void APayload_CannotBeReadAtAnotherVersion() { // The sharpest edge in this layer. A payload is sealed at the version the server will assign, so // getting that prediction wrong produces something that encrypts cleanly and never decrypts. The // binding is what turns a silent corruption into a visible failure. var payload = HostCipher.Seal(Host(), vaultKey, HostA, keyGeneration: 1, itemVersion: 2); HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 1).ShouldBeNull(); HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 3).ShouldBeNull(); HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 2).ShouldNotBeNull(); } [Fact] public void APayload_CannotBeRolledBackToAnEarlierKeyGeneration() { var payload = HostCipher.Seal(Host(), vaultKey, HostA, keyGeneration: 2, itemVersion: 1); // The generation travels with the payload, so a server rewriting the column to 1 changes the AAD // the client recomputes and the tag fails. var rolledBack = payload with { KeyGeneration = 1 }; HostCipher.TryOpen(rolledBack, vaultKey, HostA, itemVersion: 1).ShouldBeNull(); } [Fact] public void APayload_CannotBeReadWithAnotherVaultsKey() { var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1); HostCipher.TryOpen(payload, otherVaultKey, HostA, itemVersion: 1).ShouldBeNull(); } [Fact] public void OneItemsEnvelope_CannotBePairedWithAnothersKeyWrap() { // What content_key_id is in the AAD for. Without it the two halves of a payload would be // interchangeable and a server could mix them. var first = HostCipher.Seal(Host(label: "one"), vaultKey, HostA, 1, 1); var second = HostCipher.Seal(Host(label: "two"), vaultKey, HostA, 1, 1); var mixed = first with { WrappedDataKey = second.WrappedDataKey }; HostCipher.TryOpen(mixed, vaultKey, HostA, itemVersion: 1).ShouldBeNull(); } [Fact] public void ATamperedEnvelope_DoesNotOpen() { var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1); var tampered = payload.Envelope.ToArray(); tampered[^1] ^= 0xFF; HostCipher.TryOpen(payload with { Envelope = tampered }, vaultKey, HostA, 1).ShouldBeNull(); } [Fact] public void ASubstitutedDataKeyId_DoesNotOpen() { var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1); HostCipher.TryOpen(payload with { DataKeyId = Guid.CreateVersion7() }, vaultKey, HostA, 1) .ShouldBeNull(); } [Fact] public void AMissingDataKey_IsRefusedRatherThanThrowing() { // What a row written before the data key existed in the contract would look like. It must degrade // to one unreadable item, not to an exception inside a sync pass. var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1); HostCipher.TryOpen(payload with { WrappedDataKey = [] }, vaultKey, HostA, 1).ShouldBeNull(); HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 0).ShouldBeNull(); } [Fact] public void Seal_RefusesAVersionBelowOne() { // Versions start at 1, and a zero would silently produce a payload no push could ever match. Should.Throw( () => HostCipher.Seal(Host(), vaultKey, HostA, 1, itemVersion: 0)); } [Fact] public void Seal_RefusesAHostThatCannotBeStored() { Should.Throw( () => HostCipher.Seal(Host(label: " "), vaultKey, HostA, 1, 1)); } [Fact] public void TheNextVersion_IsOneMoreThanTheVersionBeingReplaced() { // The prediction both the sealing and the opening side depend on. If these two ever disagreed the // result would be an item that encrypts and never decrypts, so they share one definition. SyncVersions.NextVersion(null).ShouldBe(1); SyncVersions.NextVersion(1).ShouldBe(2); SyncVersions.NextVersion(41).ShouldBe(42); } [Fact] public void ARelayEnabledHost_ExposesItsAddressAndNothingElseDoes() { // The single point at which a hostname can leave the payload. With relay off the server learns // only that an item exists; see ADR 0004. var off = HostFields.From(Host(relayEnabled: false)); off.RelayEnabled.ShouldBeFalse(); off.Hostname.ShouldBeNull(); off.Port.ShouldBeNull(); var on = HostFields.From(Host(hostname: "bastion.internal", port: 2222, relayEnabled: true)); on.RelayEnabled.ShouldBeTrue(); on.Hostname.ShouldBe("bastion.internal"); on.Port.ShouldBe(2222); } [Fact] public void TheRelayFlagIsInsideThePayload_SoItSurvivesARoundTrip() { // It has to be, or two clients could silently disagree about it and one would re-expose an // address the other had just withdrawn. var host = Host(relayEnabled: true); var payload = HostCipher.Seal(host, vaultKey, HostA, 1, 1); HostCipher.TryOpen(payload, vaultKey, HostA, 1)!.Host.RelayEnabled.ShouldBeTrue(); } private static HostSecret Host( string label = "prod-db", string hostname = "db.internal", int port = 22, bool relayEnabled = false) => new() { Label = label, Hostname = hostname, Port = port, Username = "deploy", Options = HostOptions.Create([new HostOption("Compression", "yes")]), RelayEnabled = relayEnabled, }; }