using System.Security.Cryptography; using DodoSSH.Crypto; using NSec.Cryptography; namespace DodoSSH.Crypto.Tests; /// /// Round-trip behaviour and, more importantly, the negative cases from docs/crypto.md §4.4. /// /// /// The negative tests are the point of this file. They are the executable form of the claim /// that a server holding every ciphertext and every plaintext column still cannot relocate, /// roll back, replay or repurpose a blob. /// public sealed class DshCryptoTests { private static readonly Guid CredentialId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f"); private static readonly Guid OtherCredentialId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e10"); private static byte[] NewKey() => RandomNumberGenerator.GetBytes(CryptoSpec.SymmetricKeySize); private static AadDescriptor Payload(Guid id, uint generation = 1, uint version = 1) => AadDescriptor.Create( CryptoSpec.AadPurpose.ItemPayload, CryptoSpec.AadResourceType.Credential, id, keyGeneration: generation, itemVersion: version); [Fact] public void Seal_RoundTrips() { var key = NewKey(); var plaintext = "correct horse battery staple"u8.ToArray(); var descriptor = Payload(CredentialId); var envelope = DshCrypto.Seal(key, plaintext, descriptor); DshCrypto.Open(key, envelope, descriptor).ShouldBe(plaintext); } [Fact] public void Seal_ProducesAWellFormedEnvelope() { var envelope = DshCrypto.Seal(NewKey(), "x"u8, Payload(CredentialId)); DshEnvelope.TryRead(envelope, out var view).ShouldBeTrue(); view.Algorithm.ShouldBe(CryptoSpec.AlgorithmId.XChaCha20Poly1305); view.Nonce.Length.ShouldBe(DshEnvelope.XChaChaNonceSize); view.EphemeralPublicKey.IsEmpty.ShouldBeTrue(); envelope[..4].ShouldBe("DSH1"u8.ToArray()); } [Fact] public void Seal_UsesAFreshNoncePerCall() { var key = NewKey(); var descriptor = Payload(CredentialId); var first = DshCrypto.Seal(key, "same"u8, descriptor); var second = DshCrypto.Seal(key, "same"u8, descriptor); first.ShouldNotBe(second); } [Fact] public void Open_RejectsTheWrongKey() { var envelope = DshCrypto.Seal(NewKey(), "secret"u8, Payload(CredentialId)); DshCrypto.Open(NewKey(), envelope, Payload(CredentialId)).ShouldBeNull(); } [Fact] public void Open_RejectsATamperedCiphertext() { var key = NewKey(); var descriptor = Payload(CredentialId); var envelope = DshCrypto.Seal(key, "secret"u8, descriptor); envelope[^1] ^= 0x01; DshCrypto.Open(key, envelope, descriptor).ShouldBeNull(); } [Fact] public void Open_RejectsANonZeroFlagByte() { var key = NewKey(); var descriptor = Payload(CredentialId); var envelope = DshCrypto.Seal(key, "secret"u8, descriptor); // Fail closed on an envelope we do not fully understand. envelope[5] = 0x01; DshCrypto.Open(key, envelope, descriptor).ShouldBeNull(); } [Theory] [InlineData("DSH0")] [InlineData("XSH1")] public void Open_RejectsABadMagic(string magic) { var key = NewKey(); var descriptor = Payload(CredentialId); var envelope = DshCrypto.Seal(key, "secret"u8, descriptor); System.Text.Encoding.ASCII.GetBytes(magic).CopyTo(envelope, 0); DshCrypto.Open(key, envelope, descriptor).ShouldBeNull(); } [Fact] public void Open_RejectsATruncatedEnvelope() { var key = NewKey(); var descriptor = Payload(CredentialId); var envelope = DshCrypto.Seal(key, "secret"u8, descriptor); DshCrypto.Open(key, envelope.AsSpan(0, envelope.Length / 2), descriptor).ShouldBeNull(); DshCrypto.Open(key, [], descriptor).ShouldBeNull(); } // ---- docs/crypto.md §4.4: what a malicious server cannot do ---- [Fact] public void Server_CannotMoveCiphertextToAnotherResource() { var key = NewKey(); var envelope = DshCrypto.Seal(key, "host-a password"u8, Payload(CredentialId)); // Same vault key, same everything, different row. DshCrypto.Open(key, envelope, Payload(OtherCredentialId)).ShouldBeNull(); } [Fact] public void Server_CannotRollBackAKeyGeneration() { var key = NewKey(); var envelope = DshCrypto.Seal(key, "secret"u8, Payload(CredentialId, generation: 5)); DshCrypto.Open(key, envelope, Payload(CredentialId, generation: 4)).ShouldBeNull(); } [Fact] public void Server_CannotRollBackAnItemVersion() { var key = NewKey(); var envelope = DshCrypto.Seal(key, "v2 secret"u8, Payload(CredentialId, version: 2)); DshCrypto.Open(key, envelope, Payload(CredentialId, version: 1)).ShouldBeNull(); } [Fact] public void Server_CannotRepurposeAPayloadAsMetadata() { var key = NewKey(); var envelope = DshCrypto.Seal(key, "secret"u8, Payload(CredentialId)); var asMetadata = AadDescriptor.Create( CryptoSpec.AadPurpose.ItemMetadata, CryptoSpec.AadResourceType.Credential, CredentialId, itemVersion: 1); DshCrypto.Open(key, envelope, asMetadata).ShouldBeNull(); } [Fact] public void Server_CannotRepurposeAcrossResourceTypes() { var key = NewKey(); var envelope = DshCrypto.Seal(key, "secret"u8, Payload(CredentialId)); var asHost = AadDescriptor.Create( CryptoSpec.AadPurpose.ItemPayload, CryptoSpec.AadResourceType.Host, CredentialId, itemVersion: 1); DshCrypto.Open(key, envelope, asHost).ShouldBeNull(); } // ---- SealTo ---- private static Key NewAgreementKey() => Key.Create( KeyAgreementAlgorithm.X25519, new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport }); private static AadDescriptor Grant(Guid vaultId, uint generation = 1) => AadDescriptor.Create( CryptoSpec.AadPurpose.VaultKeyGrant, CryptoSpec.AadResourceType.Vault, vaultId, keyGeneration: generation); [Fact] public void SealTo_RoundTrips() { var vaultId = Guid.CreateVersion7(); using var recipient = NewAgreementKey(); var recipientPublic = recipient.PublicKey.Export(KeyBlobFormat.RawPublicKey); var vaultKey = NewKey(); var descriptor = Grant(vaultId); var envelope = DshCrypto.SealTo(recipientPublic, vaultKey, descriptor); DshCrypto.OpenSealed(recipient, envelope, descriptor).ShouldBe(vaultKey); } [Fact] public void SealTo_ProducesAWellFormedEnvelopeCarryingAnEphemeralKey() { using var recipient = NewAgreementKey(); var envelope = DshCrypto.SealTo( recipient.PublicKey.Export(KeyBlobFormat.RawPublicKey), NewKey(), Grant(Guid.CreateVersion7())); DshEnvelope.TryRead(envelope, out var view).ShouldBeTrue(); view.Algorithm.ShouldBe(CryptoSpec.AlgorithmId.SealToX25519); view.EphemeralPublicKey.Length.ShouldBe(CryptoSpec.PublicKeySize); view.Nonce.Length.ShouldBe(DshEnvelope.XChaChaNonceSize); } [Fact] public void SealTo_IsNotOpenableByAnotherRecipient() { using var intended = NewAgreementKey(); using var attacker = NewAgreementKey(); var descriptor = Grant(Guid.CreateVersion7()); var envelope = DshCrypto.SealTo( intended.PublicKey.Export(KeyBlobFormat.RawPublicKey), NewKey(), descriptor); DshCrypto.OpenSealed(attacker, envelope, descriptor).ShouldBeNull(); } [Fact] public void SealTo_IsBoundToItsVaultAndGeneration() { var vaultId = Guid.CreateVersion7(); using var recipient = NewAgreementKey(); var recipientPublic = recipient.PublicKey.Export(KeyBlobFormat.RawPublicKey); var envelope = DshCrypto.SealTo(recipientPublic, NewKey(), Grant(vaultId, generation: 3)); // A revoked grant from an earlier generation must not be replayable. DshCrypto.OpenSealed(recipient, envelope, Grant(vaultId, generation: 2)).ShouldBeNull(); DshCrypto.OpenSealed(recipient, envelope, Grant(Guid.CreateVersion7(), generation: 3)).ShouldBeNull(); } [Fact] public void SealTo_UsesAFreshEphemeralKeyPerCall() { using var recipient = NewAgreementKey(); var recipientPublic = recipient.PublicKey.Export(KeyBlobFormat.RawPublicKey); var descriptor = Grant(Guid.CreateVersion7()); var vaultKey = NewKey(); var first = DshCrypto.SealTo(recipientPublic, vaultKey, descriptor); var second = DshCrypto.SealTo(recipientPublic, vaultKey, descriptor); DshEnvelope.TryRead(first, out var a).ShouldBeTrue(); DshEnvelope.TryRead(second, out var b).ShouldBeTrue(); a.EphemeralPublicKey.SequenceEqual(b.EphemeralPublicKey).ShouldBeFalse(); } [Fact] public void OpenSealed_RejectsASymmetricEnvelope() { // Cross-construction confusion: a symmetric envelope must not be accepted here. using var recipient = NewAgreementKey(); var descriptor = Grant(Guid.CreateVersion7()); var symmetric = DshCrypto.Seal(NewKey(), "secret"u8, descriptor); DshCrypto.OpenSealed(recipient, symmetric, descriptor).ShouldBeNull(); } [Fact] public void Open_RejectsASealedEnvelope() { using var recipient = NewAgreementKey(); var descriptor = Grant(Guid.CreateVersion7()); var sealedEnvelope = DshCrypto.SealTo( recipient.PublicKey.Export(KeyBlobFormat.RawPublicKey), NewKey(), descriptor); DshCrypto.Open(NewKey(), sealedEnvelope, descriptor).ShouldBeNull(); } // ---- Fingerprints ---- [Fact] public void Fingerprint_IsStableAndOrderSensitive() { var x25519 = RandomNumberGenerator.GetBytes(CryptoSpec.PublicKeySize); var ed25519 = RandomNumberGenerator.GetBytes(CryptoSpec.PublicKeySize); var fingerprint = DshCrypto.ComputeFingerprint(x25519, ed25519); fingerprint.Length.ShouldBe(CryptoSpec.DigestSize); fingerprint.ShouldBe(DshCrypto.ComputeFingerprint(x25519, ed25519)); // Swapping the keys must change the fingerprint, or the two roles would be conflated. fingerprint.ShouldNotBe(DshCrypto.ComputeFingerprint(ed25519, x25519)); } [Fact] public void Fingerprint_RejectsWrongSizedKeys() { var valid = RandomNumberGenerator.GetBytes(CryptoSpec.PublicKeySize); Should.Throw(() => DshCrypto.ComputeFingerprint(new byte[31], valid)); Should.Throw(() => DshCrypto.ComputeFingerprint(valid, new byte[33])); } [Fact] public void Seal_RejectsWrongSizedKeys() { Should.Throw(() => DshCrypto.Seal(new byte[16], "x"u8, Payload(CredentialId))); } }