Signed Android updates¶
Arveil can check for updates, download a verified APK and ask Android to install it over the existing app. The identity and conversations stay in app storage. Never uninstall or clear storage to update. An older client without this screen needs one manual installation over the existing app to gain it.
Updates are independent of the realm. A distributor chooses an HTTPS feed and
an Ed25519 public key at build time. An ordinary source build has no feed and
does not contact a project-operated service. On macOS the app checks the same
signed feed and, when there is a newer version, opens its download: the person
replaces the app, or runs brew upgrade --cask arveil (see
On macOS). Sparkle and automatic update-key rotation are not
implemented.
Español: Actualizaciones firmadas.
User experience and privacy¶
Open Settings → Updates, or the update icon on the closed-profile screen. Checking is manual by default. The optional foreground check runs at most once per day, including failed attempts and restarts. It displays an in-app notice; there is no push service, background polling or notification while the app is closed. Enabling the option does not install anything.
A check requests the complete feed with no installed version, profile ID, cookie or identifying user agent. The feed host/CDN still sees the IP and time. Downloading an update also contacts the APK host. Both actions work without opening a profile or connecting to a realm.
The app verifies the announcement before showing its release notes. Download
size and SHA-256 must match. Install update may first require Android's
per-app permission to install packages (on Android 7, the global Unknown
sources setting, off by default on phones); return to Arveil and press
Install update again. Before creating a PackageInstaller session, the app
checks the package ID, a higher build number and the current signing
certificate, and hashes the bytes again as it copies them into the session. On
Android 12+, the session explicitly requires user action. Android performs the final APK verification
and asks the user to confirm. No uninstall, downgrade or data-clear fallback is
offered. An expired announcement must be refreshed before installation.
An offer that expires, or that a newer announcement overtakes, leaves the screen. A check that fails, for lack of connection or because the service answered badly, keeps an offer that is still valid, with its download. If Android cannot take the package, for example for lack of space, the download stays for another attempt; a package that is not the announced one is deleted. Some Android versions send no answer when the confirmation is dismissed: back in Arveil, the attempt then reads as cancelled after a moment, and Install update starts again. The release notes link opens in the browser, which contacts that site.
Only builds with a feed request Android's permission to install packages.
Settings → Diagnostics reports updates: followed by none, the channel,
or invalid when the build carries an update configuration the app refused;
such a build behaves as one without updates.
On macOS¶
The Mac app has the same Settings → Updates screen, the same opt-in daily
check and the same verification: signature, channel, sequence and expiry. It
offers the macos-arm64 entry of the announcement when its build is higher
than the app's own (CFBundleVersion) and the Mac meets minimum_os.
Download in the browser opens that entry's url, the ZIP in the GitHub
release; the app never downloads, unpacks or replaces anything itself. The
person quits Arveil and replaces the app in Applications, or runs
brew upgrade --cask arveil if they installed it with
Homebrew. The profile stays in
the app's sandbox container either way. An announcement without a macOS entry
offers nothing on the Mac, and Android ignores the macOS entry.
The Mac package carries the update configuration like the APK: build it with
--update-config, and sign the announcement with --macos-package and
--macos-asset-url as well. Both packages must be the same version and build.
Configure a distribution¶
Use client packaging and retain the existing Android release key. Create a different, offline update key once, with OpenSSL 3:
python3 scripts/client_updates.py init-key --key .local/update-signing/update.pem
Back it up encrypted, separately from the APK key. It never belongs on the website, relay or CI. The command refuses to overwrite it, saying that the key already exists, and prints only the public key.
The command asks twice, in the terminal, for a passphrase of at least 12
characters and stores the key encrypted with it (PKCS#8, AES-256 and
PBKDF2-HMAC-SHA256 with 600,000 iterations); signing asks for it again.
Neither the passphrase nor the key passes through command arguments or
environment variables. Generate a long random passphrase and keep it in a
password manager, never beside the key. Losing the passphrase is losing the
key: the only way out is rotating the update key (see below), which takes a
new build signed with the Android key. For scripted use, --passphrase-fd N
reads the passphrase from the first line of the inherited file descriptor N
instead of the terminal, for example through a pipe from a password manager's
command-line tool. Without a terminal or that option, the tool stops rather
than read a passphrase that would be echoed.
A key created before this encryption existed is plain PEM. Signing still accepts it, with a warning. Encrypt it once; the command checks that the public key is unchanged and prints it. Then replace the file and destroy the plaintext key and every unencrypted backup of it:
python3 scripts/client_updates.py encrypt-key \
--key .local/update-signing/update.pem \
--output .local/update-signing/update-encrypted.pem
mv .local/update-signing/update-encrypted.pem .local/update-signing/update.pem
Keep the following file in .local/distribution.json, mode 0600:
{
"ARVEIL_UPDATE_URL": "https://project.example.org/updates/clients-beta.json",
"ARVEIL_UPDATE_PUBLIC_KEY": "BASE64_32_BYTE_PUBLIC_KEY",
"ARVEIL_UPDATE_CHANNEL": "beta"
}
Replace the public-key placeholder with the printed value. Only these three
fields are accepted. Use stable or beta; each has its own feed and sequence.
Never add a realm hostname, bootstrap, invite, private key or tunnel token to
this file. The URL, public key and channel are embedded in the app and
recorded in its public BUILD.json; their local file path is not.
python3 scripts/package_clients.py build android \
--signing-config .local/signing/android-signing.json \
--update-config .local/distribution.json --build-number 18
The build number is illustrative: always choose one higher than every build
already distributed under that Android signing key. The packaging helper
checks that the APK's versionCode equals this build number, which
BUILD.json records and the announcement carries, and that the APK requests
Android's permission to install packages only when built with
--update-config. Build the macOS package with the same --update-config and
build number, so the Mac app can announce the release. Commit the source first; dirty candidates can be tested locally
but cannot be announced by the signing command. The feed is a distribution
choice, not a dependency of self-hosting.
Announce a release¶
Prepare the immutable clients-v* GitHub release and verify its artifacts as
described in the release guide. Write concise plain-text
release notes in a private file. Sign a new announcement locally:
python3 scripts/client_updates.py sign \
--key .local/update-signing/update.pem \
--config .local/distribution.json \
--package dist/clients/0.1.0+18/android \
--sequence 1 --valid-days 30 \
--notes .local/release-notes.txt \
--asset-url https://github.com/example/arveil/releases/download/clients-v0.1.0-beta.1/arveil-0.1.0-18-android-arm64.apk \
--macos-package dist/clients/0.1.0+18/macos \
--macos-asset-url https://github.com/example/arveil/releases/download/clients-v0.1.0-beta.1/arveil-0.1.0-18-macos-arm64.zip \
--notes-url https://github.com/example/arveil/releases/tag/clients-v0.1.0-beta.1 \
--output .local/releases/clients-beta-1.json
Every changed payload, including an expiry extension for the same APK, needs
a higher sequence. The tool keeps that record itself: sequences.json,
beside the key (here in .local/update-signing/, which Git ignores), lists
each signed sequence per channel with its version, build, expiry and the
SHA-256 of the signed file. Pass --sequence only for a channel's first
announcement: 1 for a new channel or, if announcements were signed before the
ledger existed, one higher than the last one published. Afterwards omit it;
the tool uses the next number and prints it. An explicit --sequence must be
higher than the last one recorded. A missing ledger, or a channel without
entries, means no earlier announcement. A malformed ledger stops signing and is
never reset: restore it from a backup or correct it by hand. The entry is
written atomically, mode 0600, only after the signature verifies and before the
output file, so a failed write can skip a number but never reuse one; clients
accept gaps. Back up the ledger with the key.
The tool checks package hashes, clean-build metadata, distribution/key agreement and an immutable GitHub asset URL, then verifies its own OpenSSL signature. It writes a new file exclusively; it never publishes or overwrites one.
Publish the verified APK first, attach the signed announcement under its
unique sequence filename, and atomically serve those same bytes at the feed's
fixed URL. Do not redirect the feed or put a browser login/challenge in front
of it. The client requests Accept-Encoding: identity and rejects compressed
responses, so its limits and hashes apply to the exact bytes. Use Content-Type: application/json, no content compression, and
Cache-Control: no-cache or a short cache lifetime; purge an old cached feed
when publishing. Downloads may follow at most five HTTPS redirects because
GitHub assets use a storage host. The app trusts the system's certificate
authorities plus ISRG Root X1: GitHub serves those assets under Let's Encrypt,
and Android 7.0 does not carry that root. Never use releases/latest or replace an
existing APK. Check the public feed against the locally signed bytes after
publication. Serve the feed separately from any personal realm.
If Cloudflare Browser Integrity Check rejects the updater's header profile, use the exact-path exception and re-enable procedure. Keep the actual rule ID, scope, approval and reversal history in private operator notes. Re-enabling BIC may block update checks again; the app must continue to reject invalid signatures rather than bypassing verification to recover access.
The default expiry is 30 days (maximum 90). Refresh it with a new sequence before it expires, even when there is no new APK. Expiry prevents offering updates from a stale announcement; it does not disable messaging. A host can still withhold a newer announcement while an older signed one is valid, and device-clock tampering is outside the rollback protection.
Wire format and local state¶
The envelope is JSON with schema: 1, base64 payload and base64 signature.
Sign the UTF-8 bytes arveil-client-updates-v1\n followed by the exact decoded
payload bytes, using Ed25519. No JSON reserialization is used for verification.
The payload contains:
{
"schema": 1,
"channel": "beta",
"sequence": 1,
"expires": "2030-01-01T00:00:00Z",
"platforms": {
"android-arm64": {
"version": "0.1.0",
"build": 18,
"minimum_sdk": 24,
"application_id": "io.github.ulzuhan.arveil",
"url": "https://github.com/example/arveil/releases/download/clients-v0.1.0-beta.1/app.apk",
"size": 123,
"sha256": "64-lowercase-hex-characters",
"notes": "Plain-text release notes.",
"notes_url": "https://example.org/releases/18"
},
"macos-arm64": {
"version": "0.1.0",
"build": 18,
"minimum_os": "12.0",
"url": "https://github.com/example/arveil/releases/download/clients-v0.1.0-beta.1/app.zip",
"size": 123,
"sha256": "64-lowercase-hex-characters",
"notes": "Plain-text release notes.",
"notes_url": "https://example.org/releases/18"
}
}
}
macos-arm64 is optional; the Android entry is always present, so Android
clients from before it keep reading the feed. A malformed macOS entry rejects
the whole announcement, as a malformed Android entry does.
The feed is limited to 64 KiB and the APK to 512 MiB. Release notes are plain
text of at most 8,000 Unicode code points; the signer and the app count them
the same way, and clients/flutter/test/fixtures/update-manifest-vectors.json
keeps their rules in step. A download fails if it stalls for 30 seconds or
takes longer than 10 minutes plus one second per 16 KiB of package; streamed
size checks bound it too. Invalid/partial downloads are removed, and a
downloaded package is removed when the app next starts, so it never stays after
installing. The app hashes the bytes again while copying them into the
installation session.
updates.json in application support stores the opt-in choice, the last
attempt and, for each update key and channel, the highest accepted sequence
plus payload digest. A build with another key or channel starts its own
history at zero; the others keep their protection. It contains no profile
information. Atomic replacement must
succeed before an announcement is offered. Repeating the exact announcement
is allowed; a lower sequence or a changed payload at the same sequence is not.
A failed read/write fails closed instead of resetting that protection. Do not
clear the app's data to recover: that deletes the encrypted profile as well.
App storage deletion or a privileged local attacker can reset this state.
Switching an installation between channels needs nothing more: the new channel keeps its own history. Rotating the update key means publishing a build with the new public key, which people install by hand once, as ADR-010 describes; never reset update state or tell users to reinstall from scratch. Losing the update key or its passphrase leaves only this rotation, and that build must be signed with the existing Android key. The current Android certificate check intentionally requires the same current signers and does not implement APK signing-key lineage migration.
Verification¶
flutter test test/updates_test.dart test/update_transport_test.dart covers
signatures (including an independent OpenSSL fixture), expiry, sequence reuse
and rollback, disabled checks, daily scheduling, package tampering and redirects.
python3 -m unittest discover -s scripts -p 'test_client_updates.py' exercises
the offline signing boundary, including the sequence ledger and encrypted keys;
test_package_clients.py checks the APK's version code and installer
permission. Android app:testDebugUnitTest checks the exact
session bytes, application ID, version and certificate policy.
For the actual system installer, use the private
integration_test/update_installer_acceptance.dart entry point on a disposable
emulator. Build a debug APK with ARVEIL_TEST_UPDATER=before, then one with
ARVEIL_TEST_UPDATER=after and a higher build number, signed with the same test
key. The first creates an encrypted native profile and retains its key in
Android Keystore. Put the second APK in the app's private cache/updates/update.apk
and its build, size, sha256 in cache/updates/acceptance.json. Grant the
per-app installation permission, tap Install test update, and verify the
system confirmation. After installation, launch again and require
ARVEIL_TEST_UPDATER_OK:profile:after. Also test denial, cancel, a wrong
certificate and tampering; each must preserve the installed app. Do not use
adb install -r for the second APK in this test: the point is to exercise the
app's own PackageInstaller path. Never distribute either acceptance APK.
For the whole flow, use integration_test/update_flow_acceptance.dart. It runs
the real app with the real update controller, transport, Rust signature check
and installer; the only difference is one more trusted root, the disposable
authority of a local HTTPS test feed, passed as ARVEIL_TEST_UPDATE_CA. Build a
before and an after APK with the feed's URL and a disposable update key,
sign an announcement for the second with that key, serve both from the test
feed, and use Settings → Updates in the first: check, download, install
and confirm. The second must report ARVEIL_TEST_UPDATER_OK:profile:after and
its ARVEIL_TEST_UPDATER_STATE line the kept sequence and the removed package.
ARVEIL_TEST_TRUST_PROBE makes both report whether an HTTPS address is reached
with the system's roots and with the updater's.