Server: a cursor carries the sync horizon it was issued under, so a read from the beginning after a collection is not refused on its own pages

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.
This commit is contained in:
jaap-jan committed 2026-10-02 17:36:03 +02:00
1 parent 8cb1457ca9
commit 55e5de0ce4
4 files changed
+213 -37

No files matched your search

+74 -15
View File
@@ -19,10 +19,21 @@ namespace DodoSSH.Domain.Sync;
/// 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>
@@ -37,7 +48,17 @@ public static class SyncCursor
/// <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)
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);
@@ -46,7 +67,12 @@ public static class SyncCursor
throw new ArgumentOutOfRangeException(nameof(sequence), sequence, "Sequence must not be negative.");
}
var payload = Encoding.UTF8.GetBytes(FormatPayload(vaultId, sequence));
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);
@@ -70,10 +96,27 @@ public static class SyncCursor
ReadOnlySpan<byte> signingKey,
string? cursor,
Guid expectedVaultId,
out long sequence)
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))
{
@@ -97,17 +140,26 @@ public static class SyncCursor
return false;
}
return TryParsePayload(payload, expectedVaultId, out sequence);
return TryParsePayload(payload, expectedVaultId, out sequence, out horizon);
}
private static string FormatPayload(Guid vaultId, long sequence) =>
string.Create(
CultureInfo.InvariantCulture,
$"{Version}{Separator}{vaultId:D}{Separator}{sequence}");
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)
private static bool TryParsePayload(
ReadOnlySpan<byte> payload,
Guid expectedVaultId,
out long sequence,
out long horizon)
{
sequence = 0;
horizon = 0;
string text;
try
@@ -122,12 +174,14 @@ public static class SyncCursor
// 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);
if (parts.Length != 3)
var expectedParts = parts[0] switch
{
return false;
}
Version => 3,
HorizonVersion => 4,
_ => 0,
};
if (!string.Equals(parts[0], Version, StringComparison.Ordinal))
if (parts.Length != expectedParts)
{
return false;
}
@@ -137,8 +191,13 @@ public static class SyncCursor
return false;
}
return long.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out sequence)
&& sequence >= 0;
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(