Public Access
Two conflicts, and the second is worth recording. main's M4 bullet gained the Android signing decision while this branch rewrote the M5 line either side of it; both are kept. The other is an ADR number collision: two sessions each took 0010, one for vault key rotation and one for Android distribution, and both are now on main. ADR numbers are identifiers — "see ADR 0010" appears in code comments as well as in prose — so leaving two would make every such reference ambiguous. The rotation ADR landed first and is referenced from crypto.md, the gaps document, ADR 0009 and the sync code; the Android one is referenced from README and android-port.md. So the later and cheaper one moves: 0010-android-distribution.md is now ADR 0011, with its title and both references updated. Nothing about either decision changes.
126 lines
8.7 KiB
Markdown
126 lines
8.7 KiB
Markdown
# ADR 0011 — 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.
|