diff --git a/.github/workflows/platform-mechanisms.yml b/.github/workflows/platform-mechanisms.yml index de9e5f482..c0a5df810 100644 --- a/.github/workflows/platform-mechanisms.yml +++ b/.github/workflows/platform-mechanisms.yml @@ -91,6 +91,7 @@ on: - tests/test_windows_tls.py - tests/test_windows_trust.py - tests/test_session_sign_out.py + - tests/test_browser_code.py - tests/test_windows_service.py - tests/test_windows_task_runtime.py - tests/test_windows_installation.py @@ -253,11 +254,11 @@ jobs: run: | .\.native-test-venv\Scripts\python.exe -B -m pytest -q -ra -s -p no:cacheprovider tests/test_windows_trust.py if ($LASTEXITCODE -ne 0) { throw 'Windows local-root trust checks failed.' } - - name: Check sign-out of all browsers on the real private store + - name: Check sign-out of all browsers and remote sign-in by code (real private store, real HTTPS) shell: pwsh run: | - .\.native-test-venv\Scripts\python.exe -B -m pytest -q -ra -p no:cacheprovider tests/test_session_sign_out.py - if ($LASTEXITCODE -ne 0) { throw 'Sign-out of all browsers checks failed.' } + .\.native-test-venv\Scripts\python.exe -B -m pytest -q -ra -p no:cacheprovider tests/test_session_sign_out.py tests/test_browser_code.py + if ($LASTEXITCODE -ne 0) { throw 'Sign-out of all browsers or remote sign-in by code checks failed.' } # Uninstall: real Start-menu shortcuts (ShellLink COM), the real HKCU # registry transaction, private files and NTFS sharing, in a throwaway # installation folder that the test removes. Task Scheduler is a stand-in. diff --git a/README.md b/README.md index 45036eb1e..c4a553a99 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,8 @@ Replace the example origin and Tailscale IP with your host's real addresses. Bef For the complete setup—including certificate transfer and trust, reverse-proxy limits, and Linux service management—see the [deployment guide](docs/DEPLOYMENT.md). +On a Windows host the steps differ: Unified stays on `127.0.0.1`, Tailscale forwards to it, and a browser on another device signs in with a one-use code that you make at the PC. See [Windows host: reach Unified from another device](docs/DEPLOYMENT.md#windows-host-reach-unified-from-another-device). +
diff --git a/amplifier_web/auth.py b/amplifier_web/auth.py index 30bda501e..9a6a2b312 100644 --- a/amplifier_web/auth.py +++ b/amplifier_web/auth.py @@ -192,7 +192,23 @@ def valid_session(app, value: str | None) -> bool: that sign-out is refused as well. """ issued = _issued(app["session_secret"], value, app["server_config"]["session_ttl_seconds"], "session") - return issued is not None and issued > app.get("session_epoch", 0) + return issued is not None and issued > getattr(app.get("sessions"), "epoch", 0) + + +class Sessions: + """The running host's sign-out time and the change hint of its saved record. + + A holder object: the keys of an application are fixed once it has started, + and this value changes while the host runs. + """ + + def __init__(self, epoch=0, hint=None): + self.epoch, self.hint = epoch, hint + + @classmethod + def load(cls, data_dir): + hint = _epoch_hint(data_dir) # Read first: a change during the read is seen next time. + return cls(session_epoch(data_dir), hint) _EPOCH = "session-epoch" @@ -280,22 +296,22 @@ def _epoch_hint(data_dir: Path): def refresh_sessions(app, streams=()) -> bool: """Apply a newer saved sign-out to the running host; True when applied. - Pending sign-in tickets are dropped, and open state streams are ended so - that a signed-out browser cannot keep watching. A stream that reconnects - must pass the session check again. + Pending sign-in tickets and codes are dropped, and open state streams are + ended so that a signed-out browser cannot keep watching. A stream that + reconnects must pass the session check again. """ - data_dir = app["service"].data_dir + data_dir, state = app["service"].data_dir, app["sessions"] hint = _epoch_hint(data_dir) - if hint == app.get("session_epoch_hint", ...): + if hint == state.hint: return False - app["session_epoch_hint"] = hint + state.hint = hint value = session_epoch(data_dir) - if value <= app.get("session_epoch", 0): + if value <= state.epoch: return False - app["session_epoch"] = value - store = app.get("browser_entry") - if store is not None: - store.clear() + state.epoch = value + for store in (app.get("browser_entry"), app.get("browser_codes")): + if store is not None: + store.clear() for task in list(streams): task.cancel() return True @@ -304,16 +320,26 @@ def refresh_sessions(app, streams=()) -> bool: async def watch_sessions(app, streams, *, interval=2.0): """Notice the owner's sign-out while the host runs.""" import asyncio + reported = False while True: await asyncio.sleep(interval) try: refresh_sessions(app, streams) - except Exception: # A watcher fault must never stop the host. - continue + except Exception as error: # A watcher fault must never stop the host. + if not reported: + # Say it once in the host log: a sign-out that the running + # host cannot apply must not fail in silence. + reported = True + try: + print("Sign-out of all browsers could not be checked: " + type(error).__name__, flush=True) + except (OSError, ValueError): + pass -def new_session(secret: str) -> str: - return _signed(secret, {"kind": "session", "issued": int(time.time()), "nonce": secrets.token_urlsafe(16)}) +def new_session(secret: str, *, remote: bool = False) -> str: + """Mark a session from a typed remote code; a local session is unchanged.""" + payload = {"kind": "session", "issued": int(time.time()), "nonce": secrets.token_urlsafe(16)} + return _signed(secret, {**payload, "via": "remote"} if remote else payload) def new_csrf(secret: str) -> str: @@ -394,15 +420,16 @@ async def authenticated(credential): # This exact handler checks a room/source-bound transport capability. # Neither that capability nor an owner cookie grants other API access. return await handler(request) - if request.path == browser_entry.ISSUE_PATH: + if request.path in {browser_entry.ISSUE_PATH, browser_entry.CODE_PATH}: authorization = request.headers.get("Authorization", "") if (request.method != "POST" or not authorization.startswith("Bearer ") or not hmac.compare_digest(authorization[7:].encode(), request.app["control_token"].encode())): return web.json_response({"error": "The owner control credential is required."}, status=403) return await handler(request) - if request.path == browser_entry.REDEEM_PATH and request.method == "POST": - # Only this exact public mutation. The handler requires HTTPS, Origin - # and signed form CSRF before atomically consuming the one-use ticket. + if request.path in {browser_entry.REDEEM_PATH, browser_entry.CODE_REDEEM_PATH} and request.method == "POST": + # Only these exact public mutations. Each handler requires HTTPS, Origin + # and signed form CSRF before atomically consuming the one-use ticket + # or typed code. return await handler(request) if extension and request.method == 'OPTIONS': return await handler(request) @@ -471,6 +498,9 @@ async def authenticated(credential): async def login_page(request: web.Request) -> web.Response: + if browser_entry.is_windows() and not browser_entry.loopback_host(urlsplit("//" + request.host).hostname): + # Another device cannot use the local launcher; it types an owner-made code. + return browser_entry.remote_login_page(request) secret = request.app["session_secret"] csrf = request.cookies.get(CSRF_COOKIE) # Preserve valid issuance for favicon redirects and concurrent login forms. diff --git a/amplifier_web/browser_entry.py b/amplifier_web/browser_entry.py index 58a47e04d..6e9d2625f 100644 --- a/amplifier_web/browser_entry.py +++ b/amplifier_web/browser_entry.py @@ -2,10 +2,12 @@ The foreground owner launcher reads existing private credentials only. Listener authentication and grant exchange remain separate from that local read. +A browser on another device signs in with a one-use code that the owner makes +on this computer and types on that device. """ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace import hashlib import hmac import ipaddress @@ -19,7 +21,14 @@ ISSUE_PATH = "/api/browser-entry/issue" REDEEM_PATH = "/api/browser-entry/redeem" +CODE_PATH = "/api/browser-entry/code" +CODE_REDEEM_PATH = "/api/browser-entry/code/redeem" +CODE_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ" # 32 symbols: no 0, 1, I or O. +CODE_LENGTH = 10 +_CODE = re.compile(f"[{CODE_ALPHABET}]{{{CODE_LENGTH}}}") REOPEN = "Open from the owner launcher again: amplifier-unified open." +NEW_CODE = "Make sure that Amplifier is running on this PC. Then run this again: amplifier-unified browsers code." +RELOAD = "Could not sign in. Open the sign-in page again and type the code." class BrowserEntryUnavailable(RuntimeError): @@ -179,6 +188,66 @@ def consume(self, ticket: str, binding: Binding) -> str | None: return record[2] +class CodeStore: + """Typed one-use codes for a browser on another device; hash-only, bounded. + + Fifty bits, at most eight codes and two minutes carry the guess resistance. + So there is no lockout and no delay: a wrong code is counted and never + removes a real one, and nobody can keep the owner out by guessing. + """ + + def __init__(self, *, ttl=120, capacity=8, clock=time.monotonic): + if not 0 < ttl <= 120 or not 1 <= capacity <= 8: + raise ValueError("Choose a TTL up to 120 seconds and capacity up to 8.") + self.ttl, self.capacity, self.clock = ttl, capacity, clock + self.failures = 0 + self._codes = {} + self._lock = threading.Lock() + + @staticmethod + def display(code: str) -> str: + return code[:5] + "-" + code[5:] + + def issue(self, binding: Binding) -> str: + with self._lock: + now = self.clock() + self._codes = {key: record for key, record in self._codes.items() if record[0] > now} + if len(self._codes) >= self.capacity: + raise BrowserEntryUnavailable("Too many codes are waiting. Wait two minutes, then make a new code.") + code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LENGTH)) + self._codes[hashlib.sha256(code.encode("ascii")).digest()] = (now + self.ttl, binding) + return code + + def clear(self) -> None: + """Drop every pending code: a sign-out must not be followed by an old one.""" + with self._lock: + self._codes.clear() + + def consume(self, text: str, binding) -> bool: + """Accept case, spaces and hyphens only; never map a look-alike symbol. + + `binding` may be a function that returns the current binding. It runs + only for a live code, so a wrong code costs no private-file read. + """ + code = (text.upper().replace(" ", "").replace("-", "") + if isinstance(text, str) and text.isascii() else "") + key = hashlib.sha256(code.encode("ascii")).digest() if _CODE.fullmatch(code) else None + with self._lock: + record = self._codes.get(key) + if record is not None and record[0] <= self.clock(): + del self._codes[key] + record = None + try: + current = None if record is None else binding() if callable(binding) else binding + except BrowserEntryUnavailable: + current = None # The host identity cannot be confirmed now; the code stays. + if record is None or record[1] != current: + self.failures += 1 + return False + del self._codes[key] + return True + + def loopback_host(host: str | None) -> bool: try: return ipaddress.ip_address(host or "").is_loopback @@ -206,8 +275,35 @@ def local_origin(request) -> str | None: return None +def _remote_address(app, origin) -> bool: + """An exact configured HTTPS address of this host, not this computer itself.""" + if not isinstance(origin, str) or origin not in app["server_config"].get("public_origins", ()): + return False + parsed = urlsplit(origin) + return parsed.scheme == "https" and not loopback_host(parsed.hostname) + + +def remote_origin(request) -> str | None: + """Require HTTPS and the explicit exact Origin of one configured remote address. + + The peer address is deliberately not read. A Tailscale TCP forward connects + from 127.0.0.1, so the socket does not say where the browser is. The typed + one-use code is the proof; this check only fixes the address it is for. + """ + from .deployment import validate_origin + if not request.secure: + return None + try: + origin = validate_origin(request.headers.get("Origin", "")) + except ValueError: + return None + if origin != f"{request.scheme}://{request.host}" or not _remote_address(request.app, origin): + return None + return origin + + def initialize(app) -> None: - app["browser_entry"] = None + app["browser_entry"] = app["browser_codes"] = None if not is_windows(): return try: @@ -216,7 +312,7 @@ def initialize(app) -> None: return except BrowserEntryUnavailable: return - app["browser_entry"] = GrantStore() + app["browser_entry"], app["browser_codes"] = GrantStore(), CodeStore() def _binding(request, origin): @@ -232,7 +328,8 @@ def _binding(request, origin): return Binding(service.update_manager.running_identity["instanceId"], identity, origin, sid) -async def issue(request): +async def _owner_issue(request, name, extra, mint, again=REOPEN): + """The one owner-only local check that a ticket and a typed code share.""" from aiohttp import web authorization = request.headers.get("Authorization", "") if not (authorization.startswith("Bearer ") and @@ -241,9 +338,9 @@ async def issue(request): origin = local_origin(request) if not origin: return web.json_response({"error": "Use the exact HTTPS local owner origin."}, status=403) - store = request.app["browser_entry"] + store = request.app[name] if not is_windows() or store is None: - return web.json_response({"error": "Windows owner browser entry is not available. " + REOPEN}, status=503) + return web.json_response({"error": "Windows owner browser entry is not available. " + again}, status=503) try: if request.content_type != "application/json": return web.json_response({"error": "Use a JSON owner entry request."}, status=400) @@ -256,28 +353,42 @@ async def issue(request): package = _package_identity(identity) if package is None: raise BrowserEntryUnavailable("Cannot verify the running package identity for browser entry.") - if (not isinstance(body, dict) or set(body) - {"instanceId", "dataIdentity", "version", "revision", "ownerSid", "next"} + if (not isinstance(body, dict) or set(body) - {"instanceId", "dataIdentity", "version", "revision", "ownerSid", extra} or _package_identity(body) != package or any(body.get(key) != value for key, value in { "instanceId": binding.instance_id, "dataIdentity": binding.data_identity, "ownerSid": binding.owner_sid}.items())): - return web.json_response({"error": "The owner host identity changed. " + REOPEN}, status=409) - ticket = store.issue(binding, body.get("next")) + return web.json_response({"error": "The owner host identity changed. " + again}, status=409) + result = mint(store, binding, body.get(extra)) except BrowserEntryUnavailable as exc: return web.json_response({"error": str(exc)}, status=503) except (ValueError, TypeError): - return web.json_response({"error": "Invalid owner entry request. " + REOPEN}, status=400) - return web.json_response({"ticket": ticket, "expiresIn": store.ttl}, + return web.json_response({"error": "Invalid owner entry request. " + again}, status=400) + return web.json_response({**result, "expiresIn": store.ttl}, headers={"Cache-Control": "no-store", "Referrer-Policy": "no-referrer"}) -async def redeem(request): - from aiohttp import web - from .auth import CSRF_COOKIE, SESSION_COOKIE, _verified, new_session - origin = local_origin(request) - store = request.app["browser_entry"] - if not origin or not is_windows() or store is None or request.content_type != "application/x-www-form-urlencoded": - return web.Response(text=REOPEN, status=403) +async def issue(request): + return await _owner_issue(request, "browser_entry", "next", + lambda store, binding, target: {"ticket": store.issue(binding, target)}) + + +async def code_issue(request): + """Owner only and this computer only, exactly as for a ticket. + + The code is bound to the one address the owner named: an exact configured + HTTPS address of this host that is not this computer itself. + """ + def mint(store, binding, origin): + if not _remote_address(request.app, origin): + raise ValueError("Not a configured remote address") + return {"code": store.issue(replace(binding, origin=origin))} + return await _owner_issue(request, "browser_codes", "origin", mint, NEW_CODE) + + +async def _signed_form(request, field): + """Return the one named form value, or None unless the signed CSRF pair matches.""" + from .auth import CSRF_COOKIE, _verified form = await request.clone(client_max_size=4096).post() csrf = request.cookies.get(CSRF_COOKIE) try: @@ -285,11 +396,24 @@ async def redeem(request): and _verified(request.app["session_secret"], csrf, 900, "csrf")) except (TypeError, ValueError): valid_csrf = False - if (not valid_csrf or set(form) != {"csrf", "ticket"} + if (not valid_csrf or set(form) != {"csrf", field} or any(len(form.getall(key)) != 1 for key in form)): + return None + return form.get(field) + + +async def redeem(request): + from aiohttp import web + from .auth import CSRF_COOKIE, SESSION_COOKIE, new_session + origin = local_origin(request) + store = request.app["browser_entry"] + if not origin or not is_windows() or store is None or request.content_type != "application/x-www-form-urlencoded": + return web.Response(text=REOPEN, status=403) + ticket = await _signed_form(request, "ticket") + if ticket is None: return web.Response(text=REOPEN, status=403) try: - target = store.consume(form.get("ticket"), _binding(request, origin)) + target = store.consume(ticket, _binding(request, origin)) except BrowserEntryUnavailable: target = None if target is None: @@ -302,12 +426,109 @@ async def redeem(request): raise response +_PRIVATE = {"Cache-Control": "no-store", "Referrer-Policy": "no-referrer"} + + +async def code_redeem(request): + """Public: the typed code is the proof. No lockout and no delay (see CodeStore). + + Only the address the browser used is checked here, never the peer address + (see remote_origin). A session from a code is marked remote. + """ + from aiohttp import web + from .auth import CSRF_COOKIE, SESSION_COOKIE, new_session + origin = remote_origin(request) + store = request.app["browser_codes"] + if not origin or not is_windows() or store is None or request.content_type != "application/x-www-form-urlencoded": + return web.Response(text=RELOAD, status=403) + code = await _signed_form(request, "code") + if code is None: + return web.Response(text=RELOAD, status=403) + # The current host identity is read only for a live code: any device that + # reaches this address can send wrong codes, and each must stay cheap. + if not store.consume(code, lambda: _binding(request, origin)): + if store.failures == 1 or store.failures % 100 == 0: + try: # The count only; never the typed text. A log fault changes nothing. + print(f"Remote sign-in: {store.failures} wrong codes since start.", flush=True) + except (OSError, ValueError): + pass + raise web.HTTPSeeOther("/login?error=1", headers=_PRIVATE) + response = web.HTTPSeeOther("/", headers=_PRIVATE) + response.set_cookie(SESSION_COOKIE, new_session(request.app["session_secret"], remote=True), + max_age=request.app["server_config"]["session_ttl_seconds"], + httponly=True, samesite="Strict", secure=True, path="/") + response.del_cookie(CSRF_COOKIE, path="/") + raise response + + +_CODE_PAGE = f""" + + + +Sign in to Amplifier + +
+

Sign in to Amplifier

+ +

1. On the Windows PC that runs Amplifier, run: amplifier-unified browsers code

+

2. Type the code here within two minutes.

+
+ + + + +
+
+""" + + +def remote_login_page(request): + """Code form for a browser on another device: no script, nothing reflected. + + Referrer-Policy is same-origin, not no-referrer: from a no-referrer page a + browser sends "Origin: null" with the form, and the redeem check refuses it. + """ + from aiohttp import web + from .auth import CSRF_COOKIE, _verified, new_csrf + secret = request.app["session_secret"] + csrf = request.cookies.get(CSRF_COOKIE) + # Preserve valid issuance for favicon redirects and concurrent login forms. + if not _verified(secret, csrf, 900, "csrf"): + csrf = new_csrf(secret) + error = ("That code did not work. A code lasts two minutes and works once. " + "Make a new code on the PC and try again.") if request.query.get("error") else "" + response = web.Response(text=_CODE_PAGE.replace("__ERROR__", error).replace("__CSRF__", csrf), + content_type="text/html", + headers={"Cache-Control": "no-store", "Referrer-Policy": "same-origin"}) + response.set_cookie(CSRF_COOKIE, csrf, httponly=True, samesite="Strict", secure=request.secure, path="/") + return response + + async def owner_browser_url(data_dir: Path, config: dict) -> str: """Pinned owner request; no redirect/proxy/HTTP fallback or uncertain reissue. The returned URL contains a single-use grant, never the control bearer. Callers must not print it or include browser backend exceptions in output. """ + origin, ticket = await _owner_post(data_dir, config, ISSUE_PATH, {}, "ticket", r"[A-Za-z0-9_-]{43}") + return origin + "/login#ticket=" + ticket + + +async def owner_browser_code(data_dir: Path, config: dict, origin: str) -> str: + """The same pinned owner request, for a code to type at one remote address. + + Only the caller shows the code, and only to the owner; never log it. + """ + _, code = await _owner_post(data_dir, config, CODE_PATH, {"origin": origin}, "code", _CODE.pattern, NEW_CODE) + return code + + +async def _owner_post(data_dir: Path, config: dict, path: str, extra: dict, field: str, pattern: str, again=REOPEN): + """Pin the privately read leaf before reading or sending the control bearer. + + Returns the local origin and the one named reply value. No redirect, no + proxy, no HTTP fallback, and an unconfirmed POST is never sent again. + """ import aiohttp from .auth import data_identity from .update_readiness import running_identity @@ -342,17 +563,18 @@ async def owner_browser_url(data_dir: Path, config: dict) -> str: except (aiohttp.ClientError, OSError, TimeoutError, ValueError): continue try: - async with client.post(origin + ISSUE_PATH, ssl=pin, allow_redirects=False, + async with client.post(origin + path, ssl=pin, allow_redirects=False, headers={**headers, "Origin": origin}, json={ "instanceId": health["instanceId"], "dataIdentity": health["dataIdentity"], - "version": version, "revision": revision, "ownerSid": sid}) as response: + "version": version, "revision": revision, "ownerSid": sid, + **extra}) as response: if response.status != 200: - raise BrowserEntryUnavailable("The owner host refused browser entry. " + REOPEN) + raise BrowserEntryUnavailable("The owner host refused browser entry. " + again) result = await response.json() - ticket = result.get("ticket") if isinstance(result, dict) else None - if not isinstance(ticket, str) or not re.fullmatch(r"[A-Za-z0-9_-]{43}", ticket): - raise ValueError("Invalid ticket") + value = result.get(field) if isinstance(result, dict) else None + if not isinstance(value, str) or not re.fullmatch(pattern, value): + raise ValueError("Invalid reply") except (aiohttp.ClientError, OSError, TimeoutError, ValueError): - raise BrowserEntryUnavailable("Browser entry response was not confirmed; no retry was made. " + REOPEN) from None - return origin + "/login#ticket=" + ticket - raise BrowserEntryUnavailable("The owned local host is not reachable or its identity does not match. " + REOPEN) + raise BrowserEntryUnavailable("Browser entry response was not confirmed; no retry was made. " + again) from None + return origin, value + raise BrowserEntryUnavailable("The owned local host is not reachable or its identity does not match. " + again) diff --git a/amplifier_web/cli.py b/amplifier_web/cli.py index 6197826ae..02a99bd40 100644 --- a/amplifier_web/cli.py +++ b/amplifier_web/cli.py @@ -98,8 +98,10 @@ def _parse() -> argparse.Namespace: runtime.add_argument("runtime_command", choices=["prepare", "status"]) runtime.add_argument("--json", action="store_true", help=argparse.SUPPRESS) browsers = subcommands.add_parser("browsers", help="Manage signed-in browsers") - browsers.add_argument("browsers_command", choices=["sign-out"], - help="sign-out: sign out every browser on every device") + browsers.add_argument("browsers_command", choices=["sign-out", "code"], + help="sign-out: sign out every browser on every device; " + "code: make a one-use code that signs in a browser on another device (Windows host)") + browsers.add_argument("--origin", metavar="URL", help="code: the address that the other device opens") setup_tls = subcommands.add_parser("setup-tls", help="Create or inspect the app-owned local CA") setup_tls.add_argument("mode", nargs="?", choices=["status", "default", "force", "export"], default="default") reset = subcommands.add_parser("reset", help="Reinstall a clean app and runtime while preserving user data") @@ -356,6 +358,48 @@ def _open_browser(args, data_dir, config): raise browser_entry.BrowserEntryUnavailable("The browser did not accept the open request. " + browser_entry.REOPEN) +def _code_origin(config, given): + """The address a code is for: the given one, or the only one for other devices.""" + from urllib.parse import urlsplit + from .deployment import validate_origin + remote = [origin for origin in config["public_origins"] if origin.startswith("https://") + and not browser_entry.loopback_host(urlsplit(origin).hostname)] + if not remote: + raise SystemExit("Amplifier has no address for other devices yet; add one with " + "amplifier-unified config set public_origins (the deployment guide has the steps).") + if given is None and len(remote) == 1: + return remote[0] + try: + given = validate_origin(given.rstrip("/")) if given is not None else None + except ValueError: + given = "" + if given in remote: + return given + raise SystemExit(("That is not one of Amplifier's addresses for other devices." if given is not None + else "Amplifier has more than one address for other devices.") + + " Run the command again with --origin and one of these:\n " + "\n ".join(remote)) + + +def _browser_code(args, data_dir): + """Show the owner one code. The pinned helper alone reads the control credential.""" + if not browser_entry.is_windows(): + raise SystemExit("On this computer, sign in with the host account password on the sign-in page.") + from .deployment import load_server_config + try: + config = load_server_config(data_dir, overrides=_server_overrides(args)) + origin = _code_origin(config, args.origin) + code = asyncio.run(browser_entry.owner_browser_code(data_dir, config, origin)) + except browser_entry.BrowserEntryUnavailable as exc: + raise SystemExit(str(exc)) from None + except (OSError, ValueError): + raise SystemExit("Could not make a code. " + browser_entry.NEW_CODE) from None + print(f"Open {origin} on the other device and type this code within two minutes:") + print() + print(" " + browser_entry.CodeStore.display(code)) + print() + print("The code works once.") + + def _open_browser_safely(args, data_dir, config): try: _open_browser(args, data_dir, config) @@ -507,6 +551,8 @@ def main(): _config(args, data_dir) return if args.command == "browsers": + if args.browsers_command == "code": + return _browser_code(args, data_dir) from .auth import sign_out_all try: sign_out_all(data_dir) diff --git a/amplifier_web/server.py b/amplifier_web/server.py index c9621d6e7..2adba828b 100644 --- a/amplifier_web/server.py +++ b/amplifier_web/server.py @@ -108,9 +108,8 @@ async def create_app(data_dir, workspace=None, runtime=None, voice=True, backgro for host in ("localhost", "127.0.0.1", "[::1]") ) app["session_secret"] = session_secret(data_dir) - from .auth import _epoch_hint, session_epoch - app["session_epoch_hint"] = _epoch_hint(data_dir) - app["session_epoch"] = session_epoch(data_dir) + from .auth import Sessions + app["sessions"] = Sessions.load(data_dir) app["control_token"] = control_token(data_dir) service = AppService(data_dir, runtime=runtime, workspace=workspace) service.port = config["port"] @@ -712,6 +711,8 @@ async def shared_conversation(request): app.router.add_post("/login", post_login) app.router.add_post(browser_entry.ISSUE_PATH, browser_entry.issue) app.router.add_post(browser_entry.REDEEM_PATH, browser_entry.redeem) + app.router.add_post(browser_entry.CODE_PATH, browser_entry.code_issue) + app.router.add_post(browser_entry.CODE_REDEEM_PATH, browser_entry.code_redeem) app.router.add_get("/setup", setup) app.router.add_get("/api/ca", certificate) app.router.add_get("/ca.crt", certificate) diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index cbd8222d8..5c29c8205 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -233,6 +233,94 @@ References: [Ubuntu trust-store guide](https://ubuntu.com/server/docs/how-to/sec and [Edge root-store policy history](https://learn.microsoft.com/en-us/deployedge/microsoft-edge-policies/microsoftrootstoreenabled). The obsolete Edge root-store policy is not a workaround. +## Windows host: reach Unified from another device + +A Windows host has no password sign-in. On the PC, the launcher opens the +browser. A browser on another device signs in with a one-use code that you make +at the PC. These steps use Tailscale. Unified stays bound to `127.0.0.1`. + +Replace `..ts.net` with the Tailscale name of the PC, and `` +with the port that Unified uses (`amplifier-unified config get port`). Pass the +same `--data-dir` value to every command if Unified uses a non-default data +directory. + +On a PC that was set up with the Windows setup file, `amplifier-unified` is not +on the PATH. Use the full program path and the data folder of that installation +in every command below: + +``` +"%LOCALAPPDATA%\Amplifier\tools\amplifier-unified\Scripts\amplifier-unified.exe" --data-dir "%LOCALAPPDATA%\Amplifier\app" +``` + +Stop and start Amplifier there with **Stop Amplifier** and **Amplifier Unified** +in the Start menu. + +1. Add the Tailscale address **after** the local address. The first address in + the list is the main one: sign-in callbacks for connected services use it. + + ``` + amplifier-unified config set public_origins "[https://127.0.0.1:, https://..ts.net:]" + ``` + +2. Stop Amplifier. +3. Make a certificate that includes the new address. + + ``` + amplifier-unified setup-tls force + ``` + +4. Start Amplifier and open it on the PC. Windows asks one question about the + new certificate. Select **Yes**. +5. Forward the Tailscale address to Unified. + + ``` + tailscale serve --bg --tcp= tcp://127.0.0.1: + ``` + +6. Export the certificate. Move the file to the other device and trust it there + (see [Trust the CA on each client](#trust-the-ca-on-each-client)). + + ``` + cmd /c "amplifier-unified setup-tls export > amplifier-unified-ca.crt" + ``` + + `cmd /c` keeps the file as plain text. Windows PowerShell 5.1 saves + redirected output as UTF-16, and another device may not accept that file. + +7. On the PC, make a code. + + ``` + amplifier-unified browsers code + ``` + +8. On the other device, open `https://..ts.net:` and type the + code within two minutes. The code works once. + +If more than one address for other devices is configured, name the one that the +other device opens: `amplifier-unified browsers code --origin https://
`. +`amplifier-unified browsers sign-out` signs out every browser, on the PC and on +other devices, and drops every code that is not used yet. +`tailscale serve --tcp= off` turns the forward off. + +Measured on Windows 11 with Tailscale 1.104.5 from an ordinary account: the +forward in step 5 needs no administrator and no firewall rule, and the other +device receives Unified's own certificate. Not measured: these steps from start +to end on a PC that was set up with the Windows setup file, a real phone, and a +Windows restart with the forward in place. + +Limits, stated plainly: + +- Never use Tailscale Funnel. It puts the address on the public internet. +- `tailscale cert` is not used. It publishes the name of the PC in a public + certificate log for good. +- A LAN address without Tailscale needs a Windows Firewall rule. An + administrator must add that rule; Unified does not add it. +- Each code is for one address, lasts two minutes and works once. Wrong codes + do not lock anyone out; see the + [design](design/native-windows-installation.md#remote-browser-sign-in-on-a-windows-host). +- Linux, macOS and WSL hosts do not use codes. Sign in there with the host + account password on the sign-in page. + ## Sign out all browsers To end every browser session on every device at once, for example after a @@ -247,7 +335,7 @@ The command saves a time in the owner's private configuration (`config/auth/session-epoch`). It needs no running host, no password and no network request: the ability to write that private folder is the authority. A running host applies the change within a few seconds. It then refuses every -session from before that time, drops pending sign-in tickets, and ends open +session from before that time, drops pending sign-in tickets and codes, and ends open live-update connections so that a signed-out browser cannot keep watching. Sign in again in the usual way for your platform. diff --git a/docs/design/native-windows-installation.md b/docs/design/native-windows-installation.md index fb297d8c3..b87be55b5 100644 --- a/docs/design/native-windows-installation.md +++ b/docs/design/native-windows-installation.md @@ -1792,10 +1792,60 @@ The owner can end every browser session with `amplifier-unified browsers sign-ou (see the deployment guide). All browser session checks go through one function, `auth.valid_session`, so that no check can ignore that sign-out. -Remote browsers need an explicit owner-approved browser-pairing design over -trusted configured HTTPS. Do not silently reuse terminal/companion grants with -broader authority. Until that path is qualified, report its limitation rather -than offering the unusable Windows password form. +#### Remote browser sign-in on a Windows host + +A browser on another device cannot use the local launcher, and a Windows host +has no password form. The owner starts that sign-in at the PC: +`amplifier-unified browsers code` prints a code, and the owner types it on the +sign-in page that the other device shows. Terminal and companion grants are not +reused for this; they carry broader authority. + +- **The code.** Ten symbols from 32 (no `0`, `1`, `I` or `O`): 50 bits. It + lasts 120 seconds and works once. The host keeps only its SHA-256, and at + most eight codes at a time. Input is upper-cased, and spaces and hyphens are + removed. No other character is changed, so a look-alike is a wrong code. +- **Who can make one.** The command uses the same request as `open`. It pins + the privately read leaf certificate before it reads or sends the control + credential, and the host accepts that request from this computer only. The + code is bound to the host instance, the data folder, the owner SID and one + address: an exact HTTPS entry of `public_origins` that is not this computer. +- **Where it works.** The redeem endpoint has its own narrow check: HTTPS, an + explicit `Origin` equal to the address the browser used, and that address an + exact configured remote one. It does **not** look at the peer address. A + Tailscale TCP forward connects to the host from `127.0.0.1` (measured), so + the socket does not say where the browser is. The local ticket keeps its + check that both socket ends are local. With the forward on, that check no + longer tells a tailnet device from this computer: a client there can also + send the local address as `Host` and `Origin`. It gains nothing by that. The + owner endpoints still need the control credential from the owner-only folder, + and the local redeem still needs a ticket that only the launcher can get. + Loopback was never authority; another Windows account on the PC is in the + same position. +- **Wrong codes.** No lockout and no delay. A lockout would let any device + that reaches the address keep the owner out. A delay taken in turn would + make the owner wait behind a flood of wrong codes. The numbers carry the + guess resistance: at 10,000 guesses each second against eight pending codes, + the chance of a hit in one code lifetime is about 1 in 100 million. A wrong + code never removes a real one and never reaches the owner's command, which + uses another endpoint. The host counts wrong codes and logs only the count. +- **The page.** Server-rendered, with no script, and it shows nothing from the + request. It is sent with `Referrer-Policy: same-origin`, not `no-referrer`: + from a `no-referrer` page a browser posts the form with `Origin: null`, and + the redeem check refuses that. The redeem reply is `no-store`, `no-referrer`. +- **The session.** The signed cookie from a code is marked remote + (`"via": "remote"`), so that a rule for remote sessions only is possible + later. Local cookies do not change. The lifetime is the configured one. +- **The control.** `amplifier-unified browsers sign-out` signs out every + browser and drops every pending ticket and code. + +Limits: there is no sign-out for one device. A person on the same network who +gets the owner to type the code into a look-alike page within 120 seconds can +use it; the command prints the exact address to open. One host process holds +the pending codes in memory, so a restart drops them and the owner makes a new +code. Not measured yet: a phone browser, and the whole journey on a real second +device. Linux, macOS and WSL hosts keep the password form and refuse both code +endpoints. The steps for the owner are in the +[deployment guide](../DEPLOYMENT.md#windows-host-reach-unified-from-another-device). ### Background lifetime diff --git a/tests/test_browser_code.py b/tests/test_browser_code.py new file mode 100644 index 000000000..7ab990c4e --- /dev/null +++ b/tests/test_browser_code.py @@ -0,0 +1,835 @@ +"""Sign-in by typed code for a browser on another device (Windows host). + +No listener: real aiohttp requests through the real authentication middleware +and the real handlers. Synchronous on purpose: the focused Windows job installs +no async pytest plugin, and it must not need the full server module. +The private store is a small model here, not native storage qualification. +""" +import asyncio +import base64 +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager +from dataclasses import replace +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import ssl +import sys +from types import SimpleNamespace +from unittest.mock import Mock +from urllib.parse import urlencode +import uuid + +import aiohttp +from aiohttp import web +from aiohttp.test_utils import make_mocked_request +import pytest + +from amplifier_web import __version__, auth, browser_entry as entry, cli + +SID = "S-1-5-21-100-200-300-1001" +REVISION = "a" * 40 +TOKEN, SECRET = "c" * 64, "s" * 64 +LOCAL = "https://127.0.0.1:8941" +REMOTE = "https://pc.tail1234.ts.net:8941" # The Tailscale name of the PC. +OTHER = "https://192.168.1.20:8941" # A second configured address. +PLAIN = "http://plain.example:8941" # Configured, but not HTTPS. +UNLISTED = "https://rebind.example:8941" # Passes the middleware; not a configured address. +BEARER = {"Authorization": "Bearer " + TOKEN} +FORM = "application/x-www-form-urlencoded" + + +@pytest.fixture +def home(tmp_path_factory): + # Native Windows refuses a runner temp folder with a broad inherited ACL; + # use a private profile child there instead of repairing or skipping. + if os.name != "nt": + yield tmp_path_factory.mktemp("home") + return + from amplifier_web.windows_files import address, directory + root = address(Path.home() / (".unified-code-test-" + uuid.uuid4().hex)) + with directory(root, owned_root=root, create=True): + pass + try: + yield root + finally: + shutil.rmtree(root) + + +class PrivateFiles: + """The two existing owner credentials behind the private-directory seam.""" + + def __init__(self): + self.events = [] + + @contextmanager + def directory(self, path, *, owned_root, create=False): + assert Path(path).is_relative_to(owned_root) + files = self + + class Pinned: + def read_private(self, name, *, limit): + files.events.append(name) + if Path(path).parts[-2:] != ("config", "auth") or name not in {"session-secret", "control-token"}: + raise FileNotFoundError(name) + return (SECRET if name == "session-secret" else TOKEN).encode() + yield Pinned() + + +@pytest.fixture +def files(monkeypatch): + from amplifier_web import windows_files + model = PrivateFiles() + monkeypatch.setattr(entry, "is_windows", lambda: True) + monkeypatch.setattr(entry, "current_owner_sid", lambda: SID) + monkeypatch.setattr(windows_files, "directory", model.directory) + return model + + +@pytest.fixture +def host(home, files): + app = web.Application() + app["control_token"], app["session_secret"] = TOKEN, SECRET + app["server_config"] = {"session_ttl_seconds": 600, + "public_origins": [REMOTE, OTHER, PLAIN, LOCAL, "https://localhost:8941"]} + app["allowed_origins"] = frozenset({LOCAL, LOCAL.replace("https:", "http:"), REMOTE, + REMOTE.replace("https:", "http:"), OTHER, PLAIN, UNLISTED}) + app["service"] = SimpleNamespace(data_dir=home, update_manager=SimpleNamespace( + running_identity={"instanceId": "b" * 32, "version": __version__, "revision": REVISION})) + entry.initialize(app) + assert isinstance(app["browser_entry"], entry.GrantStore) and isinstance(app["browser_codes"], entry.CodeStore) + app.router.add_post(entry.CODE_PATH, entry.code_issue) + app.router.add_post(entry.CODE_REDEEM_PATH, entry.code_redeem) + app.router.add_get("/login", auth.login_page) + + async def protected(request): + return web.json_response({"authenticated": True}) + app.router.add_get("/api/protected", protected) + return app + + +async def build(app, path, *, method="POST", at=LOCAL, origin=..., headers=None, body=None, + secure=True, peer="127.0.0.1"): + """One real aiohttp request to the address `at`; no socket.""" + headers = {"Host": at.split("://", 1)[1], "Origin": at if origin is ... else origin, **(headers or {})} + headers = {key: value for key, value in headers.items() if value is not None} + if isinstance(body, dict): + raw = json.dumps(body).encode() + headers.setdefault("Content-Type", "application/json") + else: + raw = (body or "").encode() + headers["Content-Length"] = str(len(raw)) + content = aiohttp.StreamReader(Mock(_reading_paused=False), 8192) + content.feed_data(raw) + content.feed_eof() + + def extra(name): + if name in {"peername", "sockname"}: + return (peer if name == "peername" else "127.0.0.1", 8941) + return make_mocked_request(method, path, headers=headers, app=app, payload=content, + transport=SimpleNamespace(get_extra_info=extra), + sslcontext=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) if secure else None) + + +async def send(app, path, **options): + """That request through the real authentication middleware to the real handler.""" + request = await build(app, path, **options) + match = await app.router.resolve(request) + match.add_app(app) + request._match_info = match + try: + return await auth.auth_required(request, match.handler) + except web.HTTPException as response: + return response + + +def call(*args, **options): + return asyncio.run(send(*args, **options)) + + +def issue_body(app, **changes): + return {"instanceId": "b" * 32, "version": __version__, "revision": REVISION, + "dataIdentity": auth.data_identity(app["service"].data_dir), "ownerSid": SID, + "origin": REMOTE, **changes} + + +def make_code(app, origin=REMOTE): + response = call(app, entry.CODE_PATH, headers=BEARER, body=issue_body(app, origin=origin)) + assert response.status == 200 + return json.loads(response.body)["code"] + + +async def typed(app, code, *, at=REMOTE, csrf=None, headers=None, body=None, **options): + """Post the code form at the remote address, as the page on the other device does.""" + csrf = csrf or auth.new_csrf(app["session_secret"]) + return await send(app, entry.CODE_REDEEM_PATH, at=at, headers={ + "Content-Type": FORM, "Cookie": f"{auth.CSRF_COOKIE}={csrf}", **(headers or {})}, + body=body or urlencode({"csrf": csrf, "code": code}), **options) + + +def redeem(*args, **options): + return asyncio.run(typed(*args, **options)) + + +def signed_in(response): + return (response.status == 303 and response.headers["Location"] == "/" + and auth.SESSION_COOKIE in response.cookies) + + +def wrong(response): + return (response.status == 303 and response.headers["Location"] == "/login?error=1" + and auth.SESSION_COOKIE not in response.cookies) + + +def session_payload(value): + encoded = value.rsplit(".", 1)[0] + return json.loads(base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4))) + + +def binding(): + return entry.Binding("b" * 32, "d" * 64, REMOTE, SID) + + +# The store. + +def test_store_is_hash_only_bounded_one_use_and_bound_to_its_address(): + now = [0] + store = entry.CodeStore(clock=lambda: now[0]) + assert (store.ttl, store.capacity) == (120, 8) + assert len(set(entry.CODE_ALPHABET)) == 32 and not set("01IO") & set(entry.CODE_ALPHABET) + assert entry.CodeStore.display("ABCDEFGHJK") == "ABCDE-FGHJK" + code = store.issue(binding()) + assert re.fullmatch(f"[{entry.CODE_ALPHABET}]{{10}}", code) + # Only the SHA-256 is kept; the code text is nowhere in the store. + assert list(store._codes) == [hashlib.sha256(code.encode()).digest()] + assert code not in repr(vars(store)) + # Another address, host, data folder or owner is refused and removes nothing. + for key in ("instance_id", "data_identity", "owner_sid", "origin"): + assert store.consume(code, replace(binding(), **{key: "wrong"})) is False + assert store.consume("2" * 10, binding()) is False and len(store._codes) == 1 + with ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(lambda _: store.consume(code, binding()), range(32))) + assert results.count(True) == 1 and results.count(False) == 31 + assert store.failures == 5 + 31 + codes = [store.issue(binding()) for _ in range(8)] + with pytest.raises(entry.BrowserEntryUnavailable, match="Too many codes"): + store.issue(binding()) + now[0] = 119 + assert store.consume(codes[0], binding()) is True + now[0] = 120 + assert store.consume(codes[1], binding()) is False # Expired at 120 seconds. + assert len(store._codes) == 6 # Only that expired code was removed. + store.issue(binding()) # The expired ones no longer fill the store. + store.clear() + assert not store._codes + + +@pytest.mark.parametrize("options", [{"ttl": 121}, {"ttl": 0}, {"capacity": 9}, {"capacity": 0}]) +def test_store_bounds_cannot_weaken_the_numbers(options): + with pytest.raises(ValueError): + entry.CodeStore(**options) + + +def test_typed_code_accepts_case_spaces_and_a_hyphen_but_never_a_look_alike(): + store = entry.CodeStore() + for write in (str.lower, entry.CodeStore.display, lambda text: " " + " ".join(text) + " ", + lambda text: entry.CodeStore.display(text).lower()): + code = store.issue(binding()) + assert store.consume(write(code), binding()) is True + code = store.issue(binding()) + for look_alike in "01IOio": + assert store.consume(look_alike + code[1:], binding()) is False + for malformed in (None, "", code[:9], code + "2", code + "\n", "\t" + code, "_" + code[1:], [code], code.encode()): + assert store.consume(malformed, binding()) is False + assert store.consume(code, binding()) is True # No wrong text removed the real code. + # A letter outside ASCII that upper-cases to a code symbol is not that symbol. + store._codes[hashlib.sha256(b"S" * 10).digest()] = (store.clock() + 120, binding()) + assert "\u017f".upper() == "S" and store.consume("\u017f" * 10, binding()) is False + assert store.consume("s" * 10, binding()) is True + + +# The owner's request for a code: this computer only, control credential only. + +@pytest.mark.parametrize("headers", [ + {}, {"Authorization": "Bearer wrong"}, {"Authorization": "Bearer amt_" + "x" * 64}, + {"Authorization": "Bearer amc_" + "x" * 64}, {"Authorization": "Bearer amd_" + "x" * 64}, + {"Cookie": f"{auth.SESSION_COOKIE}=" + auth.new_session(SECRET)}, + {"Cookie": f"{auth.SESSION_COOKIE}=" + auth.new_session(SECRET, remote=True)}]) +def test_issue_needs_the_control_bearer_not_a_session_or_a_device_token(host, headers): + assert call(host, entry.CODE_PATH, headers=headers, body=issue_body(host)).status == 403 + assert not host["browser_codes"]._codes + + +@pytest.mark.parametrize("options", [ + {"peer": "192.0.2.20"}, # Not from this computer. + {"at": REMOTE}, # Not the local address. + {"origin": None}, {"origin": REMOTE}, {"origin": "null"}, + {"secure": False, "origin": LOCAL.replace("https:", "http:")}, + {"method": "GET"}]) +def test_issue_needs_a_loopback_peer_and_the_exact_local_https_origin(host, options): + assert call(host, entry.CODE_PATH, headers=BEARER, body=issue_body(host), **options).status == 403 + assert not host["browser_codes"]._codes + + +@pytest.mark.parametrize("origin", [ + "https://unknown.example:8941", UNLISTED, # Not configured. + PLAIN, # Configured, not HTTPS. + LOCAL, "https://localhost:8941", # Configured, but this computer itself. + REMOTE + "/", REMOTE.upper(), None, 5, [REMOTE], "absent"]) +def test_issue_is_only_for_an_exact_configured_https_address_of_another_device(host, origin): + body = issue_body(host, origin=origin) + if origin == "absent": + del body["origin"] + assert call(host, entry.CODE_PATH, headers=BEARER, body=body).status == 400 + assert not host["browser_codes"]._codes + + +@pytest.mark.parametrize("change", [{key: "wrong"} for key in ( + "instanceId", "version", "revision", "dataIdentity", "ownerSid")] + [{"next": "/"}]) +def test_issue_refuses_another_host_identity(host, change): + assert call(host, entry.CODE_PATH, headers=BEARER, body=issue_body(host, **change)).status == 409 + assert not host["browser_codes"]._codes + + +def test_issue_reply_capacity_and_unavailable_host(host, monkeypatch): + response = call(host, entry.CODE_PATH, headers=BEARER, body=issue_body(host)) + reply = json.loads(response.body) + assert response.status == 200 and set(reply) == {"code", "expiresIn"} and reply["expiresIn"] == 120 + assert re.fullmatch(f"[{entry.CODE_ALPHABET}]{{10}}", reply["code"]) + assert response.headers["Cache-Control"] == "no-store" and response.headers["Referrer-Policy"] == "no-referrer" + assert call(host, entry.CODE_PATH, headers={**BEARER, "Content-Type": "text/plain"}, + body=json.dumps(issue_body(host))).status == 400 + for _ in range(7): + make_code(host, OTHER) + assert call(host, entry.CODE_PATH, headers=BEARER, body=issue_body(host)).status == 503 + assert len(host["browser_codes"]._codes) == 8 + # A full store never blocks the form: the first code still signs in. + assert signed_in(redeem(host, reply["code"])) + read = entry.read_owner_control_token + monkeypatch.setattr(entry, "read_owner_control_token", lambda *a: "d" * 64) # The private credential changed. + assert call(host, entry.CODE_PATH, headers=BEARER, body=issue_body(host)).status == 503 + monkeypatch.setattr(entry, "read_owner_control_token", read) + host["browser_codes"] = None + assert call(host, entry.CODE_PATH, headers=BEARER, body=issue_body(host)).status == 503 + + +def test_stores_exist_together_or_not_at_all(host, monkeypatch): + host["control_token"] = "d" * 64 # Not the private owner credential. + entry.initialize(host) + assert host["browser_entry"] is None and host["browser_codes"] is None + monkeypatch.setattr(entry, "is_windows", lambda: False) + other = web.Application() + entry.initialize(other) + assert other["browser_entry"] is None and other["browser_codes"] is None + + +# The typed code at the remote address. + +@pytest.mark.parametrize("peer", ["127.0.0.1", "100.101.102.103"]) +def test_code_signs_in_at_the_remote_address_whatever_the_peer_address_is(host, peer): + """A Tailscale TCP forward connects from 127.0.0.1; a LAN browser does not.""" + assert call(host, "/api/protected", method="GET", at=REMOTE, origin=None).status == 401 + response = redeem(host, make_code(host), peer=peer) + assert response.status == 303 and response.headers["Location"] == "/" + assert response.headers["Cache-Control"] == "no-store" and response.headers["Referrer-Policy"] == "no-referrer" + cookie = response.cookies[auth.SESSION_COOKIE] + assert cookie["secure"] and cookie["httponly"] and cookie["samesite"] == "Strict" and cookie["path"] == "/" + assert int(cookie["max-age"]) == 600 + deleted = response.cookies[auth.CSRF_COOKIE] + assert deleted.value == "" and int(deleted["max-age"]) == 0 + assert auth.valid_session(host, cookie.value) + assert session_payload(cookie.value)["via"] == "remote" + assert call(host, "/api/protected", method="GET", at=REMOTE, origin=None, + headers={"Cookie": f"{auth.SESSION_COOKIE}={cookie.value}"}).status == 200 + # A local session is exactly what it was: no mark. + assert list(session_payload(auth.new_session(SECRET))) == ["kind", "issued", "nonce"] + + +def test_address_check_stands_alone_and_does_not_depend_on_the_peer_address(host): + """The handler's own check, without the middleware in front of it.""" + async def origin(**options): + request = await build(host, entry.CODE_REDEEM_PATH, **{"at": REMOTE, **options}) + request.transport.get_extra_info = lambda name: pytest.fail("The peer address was read") + return entry.remote_origin(request) + + async def check(): + assert await origin() == REMOTE and await origin(at=OTHER) == OTHER + assert await origin(peer="203.0.113.9") == REMOTE + for refused in ({"origin": OTHER}, {"origin": None}, {"origin": "null"}, {"origin": REMOTE + "/"}, + {"at": LOCAL}, {"at": "https://localhost:8941"}, {"at": UNLISTED}, + {"at": PLAIN, "secure": False}, {"secure": False}, + {"secure": False, "origin": REMOTE.replace("https:", "http:")}): + assert await origin(**refused) is None, refused + asyncio.run(check()) + + +def fields(app, **values): + """A form body and the CSRF cookie that belongs to it.""" + csrf = auth.new_csrf(app["session_secret"]) + return {"csrf": csrf, "body": urlencode({"csrf": csrf, **values})} + + +REFUSED = { + "loopback Host": lambda app, code: {"at": LOCAL}, + "localhost Host": lambda app, code: {"at": "https://localhost:8941"}, + "Host that is not a configured address": lambda app, code: {"at": UNLISTED}, + "configured address that is not HTTPS": lambda app, code: {"at": PLAIN, "secure": False}, + "no Origin": lambda app, code: {"origin": None}, + "another Origin": lambda app, code: {"origin": OTHER}, + "opaque Origin": lambda app, code: {"origin": "null"}, + "plain HTTP": lambda app, code: {"secure": False, "origin": REMOTE.replace("https:", "http:")}, + "plain HTTP without Origin": lambda app, code: {"secure": False, "origin": None}, + "no CSRF cookie": lambda app, code: {"headers": {"Cookie": None}}, + "unsigned CSRF": lambda app, code: {"csrf": "wrong"}, + "expired CSRF": lambda app, code: {"csrf": auth._signed(SECRET, {"kind": "csrf", "issued": 1})}, + "a session used as CSRF": lambda app, code: {"csrf": auth.new_session(SECRET)}, + "form CSRF is not the cookie's": lambda app, code: {"body": fields(app, code=code)["body"]}, + "no code field": lambda app, code: fields(app), + "another field in place of the code": lambda app, code: fields(app, ticket=code), + "extra field": lambda app, code: fields(app, code=code, next="/"), + "duplicate field": lambda app, code: { + key: value + ("&code=" + code if key == "body" else "") for key, value in fields(app, code=code).items()}, + "JSON content type": lambda app, code: {"headers": {"Content-Type": "application/json"}}, + "multipart content type": lambda app, code: {"headers": {"Content-Type": "multipart/form-data; boundary=x"}}, +} + + +@pytest.mark.parametrize("options", REFUSED.values(), ids=[name.replace(" ", "-") for name in REFUSED]) +def test_failed_precondition_is_refused_and_keeps_the_code(host, options): + code = make_code(host) + response = redeem(host, code, **options(host, code)) + assert response.status == 403 and auth.SESSION_COOKIE not in response.cookies + assert host["browser_codes"].failures == 0 # Not counted as a wrong code. + assert signed_in(redeem(host, code)) + + +def test_oversize_form_and_changed_host_identity_are_refused(host, monkeypatch): + code = make_code(host) + assert redeem(host, code, body="csrf=" + "x" * 4096 + "&code=" + code).status == 413 + read = entry.read_owner_control_token + monkeypatch.setattr(entry, "read_owner_control_token", lambda *a: "d" * 64) # The private credential changed. + # The host identity cannot be confirmed: no session, and the code is kept. + assert wrong(redeem(host, code)) + monkeypatch.setattr(entry, "read_owner_control_token", read) + monkeypatch.setattr(entry, "current_owner_sid", lambda: "S-1-5-21-999") # Another Windows account. + assert wrong(redeem(host, code)) + monkeypatch.setattr(entry, "current_owner_sid", lambda: SID) + host["service"].update_manager.running_identity["instanceId"] = "e" * 32 + assert wrong(redeem(host, code)) # A code from before a restart. + host["service"].update_manager.running_identity["instanceId"] = "b" * 32 + assert signed_in(redeem(host, code)) + + +def test_real_https_sign_in_at_the_remote_address_with_the_product_certificate(host, tmp_path): + """Real sockets, real TLS and the real middleware; only the private store is a model. + + The listener is on loopback, as on a Windows host behind a Tailscale TCP + forward: the client reaches it by the remote name, and the peer is local. + """ + from cryptography.hazmat.primitives import serialization + from amplifier_web import deployment, windows_tls + config = deployment.validate_server({"public_origins": [LOCAL, REMOTE]}) + root, root_key = windows_tls._new_root(config) # The key-less identity's shape. + leaf, leaf_key = windows_tls._new_local_leaf(root, root_key, config) + cert, key = windows_tls._encoded_pair(leaf, leaf_key) + (tmp_path / "leaf.crt").write_bytes(cert) + (tmp_path / "leaf.key").write_bytes(key) + serving = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + serving.load_cert_chain(tmp_path / "leaf.crt", tmp_path / "leaf.key") + trusting = ssl.create_default_context(cadata=root.public_bytes(serialization.Encoding.PEM).decode()) + served = web.Application(middlewares=[auth.auth_required]) + for name in ("control_token", "session_secret", "server_config", "allowed_origins", "service", + "browser_entry", "browser_codes"): + served[name] = host[name] + served.router.add_post(entry.CODE_PATH, entry.code_issue) + served.router.add_post(entry.CODE_REDEEM_PATH, entry.code_redeem) + served.router.add_get("/login", auth.login_page) + + async def protected(request): + return web.json_response({"authenticated": True}) + served.router.add_get("/api/protected", protected) + remote_name, remote_host = "pc.tail1234.ts.net", REMOTE.split("://", 1)[1] + + async def run(): + runner = web.AppRunner(served) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0, ssl_context=serving) + await site.start() + base = f"https://127.0.0.1:{runner.addresses[0][1]}" + try: + async with aiohttp.ClientSession(cookie_jar=aiohttp.DummyCookieJar()) as client: + def at_remote(method, path, **options): + headers = {"Host": remote_host, **options.pop("headers", {})} + return client.request(method, base + path, ssl=trusting, server_hostname=remote_name, + allow_redirects=False, headers=headers, **options) + # The certificate is valid for the remote name and for no other name. + with pytest.raises(aiohttp.ClientConnectorCertificateError): + await client.get(base + "/login", ssl=trusting, server_hostname="other.example") + async with at_remote("GET", "/login") as page: + text = await page.text() + csrf = page.cookies[auth.CSRF_COOKIE].value + assert page.status == 200 and "Sign in to Amplifier" in text + assert text.count("' in text + assert text.count("' in text + for attribute in ('name="code"', 'autocomplete="one-time-code"', 'autocapitalize="characters"', + 'spellcheck="false"', " required>", ''): + assert attribute in text + assert "1. On the Windows PC that runs Amplifier, run: amplifier-unified browsers code" in text + assert "2. Type the code here within two minutes." in text and "That code did not work" not in text + csrf = page.cookies[auth.CSRF_COOKIE] + assert f'' in text + assert csrf["httponly"] and csrf["samesite"] == "Strict" and csrf["secure"] and csrf["path"] == "/" + assert page.headers["Cache-Control"] == "no-store" + # Not no-referrer: a browser would then post the form with "Origin: null". + assert page.headers["Referrer-Policy"] == "same-origin" + again = call(host, "/login?error=%3Cb%3Ereflected&next=%2Freflected", method="GET", at=REMOTE, origin=None, + headers={"Cookie": f"{auth.CSRF_COOKIE}={csrf.value}"}) + assert ("That code did not work. A code lasts two minutes and works once. " + "Make a new code on the PC and try again.") in again.text + assert "reflected" not in again.text and "= 3 + # Said once in the host log, not once per attempt and never in silence. + assert capsys.readouterr().out == "Sign-out of all browsers could not be checked: RuntimeError\n" def test_real_middleware_refuses_a_signed_out_cookie(home, monkeypatch): monkeypatch.setattr(auth.time, "time", lambda: 1_900_000_100) app = web.Application() - app.update(application(home, session_epoch=1_900_000_050, control_token="c" * 64, + app.update(application(home, sessions=auth.Sessions(1_900_000_050), control_token="c" * 64, allowed_origins=frozenset({"https://127.0.0.1:8941"}))) async def handler(request): return web.json_response({"ok": True})