using System.Buffers.Binary; using System.Security.Cryptography; namespace DodoSSH.Crypto; /// /// The hash chain over the append-only identity key log. See docs/crypto.md §7.2. /// /// /// /// Cheap key transparency. Every published key statement links to the hash of the one before it, /// so for a server to show two clients divergent views of who holds which key it must maintain /// both forks consistently across every later shared operation. Signed grants record the log head /// their signer observed, and any two clients touching the same vault then surface the mismatch. /// /// /// This converts an otherwise undetectable key-substitution attack into a detectable one. It does /// not prevent it, and it is not a substitute for the identity-provider binding in /// . See ADR 0001. /// /// /// The chain lives here rather than in the server so that clients can recompute it. A chain only /// a server can compute is a claim, not evidence. /// /// public static class KeyLogChain { /// Domain-separating prefix of the entry hash input. public static ReadOnlySpan Label => "dsh1/keylog/v1"u8; /// /// Length of the hash input: label, previous hash, user id, generation, both public keys, /// the statement signature and the timestamp. /// private const int InputLength = 14 // Label + CryptoSpec.DigestSize // previous hash + 16 // user id + sizeof(uint) // generation + (CryptoSpec.PublicKeySize * 2) + CryptoSpec.SignatureSize + sizeof(long); // created at, Unix milliseconds /// /// The previous-hash value of the first entry in an empty log: 32 zero bytes. /// public static byte[] CreateGenesisPreviousHash() => new byte[CryptoSpec.DigestSize]; /// /// Computes an entry's hash. /// /// /// The database-assigned sequence is deliberately not an input. It is unknown until /// the insert executes, and the chain already fixes the order — deriving order from the hash /// links rather than from a sequence column means a renumbered or gapped sequence cannot /// silently reorder history. /// /// is truncated to milliseconds, matching the stored column after /// a round trip through PostgreSQL. Writing a timestamp with finer precision than the hash /// input would leave the chain unverifiable by anyone who read the row back. /// /// /// Hash of the preceding entry, or . /// The user whose key this is. /// Generation published. /// X25519 public key, 32 bytes. /// Ed25519 public key, 32 bytes. /// Ed25519 self-signature over the key statement, 64 bytes. /// When the entry was appended. public static byte[] ComputeEntryHash( ReadOnlySpan previousHash, Guid userId, int generation, ReadOnlySpan encryptionPublicKey, ReadOnlySpan signingPublicKey, ReadOnlySpan statementSignature, DateTimeOffset createdAt) { RequireLength(previousHash, CryptoSpec.DigestSize, nameof(previousHash)); RequireLength(encryptionPublicKey, CryptoSpec.PublicKeySize, nameof(encryptionPublicKey)); RequireLength(signingPublicKey, CryptoSpec.PublicKeySize, nameof(signingPublicKey)); RequireLength(statementSignature, CryptoSpec.SignatureSize, nameof(statementSignature)); ArgumentOutOfRangeException.ThrowIfLessThan(generation, 1); Span input = stackalloc byte[InputLength]; input.Clear(); Label.CopyTo(input); var offset = Label.Length; previousHash.CopyTo(input[offset..]); offset += CryptoSpec.DigestSize; // RFC 4122 big-endian order, as everywhere else in this specification. if (!userId.TryWriteBytes(input[offset..], bigEndian: true, out _)) { throw new InvalidOperationException("Failed to write the user id."); } offset += 16; BinaryPrimitives.WriteUInt32BigEndian(input[offset..], (uint)generation); offset += sizeof(uint); encryptionPublicKey.CopyTo(input[offset..]); offset += CryptoSpec.PublicKeySize; signingPublicKey.CopyTo(input[offset..]); offset += CryptoSpec.PublicKeySize; statementSignature.CopyTo(input[offset..]); offset += CryptoSpec.SignatureSize; BinaryPrimitives.WriteInt64BigEndian(input[offset..], createdAt.ToUnixTimeMilliseconds()); offset += sizeof(long); if (offset != InputLength) { throw new InvalidOperationException( $"Key log hash input wrote {offset} bytes but reserved {InputLength}."); } return SHA256.HashData(input); } /// /// Truncates a timestamp to the precision the chain hashes. /// /// /// Callers must store the value this returns, not the one they started with, or the stored row /// will not reproduce its own hash. /// public static DateTimeOffset TruncateTimestamp(DateTimeOffset value) => DateTimeOffset.FromUnixTimeMilliseconds(value.ToUnixTimeMilliseconds()).ToOffset(TimeSpan.Zero); private static void RequireLength(ReadOnlySpan value, int expected, string parameterName) { if (value.Length != expected) { throw new ArgumentException( $"Expected {expected} bytes, got {value.Length}.", parameterName); } } }