Public Access
Specify the key statement encoding and key log chain (crypto.md 7.1, 7.2)
Section 7 always required "a canonical, length-prefixed encoding" for signatures without ever specifying one. That gap had to be closed before enrollment could exist: the client hashes the key statement and uses the result as an OIDC nonce, so the provider signs over those exact bytes. Two implementations disagreeing by one byte produce two nonces and an enrollment nobody can verify -- and it only shows up against a real provider, never in a local test. JSON cannot be the hashed form. Property order, number formatting, Unicode escaping and whitespace all vary between serialisers. So the statement is transmitted as JSON and hashed as a fixed binary encoding, and the two are independent by construction. Three details are load-bearing rather than stylistic: - The presence byte before each string is what makes the encoding injective. Without it an absent email and an empty one encode identically, and two different statements share a binding. - Timestamps truncate to milliseconds. PostgreSQL stores microseconds, so a statement that has been through the database must still hash to what the client hashed. The same applies to the key log, where an entry that cannot reproduce its own hash after being read back makes the chain unverifiable. - The key log entry hash deliberately excludes the database sequence. It is unknown until the insert runs, and order already follows the hash links -- so a renumbered or gapped sequence column cannot silently reorder history. KeyStatementFields is separate from Contracts.KeyStatement on purpose: one may gain JSON fields freely, the other cannot change without invalidating every stored binding, and Crypto must not depend on the contract assembly. KeyStatementDriftTests makes a field added to one and not the other a build failure, because a wire field outside the binding is unauthenticated data the server can change undetected. 54 new tests and two new golden vector sections. The vectors pin the absent-versus-empty email case and confirm that an offset-bearing sub-millisecond timestamp encodes identically to its truncated UTC form. Only additions to vectors.json; nothing existing moved.
This commit is contained in:
+72
-1
@@ -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
|
would be a convenience, never the security boundary, and would drag an asymmetric
|
||||||
implementation onto a machine that is supposed to have none.
|
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
|
## 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;
|
- canonical AAD encodings and their SHA-256, including UUID byte order;
|
||||||
- envelope framing for each `alg_id`, with fixed key, nonce and plaintext;
|
- envelope framing for each `alg_id`, with fixed key, nonce and plaintext;
|
||||||
- Argon2id and HKDF outputs for fixed inputs;
|
- 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
|
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.
|
fresh randomness, so those are verified by round-trip plus fixed-input `Open` vectors.
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using NSec.Cryptography;
|
||||||
|
|
||||||
|
namespace DodoSSH.Crypto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ed25519 signatures over the canonical encodings of the specification. See docs/crypto.md §7.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Verification returns <see langword="false"/> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class DshSignatures
|
||||||
|
{
|
||||||
|
private static SignatureAlgorithm Algorithm => SignatureAlgorithm.Ed25519;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Signs a key statement's canonical encoding.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="signingKey">The signer's Ed25519 key.</param>
|
||||||
|
/// <param name="canonicalStatement">Output of <see cref="KeyStatementCodec.Encode"/>.</param>
|
||||||
|
public static byte[] SignKeyStatement(Key signingKey, ReadOnlySpan<byte> canonicalStatement)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(signingKey);
|
||||||
|
|
||||||
|
var message = BuildMessage(CryptoSpec.SigningContexts.KeyStatement, canonicalStatement);
|
||||||
|
return Algorithm.Sign(signingKey, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies a key statement's self-signature.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="signingPublicKey">Ed25519 public key from the statement itself.</param>
|
||||||
|
/// <param name="canonicalStatement">Output of <see cref="KeyStatementCodec.Encode"/>.</param>
|
||||||
|
/// <param name="signature">The detached signature.</param>
|
||||||
|
public static bool VerifyKeyStatement(
|
||||||
|
ReadOnlySpan<byte> signingPublicKey,
|
||||||
|
ReadOnlySpan<byte> canonicalStatement,
|
||||||
|
ReadOnlySpan<byte> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prefixes a message with its signing context.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Heap-allocated rather than stack-allocated on purpose: the statement contains
|
||||||
|
/// caller-supplied strings of unbounded length, and a <c>stackalloc</c> sized from untrusted
|
||||||
|
/// input is a stack overflow waiting to happen.
|
||||||
|
/// </remarks>
|
||||||
|
private static byte[] BuildMessage(ReadOnlySpan<byte> context, ReadOnlySpan<byte> payload)
|
||||||
|
{
|
||||||
|
var message = new byte[context.Length + payload.Length];
|
||||||
|
context.CopyTo(message);
|
||||||
|
payload.CopyTo(message.AsSpan(context.Length));
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace DodoSSH.Crypto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The hash chain over the append-only identity key log. See docs/crypto.md §7.2.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// 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 cref="KeyStatementCodec"/>. See ADR 0001.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class KeyLogChain
|
||||||
|
{
|
||||||
|
/// <summary>Domain-separating prefix of the entry hash input.</summary>
|
||||||
|
public static ReadOnlySpan<byte> Label => "dsh1/keylog/v1"u8;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Length of the hash input: label, previous hash, user id, generation, both public keys,
|
||||||
|
/// the statement signature and the timestamp.
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The previous-hash value of the first entry in an empty log: 32 zero bytes.
|
||||||
|
/// </summary>
|
||||||
|
public static byte[] CreateGenesisPreviousHash() => new byte[CryptoSpec.DigestSize];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes an entry's hash.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The database-assigned sequence is deliberately <em>not</em> 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.
|
||||||
|
/// <para>
|
||||||
|
/// <paramref name="createdAt"/> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="previousHash">Hash of the preceding entry, or <see cref="CreateGenesisPreviousHash"/>.</param>
|
||||||
|
/// <param name="userId">The user whose key this is.</param>
|
||||||
|
/// <param name="generation">Generation published.</param>
|
||||||
|
/// <param name="encryptionPublicKey">X25519 public key, 32 bytes.</param>
|
||||||
|
/// <param name="signingPublicKey">Ed25519 public key, 32 bytes.</param>
|
||||||
|
/// <param name="statementSignature">Ed25519 self-signature over the key statement, 64 bytes.</param>
|
||||||
|
/// <param name="createdAt">When the entry was appended.</param>
|
||||||
|
public static byte[] ComputeEntryHash(
|
||||||
|
ReadOnlySpan<byte> previousHash,
|
||||||
|
Guid userId,
|
||||||
|
int generation,
|
||||||
|
ReadOnlySpan<byte> encryptionPublicKey,
|
||||||
|
ReadOnlySpan<byte> signingPublicKey,
|
||||||
|
ReadOnlySpan<byte> 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<byte> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Truncates a timestamp to the precision the chain hashes.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Callers must store the value this returns, not the one they started with, or the stored row
|
||||||
|
/// will not reproduce its own hash.
|
||||||
|
/// </remarks>
|
||||||
|
public static DateTimeOffset TruncateTimestamp(DateTimeOffset value) =>
|
||||||
|
DateTimeOffset.FromUnixTimeMilliseconds(value.ToUnixTimeMilliseconds()).ToOffset(TimeSpan.Zero);
|
||||||
|
|
||||||
|
private static void RequireLength(ReadOnlySpan<byte> value, int expected, string parameterName)
|
||||||
|
{
|
||||||
|
if (value.Length != expected)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Expected {expected} bytes, got {value.Length}.",
|
||||||
|
parameterName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Buffers.Text;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace DodoSSH.Crypto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The canonical encoding of an identity key statement, and the identity-provider binding value
|
||||||
|
/// derived from it. See docs/crypto.md §7.1.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This encoding is the pivot of the whole public-key trust story. The client hashes it and uses
|
||||||
|
/// the result as the <c>nonce</c> 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class KeyStatementCodec
|
||||||
|
{
|
||||||
|
/// <summary>Domain-separating prefix of the canonical encoding.</summary>
|
||||||
|
public static ReadOnlySpan<byte> Label => "dsh1/keystatement/v1"u8;
|
||||||
|
|
||||||
|
/// <summary>Highest statement version this implementation encodes.</summary>
|
||||||
|
public const int CurrentVersion = 1;
|
||||||
|
|
||||||
|
/// <summary>Length of the binding value: a SHA-256 digest.</summary>
|
||||||
|
public const int BindingLength = CryptoSpec.DigestSize;
|
||||||
|
|
||||||
|
/// <summary>Length of the base64url binding, as it appears in the <c>nonce</c> claim.</summary>
|
||||||
|
public const int NonceLength = 43;
|
||||||
|
|
||||||
|
/// <summary>version (u16) + keyGeneration (u32) + createdAt (i64) + two public keys.</summary>
|
||||||
|
private const int FixedBlockLength =
|
||||||
|
sizeof(ushort) + sizeof(uint) + sizeof(long) + (CryptoSpec.PublicKeySize * 2);
|
||||||
|
|
||||||
|
/// <summary>Presence byte plus a u32 length prefix.</summary>
|
||||||
|
private const int StringHeaderLength = 1 + sizeof(uint);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes the canonical encoding.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Layout, all integers big-endian:
|
||||||
|
/// <c>"dsh1/keystatement/v1"</c> | u16 version | u32 keyGeneration |
|
||||||
|
/// i64 createdAt (Unix milliseconds, UTC) | x25519 pk (32) | ed25519 pk (32) |
|
||||||
|
/// issuer | subject | email | deviceName.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="DateTimeOffset.ToUnixTimeMilliseconds"/> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="statement">The statement to encode.</param>
|
||||||
|
/// <exception cref="ArgumentOutOfRangeException">A version, generation or key length is invalid.</exception>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes the identity-provider binding: SHA-256 over the canonical encoding.
|
||||||
|
/// </summary>
|
||||||
|
public static byte[] ComputeBinding(KeyStatementFields statement) =>
|
||||||
|
SHA256.HashData(Encode(statement));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes the identity-provider binding from an encoding already produced by
|
||||||
|
/// <see cref="Encode"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
public static byte[] ComputeBinding(ReadOnlySpan<byte> canonicalEncoding) =>
|
||||||
|
SHA256.HashData(canonicalEncoding);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders a binding as the string that must appear in the OIDC <c>nonce</c> claim.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Base64url without padding, because the nonce travels in an authorization request query
|
||||||
|
/// string where <c>+</c>, <c>/</c> and <c>=</c> all need escaping and some providers mangle
|
||||||
|
/// them.
|
||||||
|
/// </remarks>
|
||||||
|
public static string ToNonce(ReadOnlySpan<byte> binding)
|
||||||
|
{
|
||||||
|
if (binding.Length != BindingLength)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"A binding is {BindingLength} bytes, got {binding.Length}.",
|
||||||
|
nameof(binding));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Base64Url.EncodeToString(binding);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Computes the expected <c>nonce</c> claim value for a statement.</summary>
|
||||||
|
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<byte> 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
namespace DodoSSH.Crypto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An identity key statement, in the form the specification hashes and signs.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Deliberately separate from the transport DTO in <c>DodoSSH.Contracts</c>. This type defines
|
||||||
|
/// what is <em>hashed</em>; that one defines what is <em>transmitted</em>. Keeping them apart is
|
||||||
|
/// what lets the canonical encoding stay stable while the JSON shape gains fields, and it keeps
|
||||||
|
/// <c>DodoSSH.Crypto</c> free of a dependency on the contract assembly. A test asserts the two
|
||||||
|
/// carry the same fields, so they cannot drift.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Equality on this record is not meaningful — the key and signature members are arrays, so it
|
||||||
|
/// compares by reference. Compare canonical encodings instead.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="Version">Statement format version. Currently 1.</param>
|
||||||
|
/// <param name="Issuer">OIDC issuer that vouches for the subject.</param>
|
||||||
|
/// <param name="Subject">OIDC subject.</param>
|
||||||
|
/// <param name="Email">Email at enrollment time, for display only. May be absent.</param>
|
||||||
|
/// <param name="EncryptionPublicKey">X25519 public key, 32 bytes.</param>
|
||||||
|
/// <param name="SigningPublicKey">Ed25519 public key, 32 bytes.</param>
|
||||||
|
/// <param name="KeyGeneration">Generation of this key pair, starting at 1.</param>
|
||||||
|
/// <param name="CreatedAt">When the client generated the keys.</param>
|
||||||
|
/// <param name="DeviceName">Human-readable name of the enrolling device.</param>
|
||||||
|
public sealed record KeyStatementFields(
|
||||||
|
int Version,
|
||||||
|
string Issuer,
|
||||||
|
string Subject,
|
||||||
|
string? Email,
|
||||||
|
byte[] EncryptionPublicKey,
|
||||||
|
byte[] SigningPublicKey,
|
||||||
|
int KeyGeneration,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
string DeviceName);
|
||||||
@@ -9,6 +9,13 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../../src/DodoSSH.Contracts/DodoSSH.Contracts.csproj" />
|
<ProjectReference Include="../../src/DodoSSH.Contracts/DodoSSH.Contracts.csproj" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Only so KeyStatementDriftTests can compare the wire DTO against the type the canonical
|
||||||
|
encoding is defined over. Contracts itself must not reference Crypto: the two are
|
||||||
|
deliberately independent, which is the thing that test guards.
|
||||||
|
-->
|
||||||
|
<ProjectReference Include="../../src/DodoSSH.Crypto/DodoSSH.Crypto.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using DodoSSH.Crypto;
|
||||||
|
|
||||||
|
namespace DodoSSH.Contracts.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Guards the two key statement types against drifting apart.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="KeyStatement"/> is the wire DTO; <see cref="KeyStatementFields"/> 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 <c>DodoSSH.Crypto</c> must not depend on the contract assembly.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class KeyStatementDriftTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void BothTypes_DeclareTheSameFields()
|
||||||
|
{
|
||||||
|
var contract = PropertyNames<KeyStatement>();
|
||||||
|
var canonical = PropertyNames<KeyStatementFields>();
|
||||||
|
|
||||||
|
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<KeyStatement>();
|
||||||
|
var canonical = PropertyTypes<KeyStatementFields>();
|
||||||
|
|
||||||
|
canonical.ShouldBe(contract);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> PropertyNames<T>() =>
|
||||||
|
[.. typeof(T)
|
||||||
|
.GetProperties()
|
||||||
|
.Select(p => p.Name)
|
||||||
|
.Where(name => !string.Equals(name, "EqualityContract", StringComparison.Ordinal))
|
||||||
|
.Order(StringComparer.Ordinal)];
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> PropertyTypes<T>() =>
|
||||||
|
[.. typeof(T)
|
||||||
|
.GetProperties()
|
||||||
|
.Where(p => !string.Equals(p.Name, "EqualityContract", StringComparison.Ordinal))
|
||||||
|
.Select(p => $"{p.Name}:{p.PropertyType.Name}")
|
||||||
|
.Order(StringComparer.Ordinal)];
|
||||||
|
}
|
||||||
@@ -196,6 +196,27 @@
|
|||||||
},
|
},
|
||||||
"dodossh.contracts": {
|
"dodossh.contracts": {
|
||||||
"type": "Project"
|
"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)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using NSec.Cryptography;
|
||||||
|
|
||||||
|
namespace DodoSSH.Crypto.Tests;
|
||||||
|
|
||||||
|
/// <summary>Key statement signing and verification. See docs/crypto.md §7.</summary>
|
||||||
|
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"));
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ internal static class GoldenVectors
|
|||||||
["hkdf"] = BuildHkdfVectors(),
|
["hkdf"] = BuildHkdfVectors(),
|
||||||
["argon2id"] = BuildArgon2Vectors(),
|
["argon2id"] = BuildArgon2Vectors(),
|
||||||
["fingerprint"] = BuildFingerprintVectors(),
|
["fingerprint"] = BuildFingerprintVectors(),
|
||||||
|
["keyStatement"] = BuildKeyStatementVectors(),
|
||||||
|
["keyLog"] = BuildKeyLogVectors(),
|
||||||
};
|
};
|
||||||
|
|
||||||
return root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }) + "\n";
|
return root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }) + "\n";
|
||||||
@@ -276,6 +278,137 @@ internal static class GoldenVectors
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pins the key statement encoding and the nonce derived from it. See docs/crypto.md §7.1.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
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")),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pins the key log chain hash, including the genesis link. See docs/crypto.md §7.2.</summary>
|
||||||
|
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<byte> value) =>
|
private static string Hex(ReadOnlySpan<byte> value) =>
|
||||||
Convert.ToHexString(value).ToLower(CultureInfo.InvariantCulture);
|
Convert.ToHexString(value).ToLower(CultureInfo.InvariantCulture);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
namespace DodoSSH.Crypto.Tests;
|
||||||
|
|
||||||
|
/// <summary>The key log hash chain. See docs/crypto.md §7.2.</summary>
|
||||||
|
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<ArgumentException>(() => Hash(previousHash: new byte[length]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ComputeEntryHash_RejectsAWrongLengthSignature()
|
||||||
|
{
|
||||||
|
Should.Throw<ArgumentException>(() => Hash(signature: new byte[32]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ComputeEntryHash_RejectsAGenerationBelowOne()
|
||||||
|
{
|
||||||
|
Should.Throw<ArgumentOutOfRangeException>(() => 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
using System.Buffers.Text;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace DodoSSH.Crypto.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The canonical key statement encoding and the nonce derived from it. See docs/crypto.md §7.1.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// These properties are what make the identity-provider binding checkable at all. The failure mode
|
||||||
|
/// they guard against is nasty: a client and a server that encode differently produce different
|
||||||
|
/// nonces, so every enrollment is rejected against a real provider while every local test passes.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class KeyStatementCodecTests
|
||||||
|
{
|
||||||
|
private static readonly DateTimeOffset CreatedAt = DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encode_IsDeterministic()
|
||||||
|
{
|
||||||
|
var first = KeyStatementCodec.Encode(Statement());
|
||||||
|
var second = KeyStatementCodec.Encode(Statement());
|
||||||
|
|
||||||
|
first.ShouldBe(second);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encode_BeginsWithTheDomainLabel()
|
||||||
|
{
|
||||||
|
var encoding = KeyStatementCodec.Encode(Statement());
|
||||||
|
|
||||||
|
encoding.AsSpan(0, KeyStatementCodec.Label.Length)
|
||||||
|
.SequenceEqual(KeyStatementCodec.Label)
|
||||||
|
.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnAbsentEmail_EncodesDifferentlyFromAnEmptyOne()
|
||||||
|
{
|
||||||
|
// The presence byte exists for exactly this. Without it the encoding is not injective and
|
||||||
|
// two genuinely different statements share a binding.
|
||||||
|
var absent = KeyStatementCodec.Encode(Statement(email: null));
|
||||||
|
var empty = KeyStatementCodec.Encode(Statement(email: string.Empty));
|
||||||
|
|
||||||
|
absent.ShouldNotBe(empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheSameInstantInDifferentOffsets_EncodesIdentically()
|
||||||
|
{
|
||||||
|
// The timezone a client happens to hold must not change the hash the provider signs.
|
||||||
|
var utc = KeyStatementCodec.Encode(Statement(createdAt: CreatedAt));
|
||||||
|
var shifted = KeyStatementCodec.Encode(
|
||||||
|
Statement(createdAt: CreatedAt.ToOffset(TimeSpan.FromHours(-7))));
|
||||||
|
|
||||||
|
utc.ShouldBe(shifted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubMillisecondPrecision_IsTruncatedAway()
|
||||||
|
{
|
||||||
|
// PostgreSQL stores microseconds. A statement that has been through the database must still
|
||||||
|
// hash to what the client hashed before sending it.
|
||||||
|
var exact = KeyStatementCodec.Encode(Statement(createdAt: CreatedAt));
|
||||||
|
var noisy = KeyStatementCodec.Encode(Statement(createdAt: CreatedAt.AddTicks(9_999)));
|
||||||
|
|
||||||
|
exact.ShouldBe(noisy);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("issuer")]
|
||||||
|
[InlineData("subject")]
|
||||||
|
[InlineData("email")]
|
||||||
|
[InlineData("deviceName")]
|
||||||
|
[InlineData("version")]
|
||||||
|
[InlineData("keyGeneration")]
|
||||||
|
[InlineData("createdAt")]
|
||||||
|
[InlineData("encryptionPublicKey")]
|
||||||
|
[InlineData("signingPublicKey")]
|
||||||
|
public void ChangingAnyField_ChangesTheBinding(string field)
|
||||||
|
{
|
||||||
|
var baseline = KeyStatementCodec.ComputeBinding(Statement());
|
||||||
|
var altered = KeyStatementCodec.ComputeBinding(Mutate(field));
|
||||||
|
|
||||||
|
altered.ShouldNotBe(baseline);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MovingACharacterAcrossAFieldBoundary_ChangesTheBinding()
|
||||||
|
{
|
||||||
|
// The property length prefixes buy: no field value can forge a boundary. A delimited
|
||||||
|
// encoding would give these two the same bytes.
|
||||||
|
var left = KeyStatementCodec.ComputeBinding(
|
||||||
|
Statement(issuer: "https://idp.example/a", subject: "bcd"));
|
||||||
|
|
||||||
|
var right = KeyStatementCodec.ComputeBinding(
|
||||||
|
Statement(issuer: "https://idp.example/ab", subject: "cd"));
|
||||||
|
|
||||||
|
left.ShouldNotBe(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StringLengths_AreCountedInBytesNotCharacters()
|
||||||
|
{
|
||||||
|
// A device name whose UTF-8 length exceeds its character count must still round-trip, and
|
||||||
|
// must not collide with a shorter one. A length prefix counting characters truncates here.
|
||||||
|
var multiByte = Statement(deviceName: "büro — ThinkPad");
|
||||||
|
var encoding = KeyStatementCodec.Encode(multiByte);
|
||||||
|
|
||||||
|
var expectedNameBytes = Encoding.UTF8.GetByteCount(multiByte.DeviceName);
|
||||||
|
expectedNameBytes.ShouldBeGreaterThan(multiByte.DeviceName.Length);
|
||||||
|
|
||||||
|
// The name is the last field, so it occupies the tail of the encoding.
|
||||||
|
Encoding.UTF8.GetString(encoding.AsSpan(encoding.Length - expectedNameBytes))
|
||||||
|
.ShouldBe(multiByte.DeviceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Nonce_IsUnpaddedBase64UrlOfTheBinding()
|
||||||
|
{
|
||||||
|
var statement = Statement();
|
||||||
|
var binding = KeyStatementCodec.ComputeBinding(statement);
|
||||||
|
var nonce = KeyStatementCodec.ComputeNonce(statement);
|
||||||
|
|
||||||
|
nonce.Length.ShouldBe(KeyStatementCodec.NonceLength);
|
||||||
|
nonce.ShouldNotContain("=");
|
||||||
|
nonce.ShouldNotContain("+");
|
||||||
|
nonce.ShouldNotContain("/");
|
||||||
|
|
||||||
|
Base64Url.DecodeFromChars(nonce).ShouldBe(binding);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ComputeBinding_AgreesBetweenBothOverloads()
|
||||||
|
{
|
||||||
|
var statement = Statement();
|
||||||
|
|
||||||
|
KeyStatementCodec.ComputeBinding(KeyStatementCodec.Encode(statement))
|
||||||
|
.ShouldBe(KeyStatementCodec.ComputeBinding(statement));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(31)]
|
||||||
|
[InlineData(33)]
|
||||||
|
[InlineData(64)]
|
||||||
|
public void Encode_RejectsAPublicKeyOfTheWrongLength(int length)
|
||||||
|
{
|
||||||
|
var statement = Statement() with { EncryptionPublicKey = new byte[length] };
|
||||||
|
|
||||||
|
Should.Throw<ArgumentOutOfRangeException>(() => KeyStatementCodec.Encode(statement));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(-1)]
|
||||||
|
[InlineData(65536)]
|
||||||
|
public void Encode_RejectsAnUnusableVersion(int version)
|
||||||
|
{
|
||||||
|
var statement = Statement() with { Version = version };
|
||||||
|
|
||||||
|
Should.Throw<ArgumentOutOfRangeException>(() => KeyStatementCodec.Encode(statement));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encode_RejectsAGenerationBelowOne()
|
||||||
|
{
|
||||||
|
var statement = Statement() with { KeyGeneration = 0 };
|
||||||
|
|
||||||
|
Should.Throw<ArgumentOutOfRangeException>(() => KeyStatementCodec.Encode(statement));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToNonce_RejectsSomethingThatIsNotADigest()
|
||||||
|
{
|
||||||
|
Should.Throw<ArgumentException>(() => KeyStatementCodec.ToNonce(new byte[16]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyStatementFields Statement(
|
||||||
|
string issuer = "https://idp.example/realms/dodossh",
|
||||||
|
string subject = "alice-subject",
|
||||||
|
string? email = "alice@example.com",
|
||||||
|
string deviceName = "alice-laptop",
|
||||||
|
int keyGeneration = 1,
|
||||||
|
DateTimeOffset? createdAt = null) =>
|
||||||
|
new(
|
||||||
|
Version: 1,
|
||||||
|
Issuer: issuer,
|
||||||
|
Subject: subject,
|
||||||
|
Email: email,
|
||||||
|
EncryptionPublicKey: TestKeys.Encryption,
|
||||||
|
SigningPublicKey: TestKeys.Signing,
|
||||||
|
KeyGeneration: keyGeneration,
|
||||||
|
CreatedAt: createdAt ?? CreatedAt,
|
||||||
|
DeviceName: deviceName);
|
||||||
|
|
||||||
|
private static KeyStatementFields Mutate(string field) => field switch
|
||||||
|
{
|
||||||
|
"issuer" => Statement(issuer: "https://idp.example/realms/other"),
|
||||||
|
"subject" => Statement(subject: "bob-subject"),
|
||||||
|
"email" => Statement(email: "bob@example.com"),
|
||||||
|
"deviceName" => Statement(deviceName: "bob-desktop"),
|
||||||
|
"version" => Statement() with { Version = 2 },
|
||||||
|
"keyGeneration" => Statement(keyGeneration: 2),
|
||||||
|
"createdAt" => Statement(createdAt: CreatedAt.AddSeconds(1)),
|
||||||
|
"encryptionPublicKey" => Statement() with { EncryptionPublicKey = TestKeys.Alternate },
|
||||||
|
"signingPublicKey" => Statement() with { SigningPublicKey = TestKeys.Alternate },
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unknown field."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fixed public-key bytes, so encodings are reproducible.</summary>
|
||||||
|
internal static class TestKeys
|
||||||
|
{
|
||||||
|
internal static byte[] Encryption { get; } =
|
||||||
|
[.. Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0x40 + i))];
|
||||||
|
|
||||||
|
internal static byte[] Signing { get; } =
|
||||||
|
[.. Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0x60 + i))];
|
||||||
|
|
||||||
|
internal static byte[] Alternate { get; } =
|
||||||
|
[.. Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0xA0 + i))];
|
||||||
|
}
|
||||||
Vendored
+116
@@ -186,5 +186,121 @@
|
|||||||
"ed25519PublicKey": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f",
|
"ed25519PublicKey": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f",
|
||||||
"fingerprint": "fe8d8673f517688bf0d5d9b812327619a303c765af1f47dbd6a777db193c36e5"
|
"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"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user