diff --git a/docs/crypto.md b/docs/crypto.md
index 57b1f37..1c5ec5f 100644
--- a/docs/crypto.md
+++ b/docs/crypto.md
@@ -279,6 +279,73 @@ signature verification does not require the verifier to hold the vault key.
would be a convenience, never the security boundary, and would drag an asymmetric
implementation onto a machine that is supposed to have none.
+The one exception is the key statement self-signature in §7.1, which the server *does* verify.
+That is a data-integrity check, not a boundary: an unverifiable statement admitted into the
+append-only key log (§7.2) is permanent, and every client auditing the chain afterwards would
+see an entry it cannot validate and cannot distinguish from tampering.
+
+### 7.1 Key statement — canonical encoding and the identity-provider binding
+
+> **Added 2026-07-28.** §7 always required "a canonical, length-prefixed encoding"; this
+> specifies it exactly. This is a clarification of an underspecified detail, made before any
+> client exists, not a change to a defined one. Pinned by `keyStatement` in the test vectors.
+
+```
+statement = "dsh1/keystatement/v1" 20 bytes, literal
+ || u16 version big-endian
+ || u32 keyGeneration big-endian
+ || i64 createdAt big-endian, Unix milliseconds, UTC
+ || x25519_pk 32 bytes
+ || ed25519_pk 32 bytes
+ || str(issuer) || str(subject) || str(email) || str(deviceName)
+
+str(absent) = 0x00
+str(present) = 0x01 || u32 length (big-endian) || UTF-8 bytes
+
+binding = SHA-256(statement) 32 bytes
+nonce = base64url(binding), unpadded 43 characters
+```
+
+Notes that are normative, not stylistic:
+
+- **The presence byte is what makes the encoding injective.** Without it an absent email and an
+ empty one encode identically, and two different statements would share a binding.
+- **`createdAt` is truncated to milliseconds by construction.** PostgreSQL stores microseconds,
+ so a value that has been through a database round trip must still hash to the same thing. The
+ offset is normalised to UTC, so the timezone a client happens to hold is irrelevant.
+- **JSON must never be hashed.** Property order, number formatting, Unicode escaping and
+ whitespace all vary between serialisers. Two implementations disagreeing by one byte produce
+ two nonces and an enrollment nobody can verify. The statement is *transmitted* as JSON and
+ *hashed* as the encoding above; the two are independent on purpose.
+- The nonce is base64url because it travels in an authorization request query string.
+
+The client uses `nonce` for a **fresh** OIDC authorization with `prompt=login`, so the resulting
+ID token is an identity-provider signature over exactly these keys. Verifiers must check:
+signature against the provider's JWKS **fetched directly from the provider**, `iss` matching the
+statement, `sub` matching the account, `aud` equal to the **client id** — an ID token is
+audienced to the client, never to the API — and `nonce` equal to the value above.
+
+### 7.2 Key log chain
+
+```
+entryHash = SHA-256( "dsh1/keylog/v1" 14 bytes, literal
+ || previousHash 32 bytes, all-zero for the first entry
+ || userId 16 bytes, RFC 4122 big-endian
+ || u32 generation big-endian
+ || x25519_pk 32 bytes
+ || ed25519_pk 32 bytes
+ || statementSignature 64 bytes
+ || i64 createdAt ) big-endian, Unix milliseconds, UTC
+```
+
+The database-assigned sequence is deliberately **not** an input. It is unknown until the insert
+executes, and order already follows the hash links — so a renumbered or gapped sequence column
+cannot silently reorder history.
+
+Appends must be serialised (the server takes a deployment-wide advisory lock). Two concurrent
+appends reading the same head would produce two entries claiming the same predecessor, which is
+indistinguishable from the fork the chain exists to detect.
+
## 8. Fingerprints and versioning
```
@@ -319,7 +386,11 @@ exist first. The server cannot participate.
- canonical AAD encodings and their SHA-256, including UUID byte order;
- envelope framing for each `alg_id`, with fixed key, nonce and plaintext;
- Argon2id and HKDF outputs for fixed inputs;
-- the negative cases of §4.4 — each must fail to decrypt.
+- the negative cases of §4.4 — each must fail to decrypt;
+- key statement encodings, bindings and nonces (§7.1), including an absent versus empty email,
+ a multi-byte device name, and a sub-millisecond offset-bearing timestamp that must encode
+ identically to its truncated UTC form;
+- key log entry hashes (§7.2), including the genesis link and a second entry chained to it.
Deterministic operations are pinned to exact bytes. `SealTo` and signature generation use
fresh randomness, so those are verified by round-trip plus fixed-input `Open` vectors.
diff --git a/src/DodoSSH.Crypto/DshSignatures.cs b/src/DodoSSH.Crypto/DshSignatures.cs
new file mode 100644
index 0000000..57285f4
--- /dev/null
+++ b/src/DodoSSH.Crypto/DshSignatures.cs
@@ -0,0 +1,90 @@
+using NSec.Cryptography;
+
+namespace DodoSSH.Crypto;
+
+///
+/// Ed25519 signatures over the canonical encodings of the specification. See docs/crypto.md §7.
+///
+///
+///
+/// Every signature is prefixed with a context string, so a signature produced in one role can
+/// never be replayed in another. Without that, a key statement signature and a grant signature
+/// over coincidentally-equal bytes would be interchangeable.
+///
+///
+/// Verification returns rather than throwing, including for malformed
+/// keys and signatures. These values arrive from an untrusted server or peer, so rejection is an
+/// expected outcome to be handled at the call site, not an exceptional one.
+///
+///
+public static class DshSignatures
+{
+ private static SignatureAlgorithm Algorithm => SignatureAlgorithm.Ed25519;
+
+ ///
+ /// Signs a key statement's canonical encoding.
+ ///
+ /// The signer's Ed25519 key.
+ /// Output of .
+ public static byte[] SignKeyStatement(Key signingKey, ReadOnlySpan canonicalStatement)
+ {
+ ArgumentNullException.ThrowIfNull(signingKey);
+
+ var message = BuildMessage(CryptoSpec.SigningContexts.KeyStatement, canonicalStatement);
+ return Algorithm.Sign(signingKey, message);
+ }
+
+ ///
+ /// Verifies a key statement's self-signature.
+ ///
+ ///
+ /// The statement carries the very public key that verifies it, so this proves only that
+ /// whoever produced the statement held the matching private key. It says nothing about who
+ /// that is — that is the identity provider binding's job. Both checks are required; neither
+ /// substitutes for the other.
+ ///
+ /// Ed25519 public key from the statement itself.
+ /// Output of .
+ /// The detached signature.
+ public static bool VerifyKeyStatement(
+ ReadOnlySpan signingPublicKey,
+ ReadOnlySpan canonicalStatement,
+ ReadOnlySpan signature)
+ {
+ if (signingPublicKey.Length != CryptoSpec.PublicKeySize
+ || signature.Length != CryptoSpec.SignatureSize)
+ {
+ return false;
+ }
+
+ PublicKey publicKey;
+ try
+ {
+ publicKey = PublicKey.Import(Algorithm, signingPublicKey, KeyBlobFormat.RawPublicKey);
+ }
+ catch (FormatException)
+ {
+ return false;
+ }
+
+ var message = BuildMessage(CryptoSpec.SigningContexts.KeyStatement, canonicalStatement);
+ return Algorithm.Verify(publicKey, message, signature);
+ }
+
+ ///
+ /// Prefixes a message with its signing context.
+ ///
+ ///
+ /// Heap-allocated rather than stack-allocated on purpose: the statement contains
+ /// caller-supplied strings of unbounded length, and a stackalloc sized from untrusted
+ /// input is a stack overflow waiting to happen.
+ ///
+ private static byte[] BuildMessage(ReadOnlySpan context, ReadOnlySpan payload)
+ {
+ var message = new byte[context.Length + payload.Length];
+ context.CopyTo(message);
+ payload.CopyTo(message.AsSpan(context.Length));
+
+ return message;
+ }
+}
diff --git a/src/DodoSSH.Crypto/KeyLogChain.cs b/src/DodoSSH.Crypto/KeyLogChain.cs
new file mode 100644
index 0000000..241f62b
--- /dev/null
+++ b/src/DodoSSH.Crypto/KeyLogChain.cs
@@ -0,0 +1,145 @@
+using System.Buffers.Binary;
+using System.Security.Cryptography;
+
+namespace DodoSSH.Crypto;
+
+///
+/// The hash chain over the append-only identity key log. See docs/crypto.md §7.2.
+///
+///
+///
+/// Cheap key transparency. Every published key statement links to the hash of the one before it,
+/// so for a server to show two clients divergent views of who holds which key it must maintain
+/// both forks consistently across every later shared operation. Signed grants record the log head
+/// their signer observed, and any two clients touching the same vault then surface the mismatch.
+///
+///
+/// This converts an otherwise undetectable key-substitution attack into a detectable one. It does
+/// not prevent it, and it is not a substitute for the identity-provider binding in
+/// . See ADR 0001.
+///
+///
+/// The chain lives here rather than in the server so that clients can recompute it. A chain only
+/// a server can compute is a claim, not evidence.
+///
+///
+public static class KeyLogChain
+{
+ /// Domain-separating prefix of the entry hash input.
+ public static ReadOnlySpan Label => "dsh1/keylog/v1"u8;
+
+ ///
+ /// Length of the hash input: label, previous hash, user id, generation, both public keys,
+ /// the statement signature and the timestamp.
+ ///
+ private const int InputLength =
+ 14 // Label
+ + CryptoSpec.DigestSize // previous hash
+ + 16 // user id
+ + sizeof(uint) // generation
+ + (CryptoSpec.PublicKeySize * 2)
+ + CryptoSpec.SignatureSize
+ + sizeof(long); // created at, Unix milliseconds
+
+ ///
+ /// The previous-hash value of the first entry in an empty log: 32 zero bytes.
+ ///
+ public static byte[] CreateGenesisPreviousHash() => new byte[CryptoSpec.DigestSize];
+
+ ///
+ /// Computes an entry's hash.
+ ///
+ ///
+ /// The database-assigned sequence is deliberately not an input. It is unknown until
+ /// the insert executes, and the chain already fixes the order — deriving order from the hash
+ /// links rather than from a sequence column means a renumbered or gapped sequence cannot
+ /// silently reorder history.
+ ///
+ /// is truncated to milliseconds, matching the stored column after
+ /// a round trip through PostgreSQL. Writing a timestamp with finer precision than the hash
+ /// input would leave the chain unverifiable by anyone who read the row back.
+ ///
+ ///
+ /// Hash of the preceding entry, or .
+ /// The user whose key this is.
+ /// Generation published.
+ /// X25519 public key, 32 bytes.
+ /// Ed25519 public key, 32 bytes.
+ /// Ed25519 self-signature over the key statement, 64 bytes.
+ /// When the entry was appended.
+ public static byte[] ComputeEntryHash(
+ ReadOnlySpan previousHash,
+ Guid userId,
+ int generation,
+ ReadOnlySpan encryptionPublicKey,
+ ReadOnlySpan signingPublicKey,
+ ReadOnlySpan statementSignature,
+ DateTimeOffset createdAt)
+ {
+ RequireLength(previousHash, CryptoSpec.DigestSize, nameof(previousHash));
+ RequireLength(encryptionPublicKey, CryptoSpec.PublicKeySize, nameof(encryptionPublicKey));
+ RequireLength(signingPublicKey, CryptoSpec.PublicKeySize, nameof(signingPublicKey));
+ RequireLength(statementSignature, CryptoSpec.SignatureSize, nameof(statementSignature));
+ ArgumentOutOfRangeException.ThrowIfLessThan(generation, 1);
+
+ Span input = stackalloc byte[InputLength];
+ input.Clear();
+
+ Label.CopyTo(input);
+ var offset = Label.Length;
+
+ previousHash.CopyTo(input[offset..]);
+ offset += CryptoSpec.DigestSize;
+
+ // RFC 4122 big-endian order, as everywhere else in this specification.
+ if (!userId.TryWriteBytes(input[offset..], bigEndian: true, out _))
+ {
+ throw new InvalidOperationException("Failed to write the user id.");
+ }
+
+ offset += 16;
+
+ BinaryPrimitives.WriteUInt32BigEndian(input[offset..], (uint)generation);
+ offset += sizeof(uint);
+
+ encryptionPublicKey.CopyTo(input[offset..]);
+ offset += CryptoSpec.PublicKeySize;
+
+ signingPublicKey.CopyTo(input[offset..]);
+ offset += CryptoSpec.PublicKeySize;
+
+ statementSignature.CopyTo(input[offset..]);
+ offset += CryptoSpec.SignatureSize;
+
+ BinaryPrimitives.WriteInt64BigEndian(input[offset..], createdAt.ToUnixTimeMilliseconds());
+ offset += sizeof(long);
+
+ if (offset != InputLength)
+ {
+ throw new InvalidOperationException(
+ $"Key log hash input wrote {offset} bytes but reserved {InputLength}.");
+ }
+
+ return SHA256.HashData(input);
+ }
+
+ ///
+ /// Truncates a timestamp to the precision the chain hashes.
+ ///
+ ///
+ /// Callers must store the value this returns, not the one they started with, or the stored row
+ /// will not reproduce its own hash.
+ ///
+ public static DateTimeOffset TruncateTimestamp(DateTimeOffset value) =>
+ DateTimeOffset.FromUnixTimeMilliseconds(value.ToUnixTimeMilliseconds()).ToOffset(TimeSpan.Zero);
+
+ private static void RequireLength(ReadOnlySpan value, int expected, string parameterName)
+ {
+ if (value.Length != expected)
+ {
+ throw new ArgumentException(
+ $"Expected {expected} bytes, got {value.Length}.",
+ parameterName);
+ }
+ }
+}
diff --git a/src/DodoSSH.Crypto/KeyStatementCodec.cs b/src/DodoSSH.Crypto/KeyStatementCodec.cs
new file mode 100644
index 0000000..53604a7
--- /dev/null
+++ b/src/DodoSSH.Crypto/KeyStatementCodec.cs
@@ -0,0 +1,224 @@
+using System.Buffers.Binary;
+using System.Buffers.Text;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace DodoSSH.Crypto;
+
+///
+/// The canonical encoding of an identity key statement, and the identity-provider binding value
+/// derived from it. See docs/crypto.md §7.1.
+///
+///
+///
+/// This encoding is the pivot of the whole public-key trust story. The client hashes it and uses
+/// the result as the nonce of a fresh OIDC authorization, so the identity provider ends up
+/// signing an assertion over exactly these public keys. The DodoSSH server cannot mint identity
+/// provider signatures, so it cannot fabricate a key for a user who never enrolled.
+///
+///
+/// It must therefore be reproducible byte-for-byte by every implementation. JSON is unsuitable:
+/// property order, number formatting, Unicode escaping and whitespace all vary between
+/// serialisers, and two implementations that disagree by one byte produce two different nonces
+/// and an enrollment that can never be verified. Hence a fixed binary encoding with explicit
+/// length prefixes, where no field value can forge a field boundary.
+///
+///
+public static class KeyStatementCodec
+{
+ /// Domain-separating prefix of the canonical encoding.
+ public static ReadOnlySpan Label => "dsh1/keystatement/v1"u8;
+
+ /// Highest statement version this implementation encodes.
+ public const int CurrentVersion = 1;
+
+ /// Length of the binding value: a SHA-256 digest.
+ public const int BindingLength = CryptoSpec.DigestSize;
+
+ /// Length of the base64url binding, as it appears in the nonce claim.
+ public const int NonceLength = 43;
+
+ /// version (u16) + keyGeneration (u32) + createdAt (i64) + two public keys.
+ private const int FixedBlockLength =
+ sizeof(ushort) + sizeof(uint) + sizeof(long) + (CryptoSpec.PublicKeySize * 2);
+
+ /// Presence byte plus a u32 length prefix.
+ private const int StringHeaderLength = 1 + sizeof(uint);
+
+ ///
+ /// Writes the canonical encoding.
+ ///
+ ///
+ ///
+ /// Layout, all integers big-endian:
+ /// "dsh1/keystatement/v1" | u16 version | u32 keyGeneration |
+ /// i64 createdAt (Unix milliseconds, UTC) | x25519 pk (32) | ed25519 pk (32) |
+ /// issuer | subject | email | deviceName.
+ ///
+ ///
+ /// Each string is a presence byte — 0 for absent, 1 for present — followed, when present, by a
+ /// u32 length and that many UTF-8 bytes. The presence byte is what distinguishes an absent
+ /// email from an empty one; without it the encoding would not be injective, and two different
+ /// statements could share a binding.
+ ///
+ ///
+ /// normalises to UTC and truncates, so the
+ /// offset a client happens to hold and any sub-millisecond precision are both irrelevant to
+ /// the result. That matters because PostgreSQL stores microseconds: a value that survived a
+ /// database round trip must still hash identically.
+ ///
+ ///
+ /// The statement to encode.
+ /// A version, generation or key length is invalid.
+ public static byte[] Encode(KeyStatementFields statement)
+ {
+ ArgumentNullException.ThrowIfNull(statement);
+ Validate(statement);
+
+ var issuer = Encoding.UTF8.GetBytes(statement.Issuer);
+ var subject = Encoding.UTF8.GetBytes(statement.Subject);
+ var email = statement.Email is null ? null : Encoding.UTF8.GetBytes(statement.Email);
+ var deviceName = Encoding.UTF8.GetBytes(statement.DeviceName);
+
+ var length = Label.Length
+ + FixedBlockLength
+ + StringBlockLength(issuer)
+ + StringBlockLength(subject)
+ + StringBlockLength(email)
+ + StringBlockLength(deviceName);
+
+ var buffer = new byte[length];
+ var span = buffer.AsSpan();
+
+ Label.CopyTo(span);
+ var offset = Label.Length;
+
+ BinaryPrimitives.WriteUInt16BigEndian(span[offset..], (ushort)statement.Version);
+ offset += sizeof(ushort);
+
+ BinaryPrimitives.WriteUInt32BigEndian(span[offset..], (uint)statement.KeyGeneration);
+ offset += sizeof(uint);
+
+ BinaryPrimitives.WriteInt64BigEndian(span[offset..], statement.CreatedAt.ToUnixTimeMilliseconds());
+ offset += sizeof(long);
+
+ statement.EncryptionPublicKey.CopyTo(span[offset..]);
+ offset += CryptoSpec.PublicKeySize;
+
+ statement.SigningPublicKey.CopyTo(span[offset..]);
+ offset += CryptoSpec.PublicKeySize;
+
+ offset = WriteString(span, offset, issuer);
+ offset = WriteString(span, offset, subject);
+ offset = WriteString(span, offset, email);
+ offset = WriteString(span, offset, deviceName);
+
+ if (offset != length)
+ {
+ throw new InvalidOperationException(
+ $"Canonical encoding wrote {offset} bytes but reserved {length}.");
+ }
+
+ return buffer;
+ }
+
+ ///
+ /// Computes the identity-provider binding: SHA-256 over the canonical encoding.
+ ///
+ public static byte[] ComputeBinding(KeyStatementFields statement) =>
+ SHA256.HashData(Encode(statement));
+
+ ///
+ /// Computes the identity-provider binding from an encoding already produced by
+ /// .
+ ///
+ ///
+ /// For callers that also need the encoding itself — to verify a signature over it, say — so
+ /// that the hash algorithm stays a decision of this type rather than being restated at the call
+ /// site.
+ ///
+ public static byte[] ComputeBinding(ReadOnlySpan canonicalEncoding) =>
+ SHA256.HashData(canonicalEncoding);
+
+ ///
+ /// Renders a binding as the string that must appear in the OIDC nonce claim.
+ ///
+ ///
+ /// Base64url without padding, because the nonce travels in an authorization request query
+ /// string where +, / and = all need escaping and some providers mangle
+ /// them.
+ ///
+ public static string ToNonce(ReadOnlySpan binding)
+ {
+ if (binding.Length != BindingLength)
+ {
+ throw new ArgumentException(
+ $"A binding is {BindingLength} bytes, got {binding.Length}.",
+ nameof(binding));
+ }
+
+ return Base64Url.EncodeToString(binding);
+ }
+
+ /// Computes the expected nonce claim value for a statement.
+ public static string ComputeNonce(KeyStatementFields statement) =>
+ ToNonce(ComputeBinding(statement));
+
+ private static void Validate(KeyStatementFields statement)
+ {
+ if (statement.Version is < 1 or > ushort.MaxValue)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(statement),
+ statement.Version,
+ $"Statement version must be between 1 and {ushort.MaxValue}.");
+ }
+
+ if (statement.KeyGeneration < 1)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(statement),
+ statement.KeyGeneration,
+ "Key generation must be at least 1.");
+ }
+
+ if (statement.Issuer is null || statement.Subject is null || statement.DeviceName is null)
+ {
+ throw new ArgumentException(
+ "Issuer, subject and device name are all required.",
+ nameof(statement));
+ }
+
+ RequirePublicKey(statement.EncryptionPublicKey, nameof(statement));
+ RequirePublicKey(statement.SigningPublicKey, nameof(statement));
+ }
+
+ private static int StringBlockLength(byte[]? value) =>
+ value is null ? 1 : StringHeaderLength + value.Length;
+
+ private static int WriteString(Span destination, int offset, byte[]? value)
+ {
+ if (value is null)
+ {
+ destination[offset] = 0;
+ return offset + 1;
+ }
+
+ destination[offset] = 1;
+ BinaryPrimitives.WriteUInt32BigEndian(destination[(offset + 1)..], (uint)value.Length);
+ value.CopyTo(destination[(offset + StringHeaderLength)..]);
+
+ return offset + StringHeaderLength + value.Length;
+ }
+
+ private static void RequirePublicKey(byte[]? publicKey, string parameterName)
+ {
+ if (publicKey is null || publicKey.Length != CryptoSpec.PublicKeySize)
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ publicKey?.Length ?? 0,
+ $"Public keys must be {CryptoSpec.PublicKeySize} bytes.");
+ }
+ }
+}
diff --git a/src/DodoSSH.Crypto/KeyStatementFields.cs b/src/DodoSSH.Crypto/KeyStatementFields.cs
new file mode 100644
index 0000000..6bbb1f0
--- /dev/null
+++ b/src/DodoSSH.Crypto/KeyStatementFields.cs
@@ -0,0 +1,37 @@
+namespace DodoSSH.Crypto;
+
+///
+/// An identity key statement, in the form the specification hashes and signs.
+///
+///
+///
+/// Deliberately separate from the transport DTO in DodoSSH.Contracts. This type defines
+/// what is hashed; that one defines what is transmitted. Keeping them apart is
+/// what lets the canonical encoding stay stable while the JSON shape gains fields, and it keeps
+/// DodoSSH.Crypto free of a dependency on the contract assembly. A test asserts the two
+/// carry the same fields, so they cannot drift.
+///
+///
+/// Equality on this record is not meaningful — the key and signature members are arrays, so it
+/// compares by reference. Compare canonical encodings instead.
+///
+///
+/// Statement format version. Currently 1.
+/// OIDC issuer that vouches for the subject.
+/// OIDC subject.
+/// Email at enrollment time, for display only. May be absent.
+/// X25519 public key, 32 bytes.
+/// Ed25519 public key, 32 bytes.
+/// Generation of this key pair, starting at 1.
+/// When the client generated the keys.
+/// Human-readable name of the enrolling device.
+public sealed record KeyStatementFields(
+ int Version,
+ string Issuer,
+ string Subject,
+ string? Email,
+ byte[] EncryptionPublicKey,
+ byte[] SigningPublicKey,
+ int KeyGeneration,
+ DateTimeOffset CreatedAt,
+ string DeviceName);
diff --git a/tests/DodoSSH.Contracts.Tests/DodoSSH.Contracts.Tests.csproj b/tests/DodoSSH.Contracts.Tests/DodoSSH.Contracts.Tests.csproj
index 1f65125..06b49f8 100644
--- a/tests/DodoSSH.Contracts.Tests/DodoSSH.Contracts.Tests.csproj
+++ b/tests/DodoSSH.Contracts.Tests/DodoSSH.Contracts.Tests.csproj
@@ -9,6 +9,13 @@
+
+
+
diff --git a/tests/DodoSSH.Contracts.Tests/KeyStatementDriftTests.cs b/tests/DodoSSH.Contracts.Tests/KeyStatementDriftTests.cs
new file mode 100644
index 0000000..821ab5d
--- /dev/null
+++ b/tests/DodoSSH.Contracts.Tests/KeyStatementDriftTests.cs
@@ -0,0 +1,63 @@
+using DodoSSH.Crypto;
+
+namespace DodoSSH.Contracts.Tests;
+
+///
+/// Guards the two key statement types against drifting apart.
+///
+///
+///
+/// is the wire DTO; is what the
+/// canonical encoding in docs/crypto.md §7.1 is defined over. They are separate on purpose — one
+/// may gain JSON fields freely, the other cannot change without invalidating every stored binding,
+/// and DodoSSH.Crypto must not depend on the contract assembly.
+///
+///
+/// The hazard that separation creates is a field added to the DTO and silently left out of the
+/// hash. A client would then sign and bind a statement that omits it, and the field would be
+/// unauthenticated data the server could change at will. This test makes that a build failure:
+/// adding a field to the DTO forces a deliberate decision about whether it is covered, and if it
+/// is, a spec version bump.
+///
+///
+public sealed class KeyStatementDriftTests
+{
+ [Fact]
+ public void BothTypes_DeclareTheSameFields()
+ {
+ var contract = PropertyNames();
+ var canonical = PropertyNames();
+
+ canonical.ShouldBe(
+ contract,
+ "KeyStatement and KeyStatementFields disagree. A field on the wire that the canonical "
+ + "encoding does not cover is unauthenticated: the identity provider never signs over "
+ + "it, so the server can change it undetected. Cover it and bump the statement version, "
+ + "or document why it is deliberately outside the binding.");
+ }
+
+ [Fact]
+ public void BothTypes_AgreeOnFieldTypes()
+ {
+ // Names alone would not catch a string becoming a Uri, or an int becoming a long — either
+ // of which changes what gets encoded without changing what gets listed.
+ var contract = PropertyTypes();
+ var canonical = PropertyTypes();
+
+ canonical.ShouldBe(contract);
+ }
+
+ private static IReadOnlyList PropertyNames() =>
+ [.. typeof(T)
+ .GetProperties()
+ .Select(p => p.Name)
+ .Where(name => !string.Equals(name, "EqualityContract", StringComparison.Ordinal))
+ .Order(StringComparer.Ordinal)];
+
+ private static IReadOnlyList PropertyTypes() =>
+ [.. typeof(T)
+ .GetProperties()
+ .Where(p => !string.Equals(p.Name, "EqualityContract", StringComparison.Ordinal))
+ .Select(p => $"{p.Name}:{p.PropertyType.Name}")
+ .Order(StringComparer.Ordinal)];
+}
diff --git a/tests/DodoSSH.Contracts.Tests/packages.lock.json b/tests/DodoSSH.Contracts.Tests/packages.lock.json
index 7cac625..c46c522 100644
--- a/tests/DodoSSH.Contracts.Tests/packages.lock.json
+++ b/tests/DodoSSH.Contracts.Tests/packages.lock.json
@@ -196,6 +196,27 @@
},
"dodossh.contracts": {
"type": "Project"
+ },
+ "dodossh.crypto": {
+ "type": "Project",
+ "dependencies": {
+ "NSec.Cryptography": "[26.4.0, )"
+ }
+ },
+ "libsodium": {
+ "type": "CentralTransitive",
+ "requested": "[1.0.22, )",
+ "resolved": "1.0.22",
+ "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
+ },
+ "NSec.Cryptography": {
+ "type": "CentralTransitive",
+ "requested": "[26.4.0, )",
+ "resolved": "26.4.0",
+ "contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
+ "dependencies": {
+ "libsodium": "[1.0.22, 1.0.23)"
+ }
}
}
}
diff --git a/tests/DodoSSH.Crypto.Tests/DshSignaturesTests.cs b/tests/DodoSSH.Crypto.Tests/DshSignaturesTests.cs
new file mode 100644
index 0000000..ced4b23
--- /dev/null
+++ b/tests/DodoSSH.Crypto.Tests/DshSignaturesTests.cs
@@ -0,0 +1,102 @@
+using NSec.Cryptography;
+
+namespace DodoSSH.Crypto.Tests;
+
+/// Key statement signing and verification. See docs/crypto.md §7.
+public sealed class DshSignaturesTests
+{
+ [Fact]
+ public void AFreshSignature_Verifies()
+ {
+ using var key = CreateSigningKey();
+ var statement = Canonical(key);
+
+ var signature = DshSignatures.SignKeyStatement(key, statement);
+
+ signature.Length.ShouldBe(CryptoSpec.SignatureSize);
+ DshSignatures.VerifyKeyStatement(PublicKeyBytes(key), statement, signature).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void ATamperedStatement_DoesNotVerify()
+ {
+ using var key = CreateSigningKey();
+ var statement = Canonical(key);
+ var signature = DshSignatures.SignKeyStatement(key, statement);
+
+ var tampered = statement.ToArray();
+ tampered[^1] ^= 0x01;
+
+ DshSignatures.VerifyKeyStatement(PublicKeyBytes(key), tampered, signature).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void AnotherKeysSignature_DoesNotVerify()
+ {
+ using var key = CreateSigningKey();
+ using var other = CreateSigningKey();
+ var statement = Canonical(key);
+
+ var signature = DshSignatures.SignKeyStatement(other, statement);
+
+ DshSignatures.VerifyKeyStatement(PublicKeyBytes(key), statement, signature).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void ASignatureMissingTheSigningContext_DoesNotVerify()
+ {
+ // Domain separation. A signature over the bare canonical encoding must not be accepted as a
+ // key statement signature, or the same bytes could be replayed into another role.
+ using var key = CreateSigningKey();
+ var statement = Canonical(key);
+
+ var contextless = SignatureAlgorithm.Ed25519.Sign(key, statement);
+
+ DshSignatures.VerifyKeyStatement(PublicKeyBytes(key), statement, contextless).ShouldBeFalse();
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(31)]
+ [InlineData(33)]
+ public void AMalformedPublicKey_ReturnsFalseRatherThanThrowing(int length)
+ {
+ // These values arrive from an untrusted server, so rejection has to be an ordinary outcome.
+ using var key = CreateSigningKey();
+ var statement = Canonical(key);
+ var signature = DshSignatures.SignKeyStatement(key, statement);
+
+ DshSignatures.VerifyKeyStatement(new byte[length], statement, signature).ShouldBeFalse();
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(63)]
+ [InlineData(65)]
+ public void AMalformedSignature_ReturnsFalseRatherThanThrowing(int length)
+ {
+ using var key = CreateSigningKey();
+
+ DshSignatures.VerifyKeyStatement(PublicKeyBytes(key), Canonical(key), new byte[length])
+ .ShouldBeFalse();
+ }
+
+ private static Key CreateSigningKey() =>
+ Key.Create(
+ SignatureAlgorithm.Ed25519,
+ new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport });
+
+ private static byte[] PublicKeyBytes(Key key) => key.PublicKey.Export(KeyBlobFormat.RawPublicKey);
+
+ private static byte[] Canonical(Key signingKey) =>
+ KeyStatementCodec.Encode(new KeyStatementFields(
+ Version: 1,
+ Issuer: "https://idp.example/realms/dodossh",
+ Subject: "alice-subject",
+ Email: "alice@example.com",
+ EncryptionPublicKey: TestKeys.Encryption,
+ SigningPublicKey: PublicKeyBytes(signingKey),
+ KeyGeneration: 1,
+ CreatedAt: DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123),
+ DeviceName: "alice-laptop"));
+}
diff --git a/tests/DodoSSH.Crypto.Tests/GoldenVectors.cs b/tests/DodoSSH.Crypto.Tests/GoldenVectors.cs
index eb17e94..5285849 100644
--- a/tests/DodoSSH.Crypto.Tests/GoldenVectors.cs
+++ b/tests/DodoSSH.Crypto.Tests/GoldenVectors.cs
@@ -34,6 +34,8 @@ internal static class GoldenVectors
["hkdf"] = BuildHkdfVectors(),
["argon2id"] = BuildArgon2Vectors(),
["fingerprint"] = BuildFingerprintVectors(),
+ ["keyStatement"] = BuildKeyStatementVectors(),
+ ["keyLog"] = BuildKeyLogVectors(),
};
return root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }) + "\n";
@@ -276,6 +278,137 @@ internal static class GoldenVectors
];
}
+ ///
+ /// Pins the key statement encoding and the nonce derived from it. See docs/crypto.md §7.1.
+ ///
+ ///
+ /// The highest-value vectors after the AAD ones. The nonce is what an identity provider signs
+ /// over, so a client and a server that disagree by one byte here produce enrollments that can
+ /// never be verified — and unlike a decryption failure, that only shows up against a real
+ /// provider.
+ ///
+ /// The absent-email case is present specifically to pin the presence byte, which is what stops
+ /// an absent email and an empty one sharing a binding.
+ ///
+ ///
+ private static JsonArray BuildKeyStatementVectors()
+ {
+ var array = new JsonArray();
+
+ foreach (var (name, statement) in KeyStatementCases())
+ {
+ var encoding = KeyStatementCodec.Encode(statement);
+
+ array.Add(new JsonObject
+ {
+ ["name"] = name,
+ ["version"] = statement.Version,
+ ["issuer"] = statement.Issuer,
+ ["subject"] = statement.Subject,
+ ["email"] = statement.Email,
+ ["keyGeneration"] = statement.KeyGeneration,
+ ["createdAtUnixMilliseconds"] = statement.CreatedAt.ToUnixTimeMilliseconds(),
+ ["deviceName"] = statement.DeviceName,
+ ["x25519PublicKey"] = Hex(statement.EncryptionPublicKey),
+ ["ed25519PublicKey"] = Hex(statement.SigningPublicKey),
+ ["canonicalEncoding"] = Hex(encoding),
+ ["binding"] = Hex(KeyStatementCodec.ComputeBinding(encoding)),
+ ["nonce"] = KeyStatementCodec.ComputeNonce(statement),
+ });
+ }
+
+ return array;
+ }
+
+ private static (string Name, KeyStatementFields Statement)[] KeyStatementCases()
+ {
+ var x25519 = Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0x40 + i)).ToArray();
+ var ed25519 = Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0x60 + i)).ToArray();
+
+ // A fixed instant, so the vectors do not depend on when they were generated.
+ var createdAt = DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123);
+
+ return
+ [
+ ("with-email", new KeyStatementFields(
+ 1, "https://idp.example/realms/dodossh", "alice-subject", "alice@example.com",
+ x25519, ed25519, 1, createdAt, "alice-laptop")),
+
+ // Same statement with the email absent, not empty.
+ ("without-email", new KeyStatementFields(
+ 1, "https://idp.example/realms/dodossh", "alice-subject", null,
+ x25519, ed25519, 1, createdAt, "alice-laptop")),
+
+ // ...and empty rather than absent. These three must all differ.
+ ("empty-email", new KeyStatementFields(
+ 1, "https://idp.example/realms/dodossh", "alice-subject", string.Empty,
+ x25519, ed25519, 1, createdAt, "alice-laptop")),
+
+ // Multi-byte UTF-8, so a length prefix counting characters rather than bytes fails here.
+ ("unicode-device-name", new KeyStatementFields(
+ 1, "https://idp.example/realms/dodossh", "alice-subject", "alice@example.com",
+ x25519, ed25519, 1, createdAt, "alice's ThinkPad — büro")),
+
+ // A sub-millisecond offset-bearing timestamp must encode identically to the UTC one,
+ // because the encoding normalises and truncates.
+ ("offset-and-sub-millisecond-timestamp", new KeyStatementFields(
+ 1, "https://idp.example/realms/dodossh", "alice-subject", "alice@example.com",
+ x25519, ed25519, 1,
+ createdAt.ToOffset(TimeSpan.FromHours(2)).AddTicks(7777),
+ "alice-laptop")),
+
+ ("later-generation", new KeyStatementFields(
+ 1, "https://idp.example/realms/dodossh", "alice-subject", "alice@example.com",
+ x25519, ed25519, 4, createdAt, "alice-laptop")),
+ ];
+ }
+
+ /// Pins the key log chain hash, including the genesis link. See docs/crypto.md §7.2.
+ private static JsonArray BuildKeyLogVectors()
+ {
+ var x25519 = Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0x40 + i)).ToArray();
+ var ed25519 = Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0x60 + i)).ToArray();
+ var signature = Enumerable.Range(0, CryptoSpec.SignatureSize).Select(i => (byte)(0x80 + i)).ToArray();
+ var createdAt = DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123);
+
+ var genesisPrevious = KeyLogChain.CreateGenesisPreviousHash();
+ var genesis = KeyLogChain.ComputeEntryHash(
+ genesisPrevious, ResourceA, 1, x25519, ed25519, signature, createdAt);
+
+ // The second entry links to the first, so a broken link shows up as a changed hash here
+ // rather than only in a running deployment.
+ var second = KeyLogChain.ComputeEntryHash(
+ genesis, ResourceB, 1, x25519, ed25519, signature, createdAt);
+
+ return
+ [
+ new JsonObject
+ {
+ ["name"] = "genesis",
+ ["previousHash"] = Hex(genesisPrevious),
+ ["userId"] = ResourceA.ToString(),
+ ["generation"] = 1,
+ ["x25519PublicKey"] = Hex(x25519),
+ ["ed25519PublicKey"] = Hex(ed25519),
+ ["statementSignature"] = Hex(signature),
+ ["createdAtUnixMilliseconds"] = createdAt.ToUnixTimeMilliseconds(),
+ ["hash"] = Hex(genesis),
+ },
+ new JsonObject
+ {
+ ["name"] = "second-entry",
+ ["previousHash"] = Hex(genesis),
+ ["userId"] = ResourceB.ToString(),
+ ["generation"] = 1,
+ ["x25519PublicKey"] = Hex(x25519),
+ ["ed25519PublicKey"] = Hex(ed25519),
+ ["statementSignature"] = Hex(signature),
+ ["createdAtUnixMilliseconds"] = createdAt.ToUnixTimeMilliseconds(),
+ ["hash"] = Hex(second),
+ },
+ ];
+ }
+
private static string Hex(ReadOnlySpan value) =>
Convert.ToHexString(value).ToLower(CultureInfo.InvariantCulture);
}
diff --git a/tests/DodoSSH.Crypto.Tests/KeyLogChainTests.cs b/tests/DodoSSH.Crypto.Tests/KeyLogChainTests.cs
new file mode 100644
index 0000000..d279acd
--- /dev/null
+++ b/tests/DodoSSH.Crypto.Tests/KeyLogChainTests.cs
@@ -0,0 +1,130 @@
+namespace DodoSSH.Crypto.Tests;
+
+/// The key log hash chain. See docs/crypto.md §7.2.
+public sealed class KeyLogChainTests
+{
+ private static readonly Guid Alice = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f");
+ private static readonly Guid Bob = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e10");
+ private static readonly DateTimeOffset CreatedAt = DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123);
+
+ [Fact]
+ public void GenesisPreviousHash_IsThirtyTwoZeroBytes()
+ {
+ var genesis = KeyLogChain.CreateGenesisPreviousHash();
+
+ genesis.Length.ShouldBe(CryptoSpec.DigestSize);
+ genesis.ShouldAllBe(b => b == 0);
+ }
+
+ [Fact]
+ public void GenesisPreviousHash_IsANewArrayEachCall()
+ {
+ // Shared mutable state in a hash input would be a spectacular way to corrupt a chain.
+ var first = KeyLogChain.CreateGenesisPreviousHash();
+ first[0] = 0xFF;
+
+ KeyLogChain.CreateGenesisPreviousHash()[0].ShouldBe((byte)0);
+ }
+
+ [Fact]
+ public void ComputeEntryHash_IsDeterministic()
+ {
+ Hash().ShouldBe(Hash());
+ }
+
+ [Fact]
+ public void ChangingThePreviousHash_ChangesTheEntryHash()
+ {
+ // The link itself. If this held, a server could reorder or drop entries undetectably.
+ var linked = Hash(previousHash: Hash());
+
+ linked.ShouldNotBe(Hash());
+ }
+
+ [Theory]
+ [InlineData("user")]
+ [InlineData("generation")]
+ [InlineData("encryptionKey")]
+ [InlineData("signingKey")]
+ [InlineData("signature")]
+ [InlineData("createdAt")]
+ public void ChangingAnyField_ChangesTheEntryHash(string field)
+ {
+ var baseline = Hash();
+
+ var altered = field switch
+ {
+ "user" => Hash(userId: Bob),
+ "generation" => Hash(generation: 2),
+ "encryptionKey" => Hash(encryptionPublicKey: TestKeys.Alternate),
+ "signingKey" => Hash(signingPublicKey: TestKeys.Alternate),
+ "signature" => Hash(signature: AlternateSignature),
+ "createdAt" => Hash(createdAt: CreatedAt.AddMilliseconds(1)),
+ _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unknown field."),
+ };
+
+ altered.ShouldNotBe(baseline);
+ }
+
+ [Fact]
+ public void SubMillisecondPrecision_IsTruncatedAway()
+ {
+ // The stored column round-trips through PostgreSQL's microseconds. If the hash used finer
+ // precision than the storage, no entry could ever reproduce its own hash after being read.
+ Hash(createdAt: CreatedAt.AddTicks(9_999)).ShouldBe(Hash(createdAt: CreatedAt));
+ }
+
+ [Fact]
+ public void TruncateTimestamp_MatchesWhatTheHashUses()
+ {
+ var truncated = KeyLogChain.TruncateTimestamp(CreatedAt.AddTicks(9_999));
+
+ truncated.ToUnixTimeMilliseconds().ShouldBe(CreatedAt.ToUnixTimeMilliseconds());
+ truncated.Ticks.ShouldBe(truncated.Ticks / TimeSpan.TicksPerMillisecond * TimeSpan.TicksPerMillisecond);
+ truncated.Offset.ShouldBe(TimeSpan.Zero);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(31)]
+ [InlineData(33)]
+ public void ComputeEntryHash_RejectsAWrongLengthPreviousHash(int length)
+ {
+ Should.Throw(() => Hash(previousHash: new byte[length]));
+ }
+
+ [Fact]
+ public void ComputeEntryHash_RejectsAWrongLengthSignature()
+ {
+ Should.Throw(() => Hash(signature: new byte[32]));
+ }
+
+ [Fact]
+ public void ComputeEntryHash_RejectsAGenerationBelowOne()
+ {
+ Should.Throw(() => Hash(generation: 0));
+ }
+
+ private static byte[] AlternateSignature { get; } =
+ [.. Enumerable.Range(0, CryptoSpec.SignatureSize).Select(i => (byte)(0xC0 + i))];
+
+ private static byte[] Signature { get; } =
+ [.. Enumerable.Range(0, CryptoSpec.SignatureSize).Select(i => (byte)(0x80 + i))];
+
+ private static byte[] Hash(
+ byte[]? previousHash = null,
+ Guid? userId = null,
+ int generation = 1,
+ byte[]? encryptionPublicKey = null,
+ byte[]? signingPublicKey = null,
+ byte[]? signature = null,
+ DateTimeOffset? createdAt = null) =>
+ KeyLogChain.ComputeEntryHash(
+ previousHash ?? KeyLogChain.CreateGenesisPreviousHash(),
+ userId ?? Alice,
+ generation,
+ encryptionPublicKey ?? TestKeys.Encryption,
+ signingPublicKey ?? TestKeys.Signing,
+ signature ?? Signature,
+ createdAt ?? CreatedAt);
+}
diff --git a/tests/DodoSSH.Crypto.Tests/KeyStatementCodecTests.cs b/tests/DodoSSH.Crypto.Tests/KeyStatementCodecTests.cs
new file mode 100644
index 0000000..e2719f2
--- /dev/null
+++ b/tests/DodoSSH.Crypto.Tests/KeyStatementCodecTests.cs
@@ -0,0 +1,223 @@
+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))];
+}
diff --git a/tests/fixtures/crypto/vectors.json b/tests/fixtures/crypto/vectors.json
index 5cdf1b9..358c370 100644
--- a/tests/fixtures/crypto/vectors.json
+++ b/tests/fixtures/crypto/vectors.json
@@ -186,5 +186,121 @@
"ed25519PublicKey": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f",
"fingerprint": "fe8d8673f517688bf0d5d9b812327619a303c765af1f47dbd6a777db193c36e5"
}
+ ],
+ "keyStatement": [
+ {
+ "name": "with-email",
+ "version": 1,
+ "issuer": "https://idp.example/realms/dodossh",
+ "subject": "alice-subject",
+ "email": "alice@example.com",
+ "keyGeneration": 1,
+ "createdAtUnixMilliseconds": 1750000000123,
+ "deviceName": "alice-laptop",
+ "x25519PublicKey": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
+ "ed25519PublicKey": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f",
+ "canonicalEncoding": "647368312f6b657973746174656d656e742f7631000100000001000001977420dc7b404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f010000002268747470733a2f2f6964702e6578616d706c652f7265616c6d732f646f646f737368010000000d616c6963652d7375626a6563740100000011616c696365406578616d706c652e636f6d010000000c616c6963652d6c6170746f70",
+ "binding": "2540115460ae00848233d56a19e6c8e48fafcfe96de649b63a325946524aa294",
+ "nonce": "JUARVGCuAISCM9VqGebI5I-vz-lt5km2OjJZRlJKopQ"
+ },
+ {
+ "name": "without-email",
+ "version": 1,
+ "issuer": "https://idp.example/realms/dodossh",
+ "subject": "alice-subject",
+ "email": null,
+ "keyGeneration": 1,
+ "createdAtUnixMilliseconds": 1750000000123,
+ "deviceName": "alice-laptop",
+ "x25519PublicKey": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
+ "ed25519PublicKey": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f",
+ "canonicalEncoding": "647368312f6b657973746174656d656e742f7631000100000001000001977420dc7b404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f010000002268747470733a2f2f6964702e6578616d706c652f7265616c6d732f646f646f737368010000000d616c6963652d7375626a65637400010000000c616c6963652d6c6170746f70",
+ "binding": "e1d73073fe20c35ec4afbd89c7d03525aebe34047dd4695970de95fb4c845be4",
+ "nonce": "4dcwc_4gw17Er72Jx9A1Ja6-NAR91GlZcN6V-0yEW-Q"
+ },
+ {
+ "name": "empty-email",
+ "version": 1,
+ "issuer": "https://idp.example/realms/dodossh",
+ "subject": "alice-subject",
+ "email": "",
+ "keyGeneration": 1,
+ "createdAtUnixMilliseconds": 1750000000123,
+ "deviceName": "alice-laptop",
+ "x25519PublicKey": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
+ "ed25519PublicKey": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f",
+ "canonicalEncoding": "647368312f6b657973746174656d656e742f7631000100000001000001977420dc7b404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f010000002268747470733a2f2f6964702e6578616d706c652f7265616c6d732f646f646f737368010000000d616c6963652d7375626a6563740100000000010000000c616c6963652d6c6170746f70",
+ "binding": "1106dc5bc7da7941ab565b40ec1a32d36472bac3ced03b5013ed0375d19b4585",
+ "nonce": "EQbcW8faeUGrVltA7Boy02RyusPO0DtQE-0DddGbRYU"
+ },
+ {
+ "name": "unicode-device-name",
+ "version": 1,
+ "issuer": "https://idp.example/realms/dodossh",
+ "subject": "alice-subject",
+ "email": "alice@example.com",
+ "keyGeneration": 1,
+ "createdAtUnixMilliseconds": 1750000000123,
+ "deviceName": "alice\u0027s ThinkPad \u2014 b\u00FCro",
+ "x25519PublicKey": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
+ "ed25519PublicKey": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f",
+ "canonicalEncoding": "647368312f6b657973746174656d656e742f7631000100000001000001977420dc7b404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f010000002268747470733a2f2f6964702e6578616d706c652f7265616c6d732f646f646f737368010000000d616c6963652d7375626a6563740100000011616c696365406578616d706c652e636f6d010000001a616c6963652773205468696e6b50616420e280942062c3bc726f",
+ "binding": "dbf47dd3bbce9b94b42b815758dfe599841aeca0a042da9b778036ff53f4d536",
+ "nonce": "2_R907vOm5S0K4FXWN_lmYQa7KCgQtqbd4A2_1P01TY"
+ },
+ {
+ "name": "offset-and-sub-millisecond-timestamp",
+ "version": 1,
+ "issuer": "https://idp.example/realms/dodossh",
+ "subject": "alice-subject",
+ "email": "alice@example.com",
+ "keyGeneration": 1,
+ "createdAtUnixMilliseconds": 1750000000123,
+ "deviceName": "alice-laptop",
+ "x25519PublicKey": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
+ "ed25519PublicKey": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f",
+ "canonicalEncoding": "647368312f6b657973746174656d656e742f7631000100000001000001977420dc7b404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f010000002268747470733a2f2f6964702e6578616d706c652f7265616c6d732f646f646f737368010000000d616c6963652d7375626a6563740100000011616c696365406578616d706c652e636f6d010000000c616c6963652d6c6170746f70",
+ "binding": "2540115460ae00848233d56a19e6c8e48fafcfe96de649b63a325946524aa294",
+ "nonce": "JUARVGCuAISCM9VqGebI5I-vz-lt5km2OjJZRlJKopQ"
+ },
+ {
+ "name": "later-generation",
+ "version": 1,
+ "issuer": "https://idp.example/realms/dodossh",
+ "subject": "alice-subject",
+ "email": "alice@example.com",
+ "keyGeneration": 4,
+ "createdAtUnixMilliseconds": 1750000000123,
+ "deviceName": "alice-laptop",
+ "x25519PublicKey": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
+ "ed25519PublicKey": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f",
+ "canonicalEncoding": "647368312f6b657973746174656d656e742f7631000100000004000001977420dc7b404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f010000002268747470733a2f2f6964702e6578616d706c652f7265616c6d732f646f646f737368010000000d616c6963652d7375626a6563740100000011616c696365406578616d706c652e636f6d010000000c616c6963652d6c6170746f70",
+ "binding": "2c760b871347d072ee58c407a710cb9b8cc53bd36dc698a955ae7a708db386ee",
+ "nonce": "LHYLhxNH0HLuWMQHpxDLm4zFO9NtxpipVa56cI2zhu4"
+ }
+ ],
+ "keyLog": [
+ {
+ "name": "genesis",
+ "previousHash": "0000000000000000000000000000000000000000000000000000000000000000",
+ "userId": "0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
+ "generation": 1,
+ "x25519PublicKey": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
+ "ed25519PublicKey": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f",
+ "statementSignature": "808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf",
+ "createdAtUnixMilliseconds": 1750000000123,
+ "hash": "de9419c582e4729b937f4e2f5043ed4ee51f5b2e6297e65ffea50e30e3e67d57"
+ },
+ {
+ "name": "second-entry",
+ "previousHash": "de9419c582e4729b937f4e2f5043ed4ee51f5b2e6297e65ffea50e30e3e67d57",
+ "userId": "0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e10",
+ "generation": 1,
+ "x25519PublicKey": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
+ "ed25519PublicKey": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f",
+ "statementSignature": "808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf",
+ "createdAtUnixMilliseconds": 1750000000123,
+ "hash": "e0ac141a9113afa2e4ce5e9562641f706449a52bc63f5249baa7fa9bd8326187"
+ }
]
}