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
@@ -49,6 +49,43 @@ public sealed class HostConfiguration : IEntityTypeConfiguration<SshHost>
}
}
/// <summary>Maps <see cref="VaultSshKey"/>.</summary>
/// <remarks>
/// Mirrors <see cref="HostConfiguration"/> in everything the two types share, and deliberately has no
/// analogue of the relay CHECK: a key carries no address, which is the reason it is its own table.
/// </remarks>
public sealed class SshKeyConfiguration : IEntityTypeConfiguration<VaultSshKey>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<VaultSshKey> builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.ToTable("ssh_key");
builder.HasKey(k => k.Id);
// Client-generated UUIDv7: keys must be creatable offline, with their ids.
builder.Property(k => k.Id).ValueGeneratedNever();
builder.UseXminConcurrencyToken();
builder.Property(k => k.Payload).IsRequired();
// SHA256:base64 of a 32-byte digest is 50 characters; the ceiling leaves room for another
// algorithm without a migration, and refuses anything that is plainly not a fingerprint.
builder.Property(k => k.PublicKeyFingerprint).HasMaxLength(128);
builder.HasIndex(k => new { k.VaultId, k.ChangeSequence });
builder.HasIndex(k => k.VaultId)
.HasFilter("deleted_at_utc IS NULL")
.HasDatabaseName("ix_ssh_key_vault_live");
builder.ToTable(t => t.HasCheckConstraint(
"ck_ssh_key_version",
"version >= 1"));
}
}
/// <summary>Maps <see cref="VaultChange"/>.</summary>
public sealed class SyncChangeConfiguration : IEntityTypeConfiguration<VaultChange>
{
@@ -54,6 +54,9 @@ public class DodoDbContext(DbContextOptions<DodoDbContext> options) : DbContext(
/// <summary>SSH hosts.</summary>
public DbSet<SshHost> Hosts => Set<SshHost>();
/// <summary>SSH key pairs, held as ciphertext.</summary>
public DbSet<VaultSshKey> SshKeys => Set<VaultSshKey>();
/// <summary>The per-vault change log that delta sync reads.</summary>
public DbSet<VaultChange> VaultChanges => Set<VaultChange>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DodoSSH.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddSshKeyItem : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ssh_key",
schema: "dodo",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
vault_id = table.Column<Guid>(type: "uuid", nullable: false),
payload = table.Column<byte[]>(type: "bytea", nullable: false),
data_key_wrap = table.Column<byte[]>(type: "bytea", nullable: true),
content_key_id = table.Column<Guid>(type: "uuid", nullable: true),
key_generation = table.Column<int>(type: "integer", nullable: false),
payload_aad_version = table.Column<short>(type: "smallint", nullable: false),
public_key_fingerprint = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
version = table.Column<int>(type: "integer", nullable: false),
change_sequence = table.Column<long>(type: "bigint", nullable: false),
created_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
updated_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
deleted_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
created_by_user_id = table.Column<Guid>(type: "uuid", nullable: false),
updated_by_user_id = table.Column<Guid>(type: "uuid", nullable: false),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_ssh_key", x => x.id);
table.CheckConstraint("ck_ssh_key_version", "version >= 1");
table.ForeignKey(
name: "fk_ssh_key_vaults_vault_id",
column: x => x.vault_id,
principalSchema: "dodo",
principalTable: "vault",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_ssh_key_vault_id_change_sequence",
schema: "dodo",
table: "ssh_key",
columns: new[] { "vault_id", "change_sequence" });
migrationBuilder.CreateIndex(
name: "ix_ssh_key_vault_live",
schema: "dodo",
table: "ssh_key",
column: "vault_id",
filter: "deleted_at_utc IS NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ssh_key",
schema: "dodo");
}
}
}
@@ -826,6 +826,92 @@ namespace DodoSSH.Infrastructure.Migrations
});
});
modelBuilder.Entity("DodoSSH.Domain.VaultSshKey", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<long>("ChangeSequence")
.HasColumnType("bigint")
.HasColumnName("change_sequence");
b.Property<Guid?>("ContentKeyId")
.HasColumnType("uuid")
.HasColumnName("content_key_id");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at_utc");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("uuid")
.HasColumnName("created_by_user_id");
b.Property<byte[]>("DataKeyWrap")
.HasColumnType("bytea")
.HasColumnName("data_key_wrap");
b.Property<DateTimeOffset?>("DeletedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at_utc");
b.Property<int>("KeyGeneration")
.HasColumnType("integer")
.HasColumnName("key_generation");
b.Property<byte[]>("Payload")
.IsRequired()
.HasColumnType("bytea")
.HasColumnName("payload");
b.Property<short>("PayloadAadVersion")
.HasColumnType("smallint")
.HasColumnName("payload_aad_version");
b.Property<string>("PublicKeyFingerprint")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("public_key_fingerprint");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at_utc");
b.Property<Guid>("UpdatedByUserId")
.HasColumnType("uuid")
.HasColumnName("updated_by_user_id");
b.Property<Guid>("VaultId")
.HasColumnType("uuid")
.HasColumnName("vault_id");
b.Property<int>("Version")
.HasColumnType("integer")
.HasColumnName("version");
b.Property<uint>("xmin")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.HasKey("Id")
.HasName("pk_ssh_key");
b.HasIndex("VaultId")
.HasDatabaseName("ix_ssh_key_vault_live")
.HasFilter("deleted_at_utc IS NULL");
b.HasIndex("VaultId", "ChangeSequence")
.HasDatabaseName("ix_ssh_key_vault_id_change_sequence");
b.ToTable("ssh_key", "dodo", t =>
{
t.HasCheckConstraint("ck_ssh_key_version", "version >= 1");
});
});
modelBuilder.Entity("DodoSSH.Domain.Device", b =>
{
b.HasOne("DodoSSH.Domain.UserAccount", "User")
@@ -942,6 +1028,18 @@ namespace DodoSSH.Infrastructure.Migrations
b.Navigation("Vault");
});
modelBuilder.Entity("DodoSSH.Domain.VaultSshKey", b =>
{
b.HasOne("DodoSSH.Domain.Vault", "Vault")
.WithMany()
.HasForeignKey("VaultId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_ssh_key_vaults_vault_id");
b.Navigation("Vault");
});
modelBuilder.Entity("DodoSSH.Domain.Team", b =>
{
b.Navigation("Memberships");