Files
DodoSSH/src/DodoSSH.Client.Sync/HostCipher.cs
T
jaap-janandClaude Opus 5 b7335743d9 Make a host take what its group lends it, everywhere it is read
Step 4 of docs/adding-hosts-on-the-phone.md. The domain could resolve a host
against its groups; nothing asked it to. This is the wiring, and it is mostly
one change repeated: read the resolved host, not the stored one.

TryBuildAuthentication and TryBuildConnectionRequest now take the resolved
value beside the stored one, which is where group context was being lost. It
is the only authentication resolution in the product — both heads and both
transports come through it — so a host inheriting its binding would otherwise
have been offered a password prompt on every screen at once. The
credential-username fallback becomes three levels, and Complete still refuses
an empty username, but now only after the chain has been walked; refusing
before it would refuse exactly the hosts inheritance exists to serve.

HostRowViewModel carries its ResolvedHost, resolved once when the list is
built. Address, Authentication and Dialled read it, so a row cannot disagree
with itself about what it dials — and MainWindowViewModel.Rank searches
Address, so a host inheriting 2222 that displayed 22 would have been
unfindable by the port it actually answers on.

HostsBoundTo counts over the resolved binding, which is the difference between
a warning and a silence: a key bound once on a group and inherited by twenty
hosts named nobody, would have been deleted, and would then have refused all
twenty at connect time.

HostFields.From is answered by a refusal rather than by threading a group list
through the sync engine. A relay host may not inherit its port. The reason is
stronger than the convenience: a plaintext column is a derived duplicate the
client supplies when it pushes *this* host, so an inherited port would make it
depend on another item — editing a group would change what the relay dials for
every host beneath it, except that nothing re-pushes those hosts, so the server
would keep dialling the old port until each was next touched for some unrelated
reason. A stale wire on the relay path connects the user to the wrong service.

The editor distinguishes unset from explicit in both directions. An empty port
box means "take the group's" and shows what that will be as a placeholder,
following the group picker as it moves — a pre-filled 2222 would have been
indistinguishable from one the user typed, and saving would have pinned it. The
authentication picker gains a fourth entry, offered only to a host in a group,
because for an ungrouped host it would behave exactly like the first.

Which found a real defect while the tests were being written. Filing an
ungrouped host into a group silently pinned it to a typed password: the picker
had no "Inherit" entry when it opened, so it sat on "Password (ask each time)",
and saving wrote that as a decision — the host would have been pinned to a
prompt nobody asked for and the group's key would never have reached it. Two
guards now: the picker is rebuilt when the group changes, and BuildHost writes
AsksForPassword only for a host that had the alternative on offer.

The group editor is here too, and the plan never assigned it a step. Without it
no group can carry a default, so every line above would have been unreachable.
It grows a parent picker that leaves out the group itself and everything
beneath it — a courtesy rather than the guarantee, since a cycle assembled from
two offline re-parents was never offered that list — and three defaults beside
the name, each of which may be left empty because "lend nothing" is an answer.

Tags are stored and not editable. TagIds merges, encodes and resolves; no
screen can set one yet, and the editor carries the set through a save untouched
so a client that can set them does not lose them to somebody editing a port.

Eight new tests, and they dial. That is the point of them: a resolved value
that never reaches SshConnectionRequest is a label, and every one of these
failures would be silent — a host connecting to the wrong port, or being asked
for a password it does not need, with nothing on screen admitting it.

Verified by the whole suite: 1390 tests over nineteen projects, none failing.
Both heads build. Nothing on the phone has changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 10:41:28 +02:00

188 lines
7.2 KiB
C#

using System.Security.Cryptography;
using DodoSSH.Client.Domain;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Sync;
/// <summary>
/// Turns a host into an item payload and back.
/// </summary>
/// <remarks>
/// <para>
/// Every operation binds to the item's identity, its key generation <em>and</em> its version, because
/// that is what <c>DshAad.ItemPayload</c> requires. The version part has a consequence worth stating
/// plainly: a payload must be sealed at the version the server will assign, not the version it is
/// replacing. See <see cref="SyncVersions"/>.
/// </para>
/// <para>
/// The data key is fresh per call and is zeroed before returning, as is the encoded plaintext. Neither
/// is ever handed to a caller: a data key that escaped this class would be a data key some other layer
/// could forget to clear.
/// </para>
/// </remarks>
public static class HostCipher
{
private const CryptoSpec.AadResourceType Resource = CryptoSpec.AadResourceType.Host;
/// <summary>
/// Encrypts a host.
/// </summary>
/// <param name="host">The host. 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 — one more than the version being
/// replaced. Sealing at the version being replaced would produce a payload that authenticates
/// against a row that no longer exists, and the item would read as corrupt from then on.
/// </param>
public static EncryptedPayload Seal(
HostSecret host,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion)
{
ArgumentNullException.ThrowIfNull(host);
ArgumentOutOfRangeException.ThrowIfLessThan(itemVersion, 1);
var plaintext = HostSecretCodec.Encode(host);
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);
CryptographicOperations.ZeroMemory(plaintext);
}
}
/// <summary>
/// Decrypts a host.
/// </summary>
/// <returns>
/// The host and the schema version it was written at, or <see langword="null"/> if the payload does
/// not belong to this item, version or generation, or does not parse.
/// <para>
/// A null is a meaningful outcome, not an error to be thrown past. It is what a server relocating
/// ciphertext between rows looks like from here, and it is also what an ordinary rekey looks like
/// before new grants arrive. The caller distinguishes them by comparing generations; either way one
/// unreadable item must not abort a sync pass and strand every change behind it.
/// </para>
/// </returns>
public static HostSecretDocument? 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 HostSecretCodec.TryDecode(plaintext, out var document) ? document : null;
}
finally
{
CryptographicOperations.ZeroMemory(plaintext);
}
}
finally
{
CryptographicOperations.ZeroMemory(dataKey);
}
}
}
/// <summary>
/// The one place that decides which item version a payload is sealed at.
/// </summary>
/// <remarks>
/// The payload's AAD binds the item version, so the sealing side has to predict what the server will
/// assign. That prediction is safe because it is checked: the server applies an upsert only when
/// <c>expectedVersion</c> matches, and then increments by exactly one. A mismatch is a conflict, not a
/// silently mis-sealed row. Both the sealing and the opening sides go through here, so they cannot
/// drift — the failure if they did would be an item that encrypts fine and never decrypts again.
/// </remarks>
internal static class SyncVersions
{
/// <summary>The version an accepted upsert will produce.</summary>
/// <param name="expectedVersion">The version being replaced, or null for a create.</param>
internal static int NextVersion(int? expectedVersion) => (expectedVersion ?? 0) + 1;
}
/// <summary>
/// Derives the plaintext columns the server needs from a host.
/// </summary>
/// <remarks>
/// <para>
/// The single point at which a hostname can leave the encrypted payload, which is the whole reason it
/// is a function rather than something each call site assembles. The address is emitted only when the
/// user has turned the relay on for that host; with relay off, the server learns nothing but that an
/// item exists. See ADR 0004 for why the relay cannot work any other way.
/// </para>
/// <para>
/// <b>The port is the host's own, and it is allowed to be, because a relay host may not inherit one.</b>
/// This runs from inside the generic write path — see <c>IItemKind{TSecret}.Fields</c> — which holds one
/// secret and has no group list to walk, and threading one in would put the group chain inside the sync
/// engine for the sake of a single column. It does not have to: <see cref="HostSecret.TryValidate"/>
/// refuses a relay host with no port of its own, so the branch below cannot be reached by a host that
/// inherits. That refusal exists for a stronger reason than this convenience — a plaintext column derived
/// from another item goes stale when that item is edited, and nothing re-pushes the hosts beneath it.
/// </para>
/// </remarks>
internal static class HostFields
{
internal static SyncPlaintextFields From(HostSecret host) =>
host.RelayEnabled
? new SyncPlaintextFields(RelayEnabled: true, Hostname: host.Hostname, Port: host.Port)
: new SyncPlaintextFields();
}