using System.Security.Cryptography; using DodoSSH.Client.Domain; using DodoSSH.Client.Sync; using DodoSSH.Contracts; using DodoSSH.Crypto; namespace DodoSSH.Client.Sync.Tests; /// /// Each item type must be sealed under its own AAD resource type, and the two enums that name item types /// deliberately do not agree. /// /// /// /// SyncEntityType lists only syncable items, so Host is 1 and SshKey is 3. /// CryptoSpec.AadResourceType also covers users, devices and vaults, so the same two are 4 and 6. A /// cipher written by copying its neighbour and casting the wire type would therefore seal a private key as /// if it were a vault — encrypting cleanly, decrypting cleanly on the machine that wrote it, and violating /// docs/crypto.md in a way that surfaces only when another implementation reads the item. /// /// /// These tests are cheap and the alternative is a comment. The payload's AAD is frozen, so getting this /// wrong is not something a later release can quietly correct: only clients can re-encrypt, and they can /// only do it if they can still open what is there. /// /// public sealed class AadResourceTypeTests { /// /// The pairing stated as a table. If AadResourceType is ever renumbered, this is what says so — /// loudly, and before anything is written under the new numbers. /// [Theory] [InlineData(SyncEntityType.Host, CryptoSpec.AadResourceType.Host)] [InlineData(SyncEntityType.Credential, CryptoSpec.AadResourceType.Credential)] [InlineData(SyncEntityType.SshKey, CryptoSpec.AadResourceType.SshKey)] [InlineData(SyncEntityType.HostGroup, CryptoSpec.AadResourceType.HostGroup)] [InlineData(SyncEntityType.Tag, CryptoSpec.AadResourceType.Tag)] [InlineData(SyncEntityType.Snippet, CryptoSpec.AadResourceType.Snippet)] [InlineData(SyncEntityType.PortForward, CryptoSpec.AadResourceType.PortForward)] [InlineData(SyncEntityType.KnownHostKey, CryptoSpec.AadResourceType.KnownHostKey)] public void TheTwoEnums_AreNamedAlikeAndNumberedDifferently( SyncEntityType wire, CryptoSpec.AadResourceType resource) { Enum.GetName(wire).ShouldBe(Enum.GetName(resource)); // The point of the whole file: same name, different number. A test asserting equality here would be // asserting the bug. ((int)wire).ShouldNotBe( (int)resource, $"{wire} happens to share a value with its resource type, which makes a cast look correct. " + "Either the enums were renumbered or this pairing needs re-checking by hand."); } /// /// /// Opened independently, through the low-level ItemKeys API with the resource type this /// test names itself. That is the whole point, and the first version of this file got it wrong in an /// instructive way: it checked only that a key payload does not open as a host and vice versa, which is /// true however both ciphers are misconfigured. Seal and TryOpen share one constant, so /// changing it changes both, the round trip still works, and the two ciphers still differ from each /// other. Sealing every private key as if it were a vault passed all of it. /// /// /// A test that only compares an implementation against itself cannot catch a self-consistent mistake. /// This one states the specified value out of band and refuses anything else. /// /// [Fact] public void AKeyPayload_OpensUnderTheResourceTypeTheSpecificationNames() { var vaultKey = RandomNumberGenerator.GetBytes(32); var entityId = Guid.CreateVersion7(); const uint Generation = 1; const uint Version = 1; var payload = SshKeyCipher.Seal(NewKey(), vaultKey, entityId, Generation, (int)Version); var dataKey = ItemKeys.TryUnwrapDataKey( vaultKey, payload.WrappedDataKey, CryptoSpec.AadResourceType.SshKey, entityId, Generation, Version); dataKey.ShouldNotBeNull( "SshKeyCipher must wrap the data key under AadResourceType.SshKey; if this is null it used " + "some other resource type, which round-trips fine and violates docs/crypto.md."); ItemKeys.TryOpenPayload( dataKey, payload.Envelope, CryptoSpec.AadResourceType.SshKey, entityId, payload.DataKeyId, Generation, Version).ShouldNotBeNull("and it must seal the envelope under the same resource type."); } /// Pins the host cipher the same way, since the two are now easy to confuse for each other. [Fact] public void AHostPayload_OpensUnderTheResourceTypeTheSpecificationNames() { var vaultKey = RandomNumberGenerator.GetBytes(32); var entityId = Guid.CreateVersion7(); const uint Generation = 1; const uint Version = 1; var host = new HostSecret { Label = "prod-db", Hostname = "db.internal" }; var payload = HostCipher.Seal(host, vaultKey, entityId, Generation, (int)Version); ItemKeys.TryUnwrapDataKey( vaultKey, payload.WrappedDataKey, CryptoSpec.AadResourceType.Host, entityId, Generation, Version) .ShouldNotBeNull("HostCipher must wrap the data key under AadResourceType.Host."); } [Fact] public void AKeyPayload_DoesNotOpenAsAHost() { // Weaker than the two above and kept anyway: it is the property a reader expects to see, and it // covers the case where one cipher is corrected and the other is not. var vaultKey = RandomNumberGenerator.GetBytes(32); var entityId = Guid.CreateVersion7(); var sealedKey = SshKeyCipher.Seal(NewKey(), vaultKey, entityId, keyGeneration: 1, itemVersion: 1); HostCipher.TryOpen(sealedKey, vaultKey, entityId, itemVersion: 1).ShouldBeNull(); SshKeyCipher.TryOpen(sealedKey, vaultKey, entityId, itemVersion: 1).ShouldNotBeNull(); } [Fact] public void AHostPayload_DoesNotOpenAsAKey() { var vaultKey = RandomNumberGenerator.GetBytes(32); var entityId = Guid.CreateVersion7(); var host = new HostSecret { Label = "prod-db", Hostname = "db.internal" }; var sealedHost = HostCipher.Seal(host, vaultKey, entityId, keyGeneration: 1, itemVersion: 1); SshKeyCipher.TryOpen(sealedHost, vaultKey, entityId, itemVersion: 1).ShouldBeNull(); } [Fact] public void AKey_RoundTripsThroughTheCipher() { var vaultKey = RandomNumberGenerator.GetBytes(32); var entityId = Guid.CreateVersion7(); var key = NewKey(); var payload = SshKeyCipher.Seal(key, vaultKey, entityId, keyGeneration: 1, itemVersion: 3); var opened = SshKeyCipher.TryOpen(payload, vaultKey, entityId, itemVersion: 3); opened.ShouldNotBeNull(); opened.Key.ShouldBe(key); opened.SchemaVersion.ShouldBe(SshKeySecretCodec.CurrentSchemaVersion); opened.IsReadOnly.ShouldBeFalse(); } [Fact] public void AKeySealedAtOneVersion_DoesNotOpenAtAnother() { // The item version is in the AAD, which is what stops a server rolling a row back to earlier // ciphertext. Asserted for keys as well as hosts because it is the property most easily lost by // copying a cipher and adjusting the wrong argument. var vaultKey = RandomNumberGenerator.GetBytes(32); var entityId = Guid.CreateVersion7(); var payload = SshKeyCipher.Seal(NewKey(), vaultKey, entityId, keyGeneration: 1, itemVersion: 2); SshKeyCipher.TryOpen(payload, vaultKey, entityId, itemVersion: 3).ShouldBeNull(); } private static SshKeySecret NewKey() => new() { Label = "deploy", PrivateKeyPem = "-----BEGIN OPENSSH PRIVATE KEY-----\nnot-a-real-key\n-----END OPENSSH PRIVATE KEY-----", Passphrase = "a passphrase", PublicKey = "ssh-ed25519 AAAAC3Nz deploy@example", Notes = "used by CI", }; }