diff --git a/docs/backup-credential-recovery.md b/docs/backup-credential-recovery.md index 8b08fc3..92c8ce5 100644 --- a/docs/backup-credential-recovery.md +++ b/docs/backup-credential-recovery.md @@ -139,9 +139,10 @@ python3 scripts/openbao-attended-exec.py -- /absolute/path/to/reviewed-silent-co ``` The wrapper keeps Warden's `openbao-platform-admin-login --exec` envelope and -adds a repository-scoped launcher only when all native providers are absent. -The launcher allows only the Keycloak HTTPS host and exact local OIDC callback; -it sends the URL to Windows PowerShell through stdin without recording it. +puts the repository launcher ahead of the default `xdg-open`. The launcher +allows only the Keycloak HTTPS host and exact local OIDC callback. It opens +that page in a new empty Edge or Chrome profile, not the signed-in Windows +session, and it does not record the address. No token helper or captured Warden output may be inspected. A receipt written by the owner command proves handoff; session cleanup is assessed separately from the final Warden exit status. diff --git a/scripts/openbao-attended-exec.py b/scripts/openbao-attended-exec.py index c833fb9..9a63761 100755 --- a/scripts/openbao-attended-exec.py +++ b/scripts/openbao-attended-exec.py @@ -1,8 +1,7 @@ #!/usr/bin/env python3 -"""Keep Warden containment; supply a scoped browser launcher when WSL lacks one.""" +"""Keep Warden containment and open the login in an empty browser profile.""" import os from pathlib import Path -import shutil import sys @@ -53,10 +52,9 @@ def contained_env(): env.pop('BAO_TOKEN', None) env.pop('VAULT_TOKEN', None) env['WARDEN_ROUTING_CATALOG'] = '/home/worsch/ops-warden/registry/routing/catalog.yaml' - if not any(shutil.which(x) for x in ('xdg-open', 'x-www-browser', 'www-browser')): - if not Path('/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe').is_file(): - raise SystemExit('No supported attended browser launcher is available') - env['PATH'] = str(Path(__file__).resolve().parent / 'operator-browser') + os.pathsep + env.get('PATH', '') + # Always win over ~/.local/bin/xdg-open. That shim opens the default Windows + # session, which can already be signed in as another account. + env['PATH'] = str(Path(__file__).resolve().parent / 'operator-browser') + os.pathsep + env.get('PATH', '') return env diff --git a/scripts/operator-browser/xdg-open b/scripts/operator-browser/xdg-open index bb06f2c..2f731c1 100755 --- a/scripts/operator-browser/xdg-open +++ b/scripts/operator-browser/xdg-open @@ -1,27 +1,104 @@ #!/usr/bin/env python3 -"""Contained WSL launcher, limited to the approved OpenBao OIDC callback.""" +"""Open the approved OpenBao login in a new empty browser profile. + +The default Windows session is not used. A previous login stayed signed in +there as another account, so this launcher creates a fresh profile each time. +""" +import os import subprocess import sys -from urllib.parse import urlsplit, parse_qs +import tempfile +from pathlib import Path +from urllib.parse import parse_qs, urlsplit + + +EDGE = "/mnt/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe" +CHROME = "/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe" def allowed(url): try: - u=urlsplit(url); q=parse_qs(u.query) - return (u.scheme=='https' and u.hostname=='kc.coulomb.social' - and u.port in (None,443) and u.username is None and u.password is None - and not u.fragment and not any(c in url for c in '\r\n\x00') - and q.get('redirect_uri') in (['http://localhost:8250/oidc/callback'],['http://127.0.0.1:8250/oidc/callback'])) - except ValueError: return False + parsed = urlsplit(url) + query = parse_qs(parsed.query) + return ( + parsed.scheme == "https" + and parsed.hostname == "kc.coulomb.social" + and parsed.port in (None, 443) + and parsed.username is None + and parsed.password is None + and not parsed.fragment + and not any(char in url for char in "\r\n\x00") + and query.get("redirect_uri") + in ( + ["http://localhost:8250/oidc/callback"], + ["http://127.0.0.1:8250/oidc/callback"], + ) + ) + except ValueError: + return False + + +def browser_path(): + for candidate in (EDGE, CHROME): + if Path(candidate).is_file(): + return candidate + return "" + + +def launch_argv(browser, profile_dir, url): + return [ + browser, + f"--user-data-dir={profile_dir}", + "--no-first-run", + "--no-default-browser-check", + "--new-window", + url, + ] def main(): - if len(sys.argv)!=2 or not allowed(sys.argv[1]): return 1 + if len(sys.argv) != 2 or not allowed(sys.argv[1]): + return 1 + browser = browser_path() + if not browser: + return 1 + profile = Path(tempfile.mkdtemp(prefix="operator-browser-")) + os.chmod(profile, 0o700) try: - r=subprocess.run(['/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe', - '-NoProfile','-NonInteractive','-Command', - '$ErrorActionPreference="Stop"; $u=[Console]::In.ReadLine(); Start-Process -FilePath $u'], - input=(sys.argv[1]+'\n').encode(),stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=20) - return int(r.returncode!=0) - except Exception: return 1 -if __name__=='__main__': raise SystemExit(main()) + windows_profile = subprocess.run( + ["wslpath", "-w", str(profile)], + capture_output=True, + check=False, + timeout=10, + ) + if windows_profile.returncode != 0: + return 1 + profile_dir = windows_profile.stdout.decode().strip() + if not profile_dir or any(char in profile_dir for char in "\r\n"): + return 1 + command = launch_argv(browser, profile_dir, sys.argv[1]) + payload = "\n".join(command) + "\n" + opened = subprocess.run( + [ + "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", + "-NoProfile", + "-NonInteractive", + "-Command", + "$ErrorActionPreference='Stop'; " + "$lines=@([Console]::In.ReadToEnd().Split(\"`n\") | " + "Where-Object { $_ -ne '' }); " + "if ($lines.Count -lt 2) { exit 1 }; " + "Start-Process -FilePath $lines[0] -ArgumentList $lines[1..($lines.Count-1)]", + ], + input=payload.encode(), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=20, + ) + return int(opened.returncode != 0) + except (OSError, subprocess.SubprocessError): + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_openbao_operator_loopback_callback.py b/tests/test_openbao_operator_loopback_callback.py index d323226..3423fe5 100644 --- a/tests/test_openbao_operator_loopback_callback.py +++ b/tests/test_openbao_operator_loopback_callback.py @@ -111,6 +111,9 @@ def test_attended_wrapper_pins_operator_tunnel_address(monkeypatch): assert env['VAULT_ADDR'] == wrapper.TUNNEL assert 'BAO_TOKEN' not in env assert 'VAULT_TOKEN' not in env + import os + launcher = str(Path(__file__).resolve().parents[1] / 'scripts/operator-browser') + assert env['PATH'].split(os.pathsep)[0] == launcher def test_attended_wrapper_requires_absolute_existing_executable(tmp_path, monkeypatch): diff --git a/tests/test_operator_browser.py b/tests/test_operator_browser.py index cbf815e..4ed9f19 100644 --- a/tests/test_operator_browser.py +++ b/tests/test_operator_browser.py @@ -2,7 +2,9 @@ import runpy from pathlib import Path import unittest from urllib.parse import urlencode -allowed=runpy.run_path(str(Path(__file__).resolve().parents[1]/'scripts/operator-browser/xdg-open'))['allowed'] +launcher=runpy.run_path(str(Path(__file__).resolve().parents[1]/'scripts/operator-browser/xdg-open')) +allowed=launcher['allowed'] +launch_argv=launcher['launch_argv'] class BrowserBoundary(unittest.TestCase): def test_approved_flow(self): self.assertTrue(allowed('https://kc.coulomb.social/authorize?'+urlencode({'redirect_uri':'http://localhost:8250/oidc/callback','state':'test'}))) @@ -13,3 +15,10 @@ class BrowserBoundary(unittest.TestCase): self.assertFalse(allowed('https://kc.coulomb.social/?'+urlencode({'redirect_uri':'https://evil.example'}))) def test_duplicate_callback_rejected(self): self.assertFalse(allowed('https://kc.coulomb.social/?redirect_uri=http://localhost:8250/oidc/callback&redirect_uri=https://evil.example')) + def test_launch_uses_empty_profile(self): + url='https://kc.coulomb.social/authorize?'+urlencode({'redirect_uri':'http://localhost:8250/oidc/callback'}) + argv=launch_argv(r'C:\Edge\msedge.exe', r'C:\fresh\profile', url) + self.assertEqual(argv[0], r'C:\Edge\msedge.exe') + self.assertIn(r'--user-data-dir=C:\fresh\profile', argv) + self.assertEqual(argv[-1], url) + self.assertNotIn('explorer.exe', ' '.join(argv))