Files
DodoSSH/docs/reaching-a-host-you-cannot-dial.md
T
jaap-jan 6185d74800 Write down the two things a user is promised and does not get
Two plans, both for the same class of defect: a control or a code that a user is told to rely on, backed by
storage and by nothing else. Neither is started; what follows is the reasoning, so that starting is not
where it gets thought about.

── UNLOCKING WITHOUT THE PASSPHRASE ─────────────────────────────────────────────────────────────────────
Every account is issued a recovery code at enrollment. The client generates it, wraps the identity bundle
under KEK_rc, the server stores that wrap as UserKeyWrapKind.Recovery, and both heads work to make sure the
user writes it down — the phone raises FLAG_SECURE for that screen alone and will not let anybody past it.
Nothing can use it. SessionOpener has UnlockAsync and UnlockWithDeviceAsync, and there is no third.

Walk the failure through: forget the passphrase, and the bundle cannot be unwrapped, so no vault key opens
and every item is unreadable. Signing out and back in returns the same passphrase wrap. The device key
would be the other door, and sign-out withdraws it — which is the advice the unlock screen gives for
exactly this situation. The loss is total and permanent, and the thing built to prevent it is inert.

The docs already disagree with each other about this, which is how it surfaced. manual-checks §10.2 calls
the code "the only thing standing between a forgotten passphrase and an unrecoverable vault"; android-port
says losing it *along with* the passphrase is what makes a vault unrecoverable; README says signing out is
the only answer and nothing can recover one. The third is the true one today.

More than half the work is already done and one piece of it was done on purpose: LocalCacheKey derives from
the identity bundle rather than from MK — crypto.md §3.2, changed 2026-07-30 — specifically so an unlock
that never computes MK can still read the cache it wrote. What is missing is an endpoint to serve the wrap,
an unlock path, and a way to set a new passphrase afterwards, without which the account unlocks with a
one-time code forever. That last step is the same re-wrap a change-passphrase feature needs, so it delivers
both.

Two traps are recorded because both would produce a code that verifies nowhere. The derivation uses the
displayed string *including its dashes*, so the unlock must canonicalise to the printed form rather than
strip it; and the recovery wrap uses a different Argon2 profile to the passphrase one (64 MiB against 256),
so it must derive from the parameters served with the wrap rather than from a profile constant.

── REACHING A HOST YOU CANNOT DIAL ──────────────────────────────────────────────────────────────────────
This started as "delete the dead jump-host field" and inverted twice.

HostSecret.JumpHostIds is stored, validated, encoded and three-way merged, and nothing reads it or writes
it — the ssh_config importer looks like the writer and is not; it records ProxyJump as an option and a note
saying DodoSSH cannot honour it. The first draft recommended deleting it. That was wrong twice over. ADR
0004's last consequence had already designed the implementation — a loopback TCP bridge for the relay, and
"the same bridge provides ProxyJump via a SOCKS5 dynamic forward", one mechanism and two features — which
the pinned SSH.NET 2025.1.0 supports through ForwardedPortDynamic and ProxyTypes.Socks5, checked in
Renci.SshNet.xml rather than remembered. And the stored shape is right: an ordered list of host ids is what
a chain is, the merge arm is correct, and the missing schema version is a line to add.

◆ Looking properly found the same shape one field over, where it costs something. RelayEnabled is also
stored, merged and never read by the connect path — but it is user-settable, and both heads draw a checkbox
promising it. Ticking it moves the host's address and port out of the encrypted payload into plaintext
columns, which ADR 0004 calls the single deliberate concession in the design, and then the client dials
directly anyway. The privacy is spent and the feature is not delivered. That is a defect rather than a gap,
and it is step 0.

The comparison the plan turns on: the relay reaches what the *deployment* can reach and the jump host
reaches what a *machine in the keychain* can reach, so they are not substitutes. On a self-hosted box
outside the target's network the relay reaches nothing the laptop could not. And the privacy ordering is
the opposite way round from the ADR's framing — the relay costs a plaintext address, the jump host costs
nothing, because the operator is not in it.

Both documents carry a section on what their own earlier reasoning got wrong, which for the second one is
the load-bearing part: "nothing reads this field" was read as evidence of a mistake when it was evidence of
an unfinished feature — and the same sentence one field over would have found the checkbox that is lying.
2026-08-07 08:44:21 +02:00

13 KiB

Reaching a host you cannot dial

Some machines do not answer from where the user is sitting. This product has two answers to that, and neither of them works.

  • The relay pipes raw TCP through the deployment. The server half is built and shipped; the client half does not exist, and both heads offer a checkbox that promises it.
  • A jump host reaches the target through a machine already in the keychain. HostSecret.JumpHostIds stores the chain; nothing writes it and nothing reads it.

This document is the comparison between them, which is the thing that has to be settled before either is built, and then the plan. It replaces an earlier draft of docs/jump-hosts.md that recommended deleting the jump chain — see the last section for why that was wrong.

Status: planned, nothing started. Step 0 is a one-line honesty fix and should not wait for the rest.

Step State Notes
0. Stop promising the relay Not started The checkbox is wired to storage and to nothing else
1. The loopback bridge Not started ADR 0004's "one mechanism, two features"
2. Jump hosts over it Not started No server change at all
3. The relay over it Not started Ticket call, WebSocket, then the same bridge
4. File transfer parity Not started The transfers screen opens its own connection

◆ The relay's checkbox is a false promise, and that is a defect

HostSecret.RelayEnabled is stored, validated — a relay host may not inherit its port — encoded, merged, and drawn as a checkbox in the host editor on both heads. The desktop's says "Connect through the server relay" and warns underneath that the address will be stored on the server in plain text. The phone's says the same at more length.

Nothing on the client reads it. VaultViewModel builds SshConnectionRequest(hostname, port, username, credential) and SshNetConnectionFactory dials that address directly, whether the box is ticked or not.

So a user who ticks it pays the privacy and gets nothing: the host's address and port leave the encrypted payload and land in plaintext columns on the server — the one deliberate concession in the whole design, per ADR 0004 — and the connection is still made from their laptop to the machine they already could not reach. It then fails exactly as it did before, with no hint that the box did nothing.

This is worse than the jump chain, which is invisible and harmless. It is a control that spends something real. Step 0 exists because it should not survive another release in that state, and it is one line: the checkbox says the relay is not wired up yet, the way this codebase already handles port forwarding on the phone's More screen.

The comparison

Both answers put something between the user and a machine they cannot dial. What differs is what is in between, what it costs, and who has to own it.

Relay Jump host
Reaches Anything the deployment can reach Anything a machine already in the keychain can reach
Asks of the deployment It must sit where it can dial the target, and have the relay enabled Nothing. The server is not involved at all
Tells the operator The host's address and port, in plaintext columns, for every opted-in host — plus an audit row per session: target, duration, bytes, close reason, client IP Nothing beyond the sync metadata every item already produces
The intermediate's credentials None to manage. The deployment is the intermediate The bastion is an ordinary host: its own key or password, its own host key to pin, its own group defaults
Where SSH terminates On the laptop. The relay sees ciphertext, and ADR 0004 is emphatic that no recording is possible On the laptop. The bastion forwards a TCP stream inside a session the user opened to it
When it is unavailable Deployment down, no connection — including to hosts that were reachable directly Bastion down, no connection to what is behind it
Fits an estate where The DodoSSH server is inside the network the targets are on A bastion is the policy and the server is outside — which is the ordinary enterprise shape
Auditable by the operator Yes, coarsely, and that is a feature for a team deployment No, and that is a feature for a private one

The two are not substitutes, and the deciding question is where the deployment sits. The relay only answers "unreachable" when the server has line of sight the laptop lacks — a deployment inside the VPC, on the office network, on the same Tailnet. Point it at a self-hosted box outside the target's network, which is what most people running this on a VPS will have, and the relay reaches nothing the laptop could not already reach.

And you do not get to choose other people's topology. Shipping this to strangers means shipping into estates whose shape is already decided, and bastion-fronted is the common one. Their ssh_config says so: the importer reads ProxyJump, records it as an option and writes a note on the host saying "DodoSSH does not route through a jump host yet" — a first-run experience that names the limitation on the hosts it matters for.

A relay is not a bastion with better manners. ADR 0004 rejected "server terminates SSH" and kept zero-knowledge, which is right and is not what a jump host asks for either: forwarding a TCP stream through a machine the user has authenticated to reveals nothing to the operator, because the operator is not in it. The privacy ordering is the opposite of what the ADR's framing suggests — the relay is the mechanism that costs a plaintext address, and the jump host is the one that costs nothing.

They are one piece of work, and ADR 0004 says so

The last consequence in ADR 0004, written before either half was built:

On the client, SSH.NET cannot be handed a pre-connected stream, so the relay is reached via a loopback TCP bridge. The same bridge provides ProxyJump via a SOCKS5 dynamic forward — one mechanism, two features.

That is the plan, and it holds up against the pinned package. SSH.NET 2025.1.0 offers ForwardedPortDynamic, which is a SOCKS5 proxy served over an established SshClient, and ConnectionInfo(host, port, username, ProxyTypes, proxyHost, proxyPort, proxyUsername, proxyPassword, AuthenticationMethod[]) with ProxyTypes.Socks5 — checked in Renci.SshNet.xml rather than remembered. So:

  • Jump host: connect to the bastion as an ordinary host, AddForwardedPort(new ForwardedPortDynamic(0)) on it, then dial the target with a ConnectionInfo pointed at that loopback SOCKS5 port. A chain of two is the same trick twice.
  • Relay: the same shape with a different thing on the loopback socket — a listener that pipes bytes into the dodossh.relay.v1 WebSocket instead of into a bastion's forward.

Which means the transport work is shared and the ordering is: bridge, then the cheap feature, then the one that needs the server.

The work, in order

0. Stop promising the relay. The checkbox states that the relay is not wired up yet. One line on each head, and it is the only step that should ship on its own.

1. The bridge. A loopback TcpListener on an ephemeral port that accepts exactly one connection, hands it to a Stream supplied by whoever opened the bridge, and disposes with the session. It belongs in Client.Ssh beside SshNetConnectionFactory, and it needs to bind 127.0.0.1 explicitly — a bridge on 0.0.0.0 is an open SOCKS proxy on the user's network for the life of a shell.

2. Jump hosts. No server change. In order:

  • SshConnectionRequest grows a route: the resolved chain, each hop carrying what a connect needs, so the SSH layer is handed hops rather than ids and never looks anything up.
  • VaultViewModel resolves JumpHostIds to hosts in the same vault, applying group inheritance per hop the way the target already gets it, and refuses a chain that crosses a vault — the same refusal RefusesTheDrop and the group picker already make, for the same reason.
  • Per-hop host keys. Each hop is a separate handshake against a separate endpoint, so the pin, the unknown key prompt and the changed-key refusal run per hop. The prompt has to name which hop it is about, or somebody approves a bastion's fingerprint believing it is the target's — see HostKeyCard, which is built around one connection and one question.
  • Per-hop credentials, including a hop that wants a typed password. IsAskingForConnectPassword asks about one host today.
  • Teardown: the hops belong to the outer session and go with it, including when the outer connect fails half way. A leaked bastion connection is an open session on a machine the user believes they left.
  • The schema version. A chain becomes a real field, so it joins the ladder in HostSecretCodec — a host carrying one must not be editable by a client that would drop it. That is the whole point of the rule.
  • The editor: a picker over other hosts in the same vault, and the host detail's subtitle finally getting the ⤷ bastion-eu the design asked for.

3. The relay. POST /relay/tickets with the host id, then the WebSocket with the ticket in Sec-WebSocket-Protocol, piped into the bridge from step 1. The ticket is single-use and expires in 30 seconds, so it is fetched per connect and never cached. Then the checkbox from step 0 becomes true.

4. File transfer. ISftpSessionFactory.OpenSftpAsync opens its own second connection, so a host that needs a chain or a relay to reach needs it there too, or SFTP silently fails for exactly the hosts this work exists for.

Traps already known

A relay host may not inherit its port, and a jump host has no such rule. TryValidate enforces the first because the server stores the port and a group edit would silently change what the relay dials. The chain has no plaintext counterpart, so it inherits normally — do not copy the restriction across out of symmetry.

Two hosts can name each other. A chain is ids, and nothing stops A jumping through B while B jumps through A. Resolve iteratively with a visited set and refuse a cycle before dialling anything, rather than discovering it as a stack overflow inside a connect.

The bastion's own group defaults matter. A hop is a host, so it resolves its port, username and binding through HostInheritance exactly as the target does. Skipping that dials 22 as nobody on a bastion that is on 2222 as deploy.

ForwardedPortDynamic(0) and reading the port back. Binding an ephemeral port and then asking for the one that was assigned is the part that varies between SSH.NET versions; pin it with a test that opens one against the test sshd rather than trusting the number.

The relay bridge and the jump bridge are the same class and not the same lifetime. A ticket is single-use with a 30-second expiry; a bastion's forward lives as long as the session. Sharing the listener is right, sharing a lifetime policy is not.

Tests

  • A two-hop connect against the Testcontainers sshd, which Client.Ssh.Tests already stands up — one container as bastion, one as target, with the target refusing connections from anywhere else.
  • A cycle in a chain is refused before any socket is opened.
  • Each hop's host key is asked about separately, and the question names the hop.
  • A chain crossing a vault is refused with a reason, as the group picker's is.
  • The bridge binds loopback only — assert the bound address, because the failure is silent and the consequence is an open proxy.
  • SFTP to a host behind a chain, once step 4 lands.
  • Mutations that must fail something: bind the bridge on IPAddress.Any; drop the visited set; skip group inheritance for a hop; and tear down the outer session without the hops.

Prose that becomes false

  • docs/design-import-gaps.md — the host subtitle's ⤷ bastion-eu row, the SFTP sftp over bastion-eu row, and the status bar's via bastion-eu row, all of which say jump hosts are data-only.
  • Client.Import/ImportedHost.cs — the note written onto every imported host with a ProxyJump, and the remark above it.
  • README.md and docs/android-port.md wherever the relay is described as available.
  • ADR 0004 gains a note that its last consequence was built, and how.

What the first draft of this document got wrong

It recommended deleting JumpHostIds, on the evidence that nothing writes it, nothing reads it, and it is missing from the schema-version ladder. The first two facts are true and the conclusion did not follow.

Two things were missed. ADR 0004 had already designed the implementation — the loopback bridge, the SOCKS5 dynamic forward, "one mechanism, two features" — so the transport was a solved problem sitting in an accepted ADR, and the fortnight that draft estimated was priced without it. And the stored shape is right: an ordered list of host ids is exactly what a chain is, the merge arm is already correct, and the missing schema version is a line to add rather than evidence of a bad model.

The lesson is narrower than "read the ADRs": it is that nothing reads this field was taken as evidence the field was a mistake, when it was evidence of an unfinished feature — and the same reasoning applied one paragraph further would have found the relay checkbox, which is the same shape and is actively lying to users.