Files
DodoSSH/src/DodoSSH.Domain/Hosts.cs
T
jaap-jan b08f2c58e3
ci / build and test (pull_request) Failing after 3m42s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Skipped
ci / android head (pull_request) Successful in 5m24s
Fill in the Spelunk API key on the Security page, as a synced Spelunk key that goes only to its own address
Settings → Security gains a SPELUNK section: an API address and a key, saved as a new keychain item, a Spelunk key (SyncEntityType 14, AadResourceType 17, server table spelunk_key via the AddSpelunkKeyItem migration). It is ciphertext only on the server, lives in the personal vault, syncs like every secret, and holds the key together with the one address it may be sent to. The key box empties once saved; the list shows name, address and how many hosts use each key, never the key. Saving a key for an address that already has one replaces it, and deleting goes through the shared confirmation card with the hosts that would stop connecting.

A host's route names a saved key where it named a credential, in the same apiKeyCredentialId field, so no host schema changes and an older build reads such a host as one whose key credential has gone. At connect a saved key is sent only to its own address; a host naming it with another Spelunk address is refused in words. Keys kept as a credential's password still work. The editor offers saved keys first and picks the one saved for an address as it is typed.

A link from a Spelunk with a saved key now connects straight away, including the first one: saving the key is the user's say-so that the first-link editor used to ask for.

The address and key merge as one pair, so two concurrent edits can never pair one device's key with another's address, and the key never reaches the conflict log.
2026-09-29 10:34:38 +02:00

830 lines
37 KiB
C#

namespace DodoSSH.Domain;
/// <summary>
/// An SSH host.
/// </summary>
/// <remarks>
/// <para>
/// The only vault item type in M1. Everything sensitive — username, notes, jump chain, SSH
/// options — lives inside <see cref="Payload"/>. There is deliberately no plaintext label:
/// access-control administration happens in the client, which can decrypt names, so the server
/// never needs a searchable title.
/// </para>
/// <para>
/// <see cref="Hostname"/> and <see cref="Port"/> are the one deliberate plaintext concession, and
/// only when <see cref="RelayEnabled"/> is set. The relay must resolve its target server-side or
/// it becomes an authenticated open TCP proxy into the operator's own network. A database CHECK
/// constraint enforces the pairing so it cannot drift. See ADR 0004.
/// </para>
/// <para>
/// <b>There is no group column, and there used to be one.</b> A <c>group_id</c> was reserved here from the
/// first migration and never written by any shipped client; it was dropped when groups actually landed,
/// because membership belongs inside <see cref="Payload"/>. The test ADR 0004 sets for a plaintext
/// concession is that the server <em>cannot function</em> without the value, and nothing on the server reads
/// a group — the client decrypts its own vault to draw the sidebar. What the column would have handed over
/// is a clustering of the estate: which machines this user files together, for free, in the clear. See
/// <see cref="VaultHostGroup"/>.
/// </para>
/// </remarks>
public sealed class SshHost : IVaultItem
{
/// <summary>Primary key. UUIDv7, generated by the client so items can be created offline.</summary>
public Guid Id { get; set; }
/// <summary>Owning vault.</summary>
public Guid VaultId { get; set; }
/// <summary>Owning vault.</summary>
public Vault? Vault { get; set; }
/// <summary>The encrypted item: a DSH1 envelope. Opaque to the server.</summary>
public byte[] Payload { get; set; } = [];
/// <summary>The item's data key, wrapped under the vault key. Opaque.</summary>
public byte[]? DataKeyWrap { get; set; }
/// <summary>
/// Reserved for per-item content keys wrapped to individual users, which is what will make
/// per-item access control cryptographic rather than server-enforced. Present from the first
/// migration so that lands without a migration; see docs/crypto.md §3.
/// </summary>
public Guid? ContentKeyId { get; set; }
/// <summary>Vault key generation this payload was encrypted under.</summary>
public int KeyGeneration { get; set; }
/// <summary>AAD rule version, enabling a lazy re-encrypt-on-write migration later.</summary>
public short PayloadAadVersion { get; set; }
/// <summary>Whether this host may be dialled through the server relay.</summary>
public bool RelayEnabled { get; set; }
/// <summary>Target hostname. Permitted only when <see cref="RelayEnabled"/> is set.</summary>
public string? Hostname { get; set; }
/// <summary>Target port. Permitted only when <see cref="RelayEnabled"/> is set.</summary>
public int? Port { get; set; }
/// <summary>
/// Client-visible, monotonic item version. Used for optimistic concurrency on push, and
/// deliberately distinct from the internal <c>xmin</c> guard, which is never exposed because
/// it is not stable across VACUUM FREEZE.
/// </summary>
public int Version { get; set; }
/// <summary>Latest change-log sequence touching this row, so a delta pull can join directly.</summary>
public long ChangeSequence { 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. Deletes are tombstones: a client that has been offline must be able to
/// learn an item went away, and a vanished row is indistinguishable from one never seen.
/// </summary>
public DateTimeOffset? DeletedAtUtc { get; set; }
/// <summary>Who created it.</summary>
public Guid CreatedByUserId { get; set; }
/// <summary>Who last modified it.</summary>
public Guid UpdatedByUserId { get; set; }
}
/// <summary>
/// An SSH key pair, held as ciphertext.
/// </summary>
/// <remarks>
/// <para>
/// The private key, its passphrase, its label and its comment are all inside <see cref="Payload"/>. This
/// row is the reason the vault is worth having — a key that syncs between a user's machines and can later be
/// shared with a teammate — and it is also the row that would hurt most if the server could read it, so
/// there is deliberately not one plaintext column of substance.
/// </para>
/// <para>
/// Notably absent: the relay trio. A key is not something the server dials, so the plaintext concession
/// ADR 0004 makes for <see cref="SshHost"/> has no analogue here and no reason to exist. That is the whole
/// argument for a separate table rather than one wide item table: the columns a host needs are columns a key
/// must never have, and a shared table could only make them nullable and trust the code.
/// </para>
/// <para>
/// <see cref="PublicKeyFingerprint"/> is the single exception, and it stays null until something needs it.
/// The contract reserved the slot for showing which key a host is configured to use without decrypting
/// every key first; write it only when that feature lands, and never derive anything security-relevant from
/// it, because a fingerprint the server stores is a fingerprint the server chose.
/// </para>
/// </remarks>
public sealed class VaultSshKey : IVaultItem
{
/// <summary>Primary key. UUIDv7, generated by the client so keys can be created offline.</summary>
public Guid Id { get; set; }
/// <summary>Owning vault.</summary>
public Guid VaultId { get; set; }
/// <summary>Owning vault.</summary>
public Vault? Vault { get; set; }
/// <summary>The encrypted key material: a DSH1 envelope. Opaque to the server.</summary>
public byte[] Payload { get; set; } = [];
/// <summary>The item's data key, wrapped under the vault key. Opaque.</summary>
public byte[]? DataKeyWrap { get; set; }
/// <summary>Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3.</summary>
public Guid? ContentKeyId { get; set; }
/// <summary>Vault key generation this payload was encrypted under.</summary>
public int KeyGeneration { get; set; }
/// <summary>AAD rule version, enabling a lazy re-encrypt-on-write migration later.</summary>
public short PayloadAadVersion { get; set; }
/// <summary>
/// OpenSSH-style <c>SHA256:base64</c> fingerprint of the public half, when a client publishes it.
/// </summary>
/// <remarks>
/// Plaintext, and therefore opt-in and currently unused. A public key fingerprint is not a secret, but
/// it is an identifier that links a vault to a machine's <c>authorized_keys</c>, so it is stored only
/// when a feature needs it rather than because it is harmless.
/// </remarks>
public string? PublicKeyFingerprint { get; set; }
/// <summary>Client-visible, monotonic item version, used for <c>expectedVersion</c> checks.</summary>
public int Version { get; set; }
/// <summary>Latest change-log sequence touching this row, so a delta pull can join directly.</summary>
public long ChangeSequence { 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; a tombstone, so an offline client learns the key went away.</summary>
public DateTimeOffset? DeletedAtUtc { get; set; }
/// <summary>Who created it.</summary>
public Guid CreatedByUserId { get; set; }
/// <summary>Who last modified it.</summary>
public Guid UpdatedByUserId { get; set; }
}
/// <summary>
/// A stored username and password, as ciphertext.
/// </summary>
/// <remarks>
/// <para>
/// Its own table for the same reason <see cref="VaultSshKey"/> is: the columns a host needs are columns a
/// credential must never have. There is no relay trio here, and unlike a key there is not even a fingerprint
/// — nothing about a password is safe to hold in the clear, not its length, not a hash, not a hint. So this
/// row is an opaque envelope and its bookkeeping, and that is the whole design.
/// </para>
/// <para>
/// <b>The server cannot enforce anything about the contents, and should not pretend to.</b> Whether a
/// credential has a username, whether its password is empty, whether it is still valid — all of that is
/// inside the payload and belongs to the client. The one thing this row asserts is that the ciphertext
/// belongs to a vault and carries a version, which is what the write path needs to order changes.
/// </para>
/// </remarks>
public sealed class VaultCredential : IVaultItem
{
/// <summary>Primary key. UUIDv7, generated by the client so credentials can be created offline.</summary>
public Guid Id { get; set; }
/// <summary>Owning vault.</summary>
public Guid VaultId { get; set; }
/// <summary>Owning vault.</summary>
public Vault? Vault { get; set; }
/// <summary>The encrypted credential: a DSH1 envelope. Opaque to the server.</summary>
public byte[] Payload { get; set; } = [];
/// <summary>The item's data key, wrapped under the vault key. Opaque.</summary>
public byte[]? DataKeyWrap { get; set; }
/// <summary>Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3.</summary>
public Guid? ContentKeyId { get; set; }
/// <summary>Vault key generation this payload was encrypted under.</summary>
public int KeyGeneration { get; set; }
/// <summary>AAD rule version, enabling a lazy re-encrypt-on-write migration later.</summary>
public short PayloadAadVersion { get; set; }
/// <summary>Client-visible, monotonic item version, used for <c>expectedVersion</c> checks.</summary>
public int Version { get; set; }
/// <summary>Latest change-log sequence touching this row, so a delta pull can join directly.</summary>
public long ChangeSequence { 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; a tombstone, so an offline client learns the credential went away.</summary>
public DateTimeOffset? DeletedAtUtc { get; set; }
/// <summary>Who created it.</summary>
public Guid CreatedByUserId { get; set; }
/// <summary>Who last modified it.</summary>
public Guid UpdatedByUserId { get; set; }
}
/// <summary>
/// One SSH host key a user has decided to trust, as ciphertext.
/// </summary>
/// <remarks>
/// <para>
/// The host, the port, the key algorithm and the fingerprint are all inside <see cref="Payload"/>, and this
/// is the item type where that is least obvious and most deliberate. None of those four values is
/// confidential on its own — an operator publishes the fingerprint so it can be checked, and a
/// relay-enabled host's address is already in a plaintext column next door. Together and across a user's
/// whole vault they are something else: which machines that person reaches, and when they first reached
/// them. The server has no use for any of it, so it gets none of it, and there is no column here to put it
/// in even by mistake.
/// </para>
/// <para>
/// <b>Why the server holds these at all, given it cannot read them.</b> Trust-on-first-use is only as good
/// as its memory. Kept locally, a pin dies with the machine and the user is asked to approve the same host
/// on every device — training them to approve without looking, which is the one habit that makes the
/// warning worthless. Kept here, trust follows the user, and because the payload is sealed the server
/// cannot drop a pin to force a fresh first-use decision without the client noticing the item is gone.
/// </para>
/// <para>
/// Otherwise this is <see cref="VaultCredential"/>'s shape exactly: an opaque envelope and the bookkeeping
/// the write path needs to order changes. Its own table for the same reason — the columns a host needs are
/// columns this must never have.
/// </para>
/// </remarks>
public sealed class VaultKnownHostKey : IVaultItem
{
/// <summary>Primary key. UUIDv7, generated by the client so a pin can be recorded offline.</summary>
public Guid Id { get; set; }
/// <summary>Owning vault.</summary>
public Guid VaultId { get; set; }
/// <summary>Owning vault.</summary>
public Vault? Vault { get; set; }
/// <summary>The encrypted pin: a DSH1 envelope. Opaque to the server.</summary>
public byte[] Payload { get; set; } = [];
/// <summary>The item's data key, wrapped under the vault key. Opaque.</summary>
public byte[]? DataKeyWrap { get; set; }
/// <summary>Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3.</summary>
public Guid? ContentKeyId { get; set; }
/// <summary>Vault key generation this payload was encrypted under.</summary>
public int KeyGeneration { get; set; }
/// <summary>AAD rule version, enabling a lazy re-encrypt-on-write migration later.</summary>
public short PayloadAadVersion { get; set; }
/// <summary>Client-visible, monotonic item version, used for <c>expectedVersion</c> checks.</summary>
public int Version { get; set; }
/// <summary>Latest change-log sequence touching this row, so a delta pull can join directly.</summary>
public long ChangeSequence { 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; a tombstone, so an offline client learns the pin was withdrawn.
/// </summary>
/// <remarks>
/// A tombstone matters more here than for the other types. Withdrawing trust is the deliberate act a
/// user performs when a server has legitimately been rebuilt, and a pin that simply vanished from one
/// machine's view would come back on the next pull — leaving the rebuilt host permanently unreachable
/// from the machine that had the old key.
/// </remarks>
public DateTimeOffset? DeletedAtUtc { get; set; }
/// <summary>Who created it.</summary>
public Guid CreatedByUserId { get; set; }
/// <summary>Who last modified it.</summary>
public Guid UpdatedByUserId { get; set; }
}
/// <summary>
/// A folder hosts can be filed under, as ciphertext.
/// </summary>
/// <remarks>
/// <para>
/// Everything a group is — its name, the group it sits under, and the port, username and binding hosts
/// inside it fall back to — is inside the envelope, so this row is one of the narrowest in the schema: an
/// envelope and its bookkeeping. There is no <c>parent_id</c> and no <c>name</c> column, and both absences
/// are deliberate.
/// </para>
/// <para>
/// <b>No parent column, although groups do nest.</b> The pointer exists; it lives in the payload, and the
/// push path refuses a plaintext <c>ParentId</c> outright. What a column here would hand the operator is the
/// shape of every user's estate — how many groupings, how deep, which under which — which is the same
/// disclosure a plaintext <c>group_id</c> on a host would have been, and ADR 0004 spends the one plaintext
/// concession this design allows on the relay address instead.
/// </para>
/// <para>
/// The consequence is that the server cannot see a cycle, and it was once the argument for having no parent
/// at all: two offline clients can each re-parent A under B and B under A, a scalar merge accepts both, and
/// nothing server-side can refuse the pair. That is still true and is now handled rather than avoided —
/// every walk of the chain carries a visited set and stops at a repeat, so a cycle degrades to a group that
/// reads as a root. See <c>HostGroupSecret</c> on the client, which is the only thing that can read any of
/// this.
/// </para>
/// <para>
/// <b>No name, for the reason <see cref="VaultKnownHostKey"/> has no host column.</b> A group name is not
/// confidential the way a password is, but the set of names one person files their machines under is a
/// description of the estate — "customer-a", "pci", "on-call" — and the server has no use for any of it. It
/// sorts nothing; the client decrypts its own vault to draw a list.
/// </para>
/// <para>
/// <b>Membership lives on the host, not here.</b> The alternative — a member id list in this payload — would
/// make adding one host to a group a write to the group, so two clients adding two different hosts at once
/// would collide on one item. <c>ThreeWayMerge</c> has no set merge, so that collision would resolve by one
/// side winning and the other host quietly leaving the group. One pointer per host makes each of those two
/// operations a write to a different item, which cannot collide at all.
/// </para>
/// </remarks>
public sealed class VaultHostGroup : IVaultItem
{
/// <summary>Primary key. UUIDv7, generated by the client so a group can be created offline.</summary>
public Guid Id { get; set; }
/// <summary>Owning vault.</summary>
public Guid VaultId { get; set; }
/// <summary>Owning vault.</summary>
public Vault? Vault { get; set; }
/// <summary>The encrypted group: a DSH1 envelope. Opaque to the server.</summary>
public byte[] Payload { get; set; } = [];
/// <summary>The item's data key, wrapped under the vault key. Opaque.</summary>
public byte[]? DataKeyWrap { get; set; }
/// <summary>Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3.</summary>
public Guid? ContentKeyId { get; set; }
/// <summary>Vault key generation this payload was encrypted under.</summary>
public int KeyGeneration { get; set; }
/// <summary>AAD rule version, enabling a lazy re-encrypt-on-write migration later.</summary>
public short PayloadAadVersion { get; set; }
/// <summary>Client-visible, monotonic item version, used for <c>expectedVersion</c> checks.</summary>
public int Version { get; set; }
/// <summary>Latest change-log sequence touching this row, so a delta pull can join directly.</summary>
public long ChangeSequence { 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; a tombstone, so an offline client learns the group went away.
/// </summary>
/// <remarks>
/// Deleting a group leaves every host that named it holding an id that resolves to nothing, and that is
/// the intended outcome rather than an oversight: those hosts fall back to the ungrouped heading. The
/// alternative is rewriting N host payloads inside one delete, which turns a single user action into N
/// pushes, N outbox rows and N chances to merge against a change nobody made.
/// </remarks>
public DateTimeOffset? DeletedAtUtc { get; set; }
/// <summary>Who created it.</summary>
public Guid CreatedByUserId { get; set; }
/// <summary>Who last modified it.</summary>
public Guid UpdatedByUserId { get; set; }
}
/// <summary>
/// A label that can be put on many hosts, as ciphertext.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="VaultHostGroup"/>'s shape exactly — an envelope and its bookkeeping — and the reasoning for the
/// missing <c>name</c> column is the same argument at higher stakes. A group name describes how one user
/// files their machines; a tag name describes what the machines <em>are</em>, and one tag spans twenty hosts
/// by design. A column here would give the operator a labelled map of every estate on the server, to sort a
/// list nothing server-side draws.
/// </para>
/// <para>
/// <b>No membership, and no join table either.</b> Which hosts wear this tag is a set of ids inside each
/// host's payload. <c>SyncEntityType.HostTag</c> reserves a slot for the join and it stays reserved: the one
/// thing a join buys over a set on the host — two people tagging the same host without one of them losing —
/// is already bought by the client's per-key three-way merge, and a second item type is a second table, a
/// second migration and a second pass through every rule in this file for nothing.
/// </para>
/// </remarks>
public sealed class VaultTag : IVaultItem
{
/// <summary>Primary key. UUIDv7, generated by the client so a tag can be created offline.</summary>
public Guid Id { get; set; }
/// <summary>Owning vault.</summary>
public Guid VaultId { get; set; }
/// <summary>Owning vault.</summary>
public Vault? Vault { get; set; }
/// <summary>The encrypted tag: a DSH1 envelope. Opaque to the server.</summary>
public byte[] Payload { get; set; } = [];
/// <summary>The item's data key, wrapped under the vault key. Opaque.</summary>
public byte[]? DataKeyWrap { get; set; }
/// <summary>Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3.</summary>
public Guid? ContentKeyId { get; set; }
/// <summary>Vault key generation this payload was encrypted under.</summary>
public int KeyGeneration { get; set; }
/// <summary>AAD rule version, enabling a lazy re-encrypt-on-write migration later.</summary>
public short PayloadAadVersion { get; set; }
/// <summary>Client-visible, monotonic item version, used for <c>expectedVersion</c> checks.</summary>
public int Version { get; set; }
/// <summary>Latest change-log sequence touching this row, so a delta pull can join directly.</summary>
public long ChangeSequence { 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; a tombstone, so an offline client learns the tag went away.
/// </summary>
/// <remarks>
/// Deleting a tag leaves every host wearing it holding an id that resolves to nothing, and that is the
/// intended outcome: those hosts simply stop drawing that chip. Rewriting N host payloads inside one
/// delete would turn a single user action into N pushes, and each of those is a read-modify-write of a
/// set that can lose a merge — which would leave the vault holding a tag that is deleted and still worn.
/// </remarks>
public DateTimeOffset? DeletedAtUtc { get; set; }
/// <summary>Who created it.</summary>
public Guid CreatedByUserId { get; set; }
/// <summary>Who last modified it.</summary>
public Guid UpdatedByUserId { get; set; }
}
/// <summary>
/// An S3-compatible bucket and the credentials that reach it, as ciphertext.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="VaultCredential"/>'s shape, and it holds the same class of thing: a secret access key is a
/// password by another name, and everything around it — the endpoint, the region, the bucket — describes
/// somewhere the user keeps data. There is no plaintext column and there is no argument for one; the relay
/// does not dial a bucket, so ADR 0004's single concession has no analogue here.
/// </para>
/// <para>
/// <b>The endpoint is inside the payload even though it is often <c>s3.amazonaws.com</c>.</b> For everybody
/// self-hosting MinIO or Ceph it is an address on their own network, which is precisely the thing the host
/// table only stores in the clear when the relay cannot work without it. Nothing on the server dials this.
/// </para>
/// </remarks>
public sealed class VaultObjectStore : IVaultItem
{
/// <summary>Primary key. UUIDv7, generated by the client so a bucket can be added offline.</summary>
public Guid Id { get; set; }
/// <summary>Owning vault.</summary>
public Guid VaultId { get; set; }
/// <summary>Owning vault.</summary>
public Vault? Vault { get; set; }
/// <summary>The encrypted bucket and its keys: a DSH1 envelope. Opaque to the server.</summary>
public byte[] Payload { get; set; } = [];
/// <summary>The item's data key, wrapped under the vault key. Opaque.</summary>
public byte[]? DataKeyWrap { get; set; }
/// <summary>Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3.</summary>
public Guid? ContentKeyId { get; set; }
/// <summary>Vault key generation this payload was encrypted under.</summary>
public int KeyGeneration { get; set; }
/// <summary>AAD rule version, enabling a lazy re-encrypt-on-write migration later.</summary>
public short PayloadAadVersion { get; set; }
/// <summary>Client-visible, monotonic item version, used for <c>expectedVersion</c> checks.</summary>
public int Version { get; set; }
/// <summary>Latest change-log sequence touching this row, so a delta pull can join directly.</summary>
public long ChangeSequence { 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; a tombstone, so an offline client learns the bucket went away.</summary>
public DateTimeOffset? DeletedAtUtc { get; set; }
/// <summary>Who created it.</summary>
public Guid CreatedByUserId { get; set; }
/// <summary>Who last modified it.</summary>
public Guid UpdatedByUserId { get; set; }
}
/// <summary>
/// A Spelunk API key and the Spelunk address it may be sent to, as ciphertext.
/// </summary>
/// <remarks>
/// <see cref="VaultObjectStore"/>'s shape, for the same reason: the key is a password, and the address beside
/// it is where the user's servers are managed. Nothing on the server calls Spelunk, so there is no plaintext
/// column and no argument for one.
/// </remarks>
public sealed class VaultSpelunkKey : IVaultItem
{
/// <summary>Primary key. UUIDv7, generated by the client so a key can be added offline.</summary>
public Guid Id { get; set; }
/// <summary>Owning vault.</summary>
public Guid VaultId { get; set; }
/// <summary>Owning vault.</summary>
public Vault? Vault { get; set; }
/// <summary>The encrypted key and its address: a DSH1 envelope. Opaque to the server.</summary>
public byte[] Payload { get; set; } = [];
/// <summary>The item's data key, wrapped under the vault key. Opaque.</summary>
public byte[]? DataKeyWrap { get; set; }
/// <summary>Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3.</summary>
public Guid? ContentKeyId { get; set; }
/// <summary>Vault key generation this payload was encrypted under.</summary>
public int KeyGeneration { get; set; }
/// <summary>AAD rule version, enabling a lazy re-encrypt-on-write migration later.</summary>
public short PayloadAadVersion { get; set; }
/// <summary>Client-visible, monotonic item version, used for <c>expectedVersion</c> checks.</summary>
public int Version { get; set; }
/// <summary>Latest change-log sequence touching this row, so a delta pull can join directly.</summary>
public long ChangeSequence { 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; a tombstone, so an offline client learns the key went away.</summary>
public DateTimeOffset? DeletedAtUtc { get; set; }
/// <summary>Who created it.</summary>
public Guid CreatedByUserId { get; set; }
/// <summary>Who last modified it.</summary>
public Guid UpdatedByUserId { get; set; }
}
/// <summary>
/// One connection that was made, as ciphertext.
/// </summary>
/// <remarks>
/// <para>
/// <b>Written once, at close, and never updated.</b> That single decision is what makes a synced log
/// tractable: an entry that is created and then left alone needs no three-way merge, produces exactly one
/// outbox row, and cannot collide with the unique index that allows one outbox row per item. A connection
/// that is still open is shown from the workspace's own in-memory state instead, because that is where the
/// truth about a running shell actually lives.
/// </para>
/// <para>
/// <b>Synced, and that is a deliberate trade rather than the obvious choice.</b> A log kept on the machine
/// that produced it cannot be read by an administrator, cannot survive a reinstall, and cannot be checked
/// against anything. Keeping it here buys team auditing; what it costs is that the operator learns a user's
/// connection <em>rate and timing</em> from row counts and <c>updated_at</c>, even though every field inside
/// is sealed. ADR 0001 already concedes it cannot hide that class of metadata; this widens it, and says so.
/// </para>
/// <para>
/// The shape is <see cref="VaultCredential"/>'s: an envelope and its bookkeeping, no plaintext column of any
/// kind. Which machines somebody reaches and when is the single most revealing thing this schema could hold
/// in the clear, which is exactly why it holds none of it.
/// </para>
/// </remarks>
public sealed class VaultConnectionLogEntry : IVaultItem
{
/// <summary>Primary key. UUIDv7, generated by the client, so an entry can be written offline.</summary>
public Guid Id { get; set; }
/// <summary>Owning vault.</summary>
public Guid VaultId { get; set; }
/// <summary>Owning vault.</summary>
public Vault? Vault { get; set; }
/// <summary>The encrypted entry: a DSH1 envelope. Opaque to the server.</summary>
public byte[] Payload { get; set; } = [];
/// <summary>The item's data key, wrapped under the vault key. Opaque.</summary>
public byte[]? DataKeyWrap { get; set; }
/// <summary>Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3.</summary>
public Guid? ContentKeyId { get; set; }
/// <summary>Vault key generation this payload was encrypted under.</summary>
public int KeyGeneration { get; set; }
/// <summary>AAD rule version, enabling a lazy re-encrypt-on-write migration later.</summary>
public short PayloadAadVersion { get; set; }
/// <summary>
/// Client-visible, monotonic item version.
/// </summary>
/// <remarks>
/// Always 1 in practice, because nothing updates a log entry. The column stays because the shared write
/// path needs it, and a kind that opted out of the version check would be a second write path.
/// </remarks>
public int Version { get; set; }
/// <summary>Latest change-log sequence touching this row, so a delta pull can join directly.</summary>
public long ChangeSequence { 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; a tombstone, so an offline client learns the entry was pruned.
/// </summary>
/// <remarks>
/// Retention is the only thing that deletes one of these, and it is a real tombstone that pushes — which
/// is why it is rate-limited rather than run on a timer. A prune that did not sync would delete the same
/// thousand entries again on every machine, for ever.
/// </remarks>
public DateTimeOffset? DeletedAtUtc { get; set; }
/// <summary>Who created it.</summary>
public Guid CreatedByUserId { get; set; }
/// <summary>Who last modified it.</summary>
public Guid UpdatedByUserId { get; set; }
}
/// <summary>
/// One create, edit or delete of a vault item, as ciphertext.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="VaultConnectionLogEntry"/>'s shape and its write-once rule, for a different history: what
/// changed in the keychain rather than what was connected to. The payload records the <em>names</em> of the
/// fields that changed and never their values, which is the same rule ADR 0006 imposes on the server's own
/// <c>detail</c> column. A log that recorded an old password would be a plaintext credential store with no
/// vault around it.
/// </para>
/// <para>
/// Note the deliberate asymmetry with the outbox, because it reads as a discrepancy otherwise: the outbox
/// <em>coalesces</em> two edits of one item into a single pending row, and this log does not — two edits are
/// two lines. The outbox describes what still has to be sent; this describes what somebody did.
/// </para>
/// </remarks>
public sealed class VaultActivityLogEntry : IVaultItem
{
/// <summary>Primary key. UUIDv7, generated by the client, so an entry can be written offline.</summary>
public Guid Id { get; set; }
/// <summary>Owning vault.</summary>
public Guid VaultId { get; set; }
/// <summary>Owning vault.</summary>
public Vault? Vault { get; set; }
/// <summary>The encrypted entry: a DSH1 envelope. Opaque to the server.</summary>
public byte[] Payload { get; set; } = [];
/// <summary>The item's data key, wrapped under the vault key. Opaque.</summary>
public byte[]? DataKeyWrap { get; set; }
/// <summary>Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3.</summary>
public Guid? ContentKeyId { get; set; }
/// <summary>Vault key generation this payload was encrypted under.</summary>
public int KeyGeneration { get; set; }
/// <summary>AAD rule version, enabling a lazy re-encrypt-on-write migration later.</summary>
public short PayloadAadVersion { get; set; }
/// <inheritdoc cref="VaultConnectionLogEntry.Version" />
public int Version { get; set; }
/// <summary>Latest change-log sequence touching this row, so a delta pull can join directly.</summary>
public long ChangeSequence { get; set; }
/// <summary>Creation timestamp.</summary>
public DateTimeOffset CreatedAtUtc { get; set; }
/// <summary>Last modification timestamp.</summary>
public DateTimeOffset UpdatedAtUtc { get; set; }
/// <inheritdoc cref="VaultConnectionLogEntry.DeletedAtUtc" />
public DateTimeOffset? DeletedAtUtc { get; set; }
/// <summary>Who created it.</summary>
public Guid CreatedByUserId { get; set; }
/// <summary>Who last modified it.</summary>
public Guid UpdatedByUserId { get; set; }
}
/// <summary>
/// A saved command, as ciphertext.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="VaultCredential"/>'s shape exactly, and for a stronger reason than symmetry. A snippet is a
/// command somebody runs on their infrastructure — <c>systemctl restart</c> against a named unit, a
/// <c>psql</c> line naming a database — and read as a set it describes what the estate is made of at least
/// as precisely as a list of hostnames does. There is nothing here the server could sort by that is worth
/// what holding it would cost, so there is no plaintext column to put it in even by mistake.
/// </para>
/// <para>
/// Whether the snippet runs on its own or merely gets typed into the terminal is inside the payload too, and
/// that is worth stating: it is the field the whole feature's safety rests on, so it must be one that
/// travels sealed and merges with everything else it belongs to, rather than a flag the server could see or
/// change.
/// </para>
/// </remarks>
public sealed class VaultSnippet : IVaultItem
{
/// <summary>Primary key. UUIDv7, generated by the client so a snippet can be written offline.</summary>
public Guid Id { get; set; }
/// <summary>Owning vault.</summary>
public Guid VaultId { get; set; }
/// <summary>Owning vault.</summary>
public Vault? Vault { get; set; }
/// <summary>The encrypted snippet: a DSH1 envelope. Opaque to the server.</summary>
public byte[] Payload { get; set; } = [];
/// <summary>The item's data key, wrapped under the vault key. Opaque.</summary>
public byte[]? DataKeyWrap { get; set; }
/// <summary>Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3.</summary>
public Guid? ContentKeyId { get; set; }
/// <summary>Vault key generation this payload was encrypted under.</summary>
public int KeyGeneration { get; set; }
/// <summary>AAD rule version, enabling a lazy re-encrypt-on-write migration later.</summary>
public short PayloadAadVersion { get; set; }
/// <summary>Client-visible, monotonic item version, used for <c>expectedVersion</c> checks.</summary>
public int Version { get; set; }
/// <summary>Latest change-log sequence touching this row, so a delta pull can join directly.</summary>
public long ChangeSequence { 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; a tombstone, so an offline client learns the snippet went away.</summary>
public DateTimeOffset? DeletedAtUtc { get; set; }
/// <summary>Who created it.</summary>
public Guid CreatedByUserId { get; set; }
/// <summary>Who last modified it.</summary>
public Guid UpdatedByUserId { get; set; }
}