Files
DodoSSH/src/DodoSSH.Domain/Hosts.cs
T
jaap-jan e24012b039 Sync credentials as a vault item type, and bind one to a host
Closes the largest remaining M1 gap in the data layer: a username and password
can live in the vault, sync between machines, and be named by a host as how it
authenticates. What is not here is the interface for creating one — see the end
of this message.

The third item type, and the first one that cost almost nothing to add. Server:
a VaultCredential row, an EF configuration, a migration, and a CredentialKind.
Client: a secret, a codec, a merge, a cipher, a kind, a repository facade and a
session property. No new reconciliation logic, no change to the sync engine, no
client cache migration. That was the whole point of the item-kind seam, and this
is the evidence it holds.

The narrowest type of the three on plaintext, and not for symmetry. A host has a
deliberate concession — the relay needs an address it can resolve. A key has a
fingerprint, public by nature, which this client still declines to send. A
password has no part that is safe to expose: not its length, not a hash, not a
hint. So CredentialKind refuses every plaintext field there is, hydrates none,
and the table has no column to put one in.

HostSecret.CredentialId is the password counterpart of SshKeyId, and the two are
mutually exclusive. SSH itself would happily try a key and fall back to a
password, but a host naming both leaves "how does this authenticate?" without a
single answer — the interface, the connect path and the user would each be free
to guess differently. TryValidate refuses it. One consequence was not
anticipated: "a full host" stops being a coherent idea, which is what broke
AFullHost_RoundTrips and is now written into that test.

The schema version became a ladder rather than a maximum: credential-bound is 3,
key-bound is 2, neither is still 1. Adding credentials therefore does not drag
every key-bound host in every vault onto a version that clients understanding
keys perfectly well would refuse to edit. A test pins exactly that, because it is
the property the whole content-dependent-version rule exists to provide, and the
obvious implementation would quietly lose it.

Two tests had become false and said so:

- Push_AnUnsupportedEntityType_IsInvalidNotAFailedBatch used Credential as its
  example of a type this server does not implement. It now asks the server's own
  registry what is still missing, so it cannot go stale again, and skips with a
  reason if that set ever empties.
- ThePullFilterNamesEveryTypeThisBuildSynchronises pinned the exact list, which
  is what it is for.

Also fixes ten nullable warnings — eight in SyncEndpointTests, two in a test file
added earlier today. Neither set was introduced here; both were invisible until
an unrelated change forced their project to recompile, which means the
zero-warning claims made earlier in this work only ever covered what happened to
be rebuilt.

777 tests green. Zero warnings, dotnet format clean.

Not done, and deliberately: the credential interface. The vault column is 340
pixels wide and already holds two lists and two editors, kept from clipping its
own buttons at the window's minimum height only by the one-editor-at-a-time rule
added earlier today. A third list and a third editor would recreate that defect
rather than avoid it, so the column needs a shape decision first. Credentials
sync; they cannot yet be created in the interface.
2026-07-29 21:09:08 +02:00

236 lines
10 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>
/// </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>Owning group, for tree placement. Groups arrive in M2.</summary>
public Guid? GroupId { 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; }
}