Public Access
Port forwarding took host schema version 8 first, so the Spelunk route moves to 9 and is written after the forwards. A host may carry both.
1061 lines
40 KiB
C#
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 };
|
|
}
|
|
}
|