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:
1 parent
c6fc19bbbd
commit
e93acc856f
11 files changed
+2086
-141
No files matched your search
@@ -198,10 +198,10 @@ internal sealed class SyncService(
|
||||
SyncPushOperation operation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (operation.EntityType != SyncEntityType.Host)
|
||||
if (ItemKinds.For(operation.EntityType) is not { } kind)
|
||||
{
|
||||
// M1 syncs hosts only. Other types are reserved in the contract so a newer client
|
||||
// gets a precise per-operation answer rather than a whole-batch failure.
|
||||
// Reserved in the contract but not synced here yet. A newer client gets a precise
|
||||
// per-operation answer rather than a whole-batch failure.
|
||||
return Invalid(operation, $"Entity type {operation.EntityType} is not yet supported.");
|
||||
}
|
||||
|
||||
@@ -228,13 +228,12 @@ internal sealed class SyncService(
|
||||
Detail: null);
|
||||
}
|
||||
|
||||
var host = await database.Hosts
|
||||
.SingleOrDefaultAsync(h => h.Id == operation.EntityId, cancellationToken)
|
||||
var item = await kind.FindAsync(database, operation.EntityId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// An id that exists in another vault must not be addressable from this one, and must not
|
||||
// reveal that it exists elsewhere.
|
||||
if (host is not null && host.VaultId != vault.Id)
|
||||
if (item is not null && item.VaultId != vault.Id)
|
||||
{
|
||||
return new SyncPushResult(
|
||||
operation.OperationId,
|
||||
@@ -246,17 +245,18 @@ internal sealed class SyncService(
|
||||
}
|
||||
|
||||
return operation.Operation == SyncOperation.Delete
|
||||
? await ApplyDeleteAsync(vault, actorUserId, operation, host, cancellationToken)
|
||||
? await ApplyDeleteAsync(vault, kind, actorUserId, operation, item, cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
: await ApplyUpsertAsync(vault, actorUserId, operation, host, cancellationToken)
|
||||
: await ApplyUpsertAsync(vault, kind, actorUserId, operation, item, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<SyncPushResult> ApplyUpsertAsync(
|
||||
Vault vault,
|
||||
IItemKind kind,
|
||||
Guid actorUserId,
|
||||
SyncPushOperation operation,
|
||||
SshHost? existing,
|
||||
IVaultItem? existing,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (operation.Payload is null)
|
||||
@@ -271,9 +271,9 @@ internal sealed class SyncService(
|
||||
|
||||
var fields = operation.PlaintextFields ?? new SyncPlaintextFields();
|
||||
|
||||
if (!ValidateRelayFields(fields, out var relayError))
|
||||
if (!kind.ValidateFields(fields, out var fieldError))
|
||||
{
|
||||
return Invalid(operation, relayError);
|
||||
return Invalid(operation, fieldError);
|
||||
}
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
@@ -281,30 +281,31 @@ internal sealed class SyncService(
|
||||
if (existing is null || existing.DeletedAtUtc is not null)
|
||||
{
|
||||
return await CreateAsync(
|
||||
vault, actorUserId, operation, existing, fields, now, cancellationToken)
|
||||
vault, kind, actorUserId, operation, existing, fields, now, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (operation.ExpectedVersion != existing.Version)
|
||||
{
|
||||
return await ConflictAsync(vault, operation, existing, cancellationToken)
|
||||
return await ConflictAsync(vault, kind, operation, existing, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
existing.Version++;
|
||||
ApplyFields(existing, operation.Payload, fields, actorUserId, now);
|
||||
ApplyFields(kind, existing, operation.Payload, fields, actorUserId, now);
|
||||
|
||||
return await RecordAsync(
|
||||
vault, actorUserId, operation, existing, ChangeOperation.Upsert, cancellationToken)
|
||||
vault, kind, actorUserId, operation, existing, ChangeOperation.Upsert, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Creates a new item, or rejects an upsert that cannot become one.</summary>
|
||||
private async Task<SyncPushResult> CreateAsync(
|
||||
Vault vault,
|
||||
IItemKind kind,
|
||||
Guid actorUserId,
|
||||
SyncPushOperation operation,
|
||||
SshHost? existing,
|
||||
IVaultItem? existing,
|
||||
SyncPlaintextFields fields,
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -313,39 +314,36 @@ internal sealed class SyncService(
|
||||
// deliberately under a new id rather than silently undoing someone else's delete.
|
||||
if (existing?.DeletedAtUtc is not null)
|
||||
{
|
||||
return await ConflictAsync(vault, operation, existing, cancellationToken)
|
||||
return await ConflictAsync(vault, kind, operation, existing, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (operation.ExpectedVersion is not null)
|
||||
{
|
||||
// The client believes it is updating something that does not exist here.
|
||||
return await ConflictAsync(vault, operation, existing: null, cancellationToken)
|
||||
return await ConflictAsync(vault, kind, operation, existing: null, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var created = new SshHost
|
||||
{
|
||||
Id = operation.EntityId,
|
||||
VaultId = vault.Id,
|
||||
Version = 1,
|
||||
CreatedAtUtc = now,
|
||||
CreatedByUserId = actorUserId,
|
||||
};
|
||||
var created = kind.Add(database, operation.EntityId, vault.Id);
|
||||
|
||||
ApplyFields(created, operation.Payload!, fields, actorUserId, now);
|
||||
database.Hosts.Add(created);
|
||||
created.Version = 1;
|
||||
created.CreatedAtUtc = now;
|
||||
created.CreatedByUserId = actorUserId;
|
||||
|
||||
ApplyFields(kind, created, operation.Payload!, fields, actorUserId, now);
|
||||
|
||||
return await RecordAsync(
|
||||
vault, actorUserId, operation, created, ChangeOperation.Upsert, cancellationToken)
|
||||
vault, kind, actorUserId, operation, created, ChangeOperation.Upsert, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<SyncPushResult> ApplyDeleteAsync(
|
||||
Vault vault,
|
||||
IItemKind kind,
|
||||
Guid actorUserId,
|
||||
SyncPushOperation operation,
|
||||
SshHost? existing,
|
||||
IVaultItem? existing,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (existing is null)
|
||||
@@ -368,7 +366,7 @@ internal sealed class SyncService(
|
||||
|
||||
if (operation.ExpectedVersion is not null && operation.ExpectedVersion != existing.Version)
|
||||
{
|
||||
return await ConflictAsync(vault, operation, existing, cancellationToken)
|
||||
return await ConflictAsync(vault, kind, operation, existing, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -378,36 +376,36 @@ internal sealed class SyncService(
|
||||
existing.UpdatedAtUtc = now;
|
||||
existing.UpdatedByUserId = actorUserId;
|
||||
|
||||
// The address must go with the item. Leaving it would keep the server able to resolve a
|
||||
// host the user believes they deleted.
|
||||
existing.RelayEnabled = false;
|
||||
existing.Hostname = null;
|
||||
existing.Port = null;
|
||||
kind.ClearFieldsOnDelete(existing);
|
||||
|
||||
return await RecordAsync(
|
||||
vault, actorUserId, operation, existing, ChangeOperation.Delete, cancellationToken)
|
||||
vault, kind, actorUserId, operation, existing, ChangeOperation.Delete, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The ciphertext and the bookkeeping are identical for every item type; only the plaintext columns
|
||||
/// differ, and those are the type's own business. Splitting it this way is what stops a new item type
|
||||
/// from having to restate — and possibly misstate — how a payload is stored.
|
||||
/// </remarks>
|
||||
private static void ApplyFields(
|
||||
SshHost host,
|
||||
IItemKind kind,
|
||||
IVaultItem item,
|
||||
EncryptedPayload payload,
|
||||
SyncPlaintextFields fields,
|
||||
Guid actorUserId,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
host.Payload = payload.Envelope;
|
||||
host.DataKeyWrap = payload.WrappedDataKey;
|
||||
host.ContentKeyId = payload.DataKeyId;
|
||||
host.KeyGeneration = (int)payload.KeyGeneration;
|
||||
host.PayloadAadVersion = payload.AadVersion;
|
||||
host.RelayEnabled = fields.RelayEnabled;
|
||||
host.Hostname = fields.RelayEnabled ? fields.Hostname : null;
|
||||
host.Port = fields.RelayEnabled ? fields.Port : null;
|
||||
host.GroupId = fields.GroupId;
|
||||
host.DeletedAtUtc = null;
|
||||
host.UpdatedAtUtc = now;
|
||||
host.UpdatedByUserId = actorUserId;
|
||||
item.Payload = payload.Envelope;
|
||||
item.DataKeyWrap = payload.WrappedDataKey;
|
||||
item.ContentKeyId = payload.DataKeyId;
|
||||
item.KeyGeneration = (int)payload.KeyGeneration;
|
||||
item.PayloadAadVersion = payload.AadVersion;
|
||||
item.DeletedAtUtc = null;
|
||||
item.UpdatedAtUtc = now;
|
||||
item.UpdatedByUserId = actorUserId;
|
||||
|
||||
kind.ApplyFields(item, fields);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -445,55 +443,22 @@ internal sealed class SyncService(
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
private static bool ValidateRelayFields(SyncPlaintextFields fields, out string error)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
private async Task<SyncPushResult> RecordAsync(
|
||||
Vault vault,
|
||||
IItemKind kind,
|
||||
Guid actorUserId,
|
||||
SyncPushOperation operation,
|
||||
SshHost host,
|
||||
IVaultItem item,
|
||||
ChangeOperation changeOperation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var change = new VaultChange
|
||||
{
|
||||
VaultId = vault.Id,
|
||||
EntityType = ChangeEntityType.SshHost,
|
||||
EntityId = host.Id,
|
||||
EntityType = kind.ChangeType,
|
||||
EntityId = item.Id,
|
||||
Operation = changeOperation,
|
||||
Revision = host.Version,
|
||||
Revision = item.Version,
|
||||
ActorUserId = actorUserId,
|
||||
OccurredAtUtc = clock.GetUtcNow(),
|
||||
};
|
||||
@@ -504,14 +469,14 @@ internal sealed class SyncService(
|
||||
// database on insert.
|
||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
host.ChangeSequence = change.Sequence;
|
||||
item.ChangeSequence = change.Sequence;
|
||||
|
||||
database.SyncOperationReceipts.Add(new SyncOperationReceipt
|
||||
{
|
||||
OperationId = operation.OperationId,
|
||||
VaultId = vault.Id,
|
||||
AppliedChangeSequence = change.Sequence,
|
||||
ResultVersion = host.Version,
|
||||
ResultVersion = item.Version,
|
||||
CreatedAtUtc = clock.GetUtcNow(),
|
||||
});
|
||||
|
||||
@@ -520,7 +485,7 @@ internal sealed class SyncService(
|
||||
return new SyncPushResult(
|
||||
operation.OperationId,
|
||||
SyncOperationStatus.Applied,
|
||||
host.Version,
|
||||
item.Version,
|
||||
change.Sequence,
|
||||
ServerEntity: null,
|
||||
Detail: null);
|
||||
@@ -528,8 +493,9 @@ internal sealed class SyncService(
|
||||
|
||||
private async Task<SyncPushResult> ConflictAsync(
|
||||
Vault vault,
|
||||
IItemKind kind,
|
||||
SyncPushOperation operation,
|
||||
SshHost? existing,
|
||||
IVaultItem? existing,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// The server cannot merge ciphertext, so it hands back its current state and the client
|
||||
@@ -544,7 +510,7 @@ internal sealed class SyncService(
|
||||
{
|
||||
Sequence = existing.ChangeSequence,
|
||||
VaultId = existing.VaultId,
|
||||
EntityType = ChangeEntityType.SshHost,
|
||||
EntityType = kind.ChangeType,
|
||||
EntityId = existing.Id,
|
||||
Operation = existing.DeletedAtUtc is null
|
||||
? ChangeOperation.Upsert
|
||||
@@ -569,65 +535,80 @@ internal sealed class SyncService(
|
||||
}
|
||||
|
||||
/// <summary>Attaches current row state to change-log entries.</summary>
|
||||
/// <remarks>
|
||||
/// A page of changes can mix item types, so rows are loaded per type — one query each, not one per
|
||||
/// change — and then matched back in the log's order. Ordering is the log's, never the load's: a pull
|
||||
/// that returned changes grouped by type would hand the client a sequence its cursor cannot resume from.
|
||||
/// </remarks>
|
||||
private async Task<List<Contracts.SyncChange>> HydrateAsync(
|
||||
Vault vault,
|
||||
List<VaultChange> changes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var hostIds = changes
|
||||
.Where(c => c.EntityType == ChangeEntityType.SshHost)
|
||||
.Select(c => c.EntityId)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
var rows = new Dictionary<(ChangeEntityType Type, Guid Id), IVaultItem>();
|
||||
var kinds = new Dictionary<ChangeEntityType, IItemKind>();
|
||||
|
||||
var hosts = hostIds.Length == 0
|
||||
? []
|
||||
: await database.Hosts
|
||||
.Where(h => h.VaultId == vault.Id && hostIds.Contains(h.Id))
|
||||
.ToDictionaryAsync(h => h.Id, cancellationToken)
|
||||
foreach (var group in changes.GroupBy(change => change.EntityType))
|
||||
{
|
||||
if (KindForChange(group.Key) is not { } kind)
|
||||
{
|
||||
// A change this server wrote under a type it no longer synchronises. Nothing sensible to
|
||||
// hydrate, and dropping the entry would leave a gap in the client's cursor — so it is
|
||||
// emitted below with no payload, which a client already handles as a tombstone.
|
||||
continue;
|
||||
}
|
||||
|
||||
kinds[group.Key] = kind;
|
||||
|
||||
var ids = group.Select(change => change.EntityId).Distinct().ToArray();
|
||||
|
||||
var loaded = await kind.LoadAsync(database, vault.Id, ids, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var result = new List<Contracts.SyncChange>(changes.Count);
|
||||
|
||||
foreach (var change in changes)
|
||||
{
|
||||
hosts.TryGetValue(change.EntityId, out var host);
|
||||
|
||||
// A delete carries no payload: there is nothing left to decrypt, and shipping the
|
||||
// pre-delete ciphertext would undermine the point of the tombstone.
|
||||
var isDelete = change.Operation == ChangeOperation.Delete
|
||||
|| host?.DeletedAtUtc is not null;
|
||||
|
||||
result.Add(new Contracts.SyncChange(
|
||||
EntityType: SyncEntityType.Host,
|
||||
EntityId: change.EntityId,
|
||||
Operation: isDelete ? SyncOperation.Delete : SyncOperation.Upsert,
|
||||
Version: change.Revision,
|
||||
ChangeSequence: change.Sequence,
|
||||
Payload: isDelete || host is null
|
||||
? null
|
||||
: new EncryptedPayload(
|
||||
host.Payload,
|
||||
// Non-null for every row a push can create: ValidatePayload refuses an
|
||||
// operation without them. The columns stay nullable because they are also
|
||||
// the seam for M5's per-item grants.
|
||||
host.DataKeyWrap ?? [],
|
||||
host.ContentKeyId ?? Guid.Empty,
|
||||
(uint)host.KeyGeneration,
|
||||
(byte)host.PayloadAadVersion),
|
||||
PlaintextFields: isDelete || host is null
|
||||
? null
|
||||
: new SyncPlaintextFields(
|
||||
RelayEnabled: host.RelayEnabled,
|
||||
Hostname: host.Hostname,
|
||||
Port: host.Port,
|
||||
GroupId: host.GroupId),
|
||||
UpdatedAt: change.OccurredAtUtc));
|
||||
foreach (var (id, item) in loaded)
|
||||
{
|
||||
rows[(group.Key, id)] = item;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return [.. changes.Select(change => Describe(
|
||||
change,
|
||||
kinds.GetValueOrDefault(change.EntityType),
|
||||
rows.GetValueOrDefault((change.EntityType, change.EntityId))))];
|
||||
}
|
||||
|
||||
/// <summary>Turns one change-log entry and its row into what a client receives.</summary>
|
||||
private static Contracts.SyncChange Describe(VaultChange change, IItemKind? kind, IVaultItem? item)
|
||||
{
|
||||
// A delete carries no payload: there is nothing left to decrypt, and shipping the pre-delete
|
||||
// ciphertext would undermine the point of the tombstone.
|
||||
var isDelete = change.Operation == ChangeOperation.Delete || item?.DeletedAtUtc is not null;
|
||||
var opaque = isDelete || item is null;
|
||||
|
||||
return new Contracts.SyncChange(
|
||||
EntityType: kind?.WireType ?? ToWire(change.EntityType),
|
||||
EntityId: change.EntityId,
|
||||
Operation: isDelete ? SyncOperation.Delete : SyncOperation.Upsert,
|
||||
Version: change.Revision,
|
||||
ChangeSequence: change.Sequence,
|
||||
Payload: opaque
|
||||
? null
|
||||
: new EncryptedPayload(
|
||||
item!.Payload,
|
||||
// Non-null for every row a push can create: ValidatePayload refuses an operation
|
||||
// without them. The columns stay nullable because they are also the seam for M5's
|
||||
// per-item grants.
|
||||
item.DataKeyWrap ?? [],
|
||||
item.ContentKeyId ?? Guid.Empty,
|
||||
(uint)item.KeyGeneration,
|
||||
(byte)item.PayloadAadVersion),
|
||||
PlaintextFields: opaque || kind is null ? null : kind.Hydrate(item!),
|
||||
UpdatedAt: change.OccurredAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>The kind behind a change-log entry, or null for a type this server does not synchronise.</summary>
|
||||
private static IItemKind? KindForChange(ChangeEntityType type) => ItemKinds.For(ToWire(type));
|
||||
|
||||
private async Task<long> CurrentHeadAsync(Guid vaultId, CancellationToken cancellationToken) =>
|
||||
await database.VaultChanges
|
||||
.Where(c => c.VaultId == vaultId)
|
||||
@@ -645,5 +626,14 @@ internal sealed class SyncService(
|
||||
ServerEntity: null,
|
||||
Detail: detail);
|
||||
|
||||
/// <remarks>
|
||||
/// The two enums are numerically aligned by hand — and they do not even use the same member names for
|
||||
/// the same value, <c>Host</c> against <c>SshHost</c> — so nothing in the type system defends this cast.
|
||||
/// <c>EntityTypeAlignmentTests</c> is what defends it; if it fails, changes would start being filed
|
||||
/// under another item type's name and a pull would hand clients the wrong ciphertext.
|
||||
/// </remarks>
|
||||
private static ChangeEntityType ToDomain(SyncEntityType type) => (ChangeEntityType)(int)type;
|
||||
|
||||
/// <inheritdoc cref="ToDomain" />
|
||||
private static SyncEntityType ToWire(ChangeEntityType type) => (SyncEntityType)(int)type;
|
||||
}
|
||||
Reference in new issue
Block a user