Specify the key statement encoding and key log chain (crypto.md 7.1, 7.2)

Section 7 always required "a canonical, length-prefixed encoding" for
signatures without ever specifying one. That gap had to be closed before
enrollment could exist: the client hashes the key statement and uses the
result as an OIDC nonce, so the provider signs over those exact bytes. Two
implementations disagreeing by one byte produce two nonces and an
enrollment nobody can verify -- and it only shows up against a real
provider, never in a local test.

JSON cannot be the hashed form. Property order, number formatting, Unicode
escaping and whitespace all vary between serialisers. So the statement is
transmitted as JSON and hashed as a fixed binary encoding, and the two are
independent by construction.

Three details are load-bearing rather than stylistic:

- The presence byte before each string is what makes the encoding
  injective. Without it an absent email and an empty one encode
  identically, and two different statements share a binding.
- Timestamps truncate to milliseconds. PostgreSQL stores microseconds, so
  a statement that has been through the database must still hash to what
  the client hashed. The same applies to the key log, where an entry that
  cannot reproduce its own hash after being read back makes the chain
  unverifiable.
- The key log entry hash deliberately excludes the database sequence. It
  is unknown until the insert runs, and order already follows the hash
  links -- so a renumbered or gapped sequence column cannot silently
  reorder history.

KeyStatementFields is separate from Contracts.KeyStatement on purpose: one
may gain JSON fields freely, the other cannot change without invalidating
every stored binding, and Crypto must not depend on the contract assembly.
KeyStatementDriftTests makes a field added to one and not the other a
build failure, because a wire field outside the binding is unauthenticated
data the server can change undetected.

54 new tests and two new golden vector sections. The vectors pin the
absent-versus-empty email case and confirm that an offset-bearing
sub-millisecond timestamp encodes identically to its truncated UTC form.
Only additions to vectors.json; nothing existing moved.
This commit is contained in:
2026-07-28 16:06:11 +02:00
parent e6673f0bf2
commit d2a2ed8a29
13 changed files with 1363 additions and 1 deletions
@@ -0,0 +1,223 @@
using System.Buffers.Text;
using System.Text;
namespace DodoSSH.Crypto.Tests;
/// <summary>
/// The canonical key statement encoding and the nonce derived from it. See docs/crypto.md §7.1.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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<ArgumentOutOfRangeException>(() => KeyStatementCodec.Encode(statement));
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(65536)]
public void Encode_RejectsAnUnusableVersion(int version)
{
var statement = Statement() with { Version = version };
Should.Throw<ArgumentOutOfRangeException>(() => KeyStatementCodec.Encode(statement));
}
[Fact]
public void Encode_RejectsAGenerationBelowOne()
{
var statement = Statement() with { KeyGeneration = 0 };
Should.Throw<ArgumentOutOfRangeException>(() => KeyStatementCodec.Encode(statement));
}
[Fact]
public void ToNonce_RejectsSomethingThatIsNotADigest()
{
Should.Throw<ArgumentException>(() => 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."),
};
}
/// <summary>Fixed public-key bytes, so encodings are reproducible.</summary>
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))];
}