using DodoSSH.Crypto; namespace DodoSSH.Crypto.Tests; /// /// Canonical AAD encoding, per docs/crypto.md §4. /// public sealed class AadDescriptorTests { private static readonly Guid ResourceId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f"); private static readonly Guid KeyId = Guid.Parse("0192f0c8-9999-7aaa-8bbb-cccccccccccc"); private static AadDescriptor Sample() => AadDescriptor.Create( CryptoSpec.AadPurpose.ItemPayload, CryptoSpec.AadResourceType.Credential, ResourceId, KeyId, keyGeneration: 7, itemVersion: 3); [Fact] public void Encoding_IsExactlySixtyFourBytes() { Sample().ToCanonicalEncoding().Length.ShouldBe(CryptoSpec.AadEncodedLength); } [Fact] public void Encoding_StartsWithMagicAndVersions() { var encoded = Sample().ToCanonicalEncoding(); encoded[..5].ShouldBe("dsh1\n"u8.ToArray()); encoded[5].ShouldBe(CryptoSpec.CurrentAadVersion); encoded[6].ShouldBe((byte)CryptoSpec.AadPurpose.ItemPayload); encoded[7].ShouldBe((byte)CryptoSpec.AadResourceType.Credential); } [Fact] public void Encoding_WritesUuidsInRfc4122ByteOrder() { // Guid.ToByteArray() emits the first three groups little-endian. Using it here would // make our ciphertext undecryptable by any other implementation of this spec, and the // bug would only surface at a cross-implementation boundary. var encoded = Sample().ToCanonicalEncoding(); encoded[8..24].ShouldBe(ResourceId.ToByteArray(bigEndian: true)); encoded[24..40].ShouldBe(KeyId.ToByteArray(bigEndian: true)); // And prove the mixed-endian form differs, so this test cannot pass vacuously. ResourceId.ToByteArray(bigEndian: true).ShouldNotBe(ResourceId.ToByteArray()); } [Fact] public void Encoding_WritesIntegersBigEndian() { var encoded = Sample().ToCanonicalEncoding(); encoded[40..44].ShouldBe(new byte[] { 0, 0, 0, 7 }); // keyGeneration encoded[44..48].ShouldBe(new byte[] { 0, 0, 0, 3 }); // itemVersion encoded[48..50].ShouldBe(new byte[] { 0, 1 }); // schemaVersion } [Fact] public void Encoding_LeavesReservedBytesZero() { Sample().ToCanonicalEncoding()[50..64].ShouldAllBe(b => b == 0); } [Fact] public void Encoding_IsDeterministic() { Sample().ToCanonicalEncoding().ShouldBe(Sample().ToCanonicalEncoding()); } [Fact] public void Aad_IsSha256OfTheCanonicalEncoding() { var descriptor = Sample(); descriptor.ComputeAad().ShouldBe( System.Security.Cryptography.SHA256.HashData(descriptor.ToCanonicalEncoding())); } [Fact] public void UnspecifiedPurpose_IsRejected() { var descriptor = AadDescriptor.Create( CryptoSpec.AadPurpose.Unspecified, CryptoSpec.AadResourceType.Host, ResourceId); Should.Throw(() => descriptor.ToCanonicalEncoding()); } [Fact] public void ShortDestination_IsRejected() { var descriptor = Sample(); Should.Throw(() => { var tooSmall = new byte[CryptoSpec.AadEncodedLength - 1]; descriptor.WriteCanonicalEncoding(tooSmall); }); } /// /// Each field must change the AAD. If one did not, the corresponding substitution attack /// in docs/crypto.md §4.4 would succeed. /// [Fact] public void EveryField_ChangesTheAad() { var baseline = Sample(); var baselineAad = baseline.ComputeAad(); var variants = new (string Field, AadDescriptor Descriptor)[] { ("purpose", baseline with { Purpose = CryptoSpec.AadPurpose.ItemMetadata }), ("resourceType", baseline with { ResourceType = CryptoSpec.AadResourceType.Host }), ("resourceId", baseline with { ResourceId = Guid.Parse("0192f0c8-dead-7bee-8fee-000000000001") }), ("keyId", baseline with { KeyId = Guid.Empty }), ("keyGeneration", baseline with { KeyGeneration = 8 }), ("itemVersion", baseline with { ItemVersion = 4 }), ("aadVersion", baseline with { AadVersion = 2 }), ("schemaVersion", baseline with { SchemaVersion = 2 }), }; foreach (var (field, descriptor) in variants) { descriptor.ComputeAad().ShouldNotBe(baselineAad, $"changing {field} must change the AAD"); } } }