using System.Buffers.Binary; using System.Globalization; using System.Security.Cryptography; using System.Text; namespace DodoSSH.Domain.Sync; /// /// An opaque, integrity-tagged position in a vault's change log. /// /// /// /// Clients must never construct or modify one. The tag exists so a tampered cursor is /// rejected rather than silently mis-serving: an untagged cursor would let a caller /// rewind to sequence 0 and re-read everything, or skip forward and permanently miss changes /// while believing it was up to date. Silent data loss is far worse than an error. /// /// /// The cursor also carries its vault id, so a legitimately-issued cursor for one vault cannot be /// replayed against another. See ADR 0003. /// /// /// And the vault's sync horizon when it was issued — the last deletion the server had forgotten. A cursor /// behind the horizon is refused when it is older than the collection, because its holder may still have the /// deleted item; it is fine when it is newer, because then it belongs to a read from the beginning that /// started after the collection, whose early pages are behind the horizon by nature. Position alone cannot /// tell those two apart. A cursor issued under no horizon keeps the first format, so a server from before /// this still reads it. /// /// public static class SyncCursor { private const string Version = "v1"; /// The format that carries a horizon as well. private const string HorizonVersion = "v2"; private const char Separator = '|'; /// Length of the truncated HMAC tag. 128 bits is ample for a non-secret position. private const int TagLength = 16; /// Minimum signing key length. public const int MinimumKeyLength = 32; /// /// Encodes a position. /// /// Deployment cursor signing key, at least 32 bytes. /// Vault the cursor belongs to. /// Last change sequence the client has consumed. public static string Encode(ReadOnlySpan signingKey, Guid vaultId, long sequence) => Encode(signingKey, vaultId, sequence, horizon: 0); /// /// Encodes a position, with the sync horizon it was read under. /// /// Deployment cursor signing key, at least 32 bytes. /// Vault the cursor belongs to. /// Last change sequence the client has consumed. /// The vault's sync horizon before the read that produced this position. public static string Encode(ReadOnlySpan signingKey, Guid vaultId, long sequence, long horizon) { RequireKey(signingKey); if (sequence < 0) { throw new ArgumentOutOfRangeException(nameof(sequence), sequence, "Sequence must not be negative."); } if (horizon < 0) { throw new ArgumentOutOfRangeException(nameof(horizon), horizon, "Horizon must not be negative."); } var payload = Encoding.UTF8.GetBytes(FormatPayload(vaultId, sequence, horizon)); var buffer = new byte[payload.Length + TagLength]; payload.CopyTo(buffer, 0); ComputeTag(signingKey, payload, buffer.AsSpan(payload.Length, TagLength)); return Base64Url.Encode(buffer); } /// /// Decodes and verifies a cursor. /// /// /// Returns false for anything malformed, mis-tagged, or issued for a different vault. It never /// throws on bad input: cursors arrive from clients, so rejection is an expected outcome. /// /// Deployment cursor signing key. /// The cursor to verify. /// Vault the request is scoped to. /// The decoded sequence, when verification succeeds. public static bool TryDecode( ReadOnlySpan signingKey, string? cursor, Guid expectedVaultId, out long sequence) => TryDecode(signingKey, cursor, expectedVaultId, out sequence, out _); /// /// Decodes and verifies a cursor, with the horizon it was issued under — zero for one issued under none. /// /// Deployment cursor signing key. /// The cursor to verify. /// Vault the request is scoped to. /// The decoded sequence, when verification succeeds. /// The decoded horizon, when verification succeeds. public static bool TryDecode( ReadOnlySpan signingKey, string? cursor, Guid expectedVaultId, out long sequence, out long horizon) { RequireKey(signingKey); sequence = 0; horizon = 0; if (string.IsNullOrEmpty(cursor)) { return false; } if (!Base64Url.TryDecode(cursor, out var buffer) || buffer.Length <= TagLength) { return false; } var payload = buffer.AsSpan(0, buffer.Length - TagLength); var providedTag = buffer.AsSpan(buffer.Length - TagLength, TagLength); Span expectedTag = stackalloc byte[TagLength]; ComputeTag(signingKey, payload, expectedTag); // Constant-time: a timing oracle here would let an attacker forge a tag byte by byte. if (!CryptographicOperations.FixedTimeEquals(providedTag, expectedTag)) { return false; } return TryParsePayload(payload, expectedVaultId, out sequence, out horizon); } private static string FormatPayload(Guid vaultId, long sequence, long horizon) => horizon == 0 ? string.Create( CultureInfo.InvariantCulture, $"{Version}{Separator}{vaultId:D}{Separator}{sequence}") : string.Create( CultureInfo.InvariantCulture, $"{HorizonVersion}{Separator}{vaultId:D}{Separator}{sequence}{Separator}{horizon}"); private static bool TryParsePayload( ReadOnlySpan payload, Guid expectedVaultId, out long sequence, out long horizon) { sequence = 0; horizon = 0; string text; try { text = Encoding.UTF8.GetString(payload); } catch (DecoderFallbackException) { return false; } // Tagged, so the shape is ours — but parse defensively anyway, because a key rotation // could make an old format verify against a new expectation. var parts = text.Split(Separator); var expectedParts = parts[0] switch { Version => 3, HorizonVersion => 4, _ => 0, }; if (parts.Length != expectedParts) { return false; } if (!Guid.TryParseExact(parts[1], "D", out var vaultId) || vaultId != expectedVaultId) { return false; } if (!long.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out sequence)) { return false; } return expectedParts == 3 || long.TryParse(parts[3], NumberStyles.None, CultureInfo.InvariantCulture, out horizon); } private static void ComputeTag( ReadOnlySpan signingKey, ReadOnlySpan payload, Span destination) { Span full = stackalloc byte[HMACSHA256.HashSizeInBytes]; HMACSHA256.HashData(signingKey, payload, full); full[..TagLength].CopyTo(destination); } private static void RequireKey(ReadOnlySpan signingKey) { if (signingKey.Length < MinimumKeyLength) { throw new ArgumentException( $"Cursor signing key must be at least {MinimumKeyLength} bytes.", nameof(signingKey)); } } } /// Base64url without padding, as used in URLs and headers. internal static class Base64Url { internal static string Encode(ReadOnlySpan value) => Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_'); internal static bool TryDecode(string value, out byte[] result) { result = []; var normalised = value.Replace('-', '+').Replace('_', '/'); var padding = (4 - (normalised.Length % 4)) % 4; if (padding == 3) { // A length of 4n+1 cannot be valid base64. return false; } try { result = Convert.FromBase64String(normalised + new string('=', padding)); return true; } catch (FormatException) { return false; } } }