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.