Sync SSH keys as a vault item type, over a shared write path

The private key now lives in the vault as ciphertext, syncs between a user's
machines, and is stored on the server so it can later be shared — sharing
itself needs M3's signed grants; this is the storage that makes it possible.

More was already reserved than expected: SyncEntityType.SshKey,
CryptoSpec.AadResourceType.SshKey, ChangeEntityType.SshKey,
SyncPlaintextFields.PublicKeyFingerprint, and SshPrivateKeyCredential wired
through PrivateKeyFile over a MemoryStream so a key never touches disk. The
frozen contract and crypto spec needed no change at all.

What was missing was the server. Rather than copy the push path per item type
— version check, change-log append, exactly-once receipt, advisory lock — it
is now written once over IVaultItem, with everything type-specific behind
IItemKind: which table, which plaintext columns, and what those columns must
satisfy. Ten copies of that logic by M5, with a fix applied to nine, is the
outcome this avoids. The refactor landed first with no behaviour change, so
all 66 existing Host tests were the regression net, and they stayed green.

An interface rather than a base class, deliberately: EF Core maps an
inheritance hierarchy when it can see one, so a mapped base would quietly
become a table-per-hierarchy discriminator across item types — the very
arrangement per-type tables exist to avoid.

ssh_key mirrors host and pointedly has no relay trio. That is the argument
for separate tables 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. A key carrying a relay target is refused with a
reason rather than silently dropped.

A key hydrates PlaintextFields as null, not an empty instance — the
difference is visible on the wire, because an all-defaults instance still
serialises "relayEnabled": false and invites a reader to believe the setting
exists and is off. It has none.

Two things now defended by tests rather than by comments. Each kind states
its own ChangeEntityType instead of casting: the two enums agree numerically
but do not even share member names (Host against SshHost), and filing key
changes under the host type is silent sync corruption — sabotaging it fails
three tests. And EntityTypeAlignmentTests asserts the two enums stay aligned
in both directions and in count, which nothing did before.

The client half is next: SshKeySecret, its codec and merge, the cipher, a
repository, and the UI. Note for that work — SyncEntityType.SshKey is 3 while
AadResourceType.SshKey is 6, so a cast between them would seal key ciphertext
as a vault and nothing would fail.
This commit is contained in:
2026-07-29 15:14:06 +02:00
parent c6fc19bbbd
commit e93acc856f
11 changed files with 2086 additions and 141 deletions
+82 -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 SshHost
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; }
@@ -87,3 +87,84 @@ public sealed class SshHost
/// <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; }
}
+71
View File
@@ -0,0 +1,71 @@
namespace DodoSSH.Domain;
/// <summary>
/// The shape every encrypted vault item shares.
/// </summary>
/// <remarks>
/// <para>
/// Each item type keeps its own table — the plan's data model, and the reason is per-type constraints
/// rather than tidiness: the relay CHECK on <c>host</c> has no meaning for an SSH key, and a single table
/// would have to make every such column nullable and enforce nothing. What the types genuinely share is
/// this: opaque ciphertext, the key generation it was sealed under, a client-visible version for optimistic
/// concurrency, a change-log pointer, and a tombstone.
/// </para>
/// <para>
/// An interface rather than a base class, deliberately. EF Core maps an inheritance hierarchy when it can
/// see one, and a mapped base would quietly become a table-per-hierarchy discriminator across item types —
/// which is the arrangement this interface exists to avoid. An interface carries no mapping semantics, so
/// each concrete type stays an independent entity with its own table while the write path can still be
/// written once.
/// </para>
/// <para>
/// Note what is <em>not</em> here. <c>xmin</c> is never exposed, because it is not stable across
/// <c>VACUUM FREEZE</c> and must not become a client cursor. Plaintext columns are not here either: they
/// are exactly the part that differs per type, and they are the part with privacy consequences, so they are
/// handled explicitly per type rather than through a shared abstraction that could grow one by accident.
/// </para>
/// </remarks>
public interface IVaultItem
{
/// <summary>Primary key. UUIDv7, generated by the client so items can be created offline.</summary>
Guid Id { get; set; }
/// <summary>Owning vault.</summary>
Guid VaultId { get; set; }
/// <summary>The encrypted item: a DSH1 envelope. Opaque to the server.</summary>
byte[] Payload { get; set; }
/// <summary>The item's data key, wrapped under the vault key. Opaque.</summary>
byte[]? DataKeyWrap { get; set; }
/// <summary>Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3.</summary>
Guid? ContentKeyId { get; set; }
/// <summary>Vault key generation this payload was encrypted under.</summary>
int KeyGeneration { get; set; }
/// <summary>AAD rule version, enabling a lazy re-encrypt-on-write migration later.</summary>
short PayloadAadVersion { get; set; }
/// <summary>Client-visible, monotonic item version, used for <c>expectedVersion</c> checks.</summary>
int Version { get; set; }
/// <summary>Latest change-log sequence touching this row, so a delta pull can join directly.</summary>
long ChangeSequence { get; set; }
/// <summary>Creation timestamp.</summary>
DateTimeOffset CreatedAtUtc { get; set; }
/// <summary>Last modification timestamp.</summary>
DateTimeOffset UpdatedAtUtc { get; set; }
/// <summary>Soft-delete marker. Deletes are tombstones so an offline client can learn of them.</summary>
DateTimeOffset? DeletedAtUtc { get; set; }
/// <summary>Who created it.</summary>
Guid CreatedByUserId { get; set; }
/// <summary>Who last modified it.</summary>
Guid UpdatedByUserId { get; set; }
}