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" + } ] }