Public Access
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:
+74
-22
@@ -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 4–6
|
||||
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 1–4 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.
|
||||
|
||||
Reference in New Issue
Block a user