Add guarded browser credential ceremony driver
2 files changed,
+385
−0
internal/home/browser_canary_test.go+205 −0scripts/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'); |
| +} |