Public Access
155 lines
11 KiB
Markdown
155 lines
11 KiB
Markdown
# ADR 0014 — Updating the Android client, and the two channels that makes necessary
|
|
|
|
- Status: accepted
|
|
- Date: 2026-08-04
|
|
- Builds on: [ADR 0011](0011-android-distribution.md), [ADR 0013](0013-desktop-distribution-and-updates.md)
|
|
- Amends: ADR 0011's consequence that there is "no automatic update"
|
|
|
|
## Context
|
|
|
|
[ADR 0011](0011-android-distribution.md) settled who holds the Android release key and listed the price
|
|
of shipping outside a store: *"There is no discovery, no automatic update, and no Play channel."* It
|
|
also left the door open in rule 2 — *"If an update check is ever added it points at the project's
|
|
domain"* — and named the missing update path as **the sharpest edge of the decision** for a product sold
|
|
to teams.
|
|
|
|
[ADR 0013](0013-desktop-distribution-and-updates.md) then built exactly that for Windows, and the
|
|
argument it turns on applies here word for word: an SSH client holding a team's infrastructure
|
|
credentials is precisely the software where *"the fix shipped and the user never got it"* is a security
|
|
outcome rather than an inconvenience.
|
|
|
|
So the question is not whether the phone should update itself. It is how, given one Android fact that
|
|
decides everything else:
|
|
|
|
**An installed app can only be updated by a package signed with the same key.** Not "should not" —
|
|
Android refuses, with `INSTALL_FAILED_UPDATE_INCOMPATIBLE`. A key is an app's identity for its whole
|
|
life.
|
|
|
|
That collides with ADR 0011 rule 1, which puts the release key on a machine that is not a runner. CI can
|
|
build an APK; what it cannot do is sign one that any installed copy will accept, because the debug key it
|
|
falls back to is generated fresh in every container. An APK released from CI today could be installed
|
|
once and never updated again — and each "update" would require an uninstall, which on this product means
|
|
losing the local cache, the outbox and the device key.
|
|
|
|
## Decision
|
|
|
|
**Two channels, which are two applications, because Android has no third option.**
|
|
|
|
1. **`release` — `dev.dodotech.dodossh`, signed by the key ADR 0011 rule 1 describes.** Cut from a `v*`
|
|
tag by a person running `scripts/release-android.ps1` on a machine that is not a runner. No workflow
|
|
touches it, no secret exists for it, and CI has no job that could. ADR 0011 rule 1 is unchanged, and
|
|
its "Rejected" entry — *a release key held by CI so tagging cuts a release* — still stands.
|
|
|
|
2. **`nightly` — `dev.dodotech.dodossh.nightly`, signed by a keystore committed to this repository in the
|
|
open.** Cut from `main` by CI on every push, published as a pre-release on the project's own forge.
|
|
|
|
**The key is public on purpose, and that is what makes it safe to put in CI.** ADR 0011 rule 1 exists
|
|
because a key a workflow can reach is a key held by everyone who can change a workflow file. A key
|
|
everybody already has cannot be stolen, needs no secret to configure, and grants nothing by being
|
|
held — so the rule has nothing to protect. It also means this channel works on a fresh runner with no
|
|
setup, which is the practical reason it was reachable at all.
|
|
|
|
3. **Neither channel can update the other, by construction rather than by care.** Different package ids
|
|
and different keys, so a mistake is an install Android refuses rather than a nightly quietly replacing
|
|
somebody's real client. Both can be installed at once, which is the useful half: testing a nightly
|
|
costs nobody the build they rely on.
|
|
|
|
4. **The update check reads the project's forge and never the deployment**, which is ADR 0011 rule 2
|
|
applied unchanged. The feed address is a `const` in `AndroidUpdateChannel` and there is deliberately
|
|
no setting for it — a configurable update URL is exactly the knob that would let an operator, or a
|
|
stray edit to a plaintext file, point the update path at the party ADR 0001 models as the adversary.
|
|
|
|
◆ **Which means the repository has to be public, and it was not.** A constant address with no token is
|
|
an anonymous read; the repository was private, so every check answered `404` and every phone reported
|
|
itself current. See ADR 0013 §4, which had written the incompatibility down as a fact about where a
|
|
token could live and not as something to check on the deployment. The check is one `curl` and it is
|
|
there.
|
|
|
|
The Android channel made it invisible rather than merely broken: it caught every network and parse
|
|
failure and answered null, which `IUpdateChannel` then documented as meaning "this build is current". So
|
|
a pressed CHECK NOW reported the latest build for an outage that had lasted since the feature shipped.
|
|
It throws now. The timer still swallows — a phone on a train is not news, and a half-published release
|
|
still fixes itself in six hours without anybody being told — and the button reports what happened,
|
|
which is the distinction `UpdateViewModel` was written to make and was never given anything to make it
|
|
with.
|
|
|
|
5. **The comparison is Android's `versionCode` and not the version name.** That integer is what the
|
|
platform itself uses to accept or refuse an install, so comparing anything else would let the client
|
|
offer an update the platform then rejects. The feed publishes it in a small JSON manifest beside the
|
|
APK — the counterpart of `releases.win.json` — so a check costs a few hundred bytes rather than a
|
|
download.
|
|
|
|
6. **Nothing is installed by the application.** It fetches, then asks Android to ask. The platform draws
|
|
its own confirmation naming the package, and from API 26 will not draw even that until the user has
|
|
turned this application on in the unknown-sources screen. Two deliberate answers, neither to a screen
|
|
DodoSSH controls.
|
|
|
|
◆ "Asks Android to ask" is one step longer than it sounds, and reading it as one step is why this
|
|
installed nothing at all from the day it shipped until 2026-08-05. An application holding only
|
|
`REQUEST_INSTALL_PACKAGES` gets no verdict back from committing a session: what the platform returns
|
|
first is `STATUS_PENDING_USER_ACTION`, carrying the confirmation activity in `Intent.EXTRA_INTENT` for
|
|
the application to start. **Android does not draw the dialogue on its own.** The commit succeeded, the
|
|
session staged, and nobody was ever asked anything. The receiver that starts it is
|
|
`InstallSessionReceiver`; the intent naming it has to be explicit, since a mutable pending intent
|
|
wrapping an implicit one is refused outright from API 34. Check 17.5 asks for exactly the right thing
|
|
— "Android's own installer appears naming the package" — so nothing needed rewording; it wants an
|
|
older build installed and a newer one published to run at all, and on the evidence it was never run
|
|
against a real pair.
|
|
|
|
7. **Applying does not end the process, and the shell had to learn that.** On Windows, applying replaces
|
|
the files and restarts, so the shell disposes the vault first — that is what zeroes the identity keys,
|
|
the vault keys and the cache key. On Android the install is a *request* and the answer may be no, so
|
|
disposing first would answer "not now" with a locked keychain and every shell closed. `IUpdateChannel`
|
|
gained `ApplyingEndsTheProcess`; where it is false the session is left alone, and the keys go when
|
|
Android kills the process on the install it agreed to.
|
|
|
|
## Consequences
|
|
|
|
**The nightly channel is a real attack surface and should be described as one.** Anyone can build an APK
|
|
a nightly phone will accept, because the key is public. Reaching one still means being what it fetches
|
|
from — a release on `git.dodotech.cloud` over TLS — so the practical set is *whoever can write a release
|
|
on this repository*, which is the same set ADR 0013 names for the desktop. That is acceptable for a
|
|
channel whose users are testing it and is not acceptable for the one holding people's credentials, which
|
|
is the entire reason there are two.
|
|
|
|
**A nightly is not a beta of the release channel; it is a different application.** Moving from one to the
|
|
other is an uninstall and a fresh enrolment. There is no migration and there will not be one — the two
|
|
caches are encrypted under keys held by two package identities the platform keeps apart.
|
|
|
|
◆ **The desktop has since taken this arrangement, and it had to build what Android is given.**
|
|
[ADR 0013](0013-desktop-distribution-and-updates.md) decision 9 is this ADR applied to Windows: a nightly
|
|
CI publishes from main, installing beside the release build rather than over it. The difference worth
|
|
carrying back here is that every separation this ADR gets from the platform — different package identity,
|
|
signature-checked updates, a per-app data directory — is on Windows a thing somebody had to choose and can
|
|
therefore undo. The paragraph above about the attack surface applies there word for word, with one line
|
|
removed: on the desktop the packages are not signed at all, so the feed is the only thing standing between
|
|
a nightly install and an arbitrary build.
|
|
|
|
**ADR 0011's "no automatic update" consequence is now wrong for the release channel and remains true for
|
|
reach.** Discovery, MDM deployment and the sideloading permission are all unchanged. What changed is only
|
|
that an installed copy can now learn a newer one exists.
|
|
|
|
**The release channel's `versionCode` stays a hand-bumped literal**, and forgetting to bump it is caught
|
|
rather than shipped: the release script reads what is already published and refuses to build a package
|
|
that does not beat it.
|
|
|
|
**Two identical launcher icons.** A nightly installed beside a release shows the same name under both.
|
|
Four ways to vary it per channel were tried and none reached the launcher label; the findings are in
|
|
`docs/platform-flags.md`. What tells them apart today is the package name in Android's app info, the
|
|
version, and the channel the application names on its own preferences screen.
|
|
|
|
## Rejected
|
|
|
|
- **A release keystore in CI so tagging cuts the real release.** The convenience is the point of CI and
|
|
the key is the point of ADR 0011; where they collide the key wins. This ADR is what makes that refusal
|
|
survivable rather than merely principled — the nightly channel is where the convenience went.
|
|
- **One channel, released from CI, signed with a stable key held as a secret.** This is the arrangement
|
|
most projects land on and it is ADR 0011's rejected entry with an extra step: the secret is reachable
|
|
by whoever can change a workflow file, and what it signs is the client holding the credentials.
|
|
- **Opening the release page in a browser instead of installing.** It would work, and it moves the same
|
|
APK through the same unknown-sources gate with one more step and no less trust. What it gives up is any
|
|
way of knowing a fix has been fetched, which is the property this ADR exists to buy.
|
|
- **A version-name comparison, so the feed could be a directory listing.** SemVer with prerelease heights
|
|
needs a parser, and the parser would disagree with the platform sooner or later. The number Android
|
|
uses is the number to compare.
|