Public Access
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.
134 lines
4.8 KiB
C#
134 lines
4.8 KiB
C#
namespace DodoSSH.Domain;
|
|
|
|
/// <summary>
|
|
/// A container of encrypted items sharing one vault key.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The vault name is plaintext, unlike item names. A user has to be able to pick a vault before
|
|
/// anything is decrypted, and vault names are few and low-signal compared with a full host
|
|
/// inventory.
|
|
/// </remarks>
|
|
public sealed class Vault
|
|
{
|
|
/// <summary>Primary key.</summary>
|
|
public Guid Id { get; set; }
|
|
|
|
/// <summary>Display name.</summary>
|
|
public string Name { get; set; } = string.Empty;
|
|
|
|
/// <summary>Whether this belongs to a user or a team.</summary>
|
|
public VaultOwnerKind OwnerKind { get; set; }
|
|
|
|
/// <summary>Owning user, for a personal vault.</summary>
|
|
public Guid? OwnerUserId { get; set; }
|
|
|
|
/// <summary>Owning user, for a personal vault.</summary>
|
|
public UserAccount? OwnerUser { get; set; }
|
|
|
|
/// <summary>Owning team, for a team vault.</summary>
|
|
public Guid? TeamId { get; set; }
|
|
|
|
/// <summary>Owning team, for a team vault.</summary>
|
|
public Team? Team { get; set; }
|
|
|
|
/// <summary>
|
|
/// Current key generation. Bumped on rekey, and part of every item's AAD, so a server cannot
|
|
/// roll a row back to a superseded generation.
|
|
/// </summary>
|
|
public int KeyGeneration { get; set; } = 1;
|
|
|
|
/// <summary>Whether a membership or key change has left this vault needing a rekey.</summary>
|
|
public bool RekeyRequired { get; set; }
|
|
|
|
/// <summary>Why a rekey is pending.</summary>
|
|
public RekeyReason RekeyReason { get; set; }
|
|
|
|
/// <summary>Creation timestamp.</summary>
|
|
public DateTimeOffset CreatedAtUtc { get; set; }
|
|
|
|
/// <summary>Last modification timestamp.</summary>
|
|
public DateTimeOffset UpdatedAtUtc { get; set; }
|
|
|
|
/// <summary>Soft-delete marker.</summary>
|
|
public DateTimeOffset? DeletedAtUtc { get; set; }
|
|
|
|
/// <summary>Wrapped vault keys, one per recipient per generation.</summary>
|
|
public ICollection<VaultKeyGrant> KeyGrants { get; } = [];
|
|
|
|
/// <summary>Hosts in this vault.</summary>
|
|
public ICollection<SshHost> Hosts { get; } = [];
|
|
}
|
|
|
|
/// <summary>
|
|
/// A vault key wrapped to one recipient, for one key generation.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The server stores <see cref="WrappedKey"/> verbatim and cannot verify that it is the correct
|
|
/// vault key. A malicious granter can seal garbage; the recipient detects it on first unwrap as a
|
|
/// tag failure, and <see cref="Signature"/> names who did it. Detectable and attributable is the
|
|
/// right failure mode here — silent is not achievable, since verification would require the
|
|
/// server to hold the key.
|
|
/// </para>
|
|
/// <para>
|
|
/// <see cref="GranterKeyFingerprint"/> and <see cref="KeyLogHead"/> are recorded so a recipient
|
|
/// can check both who wrapped this and what view of the key log they held at the time.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class VaultKeyGrant
|
|
{
|
|
/// <summary>Primary key.</summary>
|
|
public Guid Id { get; set; }
|
|
|
|
/// <summary>The vault.</summary>
|
|
public Guid VaultId { get; set; }
|
|
|
|
/// <summary>The vault.</summary>
|
|
public Vault? Vault { get; set; }
|
|
|
|
/// <summary>Key generation this grant is for.</summary>
|
|
public int KeyGeneration { get; set; }
|
|
|
|
/// <summary>Why this grant exists: a member, a recovery key, or escrow.</summary>
|
|
public GrantKind Kind { get; set; }
|
|
|
|
/// <summary>Recipient, for a member grant.</summary>
|
|
public Guid? RecipientUserId { get; set; }
|
|
|
|
/// <summary>Recipient, for a member grant.</summary>
|
|
public UserAccount? RecipientUser { get; set; }
|
|
|
|
/// <summary>
|
|
/// Fingerprint of the exact public key this was wrapped to, so a later key rotation
|
|
/// invalidates the grant explicitly rather than silently.
|
|
/// </summary>
|
|
public byte[] RecipientKeyFingerprint { get; set; } = [];
|
|
|
|
/// <summary>The vault key, sealed to the recipient. Opaque.</summary>
|
|
public byte[] WrappedKey { get; set; } = [];
|
|
|
|
/// <summary>Who created this grant.</summary>
|
|
public Guid GranterUserId { get; set; }
|
|
|
|
/// <summary>Fingerprint of the granter's identity key.</summary>
|
|
public byte[] GranterKeyFingerprint { get; set; } = [];
|
|
|
|
/// <summary>Key log head the granter observed. Enables fork detection.</summary>
|
|
public byte[]? KeyLogHead { get; set; }
|
|
|
|
/// <summary>
|
|
/// Ed25519 signature by the granter over the canonical grant tuple. Verified by clients, not
|
|
/// by the server: server-side verification would be a convenience, never the boundary.
|
|
/// </summary>
|
|
public byte[] Signature { get; set; } = [];
|
|
|
|
/// <summary>Grant state.</summary>
|
|
public GrantState State { get; set; }
|
|
|
|
/// <summary>Creation timestamp.</summary>
|
|
public DateTimeOffset CreatedAtUtc { get; set; }
|
|
|
|
/// <summary>Revocation timestamp.</summary>
|
|
public DateTimeOffset? RevokedAtUtc { get; set; }
|
|
}
|