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:
@@ -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);
|
||||
Reference in New Issue
Block a user