From 7a3a521c59b615f4f0e203bb3f2c4f22118f6956 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Fri, 31 Jul 2026 21:43:11 +0200 Subject: [PATCH] Give the phone the rest of its screens, and a way in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All seven screens of the design, plus the two it does not draw because it starts at an enrolled phone: naming a server, and choosing a passphrase. The five states docs/android-port.md worried about losing at 360dp are all here and none of them softened. The changed-key refusal is a full-screen panel rather than a bottom sheet, because a sheet is swipe-to-dismiss by convention and that screen must have no way forward. The recovery code raises FLAG_SECURE for its own state and lowers it afterwards, so the sentence about screenshots is true rather than decorative. The delete confirmations keep their counts and replace the row in place. Signing in works, and the seam it needed is worth more than the implementation: IAuthorizationCallback now sits between OidcClient and the loopback listener, so the two heads differ in where the response arrives and in nothing else. PKCE, the state check, discovery, the token exchange and the key binding stay one implementation — a second OIDC client would be a second place for a security bug to live. The phone registers a private-use scheme with the system rather than binding a loopback port, which on a shared device any other app can do first. The accessory key row needed TerminalWorkspace.SendInputAsync: ordinary typing goes from the renderer straight down the socket, and there was no way in for the keys a software keyboard does not have. Ctrl latches, because one thumb cannot chord, and the latch is drawn — a modifier that is on and does not look on is how somebody sends ^L to a database prompt believing they typed an l. 597 client tests green, including two new ones for the input path and one for the terminal surface command. Nothing has run on a device. --- README.md | 41 ++-- docs/android-port.md | 67 ++++-- src/DodoSSH.Client.Android/App.axaml | 1 + src/DodoSSH.Client.Android/App.axaml.cs | 65 ++++-- .../DodoSSH.Client.Android.csproj | 5 + .../DodoSshAndroidApplication.cs | 6 +- src/DodoSSH.Client.Android/MainActivity.cs | 58 +++++- .../Platform/AndroidAuthorization.cs | 150 ++++++++++++++ .../Platform/AndroidDeviceKeyStore.cs | 3 +- .../Platform/PhoneEnvironment.cs | 14 +- src/DodoSSH.Client.Android/Theme/Phone.axaml | 193 ++++++++++++++++++ .../Views/EnrollScreen.axaml | 46 +++++ .../Views/EnrollScreen.axaml.cs | 10 + .../Views/HostKeySheet.axaml | 116 +++++++++++ .../Views/HostKeySheet.axaml.cs | 10 + .../Views/HostsScreen.axaml | 173 ++++++++++++++++ .../Views/HostsScreen.axaml.cs | 10 + .../Views/KeychainScreen.axaml | 134 ++++++++++++ .../Views/KeychainScreen.axaml.cs | 10 + src/DodoSSH.Client.Android/Views/NavButton.cs | 119 +++++++++++ .../Views/PhoneShell.axaml | 166 ++++++++++++--- .../Views/PhoneShell.axaml.cs | 74 ++++++- .../Views/RecoveryCodeScreen.axaml | 62 ++++++ .../Views/RecoveryCodeScreen.axaml.cs | 10 + .../Views/ServerScreen.axaml | 56 +++++ .../Views/ServerScreen.axaml.cs | 10 + .../Views/TerminalScreen.axaml | 109 ++++++++++ .../Views/TerminalScreen.axaml.cs | 180 ++++++++++++++++ src/DodoSSH.Client.Android/packages.lock.json | 28 --- src/DodoSSH.Client.Api/packages.lock.json | 8 + .../LoopbackCallbackListener.cs | 41 +++- src/DodoSSH.Client.Auth/OidcClient.cs | 5 +- src/DodoSSH.Client.Auth/OidcClientOptions.cs | 19 +- src/DodoSSH.Client.Auth/packages.lock.json | 3 +- src/DodoSSH.Client.Domain/packages.lock.json | 3 +- src/DodoSSH.Client.Import/packages.lock.json | 3 +- .../packages.lock.json | 8 + .../ServerConnection.cs | 15 +- src/DodoSSH.Client.Session/packages.lock.json | 14 ++ .../ViewModels/MainWindowViewModel.cs | 36 ++++ src/DodoSSH.Client.Shell/packages.lock.json | 14 ++ src/DodoSSH.Client.Ssh/packages.lock.json | 8 + src/DodoSSH.Client.Storage/packages.lock.json | 14 ++ src/DodoSSH.Client.Sync/packages.lock.json | 14 ++ .../TerminalWorkspace.cs | 39 ++++ .../packages.lock.json | 8 + .../packages.lock.json | 8 + src/DodoSSH.Contracts/packages.lock.json | 3 +- src/DodoSSH.Crypto/packages.lock.json | 8 + .../ShellFlowTests.cs | 31 +++ .../TerminalWorkspaceTests.cs | 50 +++++ 51 files changed, 2144 insertions(+), 134 deletions(-) create mode 100644 src/DodoSSH.Client.Android/Platform/AndroidAuthorization.cs create mode 100644 src/DodoSSH.Client.Android/Theme/Phone.axaml create mode 100644 src/DodoSSH.Client.Android/Views/EnrollScreen.axaml create mode 100644 src/DodoSSH.Client.Android/Views/EnrollScreen.axaml.cs create mode 100644 src/DodoSSH.Client.Android/Views/HostKeySheet.axaml create mode 100644 src/DodoSSH.Client.Android/Views/HostKeySheet.axaml.cs create mode 100644 src/DodoSSH.Client.Android/Views/HostsScreen.axaml create mode 100644 src/DodoSSH.Client.Android/Views/HostsScreen.axaml.cs create mode 100644 src/DodoSSH.Client.Android/Views/KeychainScreen.axaml create mode 100644 src/DodoSSH.Client.Android/Views/KeychainScreen.axaml.cs create mode 100644 src/DodoSSH.Client.Android/Views/NavButton.cs create mode 100644 src/DodoSSH.Client.Android/Views/RecoveryCodeScreen.axaml create mode 100644 src/DodoSSH.Client.Android/Views/RecoveryCodeScreen.axaml.cs create mode 100644 src/DodoSSH.Client.Android/Views/ServerScreen.axaml create mode 100644 src/DodoSSH.Client.Android/Views/ServerScreen.axaml.cs create mode 100644 src/DodoSSH.Client.Android/Views/TerminalScreen.axaml create mode 100644 src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs diff --git a/README.md b/README.md index c15195f..798ab6c 100644 --- a/README.md +++ b/README.md @@ -260,10 +260,16 @@ vault is visible to everybody in that team and moving it back means deleting and ### 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). +`src/DodoSSH.Client.Android` is a phone-first head that shares every view model with the desktop one — the +keychain and a terminal, which is the scope [`docs/android-port.md`](docs/android-port.md) decided on and +the reasoning behind it. Sign in, unlock, browse hosts, open a shell, and read the keychain; the two +host-key decisions and the counted delete confirmations are there too, and none of them were softened to +fit 360dp. + +What it does **not** have is file transfer — deliberately, since scoped storage means there is no local +pane to put beside the remote one — and the four list screens the desktop grew last (pins, snippets, logs, +teams), whose view models are already shared and which are additive rather than structural. Importing an +`~/.ssh/config` has no meaning on a phone at all. 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 @@ -284,13 +290,18 @@ API 36 is not a preference. `Avalonia.Controls.WebView` ships only a `net10.0-an 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. +Four 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. +**Signing in does not use the desktop's loopback redirect, and must not.** On a shared device any other +application can bind a loopback port and race for the authorization code — the attack RFC 8252 §8.3 names. +The phone registers a redirect with the system instead and is handed the response as an intent. Everything +above that — PKCE, the state check, discovery, the token exchange, the key binding — is the same code the +desktop runs, because the only thing that varies is where the response arrives. + +The redirect is a private-use scheme (`dev.dodotech.dodossh:`) rather than an Android App Link, and the +limit is worth knowing: another app can declare the same scheme, and Android will offer a chooser rather +than refuse. PKCE is what makes an intercepted code useless. An App Link closes it properly and costs an +`assetlinks.json` on your own server's domain. **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 @@ -301,9 +312,15 @@ passphrase again and re-registering — which the unlock screen treats as ordina 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. +**The recovery code screen blocks screenshots.** `FLAG_SECURE` is raised for that one state and lowered +again afterwards, so the screen's own claim is true and a shell is still screenshotable. It stops the +accident worth stopping — the only copy of an unrecoverable code landing in a cloud photo library, or in the +recent-apps thumbnail — and stops nothing determined, since a second phone photographs a screen perfectly +well. + **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. +for arm64 — that is verified, and CI verifies it on every change. Everything about its runtime behaviour is +not, and `docs/android-port.md` says which claims those are. ### End-to-end verification diff --git a/docs/android-port.md b/docs/android-port.md index 2e976bc..62bdfa3 100644 --- a/docs/android-port.md +++ b/docs/android-port.md @@ -1,7 +1,9 @@ # An Android client: what it would take -**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 +**Status: the decided scope is built. Steps 3–8 are done.** `src/DodoSSH.Client.Android` is a phone-first +head that builds and packages: the keychain and a terminal, which is exactly the scope +[decided](#scope-the-keychain-and-a-terminal), plus the sign-in and enrollment states needed to reach them +from a phone that has never been used before. 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. @@ -437,30 +439,57 @@ go at 360dp: `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. +5. ~~**Android sign-in.**~~ **Done**, and the seam it needed turned out to be worth more than the + implementation. `IAuthorizationCallback` now sits between `OidcClient` and the loopback listener, so the + two heads differ in *where the response arrives* and in nothing else — PKCE, the state check, discovery, + the token exchange and the key binding are one implementation, which is the point. A second OIDC client + would have been a second place for a security bug to live. - **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. + The system browser by `ACTION_VIEW` rather than a Custom Tab: a Custom Tab is the nicer surface and + shares the same cookie jar, but it needs `androidx.browser` and this needs nothing. Worth revisiting when + something else pulls AndroidX in. + + **A private-use scheme, not an App Link**, and the difference is worth writing down rather than + discovering. The redirect is `dev.dodotech.dodossh:/callback` — the reversed package name, which RFC 8252 + §7.1 recommends. Another application *can* also declare that scheme, and Android will show a chooser + rather than refuse; what stops that being a compromise is PKCE, since the code is useless without a + verifier that never leaves the process. An App Link would close it properly, at the cost of an + `assetlinks.json` on the DodoSSH server's own domain — a server change, and the natural upgrade. 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. +7. ~~**The interface**, phone-first.~~ **Done for the decided scope** — all seven screens of the design, + plus the two states the design does not draw because it starts at an enrolled phone (naming a server, and + choosing a passphrase). -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. + All five of the ◆ states this document worried about are built and none of them softened: + + - the changed-key refusal has no continue button, and is a full-screen panel rather than a bottom sheet + precisely because a sheet is swipe-to-dismiss by convention; + - the recovery code cannot be skipped, and `FLAG_SECURE` is raised for that state alone so the screen's + claim about screenshots is true rather than decorative; + - the delete confirmations keep their counts and replace the row in place; + - the unknown-key prompt shows the fingerprint in full, wrapping rather than clipping; + - the conflict log is a banner on the host list rather than a screen nobody opens. + + The nav rail's eight destinations became four. Pins, snippets, logs, import and teams are not built here: + import has no meaning under scoped storage, and the other four are list screens whose view models already + exist — they are additive rather than structural. +8. ~~**The terminal.**~~ **Done** — one `NativeWebView` for every session, as on the desktop and for the + same reason, with the tab strip scrolling horizontally rather than wrapping so that opening a tab never + reflows a terminal while output is arriving. + + The accessory key row needed one addition to the shared layer: `TerminalWorkspace.SendInputAsync`, since + ordinary typing goes from the renderer straight down the socket and there was no way in for input that + has no key to produce it. Ctrl latches rather than being held — one thumb cannot chord — and the latch is + drawn, because a modifier that is on and does not look on is how somebody sends `^L` to a database prompt + believing they typed an `l`. + +All eight are done for the decided scope. What is left is not on this list: the four destinations the +phone does not draw, file transfer (deliberately deferred, and see the decision above for the shape it +takes), the App Link upgrade, and — the one that is not optional — running any of it on a device. --- diff --git a/src/DodoSSH.Client.Android/App.axaml b/src/DodoSSH.Client.Android/App.axaml index a0d22f3..41c13de 100644 --- a/src/DodoSSH.Client.Android/App.axaml +++ b/src/DodoSSH.Client.Android/App.axaml @@ -21,6 +21,7 @@ + diff --git a/src/DodoSSH.Client.Android/App.axaml.cs b/src/DodoSSH.Client.Android/App.axaml.cs index 16bca11..6f5bc1a 100644 --- a/src/DodoSSH.Client.Android/App.axaml.cs +++ b/src/DodoSSH.Client.Android/App.axaml.cs @@ -4,6 +4,7 @@ using Avalonia.Markup.Xaml; using DodoSSH.Client.Android.Platform; using DodoSSH.Client.Android.Views; +using DodoSSH.Client.Auth; using DodoSSH.Client.Session; using DodoSSH.Client.Shell.Terminal; using DodoSSH.Client.Shell.ViewModels; @@ -86,49 +87,69 @@ public sealed partial class DodoSshApp : Avalonia.Application // its own, which is the half that would otherwise leave a notification up over nothing. var keepAlive = new SessionKeepAlive(workspace, activeTransfers: () => 0); + keepAlive.Refresh(); + + return new PhoneShell { DataContext = ComposeShell(paths, caches, workspace, knownHosts, connections) }; + } + + /// + /// Split from only for length. The division is a real one though: above this is + /// the platform graph, and below it is the shell every head shares. + /// + private static MainWindowViewModel ComposeShell( + ClientPaths paths, + ClientCacheFactory caches, + TerminalWorkspace workspace, + VaultKnownHostStore knownHosts, + SshNetConnectionFactory connections) + { // Difference 3: the Android keystore, with a fingerprint or the device credential releasing the // key. A straight implementation of the interface the session layer has always taken. var deviceKeys = new AndroidDeviceKeyStore(paths); + var browser = new AndroidBrowserLauncher(); + var viewModel = new MainWindowViewModel( paths, caches, workspace, knownHosts, deviceKeys, - SignInIsNotBuiltHere, + + // Difference 4: the system browser by intent, and the response by intent too rather than on a + // loopback socket. See AndroidAuthorization for why the desktop's listener is not reused. + async (url, cancellationToken) => await ServerConnection + .SignInAsync(url, browser, TimeProvider.System, cancellationToken, ConfigureAndroidOidc) + .ConfigureAwait(false), + TimeProvider.System, connections, - passphraseProfile: null); + passphraseProfile: null, + + // The other half of signing in: a refresh grant, no browser, and nobody present. It is what + // makes a launch after the first one arrive online rather than merely enrolled. + resume: async (url, refreshToken, cancellationToken) => await ServerConnection + .ResumeAsync(url, refreshToken, TimeProvider.System, cancellationToken) + .ConfigureAwait(false)); // Started rather than awaited: framework initialisation must not block on a schema migration. The // view model shows its own progress and handles its own failures. _ = viewModel.StartAsync(CancellationToken.None); - keepAlive.Refresh(); - - return new PhoneShell { DataContext = viewModel }; + return viewModel; } /// - /// Difference 4, and the one that is a refusal rather than an implementation. + /// Difference 4: where the authorization response comes back to. /// /// - /// - /// Signing in needs a redirect this head has not got. The desktop client receives the authorization - /// response on a loopback TcpListener (RFC 8252 §7.3), and reusing that here would be a - /// security regression rather than a shortcut: on a shared device any other application can bind a - /// loopback port and race for the response, which is the attack §8.3 names and the reason app links - /// exist. Process.Start does not exist on this platform either. - /// - /// - /// So this throws rather than half-working, and the shell never reaches it: NeedsServer draws a - /// screen that says the same thing in the user's words. A phone enrolled from the desktop client - /// unlocks here perfectly well, because unlocking needs no network at all. - /// + /// The only thing this head changes about signing in, and it changes it for a security reason rather + /// than a platform one. The desktop receives the response on a loopback TcpListener; on a phone + /// any other installed application can bind a loopback port and race for the authorization code, which + /// is the attack RFC 8252 §8.3 names. Android routes a registered redirect to this application + /// instead — see , including what a private-use scheme does and + /// does not protect against. /// - private static Task SignInIsNotBuiltHere(Uri serverUrl, CancellationToken cancellationToken) => - throw new NotSupportedException( - "Signing in is not built on the Android head yet: it needs an app-link redirect rather than " - + "the desktop client's loopback listener. See docs/android-port.md §5."); + private static OidcClientOptions ConfigureAndroidOidc(OidcClientOptions options) => + options with { CallbackFactory = path => new AndroidRedirectCallback(path) }; } diff --git a/src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj b/src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj index 597c308..7dc1237 100644 --- a/src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj +++ b/src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj @@ -30,6 +30,11 @@ false + + + + + diff --git a/src/DodoSSH.Client.Android/DodoSshAndroidApplication.cs b/src/DodoSSH.Client.Android/DodoSshAndroidApplication.cs index 456174f..6b8498b 100644 --- a/src/DodoSSH.Client.Android/DodoSshAndroidApplication.cs +++ b/src/DodoSSH.Client.Android/DodoSshAndroidApplication.cs @@ -1,11 +1,9 @@ // See the note at the top of MainActivity for why the platform namespaces are reached through `global::`. -using global::Android.App; -using global::Android.Runtime; - using Avalonia; using Avalonia.Android; - using DodoSSH.Client.Android.Platform; +using global::Android.App; +using global::Android.Runtime; namespace DodoSSH.Client.Android; diff --git a/src/DodoSSH.Client.Android/MainActivity.cs b/src/DodoSSH.Client.Android/MainActivity.cs index 0f36537..87e9f0d 100644 --- a/src/DodoSSH.Client.Android/MainActivity.cs +++ b/src/DodoSSH.Client.Android/MainActivity.cs @@ -3,10 +3,11 @@ // binds to `DodoSSH.Client.Android.App` — this head's own Avalonia application type — rather than to the // platform. The desktop head hit the same class of collision and answered it by renaming its type; here // the collision is in the namespace itself, so the qualification is the honest fix. -using global::Android.App; -using global::Android.Content.PM; - using Avalonia.Android; +using DodoSSH.Client.Android.Platform; +using global::Android.App; +using global::Android.Content; +using global::Android.Content.PM; namespace DodoSSH.Client.Android; @@ -45,6 +46,57 @@ namespace DodoSSH.Client.Android; | ConfigChanges.SmallestScreenSize | ConfigChanges.KeyboardHidden | ConfigChanges.UiMode)] +// The intent filter is what makes the sign-in redirect reach this application rather than a loopback +// socket. The scheme is the reversed package name — RFC 8252 §7.1 — and it must match the constant on +// AndroidRedirectCallback exactly; they are two declarations of one fact, so the constant is referenced +// here rather than retyped and a rename cannot break only one of them. +[IntentFilter( + [Intent.ActionView], + Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], + DataScheme = AndroidRedirectCallback.Scheme)] public sealed class MainActivity : AvaloniaMainActivity { + /// + /// + /// OnNewIntent rather than OnCreate, and that is what SingleTask above buys: the + /// activity is already running with a sign-in waiting inside it, so the redirect has to be delivered + /// into that instance. Any other launch mode would start a second copy of the activity — and with it a + /// second Avalonia application over a live one — leaving the original waiting for a response that had + /// already been consumed. + /// + /// + protected override void OnResume() + { + base.OnResume(); + + PhoneEnvironment.CurrentActivity = this; + } + + /// + /// + /// Cleared only if it is still this activity. Android may resume the next one before pausing this one, + /// and clearing unconditionally would drop a reference the newcomer had just set. + /// + protected override void OnPause() + { + base.OnPause(); + + if (ReferenceEquals(PhoneEnvironment.CurrentActivity, this)) + { + PhoneEnvironment.CurrentActivity = null; + } + } + + /// + protected override void OnNewIntent(Intent? intent) + { + base.OnNewIntent(intent); + + if (intent?.Data is { } data + && string.Equals(data.Scheme, AndroidRedirectCallback.Scheme, StringComparison.Ordinal) + && Uri.TryCreate(data.ToString(), UriKind.Absolute, out var redirect)) + { + AndroidRedirectCallback.Complete(redirect); + } + } } diff --git a/src/DodoSSH.Client.Android/Platform/AndroidAuthorization.cs b/src/DodoSSH.Client.Android/Platform/AndroidAuthorization.cs new file mode 100644 index 0000000..378363f --- /dev/null +++ b/src/DodoSSH.Client.Android/Platform/AndroidAuthorization.cs @@ -0,0 +1,150 @@ +using DodoSSH.Client.Auth; +using global::Android.Content; + +namespace DodoSSH.Client.Android.Platform; + +/// +/// Opens the authorization page in the system browser. +/// +/// +/// +/// Process.Start does not exist on this platform, so the desktop head's launcher cannot be reused — +/// but the rule it enforces is the same one and is enforced here too: the system browser, never an +/// embedded WebView. RFC 8252 §8.12 — an embedded user-agent can read the user's credentials as they are +/// typed, hides the real address bar, and cannot reuse an existing single-sign-on session. +/// +/// +/// An ACTION_VIEW intent rather than a Custom Tab. A Custom Tab is the nicer surface and shares the +/// browser's cookie jar just as this does, but it needs the androidx.browser package; this needs +/// nothing and satisfies the same requirement. Worth revisiting when something else pulls AndroidX in. +/// +/// +internal sealed class AndroidBrowserLauncher : IBrowserLauncher +{ + /// + public Task OpenAsync(Uri url, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(url); + cancellationToken.ThrowIfCancellationRequested(); + + // Asserted rather than assumed, exactly as the desktop launcher does: an ACTION_VIEW on some other + // scheme is a request to open whatever app claims it, and this only ever opens an authorize URL. + if (!string.Equals(url.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal) + && !string.Equals(url.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal)) + { + throw new ArgumentException($"Refusing to open a '{url.Scheme}' URL.", nameof(url)); + } + + var intent = new Intent(Intent.ActionView, global::Android.Net.Uri.Parse(url.AbsoluteUri)); + + // NewTask because the launch comes from an application context rather than an activity one. + intent.AddFlags(ActivityFlags.NewTask); + + PhoneEnvironment.Require().StartActivity(intent); + + return Task.CompletedTask; + } +} + +/// +/// Receives the authorization response as an intent rather than on a socket. +/// +/// +/// +/// Why not the loopback listener. RFC 8252 §7.3's loopback redirect assumes a machine where the +/// application's own port is the application's own. On a phone it is not: any installed application can +/// bind a loopback port and, on a race, take the authorization code. §8.3 names this, and the platform's +/// answer is to have the system route the redirect to the registered application instead. +/// +/// +/// A private-use scheme, and its limit is worth stating. The redirect is +/// dev.dodotech.dodossh:/oauth — the reversed package name, which RFC 8252 §7.1 recommends because +/// it is a namespace the application demonstrably controls. It is *not* an Android App Link, and the +/// difference is real: another application can also declare this scheme, and Android will offer the user a +/// chooser rather than refusing. What stops that being a compromise is PKCE — the code is useless without +/// the verifier, which never leaves this process — plus the state check. An App Link would close the gap +/// properly, at the cost of hosting an assetlinks.json on the DodoSSH server's own domain; that is the +/// upgrade, and it is a server change rather than a client one. +/// +/// +/// The completion HTML is ignored. There is no tab of this application's own to write it into: the system +/// hands the intent over and closes the browser itself. +/// +/// +internal sealed class AndroidRedirectCallback : IAuthorizationCallback +{ + /// The scheme, which must match the intent filter on MainActivity exactly. + internal const string Scheme = "dev.dodotech.dodossh"; + + private readonly TaskCompletionSource completion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + private static AndroidRedirectCallback? waiting; + + /// + /// The redirect path, from OidcClientOptions. Carried through so the desktop's configured value and + /// this one cannot silently differ. + /// + public AndroidRedirectCallback(string path) + { + RedirectUri = new Uri($"{Scheme}:{(path.StartsWith('/') ? path : "/" + path)}"); + + // Exactly one sign-in can be outstanding, because exactly one activity receives the intent. A + // second overlapping attempt would leave the first waiting forever on a response the second + // consumed, so the earlier one is failed rather than orphaned. + Interlocked.Exchange(ref waiting, this)?.completion.TrySetCanceled(); + } + + /// + public Uri RedirectUri { get; } + + /// Hands a received redirect to whichever sign-in is waiting for it. + /// + /// Called from MainActivity.OnNewIntent. Returns quietly when nothing is waiting: a redirect + /// can arrive after the app was killed and relaunched, and that is a stale response rather than an + /// error worth showing anybody. + /// + internal static void Complete(Uri redirect) + { + var target = Volatile.Read(ref waiting); + + if (target is null) + { + return; + } + + var parameters = new Dictionary(StringComparer.Ordinal); + + // The query, and the fragment if the provider used one. Response parameters arrive in the query + // for a code flow; reading both costs nothing and means a provider that answers errors in the + // fragment does not look like a silent hang. + foreach (var part in new[] { redirect.Query, redirect.Fragment }) + { + foreach (var pair in part.TrimStart('?', '#').Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var split = pair.Split('=', 2); + + parameters[Uri.UnescapeDataString(split[0])] = + split.Length == 2 ? Uri.UnescapeDataString(split[1]) : string.Empty; + } + } + + target.completion.TrySetResult(new CallbackResult(parameters)); + } + + /// + public Task WaitForCallbackAsync( + string completionHtml, + CancellationToken cancellationToken) => + completion.Task.WaitAsync(cancellationToken); + + /// + public void Dispose() + { + // Only if it is still this one. A later sign-in has already replaced it, and clearing the slot + // then would silently break the attempt that is actually in flight. + Interlocked.CompareExchange(ref waiting, null, this); + + completion.TrySetCanceled(); + } +} diff --git a/src/DodoSSH.Client.Android/Platform/AndroidDeviceKeyStore.cs b/src/DodoSSH.Client.Android/Platform/AndroidDeviceKeyStore.cs index 579f3a0..cac8b5e 100644 --- a/src/DodoSSH.Client.Android/Platform/AndroidDeviceKeyStore.cs +++ b/src/DodoSSH.Client.Android/Platform/AndroidDeviceKeyStore.cs @@ -1,11 +1,10 @@ +using DodoSSH.Client.Session; using global::Android.App; using global::Android.Security.Keystore; using global::Java.Security; using global::Javax.Crypto; using global::Javax.Crypto.Spec; -using DodoSSH.Client.Session; - namespace DodoSSH.Client.Android.Platform; /// diff --git a/src/DodoSSH.Client.Android/Platform/PhoneEnvironment.cs b/src/DodoSSH.Client.Android/Platform/PhoneEnvironment.cs index 656d3ac..9b4936f 100644 --- a/src/DodoSSH.Client.Android/Platform/PhoneEnvironment.cs +++ b/src/DodoSSH.Client.Android/Platform/PhoneEnvironment.cs @@ -1,9 +1,8 @@ +using DodoSSH.Client.Session; using global::Android.Content; using global::Android.OS; using global::Android.Provider; -using DodoSSH.Client.Session; - namespace DodoSSH.Client.Android.Platform; /// @@ -78,6 +77,17 @@ internal static class PhoneEnvironment } } + /// + /// The activity currently on screen, or null while the app is backgrounded. + /// + /// + /// Set and cleared by MainActivity. Separate from because the two have + /// genuinely different lifetimes and different uses: the application context is what outlives + /// everything and is right for starting services, and this is what a window flag or a system dialogue + /// needs — neither substitutes for the other. + /// + public static global::Android.App.Activity? CurrentActivity { get; set; } + /// The application context, once has run. public static Context Require() => context ?? throw new InvalidOperationException( diff --git a/src/DodoSSH.Client.Android/Theme/Phone.axaml b/src/DodoSSH.Client.Android/Theme/Phone.axaml new file mode 100644 index 0000000..8cbb31b --- /dev/null +++ b/src/DodoSSH.Client.Android/Theme/Phone.axaml @@ -0,0 +1,193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DodoSSH.Client.Android/Views/EnrollScreen.axaml b/src/DodoSSH.Client.Android/Views/EnrollScreen.axaml new file mode 100644 index 0000000..3c90415 --- /dev/null +++ b/src/DodoSSH.Client.Android/Views/EnrollScreen.axaml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs index 93dfc3b..3c4a1b7 100644 --- a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs +++ b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs @@ -1,10 +1,82 @@ +using global::Android.Views; + using Avalonia.Controls; using Avalonia.Markup.Xaml; +using DodoSSH.Client.Android.Platform; +using DodoSSH.Client.Shell.ViewModels; + namespace DodoSSH.Client.Android.Views; /// The phone's single view. The desktop head's MainWindow, without the window. internal sealed partial class PhoneShell : UserControl { - public PhoneShell() => AvaloniaXamlLoader.Load(this); + private MainWindowViewModel? shell; + + public PhoneShell() + { + AvaloniaXamlLoader.Load(this); + + DataContextChanged += (_, _) => + { + if (shell is not null) + { + shell.PropertyChanged -= OnShellChanged; + } + + shell = DataContext as MainWindowViewModel; + + if (shell is not null) + { + shell.PropertyChanged += OnShellChanged; + ApplyScreenshotPolicy(shell.State); + } + }; + } + + private void OnShellChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (shell is not null && e.PropertyName is nameof(MainWindowViewModel.State)) + { + ApplyScreenshotPolicy(shell.State); + } + } + + /// + /// Blocks screenshots and screen recording while the recovery code is on screen. + /// + /// + /// + /// The recovery screen says screenshots are blocked, and this is what makes that true rather than a + /// claim. FLAG_SECURE is a window flag — no control can set it — so it lives here, on the one + /// object that has the activity. + /// + /// + /// It is worth being clear about what this buys. It stops the obvious accident — a screenshot + /// of the only copy of an unrecoverable code landing in a cloud photo library — and it excludes the + /// screen from the recent-apps thumbnail, which is the part users never think about. It stops nothing + /// determined: a second phone photographs the screen perfectly well. The code is meant to be written + /// down, and this only pushes people away from the one place it must not be written down to. + /// + /// + /// Lowered again afterwards rather than left on. Leaving it set would make the terminal unscreenshotable + /// too, and a screenshot of a shell is a thing people legitimately want. + /// + /// + private static void ApplyScreenshotPolicy(ShellState state) + { + if (PhoneEnvironment.CurrentActivity?.Window is not { } window) + { + return; + } + + if (state is ShellState.ShowingRecoveryCode) + { + window.SetFlags(WindowManagerFlags.Secure, WindowManagerFlags.Secure); + } + else + { + window.ClearFlags(WindowManagerFlags.Secure); + } + } } diff --git a/src/DodoSSH.Client.Android/Views/RecoveryCodeScreen.axaml b/src/DodoSSH.Client.Android/Views/RecoveryCodeScreen.axaml new file mode 100644 index 0000000..95af9c4 --- /dev/null +++ b/src/DodoSSH.Client.Android/Views/RecoveryCodeScreen.axaml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs b/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs new file mode 100644 index 0000000..9af3e31 --- /dev/null +++ b/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs @@ -0,0 +1,180 @@ +using System.Text; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Markup.Xaml; +using Avalonia.Media; + +using DodoSSH.Client.Shell.ViewModels; + +// Avalonia's Button, not Android.Widget's. .NET for Android puts Android.Widget in this project's +// implicit usings, so the bare name is ambiguous — the same collision the namespace itself causes +// for Android.App. See the note at the top of MainActivity. +using Application = Avalonia.Application; +using Button = Avalonia.Controls.Button; + +namespace DodoSSH.Client.Android.Views; + +/// Design 03 — the terminal, and the accessory key row under it. +internal sealed partial class TerminalScreen : UserControl +{ + /// + /// The keys a software keyboard does not have. + /// + /// + /// + /// Chosen by what a shell session actually needs rather than by what a keyboard has: Esc leaves vi's + /// insert mode, Tab completes a path, Ctrl makes ^C reachable, the arrows reach history, and the + /// pipe and hyphen are two characters that are three taps deep on every Android keyboard and appear in + /// almost every command worth typing on a phone. + /// + /// + /// The order matches the design's row. It is scrollable rather than compressed, because shrinking ten + /// keys to fit 360dp produces ten targets too small to hit. + /// + /// + private static readonly (string Label, byte[] Bytes, bool Latches)[] Keys = + [ + ("esc", [0x1B], false), + ("tab", [0x09], false), + ("ctrl", [], true), + ("|", [(byte)'|'], false), + ("-", [(byte)'-'], false), + ("~", [(byte)'~'], false), + ("/", [(byte)'/'], false), + + // The cursor keys, as the ANSI sequences a PTY expects. Written out rather than composed, because + // the difference between CSI A and SS3 A is the difference between working in bash and not. + ("↑", [0x1B, (byte)'[', (byte)'A'], false), + ("↓", [0x1B, (byte)'[', (byte)'B'], false), + ("←", [0x1B, (byte)'[', (byte)'D'], false), + ("→", [0x1B, (byte)'[', (byte)'C'], false), + ]; + + /// + /// Whether the next ordinary key should be sent as a control character. + /// + /// + /// Latching rather than held. Holding a modifier while typing needs two hands and a keyboard that + /// reports chords, and this row has neither — so Ctrl is pressed, then C, and releases itself. Every + /// Android SSH client does this and users expect it. + /// + private bool controlLatched; + + private Button? controlKey; + + public TerminalScreen() + { + AvaloniaXamlLoader.Load(this); + + BuildAccessoryRow(); + + // The URL is only known once the loopback listener has bound a port, so it cannot be set in XAML. + DataContextChanged += (_, _) => + { + if (DataContext is MainWindowViewModel shell) + { + this.FindControl("Renderer")!.Source = shell.TerminalPageUrl; + } + }; + } + + private void BuildAccessoryRow() + { + var row = this.FindControl("AccessoryKeys")!; + + foreach (var (label, bytes, latches) in Keys) + { + var key = new Button + { + Content = new TextBlock + { + Text = label, + FontFamily = (FontFamily)Application.Current!.FindResource("MonoFont")!, + FontSize = 11, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }, + + // 44 wide as well as tall. The design draws them flexed across the width, which at 360dp + // with ten keys is 32 pixels each — under every thumb-target guideline there is. + MinWidth = 44, + Height = 34, + Padding = new Thickness(10, 0), + CornerRadius = new CornerRadius(5), + Background = new SolidColorBrush(Color.Parse("#161B19")), + BorderBrush = new SolidColorBrush(Color.Parse("#232927")), + BorderThickness = new Thickness(1), + Foreground = new SolidColorBrush(Color.Parse("#B7C0BB")), + HorizontalContentAlignment = HorizontalAlignment.Center, + }; + + if (latches) + { + controlKey = key; + key.Click += (_, _) => ToggleControl(); + } + else + { + key.Click += (_, _) => SendAsync(bytes); + } + + row.Children.Add(key); + } + } + + private void ToggleControl() + { + controlLatched = !controlLatched; + + if (controlKey is null) + { + return; + } + + // Latched state has to be visible. A modifier that is on and does not look on is how somebody sends + // ^L to a database prompt believing they typed an l. + controlKey.Background = new SolidColorBrush( + Color.Parse(controlLatched ? "#243A2F" : "#161B19")); + + controlKey.Foreground = new SolidColorBrush( + Color.Parse(controlLatched ? "#3CE88F" : "#B7C0BB")); + } + + /// + /// The control translation is the ASCII one and nothing cleverer: Ctrl-A through Ctrl-Z are 0x01 to + /// 0x1A, which is letter & 0x1F. Applied only to letters, because Ctrl with an arrow key is a + /// different sequence entirely and silently mangling one into a control byte would be worse than + /// ignoring the latch. + /// + private void SendAsync(byte[] bytes) + { + if (DataContext is not MainWindowViewModel { SelectedTab: { } tab } shell) + { + return; + } + + var payload = bytes; + + if (controlLatched && bytes.Length == 1) + { + var c = bytes[0]; + + if (c is >= (byte)'a' and <= (byte)'z' or >= (byte)'A' and <= (byte)'Z') + { + payload = [(byte)(c & 0x1F)]; + } + + ToggleControl(); + } + + // Discarded rather than awaited: this is a keystroke, the workspace ignores a session that has + // gone, and a button handler that awaited would serialise the row behind a slow link. + _ = shell.SendTerminalInputAsync(tab.SessionId, payload).AsTask(); + } + + /// Sends a literal string, for the keys that carry text rather than a control code. + /// Kept because the snippet feature will type into a terminal exactly this way. + internal void Send(string text) => SendAsync(Encoding.UTF8.GetBytes(text)); +} diff --git a/src/DodoSSH.Client.Android/packages.lock.json b/src/DodoSSH.Client.Android/packages.lock.json index 1d3b72a..d835f3e 100644 --- a/src/DodoSSH.Client.Android/packages.lock.json +++ b/src/DodoSSH.Client.Android/packages.lock.json @@ -1170,34 +1170,6 @@ "resolved": "1.0.22", "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ==" } - }, - "net10.0-android36.0/android-x64": { - "HarfBuzzSharp.NativeAssets.Android": { - "type": "Transitive", - "resolved": "8.3.1.3", - "contentHash": "Yte9/yYql8ngAjo7YgHlXSinLJcJXIRBM9gegVXpJ2SVYT1i2O/wMA+H3jmYiYiTQxHpHKi4exZUcMzry171MA==" - }, - "HarfBuzzSharp.NativeAssets.Linux": { - "type": "Transitive", - "resolved": "8.3.1.3", - "contentHash": "RI6A1LgmooU30+4QIyFt5rmBCzP0VzTR+587IJSGvYIsHHWlahFufihYxtraLfsIhW7I8dn6+xX+DZGygOPKWQ==" - }, - "SkiaSharp.NativeAssets.Android": { - "type": "Transitive", - "resolved": "3.119.4", - "contentHash": "qfjNh5hZBZxpOIM1aeDByj2qNbcK2JZG5Y7YyGSeliaYnf1N/hVfsswIPUa+qzcMqS9Q0VCGk85zQLvwVXtrvQ==" - }, - "SkiaSharp.NativeAssets.Linux": { - "type": "Transitive", - "resolved": "3.119.4", - "contentHash": "UAyVzbqNfZsZbKbzj68zXLyUyF/SbTKmzTfOO6qDu++dtIUMMTzPBe8oOuzU/DiewpfKoUUlOSsJmqWc6blxBw==" - }, - "libsodium": { - "type": "CentralTransitive", - "requested": "[1.0.22, )", - "resolved": "1.0.22", - "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ==" - } } } } \ No newline at end of file diff --git a/src/DodoSSH.Client.Api/packages.lock.json b/src/DodoSSH.Client.Api/packages.lock.json index 8d54521..b82257b 100644 --- a/src/DodoSSH.Client.Api/packages.lock.json +++ b/src/DodoSSH.Client.Api/packages.lock.json @@ -41,6 +41,14 @@ "libsodium": "[1.0.22, 1.0.23)" } } + }, + "net10.0/android-arm64": { + "libsodium": { + "type": "CentralTransitive", + "requested": "[1.0.22, )", + "resolved": "1.0.22", + "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ==" + } } } } \ No newline at end of file diff --git a/src/DodoSSH.Client.Auth/LoopbackCallbackListener.cs b/src/DodoSSH.Client.Auth/LoopbackCallbackListener.cs index 4536dc7..eb8b1ac 100644 --- a/src/DodoSSH.Client.Auth/LoopbackCallbackListener.cs +++ b/src/DodoSSH.Client.Auth/LoopbackCallbackListener.cs @@ -43,7 +43,46 @@ public sealed record CallbackResult(IReadOnlyDictionary Paramete /// but the name resolves through the hosts file and DNS, and the address does not. /// /// -public sealed class LoopbackCallbackListener : IDisposable +/// +/// Where the authorization response comes back to. +/// +/// +/// +/// An interface because the answer is a platform decision. A desktop uses a loopback redirect (RFC 8252 +/// §7.3) because it has a real HTTP stack and no way to register a URI scheme; a phone must not, because +/// on a shared device any other application can bind a loopback port and race for the response — the +/// attack §8.3 names. Android registers a redirect with the system instead and is handed the response as +/// an intent. +/// +/// +/// What does not vary is everything above it. PKCE, the state check, discovery, the token exchange +/// and the key binding are the same on both, which is the whole point of putting the seam here rather than +/// giving the phone its own flow — a second implementation of an OIDC client is a second place for a +/// security bug to live. +/// +/// +public interface IAuthorizationCallback : IDisposable +{ + /// + /// Where the provider should send the response. + /// + /// + /// Read before the browser opens and sent again with the token request, because the authorization + /// server has to see the same value twice. A listener that chose its port lazily would hand out one + /// URI and listen on another. + /// + Uri RedirectUri { get; } + + /// Waits for the response. + /// + /// What to show in the browser once the response arrives. Ignored by an implementation that has no + /// browser tab of its own to write into — the system closes the tab when the app takes the intent. + /// + /// Abandons the wait. + Task WaitForCallbackAsync(string completionHtml, CancellationToken cancellationToken); +} + +public sealed class LoopbackCallbackListener : IAuthorizationCallback { /// Longest request line and header block accepted, as a denial-of-service bound. private const int MaximumRequestBytes = 16 * 1024; diff --git a/src/DodoSSH.Client.Auth/OidcClient.cs b/src/DodoSSH.Client.Auth/OidcClient.cs index a77334b..227653e 100644 --- a/src/DodoSSH.Client.Auth/OidcClient.cs +++ b/src/DodoSSH.Client.Auth/OidcClient.cs @@ -206,7 +206,10 @@ public sealed class OidcClient( var pkce = PkcePair.Create(); var state = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32)); - using var listener = new LoopbackCallbackListener(options.RedirectPath); + // Loopback on a desktop, a registered redirect on a phone. See OidcClientOptions.CallbackFactory — + // everything below this line is identical either way, which is the reason the seam is here and not + // one layer up. + using var listener = options.CallbackFactory(options.RedirectPath); var redirectUri = listener.RedirectUri; var authorizeUri = BuildAuthorizeUri( diff --git a/src/DodoSSH.Client.Auth/OidcClientOptions.cs b/src/DodoSSH.Client.Auth/OidcClientOptions.cs index e0a911f..8532787 100644 --- a/src/DodoSSH.Client.Auth/OidcClientOptions.cs +++ b/src/DodoSSH.Client.Auth/OidcClientOptions.cs @@ -8,7 +8,7 @@ namespace DodoSSH.Client.Auth; /// onboarding story: the user types one server URL and the client discovers the authority, the client /// id and the scopes from it. /// -public sealed class OidcClientOptions +public sealed record OidcClientOptions { /// The provider's issuer URL. public required Uri Authority { get; init; } @@ -28,6 +28,23 @@ public sealed class OidcClientOptions /// Path the loopback listener answers the redirect on. public string RedirectPath { get; init; } = "/callback"; + /// + /// How the authorization response is received. Loopback unless a head substitutes one. + /// + /// + /// + /// A factory rather than an instance, because a callback owns a socket or a system registration and + /// must not outlive one sign-in. It takes the redirect path so the default keeps behaving exactly as + /// it did when it was constructed inline. + /// + /// + /// The Android head replaces this: a loopback redirect on a shared device is the attack RFC 8252 §8.3 + /// names, since any other application can bind the port. See . + /// + /// + public Func CallbackFactory { get; init; } = + path => new LoopbackCallbackListener(path); + /// Whether provider metadata must be served over HTTPS. Only false for local development. public bool RequireHttpsMetadata { get; init; } = true; diff --git a/src/DodoSSH.Client.Auth/packages.lock.json b/src/DodoSSH.Client.Auth/packages.lock.json index f70ba7f..6bc156e 100644 --- a/src/DodoSSH.Client.Auth/packages.lock.json +++ b/src/DodoSSH.Client.Auth/packages.lock.json @@ -14,6 +14,7 @@ "resolved": "5.6.0", "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw==" } - } + }, + "net10.0/android-arm64": {} } } \ No newline at end of file diff --git a/src/DodoSSH.Client.Domain/packages.lock.json b/src/DodoSSH.Client.Domain/packages.lock.json index f70ba7f..6bc156e 100644 --- a/src/DodoSSH.Client.Domain/packages.lock.json +++ b/src/DodoSSH.Client.Domain/packages.lock.json @@ -14,6 +14,7 @@ "resolved": "5.6.0", "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw==" } - } + }, + "net10.0/android-arm64": {} } } \ No newline at end of file diff --git a/src/DodoSSH.Client.Import/packages.lock.json b/src/DodoSSH.Client.Import/packages.lock.json index a11804f..05c5d79 100644 --- a/src/DodoSSH.Client.Import/packages.lock.json +++ b/src/DodoSSH.Client.Import/packages.lock.json @@ -17,6 +17,7 @@ "dodossh.client.domain": { "type": "Project" } - } + }, + "net10.0/android-arm64": {} } } \ No newline at end of file diff --git a/src/DodoSSH.Client.ObjectStore/packages.lock.json b/src/DodoSSH.Client.ObjectStore/packages.lock.json index 99de2a2..08c7f37 100644 --- a/src/DodoSSH.Client.ObjectStore/packages.lock.json +++ b/src/DodoSSH.Client.ObjectStore/packages.lock.json @@ -83,6 +83,14 @@ "Microsoft.Extensions.Logging.Abstractions": "8.0.3" } } + }, + "net10.0/android-arm64": { + "libsodium": { + "type": "CentralTransitive", + "requested": "[1.0.22, )", + "resolved": "1.0.22", + "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ==" + } } } } \ No newline at end of file diff --git a/src/DodoSSH.Client.Session/ServerConnection.cs b/src/DodoSSH.Client.Session/ServerConnection.cs index 43f784b..9b7370b 100644 --- a/src/DodoSSH.Client.Session/ServerConnection.cs +++ b/src/DodoSSH.Client.Session/ServerConnection.cs @@ -270,7 +270,8 @@ public sealed class ServerConnection : IVaultServer Uri serverUrl, IBrowserLauncher browser, TimeProvider clock, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + Func? configureOidc = null) { ArgumentNullException.ThrowIfNull(serverUrl); ArgumentNullException.ThrowIfNull(browser); @@ -287,7 +288,17 @@ public sealed class ServerConnection : IVaultServer var meta = await discovery.GetMetaAsync(cancellationToken).ConfigureAwait(false); - var oidc = new OidcClient(transport, browser, clock, BuildOidcOptions(configuration)); + // The head gets a say in how the authorization response comes back, and in nothing else. + // Everything that decides whether the flow is *safe* — PKCE, the state check, the discovery + // document, the token exchange — is built from the server's own configuration above and is not + // reachable from here. See OidcClientOptions.CallbackFactory. + var oidcOptions = BuildOidcOptions(configuration); + + var oidc = new OidcClient( + transport, + browser, + clock, + configureOidc is null ? oidcOptions : configureOidc(oidcOptions)); var tokenSet = await oidc.SignInAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/DodoSSH.Client.Session/packages.lock.json b/src/DodoSSH.Client.Session/packages.lock.json index 0b12ecc..61c6203 100644 --- a/src/DodoSSH.Client.Session/packages.lock.json +++ b/src/DodoSSH.Client.Session/packages.lock.json @@ -291,6 +291,20 @@ "Microsoft.Extensions.Logging.Abstractions": "8.0.3" } } + }, + "net10.0/android-arm64": { + "libsodium": { + "type": "CentralTransitive", + "requested": "[1.0.22, )", + "resolved": "1.0.22", + "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ==" + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "CentralTransitive", + "requested": "[2.1.12, )", + "resolved": "2.1.12", + "contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w==" + } } } } \ No newline at end of file diff --git a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs index bb791f3..fa14d8b 100644 --- a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs +++ b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs @@ -411,6 +411,24 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp /// Where the embedded browser should navigate. internal Uri TerminalPageUrl => workspace.PageUrl; + /// + /// Types into a terminal on behalf of something that is not the keyboard. + /// + /// + /// + /// Exposed on the shell rather than reached through the workspace directly, because the workspace is a + /// composition-root object and a view has no business holding one — the same reason tabs go through + /// here rather than through TerminalWorkspace.CloseSessionAsync. + /// + /// + /// The Android head's accessory key row is what needs it: a software keyboard has no Ctrl, Esc, Tab or + /// arrows, so those keys are drawn and their bytes sent from here. Ordinary typing never comes this + /// way — it goes from the renderer straight down the socket. + /// + /// + internal ValueTask SendTerminalInputAsync(uint sessionId, ReadOnlyMemory data) => + workspace.SendInputAsync(sessionId, data, CancellationToken.None); + /// /// Raised when a terminal session opens, so the view can hand the terminal the keyboard. /// @@ -620,6 +638,24 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp Surface = ShellSurface.Page; } + /// Switches to the terminal surface. + /// + /// + /// The other half of , and it exists because the phone's bottom bar + /// names the terminal beside the pages. The desktop reaches this surface only implicitly — opening a + /// session or clicking a tab — because its tab strip is always on screen and is itself the way back. + /// A phone has no room for a permanent strip beside a full-height screen, so the destination needs a + /// button, and a button needs a command. + /// + /// + /// Not gated on there being a tab, for the same reason is not: closing + /// the last tab returns the surface to a page, so the empty case does not arise here — and the terminal + /// screen carries an empty state anyway, which is worth being able to reach deliberately. + /// + /// + [RelayCommand] + private void ShowTerminal() => Surface = ShellSurface.Terminal; + // ---- Open terminals ---- /// diff --git a/src/DodoSSH.Client.Shell/packages.lock.json b/src/DodoSSH.Client.Shell/packages.lock.json index c3f199a..ad198a7 100644 --- a/src/DodoSSH.Client.Shell/packages.lock.json +++ b/src/DodoSSH.Client.Shell/packages.lock.json @@ -371,6 +371,20 @@ "Microsoft.Extensions.Logging.Abstractions": "8.0.3" } } + }, + "net10.0/android-arm64": { + "libsodium": { + "type": "CentralTransitive", + "requested": "[1.0.22, )", + "resolved": "1.0.22", + "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ==" + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "CentralTransitive", + "requested": "[2.1.12, )", + "resolved": "2.1.12", + "contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w==" + } } } } \ No newline at end of file diff --git a/src/DodoSSH.Client.Ssh/packages.lock.json b/src/DodoSSH.Client.Ssh/packages.lock.json index fdf5787..e5c55f8 100644 --- a/src/DodoSSH.Client.Ssh/packages.lock.json +++ b/src/DodoSSH.Client.Ssh/packages.lock.json @@ -58,6 +58,14 @@ "resolved": "1.0.22", "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ==" } + }, + "net10.0/android-arm64": { + "libsodium": { + "type": "CentralTransitive", + "requested": "[1.0.22, )", + "resolved": "1.0.22", + "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ==" + } } } } \ No newline at end of file diff --git a/src/DodoSSH.Client.Storage/packages.lock.json b/src/DodoSSH.Client.Storage/packages.lock.json index 0b7ddce..cfebbd2 100644 --- a/src/DodoSSH.Client.Storage/packages.lock.json +++ b/src/DodoSSH.Client.Storage/packages.lock.json @@ -395,6 +395,20 @@ "SQLitePCLRaw.core": "2.1.12" } } + }, + "net10.0/android-arm64": { + "libsodium": { + "type": "CentralTransitive", + "requested": "[1.0.22, )", + "resolved": "1.0.22", + "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ==" + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "CentralTransitive", + "requested": "[2.1.12, )", + "resolved": "2.1.12", + "contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w==" + } } } } \ No newline at end of file diff --git a/src/DodoSSH.Client.Sync/packages.lock.json b/src/DodoSSH.Client.Sync/packages.lock.json index 76eefc2..8971da3 100644 --- a/src/DodoSSH.Client.Sync/packages.lock.json +++ b/src/DodoSSH.Client.Sync/packages.lock.json @@ -252,6 +252,20 @@ "SQLitePCLRaw.core": "2.1.12" } } + }, + "net10.0/android-arm64": { + "libsodium": { + "type": "CentralTransitive", + "requested": "[1.0.22, )", + "resolved": "1.0.22", + "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ==" + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "CentralTransitive", + "requested": "[2.1.12, )", + "resolved": "2.1.12", + "contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w==" + } } } } \ No newline at end of file diff --git a/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs b/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs index c62c42a..7e26335 100644 --- a/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs +++ b/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs @@ -335,6 +335,45 @@ public sealed class TerminalWorkspace : IAsyncDisposable return true; } + /// + /// Sends keystrokes to one terminal as though they had been typed into it. + /// + /// + /// + /// Ordinary typing does not come through here — it goes from the renderer's own keyboard handling + /// straight down the socket, which is one hop shorter and is what keeps a fast cat responsive. + /// This is for input that has no key on the keyboard to produce it. + /// + /// + /// Which on a phone is most of the useful input. A software keyboard has no Ctrl, Esc, Tab or + /// arrows, so the Android head draws an accessory row and sends the bytes itself; the desktop head + /// will want the same seam the day it grows a snippet that types into a terminal. Bytes rather than a + /// key name deliberately: what a terminal wants is a control sequence, and translating one at this + /// layer would mean owning a keymap that the renderer already owns. + /// + /// + /// A session id this workspace does not know is ignored rather than throwing. The caller is a tab + /// strip, and a tab that closed while a key was in flight is ordinary rather than exceptional. + /// + /// + /// The terminal to type into. + /// Raw bytes, already encoded as the remote expects them. + /// Cancellation token. + public ValueTask SendInputAsync( + uint sessionId, + ReadOnlyMemory data, + CancellationToken cancellationToken) + { + TerminalSessionPump? pump; + + lock (sessionGate) + { + pump = sessions.TryGetValue(sessionId, out var session) ? session.Pump : null; + } + + return pump is null ? ValueTask.CompletedTask : pump.WriteInputAsync(data, cancellationToken); + } + /// /// Closes one terminal. /// diff --git a/src/DodoSSH.Client.Terminal/packages.lock.json b/src/DodoSSH.Client.Terminal/packages.lock.json index 6e0493f..e1712a8 100644 --- a/src/DodoSSH.Client.Terminal/packages.lock.json +++ b/src/DodoSSH.Client.Terminal/packages.lock.json @@ -65,6 +65,14 @@ "Microsoft.Extensions.Logging.Abstractions": "8.0.3" } } + }, + "net10.0/android-arm64": { + "libsodium": { + "type": "CentralTransitive", + "requested": "[1.0.22, )", + "resolved": "1.0.22", + "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ==" + } } } } \ No newline at end of file diff --git a/src/DodoSSH.Client.Transfer/packages.lock.json b/src/DodoSSH.Client.Transfer/packages.lock.json index 6e0493f..e1712a8 100644 --- a/src/DodoSSH.Client.Transfer/packages.lock.json +++ b/src/DodoSSH.Client.Transfer/packages.lock.json @@ -65,6 +65,14 @@ "Microsoft.Extensions.Logging.Abstractions": "8.0.3" } } + }, + "net10.0/android-arm64": { + "libsodium": { + "type": "CentralTransitive", + "requested": "[1.0.22, )", + "resolved": "1.0.22", + "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ==" + } } } } \ No newline at end of file diff --git a/src/DodoSSH.Contracts/packages.lock.json b/src/DodoSSH.Contracts/packages.lock.json index a5a3876..395937b 100644 --- a/src/DodoSSH.Contracts/packages.lock.json +++ b/src/DodoSSH.Contracts/packages.lock.json @@ -26,6 +26,7 @@ "resolved": "10.0.10", "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg==" } - } + }, + "net10.0/android-arm64": {} } } \ No newline at end of file diff --git a/src/DodoSSH.Crypto/packages.lock.json b/src/DodoSSH.Crypto/packages.lock.json index 52bb4f7..2f41cfd 100644 --- a/src/DodoSSH.Crypto/packages.lock.json +++ b/src/DodoSSH.Crypto/packages.lock.json @@ -35,6 +35,14 @@ "resolved": "1.0.22", "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ==" } + }, + "net10.0/android-arm64": { + "libsodium": { + "type": "CentralTransitive", + "requested": "[1.0.22, )", + "resolved": "1.0.22", + "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ==" + } } } } \ No newline at end of file diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs index b41c273..d8593f6 100644 --- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs +++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs @@ -562,6 +562,37 @@ public sealed class ShellFlowTests : IAsyncLifetime shell.Screen.ShouldBe(ShellScreen.Preferences); } + /// + /// + /// The phone's bottom bar names the terminal beside the pages, so the surface needs a command of its + /// own — the desktop only ever reaches it implicitly, by opening a session or clicking a tab. + /// + /// + /// Tested here rather than in the Android head because it is shared state-machine behaviour, and + /// because nothing in this repository can run a test on a phone. + /// + /// + [Fact] + public async Task ShowingTheTerminal_SwitchesSurfaceWithoutChangingTheScreen() + { + var vault = await ReadyToConnectAsync(); + + await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); + await vault.ConnectCommand.ExecuteAsync(null); + + shell.ShowScreenCommand.Execute(ShellScreen.Vault); + shell.IsTerminalShowing.ShouldBeFalse(); + + shell.ShowTerminalCommand.Execute(null); + + shell.IsTerminalShowing.ShouldBeTrue(); + shell.IsShowingPages.ShouldBeFalse(); + + // The page underneath is remembered, not reset. Going to the terminal and back is navigation, and + // navigation that forgets where you were is how a four-button bar becomes annoying. + shell.Screen.ShouldBe(ShellScreen.Vault); + } + /// /// A visible WebView with no pane in it reads as the application having broken, so this is the one /// transition that moves the surface back on its own. diff --git a/tests/DodoSSH.Client.Terminal.Tests/TerminalWorkspaceTests.cs b/tests/DodoSSH.Client.Terminal.Tests/TerminalWorkspaceTests.cs index a62f3d4..c15d086 100644 --- a/tests/DodoSSH.Client.Terminal.Tests/TerminalWorkspaceTests.cs +++ b/tests/DodoSSH.Client.Terminal.Tests/TerminalWorkspaceTests.cs @@ -268,6 +268,56 @@ public sealed class TerminalWorkspaceTests // ---- Helpers ---- + /// + /// + /// Input that did not come from the keyboard. The Android head's accessory key row is what needs this — + /// a software keyboard has no Ctrl, Esc, Tab or arrows — and what it sends has to arrive at the remote + /// byte for byte, because an escape sequence that loses a byte is not a degraded arrow key, it is a + /// stray character in somebody's shell. + /// + /// + /// Ordinary typing does not come this way and is not what is being tested: that goes from the renderer + /// down the socket, which the pump's own tests cover. + /// + /// + [Fact] + public async Task SendingInput_ReachesTheRemoteUnchanged() + { + var connections = new FakeConnectionFactory(); + + await using var workspace = CreateWorkspace(connections); + + var sessionId = await workspace.OpenSessionAsync( + Request(), TerminalSize.Default, TestContext.Current.CancellationToken); + + // The up-arrow, as a PTY expects it. Three bytes, and all three matter. + byte[] upArrow = [0x1B, (byte)'[', (byte)'A']; + + await workspace.SendInputAsync(sessionId, upArrow, TestContext.Current.CancellationToken); + + await WaitUntilAsync(() => + connections.Connections.SingleOrDefault()?.Shell?.Written.SequenceEqual(upArrow) == true); + } + + /// + /// A tab can close while a key is still in flight, which on a phone is one mis-tap rather than a rare + /// race — the close cross sits inside the tab and the accessory row is directly under it. Throwing + /// would turn that into a crash on a keystroke that no longer matters. + /// + [Fact] + public async Task SendingInputToASessionThatIsGone_IsIgnored() + { + await using var workspace = CreateWorkspace(new FakeConnectionFactory()); + + var sessionId = await workspace.OpenSessionAsync( + Request(), TerminalSize.Default, TestContext.Current.CancellationToken); + + await workspace.CloseSessionAsync(sessionId); + + await Should.NotThrowAsync(async () => + await workspace.SendInputAsync(sessionId, "x"u8.ToArray(), TestContext.Current.CancellationToken)); + } + /// /// How connections are made. The renderer-gate tests never reach it — they stop at the gate — but they /// take a fake anyway, because reaching a real host from here would make this a network test.