Public Access
Merge branch 'claude/vault-creation-sharing-62c0b6'
# Conflicts: # README.md
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user