Give the phone both pickers, and settle who signs the APK

The files screen could browse a remote and delete on it, and that was all: there
is no browsable local filesystem on Android for a second pane to show, so the
gesture the desktop is built around — choose on the left, press the arrow — has
nothing to stand on. What replaces it is the platform's own two pickers. ADD
FILES is ACTION_OPEN_DOCUMENT, so a document is pointed at wherever it lives and
goes to the directory showing; SAVE FILE is ACTION_CREATE_DOCUMENT for the
selected row.

Both stage through the application's cache, and that copy is a requirement
rather than a shortcut. android-port.md predicted a picked document would be a
third IRemoteFileStore beside SFTP and S3; it cannot be. FileTransferQueue seeks,
because an upload resumes from the byte the last attempt reached, and a
content:// URI has no path behind it, no length worth trusting, no promised seek
and no grant that survives the document being edited underneath it. Copying
first costs one class in the head and nothing at all in the shared layers, where
the alternative was every resume rule rewritten around a stream that cannot
rewind. The copy is deleted when the transfer completes, kept while it is stopped
so RESUME still has something to read, and swept at the next launch — which is
the one moment emptying that directory is provably safe, since nothing has
queued anything yet.

Coming out had a decision going in did not: when to ask where it goes. The save
picker is raised before the transfer, so the download runs into the same staging
directory and hands its bytes to a callback the head supplied, held against the
transfer id so a RETRY still lands where the person pointed. Asking afterwards
would put the picker minutes from the button that caused it and, on a phone,
usually while the application is backgrounded and Android will not show one at
all. The cost is that the picker creates its file when it is dismissed, so a
download that then fails leaves an empty one there; that is said on the screen,
in the README and in the manual checks rather than left to be discovered. A
delivery that fails keeps the staged bytes for the sweep instead of throwing away
the one copy of something just fetched over somebody's network.

The foreground service counts transfers now, which is the half of it that
matters most here: a shell survives backgrounding because somebody is looking at
it, and an upload has to survive precisely when nobody is. Queued counts as
active, so putting five files in and locking the phone moves five files. The
seam was built for this and wired to () => 0 because nothing could fill the
queue.

Alongside it, ADR 0010 answers the second question android-port.md left open,
and it had to be answered before the first release rather than at upload time: a
new Play app must use App Bundles and therefore Play App Signing, and an
installed app can only be updated by a package signed with the same key, so the
first release picks an identity for good. The project holds the key, offline and
never in CI — the workflow's package step now says so where somebody would break
it — and a DodoSSH deployment never serves the client, because a download link on
your own server hands the binary that holds the plaintext to the party the whole
threat model is about.

The README's M1 gap note was stale in both halves and is replaced by what is
actually true: credentials have an editor and a REMEMBER tick, and the device key
registers into the TPM under a CNG policy that makes the consent dialog a
condition of using it. What is left is the floor rather than a gap — no TPM, or
no Windows, means the passphrase on every launch.
This commit is contained in:
2026-08-04 10:07:16 +02:00
parent 7b7fd7b2ef
commit ebb88c8ae4
13 changed files with 1032 additions and 59 deletions
+39 -11
View File
@@ -186,10 +186,15 @@ signing in again brings it all back; the count in the confirmation is the one th
else. Your session at the identity provider is *not* ended — DodoSSH has no way to end it — so on a machine
that is not yours, sign out there too.
Two of M1's known gaps are visible immediately, so they are worth expecting rather than diagnosing: password
authentication asks for the password every time, because nothing in the interface can create a vault
credential yet (they do sync — there is just no editor for one); and unlock asks for the passphrase on every
launch, because no device key is registered.
**Neither a password nor a passphrase has to be typed twice**, and both ways out of that are opt-in. A
password typed to connect is typed once: tick **Remember this password** under the box and it is saved to
your keychain and bound to that host the moment the remote accepts it — or add one outright with **+
PASSWORD** on the Vault screen. And unlock can be a Windows confirmation instead of the passphrase:
Preferences → *This machine***REGISTER** keeps this machine's device key in the TPM, so a later launch
offers **USE WINDOWS HELLO** on the unlock card. A machine with no TPM — and any desktop that is not
Windows — is offered neither button and keeps asking for the passphrase, which Preferences says out loud
rather than leaving you to notice. The passphrase never stops working either way: a declined confirmation
leaves the box exactly where it was.
### Moving files
@@ -360,8 +365,20 @@ design's blue-tinted `#E3E7F4`, and the type scale is a point larger.
File transfer **is** here now, in the shape scoped storage allows: one remote pane and the queue, over
either an SFTP host or a bucket. There is no local pane, because there is no browsable local filesystem to
put in one — moving a file *in* from the phone needs the system document picker and is the next piece of
work rather than a thing the screen pretends to do. Hosts and groups are made and corrected here now, from
put in one. So the way in is **ADD FILES**, which is the system document picker: point at a document
wherever it lives and it goes to the directory showing, rather than choosing on the left and pressing an
arrow. What Android hands back is a `content://` URI with no path behind it and no promise of a seek, so
the document is copied into the app's own cache and the copy is what the queue moves — which is what lets
a stopped upload resume from where it stopped. The copy is deleted when the transfer finishes, kept while
it is stopped so RESUME has something to read, and swept at the next launch.
**SAVE FILE is the way back out**, and it is the system's save picker for the same reason: there is nowhere
this application could put a file that you would then be able to open. You choose where it goes before the
transfer starts, the download runs into the cache, and the finished bytes are copied out to the document you
chose. That order has one visible cost, and the screen says it rather than leaving it to be discovered: the
picker creates the file when you dismiss it, so a download that then fails leaves an empty one there. The
alternative is a picker that appears minutes later over whatever you moved on to — and often while the app
is in the background, where Android will not show one at all. Hosts and groups are made and corrected here now, from
a floating + on the Hosts screen, and both editors are cards in the list's own row rather than dialogs, so
the form never covers the thing it is about. The keychain has no editor of its own: SSH keys and buckets are
created on the desktop and sync down, and the phone will delete an item — behind the same counted
@@ -506,10 +523,15 @@ keychain plus a terminal — and the spike that gates all of it.
host key approved at that shell's prompt reaching the second machine as well. See
[End-to-end verification](#end-to-end-verification).
Known gaps in the client, stated rather than implied by the interface: nothing in the interface can create
a vault credential yet, so password authentication still asks for the password each time — SSH keys *are*
editable, and binding one to a host is the way to connect without typing anything; and no device key is
registered, so the passphrase is needed on every launch until the OS keystore is wired.
The two gaps this milestone shipped with have both closed since. A vault credential can be created — from
the Vault screen, or from the REMEMBER tick beside the connect password, which saves it and binds the host
to it once the remote has accepted — so password authentication asks once rather than every time. And a
device key is registered where the machine can hold one: Windows keeps it in the TPM under a CNG policy
that makes the consent dialog a condition of *using* the key rather than a prompt this application draws,
which is stronger than [ADR 0007](docs/adr/0007-device-key-protection.md) originally described and is why
that ADR was corrected. What is left is the floor rather than a gap: a machine with no TPM, or a desktop
that is not Windows, gets a store that reports itself unavailable and keeps asking for the passphrase —
the honest answer rather than a degraded one.
Host key trust *is* in the vault, which is what makes trust-on-first-use worth having: a fingerprint
approved on one machine is approved on all of them and survives a restart, and the server cannot drop a
@@ -594,7 +616,13 @@ keychain plus a terminal — and the spike that gates all of it.
authority to finish a transfer that stopped in the middle. Nothing else is touched: you keep your vault
key grants, because removing the outgoing owner would revoke them and flag every team vault for rekey,
which is a much larger act than the one being asked for.
- **M4 — hardening and ops**, packaging, self-hosting guide.
- **M4 — hardening and ops**, packaging, self-hosting guide. *Decided ahead of the work, because the first
release takes it irreversibly:* who signs the client and where it comes from.
[ADR 0010](docs/adr/0010-android-distribution.md) puts the release key with the project rather than with
a store, and rules out the arrangement a self-hosted product reaches for by default — the deployment
serving the client binary, which hands it to the one party the whole trust model is about. An installed
Android app can only ever be updated by a package signed with the same key, so this is the first
release's decision to make and nobody else's afterwards.
- **M5 — multi-provider OIDC**, key rotation, per-item content keys.
## Licence