RunLedger.cs
1 using System.Buffers.Binary; 2 using System.Collections.Immutable; 3 using System.Collections.ObjectModel; 4 using System.Security.Cryptography; 5 using GUNRPG.Gossip; 6 using GUNRPG.Ledger.Indexing; 7 using GUNRPG.Security; 8 9 namespace GUNRPG.Ledger; 10 11 public class RunLedger 12 { 13 private const int HashSize = SHA256.HashSizeInBytes; 14 private const int GuidSize = 16; 15 private const int Int64Size = 8; 16 17 private static readonly ImmutableArray<byte> ZeroHash = ImmutableArray.Create(new byte[HashSize]); 18 19 private readonly List<RunLedgerEntry> _entries = []; 20 private readonly ReadOnlyCollection<RunLedgerEntry> _readOnlyEntries; 21 private readonly MerkleSkipIndex _merkleSkipIndex; 22 23 public RunLedger() 24 { 25 _readOnlyEntries = _entries.AsReadOnly(); 26 _merkleSkipIndex = new MerkleSkipIndex(GetEntryHashAt); 27 } 28 29 public IReadOnlyList<RunLedgerEntry> Entries => _readOnlyEntries; 30 31 public RunLedgerEntry? Head => _entries.Count == 0 ? null : _entries[^1]; 32 33 public MerkleSkipIndex MerkleSkipIndex => _merkleSkipIndex; 34 35 public RunLedgerEntry Append(RunValidationResult run) 36 { 37 return Append(run, DateTimeOffset.UtcNow); 38 } 39 40 [Obsolete("Use the TryAppendWithQuorum overload that accepts a SignatureVerifier.")] 41 public bool TryAppendWithQuorum( 42 RunValidationResult run, 43 QuorumValidator quorumValidator, 44 AuthoritySet authoritySet, 45 QuorumPolicy quorumPolicy) 46 { 47 throw new NotSupportedException("Use the TryAppendWithQuorum overload that accepts a SignatureVerifier."); 48 } 49 50 public bool TryAppendWithQuorum( 51 RunValidationResult run, 52 SignatureVerifier signatureVerifier, 53 QuorumValidator quorumValidator, 54 AuthoritySet authoritySet, 55 QuorumPolicy quorumPolicy) 56 { 57 return TryAppendWithQuorum(run, signatureVerifier, quorumValidator, authoritySet, quorumPolicy, DateTimeOffset.UtcNow); 58 } 59 60 internal RunLedgerEntry Append(RunValidationResult run, DateTimeOffset timestamp) 61 { 62 ArgumentNullException.ThrowIfNull(run); 63 64 var index = (long)_entries.Count; 65 var previousHash = _entries.Count == 0 ? ZeroHash : _entries[^1].EntryHash; 66 67 var entryHash = ComputeEntryHash(index, previousHash, timestamp, run); 68 69 var entry = new RunLedgerEntry(index, previousHash, entryHash, timestamp, run); 70 _entries.Add(entry); 71 _merkleSkipIndex.Append(entry); 72 return entry; 73 } 74 75 internal bool TryAppendWithQuorum( 76 RunValidationResult run, 77 SignatureVerifier signatureVerifier, 78 QuorumValidator quorumValidator, 79 AuthoritySet authoritySet, 80 QuorumPolicy quorumPolicy, 81 DateTimeOffset timestamp) 82 { 83 ArgumentNullException.ThrowIfNull(run); 84 ArgumentNullException.ThrowIfNull(signatureVerifier); 85 ArgumentNullException.ThrowIfNull(quorumValidator); 86 ArgumentNullException.ThrowIfNull(authoritySet); 87 ArgumentNullException.ThrowIfNull(quorumPolicy); 88 89 if (!signatureVerifier.Verify(run.Attestation, timestamp)) 90 { 91 return false; 92 } 93 94 if (!quorumValidator.HasQuorum(run.Attestation, authoritySet, quorumPolicy)) 95 { 96 return false; 97 } 98 99 Append(run, timestamp); 100 return true; 101 } 102 103 public bool Verify() 104 { 105 return VerifyEntries(_entries); 106 } 107 108 public LedgerHead GetHead() 109 { 110 if (_entries.Count == 0) 111 { 112 return new LedgerHead(-1, ZeroHash); 113 } 114 115 var head = _entries[^1]; 116 return new LedgerHead(head.Index, head.EntryHash); 117 } 118 119 public IReadOnlyList<RunLedgerEntry> GetEntriesFrom(long fromIndex, int maxCount = int.MaxValue) 120 { 121 if (maxCount < 0) 122 { 123 throw new ArgumentOutOfRangeException(nameof(maxCount), "maxCount must be non-negative."); 124 } 125 126 if (fromIndex < 0 || fromIndex >= _entries.Count) 127 { 128 return []; 129 } 130 131 var count = Math.Min(_entries.Count - (int)fromIndex, maxCount); 132 return _entries.GetRange((int)fromIndex, count).AsReadOnly(); 133 } 134 135 public bool TryAppendEntry(RunLedgerEntry entry) 136 { 137 ArgumentNullException.ThrowIfNull(entry); 138 139 if (entry.Run is null) 140 { 141 return false; 142 } 143 144 var expectedIndex = (long)_entries.Count; 145 if (entry.Index != expectedIndex) 146 { 147 return false; 148 } 149 150 if (entry.EntryHash.Length != HashSize || entry.PreviousHash.Length != HashSize) 151 { 152 return false; 153 } 154 155 var expectedPreviousHash = _entries.Count == 0 ? ZeroHash : _entries[^1].EntryHash; 156 if (!CryptographicOperations.FixedTimeEquals(entry.PreviousHash.AsSpan(), expectedPreviousHash.AsSpan())) 157 { 158 return false; 159 } 160 161 var recomputed = ComputeEntryHash(entry.Index, entry.PreviousHash, entry.Timestamp, entry.Run); 162 if (!CryptographicOperations.FixedTimeEquals(entry.EntryHash.AsSpan(), recomputed.AsSpan())) 163 { 164 return false; 165 } 166 167 _entries.Add(entry); 168 _merkleSkipIndex.Append(entry); 169 return true; 170 } 171 172 // Replaces an entry at the given index — internal for tamper-detection testing only. 173 internal void ReplaceEntryForTest(int index, RunLedgerEntry entry) 174 { 175 if (entry.Index != index) 176 { 177 throw new ArgumentException("Replacement entry index must match the target index.", nameof(entry)); 178 } 179 180 _entries[index] = entry; 181 _merkleSkipIndex.Update(entry); 182 } 183 184 internal void ReplaceEntriesFrom(long divergenceIndex, IReadOnlyList<RunLedgerEntry> entries, int startIndex = 0) 185 { 186 ArgumentNullException.ThrowIfNull(entries); 187 188 if (divergenceIndex < 0 || divergenceIndex > _entries.Count) 189 { 190 throw new ArgumentOutOfRangeException(nameof(divergenceIndex)); 191 } 192 193 if (startIndex < 0 || startIndex > entries.Count) 194 { 195 throw new ArgumentOutOfRangeException(nameof(startIndex)); 196 } 197 198 _entries.RemoveRange((int)divergenceIndex, _entries.Count - (int)divergenceIndex); 199 for (var i = startIndex; i < entries.Count; i++) 200 { 201 _entries.Add(entries[i]); 202 } 203 204 _merkleSkipIndex.Rebuild(_entries); 205 } 206 207 internal static bool VerifyEntries(IReadOnlyList<RunLedgerEntry> entries) 208 { 209 ArgumentNullException.ThrowIfNull(entries); 210 211 for (var i = 0; i < entries.Count; i++) 212 { 213 var entry = entries[i]; 214 215 if (entry.Index != i) 216 { 217 return false; 218 } 219 220 if (entry.Run is null || entry.EntryHash.Length != HashSize || entry.PreviousHash.Length != HashSize) 221 { 222 return false; 223 } 224 225 var recomputed = ComputeEntryHash(entry.Index, entry.PreviousHash, entry.Timestamp, entry.Run); 226 if (!CryptographicOperations.FixedTimeEquals(entry.EntryHash.AsSpan(), recomputed.AsSpan())) 227 { 228 return false; 229 } 230 231 var expectedPreviousHash = i == 0 ? ZeroHash : entries[i - 1].EntryHash; 232 if (expectedPreviousHash.Length != HashSize) 233 { 234 return false; 235 } 236 237 if (!CryptographicOperations.FixedTimeEquals(entry.PreviousHash.AsSpan(), expectedPreviousHash.AsSpan())) 238 { 239 return false; 240 } 241 } 242 243 return true; 244 } 245 246 internal static ImmutableArray<byte> ComputeEntryHash( 247 long index, 248 ImmutableArray<byte> previousHash, 249 DateTimeOffset timestamp, 250 RunValidationResult run) 251 { 252 // Fixed-width payload: int64 + 32 bytes + int64 + 3×16 bytes + 32 bytes = 144 bytes 253 var buffer = new byte[Int64Size + HashSize + Int64Size + GuidSize + GuidSize + GuidSize + HashSize]; 254 var offset = 0; 255 256 WriteInt64(index, buffer, ref offset); 257 WriteBytes(previousHash.AsSpan(), buffer, ref offset); 258 WriteInt64(timestamp.UtcTicks, buffer, ref offset); 259 WriteGuid(run.RunId, buffer, ref offset); 260 WriteGuid(run.PlayerId, buffer, ref offset); 261 WriteGuid(run.ServerId, buffer, ref offset); 262 WriteBytes(run.FinalStateHash, buffer, ref offset); 263 264 return ImmutableArray.Create(SHA256.HashData(buffer)); 265 } 266 267 private static void WriteInt64(long value, Span<byte> destination, ref int offset) 268 { 269 BinaryPrimitives.WriteInt64BigEndian(destination[offset..], value); 270 offset += Int64Size; 271 } 272 273 private static void WriteBytes(ReadOnlySpan<byte> value, Span<byte> destination, ref int offset) 274 { 275 value.CopyTo(destination[offset..]); 276 offset += value.Length; 277 } 278 279 private static void WriteGuid(Guid value, Span<byte> destination, ref int offset) 280 { 281 if (!value.TryWriteBytes(destination[offset..], bigEndian: true, out var bytesWritten) || bytesWritten != GuidSize) 282 { 283 throw new InvalidOperationException("Failed to write a 16-byte big-endian Guid into the ledger entry hash buffer."); 284 } 285 286 offset += bytesWritten; 287 } 288 289 private ImmutableArray<byte>? GetEntryHashAt(long index) 290 { 291 if (index < 0 || index >= _entries.Count) 292 { 293 return null; 294 } 295 296 return _entries[(int)index].EntryHash; 297 } 298 }