From 81e7e6d93993bc82986e712fe80b19813aeeee7b Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Fri, 31 Jul 2026 21:09:42 +0200 Subject: [PATCH] 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. --- .github/workflows/ci.yml | 49 +++++++++- README.md | 67 +++++++++++-- docs/android-port.md | 96 ++++++++++++++----- .../Views/LockedScreen.axaml | 2 +- 4 files changed, 184 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7deae0b..0ad6c98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,4 +52,51 @@ jobs: # server through Testcontainers and runs the API as a child process — so it needs a # 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 - # dominate a cold run. \ No newline at end of file + # 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 diff --git a/README.md b/README.md index 52945b9..c15195f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/android-port.md b/docs/android-port.md index 5db6343..2e976bc 100644 --- a/docs/android-port.md +++ b/docs/android-port.md @@ -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. diff --git a/src/DodoSSH.Client.Android/Views/LockedScreen.axaml b/src/DodoSSH.Client.Android/Views/LockedScreen.axaml index e63b333..c52cacd 100644 --- a/src/DodoSSH.Client.Android/Views/LockedScreen.axaml +++ b/src/DodoSSH.Client.Android/Views/LockedScreen.axaml @@ -49,7 +49,7 @@ the nearest thing to hand, and reaching past it to a button is the sort of friction that gets a phone client called slow. --> -