Public Access
A cursor behind the horizon was refused on position alone. But the read a refused client starts from the beginning is itself behind the horizon on every page until it passes the collected deletion, since older untouched items come first. So its second page was refused, it started over, and it never finished, on any vault larger than one page. The cursor now records the horizon read before the page that produced it, and only a cursor issued under an older horizon is refused. A cursor issued under no horizon keeps the v1 format, so a server from before this still reads it.
253 lines
9.0 KiB
C#
253 lines
9.0 KiB
C#
using System.Buffers.Binary;
|
|
using System.Globalization;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
|
|
namespace DodoSSH.Domain.Sync;
|
|
|
|
/// <summary>
|
|
/// An opaque, integrity-tagged position in a vault's change log.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Clients must never construct or modify one. The tag exists so a tampered cursor is
|
|
/// <em>rejected</em> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// The cursor also carries its vault id, so a legitimately-issued cursor for one vault cannot be
|
|
/// replayed against another. See ADR 0003.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static class SyncCursor
|
|
{
|
|
private const string Version = "v1";
|
|
|
|
/// <summary>The format that carries a horizon as well.</summary>
|
|
private const string HorizonVersion = "v2";
|
|
private const char Separator = '|';
|
|
|
|
/// <summary>Length of the truncated HMAC tag. 128 bits is ample for a non-secret position.</summary>
|
|
private const int TagLength = 16;
|
|
|
|
/// <summary>Minimum signing key length.</summary>
|
|
public const int MinimumKeyLength = 32;
|
|
|
|
/// <summary>
|
|
/// Encodes a position.
|
|
/// </summary>
|
|
/// <param name="signingKey">Deployment cursor signing key, at least 32 bytes.</param>
|
|
/// <param name="vaultId">Vault the cursor belongs to.</param>
|
|
/// <param name="sequence">Last change sequence the client has consumed.</param>
|
|
public static string Encode(ReadOnlySpan<byte> signingKey, Guid vaultId, long sequence) =>
|
|
Encode(signingKey, vaultId, sequence, horizon: 0);
|
|
|
|
/// <summary>
|
|
/// Encodes a position, with the sync horizon it was read under.
|
|
/// </summary>
|
|
/// <param name="signingKey">Deployment cursor signing key, at least 32 bytes.</param>
|
|
/// <param name="vaultId">Vault the cursor belongs to.</param>
|
|
/// <param name="sequence">Last change sequence the client has consumed.</param>
|
|
/// <param name="horizon">The vault's sync horizon before the read that produced this position.</param>
|
|
public static string Encode(ReadOnlySpan<byte> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Decodes and verifies a cursor.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
/// <param name="signingKey">Deployment cursor signing key.</param>
|
|
/// <param name="cursor">The cursor to verify.</param>
|
|
/// <param name="expectedVaultId">Vault the request is scoped to.</param>
|
|
/// <param name="sequence">The decoded sequence, when verification succeeds.</param>
|
|
public static bool TryDecode(
|
|
ReadOnlySpan<byte> signingKey,
|
|
string? cursor,
|
|
Guid expectedVaultId,
|
|
out long sequence) =>
|
|
TryDecode(signingKey, cursor, expectedVaultId, out sequence, out _);
|
|
|
|
/// <summary>
|
|
/// Decodes and verifies a cursor, with the horizon it was issued under — zero for one issued under none.
|
|
/// </summary>
|
|
/// <param name="signingKey">Deployment cursor signing key.</param>
|
|
/// <param name="cursor">The cursor to verify.</param>
|
|
/// <param name="expectedVaultId">Vault the request is scoped to.</param>
|
|
/// <param name="sequence">The decoded sequence, when verification succeeds.</param>
|
|
/// <param name="horizon">The decoded horizon, when verification succeeds.</param>
|
|
public static bool TryDecode(
|
|
ReadOnlySpan<byte> 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<byte> 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<byte> 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<byte> signingKey,
|
|
ReadOnlySpan<byte> payload,
|
|
Span<byte> destination)
|
|
{
|
|
Span<byte> full = stackalloc byte[HMACSHA256.HashSizeInBytes];
|
|
HMACSHA256.HashData(signingKey, payload, full);
|
|
full[..TagLength].CopyTo(destination);
|
|
}
|
|
|
|
private static void RequireKey(ReadOnlySpan<byte> signingKey)
|
|
{
|
|
if (signingKey.Length < MinimumKeyLength)
|
|
{
|
|
throw new ArgumentException(
|
|
$"Cursor signing key must be at least {MinimumKeyLength} bytes.",
|
|
nameof(signingKey));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Base64url without padding, as used in URLs and headers.</summary>
|
|
internal static class Base64Url
|
|
{
|
|
internal static string Encode(ReadOnlySpan<byte> 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;
|
|
}
|
|
}
|
|
}
|