Public Access
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:
@@ -0,0 +1,294 @@
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Domain;
|
||||
using DodoSSH.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DodoSSH.Api.Features.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Everything about one item type that the shared write path cannot know.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The push path is the most dangerous code in the server: it holds the version check, the change-log
|
||||
/// append, the receipt that makes a retry exactly-once, and the advisory lock that keeps the sequence in
|
||||
/// commit order. Duplicating that per item type would mean ten copies of it by M5, and a fix applied to nine
|
||||
/// of them. So it is written once over <see cref="IVaultItem"/>, and everything genuinely type-specific —
|
||||
/// which table, which plaintext columns, what those columns must satisfy — arrives through here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Deliberately not generic. A generic <c>IItemKind<TItem></c> reads better in isolation and would
|
||||
/// force every method on the write path to be generic too, including the async ones, for no benefit: the
|
||||
/// path never needs the concrete type, only the shared shape. The downcast each implementation performs is
|
||||
/// contained to one class per type and is guaranteed by construction, because the same class both creates
|
||||
/// and queries the rows.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal interface IItemKind
|
||||
{
|
||||
/// <summary>The type as the wire contract names it.</summary>
|
||||
SyncEntityType WireType { get; }
|
||||
|
||||
/// <summary>The type as the change log names it.</summary>
|
||||
/// <remarks>
|
||||
/// Stated rather than cast. The two enums do agree numerically — and they have different member names
|
||||
/// for the same value, <c>Host</c> against <c>SshHost</c> — but that alignment is hand-kept and nothing
|
||||
/// in the type system defends it. Writing it out per kind means adding a type cannot silently file its
|
||||
/// changes under another type's name.
|
||||
/// </remarks>
|
||||
ChangeEntityType ChangeType { get; }
|
||||
|
||||
/// <summary>Finds one item by id, across every vault, so a cross-vault id can be refused.</summary>
|
||||
Task<IVaultItem?> FindAsync(DodoDbContext database, Guid id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Loads the rows behind a set of change-log entries, scoped to one vault.</summary>
|
||||
Task<Dictionary<Guid, IVaultItem>> LoadAsync(
|
||||
DodoDbContext database,
|
||||
Guid vaultId,
|
||||
Guid[] ids,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Creates an empty row of this type and tracks it.</summary>
|
||||
IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId);
|
||||
|
||||
/// <summary>Rejects plaintext fields this type may not carry.</summary>
|
||||
bool ValidateFields(SyncPlaintextFields fields, out string error);
|
||||
|
||||
/// <summary>Copies this type's plaintext columns out of the request.</summary>
|
||||
void ApplyFields(IVaultItem item, SyncPlaintextFields fields);
|
||||
|
||||
/// <summary>Clears plaintext columns that must not outlive the item.</summary>
|
||||
void ClearFieldsOnDelete(IVaultItem item);
|
||||
|
||||
/// <summary>Reads this type's plaintext columns back for a pull, or null when it has none.</summary>
|
||||
SyncPlaintextFields? Hydrate(IVaultItem item);
|
||||
}
|
||||
|
||||
/// <summary>The item types this server can synchronise, by wire type.</summary>
|
||||
internal static class ItemKinds
|
||||
{
|
||||
private static readonly Dictionary<SyncEntityType, IItemKind> Supported =
|
||||
new[] { (IItemKind)new HostKind(), new SshKeyKind() }
|
||||
.ToDictionary(kind => kind.WireType);
|
||||
|
||||
/// <summary>The kind for a wire type, or null when this server does not synchronise it yet.</summary>
|
||||
/// <remarks>
|
||||
/// Null rather than a throw. Every entity type in the contract is reachable by a newer client, and the
|
||||
/// push endpoint answers per operation — so an unsupported type has to become one <c>Invalid</c> result
|
||||
/// with a reason, not a failed batch that also rejects the operations this server did understand.
|
||||
/// </remarks>
|
||||
internal static IItemKind? For(SyncEntityType type) =>
|
||||
Supported.TryGetValue(type, out var kind) ? kind : null;
|
||||
}
|
||||
|
||||
/// <summary>Hosts: the one item type with a deliberate plaintext concession.</summary>
|
||||
internal sealed class HostKind : IItemKind
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public SyncEntityType WireType => SyncEntityType.Host;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ChangeEntityType ChangeType => ChangeEntityType.SshHost;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IVaultItem?> FindAsync(
|
||||
DodoDbContext database,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
await database.Hosts.SingleOrDefaultAsync(h => h.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
|
||||
DodoDbContext database,
|
||||
Guid vaultId,
|
||||
Guid[] ids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = await database.Hosts
|
||||
.Where(h => h.VaultId == vaultId && ids.Contains(h.Id))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
|
||||
{
|
||||
var host = new SshHost { Id = id, VaultId = vaultId };
|
||||
|
||||
database.Hosts.Add(host);
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors the database CHECK so a bad request is a clear 200-with-Invalid rather than a constraint
|
||||
/// violation surfacing as a 500.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public bool ValidateFields(SyncPlaintextFields fields, out string error)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fields);
|
||||
|
||||
error = string.Empty;
|
||||
|
||||
if (fields.RelayEnabled)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fields.Hostname) || fields.Port is null)
|
||||
{
|
||||
error = "Relay-enabled hosts require both a hostname and a port.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fields.Port is < 1 or > 65535)
|
||||
{
|
||||
error = "Port must be between 1 and 65535.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (fields.Hostname is not null || fields.Port is not null)
|
||||
{
|
||||
error = "A hostname or port may only be supplied when relay is enabled for the host.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fields);
|
||||
|
||||
var host = (SshHost)item;
|
||||
|
||||
host.RelayEnabled = fields.RelayEnabled;
|
||||
host.Hostname = fields.RelayEnabled ? fields.Hostname : null;
|
||||
host.Port = fields.RelayEnabled ? fields.Port : null;
|
||||
host.GroupId = fields.GroupId;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The address goes with the item. Leaving it would keep the server able to resolve a host the user
|
||||
/// believes they deleted.
|
||||
/// </remarks>
|
||||
/// <inheritdoc />
|
||||
public void ClearFieldsOnDelete(IVaultItem item)
|
||||
{
|
||||
var host = (SshHost)item;
|
||||
|
||||
host.RelayEnabled = false;
|
||||
host.Hostname = null;
|
||||
host.Port = null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SyncPlaintextFields? Hydrate(IVaultItem item)
|
||||
{
|
||||
var host = (SshHost)item;
|
||||
|
||||
return new SyncPlaintextFields(
|
||||
RelayEnabled: host.RelayEnabled,
|
||||
Hostname: host.Hostname,
|
||||
Port: host.Port,
|
||||
GroupId: host.GroupId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>SSH keys: ciphertext and nothing else.</summary>
|
||||
internal sealed class SshKeyKind : IItemKind
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public SyncEntityType WireType => SyncEntityType.SshKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ChangeEntityType ChangeType => ChangeEntityType.SshKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IVaultItem?> FindAsync(
|
||||
DodoDbContext database,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
await database.SshKeys.SingleOrDefaultAsync(k => k.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
|
||||
DodoDbContext database,
|
||||
Guid vaultId,
|
||||
Guid[] ids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = await database.SshKeys
|
||||
.Where(k => k.VaultId == vaultId && ids.Contains(k.Id))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
|
||||
{
|
||||
var key = new VaultSshKey { Id = id, VaultId = vaultId };
|
||||
|
||||
database.SshKeys.Add(key);
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refuses the relay columns outright.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A key is not something the server dials, so an address on one is either a client bug or an attempt to
|
||||
/// get the server to store something it has no reason to hold. Refused with a reason rather than
|
||||
/// silently dropped, because a client that thinks it is storing a field and is not will eventually be
|
||||
/// surprised by its absence.
|
||||
/// </remarks>
|
||||
/// <inheritdoc />
|
||||
public bool ValidateFields(SyncPlaintextFields fields, out string error)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fields);
|
||||
|
||||
error = string.Empty;
|
||||
|
||||
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
|
||||
{
|
||||
error = "An SSH key has no relay target; relay fields may only be set on a host.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fields);
|
||||
|
||||
// Only the fingerprint, and only because the contract reserved it. Everything that identifies the
|
||||
// key to a person — its label, its comment — is inside the ciphertext.
|
||||
((VaultSshKey)item).PublicKeyFingerprint = fields.PublicKeyFingerprint;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearFieldsOnDelete(IVaultItem item) =>
|
||||
((VaultSshKey)item).PublicKeyFingerprint = null;
|
||||
|
||||
/// <remarks>
|
||||
/// Null rather than an all-defaults instance, and the difference is visible on the wire: a
|
||||
/// <c>SyncPlaintextFields</c> with nothing set still serialises <c>relayEnabled: false</c>, which
|
||||
/// invites a reader to believe this type has a relay setting that happens to be off. It has none.
|
||||
/// </remarks>
|
||||
/// <inheritdoc />
|
||||
public SyncPlaintextFields? Hydrate(IVaultItem item) =>
|
||||
((VaultSshKey)item).PublicKeyFingerprint is { } fingerprint
|
||||
? new SyncPlaintextFields(PublicKeyFingerprint: fingerprint)
|
||||
: null;
|
||||
}
|
||||
Reference in New Issue
Block a user