Add sync engine: cursors, push/pull, and the advisory-lock ordering proof (M1)

The vault write path. Push is the only way items change — no per-entity POST/PUT/DELETE —
so one place enforces revisions, the change log and access control.

The concurrency hazard, now proven rather than asserted:
bigserial assigns sequence values when the INSERT runs, not at commit, so transaction A
can take sequence 5 while B takes 6 and commits first. A reader polling in between sees
only 6, advances past 5, and never learns about it. AdvisoryLockOrderingTests reproduces
that gap WITHOUT the lock first — otherwise the with-lock test proves nothing, since it
would pass just as happily if the interleaving never occurred — then shows
pg_advisory_xact_lock removes it, and that 12 concurrent writers produce no gaps.

Cursors are opaque and HMAC-tagged, and carry their vault id. 29 unit tests cover the
rejections, which are the point: an accepted-but-wrong cursor is silent data loss, strictly
worse than an error a client can resync from. Rejected: tampered tag, tampered payload,
foreign signing key, a legitimately-issued cursor from another vault, truncation, and
hostile input (never throws — cursors come from clients).

Push semantics:
- 200 even on partial failure, with per-operation status, so one stale item cannot block
  everything a client queued while offline.
- Conflict returns the server's current row for client-side three-way merge. The server
  cannot merge ciphertext, so never last-writer-wins.
- opId receipts make retries exactly-once per operation, not per batch — a client retrying
  a partially-overlapping batch after a timeout would otherwise double-apply what landed.
- A tombstone beats a late upsert, and delete clears hostname/port: leaving the address
  would keep the server able to resolve a host the user believes they deleted.
- Relay field validation mirrors the DB CHECK so a bad request is a clear Invalid rather
  than a constraint violation surfacing as a 500.

Authorization goes through IVaultAccessService, which returns the same answer for "absent"
and "forbidden" — distinguishing them is an existence oracle for other tenants' vault ids.
Team vaults are explicitly denied until M3 rather than falling through to a permissive
default. JIT provisioning keys on (issuer, subject), never email, and handles the
concurrent-first-request race via the unique index.

Renamed two domain types: Host -> SshHost, because Host collides with
Microsoft.Extensions.Hosting.Host in every file of a web project, and SyncChange ->
VaultChange to stop it colliding with the Contracts DTO of the same name. Aliasing at every
use site would have been permanent friction.

Worth noting: `ef migrations has-pending-model-changes` reported clean after those renames
even though the snapshot still said "DodoSSH.Domain.Host" — it diffs tables, not CLR type
names. The snapshot was regenerated and the emitted DDL diffed against the previous
artifacts/schema/v0.1.sql to confirm the rename produced no schema change.

Also removed ConfigureAwait(false) from test methods: xUnit1030 flags it as bypassing
parallelization limits, which is why MA0004 is suppressed in test projects.

Verified: 0 warnings on a clean rebuild, 146 tests pass (up from 122), format clean.

Endpoint-level tests are the immediate next step: they need a WireMock OIDC/JWKS stub and
real JWT minting, so the "wrong user is denied" matrix does not exist yet for these two
routes. The service-layer authorization and the concurrency property are covered.
This commit is contained in:
2026-07-28 15:02:02 +02:00
parent d3b14e6bc0
commit 3829217e8a
23 changed files with 1931 additions and 290 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ namespace DodoSSH.Domain;
/// constraint enforces the pairing so it cannot drift. See ADR 0004.
/// </para>
/// </remarks>
public sealed class Host
public sealed class SshHost
{
/// <summary>Primary key. UUIDv7, generated by the client so items can be created offline.</summary>
public Guid Id { get; set; }
+193
View File
@@ -0,0 +1,193 @@
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>
/// </remarks>
public static class SyncCursor
{
private const string Version = "v1";
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)
{
RequireKey(signingKey);
if (sequence < 0)
{
throw new ArgumentOutOfRangeException(nameof(sequence), sequence, "Sequence must not be negative.");
}
var payload = Encoding.UTF8.GetBytes(FormatPayload(vaultId, sequence));
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)
{
RequireKey(signingKey);
sequence = 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);
}
private static string FormatPayload(Guid vaultId, long sequence) =>
string.Create(
CultureInfo.InvariantCulture,
$"{Version}{Separator}{vaultId:D}{Separator}{sequence}");
private static bool TryParsePayload(ReadOnlySpan<byte> payload, Guid expectedVaultId, out long sequence)
{
sequence = 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);
if (parts.Length != 3)
{
return false;
}
if (!string.Equals(parts[0], Version, StringComparison.Ordinal))
{
return false;
}
if (!Guid.TryParseExact(parts[1], "D", out var vaultId) || vaultId != expectedVaultId)
{
return false;
}
return long.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out sequence)
&& sequence >= 0;
}
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;
}
}
}
+2 -2
View File
@@ -20,7 +20,7 @@ public enum ChangeEntityType
Unspecified = 0,
/// <summary>An SSH host.</summary>
Host = 1,
SshHost = 1,
/// <summary>A credential. M2.</summary>
Credential = 2,
@@ -65,7 +65,7 @@ public enum ChangeEntityType
/// statement, so sequence order equals commit order. See ADR 0003.
/// </para>
/// </remarks>
public sealed class SyncChange
public sealed class VaultChange
{
/// <summary>Monotonic sequence. Database-generated.</summary>
public long Sequence { get; set; }
+1 -1
View File
@@ -56,7 +56,7 @@ public sealed class Vault
public ICollection<VaultKeyGrant> KeyGrants { get; } = [];
/// <summary>Hosts in this vault.</summary>
public ICollection<Host> Hosts { get; } = [];
public ICollection<SshHost> Hosts { get; } = [];
}
/// <summary>