oficina / home Public

Add guarded browser credential ceremony driver

Paul Campbell pushed by paul@keymail.dev db4d9a857e7fbf0717e4dd6446737642d17b31e4 parent 4013745
2 files changed, +385 −0
  • internal/home/browser_canary_test.go +205 −0
  • scripts/credential-browser.mjs +180 −0

CI passed — run details

diff --git a/internal/home/browser_canary_test.go b/internal/home/browser_canary_test.go
new file mode 100644
index 0000000..d8b1a1d
--- /dev/null
+++ b/internal/home/browser_canary_test.go
@@ -0,0 +1,205 @@
+package home
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "log"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+func TestCredentialBrowser(t *testing.T) {
+ module := os.Getenv("HOME_BROWSER_PLAYWRIGHT")
+ if module == "" {
+ t.Skip()
+ }
+ origins := Origins{"https://id.home-browser.test", "https://directory.home-browser.test"}
+ app := newTestAppWithOrigins(t, origins)
+ dir := t.TempDir()
+ mailbox := filepath.Join(dir, "mailbox.json")
+ write := func(path string, value any) error {
+ b, err := json.Marshal(value)
+ if err != nil {
+ return err
+ }
+ tmp, err := os.CreateTemp(filepath.Dir(path), "mailbox-*")
+ if err != nil {
+ return err
+ }
+ defer os.Remove(tmp.Name())
+ if err = tmp.Chmod(0600); err == nil {
+ _, err = tmp.Write(b)
+ }
+ closeErr := tmp.Close()
+ if err != nil {
+ return err
+ }
+ if closeErr != nil {
+ return closeErr
+ }
+ return os.Rename(tmp.Name(), path)
+ }
+ if err := write(mailbox, map[string]any{"sequence": 0, "url": ""}); err != nil {
+ t.Fatal(err)
+ }
+ var mu sync.Mutex
+ sequence := 0
+ posts := 0
+ directoryRequests := 0
+ directoryCookies := false
+ version := "local-browser-test"
+ router := app.Router()
+ server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ if r.Method == "POST" {
+ posts++
+ }
+ if r.Host == "directory.home-browser.test" {
+ directoryRequests++
+ directoryCookies = directoryCookies || r.Header.Get("Cookie") != ""
+ }
+ currentVersion := version
+ mu.Unlock()
+ if r.URL.Path == "/api/version" {
+ _, _ = w.Write([]byte(currentVersion))
+ return
+ }
+ router.ServeHTTP(w, r)
+ if r.Method == "POST" && r.URL.Path == "/signin" {
+ mu.Lock()
+ defer mu.Unlock()
+ sent, ok := app.mailer.last()
+ if !ok {
+ return
+ }
+ start := strings.Index(sent.Body, origins.Credential+linkPath)
+ if start < 0 {
+ return
+ }
+ link := strings.Fields(sent.Body[start:])[0]
+ link = strings.TrimRight(link, "\"")
+ sequence++
+ if err := write(mailbox, map[string]any{"sequence": sequence, "url": link}); err != nil {
+ t.Error("mailbox write failed")
+ }
+ }
+ }))
+ server.Config.ErrorLog = log.New(io.Discard, "", 0)
+ server.StartTLS()
+ defer server.Close()
+ u, _ := url.Parse(server.URL)
+ port, _ := strconv.Atoi(u.Port())
+ config := filepath.Join(dir, "config.json")
+ base := map[string]any{"mode": "local", "credentialOrigin": origins.Credential, "directoryOrigin": origins.Directory, "localPort": port, "version": "local-browser-test", "address": "browser@example.invalid", "mailboxFile": mailbox}
+ run := func(allowLocal, allowLive bool) ([]byte, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, "node", "../../scripts/credential-browser.mjs")
+ cmd.Cancel = func() error { return cmd.Process.Signal(os.Interrupt) }
+ cmd.WaitDelay = 10 * time.Second
+ cmd.Env = append(os.Environ(), "HOME_BROWSER_CONFIG="+config, "HOME_BROWSER_LIVE=0", "HOME_BROWSER_LOCAL=0")
+ if allowLocal {
+ cmd.Env = append(cmd.Env, "HOME_BROWSER_LOCAL=1")
+ }
+ if allowLive {
+ cmd.Env = append(cmd.Env, "HOME_BROWSER_LIVE=1")
+ }
+ return cmd.CombinedOutput()
+ }
+ for _, name := range []string{"wrong-version", "wrong-origin", "mailbox-permissions", "unconfigured-live", "recovery-existing", "recovery-symlink"} {
+ t.Run(name, func(t *testing.T) {
+ cfg := make(map[string]any)
+ for k, v := range base {
+ cfg[k] = v
+ }
+ mu.Lock()
+ before := posts
+ if name == "wrong-version" {
+ version = "wrong"
+ }
+ mu.Unlock()
+ if name == "wrong-origin" {
+ cfg["credentialOrigin"] = "https://unexpected.home-browser.test"
+ }
+ if name == "mailbox-permissions" {
+ if err := os.Chmod(mailbox, 0644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if strings.HasPrefix(name, "recovery-") {
+ cfg["mode"] = "canary"
+ cfg["credentialOrigin"] = "https://driver-id.bds.oncarlos.com"
+ cfg["directoryOrigin"] = "https://driver-home.bds.oncarlos.com"
+ delete(cfg, "localPort")
+ cfg["version"] = strings.Repeat("a", 40)
+ cfg["disposableAccount"] = true
+ cfg["maxMailRequests"] = 4
+ target := filepath.Join(dir, name)
+ cfg["recoveryFile"] = target
+ if name == "recovery-existing" {
+ if err := os.WriteFile(target, []byte("preserve"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ } else {
+ if err := os.Symlink(mailbox, target); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ if err := write(config, cfg); err != nil {
+ t.Fatal(err)
+ }
+ output, err := run(name != "unconfigured-live", strings.HasPrefix(name, "recovery-"))
+ if err == nil {
+ t.Fatal("unsafe configuration accepted")
+ }
+ var result struct {
+ Stage string
+ Passed bool
+ }
+ if json.Unmarshal(output, &result) != nil || result.Passed {
+ t.Fatal("invalid refusal report")
+ }
+ expected := "configuration"
+ if name == "wrong-version" {
+ expected = "version"
+ }
+ if result.Stage != expected {
+ t.Fatalf("refusal stage %s", result.Stage)
+ }
+ mu.Lock()
+ unchanged := posts == before
+ version = "local-browser-test"
+ mu.Unlock()
+ if !unchanged {
+ t.Fatal("refusal sent a POST")
+ }
+ if err := os.Chmod(mailbox, 0600); err != nil {
+ t.Fatal(err)
+ }
+ })
+ }
+ if err := write(config, base); err != nil {
+ t.Fatal(err)
+ }
+ output, err := run(true, false)
+ if err != nil {
+ t.Fatalf("browser failed: %s", output)
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ if sequence != 4 || directoryRequests == 0 || directoryCookies {
+ t.Fatal("mail budget or directory cookie isolation failed")
+ }
+ t.Log(string(output))
+}
diff --git a/scripts/credential-browser.mjs b/scripts/credential-browser.mjs
new file mode 100644
index 0000000..d40d499
--- /dev/null
+++ b/scripts/credential-browser.mjs
@@ -0,0 +1,180 @@
+import fs from 'node:fs/promises';
+import { constants } from 'node:fs';
+import { pathToFileURL } from 'node:url';
+
+let stage = 'configuration';
+let browser;
+let context;
+let recoveryFile;
+const report = { checks: [], local: false, serverCleanup: false };
+for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, async () => {
+ const deadline = setTimeout(() => process.exit(1), 5000);
+ if (context) await context.close().catch(() => {});
+ if (browser) await browser.close().catch(() => {});
+ if (recoveryFile) await recoveryFile.close().catch(() => {});
+ clearTimeout(deadline);
+ process.exit(1);
+});
+function check(ok) { if (!ok) throw new Error('check failed'); }
+async function privateJSON(path) {
+ const file = await fs.open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
+ try {
+ const stat = await file.stat();
+ check(stat.isFile() && stat.size <= 65536 && (stat.mode & 0o777) === 0o600 && stat.uid === process.getuid());
+ return JSON.parse(await file.readFile('utf8'));
+ } finally { await file.close(); }
+}
+try {
+ check(process.env.HOME_BROWSER_CONFIG && process.env.HOME_BROWSER_PLAYWRIGHT);
+ const cfg = await privateJSON(process.env.HOME_BROWSER_CONFIG);
+ const credential = new URL(cfg.credentialOrigin);
+ const directory = new URL(cfg.directoryOrigin);
+ check(credential.origin === cfg.credentialOrigin && directory.origin === cfg.directoryOrigin);
+ check(credential.protocol === 'https:' && directory.protocol === 'https:' && credential.hostname !== directory.hostname);
+ check(!credential.port && !directory.port && !credential.username && !directory.username);
+ check(!credential.hostname.endsWith('.' + directory.hostname) && !directory.hostname.endsWith('.' + credential.hostname));
+ const local = cfg.mode === 'local' && process.env.HOME_BROWSER_LOCAL === '1';
+ report.local = local;
+ if (local) {
+ check(cfg.credentialOrigin === 'https://id.home-browser.test' && cfg.directoryOrigin === 'https://directory.home-browser.test');
+ check(Number.isInteger(cfg.localPort) && cfg.localPort > 1024 && cfg.localPort < 65536);
+ check(cfg.address === 'browser@example.invalid' && cfg.version === 'local-browser-test');
+ } else {
+ check(cfg.mode === 'canary' && process.env.HOME_BROWSER_LIVE === '1' && cfg.disposableAccount === true);
+ check(credential.hostname.endsWith('.bds.oncarlos.com') && directory.hostname.endsWith('.bds.oncarlos.com'));
+ check(/^[a-f0-9]{40}$/.test(cfg.version));
+ check(!cfg.localPort && cfg.maxMailRequests === 4 && cfg.recoveryFile);
+ recoveryFile = await fs.open(cfg.recoveryFile, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
+ }
+ check(typeof cfg.address === 'string' && cfg.address.includes('@') && cfg.mailboxFile);
+ const initial = await privateJSON(cfg.mailboxFile);
+ check(initial.sequence === 0 && initial.url === '');
+ const { chromium } = await import(pathToFileURL(process.env.HOME_BROWSER_PLAYWRIGHT));
+ const args = local ? ['--no-proxy-server', `--host-resolver-rules=MAP ${credential.hostname} 127.0.0.1:${cfg.localPort}, MAP ${directory.hostname} 127.0.0.1:${cfg.localPort}`] : [];
+ browser = await chromium.launch({ headless: true, args });
+ context = await browser.newContext({ ignoreHTTPSErrors: local });
+ let external = false;
+ let directoryCookie = false;
+ await context.route('**/*', async route => {
+ const req = route.request();
+ const u = new URL(req.url());
+ if (![credential.origin, directory.origin].includes(u.origin)) { external = true; await route.abort(); return; }
+ if (u.origin === directory.origin && (await req.allHeaders()).cookie) directoryCookie = true;
+ await route.continue();
+ });
+ const page = await context.newPage();
+ page.setDefaultTimeout(15000);
+ stage = 'version';
+ for (const origin of [credential.origin, directory.origin]) {
+ const response = await page.goto(origin + '/api/version');
+ check(response.status() === 200 && (await response.text()).trim() === cfg.version);
+ if (!local) check(response.headers()['x-carlos-version'] === cfg.version);
+ }
+ report.checks.push('paired-version');
+ stage = 'virtual-authenticator';
+ const cdp = await context.newCDPSession(page);
+ await cdp.send('WebAuthn.enable');
+ const { authenticatorId } = await cdp.send('WebAuthn.addVirtualAuthenticator', { options: {
+ protocol: 'ctap2', transport: 'internal', hasResidentKey: true, hasUserVerification: true,
+ isUserVerified: true, automaticPresenceSimulation: true,
+ }});
+ let sequence = 0;
+ async function mailLink() {
+ const deadline = Date.now() + 90000;
+ while (Date.now() < deadline) {
+ const value = await privateJSON(cfg.mailboxFile);
+ if (value.sequence === sequence + 1) {
+ const u = new URL(value.url);
+ check(u.origin === credential.origin && u.pathname === '/signin/verify' && u.searchParams.get('token'));
+ check(!u.username && !u.password && !u.hash && [...u.searchParams.keys()].every(k => k === 'token'));
+ sequence++;
+ const file = await fs.open(cfg.mailboxFile, constants.O_WRONLY | constants.O_NOFOLLOW);
+ try {
+ const stat = await file.stat();
+ check(stat.isFile() && stat.size <= 65536 && (stat.mode & 0o777) === 0o600 && stat.uid === process.getuid());
+ await file.truncate();
+ await file.writeFile(JSON.stringify({ sequence, url: '' }));
+ } finally { await file.close(); }
+ return u.href;
+ }
+ await new Promise(resolve => setTimeout(resolve, 100));
+ }
+ throw new Error('mail timeout');
+ }
+ async function cookies(full) {
+ const all = await context.cookies(credential.origin);
+ const session = all.filter(c => c.name.includes('session'));
+ check(full ? session.length === 1 : session.length === 0);
+ for (const c of all) check(c.name.startsWith('__Host-') && c.domain === credential.hostname && c.path === '/' && c.secure && c.httpOnly && c.sameSite === 'Lax');
+ check((await context.cookies(directory.origin)).length === 0);
+ const other = await context.newPage();
+ await other.goto(directory.origin + '/');
+ await other.close();
+ check(!directoryCookie);
+ }
+ async function firstFactor(target) {
+ check(sequence < 4);
+ await context.clearCookies();
+ await page.goto(credential.origin + target);
+ check(new URL(page.url()).pathname === '/signin');
+ await page.locator('#address').fill(cfg.address);
+ await page.locator('form button[type=submit]').click();
+ const link = await mailLink();
+ await page.goto(link);
+ await page.locator('form button[type=submit]').click();
+ }
+ stage = 'registration-return';
+ await firstFactor('/passkey/enrol');
+ await page.waitForURL(credential.origin + '/passkey/enrol');
+ await cookies(true);
+ await page.locator('#passkey-enrol').click();
+ await page.waitForURL(credential.origin + '/passkey/codes');
+ const credentials = await cdp.send('WebAuthn.getCredentials', { authenticatorId });
+ check(credentials.credentials.length === 1 && credentials.credentials[0].rpId === credential.hostname);
+ report.checks.push('registration', 'enrol-return', 'credential-rp-id', 'host-only-cookie');
+ stage = 'recovery-generation';
+ await page.locator('form button[type=submit]').click();
+ const codes = await page.locator('li code').allTextContents();
+ check(codes.length > 1);
+ if (recoveryFile) { await recoveryFile.writeFile(JSON.stringify({ codes })); await recoveryFile.sync(); }
+ report.checks.push('recovery-generated');
+ stage = 'assertion-return';
+ await firstFactor('/passkey/codes');
+ await page.waitForURL(credential.origin + '/passkey/confirm');
+ await cookies(false);
+ await page.locator('#passkey-confirm').click();
+ await page.waitForURL(credential.origin + '/passkey/codes');
+ await cookies(true);
+ const asserted = await cdp.send('WebAuthn.getCredentials', { authenticatorId });
+ check(asserted.credentials[0].signCount > credentials.credentials[0].signCount);
+ report.checks.push('assertion', 'second-factor-gate', 'codes-return');
+ stage = 'recovery-return';
+ await cdp.send('Emulation.setScriptExecutionDisabled', { value: true });
+ await firstFactor('/passkey/codes');
+ await page.waitForURL(credential.origin + '/passkey/confirm');
+ await cookies(false);
+ await page.locator('#code').fill(codes[0]);
+ await page.locator('form button[type=submit]').click();
+ await page.waitForURL(credential.origin + '/passkey/codes');
+ await cookies(true);
+ report.checks.push('recovery-return', 'scriptless-recovery');
+ stage = 'recovery-single-use';
+ await firstFactor('/passkey/codes');
+ await page.waitForURL(credential.origin + '/passkey/confirm');
+ await page.locator('#code').fill(codes[0]);
+ await page.locator('form button[type=submit]').click();
+ await page.locator('[role=alert]').waitFor();
+ check(new URL(page.url()).pathname === '/passkey/confirm');
+ await cookies(false);
+ report.checks.push('recovery-single-use', 'directory-cookie-isolation');
+ check(!external);
+ await cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
+ stage = 'complete';
+} catch {
+ process.exitCode = 1;
+} finally {
+ if (context) { await context.clearCookies().catch(() => {}); await context.close().catch(() => {}); }
+ if (recoveryFile) await recoveryFile.close().catch(() => {});
+ if (browser) await browser.close().catch(() => {});
+ process.stdout.write(JSON.stringify({ ...report, stage, passed: stage === 'complete' }) + '\n');
+}