using System.Buffers.Text; using System.Text; namespace DodoSSH.Crypto.Tests; /// /// The canonical key statement encoding and the nonce derived from it. See docs/crypto.md §7.1. /// /// /// These properties are what make the identity-provider binding checkable at all. The failure mode /// they guard against is nasty: a client and a server that encode differently produce different /// nonces, so every enrollment is rejected against a real provider while every local test passes. /// public sealed class KeyStatementCodecTests { private static readonly DateTimeOffset CreatedAt = DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123); [Fact] public void Encode_IsDeterministic() { var first = KeyStatementCodec.Encode(Statement()); var second = KeyStatementCodec.Encode(Statement()); first.ShouldBe(second); } [Fact] public void Encode_BeginsWithTheDomainLabel() { var encoding = KeyStatementCodec.Encode(Statement()); encoding.AsSpan(0, KeyStatementCodec.Label.Length) .SequenceEqual(KeyStatementCodec.Label) .ShouldBeTrue(); } [Fact] public void AnAbsentEmail_EncodesDifferentlyFromAnEmptyOne() { // The presence byte exists for exactly this. Without it the encoding is not injective and // two genuinely different statements share a binding. var absent = KeyStatementCodec.Encode(Statement(email: null)); var empty = KeyStatementCodec.Encode(Statement(email: string.Empty)); absent.ShouldNotBe(empty); } [Fact] public void TheSameInstantInDifferentOffsets_EncodesIdentically() { // The timezone a client happens to hold must not change the hash the provider signs. var utc = KeyStatementCodec.Encode(Statement(createdAt: CreatedAt)); var shifted = KeyStatementCodec.Encode( Statement(createdAt: CreatedAt.ToOffset(TimeSpan.FromHours(-7)))); utc.ShouldBe(shifted); } [Fact] public void SubMillisecondPrecision_IsTruncatedAway() { // PostgreSQL stores microseconds. A statement that has been through the database must still // hash to what the client hashed before sending it. var exact = KeyStatementCodec.Encode(Statement(createdAt: CreatedAt)); var noisy = KeyStatementCodec.Encode(Statement(createdAt: CreatedAt.AddTicks(9_999))); exact.ShouldBe(noisy); } [Theory] [InlineData("issuer")] [InlineData("subject")] [InlineData("email")] [InlineData("deviceName")] [InlineData("version")] [InlineData("keyGeneration")] [InlineData("createdAt")] [InlineData("encryptionPublicKey")] [InlineData("signingPublicKey")] public void ChangingAnyField_ChangesTheBinding(string field) { var baseline = KeyStatementCodec.ComputeBinding(Statement()); var altered = KeyStatementCodec.ComputeBinding(Mutate(field)); altered.ShouldNotBe(baseline); } [Fact] public void MovingACharacterAcrossAFieldBoundary_ChangesTheBinding() { // The property length prefixes buy: no field value can forge a boundary. A delimited // encoding would give these two the same bytes. var left = KeyStatementCodec.ComputeBinding( Statement(issuer: "https://idp.example/a", subject: "bcd")); var right = KeyStatementCodec.ComputeBinding( Statement(issuer: "https://idp.example/ab", subject: "cd")); left.ShouldNotBe(right); } [Fact] public void StringLengths_AreCountedInBytesNotCharacters() { // A device name whose UTF-8 length exceeds its character count must still round-trip, and // must not collide with a shorter one. A length prefix counting characters truncates here. var multiByte = Statement(deviceName: "büro — ThinkPad"); var encoding = KeyStatementCodec.Encode(multiByte); var expectedNameBytes = Encoding.UTF8.GetByteCount(multiByte.DeviceName); expectedNameBytes.ShouldBeGreaterThan(multiByte.DeviceName.Length); // The name is the last field, so it occupies the tail of the encoding. Encoding.UTF8.GetString(encoding.AsSpan(encoding.Length - expectedNameBytes)) .ShouldBe(multiByte.DeviceName); } [Fact] public void Nonce_IsUnpaddedBase64UrlOfTheBinding() { var statement = Statement(); var binding = KeyStatementCodec.ComputeBinding(statement); var nonce = KeyStatementCodec.ComputeNonce(statement); nonce.Length.ShouldBe(KeyStatementCodec.NonceLength); nonce.ShouldNotContain("="); nonce.ShouldNotContain("+"); nonce.ShouldNotContain("/"); Base64Url.DecodeFromChars(nonce).ShouldBe(binding); } [Fact] public void ComputeBinding_AgreesBetweenBothOverloads() { var statement = Statement(); KeyStatementCodec.ComputeBinding(KeyStatementCodec.Encode(statement)) .ShouldBe(KeyStatementCodec.ComputeBinding(statement)); } [Theory] [InlineData(0)] [InlineData(31)] [InlineData(33)] [InlineData(64)] public void Encode_RejectsAPublicKeyOfTheWrongLength(int length) { var statement = Statement() with { EncryptionPublicKey = new byte[length] }; Should.Throw(() => KeyStatementCodec.Encode(statement)); } [Theory] [InlineData(0)] [InlineData(-1)] [InlineData(65536)] public void Encode_RejectsAnUnusableVersion(int version) { var statement = Statement() with { Version = version }; Should.Throw(() => KeyStatementCodec.Encode(statement)); } [Fact] public void Encode_RejectsAGenerationBelowOne() { var statement = Statement() with { KeyGeneration = 0 }; Should.Throw(() => KeyStatementCodec.Encode(statement)); } [Fact] public void ToNonce_RejectsSomethingThatIsNotADigest() { Should.Throw(() => KeyStatementCodec.ToNonce(new byte[16])); } private static KeyStatementFields Statement( string issuer = "https://idp.example/realms/dodossh", string subject = "alice-subject", string? email = "alice@example.com", string deviceName = "alice-laptop", int keyGeneration = 1, DateTimeOffset? createdAt = null) => new( Version: 1, Issuer: issuer, Subject: subject, Email: email, EncryptionPublicKey: TestKeys.Encryption, SigningPublicKey: TestKeys.Signing, KeyGeneration: keyGeneration, CreatedAt: createdAt ?? CreatedAt, DeviceName: deviceName); private static KeyStatementFields Mutate(string field) => field switch { "issuer" => Statement(issuer: "https://idp.example/realms/other"), "subject" => Statement(subject: "bob-subject"), "email" => Statement(email: "bob@example.com"), "deviceName" => Statement(deviceName: "bob-desktop"), "version" => Statement() with { Version = 2 }, "keyGeneration" => Statement(keyGeneration: 2), "createdAt" => Statement(createdAt: CreatedAt.AddSeconds(1)), "encryptionPublicKey" => Statement() with { EncryptionPublicKey = TestKeys.Alternate }, "signingPublicKey" => Statement() with { SigningPublicKey = TestKeys.Alternate }, _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unknown field."), }; } /// Fixed public-key bytes, so encodings are reproducible. internal static class TestKeys { internal static byte[] Encryption { get; } = [.. Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0x40 + i))]; internal static byte[] Signing { get; } = [.. Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0x60 + i))]; internal static byte[] Alternate { get; } = [.. Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0xA0 + i))]; }