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
+61 -6
View File
@@ -20,7 +20,7 @@ zero-knowledge.
| Component | Choice |
| --- | --- |
| 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) |
| Vault | End-to-end encrypted; X25519 + Ed25519 + XChaCha20-Poly1305, Argon2id unlock |
| 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.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.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
docs/adr/ architecture decision records
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/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
seam that lets the SSH layer, the terminal's flow control and the OIDC flow be tested without a UI
toolkit or a browser engine — which is most of why they are testable at all.
Everything under `src/DodoSSH.Client.*` except the two heads and `Shell` is deliberately free of Avalonia.
That is the seam that lets the SSH layer, the terminal's flow control and the OIDC flow be tested without a
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
@@ -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
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
One suite runs against a real server rather than a stub. It needs a Docker daemon and nothing else, so it