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:
1 parent
e6673f0bf2
commit
d2a2ed8a29
13 files changed
+1363
-1
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user