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