Build the three things the phone's + needs, before the + exists

Steps 1 to 3 of docs/adding-hosts-on-the-phone.md: the domain half. Nothing
on either head has changed, which is deliberate — the plan orders these first
because everything the editors will bind to has to exist and be merge-safe
before a screen can offer it.

HostGroupSecret gains a parent and four defaults, and the codec gains the
version rule it never had. It stamped CurrentSchemaVersion unconditionally,
which was harmless with one field and one version and stops being harmless
here: upgrading one machine and renaming any group would have made that group
uneditable on every machine still on the old build. It now emits the lowest
version that loses nothing, so a flat group with no defaults still encodes at
version 1, byte for byte, pinned against a literal.

Tags become a real item over the reserved slot. Secret, codec, merge, cipher,
repository, both registries, the EF entity and a generated AddTagItem
migration. TagCipher names AadResourceType.Tag as a constant rather than
casting the wire type, because Tag is 5 on the wire and 8 in the crypto enum
and 5 there is Credential — a cast would seal every tag under the resource
type for a password, encrypt and decrypt perfectly on the machine that wrote
it, and only fail when another implementation refused the item, by which time
the AAD is frozen into stored ciphertext. HostTag stays reserved and unused:
the one thing the join buys over a set on the host is bought instead by
merging TagIds per id.

HostSecret grows TagIds and Port goes nullable, which is the change with the
widest blast radius and the only one that loses an item rather than locking
one. A host with no port of its own omits the property, an older build reads
int Port as 0, and TryValidate refuses it — unreadable rather than read-only.
That cost is confined to hosts which actually inherit, because the version is
a maximum over the fields present; the alternative, writing 22 into every
host, is the lie inheritance exists to stop telling.

One decision the plan did not specify. "Three states where there were two" is
four — key, credential, typed password, or the group's answer — and two
nullable ids carry three. Naming neither id now means inherit, so
AsksForPassword says "a typed password even under a group that lends a key"
out loud. Only true is ever written and a decoded false folds back to null, so
a host that never touched it encodes as it always did. Nothing already stored
changed meaning: no group could lend a binding before this build, so every
existing host resolves exactly as it did.

HostInheritance is the resolver, and its visited set is load-bearing rather
than defensive. Two clients can each re-parent A under B and B under A while
offline; the merge sees one item against one item and the server sees
ciphertext, so nothing upstream can refuse the pair. With inheritance the
chain is walked at connect time, so an unguarded cycle is not an undrawable
sidebar — it is a shell that never opens. Stopping at the first repeat
degrades it to a group that reads as a root, and clearing the parent is the
repair.

A tag set turns out to be the one field on a host that can never ask the user
anything. TagSet.ToIdMap keys by the value, so no key can hold two values, so
the both-sides-moved-differently branch of the keyed merge is unreachable —
asserted over the whole eight-row matrix. The conflict loop is kept anyway,
because that proof is one edit from ceasing to hold and what it would cause is
a discarded tag nothing records.

Three guard tests failed by design and were fixed rather than relaxed: the
ordered pull filter, the AAD pinning table, and the server's refusal of a
plaintext parent — that last one survives with its reason rewritten, because
the refusal now means "the parent is not the server's to hold" rather than
"there is no such thing as a parent". The prose that said groups are flat is
rewritten in all four places it appeared, not deleted.

The five view-model sites that read Port directly now go through the resolver,
which is a down payment on step 4 rather than the whole of it. HostFields.From
still emits the stored port, and that is the one remaining place where an
unresolved read would be a wrong wire rather than a wrong label.

Verified by the whole suite: 1382 tests over nineteen projects, none failing.
Both heads build. Nothing seen on a display, because nothing on a display has
changed yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 10:21:02 +02:00
co-authored by Claude Opus 5
parent c9eca96ce7
commit 8c04ba60b0
35 changed files with 4921 additions and 120 deletions
+98 -5
View File
@@ -154,6 +154,9 @@ internal static class ItemKinds
(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)),
@@ -267,7 +270,9 @@ internal sealed class HostKind : IItemKind<HostSecret>
Note(changed, "Relay", before.RelayEnabled, after.RelayEnabled);
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);
return changed;
}
@@ -588,13 +593,14 @@ internal sealed class HostGroupKind : IItemKind<HostGroupSecret>
HostGroupCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
/// <summary>
/// Nothing, and the field it declines to send is the one named after this type.
/// Nothing, and the two fields it declines to send are the two this type could have filled.
/// </summary>
/// <remarks>
/// <c>SyncPlaintextFields.GroupId</c> exists, the server had a column for it, and no client ever wrote
/// one. What it would have handed over is a clustering of the estate — which machines this user files
/// together — for a column nothing in the product reads. The server now refuses the field outright, on
/// hosts as well as here. See ADR 0004.
/// <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;
@@ -619,6 +625,11 @@ internal sealed class HostGroupKind : IItemKind<HostGroupSecret>
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;
}
@@ -632,6 +643,88 @@ internal sealed class HostGroupKind : IItemKind<HostGroupSecret>
}
}
/// <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>
{
+132
View File
@@ -0,0 +1,132 @@
using System.Security.Cryptography;
using DodoSSH.Client.Domain;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Sync;
/// <summary>
/// Turns a tag into an item payload and back.
/// </summary>
/// <remarks>
/// <para>
/// Mirrors <see cref="HostGroupCipher"/> exactly, including the rule that a payload is sealed at the version
/// the server <em>will</em> assign rather than the one it replaces — see <see cref="SyncVersions"/>.
/// </para>
/// <para>
/// <b>The resource type is the one thing not to copy, and this type is where copying it is most tempting.</b>
/// <c>SyncEntityType.Tag</c> is 5 and <c>CryptoSpec.AadResourceType.Tag</c> is 8, because the crypto enum
/// carries None, User, Device and Vault ahead of the item types. A cast from one to the other compiles, and
/// 5 in the crypto enum is <c>Credential</c> — so every tag in the vault would be sealed under the resource
/// type for a password. It would encrypt perfectly and decrypt perfectly on the machine that wrote it, and
/// the first thing to notice would be another implementation refusing the item. The AAD is frozen into
/// stored ciphertext, so by then it is not a bug that can be fixed by a release.
/// </para>
/// </remarks>
public static class TagCipher
{
private const CryptoSpec.AadResourceType Resource = CryptoSpec.AadResourceType.Tag;
/// <summary>Encrypts a tag.</summary>
/// <param name="tag">The tag. Must be valid for storage.</param>
/// <param name="vaultKey">The vault key, which the data key is wrapped under.</param>
/// <param name="entityId">The item id, which the AAD binds.</param>
/// <param name="keyGeneration">The vault's current key generation.</param>
/// <param name="itemVersion">The version this payload will hold once the server accepts it.</param>
public static EncryptedPayload Seal(
TagSecret tag,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion)
{
ArgumentNullException.ThrowIfNull(tag);
ArgumentOutOfRangeException.ThrowIfLessThan(itemVersion, 1);
var plaintext = TagSecretCodec.Encode(tag);
var dataKey = ItemKeys.CreateDataKey();
try
{
var dataKeyId = Guid.CreateVersion7();
var wrappedDataKey = ItemKeys.WrapDataKey(
dataKey, vaultKey, Resource, entityId, keyGeneration, (uint)itemVersion);
var envelope = ItemKeys.SealPayload(
dataKey, plaintext, Resource, entityId, dataKeyId, keyGeneration, (uint)itemVersion);
return new EncryptedPayload(
envelope, wrappedDataKey, dataKeyId, keyGeneration, CryptoSpec.CurrentAadVersion);
}
finally
{
CryptographicOperations.ZeroMemory(dataKey);
// Wiped like every other payload in this folder, and here for the smallest reason of all: the
// buffer holds one word somebody chose for a filter. It is wiped anyway, because the rule this
// folder follows is that plaintext does not outlive the call that made it, and an exception for
// the case that seems harmless is how the rule stops being one.
CryptographicOperations.ZeroMemory(plaintext);
}
}
/// <summary>Decrypts a tag.</summary>
/// <inheritdoc cref="HostCipher.TryOpen" path="/returns" />
public static TagSecretDocument? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
ArgumentNullException.ThrowIfNull(payload);
if (itemVersion < 1 || payload.WrappedDataKey.Length == 0)
{
return null;
}
var dataKey = ItemKeys.TryUnwrapDataKey(
vaultKey,
payload.WrappedDataKey,
Resource,
entityId,
payload.KeyGeneration,
(uint)itemVersion);
if (dataKey is null)
{
return null;
}
try
{
var plaintext = ItemKeys.TryOpenPayload(
dataKey,
payload.Envelope,
Resource,
entityId,
payload.DataKeyId,
payload.KeyGeneration,
(uint)itemVersion);
if (plaintext is null)
{
return null;
}
try
{
return TagSecretCodec.TryDecode(plaintext, out var document) ? document : null;
}
finally
{
CryptographicOperations.ZeroMemory(plaintext);
}
}
finally
{
CryptographicOperations.ZeroMemory(dataKey);
}
}
}
+58
View File
@@ -0,0 +1,58 @@
using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
namespace DodoSSH.Client.Sync;
/// <summary>
/// The tags in this vault, decrypted, with unpushed local changes laid over them.
/// </summary>
/// <remarks>
/// <para>
/// Another facade over the same generic repository, and like the ones before it, it needed no new sync logic
/// at all.
/// </para>
/// <para>
/// <b>Deleting a tag does not touch the hosts wearing it</b>, for the reason
/// <see cref="HostGroupRepository"/> gives about groups and one more besides. The shared reason: one user
/// action would become N host writes, N outbox rows and N chances to merge against an edit nobody made, and
/// the tag's own tombstone can still lose a merge — by which time the membership it was clearing is gone.
/// The extra one: a host can wear several tags, so unpicking one from N hosts is N read-modify-writes of a
/// set rather than N clears of a pointer, and any of them that loses a merge leaves the vault holding a tag
/// that was deleted and is still worn. Hosts left holding a dangling id simply do not show that chip. See
/// <see cref="HostSecret.TagIds"/>.
/// </para>
/// </remarks>
public sealed class TagRepository(
ItemStore items,
OutboxStore outbox,
VaultKeyring keyring,
IActivityLogSink? activity = null)
{
private readonly VaultItemRepository<TagSecret> tags =
new(TagKind.Instance, items, outbox, keyring, activity);
/// <inheritdoc cref="VaultItemRepository{TSecret}.ListAsync" />
public Task<ItemListing<TagSecret>> ListAsync(
Guid vaultId,
CancellationToken cancellationToken) =>
tags.ListAsync(vaultId, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.CreateAsync" />
public Task<Guid> CreateAsync(
Guid vaultId,
TagSecret tag,
CancellationToken cancellationToken) =>
tags.CreateAsync(vaultId, tag, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.UpdateAsync" />
public Task UpdateAsync(
Guid vaultId,
Guid entityId,
TagSecret tag,
CancellationToken cancellationToken) =>
tags.UpdateAsync(vaultId, entityId, tag, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
tags.DeleteAsync(vaultId, entityId, cancellationToken);
}