Public Access
Give the phone the rest of its screens, and a way in
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.
This commit is contained in:
@@ -43,7 +43,46 @@ public sealed record CallbackResult(IReadOnlyDictionary<string, string> Paramete
|
||||
/// but the name resolves through the hosts file and DNS, and the address does not.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class LoopbackCallbackListener : IDisposable
|
||||
/// <summary>
|
||||
/// Where the authorization response comes back to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>What does not vary is everything above it.</b> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IAuthorizationCallback : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Where the provider should send the response.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
Uri RedirectUri { get; }
|
||||
|
||||
/// <summary>Waits for the response.</summary>
|
||||
/// <param name="completionHtml">
|
||||
/// 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.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Abandons the wait.</param>
|
||||
Task<CallbackResult> WaitForCallbackAsync(string completionHtml, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class LoopbackCallbackListener : IAuthorizationCallback
|
||||
{
|
||||
/// <summary>Longest request line and header block accepted, as a denial-of-service bound.</summary>
|
||||
private const int MaximumRequestBytes = 16 * 1024;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
/// </remarks>
|
||||
public sealed class OidcClientOptions
|
||||
public sealed record OidcClientOptions
|
||||
{
|
||||
/// <summary>The provider's issuer URL.</summary>
|
||||
public required Uri Authority { get; init; }
|
||||
@@ -28,6 +28,23 @@ public sealed class OidcClientOptions
|
||||
/// <summary>Path the loopback listener answers the redirect on.</summary>
|
||||
public string RedirectPath { get; init; } = "/callback";
|
||||
|
||||
/// <summary>
|
||||
/// How the authorization response is received. Loopback unless a head substitutes one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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 <see cref="IAuthorizationCallback"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public Func<string, IAuthorizationCallback> CallbackFactory { get; init; } =
|
||||
path => new LoopbackCallbackListener(path);
|
||||
|
||||
/// <summary>Whether provider metadata must be served over HTTPS. Only false for local development.</summary>
|
||||
public bool RequireHttpsMetadata { get; init; } = true;
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"net10.0/android-arm64": {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user