Public Access
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:
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user