Files
DodoSSH/docs/adr/0010-android-distribution.md
T
jaap-jan ebb88c8ae4 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.
2026-08-04 10:07:16 +02:00

8.7 KiB

ADR 0010 — Distributing the Android client, and who holds the release key

Context

ADR 0001 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.