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
+125
View File
@@ -0,0 +1,125 @@
# ADR 0010 — Distributing the Android client, and who holds the release key
- Status: accepted
- Date: 2026-08-03
- Builds on: [ADR 0001](0001-e2ee-trust-model.md)
- Settles: the second open question in [`docs/android-port.md`](../android-port.md#still-open)
## Context
[ADR 0001](0001-e2ee-trust-model.md) ends on the hole it cannot close with cryptography: **an operator
who wants the secrets attacks the client, not the crypto**, and what that costs is *release signing with
a key not held by the server*, and eventually reproducible builds. Until now that sentence had nothing
to bind to. The desktop head is not packaged at all — packaging is M4 — and the Android head is a debug
artefact: CI runs `-t:SignAndroidPackage` with no keystore, so it is signed with the debug key .NET for
Android falls back to when `AndroidKeyStore` is false, and that APK is a build check rather than
something anyone installs.
The first release changes that, and it does so **irreversibly**, which is why this is decided here
rather than at upload time. Two Android facts make it a one-way door:
- **An installed app can only be updated by a package signed with the same key.** The signing key is
the app's identity for its whole life; changing it means every existing user uninstalls first, losing
their local cache and re-enrolling. (v3 signature rotation exists, but the lineage has to be created
*before* it is needed, by the key it is rotating away from.)
- **A new app on Google Play must ship as an App Bundle, which means Play App Signing**, so Google
generates and holds the key that signs what users install; the developer holds an upload key only.
There is no un-enrolling. For an app that already exists outside Play, the only way to keep one
package id across both channels is to *hand Google the existing key*.
So "publish on Play" and "hold our own key" are not two settings. They are two package identities, and
the first release picks one.
The third party changes but the shape does not: **whoever can sign an update can ship one person a
build that copies the passphrase.** The vault's encryption is irrelevant to that attack — the client is
where the plaintext is, by construction (ADR 0001, `Connect` cannot be a security boundary). So this ADR
is about *which* parties are in that position, not about removing them, and there are three candidates:
the deployment operator, DodoTech, and Google.
## Decision
**DodoTech holds the release key, the deployment never serves the client, and Play is a separate
decision that has not been taken.**
1. **One release key, held by the project, kept offline, and never in CI.** Release signing is a
deliberate manual step on a machine that is not a runner. CI keeps doing exactly what it does now —
packaging with the debug key to catch link-time failures — and must never gain a keystore secret or
an `AndroidKeyStore=true`. A signing key in CI is a key held by whoever can push a workflow file,
which for a public repository is a wider set than it looks.
2. **The APK is published on the project's own release page, and a DodoSSH deployment never distributes
it.** This is the refusal that carries the security content, and it is the one a self-hosted product
gets wrong by default: a "download the app" link on your own server is convenient, obvious, and hands
the client binary to the exact party ADR 0001 models as the adversary. The operator may tell people
where to get it. They may not be the place it comes from.
The same rule reaches the update path. A version check pointed at the deployment lets the operator
pin a chosen user to a known-vulnerable build by withholding the answer — a weaker attack than
signing one, and available without any key at all. If an update check is ever added it points at the
project's domain, and the first release simply has none: the release page is the channel and the
README says so.
3. **Play App Signing is not entered, and cannot be entered by accident.** Not because Google is a worse
custodian than DodoTech — on the mechanics it is a better one, since the key lives in Google's
infrastructure rather than on a laptop, and the reach and auto-update story is not close. It is
declined because of what it costs *this* product specifically: the buyers named in ADR 0001 are teams
who refuse to put infrastructure credentials in a SaaS, and telling them the client that holds their
plaintext is signed by a key the vendor cannot see is the same answer they already rejected, one
layer down. A targeted signed build compelled by a lawful order or produced from a compromised
console account is the archetype of the attack ADR 0001 calls the largest practical hole.
Deferring is cheap and reversing is not, so the default falls the deferrable way. Revisiting is a
second ADR, and it has two honest exits: hand Google the existing key and keep one identity, or take
a distinct package id and accept two apps. Both are worse decisions to discover than to take.
4. **Reproducible builds are the goal that makes all of the above matter less, and they are not
achievable today.** A build a third party can reproduce from source turns the signing key from a
trusted authority into a convenience — anyone can check that the published APK is the published
source. .NET for Android is not there: dex output, AOT images and archive timestamps are not
bit-reproducible across machines in practice. It stays the standing goal ADR 0001 names, recorded
here as the thing that would let point 3 be reconsidered on the merits rather than on custody.
5. **F-Droid is not a channel.** Its build server compiles from source and signs with its own key, which
would be a genuinely better transparency story — but it has no support for a .NET workload plus an
Android SDK toolchain, and this head needs both. Not refused; unavailable.
## Consequences
**The reach cost is real and should not be talked down.** Installing means enabling installation from
the browser or file manager, per source, on Android 8 and later — a permission the platform frames as
dangerous, correctly. There is no discovery, no automatic update, and no Play channel for a corporate
MDM to deploy from, which for a product sold to teams is the sharpest edge of this decision. What
partially answers it is that this client is installed by people who already run their own identity
provider and their own vault server; sideloading is not the strangest thing they will do that week.
**The key becomes a single point of failure with no recovery.** Losing it means existing installs can
never be updated again — not a bad update, *no* update — and the only way out is a new package id and a
manual migration. It is therefore backed up offline in more than one place, and a v3 rotation lineage is
created at the first release rather than at the first emergency, because a lineage can only be signed by
the key it replaces.
**The attack ADR 0001 names is narrowed, not removed.** DodoTech can still ship one user a malicious
build. What changes is that the deployment operator — the party the threat model is actually about, and
the one with a motive to read their own team's credentials — cannot, and that a compelled or breached
third-party store is not in the path either. That is the whole of what this decision buys, and it is
worth stating at that size rather than larger.
**M4's desktop packaging inherits rule 2 and not the rest.** Windows and macOS have no equivalent of
Play App Signing in the mandatory sense: Authenticode and Developer ID both leave the private key with
the developer, and Apple's notarization is a scan rather than a signature over the shipped binary. The
custody question is therefore easy there; the "not served by the deployment" rule is the part that
carries over, and it carries over unchanged.
## Rejected
- **Play as the primary channel, sideloading as the fallback.** This is the arrangement most Android
products land on, and it does not survive contact with rule 2's reasoning: it is the same shape —
the binary that holds the plaintext arriving through a party who can be compelled — with a larger and
better-resourced party in the middle. Better mechanics, same class.
- **Shipping the APK from the DodoSSH server it will talk to**, so a new phone gets the client from the
deployment it is enrolling against. Genuinely the nicest onboarding available, and it makes the
operator the distributor of the client that holds their team's credentials. Refused outright, and
named here because it will be proposed again.
- **A release key held by CI so tagging cuts a release.** The convenience is the point of CI and the
key is the point of this ADR; where they collide the key wins. Signing an artefact is one command, run
rarely, by a person.
+44 -13
View File
@@ -28,7 +28,9 @@ verified is that it compiles, links, packages, and carries the right natives.
**The shape agreed:** a **phone-first** client that is the keychain plus a **terminal**, with sessions and
transfers protected by a **foreground service**. File transfer is not in the first scope; when it arrives it
is **one remote pane** with Android's document picker for moving files in and out.
is **one remote pane** with Android's document picker for moving files in and out. *It has since arrived,
both ways:* the pane, the queue, `ACTION_OPEN_DOCUMENT` going in and `ACTION_CREATE_DOCUMENT` coming out,
with the foreground service now counting transfers as well as shells.
**What was actually checked**, so the rest can be read with the right amount of trust:
@@ -132,7 +134,9 @@ mean the transfer queue's local half (`LocalDirectory`, the drive list, the brea
code.
Note what *does* carry: `FileTransferQueue` itself, and `IRemoteFileStore` — Phase 6 already proved that
seam holds two very different remotes, and a `Uri`-backed Android document would be a third.
seam holds two very different remotes, and a `Uri`-backed Android document would be a third. (**⚠️ That last
clause is wrong, and the build is what corrected it**: a document URI cannot resume, so what shipped stages a
copy and hands over a path. See the note under the decision below.)
**Decided: one remote pane and the document picker**, and out of the first scope. See
[Decisions](#file-transfer-when-it-comes-one-pane-and-the-document-picker).
@@ -256,6 +260,24 @@ and `IRemoteFileStore` both carry over unchanged. Phase 6 already put a bucket b
an SFTP host, so a picker-granted document is a third implementation of a seam that has been exercised twice.
What is desktop-only is the *left* pane — `LocalDirectory`, the drive list, the breadcrumb trail.
> **⚠️ Corrected by the build. Both directions shipped, and neither as a third `IRemoteFileStore`.** A document
> URI cannot sit behind that interface honestly: the queue seeks, because an upload resumes from the byte the
> last attempt reached, and a `content://` stream promises no seek, no stable length, and no grant that
> survives the document being edited underneath it. So `DocumentStaging` copies the chosen document into the
> application's cache and hands the queue an ordinary path — which cost one class in the head and *nothing*
> in the shared layers, rather than a third implementation of a seam and every resume rule rewritten to cope
> with a stream that cannot rewind. The copy is deleted when the transfer completes, kept while it is stopped
> so RESUME can read it, and swept at the next launch.
>
> **Outbound is the mirror image, with one decision the inbound half did not have to take: when to ask.** The
> save picker is raised *before* the transfer, so `QueueDeliveredDownload` runs into the same staging
> directory and hands the finished bytes to a callback the head supplied. Asking afterwards would put the
> picker minutes away from the button that caused it and, on a phone, frequently in the background — where
> Android will not show one. The cost is that `ACTION_CREATE_DOCUMENT` creates its file when it is
> dismissed, so a download that then fails leaves an empty one; that is said on the screen and in the
> README rather than left to be found. A delivery that fails keeps the staged bytes for the sweep instead of
> deleting the one copy of something that was just fetched over somebody's network.
### Sessions survive backgrounding, via a foreground service
A persistent notification for as long as a shell or a transfer is live.
@@ -562,9 +584,11 @@ What is left, in the order it matters:
- **Running any of it on a device.** Still the one that is not optional, and still true: nothing here has
ever been launched on hardware or an emulator. Everything below is reasoning from the code.
- **The document picker.** The files screen browses a remote, downloads from it and runs the queue; putting
a file *on* a host from the phone needs `ACTION_OPEN_DOCUMENT` behind a third `IRemoteFileStore`-shaped
source, which is the decision recorded above and the obvious next piece of work.
- ~~**The save picker — the other half of file transfer.**~~ **Built**, along with the half before it: both
pickers go through Avalonia's storage provider and both stage through the cache. See the correction under
the decision above for the shape and for the one thing about it a person will notice — an empty file where
a failed download was pointed. What is *not* built is a folder picker for several downloads at once: the
save picker names one destination, so SAVE FILE takes the selected row.
- **Editors.** There is no host editor and no keychain item editor on the phone, so both are create-on-
desktop-and-sync. That is why the v2 design's `+` buttons on HOSTS and on the keychain are not drawn.
- **Pins and import**, which v2 does not draw either. Teams *is* drawn, behind MORE — it was the one of
@@ -575,7 +599,9 @@ What is left, in the order it matters:
## Still open
Neither of these blocks the spike, and both want answering before there is anything to release.
Neither of these blocked the spike, and both wanted answering before there was anything to release. Both
now have answers, kept here under the questions that produced them — the heading stays because what is
worth reading is which way each went and why, not that the list is empty.
- ~~**Which Android versions.**~~ **✅ Settled: minSdk 28, targetSdk 36**, and the reasoning divided in two.
@@ -591,13 +617,18 @@ Neither of these blocks the spike, and both want answering before there is anyth
devices that mostly cannot hold a hardware-backed key anyway — which is the one thing the store is for.
API 28 and 29 still cost one branch each in `BiometricGate`, because allowed-authenticator lists arrived
in 30.
- **How it is distributed, and what that does to the supply-chain story.** ADR 0001 says plainly that an
operator who wants the secrets attacks the client rather than the crypto, and that release signing with a
key **not held by the server** is what that costs. Play App Signing means Google holds the release key.
That is not necessarily wrong — it is a different, and in some ways better-audited, trust arrangement —
but it is a change to a documented security property of this product, and it should be reasoned about in
an ADR rather than discovered at upload time. Sideloading a self-signed APK preserves the current story and
costs reach.
- ~~**How it is distributed, and what that does to the supply-chain story.**~~ **✅ Settled in
[ADR 0010](adr/0010-android-distribution.md): the project holds the release key, the deployment never
serves the client, and Play is deferred.** The question was whether Play App Signing Google generating
and holding the release key — is a change to the security property ADR 0001 documents. It is, and the ADR
takes it as a one-way door rather than a setting: a new Play app must use App Bundles and therefore Play
App Signing, an installed app can only be updated by a package signed with the same key, so the first
release picks one identity for good.
Two things follow for this head *now*, before there is anything to release. CI keeps signing with the
debug key and must never gain a keystore secret. And no download link for the APK may ever be served by a
DodoSSH deployment — that hands the client binary to the party the threat model is about, which is a
worse arrangement than either of the two the question was originally between.
## Smaller things, decided by default
+1 -1
View File
@@ -46,7 +46,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> | Logs: a blinking **FOLLOW** pill over a live `tail` | A REFRESH button. These are the keychain's own synced audit records, written once at close and read when the screen opens — there is nothing to tail. |
> | Logs: `ALL 8 / INFO 5 / WARN 2 / ERROR 1` and per-host chips | The real two-way choice: connections, or keychain activity. A connection record is not a syslog line and carries no level, and neither log filters by host. |
> | SFTP: the `LOCAL / REMOTE` toggle | One remote pane. Scoped storage means there is no browsable local filesystem, which `docs/android-port.md` decided before any of this was built. |
> | SFTP: the transfer card, and moving files at all | ◆ **Browsing, opening and deleting only, and the screen says so.** Both transfer commands exist and both work — against the local pane. `QueueDownloads` writes to `Path.Combine(LocalPath, name)`, and `LocalPath` starts at `SpecialFolder.UserProfile`, which on Android is the application's own private directory: a download would report success and leave the file somewhere the person who asked for it cannot open. That is worse than not offering it, because a refusal is visible and a file in `/data/user/0/…` is not. The queue is not drawn either, since nothing on this head can put anything in it. Both return with the document picker. |
> | SFTP: the transfer card, and moving files at all | ◆ **Both directions ship, and neither is the button the design drew.** There is no local pane for an UPLOAD arrow to point away from, so the gestures are the platform's two pickers: ADD FILES is `ACTION_OPEN_DOCUMENT` — "point at a document wherever it lives" — and SAVE FILE is `ACTION_CREATE_DOCUMENT`, asked *before* the transfer because a picker raised on completion arrives over whatever the person moved on to, and often while the app is backgrounded and Android will not show one. Both stage through the cache, for the same reason: a `content://` URI has no path, no trustworthy length and no promised seek, and the queue needs all three to resume. `QueueDownloads` — the desktop's path, writing into `LocalPath` — is unused here, because `LocalPath` on Android is the app's own private directory. The queue is drawn now, since this head can fill it. |
> | S3: `96 objects · 412 GB` and `lifecycle: IA after 7d` | Omitted. Neither a bucket total nor a lifecycle policy is fetched; the listing is what `IRemoteFileStore` returns. |
> | A `+` floating button on **HOSTS** | **Shipped in v3.** It was omitted twice, on the grounds that it would open a host editor this head had not got — and that was true until one existed. It raises a sheet rather than a form, because "add" on this screen is two operations: a host, or a group to file hosts under. |
> | A 14px rounded inset around the terminal | Not drawn. The renderer is a native child view composited above everything Avalonia draws, so a rounded frame behind it clips nothing. What the design was after comes from the page's own background, which is the design's `#171A26`. |
+119
View File
@@ -1302,3 +1302,122 @@ rather than two. There is no confirmation prompt, deliberately.
**Failure means:** a phone that still unlocks itself after this is the local half not happening, which is the
half that matters when the handset is the thing that was lost.
---
## Phase 14 — Moving files to and from the phone's remote
Every check here needs a real Android device or emulator, a host with SFTP or a bucket, and at least one
document on the phone to send. What is automated is what can be: `TransferQueueingTests` says a staged
upload obeys the same rules as any other and that a delivered download refuses a directory before the
picker's damage is done. What cannot be automated is everything below — the two system pickers are another
application, and the staging copies, the delivery, the notification and the resume are all things only a
running phone does.
### 14.1 ADD FILES opens the system picker, and takes more than one
Connect to a host on SFTP, navigate somewhere writable, press **ADD FILES**, and choose two documents in one
go — long-press to multi-select in Android's picker.
**Pass:** two rows appear in the queue with the names the picker showed, and both land in the directory the
breadcrumb names. The pane's listing shows them after **↻**.
**Failure means:** one row from a two-document pick is `PickAsync` losing the rest, and two rows with one
name is the per-file staging directory having gone — that is the overwrite `DocumentStaging` documents, and
it silently uploads the same bytes twice.
### 14.2 The name that arrives is the name that was picked · **the one most likely to be wrong**
Pick a document whose display name has a space and a non-Latin character in it, and one from a cloud
provider — Drive, or the Downloads shortcut — rather than local storage.
**Pass:** the file on the host is called what the picker called it. A cloud document uploads too, or fails
with the provider's own message in the status line rather than a crash.
**Failure means:** a mangled name is `SafeName` over-reaching. A name that reaches the host with a `/` in it
is `SafeName` under-reaching, and that one writes to a path nobody chose. A cloud document that hangs is
the copy being made on the interface thread — the whole reason `CopyInAsync` leaves it.
### 14.3 The queue is bounded, and the buttons stay reachable
Queue five or six files at once, on a small phone if there is one.
**Pass:** the queue scrolls inside its own region and **ADD FILES**, **DELETE** and **CLOSE** are all still
on screen. Every button is a thumb's size.
**Failure means:** buttons pushed off the bottom is the `MaxHeight` gone from the queue's scroller, and it
makes the screen unusable exactly when somebody has queued the most work.
### 14.4 A stopped upload resumes rather than starting again
Start a large upload, press **STOP** part way, then press **RESUME**.
**Pass:** it carries on from roughly where it stopped rather than from zero — the progress text is the thing
to read.
**Failure means:** restarting from zero means the staged copy was deleted at the stop, which is precisely
what `QueueStagedUploads` does not do and why it does not. A failure saying the file cannot be found is the
same bug, one step further along.
### 14.5 The copies do not accumulate · **the one nothing else would catch**
Note the app's storage in Android Settings → Apps → DodoSSH → Storage. Upload a large file, let it finish,
and look again. Then stop an upload part way, leave it stopped, force-stop the app and relaunch it.
**Pass:** storage returns to about what it was after the successful upload — the copy is deleted the moment
the transfer completes. After the stopped one, the cache is bigger while the app stays open (the copy is
being kept for RESUME) and back to its old size after the relaunch, which is `DocumentStaging.Sweep`.
**Failure means:** growth after a successful upload is `ReleaseStaged` not firing, and every file sent
leaves a second copy on the phone until Android reclaims the cache. Growth that survives a relaunch is the
sweep not running.
### 14.6 The notification is up while it transfers, and gone afterwards
Queue several files in each direction, put the phone to sleep with the screen off, and wait.
**Pass:** the foreground notification is up, the transfers finish while the screen is off, and the
notification goes away when the last one does — with no shell open. With a shell open it stays, because that
is what it was already for.
**Failure means:** an upload that stalls with the screen off is the count not reaching
`SessionForegroundService`, and Android has stopped the process mid-transfer. A notification left up
afterwards is `ActivityChanged` not being subscribed — the other end of the same wire.
### 14.7 SAVE FILE writes where you pointed it, and the file opens
Select a file on the host — something with a viewer, an image or a PDF — press **SAVE FILE**, and put it
somewhere reachable: Downloads, or a folder in Drive. When the transfer finishes, open it from the phone's
own Files app.
**Pass:** the status line says it was saved, the file is where the picker was pointed under the name shown
there, and it opens with the right contents. The queue row says DONE.
**Failure means:** a row that says DONE with nothing at the destination is `DeliverAsync` never running —
the delivery is registered per transfer id, and losing it makes the download look like a success while the
bytes sit in a cache nobody can reach. A file that is there but empty or truncated is the copy out, not the
transfer: check the `SetLength(0)` and that the write stream is being disposed before the status is written.
### 14.8 The button is dead until a file is chosen, and refuses a directory
With nothing selected, look at **SAVE FILE**. Then select a directory row.
**Pass:** disabled in both cases — it needs a connected remote and a selected *file*, which is the desktop's
own `CanDownload`.
**Failure means:** an enabled button over a directory reaches `QueueDeliveredDownload`'s refusal, which is
the right answer arriving too late: the save picker has already created an empty file, so the person is left
with a file they did not want and a message saying nothing happened.
### 14.9 A download that fails leaves the empty file it warned about
Point SAVE FILE at a destination for a large file, then break the transfer — turn off Wi-Fi and mobile data
while it runs.
**Pass:** the row goes to FAILED with the reason, the status line does not claim it was saved, and there is
an empty file at the destination. Reconnect, press **RETRY**, and the same destination fills in — the
delivery survives the failure because it is held against the transfer rather than the attempt.
**Failure means:** a retry that succeeds but leaves the destination empty is the delivery having been
dropped on the failure. An error saying the staged file is missing is the copy having been deleted at the
stop, which is what `QueueDeliveredDownload` documents it does not do.