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