Files
DodoSSH/src/DodoSSH.Client.Sync/ItemKinds.cs
T
laurensandClaude Opus 5.5 ef365f462e Spelunk: sign each device in through the browser instead of keeping Spelunk API keys in the vault
A Spelunk API key reaches one organisation, so a server elsewhere - oc-linux in Orange Climate - was
refused before it reached the agent. Each device now signs in to a Spelunk once, under Settings ->
Security -> Spelunk or from a Spelunk host's editor: the OAuth authorization code flow with PKCE for a
native app (RFC 8252), with Spelunk as the authorization server. The browser opens Spelunk's
/api/app-sign-in/authorize with a loopback redirect (the app's own scheme on Android), the person signs
in there however they normally do and allows "DodoSSH on <device>", and the code is redeemed at
/api/app-sign-in/token for an app session token. That token opens terminals in every organisation its
person is in, with the role they have on each server at the time; tunnel tickets send no organisation.

The sign-in stays on the device: sealed in the local cache under the LocalCacheKey (AAD resource type
18, bound to its row), never synced, wiped with the cache, and only ever sent to the Spelunk it came
from. A 401 from Spelunk forgets it; SIGN OUT ends it there and here, and so does signing out of
DodoSSH. Before every Spelunk connection DodoSSH asks its own identity provider whether its sign-in
still stands (a forced refresh, at most once a minute), and an account it refuses is signed out of
DodoSSH and of every Spelunk on the device.

A route names no key any more: Spelunk hosts are written at host schema 11, which an older build shows
read-only and asks to be updated for, and a payload that still names a key credential decodes with the
id skipped. The SpelunkKey item type is gone: SyncEntityType 14, ChangeEntityType 14 and AAD type 17
are retired, the server drops spelunk_key and its change-log rows, and the client cache drops its
cached items, outbox entries and conflicts of that type. A link still never starts a sign-in: from a
Spelunk this device is not signed in to it opens the editor, which offers to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:26:25 +02:00

1061 lines
40 KiB
C#

using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
using DodoSSH.Contracts;
using static DodoSSH.Client.Sync.FieldChange;
namespace DodoSSH.Client.Sync;
/// <summary>A decrypted item, and whether this build may write it back.</summary>
/// <param name="Secret">The item.</param>
/// <param name="IsReadOnly">
/// Whether a newer client wrote it, in which case re-encoding it here would drop fields this build has
/// no concept of.
/// </param>
internal sealed record OpenedItem<TSecret>(TSecret Secret, bool IsReadOnly)
where TSecret : class, IVaultSecret;
/// <summary>A merged item, and everything that had to be overridden to produce it.</summary>
/// <param name="Merged">The item to store and push.</param>
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
internal sealed record MergedItem<TSecret>(
TSecret Merged,
IReadOnlyList<HostFieldConflict> Conflicts)
where TSecret : class, IVaultSecret;
/// <summary>
/// Everything about one item type that the shared sync path cannot know.
/// </summary>
/// <remarks>
/// <para>
/// The reconciler holds the six answers a collision can have — merge, adopt, resurrect, abandon, park,
/// refuse — and every one of them is identical for a host, an SSH key, a credential and a pinned host key.
/// Only the encoding,
/// the merge and the plaintext columns differ, and those arrive through here. A second copy of the
/// reconciler per item type is the alternative, and it is not a real one: the file's whole premise is
/// that the pull and push paths must answer the same situation the same way, and two copies would drift
/// the moment one of them was fixed.
/// </para>
/// <para>
/// Generic, unlike the server's <c>IItemKind</c>, and for a reason that reverses there: the client
/// <em>does</em> need the concrete type. It merges two versions of an item field by field and hands the
/// result to a codec, so erasing the type would only move the downcasts inside the reconciler, where
/// they would be a cast per branch instead of none.
/// </para>
/// </remarks>
internal interface IItemKind<TSecret>
where TSecret : class, IVaultSecret
{
/// <summary>The type as the wire contract names it.</summary>
SyncEntityType EntityType { get; }
/// <summary>
/// What to call one of these when telling a person what happened to it.
/// </summary>
/// <remarks>
/// Lower case and singular, because every use is mid-sentence. This exists because the conflict log
/// is read by people: "this host could not be decrypted" is actively misleading when the item was a
/// private key, and a user who is told the wrong noun looks in the wrong place.
/// </remarks>
string Noun { get; }
/// <inheritdoc cref="HostCipher.TryOpen" />
OpenedItem<TSecret>? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion);
/// <inheritdoc cref="HostCipher.Seal" />
EncryptedPayload Seal(
TSecret secret,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion);
/// <summary>
/// The plaintext columns the server gets, or null when this type gives it nothing.
/// </summary>
/// <remarks>
/// Nullable rather than an all-defaults record, because the difference is visible on the wire and to
/// a reader: <c>SyncPlaintextFields</c> with nothing set still serialises <c>relayEnabled: false</c>,
/// which invites the belief that the type has a relay setting which happens to be off.
/// </remarks>
SyncPlaintextFields? Fields(TSecret secret);
/// <summary>Merges two divergent versions against the version they both started from.</summary>
MergedItem<TSecret> Merge(TSecret ancestor, TSecret local, TSecret remote);
/// <summary>
/// The names of the fields that differ between two versions of an item.
/// </summary>
/// <remarks>
/// <para>
/// For the activity log, which records what changed and never what it changed to. Written per kind
/// rather than derived by reflection, for two reasons: this project's serialisation is source-generated
/// precisely to keep reflection out of it, and the names here are read by a person — so each kind gets
/// to say "Passphrase" rather than whatever a property happens to be called.
/// </para>
/// <para>
/// <b>Never a value.</b> A log that recorded an old password would be a plaintext credential store with
/// a vault drawn around it; see ADR 0006, which imposes the same rule on the server's own detail column.
/// </para>
/// </remarks>
IReadOnlyList<string> Changes(TSecret before, TSecret after);
/// <summary>
/// Whether writing one of these is worth a line in the activity log.
/// </summary>
/// <remarks>
/// False for the log kinds themselves, and that is not a preference: the activity hook sits in the one
/// generic repository every kind goes through, so a log entry that logged itself would produce an entry
/// per entry, for ever. It is stated per kind rather than special-cased at the call site so that a
/// future kind with the same shape — anything written by the machine rather than by a person — cannot
/// re-enter the loop by being forgotten.
/// </remarks>
bool IsAudited => true;
/// <summary>The same item under a new name, for a resurrection.</summary>
TSecret Relabel(TSecret secret, string label);
}
/// <summary>
/// The item types this client synchronises.
/// </summary>
/// <remarks>
/// <para>
/// <b>One list, and the pull filter is derived from it.</b> The engine asks the server for exactly the
/// types in <see cref="Registry"/> and refuses to apply a change of any other type, so adding a kind
/// cannot leave the filter behind — which is the specific way this would otherwise break: an item type
/// that reads and writes perfectly in every unit test and is never once requested from the server.
/// </para>
/// <para>
/// A reconciler per type rather than one shared instance, because each closes the generic over its own
/// secret type. They are cheap — four fields and no state — and building them once per engine keeps the
/// per-change path a dictionary lookup.
/// </para>
/// </remarks>
internal static class ItemKinds
{
private static readonly (SyncEntityType Type, ReconcilerFactory Create)[] Registry =
[
(SyncEntityType.Host, static (outbox, conflicts, keyring) =>
new ItemReconciler<HostSecret>(HostKind.Instance, outbox, conflicts, keyring)),
(SyncEntityType.SshKey, static (outbox, conflicts, keyring) =>
new ItemReconciler<SshKeySecret>(SshKeyKind.Instance, outbox, conflicts, keyring)),
(SyncEntityType.Credential, static (outbox, conflicts, keyring) =>
new ItemReconciler<CredentialSecret>(CredentialKind.Instance, outbox, conflicts, keyring)),
(SyncEntityType.KnownHostKey, static (outbox, conflicts, keyring) =>
new ItemReconciler<KnownHostSecret>(KnownHostKeyKind.Instance, outbox, conflicts, keyring)),
(SyncEntityType.HostGroup, static (outbox, conflicts, keyring) =>
new ItemReconciler<HostGroupSecret>(HostGroupKind.Instance, outbox, conflicts, keyring)),
(SyncEntityType.Tag, static (outbox, conflicts, keyring) =>
new ItemReconciler<TagSecret>(TagKind.Instance, outbox, conflicts, keyring)),
(SyncEntityType.Snippet, static (outbox, conflicts, keyring) =>
new ItemReconciler<SnippetSecret>(SnippetKind.Instance, outbox, conflicts, keyring)),
(SyncEntityType.ConnectionLogEntry, static (outbox, conflicts, keyring) =>
new ItemReconciler<ConnectionLogSecret>(
ConnectionLogEntryKind.Instance, outbox, conflicts, keyring)),
(SyncEntityType.ActivityLogEntry, static (outbox, conflicts, keyring) =>
new ItemReconciler<ActivityLogSecret>(
ActivityLogEntryKind.Instance, outbox, conflicts, keyring)),
(SyncEntityType.ObjectStore, static (outbox, conflicts, keyring) =>
new ItemReconciler<ObjectStoreSecret>(ObjectStoreKind.Instance, outbox, conflicts, keyring)),
];
/// <summary>The types to ask the server for, in a fixed order.</summary>
internal static IReadOnlyList<SyncEntityType> SyncedTypes { get; } =
[.. Registry.Select(entry => entry.Type)];
private delegate IItemReconciler ReconcilerFactory(
OutboxStore outbox,
ConflictStore conflicts,
VaultKeyring keyring);
/// <summary>Builds one reconciler per synchronised type.</summary>
internal static Dictionary<SyncEntityType, IItemReconciler> Reconcilers(
OutboxStore outbox,
ConflictStore conflicts,
VaultKeyring keyring) =>
Registry.ToDictionary(
entry => entry.Type,
entry => entry.Create(outbox, conflicts, keyring));
}
/// <summary>What the per-kind field comparisons share.</summary>
/// <remarks>
/// One line per field at each call site, which is the point: the alternative was reflection, and this
/// project keeps reflection out of its serialisation on purpose. Comparison is <see cref="object.Equals(object)"/>
/// on the values, which is why the collection-shaped fields on a host are types with structural equality —
/// <c>JumpChain</c> and <c>HostOptions</c> — rather than plain lists.
/// </remarks>
internal static class FieldChange
{
internal static void Note<T>(List<string> changed, string name, T before, T after)
{
if (!EqualityComparer<T>.Default.Equals(before, after))
{
changed.Add(name);
}
}
}
/// <summary>Hosts.</summary>
internal sealed class HostKind : IItemKind<HostSecret>
{
internal static HostKind Instance { get; } = new();
/// <inheritdoc />
public SyncEntityType EntityType => SyncEntityType.Host;
/// <inheritdoc />
public string Noun => "host";
/// <inheritdoc />
public OpenedItem<HostSecret>? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
var document = HostCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
return document is null ? null : new OpenedItem<HostSecret>(document.Host, document.IsReadOnly);
}
/// <inheritdoc />
public EncryptedPayload Seal(
HostSecret secret,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion) =>
HostCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
/// <inheritdoc />
public SyncPlaintextFields? Fields(HostSecret secret) => HostFields.From(secret);
/// <inheritdoc />
public MergedItem<HostSecret> Merge(HostSecret ancestor, HostSecret local, HostSecret remote)
{
var merged = HostSecretMerge.Merge(ancestor, local, remote);
return new MergedItem<HostSecret>(merged.Merged, merged.Conflicts);
}
/// <inheritdoc />
public IReadOnlyList<string> Changes(HostSecret before, HostSecret after)
{
ArgumentNullException.ThrowIfNull(before);
ArgumentNullException.ThrowIfNull(after);
var changed = new List<string>();
Note(changed, "Name", before.Label, after.Label);
Note(changed, "Hostname", before.Hostname, after.Hostname);
Note(changed, "Port", before.Port, after.Port);
Note(changed, "Username", before.Username, after.Username);
Note(changed, "Notes", before.Notes, after.Notes);
Note(changed, "Jump chain", before.JumpHostIds, after.JumpHostIds);
Note(changed, "Options", before.Options, after.Options);
Note(changed, "Relay", before.RelayEnabled, after.RelayEnabled);
Note(changed, "Spelunk", before.Spelunk, after.Spelunk);
Note(changed, "SSH key", before.SshKeyId, after.SshKeyId);
Note(changed, "Credential", before.CredentialId, after.CredentialId);
Note(changed, "Password prompt", before.AsksForPassword, after.AsksForPassword);
Note(changed, "Group", before.GroupId, after.GroupId);
Note(changed, "Tags", before.TagIds, after.TagIds);
Note(changed, "Port forwards", before.PortForwards, after.PortForwards);
Note(changed, "Pinned paths", before.PinnedPaths, after.PinnedPaths);
return changed;
}
/// <inheritdoc />
public HostSecret Relabel(HostSecret secret, string label)
{
ArgumentNullException.ThrowIfNull(secret);
return secret with { Label = label };
}
}
/// <summary>SSH keys.</summary>
internal sealed class SshKeyKind : IItemKind<SshKeySecret>
{
internal static SshKeyKind Instance { get; } = new();
/// <inheritdoc />
public SyncEntityType EntityType => SyncEntityType.SshKey;
/// <inheritdoc />
public string Noun => "SSH key";
/// <inheritdoc />
public OpenedItem<SshKeySecret>? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
var document = SshKeyCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
return document is null ? null : new OpenedItem<SshKeySecret>(document.Key, document.IsReadOnly);
}
/// <inheritdoc />
public EncryptedPayload Seal(
SshKeySecret secret,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion) =>
SshKeyCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
/// <summary>
/// Nothing at all.
/// </summary>
/// <remarks>
/// The server has a <c>public_key_fingerprint</c> column and would accept one here, and this client
/// deliberately declines to fill it. A fingerprint is not a secret, but it is a stable identifier for
/// a key pair, and handing it over would let the operator tell which of their users hold the same key
/// and correlate one key across vaults — for a column nothing in the product reads. The design allows
/// itself exactly one plaintext concession, the relay address, and it is a concession because the
/// relay cannot work without it. This is not that. See ADR 0004.
/// </remarks>
/// <inheritdoc />
public SyncPlaintextFields? Fields(SshKeySecret secret) => null;
/// <inheritdoc />
public MergedItem<SshKeySecret> Merge(SshKeySecret ancestor, SshKeySecret local, SshKeySecret remote)
{
var merged = SshKeySecretMerge.Merge(ancestor, local, remote);
return new MergedItem<SshKeySecret>(merged.Merged, merged.Conflicts);
}
/// <remarks>
/// The private key is compared and never reported by value, which is the whole rule. "Private key"
/// appearing in a log is the fact somebody needs; the key itself is what nobody does.
/// </remarks>
/// <inheritdoc />
public IReadOnlyList<string> Changes(SshKeySecret before, SshKeySecret after)
{
ArgumentNullException.ThrowIfNull(before);
ArgumentNullException.ThrowIfNull(after);
var changed = new List<string>();
Note(changed, "Name", before.Label, after.Label);
Note(changed, "Private key", before.PrivateKeyPem, after.PrivateKeyPem);
Note(changed, "Passphrase", before.Passphrase, after.Passphrase);
Note(changed, "Public key", before.PublicKey, after.PublicKey);
Note(changed, "Notes", before.Notes, after.Notes);
return changed;
}
/// <inheritdoc />
public SshKeySecret Relabel(SshKeySecret secret, string label)
{
ArgumentNullException.ThrowIfNull(secret);
return secret with { Label = label };
}
}
/// <summary>Credentials.</summary>
internal sealed class CredentialKind : IItemKind<CredentialSecret>
{
internal static CredentialKind Instance { get; } = new();
/// <inheritdoc />
public SyncEntityType EntityType => SyncEntityType.Credential;
/// <inheritdoc />
public string Noun => "credential";
/// <inheritdoc />
public OpenedItem<CredentialSecret>? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
var document = CredentialCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
return document is null
? null
: new OpenedItem<CredentialSecret>(document.Credential, document.IsReadOnly);
}
/// <inheritdoc />
public EncryptedPayload Seal(
CredentialSecret secret,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion) =>
CredentialCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
/// <summary>
/// Nothing, and for this type there was never a candidate.
/// </summary>
/// <remarks>
/// A key at least has a fingerprint, which is public by nature and which this client still declines to
/// send. A password has no part that is safe to expose — not its length, not a hash, not a hint — so
/// there is no decision to make here. The server refuses plaintext fields on this type outright.
/// </remarks>
/// <inheritdoc />
public SyncPlaintextFields? Fields(CredentialSecret secret) => null;
/// <inheritdoc />
public MergedItem<CredentialSecret> Merge(
CredentialSecret ancestor,
CredentialSecret local,
CredentialSecret remote)
{
var merged = CredentialSecretMerge.Merge(ancestor, local, remote);
return new MergedItem<CredentialSecret>(merged.Merged, merged.Conflicts);
}
/// <inheritdoc />
public IReadOnlyList<string> Changes(CredentialSecret before, CredentialSecret after)
{
ArgumentNullException.ThrowIfNull(before);
ArgumentNullException.ThrowIfNull(after);
var changed = new List<string>();
Note(changed, "Name", before.Label, after.Label);
Note(changed, "Password", before.Password, after.Password);
Note(changed, "Username", before.Username, after.Username);
Note(changed, "Notes", before.Notes, after.Notes);
return changed;
}
/// <inheritdoc />
public CredentialSecret Relabel(CredentialSecret secret, string label)
{
ArgumentNullException.ThrowIfNull(secret);
return secret with { Label = label };
}
}
/// <summary>Known host keys.</summary>
internal sealed class KnownHostKeyKind : IItemKind<KnownHostSecret>
{
internal static KnownHostKeyKind Instance { get; } = new();
/// <inheritdoc />
public SyncEntityType EntityType => SyncEntityType.KnownHostKey;
/// <summary>
/// What to call one of these to a person.
/// </summary>
/// <remarks>
/// "Known host key" rather than "host key", because a user who reads "this host key could not be
/// decrypted" would go looking at the host they are connecting to. The item is the record of a decision
/// they made about that host, and the noun has to say so.
/// </remarks>
/// <inheritdoc />
public string Noun => "known host key";
/// <inheritdoc />
public OpenedItem<KnownHostSecret>? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
var document = KnownHostKeyCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
return document is null
? null
: new OpenedItem<KnownHostSecret>(document.KnownHost, document.IsReadOnly);
}
/// <inheritdoc />
public EncryptedPayload Seal(
KnownHostSecret secret,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion) =>
KnownHostKeyCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
/// <summary>
/// Nothing, and here the temptation is the strongest of the four.
/// </summary>
/// <remarks>
/// This item holds an address and a fingerprint, and neither is confidential in itself: the operator
/// published the fingerprint and the address is one the server may already hold for a relay-enabled host.
/// Handing them over anyway would tell the operator which machines each user actually connects to and
/// when they first did — a map of the estate assembled out of individually harmless facts, for columns
/// that do not exist and nothing that would read them. See ADR 0004.
/// </remarks>
/// <inheritdoc />
public SyncPlaintextFields? Fields(KnownHostSecret secret) => null;
/// <inheritdoc />
public MergedItem<KnownHostSecret> Merge(
KnownHostSecret ancestor,
KnownHostSecret local,
KnownHostSecret remote)
{
var merged = KnownHostSecretMerge.Merge(ancestor, local, remote);
return new MergedItem<KnownHostSecret>(merged.Merged, merged.Conflicts);
}
/// <summary>
/// The pin unchanged, because a pin has no name of its own to change.
/// </summary>
/// <remarks>
/// The only caller is the reconciler's resurrection path, which renames rescued content so a user can see
/// what happened to it. <see cref="KnownHostSecret.Label"/> is derived from the host, port and algorithm
/// the pin is about, so there is nothing here that a rename could move: renaming it would mean claiming
/// the pin is about a different host. The resurrected item still gets its own id and still produces a
/// conflict notice, so the event is visible — the notice simply names the pin the same way twice.
/// </remarks>
/// <remarks>
/// In practice only the fingerprint can change: the store rewrites one or creates a new item, and never
/// re-addresses an existing pin. The other three are compared anyway, because a payload from elsewhere
/// is untrusted input and a silently unreported change to what a pin is <em>about</em> is the one thing
/// an audit trail must not miss.
/// </remarks>
/// <inheritdoc />
public IReadOnlyList<string> Changes(KnownHostSecret before, KnownHostSecret after)
{
ArgumentNullException.ThrowIfNull(before);
ArgumentNullException.ThrowIfNull(after);
var changed = new List<string>();
Note(changed, "Host", before.Host, after.Host);
Note(changed, "Port", before.Port, after.Port);
Note(changed, "Algorithm", before.Algorithm, after.Algorithm);
Note(changed, "Fingerprint", before.Fingerprint, after.Fingerprint);
return changed;
}
/// <inheritdoc />
public KnownHostSecret Relabel(KnownHostSecret secret, string label)
{
ArgumentNullException.ThrowIfNull(secret);
return secret;
}
}
/// <summary>Host groups.</summary>
internal sealed class HostGroupKind : IItemKind<HostGroupSecret>
{
internal static HostGroupKind Instance { get; } = new();
/// <inheritdoc />
public SyncEntityType EntityType => SyncEntityType.HostGroup;
/// <inheritdoc />
public string Noun => "group";
/// <inheritdoc />
public OpenedItem<HostGroupSecret>? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
var document = HostGroupCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
return document is null
? null
: new OpenedItem<HostGroupSecret>(document.Group, document.IsReadOnly);
}
/// <inheritdoc />
public EncryptedPayload Seal(
HostGroupSecret secret,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion) =>
HostGroupCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
/// <summary>
/// Nothing, and the two fields it declines to send are the two this type could have filled.
/// </summary>
/// <remarks>
/// <c>SyncPlaintextFields</c> has a <c>GroupId</c> and a <c>ParentId</c>, the server had a column for the
/// first, and no client ever wrote either. What they would hand over is the shape of the estate — which
/// machines this user files together, and which of those groupings sit under which — for columns nothing
/// in the product reads. Groups nest now and the parent is still not sent: it lives in the payload, and
/// the server refuses the plaintext field outright, on hosts as well as here. See ADR 0004.
/// </remarks>
/// <inheritdoc />
public SyncPlaintextFields? Fields(HostGroupSecret secret) => null;
/// <inheritdoc />
public MergedItem<HostGroupSecret> Merge(
HostGroupSecret ancestor,
HostGroupSecret local,
HostGroupSecret remote)
{
var merged = HostGroupSecretMerge.Merge(ancestor, local, remote);
return new MergedItem<HostGroupSecret>(merged.Merged, merged.Conflicts);
}
/// <inheritdoc />
public IReadOnlyList<string> Changes(HostGroupSecret before, HostGroupSecret after)
{
ArgumentNullException.ThrowIfNull(before);
ArgumentNullException.ThrowIfNull(after);
var changed = new List<string>();
Note(changed, "Name", before.Label, after.Label);
Note(changed, "Parent", before.ParentId, after.ParentId);
Note(changed, "Default port", before.DefaultPort, after.DefaultPort);
Note(changed, "Default username", before.DefaultUsername, after.DefaultUsername);
Note(changed, "Default SSH key", before.DefaultSshKeyId, after.DefaultSshKeyId);
Note(changed, "Default credential", before.DefaultCredentialId, after.DefaultCredentialId);
return changed;
}
/// <inheritdoc />
public HostGroupSecret Relabel(HostGroupSecret secret, string label)
{
ArgumentNullException.ThrowIfNull(secret);
return secret with { Label = label };
}
}
/// <summary>Tags.</summary>
internal sealed class TagKind : IItemKind<TagSecret>
{
internal static TagKind Instance { get; } = new();
/// <inheritdoc />
public SyncEntityType EntityType => SyncEntityType.Tag;
/// <inheritdoc />
public string Noun => "tag";
/// <inheritdoc />
public OpenedItem<TagSecret>? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
var document = TagCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
return document is null ? null : new OpenedItem<TagSecret>(document.Tag, document.IsReadOnly);
}
/// <inheritdoc />
public EncryptedPayload Seal(
TagSecret secret,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion) =>
TagCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
/// <summary>
/// Nothing, and null rather than an empty <see cref="SyncPlaintextFields"/>.
/// </summary>
/// <remarks>
/// <para>
/// The distinction is not pedantry. An empty record still serialises <c>relayEnabled: false</c>, which
/// invites every reader of the wire — including a future implementation of this client — to conclude
/// that a tag has a relay setting and that it is switched off. Null says the type has no plaintext at
/// all, which is the true statement.
/// </para>
/// <para>
/// A tag name in the clear would be the most aggregable column in the schema. Groups file machines;
/// tags describe them — "pci", "customer-a", "eu-west" — and one tag spans twenty hosts by design, so a
/// column here would hand the operator a labelled map of every user's estate for a sort no server in
/// this product performs. See ADR 0004.
/// </para>
/// </remarks>
/// <inheritdoc />
public SyncPlaintextFields? Fields(TagSecret secret) => null;
/// <inheritdoc />
public MergedItem<TagSecret> Merge(TagSecret ancestor, TagSecret local, TagSecret remote)
{
var merged = TagSecretMerge.Merge(ancestor, local, remote);
return new MergedItem<TagSecret>(merged.Merged, merged.Conflicts);
}
/// <inheritdoc />
public IReadOnlyList<string> Changes(TagSecret before, TagSecret after)
{
ArgumentNullException.ThrowIfNull(before);
ArgumentNullException.ThrowIfNull(after);
var changed = new List<string>();
Note(changed, "Name", before.Label, after.Label);
return changed;
}
/// <inheritdoc />
public TagSecret Relabel(TagSecret secret, string label)
{
ArgumentNullException.ThrowIfNull(secret);
return secret with { Label = label };
}
}
/// <summary>Snippets.</summary>
internal sealed class SnippetKind : IItemKind<SnippetSecret>
{
internal static SnippetKind Instance { get; } = new();
/// <inheritdoc />
public SyncEntityType EntityType => SyncEntityType.Snippet;
/// <inheritdoc />
public string Noun => "snippet";
/// <inheritdoc />
public OpenedItem<SnippetSecret>? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
var document = SnippetCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
return document is null
? null
: new OpenedItem<SnippetSecret>(document.Snippet, document.IsReadOnly);
}
/// <inheritdoc />
public EncryptedPayload Seal(
SnippetSecret secret,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion) =>
SnippetCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
/// <summary>
/// Nothing. A command is a description of the estate as precise as a hostname is.
/// </summary>
/// <inheritdoc />
public SyncPlaintextFields? Fields(SnippetSecret secret) => null;
/// <inheritdoc />
public MergedItem<SnippetSecret> Merge(
SnippetSecret ancestor,
SnippetSecret local,
SnippetSecret remote)
{
var merged = SnippetSecretMerge.Merge(ancestor, local, remote);
return new MergedItem<SnippetSecret>(merged.Merged, merged.Conflicts);
}
/// <inheritdoc />
public IReadOnlyList<string> Changes(SnippetSecret before, SnippetSecret after)
{
ArgumentNullException.ThrowIfNull(before);
ArgumentNullException.ThrowIfNull(after);
var changed = new List<string>();
Note(changed, "Name", before.Label, after.Label);
Note(changed, "Command", before.Command, after.Command);
Note(changed, "Notes", before.Notes, after.Notes);
// Named for what it means rather than after the property, because this is the line somebody
// reviewing a log actually needs to see: a snippet that has been turned into one that runs.
Note(changed, "Runs on insert", before.RunsOnInsert, after.RunsOnInsert);
return changed;
}
/// <inheritdoc />
public SnippetSecret Relabel(SnippetSecret secret, string label)
{
ArgumentNullException.ThrowIfNull(secret);
return secret with { Label = label };
}
}
/// <summary>Connection log entries.</summary>
internal sealed class ConnectionLogEntryKind : IItemKind<ConnectionLogSecret>
{
internal static ConnectionLogEntryKind Instance { get; } = new();
/// <inheritdoc />
public SyncEntityType EntityType => SyncEntityType.ConnectionLogEntry;
/// <summary>What to call one of these to a person.</summary>
/// <remarks>
/// "Log entry" and not "connection". A user told that "this connection could not be decrypted" would go
/// looking at a machine they cannot reach; the item is the <em>record</em> of one, and the noun has to
/// say so.
/// </remarks>
public string Noun => "log entry";
/// <inheritdoc />
public OpenedItem<ConnectionLogSecret>? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
var document = ConnectionLogCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
return document is null
? null
: new OpenedItem<ConnectionLogSecret>(document.Entry, document.IsReadOnly);
}
/// <inheritdoc />
public EncryptedPayload Seal(
ConnectionLogSecret secret,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion) =>
ConnectionLogCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
/// <summary>
/// Nothing, and here the temptation is a timestamp rather than an address.
/// </summary>
/// <remarks>
/// A plaintext <c>startedAt</c> would let the server order and prune a log without a client's help, which
/// is genuinely useful and is exactly the wrong trade: a timestamp column on this table is a record of
/// when each user works, assembled for free. The client sorts and prunes its own log. See ADR 0004.
/// </remarks>
/// <inheritdoc />
public SyncPlaintextFields? Fields(ConnectionLogSecret secret) => null;
/// <inheritdoc />
public MergedItem<ConnectionLogSecret> Merge(
ConnectionLogSecret ancestor,
ConnectionLogSecret local,
ConnectionLogSecret remote)
{
var merged = ConnectionLogSecretMerge.Merge(ancestor, local, remote);
return new MergedItem<ConnectionLogSecret>(merged.Merged, merged.Conflicts);
}
/// <summary>Never asked, because a log entry is never audited or updated.</summary>
/// <inheritdoc />
public IReadOnlyList<string> Changes(ConnectionLogSecret before, ConnectionLogSecret after) => [];
/// <summary>
/// False, and this is the guard that stops the log logging itself.
/// </summary>
/// <remarks>
/// The activity hook lives in the one generic repository every kind writes through, so without this a
/// connection entry would produce an activity entry, which would produce another, without end. It is a
/// property of the kind rather than a check at the call site so that the next machine-written kind
/// cannot re-enter the loop by being overlooked.
/// </remarks>
public bool IsAudited => false;
/// <summary>
/// The entry unchanged, because an entry has no name of its own to change.
/// </summary>
/// <remarks>
/// As for a pinned host key: the label is derived from what the entry records, so renaming it would mean
/// claiming the connection was to a different machine. A resurrected entry still gets its own id and
/// still produces a conflict notice, so the event stays visible.
/// </remarks>
/// <inheritdoc />
public ConnectionLogSecret Relabel(ConnectionLogSecret secret, string label)
{
ArgumentNullException.ThrowIfNull(secret);
return secret;
}
}
/// <summary>Activity log entries.</summary>
internal sealed class ActivityLogEntryKind : IItemKind<ActivityLogSecret>
{
internal static ActivityLogEntryKind Instance { get; } = new();
/// <inheritdoc />
public SyncEntityType EntityType => SyncEntityType.ActivityLogEntry;
/// <inheritdoc />
public string Noun => "log entry";
/// <inheritdoc />
public OpenedItem<ActivityLogSecret>? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
var document = ActivityLogCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
return document is null
? null
: new OpenedItem<ActivityLogSecret>(document.Entry, document.IsReadOnly);
}
/// <inheritdoc />
public EncryptedPayload Seal(
ActivityLogSecret secret,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion) =>
ActivityLogCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
/// <summary>
/// Nothing. <c>SyncPlaintextFields.Kind</c> would fit and is refused for it.
/// </summary>
/// <inheritdoc />
public SyncPlaintextFields? Fields(ActivityLogSecret secret) => null;
/// <inheritdoc />
public MergedItem<ActivityLogSecret> Merge(
ActivityLogSecret ancestor,
ActivityLogSecret local,
ActivityLogSecret remote)
{
var merged = ActivityLogSecretMerge.Merge(ancestor, local, remote);
return new MergedItem<ActivityLogSecret>(merged.Merged, merged.Conflicts);
}
/// <inheritdoc cref="ConnectionLogEntryKind.Changes" />
public IReadOnlyList<string> Changes(ActivityLogSecret before, ActivityLogSecret after) => [];
/// <inheritdoc cref="ConnectionLogEntryKind.IsAudited" />
public bool IsAudited => false;
/// <inheritdoc cref="ConnectionLogEntryKind.Relabel" />
public ActivityLogSecret Relabel(ActivityLogSecret secret, string label)
{
ArgumentNullException.ThrowIfNull(secret);
return secret;
}
}
/// <summary>Buckets.</summary>
internal sealed class ObjectStoreKind : IItemKind<ObjectStoreSecret>
{
internal static ObjectStoreKind Instance { get; } = new();
/// <inheritdoc />
public SyncEntityType EntityType => SyncEntityType.ObjectStore;
/// <summary>What to call one of these to a person.</summary>
/// <remarks>
/// "Bucket" rather than "object store", because that is the word on the screen and in every service's own
/// documentation. The type is named for the protocol; the noun is named for what people say.
/// </remarks>
public string Noun => "bucket";
/// <inheritdoc />
public OpenedItem<ObjectStoreSecret>? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
var document = ObjectStoreCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
return document is null
? null
: new OpenedItem<ObjectStoreSecret>(document.Store, document.IsReadOnly);
}
/// <inheritdoc />
public EncryptedPayload Seal(
ObjectStoreSecret secret,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion) =>
ObjectStoreCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
/// <summary>
/// Nothing, and for this type there was never a candidate.
/// </summary>
/// <remarks>
/// The endpoint is the field somebody might reach for, and it is the one that must not go: for everybody
/// self-hosting it is an address on their own network, which is exactly what the host table only holds in
/// the clear when the relay cannot work without it. Nothing on the server dials a bucket.
/// </remarks>
/// <inheritdoc />
public SyncPlaintextFields? Fields(ObjectStoreSecret secret) => null;
/// <inheritdoc />
public MergedItem<ObjectStoreSecret> Merge(
ObjectStoreSecret ancestor,
ObjectStoreSecret local,
ObjectStoreSecret remote)
{
var merged = ObjectStoreSecretMerge.Merge(ancestor, local, remote);
return new MergedItem<ObjectStoreSecret>(merged.Merged, merged.Conflicts);
}
/// <remarks>
/// The secret access key is compared and never reported by value, which is the rule every credential-like
/// field in this file follows. The access key id is shown: it is an identifier, not a secret.
/// </remarks>
/// <inheritdoc />
public IReadOnlyList<string> Changes(ObjectStoreSecret before, ObjectStoreSecret after)
{
ArgumentNullException.ThrowIfNull(before);
ArgumentNullException.ThrowIfNull(after);
var changed = new List<string>();
Note(changed, "Name", before.Label, after.Label);
Note(changed, "Bucket", before.Bucket, after.Bucket);
Note(changed, "Access key id", before.AccessKeyId, after.AccessKeyId);
Note(changed, "Secret access key", before.SecretAccessKey, after.SecretAccessKey);
Note(changed, "Region", before.Region, after.Region);
Note(changed, "Endpoint", before.Endpoint, after.Endpoint);
Note(changed, "Path-style addressing", before.UsePathStyle, after.UsePathStyle);
Note(changed, "Notes", before.Notes, after.Notes);
return changed;
}
/// <inheritdoc />
public ObjectStoreSecret Relabel(ObjectStoreSecret secret, string label)
{
ArgumentNullException.ThrowIfNull(secret);
return secret with { Label = label };
}
}