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");
}
}
}