Write down what the phone found, and stop it rotting

docs/android-port.md was an audit of work not started; it now says what is built. Three of
its statements needed correcting rather than extending, and they are marked where they sit:
the Android version question is settled and was never as open as it looked, because
Avalonia.Controls.WebView ships only a net10.0-android36.0 assembly and nothing lower can
resolve it; cleartext to loopback has to be permitted explicitly, which the audit missed
entirely; and the spike produced a structural change it did not anticipate, in
DodoSSH.Client.Shell.

A CI job of its own, because the head is deliberately not in DodoSSH.slnx and a project
outside the solution is a project nobody notices breaking. It packages as well as builds:
a native library with no Android ABI and an assembly that will not dex are both invisible
to a compile, and both are exactly what this head is exposed to.

The README says plainly that signing in is not built, that a fingerprint re-enrolment
destroys the device key, that a notification appears while a shell is open, and that none
of it has run on a device.
This commit is contained in:
2026-07-31 21:09:42 +02:00
parent 2caedd93ff
commit 81e7e6d939
4 changed files with 184 additions and 30 deletions
+74 -22
View File
@@ -1,8 +1,28 @@
# An Android client: what it would take
**Status: audited, scoped, not started.** No Android code exists. The four decisions that shape the work
have been taken and are recorded in [Decisions](#decisions-taken); everything else here is the audit they
were taken against.
**Status: started. Steps 3, 4 and 6 are done; the interface is next.** `src/DodoSSH.Client.Android` is a
real head that builds and packages. The four decisions that shape the work are recorded in
[Decisions](#decisions-taken); everything below them is the audit they were taken against, and it has held
up — with three corrections and one addition, all marked **✅ settled** or **⚠️ corrected** where they
belong.
**What the spike came back with**, since everything downstream was gated on it:
- **The cryptography works.** `libsodium.so` and `libe_sqlite3.so` are both in the arm64 APK. NSec
resolving its native half despite shipping no Android-specific build was the one dependency this
document said was worth proving with a build, and it proves out.
- **`Avalonia.Controls.WebView` forces `targetSdk` 36**, which settles the open question about Android
versions — see [Still open](#still-open). This head is minSdk 28 / targetSdk 36.
- **Cleartext to loopback has to be allowed explicitly.** Not in the original audit and easy to lose a day
to: Android has blocked plaintext HTTP by default since API 28, so the terminal renderer needs a network
security config scoped to `127.0.0.1` or the WebView loads nothing and the failure looks like a renderer
that never attached. See `Resources/xml/network_security_config.xml`.
**Still unverified, and it is the honest limit of all this:** nothing has run on a device or an emulator.
No system image is installed here, so every statement below about *runtime* behaviour — Argon2id actually
completing at the 256 MiB profile on a phone, the WebView actually loading the renderer, the biometric
prompt actually releasing the key — remains reasoning from the code and the platform's rules. What is
verified is that it compiles, links, packages, and carries the right natives.
**The shape agreed:** a **phone-first** client that is the keychain plus a **terminal**, with sessions and
transfers protected by a **foreground service**. File transfer is not in the first scope; when it arrives it
@@ -398,26 +418,49 @@ go at 360dp:
1. ~~Decide the product questions.~~ **Done** — see [Decisions](#decisions-taken).
2. ~~Move `WindowsDeviceKeyStore` into the desktop head.~~ **Done**`DodoSSH.Client.Session` is now free
of Windows APIs entirely.
3. **The spike, and it answers two questions at once.** One throwaway Android head that unlocks a vault from
a passphrase, then puts a WebView on the screen pointing at the terminal data plane. The first half
settles NSec-on-Android, libsodium's native resolution, SQLite and whether the sync stack runs; the second
settles whether the terminal is possible at all, which the scope decision now depends on. Both are cheap
and both are gates — nothing after this is worth starting until it comes back.
4. **Android device key store**, with biometric or device-credential release. A straightforward
implementation of `IDeviceKeyStore`, and the piece of platform integration most clearly worth doing well:
the Android Keystore is a closer match to what the unlock screen wants than the Windows one is.
3. ~~**The spike.**~~ **Done.** It came back clean — see the summary at the top. The throwaway screen has
been deleted; what it proved is now load-bearing in the head itself.
It also produced the one structural change the audit did not anticipate. The interface is a redesign, but
*what survives it* — every view model, command and piece of state — had to be reachable from two heads,
and it was sitting inside the desktop one. So **`DodoSSH.Client.Shell` is new**: the view models, the
terminal renderer's files and the palette moved there, and both heads reference it. It is the one place
the "everything except `App` is free of Avalonia" rule bends, and it bends narrowly — what it uses is
`Dispatcher`, the asset loader and a resource dictionary, none of which imply a window.
4. ~~**Android device key store.**~~ **Done**`AndroidDeviceKeyStore` plus `BiometricGate`. A StrongBox-backed
AES-GCM key, generated with `setUserAuthenticationRequired`, wraps the X25519 scalar into a file beside
the cache; the keystore will not perform the unwrap unless the prompt actually happened, because the
cipher is bound to the prompt rather than merely following it.
Two things worth knowing. StrongBox is asked for and fell back from, because refusing a TEE-backed key
would cost a mid-range phone the whole feature to buy a distinction the threat model does not draw. And
`setInvalidatedByBiometricEnrollment` is on, which is what stops somebody who can add a fingerprint to an
unlocked phone from inheriting the vault — the cost being that re-enrolment permanently destroys the key,
which the load path treats as ordinary rather than exceptional.
5. **Android sign-in**: Custom Tabs plus an app link, behind the existing seams. Not the loopback listener —
on a shared device any app can bind a loopback port, which is the attack RFC 8252 §8.3 names.
6. **The foreground service**, before the terminal rather than after it. A session that dies on backgrounding
would otherwise shape every decision made while building the screen, and be expensive to unpick.
7. **The interface**, phone-first. The actual project, and the one that dominates the estimate.
**Not started, and it is now the gap that matters most.** The head composes a sign-in delegate that
throws, and `NeedsServer` draws a screen saying so in the user's words. A phone enrolled from the desktop
client unlocks here perfectly well, because unlocking needs no network — so this blocks first-run, not
the product. It is the next piece of work.
6. ~~**The foreground service.**~~ **Done**`SessionForegroundService`, with `SessionKeepAlive` starting and
stopping it from a count rather than a lifecycle. `TerminalWorkspace.LiveSessionCount` is the source of
truth deliberately: it already knows that a session whose shell exited is not live, which a counter
incremented on open would not, and a phone showing "1 shell connected" over nothing would be exactly the
dishonesty the unlock screen's count exists to prevent.
7. **The interface**, phone-first. The actual project, and the one that dominates the estimate. **Started:**
the shell switches on `ShellState` and the unlock screen is built from the design — including both of its
◆ disclosures, the shell count and the paragraph saying *locked* describes the keychain and not this
phone's access to the hosts. Every other state draws a `PendingScreen` naming what is missing, in the
desktop head's `NotBuiltScreen` tradition and for the same reason.
8. **The terminal**, last — the highest-value screen, and the one whose remaining unknowns are cheapest to
resolve once the shell around it exists. Plus an accessory key row: a software keyboard has no Ctrl, Esc,
Tab or arrows, and every Android SSH client ships one for exactly this reason.
Steps 1 and 2 are done. Step 3 is a few days and retires nearly all the remaining technical risk. Steps 46
are each perhaps a week and are ordinary work behind interfaces that already exist. Step 7 dominates
everything else put together, and step 8 is small only because step 7 came first.
Steps 14 and 6 are done. Step 5 is ordinary work behind seams that already exist, and is what first-run
waits on. Step 7 still dominates everything else put together — the unlock screen is one of nine
destinations and the least structural of them — and step 8 is small only because step 7 comes first.
---
@@ -425,11 +468,20 @@ everything else put together, and step 8 is small only because step 7 came first
Neither of these blocks the spike, and both want answering before there is anything to release.
- **Which Android versions.** Less forced than it first looked: the packages *compile against* API 36 and 31
respectively, which constrains `targetSdk` rather than `minSdk`. The floor is therefore a real choice about
which devices are worth supporting, and it should be made deliberately rather than inherited from whatever
restores. Worth settling before the interface work, since it decides which platform APIs are available to
design against.
- ~~**Which Android versions.**~~ **✅ Settled: minSdk 28, targetSdk 36**, and the reasoning divided in two.
The ceiling is not a choice at all, which the audit had slightly wrong. `Avalonia.Controls.WebView` ships
**only** a `net10.0-android36.0` assembly — there is no lower Android target in the package — so a project
below `targetSdk` 36 cannot resolve it and therefore cannot have a terminal. That is a hard constraint
rather than a preference, and it also means the API 36 platform must be installed to build this head at
all.
The floor *was* a real choice, and 28 is where the three platform APIs the device key store depends on all
exist in the framework rather than behind an AndroidX shim: `BiometricPrompt`, StrongBox-backed keys, and
`setInvalidatedByBiometricEnrollment`. Going lower would mean carrying `androidx.biometric` to reach
devices that mostly cannot hold a hardware-backed key anyway — which is the one thing the store is for.
API 28 and 29 still cost one branch each in `BiometricGate`, because allowed-authenticator lists arrived
in 30.
- **How it is distributed, and what that does to the supply-chain story.** ADR 0001 says plainly that an
operator who wants the secrets attacks the client rather than the crypto, and that release signing with a
key **not held by the server** is what that costs. Play App Signing means Google holds the release key.