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