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);
}
}
}