Public Access
Settings → Security gains a SPELUNK section: an API address and a key, saved as a new keychain item, a Spelunk key (SyncEntityType 14, AadResourceType 17, server table spelunk_key via the AddSpelunkKeyItem migration). It is ciphertext only on the server, lives in the personal vault, syncs like every secret, and holds the key together with the one address it may be sent to. The key box empties once saved; the list shows name, address and how many hosts use each key, never the key. Saving a key for an address that already has one replaces it, and deleting goes through the shared confirmation card with the hosts that would stop connecting. A host's route names a saved key where it named a credential, in the same apiKeyCredentialId field, so no host schema changes and an older build reads such a host as one whose key credential has gone. At connect a saved key is sent only to its own address; a host naming it with another Spelunk address is refused in words. Keys kept as a credential's password still work. The editor offers saved keys first and picks the one saved for an address as it is typed. A link from a Spelunk with a saved key now connects straight away, including the first one: saving the key is the user's say-so that the first-link editor used to ask for. The address and key merge as one pair, so two concurrent edits can never pair one device's key with another's address, and the key never reaches the conflict log.
1192 lines
41 KiB
C#
1192 lines
41 KiB
C#
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(), new CredentialKind(), new KnownHostKeyKind(),
|
|
new HostGroupKind(), new TagKind(), new SnippetKind(),
|
|
new ConnectionLogEntryKind(), new ActivityLogEntryKind(), new ObjectStoreKind(),
|
|
new SpelunkKeyKind(),
|
|
}.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>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The group check comes <em>first</em>, ahead of the relay branch, and that placement is the point: the
|
|
/// relay branch returns early on its happy path, so a check placed after it would apply to non-relay
|
|
/// hosts only — leaving the one field this refusal exists for reachable by exactly the hosts most likely
|
|
/// to carry it.
|
|
/// </para>
|
|
/// <para>
|
|
/// <c>SyncPlaintextFields.GroupId</c> is part of a frozen wire contract and cannot be removed from it, so
|
|
/// refusing it here is what actually keeps the value out of the database. The column it used to be copied
|
|
/// into was dropped when groups landed; see <see cref="VaultHostGroup"/> for why membership travels inside
|
|
/// the payload instead.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <inheritdoc />
|
|
public bool ValidateFields(SyncPlaintextFields fields, out string error)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(fields);
|
|
|
|
error = string.Empty;
|
|
|
|
if (fields.GroupId is not null)
|
|
{
|
|
error = "A host's group is inside its encrypted payload; the server does not store one.";
|
|
return false;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
|
|
/// <summary>Credentials: an envelope and nothing else.</summary>
|
|
/// <remarks>
|
|
/// As strict as a kind gets about plaintext — <see cref="KnownHostKeyKind"/> is the other one — and the
|
|
/// reason is not symmetry. A key at least has a fingerprint that is public by nature; a password has no part
|
|
/// that is safe to expose, so this kind accepts no plaintext fields at all and hydrates none.
|
|
/// </remarks>
|
|
internal sealed class CredentialKind : IItemKind
|
|
{
|
|
/// <inheritdoc />
|
|
public SyncEntityType WireType => SyncEntityType.Credential;
|
|
|
|
/// <inheritdoc />
|
|
public ChangeEntityType ChangeType => ChangeEntityType.Credential;
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IVaultItem?> FindAsync(
|
|
DodoDbContext database,
|
|
Guid id,
|
|
CancellationToken cancellationToken) =>
|
|
await database.Credentials.SingleOrDefaultAsync(c => c.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.Credentials
|
|
.Where(c => c.VaultId == vaultId && ids.Contains(c.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 credential = new VaultCredential { Id = id, VaultId = vaultId };
|
|
|
|
database.Credentials.Add(credential);
|
|
|
|
return credential;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Refuses every plaintext field there is.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Refused with a reason rather than silently dropped, so a client that believes it is storing something
|
|
/// finds out now rather than when the field turns out to be missing.
|
|
/// </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 = "A credential has no relay target; relay fields may only be set on a host.";
|
|
return false;
|
|
}
|
|
|
|
if (fields.PublicKeyFingerprint is not null)
|
|
{
|
|
error = "A credential has no public key.";
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
|
|
/// <inheritdoc />
|
|
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
|
|
{
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void ClearFieldsOnDelete(IVaultItem item)
|
|
{
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Always null, which is a stronger statement than an empty record: this type has no plaintext columns,
|
|
/// so there is nothing a pull could hydrate even in principle.
|
|
/// </remarks>
|
|
/// <inheritdoc />
|
|
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
|
|
}
|
|
|
|
/// <summary>Known host keys: an envelope and nothing else.</summary>
|
|
/// <remarks>
|
|
/// As strict as <see cref="CredentialKind"/> about plaintext, for a reason that is about aggregation rather
|
|
/// than secrecy. A fingerprint is published so it can be compared and an address may already sit in a
|
|
/// relay-enabled host's columns — but the set of endpoints one user has approved is a map of their estate,
|
|
/// and this server has nothing to do with it.
|
|
/// </remarks>
|
|
internal sealed class KnownHostKeyKind : IItemKind
|
|
{
|
|
/// <inheritdoc />
|
|
public SyncEntityType WireType => SyncEntityType.KnownHostKey;
|
|
|
|
/// <inheritdoc />
|
|
public ChangeEntityType ChangeType => ChangeEntityType.KnownHostKey;
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IVaultItem?> FindAsync(
|
|
DodoDbContext database,
|
|
Guid id,
|
|
CancellationToken cancellationToken) =>
|
|
await database.KnownHostKeys.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.KnownHostKeys
|
|
.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 knownHost = new VaultKnownHostKey { Id = id, VaultId = vaultId };
|
|
|
|
database.KnownHostKeys.Add(knownHost);
|
|
|
|
return knownHost;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Refuses every plaintext field there is.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The relay fields are refused although this type is the one that <em>does</em> hold an address, and
|
|
/// that is the point: the address belongs in the ciphertext. A client sending it here is either confused
|
|
/// or trying to get the server to keep a list it has no business keeping, and either way it should be
|
|
/// told rather than have the value quietly dropped.
|
|
/// </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 = "A known host key is not something the server dials; its address stays encrypted.";
|
|
return false;
|
|
}
|
|
|
|
if (fields.PublicKeyFingerprint is not null)
|
|
{
|
|
error = "A known host key's fingerprint stays inside its payload.";
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
|
|
/// <inheritdoc />
|
|
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
|
|
{
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void ClearFieldsOnDelete(IVaultItem item)
|
|
{
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Always null, as for a credential: this type has no plaintext columns, so there is nothing a pull could
|
|
/// hydrate even in principle.
|
|
/// </remarks>
|
|
/// <inheritdoc />
|
|
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
|
|
}
|
|
|
|
/// <summary>Host groups: an envelope and nothing else.</summary>
|
|
/// <remarks>
|
|
/// The kind that closes a hole rather than opening one. <c>SyncPlaintextFields</c> has carried a
|
|
/// <c>GroupId</c> since the contract was frozen and <see cref="HostKind"/> used to copy it into a column;
|
|
/// nothing ever sent one, and now nothing may. The group itself arrives here as ciphertext with no name the
|
|
/// server can read, which is the same answer <see cref="KnownHostKeyKind"/> gives for the same reason.
|
|
/// </remarks>
|
|
internal sealed class HostGroupKind : IItemKind
|
|
{
|
|
/// <inheritdoc />
|
|
public SyncEntityType WireType => SyncEntityType.HostGroup;
|
|
|
|
/// <inheritdoc />
|
|
public ChangeEntityType ChangeType => ChangeEntityType.HostGroup;
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IVaultItem?> FindAsync(
|
|
DodoDbContext database,
|
|
Guid id,
|
|
CancellationToken cancellationToken) =>
|
|
await database.HostGroups.SingleOrDefaultAsync(g => g.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.HostGroups
|
|
.Where(g => g.VaultId == vaultId && ids.Contains(g.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 group = new VaultHostGroup { Id = id, VaultId = vaultId };
|
|
|
|
database.HostGroups.Add(group);
|
|
|
|
return group;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Refuses every plaintext field there is, including the two that would describe this type's own place
|
|
/// in a tree.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <b>Groups nest, and the pointer that nests them is still refused here.</b> The refusal used to mean
|
|
/// "there is no such thing as a parent"; it now means "the parent is not the server's to hold". A
|
|
/// <c>ParentId</c> or <c>GroupId</c> column on this table would let the operator reconstruct the shape of
|
|
/// every user's estate — how many groupings, how deep, which under which — which is precisely the
|
|
/// disclosure ADR 0004 refuses everywhere except the one address the relay cannot dial without. The
|
|
/// parent travels inside the envelope like the name beside it, and the client is the only thing that can
|
|
/// read either.
|
|
/// </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 = "A host group is not something the server dials.";
|
|
return false;
|
|
}
|
|
|
|
if (fields.GroupId is not null || fields.ParentId is not null)
|
|
{
|
|
error = "A host group's name and its place in the tree are inside its payload.";
|
|
return false;
|
|
}
|
|
|
|
if (fields.PublicKeyFingerprint is not null)
|
|
{
|
|
error = "A host group has no public key.";
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
|
|
/// <inheritdoc />
|
|
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
|
|
{
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void ClearFieldsOnDelete(IVaultItem item)
|
|
{
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
|
|
}
|
|
|
|
/// <summary>Tags: an envelope and nothing else.</summary>
|
|
/// <remarks>
|
|
/// <see cref="HostGroupKind"/>'s answer to the same question, at higher stakes. A group name says how one
|
|
/// user files their machines; a tag name says what the machines are, and one tag is meant to span twenty of
|
|
/// them — so a plaintext label here would hand the operator a labelled map of every estate on the server, to
|
|
/// sort a list nothing server-side draws.
|
|
/// </remarks>
|
|
internal sealed class TagKind : IItemKind
|
|
{
|
|
/// <inheritdoc />
|
|
public SyncEntityType WireType => SyncEntityType.Tag;
|
|
|
|
/// <inheritdoc />
|
|
public ChangeEntityType ChangeType => ChangeEntityType.Tag;
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IVaultItem?> FindAsync(
|
|
DodoDbContext database,
|
|
Guid id,
|
|
CancellationToken cancellationToken) =>
|
|
await database.Tags.SingleOrDefaultAsync(t => t.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.Tags
|
|
.Where(t => t.VaultId == vaultId && ids.Contains(t.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 tag = new VaultTag { Id = id, VaultId = vaultId };
|
|
|
|
database.Tags.Add(tag);
|
|
|
|
return tag;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Refuses every plaintext field there is, including the one a join table would have reached for.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <b><c>RelatedId</c> matters more here than for any other kind.</b> It is the obvious place to put "the
|
|
/// host this tag is on", which is exactly what <c>SyncEntityType.HostTag</c> reserves a slot for and what
|
|
/// this design decided not to build — membership is a set inside each host's payload. A future client
|
|
/// reaching for the field would be storing the host-to-tag graph in the clear, one row at a time, which
|
|
/// is the aggregation this kind exists to refuse. <c>SyncPlaintextFields</c> is frozen so the field
|
|
/// cannot be removed; refusing it is the only place the decision can be enforced.
|
|
/// </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 = "A tag is not something the server dials.";
|
|
return false;
|
|
}
|
|
|
|
if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
|
|
{
|
|
error = "A tag's name is inside its payload, and which hosts wear it is inside theirs.";
|
|
return false;
|
|
}
|
|
|
|
if (fields.PublicKeyFingerprint is not null)
|
|
{
|
|
error = "A tag has no public key.";
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
|
|
/// <inheritdoc />
|
|
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
|
|
{
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void ClearFieldsOnDelete(IVaultItem item)
|
|
{
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
|
|
}
|
|
|
|
/// <summary>Snippets: an envelope and nothing else.</summary>
|
|
/// <remarks>
|
|
/// A label column here would sort a list this server never draws, and the commands beside that label describe
|
|
/// the estate as precisely as a list of hostnames would. So this kind is as strict as
|
|
/// <see cref="CredentialKind"/>, and for the aggregation reason rather than the secrecy one.
|
|
/// </remarks>
|
|
internal sealed class SnippetKind : IItemKind
|
|
{
|
|
/// <inheritdoc />
|
|
public SyncEntityType WireType => SyncEntityType.Snippet;
|
|
|
|
/// <inheritdoc />
|
|
public ChangeEntityType ChangeType => ChangeEntityType.Snippet;
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IVaultItem?> FindAsync(
|
|
DodoDbContext database,
|
|
Guid id,
|
|
CancellationToken cancellationToken) =>
|
|
await database.Snippets.SingleOrDefaultAsync(s => s.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.Snippets
|
|
.Where(s => s.VaultId == vaultId && ids.Contains(s.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 snippet = new VaultSnippet { Id = id, VaultId = vaultId };
|
|
|
|
database.Snippets.Add(snippet);
|
|
|
|
return snippet;
|
|
}
|
|
|
|
/// <summary>Refuses every plaintext field there is.</summary>
|
|
/// <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 = "A snippet is not something the server dials.";
|
|
return false;
|
|
}
|
|
|
|
if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
|
|
{
|
|
error = "A snippet's contents, including anything it is scoped to, stay inside its payload.";
|
|
return false;
|
|
}
|
|
|
|
if (fields.PublicKeyFingerprint is not null)
|
|
{
|
|
error = "A snippet has no public key.";
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
|
|
/// <inheritdoc />
|
|
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
|
|
{
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void ClearFieldsOnDelete(IVaultItem item)
|
|
{
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
|
|
}
|
|
|
|
/// <summary>Connection log entries: an envelope and nothing else.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The strictest kind here, and the one where a plaintext column would have been most tempting: a
|
|
/// <c>started_at</c> would let this server order and prune a log without any client's help. It gets none,
|
|
/// because a timestamp column on this table is a record of when each user works, and the times are the
|
|
/// interesting part of a connection log even when the hostnames are sealed.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The server cannot enforce write-once, and does not pretend to.</b> That an entry is created and never
|
|
/// updated is a client rule — see <see cref="VaultConnectionLogEntry"/> — and the shared write path would
|
|
/// accept an upsert with a correct <c>expectedVersion</c> like any other. Adding a refusal here would be a
|
|
/// guarantee about payload semantics this server cannot read.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed class ConnectionLogEntryKind : IItemKind
|
|
{
|
|
/// <inheritdoc />
|
|
public SyncEntityType WireType => SyncEntityType.ConnectionLogEntry;
|
|
|
|
/// <inheritdoc />
|
|
public ChangeEntityType ChangeType => ChangeEntityType.ConnectionLogEntry;
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IVaultItem?> FindAsync(
|
|
DodoDbContext database,
|
|
Guid id,
|
|
CancellationToken cancellationToken) =>
|
|
await database.ConnectionLog.SingleOrDefaultAsync(e => e.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.ConnectionLog
|
|
.Where(e => e.VaultId == vaultId && ids.Contains(e.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 entry = new VaultConnectionLogEntry { Id = id, VaultId = vaultId };
|
|
|
|
database.ConnectionLog.Add(entry);
|
|
|
|
return entry;
|
|
}
|
|
|
|
/// <summary>Refuses every plaintext field there is.</summary>
|
|
/// <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 = "A log entry is not something the server dials; what was connected to stays encrypted.";
|
|
return false;
|
|
}
|
|
|
|
if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
|
|
{
|
|
error = "A log entry names what it is about inside its payload.";
|
|
return false;
|
|
}
|
|
|
|
if (fields.Kind is not null || fields.PublicKeyFingerprint is not null)
|
|
{
|
|
error = "A log entry carries no plaintext fields at all.";
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
|
|
/// <inheritdoc />
|
|
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
|
|
{
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void ClearFieldsOnDelete(IVaultItem item)
|
|
{
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
|
|
}
|
|
|
|
/// <summary>Activity log entries: an envelope and nothing else.</summary>
|
|
/// <remarks>
|
|
/// As strict as <see cref="ConnectionLogEntryKind"/>. <c>SyncPlaintextFields.Kind</c> exists and would fit
|
|
/// "which sort of item this entry is about" exactly, which is why it is refused by name: a column recording
|
|
/// that a user created four SSH keys last Tuesday is a description of the keychain, assembled from facts
|
|
/// that each look harmless.
|
|
/// </remarks>
|
|
internal sealed class ActivityLogEntryKind : IItemKind
|
|
{
|
|
/// <inheritdoc />
|
|
public SyncEntityType WireType => SyncEntityType.ActivityLogEntry;
|
|
|
|
/// <inheritdoc />
|
|
public ChangeEntityType ChangeType => ChangeEntityType.ActivityLogEntry;
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IVaultItem?> FindAsync(
|
|
DodoDbContext database,
|
|
Guid id,
|
|
CancellationToken cancellationToken) =>
|
|
await database.ActivityLog.SingleOrDefaultAsync(e => e.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.ActivityLog
|
|
.Where(e => e.VaultId == vaultId && ids.Contains(e.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 entry = new VaultActivityLogEntry { Id = id, VaultId = vaultId };
|
|
|
|
database.ActivityLog.Add(entry);
|
|
|
|
return entry;
|
|
}
|
|
|
|
/// <summary>Refuses every plaintext field there is.</summary>
|
|
/// <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 = "A log entry is not something the server dials.";
|
|
return false;
|
|
}
|
|
|
|
if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
|
|
{
|
|
error = "Which item a log entry is about stays inside its payload.";
|
|
return false;
|
|
}
|
|
|
|
if (fields.Kind is not null || fields.PublicKeyFingerprint is not null)
|
|
{
|
|
error = "A log entry carries no plaintext fields at all.";
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
|
|
/// <inheritdoc />
|
|
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
|
|
{
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void ClearFieldsOnDelete(IVaultItem item)
|
|
{
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
|
|
}
|
|
|
|
/// <summary>Object stores: an envelope and nothing else.</summary>
|
|
/// <remarks>
|
|
/// As strict as <see cref="CredentialKind"/>, because it holds the same class of thing. A secret access key
|
|
/// is a password; the endpoint beside it is, for everybody self-hosting, an address on their own network. The
|
|
/// relay does not dial a bucket, so ADR 0004's one concession has no analogue here and there is nothing to
|
|
/// weigh.
|
|
/// </remarks>
|
|
internal sealed class ObjectStoreKind : IItemKind
|
|
{
|
|
/// <inheritdoc />
|
|
public SyncEntityType WireType => SyncEntityType.ObjectStore;
|
|
|
|
/// <inheritdoc />
|
|
public ChangeEntityType ChangeType => ChangeEntityType.ObjectStore;
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IVaultItem?> FindAsync(
|
|
DodoDbContext database,
|
|
Guid id,
|
|
CancellationToken cancellationToken) =>
|
|
await database.ObjectStores.SingleOrDefaultAsync(o => o.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.ObjectStores
|
|
.Where(o => o.VaultId == vaultId && ids.Contains(o.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 store = new VaultObjectStore { Id = id, VaultId = vaultId };
|
|
|
|
database.ObjectStores.Add(store);
|
|
|
|
return store;
|
|
}
|
|
|
|
/// <summary>Refuses every plaintext field there is.</summary>
|
|
/// <remarks>
|
|
/// The relay fields are refused although this type <em>does</em> hold an address, exactly as they are for
|
|
/// a pinned host key: the address belongs in the ciphertext, and a client sending it here is either
|
|
/// confused or trying to get the server to keep a list of where its users store data.
|
|
/// </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 = "A bucket is not something the server dials; its endpoint stays encrypted.";
|
|
return false;
|
|
}
|
|
|
|
if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
|
|
{
|
|
error = "A bucket's contents are inside its payload.";
|
|
return false;
|
|
}
|
|
|
|
if (fields.Kind is not null || fields.PublicKeyFingerprint is not null)
|
|
{
|
|
error = "A bucket carries no plaintext fields at all.";
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
|
|
/// <inheritdoc />
|
|
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
|
|
{
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void ClearFieldsOnDelete(IVaultItem item)
|
|
{
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
|
|
}
|
|
|
|
/// <summary>Spelunk keys: an envelope and nothing else.</summary>
|
|
/// <remarks>
|
|
/// <see cref="ObjectStoreKind"/>'s rules, because it holds the same class of thing: an API key is a password,
|
|
/// and the address beside it is where somebody manages their servers. Nothing on the server calls Spelunk.
|
|
/// </remarks>
|
|
internal sealed class SpelunkKeyKind : IItemKind
|
|
{
|
|
/// <inheritdoc />
|
|
public SyncEntityType WireType => SyncEntityType.SpelunkKey;
|
|
|
|
/// <inheritdoc />
|
|
public ChangeEntityType ChangeType => ChangeEntityType.SpelunkKey;
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IVaultItem?> FindAsync(
|
|
DodoDbContext database,
|
|
Guid id,
|
|
CancellationToken cancellationToken) =>
|
|
await database.SpelunkKeys.SingleOrDefaultAsync(s => s.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.SpelunkKeys
|
|
.Where(s => s.VaultId == vaultId && ids.Contains(s.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 VaultSpelunkKey { Id = id, VaultId = vaultId };
|
|
|
|
database.SpelunkKeys.Add(key);
|
|
|
|
return key;
|
|
}
|
|
|
|
/// <summary>Refuses every plaintext field there is.</summary>
|
|
/// <remarks>
|
|
/// The relay fields are refused although this type holds an address, as they are for a bucket: the
|
|
/// address belongs in the ciphertext, and the server has no business keeping a list of where its users
|
|
/// manage their servers from.
|
|
/// </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 = "A Spelunk key is not something the server dials; its address stays encrypted.";
|
|
return false;
|
|
}
|
|
|
|
if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
|
|
{
|
|
error = "A Spelunk key's contents are inside its payload.";
|
|
return false;
|
|
}
|
|
|
|
if (fields.Kind is not null || fields.PublicKeyFingerprint is not null)
|
|
{
|
|
error = "A Spelunk key carries no plaintext fields at all.";
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
|
|
/// <inheritdoc />
|
|
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
|
|
{
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void ClearFieldsOnDelete(IVaultItem item)
|
|
{
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
|
|
}
|