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
+47
View File
@@ -53,3 +53,50 @@ jobs:
# Docker daemon and gets one here. That is why the tests run on ubuntu rather than # Docker daemon and gets one here. That is why the tests run on ubuntu rather than
# macOS, whose runners have no daemon at all. Expect the Keycloak image pull to # macOS, whose runners have no daemon at all. Expect the Keycloak image pull to
# dominate a cold run. # dominate a cold run.
android:
name: android head
runs-on: [linux]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json
cache: true
cache-dependency-path: '**/packages.lock.json'
# A job of its own, because DodoSSH.Client.Android is deliberately not in DodoSSH.slnx.
# Adding it there would make the android workload and a full Android SDK a prerequisite of
# `dotnet build DodoSSH.slnx` for everyone — including the build job above, which needs
# neither and would grow several minutes for a head it does not compile.
#
# The cost of keeping it out is that nothing in the main job would notice this head
# breaking, which for a project sharing view models with the desktop one is a matter of
# when rather than whether. This job is that notice.
- name: install the android workload
run: dotnet workload install android --skip-sign-check
# API 36 specifically, and it is not a preference: Avalonia.Controls.WebView ships only a
# net10.0-android36.0 assembly, so anything lower cannot resolve it and the head loses its
# terminal. See docs/android-port.md.
- name: install the android sdk platform
run: |
echo "y" | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \
"platforms;android-36" "build-tools;36.0.0"
- name: restore
run: dotnet restore src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj --locked-mode
- name: build
run: >
dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj
--no-restore --configuration Release
# Packaging rather than only compiling, because the two failures this head is most exposed to
# are both link-time: a native library with no android ABI, and a managed assembly that
# resolves for net10.0 but has nothing to dex. Neither shows up in a compile.
- name: package
run: >
dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj
--no-restore --configuration Release
-t:SignAndroidPackage -p:RuntimeIdentifier=android-arm64
+61 -6
View File
@@ -20,7 +20,7 @@ zero-knowledge.
| Component | Choice | | Component | Choice |
| --- | --- | | --- | --- |
| Backend | ASP.NET Core on .NET 10, PostgreSQL + EF Core | | Backend | ASP.NET Core on .NET 10, PostgreSQL + EF Core |
| Client | Avalonia (C#) for Windows/Linux/macOS; terminal pane is a WebView running xterm.js | | Client | Avalonia (C#): a desktop head for Windows/Linux/macOS and a phone-first Android head, sharing one set of view models; terminal pane is a WebView running xterm.js |
| Auth | OIDC, provider-agnostic (Entra ID, Keycloak, Auth0, Authentik) | | Auth | OIDC, provider-agnostic (Entra ID, Keycloak, Auth0, Authentik) |
| Vault | End-to-end encrypted; X25519 + Ed25519 + XChaCha20-Poly1305, Argon2id unlock | | Vault | End-to-end encrypted; X25519 + Ed25519 + XChaCha20-Poly1305, Argon2id unlock |
| Connections | Client-direct SSH by default, with an optional raw-TCP server relay | | Connections | Client-direct SSH by default, with an optional raw-TCP server relay |
@@ -70,18 +70,26 @@ src/
DodoSSH.Client.Transfer the transfer queue, part files and resume, and the local file listing DodoSSH.Client.Transfer the transfer queue, part files and resume, and the local file listing
DodoSSH.Client.ObjectStore S3-compatible buckets, behind the same interface as SFTP DodoSSH.Client.ObjectStore S3-compatible buckets, behind the same interface as SFTP
DodoSSH.Client.Import reading ~/.ssh/config, with no I/O of its own DodoSSH.Client.Import reading ~/.ssh/config, with no I/O of its own
DodoSSH.Client.App Avalonia; the only project that knows about a UI toolkit DodoSSH.Client.Shell the view models both heads drive, the renderer's files, the palette
DodoSSH.Client.App the desktop head: its views, and its Windows integration
DodoSSH.Client.Android the phone head: its views, and its Android integration
tests/ one test project per source project tests/ one test project per source project
docs/adr/ architecture decision records docs/adr/ architecture decision records
docs/design-import-gaps.md what the client's design asked for and this build has not got docs/design-import-gaps.md what the client's design asked for and this build has not got
docs/platform-flags.md what differs off Windows, and the gotchas that have cost time docs/platform-flags.md what differs off Windows, and the gotchas that have cost time
docs/manual-checks.md what no test can reach, and what to look for when checking by hand docs/manual-checks.md what no test can reach, and what to look for when checking by hand
docs/android-port.md what an Android client would take, and what has been decided about it docs/android-port.md the Android head: what was decided, what is built, what is left
``` ```
Everything under `src/DodoSSH.Client.*` except `App` is deliberately free of Avalonia. That is the Everything under `src/DodoSSH.Client.*` except the two heads and `Shell` is deliberately free of Avalonia.
seam that lets the SSH layer, the terminal's flow control and the OIDC flow be tested without a UI That is the seam that lets the SSH layer, the terminal's flow control and the OIDC flow be tested without a
toolkit or a browser engine — which is most of why they are testable at all. UI toolkit or a browser engine — which is most of why they are testable at all.
`Shell` is the narrow exception and it earns it: what it takes from Avalonia is `Dispatcher`, the asset
loader and a resource dictionary, none of which imply a window, and what it holds is the shell's state
machine — which two heads have to agree on exactly rather than approximately. The rule's purpose was never
Avalonia-avoidance for its own sake; it was that the layers with the hard logic stay testable, and none of
them are here.
## Building ## Building
@@ -250,6 +258,53 @@ Items are filed into one vault at a time. When more than one vault is writable,
show a picker; it defaults to your personal vault and never moves on its own, because an item put in a team show a picker; it defaults to your personal vault and never moves on its own, because an item put in a team
vault is visible to everybody in that team and moving it back means deleting and retyping. vault is visible to everybody in that team and moving it back means deleting and retyping.
### The Android head
`src/DodoSSH.Client.Android` is a phone-first head that shares every view model with the desktop one. It is
**partly built**: what exists is the unlock screen, the platform integration under it, and a shell that
names each state it has not built yet rather than showing an empty screen. What it is for, what was decided
and what is left are in [`docs/android-port.md`](docs/android-port.md).
It is deliberately **not** in `DodoSSH.slnx`. Putting it there would make the `android` workload and a full
Android SDK a prerequisite of `dotnet build DodoSSH.slnx` for everybody; it has its own CI job instead, which
builds *and packages* it, because the two failures it is most exposed to — a native library with no Android
ABI, and an assembly that resolves but will not dex — are both invisible to a plain compile.
Building it needs the workload and **API 36 specifically**:
```bash
dotnet workload install android
```
```bash
dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj
```
API 36 is not a preference. `Avalonia.Controls.WebView` ships only a `net10.0-android36.0` assembly, so
anything lower cannot resolve it and the head loses its terminal. The floor is API 28, which is where
`BiometricPrompt` and StrongBox-backed keys exist without an AndroidX shim.
Three things about it are worth expecting rather than discovering.
**Signing in is not built yet, and that is the one real gap.** The desktop client receives the authorization
response on a loopback `TcpListener`; reusing that on a phone would be a security regression rather than a
shortcut, because on a shared device any other app can bind a loopback port — the attack RFC 8252 §8.3 names.
It needs an app link instead. A phone enrolled from the desktop client unlocks here perfectly well, because
**unlocking needs no network at all**, so this blocks first-run rather than the product.
**A fingerprint releases the device key, and re-enrolling a fingerprint destroys it.** The key is generated
with `setInvalidatedByBiometricEnrollment`, which is what stops somebody who can add their own fingerprint to
an unlocked phone from inheriting the vault. The cost is that adding a finger legitimately means typing the
passphrase again and re-registering — which the unlock screen treats as ordinary, because it is.
**A notification appears while a shell is open.** Android stops backgrounded processes, and the desktop
client's promise that locking the vault does not close your shells is only true here behind a foreground
service. The notification is the price of that promise; it goes when the last shell does.
**Nothing has been run on a device.** It compiles, links, packages, and carries the right native libraries
for arm64 — that is verified. Everything about its runtime behaviour is not, and `docs/android-port.md` says
which claims those are.
### End-to-end verification ### End-to-end verification
One suite runs against a real server rather than a stub. It needs a Docker daemon and nothing else, so it One suite runs against a real server rather than a stub. It needs a Docker daemon and nothing else, so it
+74 -22
View File
@@ -1,8 +1,28 @@
# An Android client: what it would take # An Android client: what it would take
**Status: audited, scoped, not started.** No Android code exists. The four decisions that shape the work **Status: started. Steps 3, 4 and 6 are done; the interface is next.** `src/DodoSSH.Client.Android` is a
have been taken and are recorded in [Decisions](#decisions-taken); everything else here is the audit they real head that builds and packages. The four decisions that shape the work are recorded in
were taken against. [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 **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 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). 1. ~~Decide the product questions.~~ **Done** — see [Decisions](#decisions-taken).
2. ~~Move `WindowsDeviceKeyStore` into the desktop head.~~ **Done**`DodoSSH.Client.Session` is now free 2. ~~Move `WindowsDeviceKeyStore` into the desktop head.~~ **Done**`DodoSSH.Client.Session` is now free
of Windows APIs entirely. of Windows APIs entirely.
3. **The spike, and it answers two questions at once.** One throwaway Android head that unlocks a vault from 3. ~~**The spike.**~~ **Done.** It came back clean — see the summary at the top. The throwaway screen has
a passphrase, then puts a WebView on the screen pointing at the terminal data plane. The first half been deleted; what it proved is now load-bearing in the head itself.
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 It also produced the one structural change the audit did not anticipate. The interface is a redesign, but
and both are gates — nothing after this is worth starting until it comes back. *what survives it* — every view model, command and piece of state — had to be reachable from two heads,
4. **Android device key store**, with biometric or device-credential release. A straightforward and it was sitting inside the desktop one. So **`DodoSSH.Client.Shell` is new**: the view models, the
implementation of `IDeviceKeyStore`, and the piece of platform integration most clearly worth doing well: terminal renderer's files and the palette moved there, and both heads reference it. It is the one place
the Android Keystore is a closer match to what the unlock screen wants than the Windows one is. 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 — 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. 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. **Not started, and it is now the gap that matters most.** The head composes a sign-in delegate that
7. **The interface**, phone-first. The actual project, and the one that dominates the estimate. 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 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, 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. 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 Steps 14 and 6 are done. Step 5 is ordinary work behind seams that already exist, and is what first-run
are each perhaps a week and are ordinary work behind interfaces that already exist. Step 7 dominates waits on. Step 7 still dominates everything else put together — the unlock screen is one of nine
everything else put together, and step 8 is small only because step 7 came first. 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. 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 - ~~**Which Android versions.**~~ **✅ Settled: minSdk 28, targetSdk 36**, and the reasoning divided in two.
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 The ceiling is not a choice at all, which the audit had slightly wrong. `Avalonia.Controls.WebView` ships
restores. Worth settling before the interface work, since it decides which platform APIs are available to **only** a `net10.0-android36.0` assembly — there is no lower Android target in the package — so a project
design against. 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 - **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 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. key **not held by the server** is what that costs. Play App Signing means Google holds the release key.
@@ -49,7 +49,7 @@
the nearest thing to hand, and reaching past it to a button is the sort of friction that gets a the nearest thing to hand, and reaching past it to a button is the sort of friction that gets a
phone client called slow. phone client called slow.
--> -->
<TextBox Text="{Binding Passphrase}" PasswordChar="•" Watermark="vault passphrase" <TextBox Text="{Binding Passphrase}" PasswordChar="•" PlaceholderText="vault passphrase"
Height="48" Padding="14,0" VerticalContentAlignment="Center" Height="48" Padding="14,0" VerticalContentAlignment="Center"
Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}" Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" CornerRadius="6" Foreground="{StaticResource Text}" BorderThickness="1" CornerRadius="6" Foreground="{StaticResource Text}"