diff --git a/.github/workflows/server-test.yaml b/.github/workflows/server-test.yaml index 6e38fd033..667e969f7 100644 --- a/.github/workflows/server-test.yaml +++ b/.github/workflows/server-test.yaml @@ -108,6 +108,12 @@ jobs: env: KERNEL_CDPMONITOR_CHROME_E2E: "1" + - name: Run WebAuthn page hook browser tests + run: go test -race ./lib/webauthnhook -run '^TestChromiumPageHook$' -count=1 -v + working-directory: server + env: + KERNEL_WEBAUTHN_CHROME_E2E: "1" + - name: Compute short SHA for images id: vars shell: bash diff --git a/server/README.md b/server/README.md index bef0c9e0f..d656b1713 100644 --- a/server/README.md +++ b/server/README.md @@ -57,6 +57,8 @@ Configure the server using environment variables: | `MAX_SIZE_MB` | `500` | Default maximum file size (MB) | | `OUTPUT_DIR` | `.` | Directory to save recordings | | `FFMPEG_PATH` | `ffmpeg` | Path to the ffmpeg binary | +| `KERNEL_METRO_API_BASE_URL` | | Metro API base URL; answers the WebAuthn page hook (`PUT /webauthn/hook`) | +| `KERNEL_INSTANCE_JWT` | | Instance credential sent to the metro API and the OTLP relay | #### Example Configuration diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index 9cde77239..bb977b22b 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -20,6 +20,7 @@ import ( "github.com/kernel/kernel-images/server/lib/recorder" "github.com/kernel/kernel-images/server/lib/scaletozero" "github.com/kernel/kernel-images/server/lib/telemetry" + "github.com/kernel/kernel-images/server/lib/webauthnhook" "github.com/kernel/kernel-images/server/lib/webmcpclient" ) @@ -142,6 +143,10 @@ type ApiService struct { // decide whether to refuse a context that would set its own proxy. egressPolicy *egresspolicy.State + // webauthnHook answers pages' WebAuthn calls over its own DevTools + // connection; PUT /webauthn/hook turns it on and off. + webauthnHook *webauthnhook.Hook + lifecycleCtx context.Context lifecycleCancel context.CancelFunc } @@ -160,6 +165,7 @@ func New( otlpExport OTLPExporter, s2Storage S2Storage, egressPolicy *egresspolicy.State, + webauthn webauthnhook.Config, ) (*ApiService, error) { switch { case recordManager == nil: @@ -199,6 +205,7 @@ func New( otlpExport: otlpExport, s2Storage: s2Storage, egressPolicy: egressPolicy, + webauthnHook: webauthnhook.New(webauthn, upstreamMgr), webmcp: webmcpclient.NewManager(upstreamMgr), browserRepl: newBrowserReplManager(), lifecycleCtx: ctx, @@ -471,6 +478,12 @@ func (s *ApiService) StartNetworkMonitor() error { return s.cdpMonitor.Start(s.lifecycleCtx) } +// StartWebAuthnHook reconnects the WebAuthn page hook when a previous run of +// the process left it on. +func (s *ApiService) StartWebAuthnHook() { + s.webauthnHook.Start(s.lifecycleCtx) +} + func (s *ApiService) NetworkMetrics() (resets, completed uint64, up bool) { snapshot := s.cdpMonitor.NetworkSnapshot() return snapshot.Resets, snapshot.Completed, snapshot.Up @@ -482,6 +495,7 @@ func (s *ApiService) Shutdown(ctx context.Context) error { s.playwrightExecutors.Shutdown(ctx) _ = s.webmcp.Close() + s.webauthnHook.Stop() s.monitorMu.Lock() s.cdpMonitor.Stop() s.telemetrySession.Stop() diff --git a/server/cmd/api/api/api_test.go b/server/cmd/api/api/api_test.go index 4300a4546..58e35b344 100644 --- a/server/cmd/api/api/api_test.go +++ b/server/cmd/api/api/api_test.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "math/rand" + "net/http" "os" "testing" @@ -20,6 +21,7 @@ import ( "github.com/kernel/kernel-images/server/lib/recorder" "github.com/kernel/kernel-images/server/lib/scaletozero" "github.com/kernel/kernel-images/server/lib/telemetry" + "github.com/kernel/kernel-images/server/lib/webauthnhook" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -404,11 +406,32 @@ func testEgressPolicy(t *testing.T) *egresspolicy.State { return egresspolicy.Load(filepath.Join(t.TempDir(), "egress-policy.json"), slog.New(slog.NewTextHandler(io.Discard, nil))) } +// testWebAuthn wires the hook as main does on a VM without a metro API URL. +func testWebAuthn(t *testing.T) webauthnhook.Config { + t.Helper() + return testWebAuthnWithMetro(t, "") +} + +func testWebAuthnWithMetro(t *testing.T, metroURL string) webauthnhook.Config { + t.Helper() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + return webauthnhook.Config{ + StatePath: filepath.Join(t.TempDir(), "webauthn-hook.json"), + Signer: &webauthnhook.MetroSigner{ + BaseURL: func() string { return metroURL }, + Token: func() string { return "instance-jwt" }, + Client: &http.Client{}, + Logger: logger, + }, + Logger: logger, + } +} + // newSvc constructs an ApiService with a fresh telemetry session and event stream. func newSvc(t *testing.T, mgr recorder.RecordManager) (*ApiService, error) { t.Helper() ts, es := newTelemetrySession(t) - return New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), ts, es, 0, nil, nil, testEgressPolicy(t)) + return New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), ts, es, 0, nil, nil, testEgressPolicy(t), testWebAuthn(t)) } func TestApiService_PatchChromiumFlags(t *testing.T) { diff --git a/server/cmd/api/api/display_test.go b/server/cmd/api/api/display_test.go index fd283b87f..0561cab41 100644 --- a/server/cmd/api/api/display_test.go +++ b/server/cmd/api/api/display_test.go @@ -36,7 +36,7 @@ func testFFmpegFactory(t *testing.T, tempDir string) recorder.FFmpegRecorderFact func newTestServiceWithFactory(t *testing.T, mgr recorder.RecordManager, factory recorder.FFmpegRecorderFactory) *ApiService { t.Helper() ts, es := newTelemetrySession(t) - svc, err := New(mgr, factory, newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), ts, es, 0, nil, nil, testEgressPolicy(t)) + svc, err := New(mgr, factory, newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), ts, es, 0, nil, nil, testEgressPolicy(t), testWebAuthn(t)) require.NoError(t, err) return svc } diff --git a/server/cmd/api/api/telemetry_test.go b/server/cmd/api/api/telemetry_test.go index ca71f9561..ae209bc76 100644 --- a/server/cmd/api/api/telemetry_test.go +++ b/server/cmd/api/api/telemetry_test.go @@ -409,7 +409,7 @@ func (m *mockRecordManager) StopAll(_ context.Context) error func newTestService(t *testing.T, mgr recorder.RecordManager) *ApiService { t.Helper() ts, es := newTelemetrySession(t) - svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), ts, es, 0, nil, nil, testEgressPolicy(t)) + svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), ts, es, 0, nil, nil, testEgressPolicy(t), testWebAuthn(t)) require.NoError(t, err) svc.cdpMonitor = &stubCdpMonitor{} return svc diff --git a/server/cmd/api/api/webauthn.go b/server/cmd/api/api/webauthn.go new file mode 100644 index 000000000..8b70f8a70 --- /dev/null +++ b/server/cmd/api/api/webauthn.go @@ -0,0 +1,26 @@ +package api + +import ( + "context" + "errors" + + "github.com/kernel/kernel-images/server/lib/logger" + oapi "github.com/kernel/kernel-images/server/lib/oapi" + "github.com/kernel/kernel-images/server/lib/webauthnhook" +) + +// PutWebAuthnHook handles PUT /webauthn/hook. +func (s *ApiService) PutWebAuthnHook(ctx context.Context, req oapi.PutWebAuthnHookRequestObject) (oapi.PutWebAuthnHookResponseObject, error) { + log := logger.FromContext(ctx) + if req.Body == nil { + return oapi.PutWebAuthnHook400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "missing body"}}, nil + } + if err := s.webauthnHook.Set(ctx, req.Body.Enabled); errors.Is(err, webauthnhook.ErrNotConfigured) { + return oapi.PutWebAuthnHook400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: err.Error()}}, nil + } else if err != nil { + log.Error("failed to apply WebAuthn hook", "err", err, "enabled", req.Body.Enabled) + return oapi.PutWebAuthnHook500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to apply WebAuthn hook"}}, nil + } + log.Info("WebAuthn hook applied", "enabled", req.Body.Enabled) + return oapi.PutWebAuthnHook200JSONResponse{Enabled: req.Body.Enabled}, nil +} diff --git a/server/cmd/api/api/webauthn_test.go b/server/cmd/api/api/webauthn_test.go new file mode 100644 index 000000000..168aee0c6 --- /dev/null +++ b/server/cmd/api/api/webauthn_test.go @@ -0,0 +1,59 @@ +package api + +import ( + "context" + "testing" + + oapi "github.com/kernel/kernel-images/server/lib/oapi" + "github.com/kernel/kernel-images/server/lib/scaletozero" + "github.com/stretchr/testify/require" +) + +func newWebAuthnSvc(t *testing.T, metroURL string) *ApiService { + t.Helper() + ts, es := newTelemetrySession(t) + svc, err := New(newMockRecordManager(), newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), ts, es, 0, nil, nil, testEgressPolicy(t), testWebAuthnWithMetro(t, metroURL)) + require.NoError(t, err) + return svc +} + +func TestPutWebAuthnHook(t *testing.T) { + svc := newWebAuthnSvc(t, "https://metro.example") + + for _, want := range []bool{true, true, false, false} { + resp, err := svc.PutWebAuthnHook(context.Background(), oapi.PutWebAuthnHookRequestObject{ + Body: &oapi.WebAuthnHook{Enabled: want}, + }) + require.NoError(t, err) + require.Equal(t, oapi.PutWebAuthnHook200JSONResponse{Enabled: want}, resp) + } +} + +// Until the metro API URL reaches every VM, the endpoint refuses to turn the +// hook on rather than arming a hook nothing can answer. Turning it off still +// succeeds. +func TestPutWebAuthnHookWithoutMetroAPI(t *testing.T) { + svc := newWebAuthnSvc(t, "") + + resp, err := svc.PutWebAuthnHook(context.Background(), oapi.PutWebAuthnHookRequestObject{ + Body: &oapi.WebAuthnHook{Enabled: true}, + }) + require.NoError(t, err) + require.Equal(t, oapi.PutWebAuthnHook400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{ + Message: "WebAuthn hook is not configured: KERNEL_METRO_API_BASE_URL is not set", + }}, resp) + + resp, err = svc.PutWebAuthnHook(context.Background(), oapi.PutWebAuthnHookRequestObject{ + Body: &oapi.WebAuthnHook{Enabled: false}, + }) + require.NoError(t, err) + require.Equal(t, oapi.PutWebAuthnHook200JSONResponse{Enabled: false}, resp) +} + +func TestPutWebAuthnHookRejectsMissingBody(t *testing.T) { + svc := newWebAuthnSvc(t, "https://metro.example") + + resp, err := svc.PutWebAuthnHook(context.Background(), oapi.PutWebAuthnHookRequestObject{}) + require.NoError(t, err) + require.IsType(t, oapi.PutWebAuthnHook400JSONResponse{}, resp) +} diff --git a/server/cmd/api/otlp_credential.go b/server/cmd/api/instance_identity.go similarity index 55% rename from server/cmd/api/otlp_credential.go rename to server/cmd/api/instance_identity.go index f58b17080..8e6e1d22a 100644 --- a/server/cmd/api/otlp_credential.go +++ b/server/cmd/api/instance_identity.go @@ -8,61 +8,65 @@ import ( "github.com/kernel/kernel-images/server/lib/forkidentity" ) -// otlpIdentityCacheTTL bounds how often the provider re-reads the applied +// identityCacheTTL bounds how often the provider re-reads the applied // fork-identity payload from disk. Export requests are frequent; the identity // changes at most once per fork, so a few seconds of staleness is harmless and // avoids a filesystem read on every request. Only a payload-sourced identity is // cached; a boot fallback during the pre-apply window is returned uncached so // the fresh identity is picked up on the very next request. -const otlpIdentityCacheTTL = 3 * time.Second +const identityCacheTTL = 3 * time.Second -// otlpIdentity is the platform identity stamped onto OTLP export: the relay -// bearer credential plus the resource attributes. -type otlpIdentity struct { +// instanceIdentity is the platform identity this VM presents to Kernel +// services: the instance JWT used as a bearer credential (by the OTLP relay +// and the metro API), the OTLP resource attributes, and the metro API URL. +type instanceIdentity struct { jwt string instanceName string metro string + metroAPIURL string } -// otlpIdentityProvider resolves the OTLP identity per use. +// instanceIdentityProvider resolves the instance identity per use. // // In fork-identity-wait mode the platform delivers a fresh KERNEL_INSTANCE_JWT -// (and INST_NAME/METRO_NAME) in the applied identity payload after -// kernel-images-api has already started, and the process is not restarted, so -// the boot-time env values are stale (empty on an unbound fork). This provider -// reads the identity from the applied payload instead, falling back to the boot -// values. Outside fork mode it returns the boot identity unchanged. -type otlpIdentityProvider struct { - boot otlpIdentity +// (and INST_NAME/METRO_NAME/KERNEL_METRO_API_BASE_URL) in the applied identity payload +// after kernel-images-api has already started, and the process is not +// restarted, so the boot-time env values are stale (empty on an unbound fork). +// This provider reads the identity from the applied payload instead, falling +// back to the boot values. Outside fork mode it returns the boot identity +// unchanged. +type instanceIdentityProvider struct { + boot instanceIdentity forkEnabled bool mu sync.Mutex - cached otlpIdentity + cached instanceIdentity cachedOK bool cachedAt time.Time } -func newOTLPIdentityProvider(boot otlpIdentity, log *slog.Logger) *otlpIdentityProvider { +func newInstanceIdentityProvider(boot instanceIdentity, log *slog.Logger) *instanceIdentityProvider { enabled, err := forkidentity.WaitEnabled() if err != nil { // Malformed flag: treat as disabled but surface it, otherwise a fork VM // would silently keep using the stale boot identity. log.Warn("fork-identity wait flag invalid; treating as disabled", "err", err) } - return &otlpIdentityProvider{boot: boot, forkEnabled: enabled} + return &instanceIdentityProvider{boot: boot, forkEnabled: enabled} } -func (p *otlpIdentityProvider) Token() string { return p.current().jwt } -func (p *otlpIdentityProvider) InstanceName() string { return p.current().instanceName } -func (p *otlpIdentityProvider) Metro() string { return p.current().metro } +func (p *instanceIdentityProvider) Token() string { return p.current().jwt } +func (p *instanceIdentityProvider) InstanceName() string { return p.current().instanceName } +func (p *instanceIdentityProvider) Metro() string { return p.current().metro } +func (p *instanceIdentityProvider) MetroAPIURL() string { return p.current().metroAPIURL } // current returns the identity, caching only a payload-sourced (or non-fork, // stable) result so a boot fallback during the pre-apply window is retried on // the next call rather than pinned for the TTL. -func (p *otlpIdentityProvider) current() otlpIdentity { +func (p *instanceIdentityProvider) current() instanceIdentity { p.mu.Lock() defer p.mu.Unlock() - if p.cachedOK && time.Since(p.cachedAt) < otlpIdentityCacheTTL { + if p.cachedOK && time.Since(p.cachedAt) < identityCacheTTL { return p.cached } id, ok := p.resolve() @@ -75,7 +79,7 @@ func (p *otlpIdentityProvider) current() otlpIdentity { // resolve reads the applied fork-identity payload. The bool reports whether the // result is stable/cacheable: true for the boot identity outside fork mode or a // successfully read payload, false for a boot fallback while a fork is mid-apply. -func (p *otlpIdentityProvider) resolve() (otlpIdentity, bool) { +func (p *instanceIdentityProvider) resolve() (instanceIdentity, bool) { if !p.forkEnabled { return p.boot, true } @@ -89,9 +93,10 @@ func (p *otlpIdentityProvider) resolve() (otlpIdentity, bool) { if err != nil { return p.boot, false } - return otlpIdentity{ + return instanceIdentity{ jwt: forkidentity.FirstNonEmpty(payload.Get("kernel_instance_jwt"), p.boot.jwt), instanceName: forkidentity.FirstNonEmpty(payload.InstanceName(), p.boot.instanceName), metro: forkidentity.FirstNonEmpty(payload.Get("metro_name"), p.boot.metro), + metroAPIURL: forkidentity.FirstNonEmpty(forkidentity.MetroAPIURL(payload), p.boot.metroAPIURL), }, true } diff --git a/server/cmd/api/otlp_credential_test.go b/server/cmd/api/instance_identity_test.go similarity index 69% rename from server/cmd/api/otlp_credential_test.go rename to server/cmd/api/instance_identity_test.go index 273de9dca..9780d2fe8 100644 --- a/server/cmd/api/otlp_credential_test.go +++ b/server/cmd/api/instance_identity_test.go @@ -25,10 +25,11 @@ func withForkIdentityFiles(t *testing.T) { func writeAppliedPayload(t *testing.T, jwt, instanceName, metro string) { t.Helper() p := forkidentity.Payload{ - "instance_name": instanceName, - "metro_name": metro, - "session_intel_url": "https://intel.example", - "kernel_instance_jwt": jwt, + "instance_name": instanceName, + "metro_name": metro, + "session_intel_url": "https://intel.example", + "kernel_metro_api_base_url": "https://metro.example", + "kernel_instance_jwt": jwt, } if err := forkidentity.WritePayload(p); err != nil { t.Fatalf("write payload: %v", err) @@ -38,25 +39,25 @@ func writeAppliedPayload(t *testing.T, jwt, instanceName, metro string) { } } -func bootIdentity() otlpIdentity { - return otlpIdentity{jwt: "boot-jwt", instanceName: "boot-inst", metro: "boot-metro"} +func bootIdentity() instanceIdentity { + return instanceIdentity{jwt: "boot-jwt", instanceName: "boot-inst", metro: "boot-metro", metroAPIURL: "https://boot-metro.example"} } -func TestOTLPIdentityProvider(t *testing.T) { +func TestInstanceIdentityProvider(t *testing.T) { t.Run("non-fork returns boot identity", func(t *testing.T) { withForkIdentityFiles(t) t.Setenv(forkidentity.WaitEnv, "") writeAppliedPayload(t, "payload-jwt", "payload-inst", "payload-metro") // ignored in non-fork - p := newOTLPIdentityProvider(bootIdentity(), slog.Default()) - if p.Token() != "boot-jwt" || p.InstanceName() != "boot-inst" || p.Metro() != "boot-metro" { - t.Errorf("got (%q,%q,%q), want boot values", p.Token(), p.InstanceName(), p.Metro()) + p := newInstanceIdentityProvider(bootIdentity(), slog.Default()) + if p.Token() != "boot-jwt" || p.InstanceName() != "boot-inst" || p.Metro() != "boot-metro" || p.MetroAPIURL() != "https://boot-metro.example" { + t.Errorf("got (%q,%q,%q,%q), want boot values", p.Token(), p.InstanceName(), p.Metro(), p.MetroAPIURL()) } }) t.Run("fork before apply falls back to boot", func(t *testing.T) { withForkIdentityFiles(t) t.Setenv(forkidentity.WaitEnv, "true") - p := newOTLPIdentityProvider(bootIdentity(), slog.Default()) + p := newInstanceIdentityProvider(bootIdentity(), slog.Default()) if p.Token() != "boot-jwt" || p.InstanceName() != "boot-inst" { t.Errorf("got (%q,%q), want boot fallback", p.Token(), p.InstanceName()) } @@ -67,17 +68,17 @@ func TestOTLPIdentityProvider(t *testing.T) { t.Setenv(forkidentity.WaitEnv, "true") // Empty boot values mirror an unbound fork; the fresh identity comes from // the applied payload. - p := newOTLPIdentityProvider(otlpIdentity{}, slog.Default()) + p := newInstanceIdentityProvider(instanceIdentity{}, slog.Default()) writeAppliedPayload(t, "fresh-jwt", "fresh-inst", "fresh-metro") - if p.Token() != "fresh-jwt" || p.InstanceName() != "fresh-inst" || p.Metro() != "fresh-metro" { - t.Errorf("got (%q,%q,%q), want fresh payload values", p.Token(), p.InstanceName(), p.Metro()) + if p.Token() != "fresh-jwt" || p.InstanceName() != "fresh-inst" || p.Metro() != "fresh-metro" || p.MetroAPIURL() != "https://metro.example" { + t.Errorf("got (%q,%q,%q,%q), want fresh payload values", p.Token(), p.InstanceName(), p.Metro(), p.MetroAPIURL()) } }) t.Run("boot fallback is not cached; apply is picked up next call", func(t *testing.T) { withForkIdentityFiles(t) t.Setenv(forkidentity.WaitEnv, "true") - p := newOTLPIdentityProvider(bootIdentity(), slog.Default()) + p := newInstanceIdentityProvider(bootIdentity(), slog.Default()) // Pre-apply: boot fallback, and must not be cached. if got := p.Token(); got != "boot-jwt" { t.Fatalf("pre-apply Token() = %q, want boot-jwt", got) diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index eff99bcbb..2f05ffd64 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -40,6 +40,7 @@ import ( "github.com/kernel/kernel-images/server/lib/scaletozero" "github.com/kernel/kernel-images/server/lib/sysmon" "github.com/kernel/kernel-images/server/lib/telemetry" + "github.com/kernel/kernel-images/server/lib/webauthnhook" "github.com/kernel/kernel-images/server/lib/wsdrain" ) @@ -143,18 +144,20 @@ func main() { // Constructed when an endpoint is provisioned, but left stopped: export is // off until turned on per-session via the telemetry API, so a VM that always // has the relay injected does not export (and get dropped) by default. + // The OTLP relay and the metro API authenticate the VM by its instance JWT, + // sent as a bearer token. Identity is resolved dynamically: on a forked VM + // the boot env is stale, so it is re-read from the applied fork-identity + // payload (see instanceIdentityProvider). + identity := newInstanceIdentityProvider(instanceIdentity{ + jwt: config.InstanceJWT, + instanceName: config.InstanceName, + metro: config.MetroName, + metroAPIURL: config.MetroAPIBaseURL, + }, slogger) + var otlpExporter api.OTLPExporter var otlpMetrics *events.OTLPMetrics if config.OTLPEndpoint != "" { - // The relay authenticates the VM by its instance JWT, sent as a bearer - // token. Identity (JWT + resource attrs) is resolved dynamically: on a - // forked VM the boot env is stale, so it is re-read from the applied - // fork-identity payload (see otlpIdentityProvider). - identity := newOTLPIdentityProvider(otlpIdentity{ - jwt: config.InstanceJWT, - instanceName: config.InstanceName, - metro: config.MetroName, - }, slogger) slogger.Info("OTLP export available", "endpoint", config.OTLPEndpoint, "path", config.OTLPPath) // The OTel log SDK reports batch-queue drops through its global logger at // logr V(1), which slog renders just below Info, so a default Info handler @@ -207,6 +210,16 @@ func main() { otlpExporter, s2Storage, egressPolicy, + webauthnhook.Config{ + StatePath: webauthnhook.DefaultStatePath, + Signer: &webauthnhook.MetroSigner{ + BaseURL: identity.MetroAPIURL, + Token: identity.Token, + Client: &http.Client{}, + Logger: slogger, + }, + Logger: slogger, + }, ) if err != nil { slogger.Error("failed to create api service", "err", err) @@ -217,6 +230,7 @@ func main() { slogger.Error("failed to start network monitor", "err", err) os.Exit(1) } + apiService.StartWebAuthnHook() // api_call event emission. Off until the telemetry handlers flip it on. r.Use(api.TelemetryHTTPMiddleware(telemetrySession.Publish)) @@ -500,7 +514,7 @@ func (r *s2StreamResolver) RecordAppliedPayload(payload forkidentity.Payload) { // this instance may bind yet, so the result is empty; pending reports that case. // Once one is applied only its own stream counts, never the boot env's. // -// OTLP resolves its identity per use instead (otlpIdentityProvider), so a stale +// OTLP resolves its identity per use instead (instanceIdentityProvider), so a stale // read there self-corrects; an S2 append session binds once, so this read has to // be right the first time. func appliedS2Stream(cfg *config.Config) (stream string, pending bool) { diff --git a/server/cmd/config/config.go b/server/cmd/config/config.go index 70cce9142..068f01247 100644 --- a/server/cmd/config/config.go +++ b/server/cmd/config/config.go @@ -75,6 +75,9 @@ type Config struct { InstanceJWT string `envconfig:"KERNEL_INSTANCE_JWT" default:""` InstanceName string `envconfig:"INST_NAME" default:""` MetroName string `envconfig:"METRO_NAME" default:""` + + // Metro API base URL, which answers the WebAuthn page hook's requests. + MetroAPIBaseURL string `envconfig:"KERNEL_METRO_API_BASE_URL" default:""` } // LogValue implements slog.LogValuer, redacting secret fields. @@ -116,6 +119,7 @@ func (c *Config) LogValue() slog.Value { slog.Duration("otlp_export_timeout", c.OTLPExportTimeout), slog.String("otlp_instance_name", c.InstanceName), slog.String("otlp_metro", c.MetroName), + slog.String("metro_api_base_url", c.MetroAPIBaseURL), ) } diff --git a/server/lib/events/category_gen.go b/server/lib/events/category_gen.go index 80e772eb8..164b06ed4 100644 --- a/server/lib/events/category_gen.go +++ b/server/lib/events/category_gen.go @@ -97,6 +97,7 @@ var categoryByOperationID = map[string]oapi.TelemetryEventCategory{ "PublishTelemetryEvent": oapi.TelemetryEventCategory("platform"), "PutNetworkEgressPolicy": oapi.TelemetryEventCategory("platform"), "PutTelemetry": oapi.TelemetryEventCategory("platform"), + "PutWebAuthnHook": oapi.TelemetryEventCategory("platform"), "ReadClipboard": oapi.TelemetryEventCategory("control"), "ReadFile": oapi.TelemetryEventCategory("platform"), "RemoveCustomWebMCPTool": oapi.TelemetryEventCategory("control"), diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index 22194a0c1..35b48177d 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -6976,6 +6976,11 @@ type TypeTextRequest struct { TypoChance *float32 `json:"typo_chance,omitempty"` } +// WebAuthnHook Whether the WebAuthn page hook is installed in new documents. +type WebAuthnHook struct { + Enabled bool `json:"enabled"` +} + // WebMCPCustomToolSource defines model for WebMCPCustomToolSource. type WebMCPCustomToolSource struct { Id string `json:"id"` @@ -7398,6 +7403,9 @@ type PutTelemetryJSONRequestBody = BrowserTelemetryConfig // PublishTelemetryEventJSONRequestBody defines body for PublishTelemetryEvent for application/json ContentType. type PublishTelemetryEventJSONRequestBody = PublishEventRequest +// PutWebAuthnHookJSONRequestBody defines body for PutWebAuthnHook for application/json ContentType. +type PutWebAuthnHookJSONRequestBody = WebAuthnHook + // AddCustomWebMCPToolsJSONRequestBody defines body for AddCustomWebMCPTools for application/json ContentType. type AddCustomWebMCPToolsJSONRequestBody = AddCustomWebMCPToolsRequest @@ -10098,6 +10106,11 @@ type ClientInterface interface { // StreamTelemetryEvents request StreamTelemetryEvents(ctx context.Context, params *StreamTelemetryEventsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // PutWebAuthnHookWithBody request with any body + PutWebAuthnHookWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PutWebAuthnHook(ctx context.Context, body PutWebAuthnHookJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // ListCustomWebMCPTools request ListCustomWebMCPTools(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -11270,6 +11283,30 @@ func (c *Client) StreamTelemetryEvents(ctx context.Context, params *StreamTeleme return c.Client.Do(req) } +func (c *Client) PutWebAuthnHookWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPutWebAuthnHookRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PutWebAuthnHook(ctx context.Context, body PutWebAuthnHookJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPutWebAuthnHookRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) ListCustomWebMCPTools(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewListCustomWebMCPToolsRequest(c.Server) if err != nil { @@ -13849,6 +13886,46 @@ func NewStreamTelemetryEventsRequest(server string, params *StreamTelemetryEvent return req, nil } +// NewPutWebAuthnHookRequest calls the generic PutWebAuthnHook builder with application/json body +func NewPutWebAuthnHookRequest(server string, body PutWebAuthnHookJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPutWebAuthnHookRequestWithBody(server, "application/json", bodyReader) +} + +// NewPutWebAuthnHookRequestWithBody generates requests for PutWebAuthnHook with any type of body +func NewPutWebAuthnHookRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/webauthn/hook") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("PUT", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewListCustomWebMCPToolsRequest generates requests for ListCustomWebMCPTools func NewListCustomWebMCPToolsRequest(server string) (*http.Request, error) { var err error @@ -14336,6 +14413,11 @@ type ClientWithResponsesInterface interface { // StreamTelemetryEventsWithResponse request StreamTelemetryEventsWithResponse(ctx context.Context, params *StreamTelemetryEventsParams, reqEditors ...RequestEditorFn) (*StreamTelemetryEventsResponse, error) + // PutWebAuthnHookWithBodyWithResponse request with any body + PutWebAuthnHookWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PutWebAuthnHookResponse, error) + + PutWebAuthnHookWithResponse(ctx context.Context, body PutWebAuthnHookJSONRequestBody, reqEditors ...RequestEditorFn) (*PutWebAuthnHookResponse, error) + // ListCustomWebMCPToolsWithResponse request ListCustomWebMCPToolsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListCustomWebMCPToolsResponse, error) @@ -15832,6 +15914,30 @@ func (r StreamTelemetryEventsResponse) StatusCode() int { return 0 } +type PutWebAuthnHookResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *WebAuthnHook + JSON400 *BadRequestError + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r PutWebAuthnHookResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r PutWebAuthnHookResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type ListCustomWebMCPToolsResponse struct { Body []byte HTTPResponse *http.Response @@ -16783,6 +16889,23 @@ func (c *ClientWithResponses) StreamTelemetryEventsWithResponse(ctx context.Cont return ParseStreamTelemetryEventsResponse(rsp) } +// PutWebAuthnHookWithBodyWithResponse request with arbitrary body returning *PutWebAuthnHookResponse +func (c *ClientWithResponses) PutWebAuthnHookWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PutWebAuthnHookResponse, error) { + rsp, err := c.PutWebAuthnHookWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePutWebAuthnHookResponse(rsp) +} + +func (c *ClientWithResponses) PutWebAuthnHookWithResponse(ctx context.Context, body PutWebAuthnHookJSONRequestBody, reqEditors ...RequestEditorFn) (*PutWebAuthnHookResponse, error) { + rsp, err := c.PutWebAuthnHook(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePutWebAuthnHookResponse(rsp) +} + // ListCustomWebMCPToolsWithResponse request returning *ListCustomWebMCPToolsResponse func (c *ClientWithResponses) ListCustomWebMCPToolsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListCustomWebMCPToolsResponse, error) { rsp, err := c.ListCustomWebMCPTools(ctx, reqEditors...) @@ -19216,6 +19339,46 @@ func ParseStreamTelemetryEventsResponse(rsp *http.Response) (*StreamTelemetryEve return response, nil } +// ParsePutWebAuthnHookResponse parses an HTTP response from a PutWebAuthnHookWithResponse call +func ParsePutWebAuthnHookResponse(rsp *http.Response) (*PutWebAuthnHookResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &PutWebAuthnHookResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest WebAuthnHook + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest BadRequestError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseListCustomWebMCPToolsResponse parses an HTTP response from a ListCustomWebMCPToolsWithResponse call func ParseListCustomWebMCPToolsResponse(rsp *http.Response) (*ListCustomWebMCPToolsResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -19611,6 +19774,9 @@ type ServerInterface interface { // Stream telemetry events as Server-Sent Events // (GET /telemetry/stream) StreamTelemetryEvents(w http.ResponseWriter, r *http.Request, params StreamTelemetryEventsParams) + // Turn the WebAuthn page hook on or off + // (PUT /webauthn/hook) + PutWebAuthnHook(w http.ResponseWriter, r *http.Request) // List custom WebMCP tools // (GET /webmcp/custom-tools) ListCustomWebMCPTools(w http.ResponseWriter, r *http.Request) @@ -20004,6 +20170,12 @@ func (_ Unimplemented) StreamTelemetryEvents(w http.ResponseWriter, r *http.Requ w.WriteHeader(http.StatusNotImplemented) } +// Turn the WebAuthn page hook on or off +// (PUT /webauthn/hook) +func (_ Unimplemented) PutWebAuthnHook(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // List custom WebMCP tools // (GET /webmcp/custom-tools) func (_ Unimplemented) ListCustomWebMCPTools(w http.ResponseWriter, r *http.Request) { @@ -21250,6 +21422,20 @@ func (siw *ServerInterfaceWrapper) StreamTelemetryEvents(w http.ResponseWriter, handler.ServeHTTP(w, r) } +// PutWebAuthnHook operation middleware +func (siw *ServerInterfaceWrapper) PutWebAuthnHook(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.PutWebAuthnHook(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // ListCustomWebMCPTools operation middleware func (siw *ServerInterfaceWrapper) ListCustomWebMCPTools(w http.ResponseWriter, r *http.Request) { @@ -21643,6 +21829,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/telemetry/stream", wrapper.StreamTelemetryEvents) }) + r.Group(func(r chi.Router) { + r.Put(options.BaseURL+"/webauthn/hook", wrapper.PutWebAuthnHook) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/webmcp/custom-tools", wrapper.ListCustomWebMCPTools) }) @@ -24251,6 +24440,41 @@ func (response StreamTelemetryEvents200TexteventStreamResponse) VisitStreamTelem } } +type PutWebAuthnHookRequestObject struct { + Body *PutWebAuthnHookJSONRequestBody +} + +type PutWebAuthnHookResponseObject interface { + VisitPutWebAuthnHookResponse(w http.ResponseWriter) error +} + +type PutWebAuthnHook200JSONResponse WebAuthnHook + +func (response PutWebAuthnHook200JSONResponse) VisitPutWebAuthnHookResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PutWebAuthnHook400JSONResponse struct{ BadRequestErrorJSONResponse } + +func (response PutWebAuthnHook400JSONResponse) VisitPutWebAuthnHookResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type PutWebAuthnHook500JSONResponse struct{ InternalErrorJSONResponse } + +func (response PutWebAuthnHook500JSONResponse) VisitPutWebAuthnHookResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + type ListCustomWebMCPToolsRequestObject struct { } @@ -24630,6 +24854,9 @@ type StrictServerInterface interface { // Stream telemetry events as Server-Sent Events // (GET /telemetry/stream) StreamTelemetryEvents(ctx context.Context, request StreamTelemetryEventsRequestObject) (StreamTelemetryEventsResponseObject, error) + // Turn the WebAuthn page hook on or off + // (PUT /webauthn/hook) + PutWebAuthnHook(ctx context.Context, request PutWebAuthnHookRequestObject) (PutWebAuthnHookResponseObject, error) // List custom WebMCP tools // (GET /webmcp/custom-tools) ListCustomWebMCPTools(ctx context.Context, request ListCustomWebMCPToolsRequestObject) (ListCustomWebMCPToolsResponseObject, error) @@ -26498,6 +26725,37 @@ func (sh *strictHandler) StreamTelemetryEvents(w http.ResponseWriter, r *http.Re } } +// PutWebAuthnHook operation middleware +func (sh *strictHandler) PutWebAuthnHook(w http.ResponseWriter, r *http.Request) { + var request PutWebAuthnHookRequestObject + + var body PutWebAuthnHookJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.PutWebAuthnHook(ctx, request.(PutWebAuthnHookRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PutWebAuthnHook") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(PutWebAuthnHookResponseObject); ok { + if err := validResponse.VisitPutWebAuthnHookResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // ListCustomWebMCPTools operation middleware func (sh *strictHandler) ListCustomWebMCPTools(w http.ResponseWriter, r *http.Request) { var request ListCustomWebMCPToolsRequestObject @@ -26639,51 +26897,51 @@ func (sh *strictHandler) GetWebMCPTools(w http.ResponseWriter, r *http.Request, // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", @@ -26697,407 +26955,407 @@ var swaggerSpec = []string{ "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", @@ -27105,244 +27363,251 @@ var swaggerSpec = []string{ "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", + "w+tJO9Zm+vjx/z8AAFvfSQzbAwA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/lib/webauthnhook/chrome_e2e_test.go b/server/lib/webauthnhook/chrome_e2e_test.go new file mode 100644 index 000000000..0fcb0791e --- /dev/null +++ b/server/lib/webauthnhook/chrome_e2e_test.go @@ -0,0 +1,908 @@ +package webauthnhook + +import ( + "context" + "crypto/ecdsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/kernel/kernel-images/server/lib/cdpclient" + "github.com/kernel/kernel-images/server/lib/cdpmonitor" + "github.com/kernel/kernel-images/server/lib/events" + "github.com/stretchr/testify/require" +) + +// TestChromiumPageHook drives the hook through a real Chromium over its own +// DevTools connection, answered by a fake metro-api that signs with ES256. A +// telemetry monitor runs alongside it on a separate connection. +// Sites are served over HTTPS under example.test and other.test, so the +// cross-site iframe runs out of process. +// +// Opt-in: set KERNEL_WEBAUTHN_CHROME_E2E=1 with a chromium binary on PATH. +// The fallback cases each wait out the hook's 10-second window. +func TestChromiumPageHook(t *testing.T) { + if os.Getenv("KERNEL_WEBAUTHN_CHROME_E2E") == "" { + t.Skip("set KERNEL_WEBAUTHN_CHROME_E2E=1 to run the real-Chromium WebAuthn hook test") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + env := newChromeEnv(t, ctx) + login := env.site("login.example.test") + + var registered pageCredential + t.Run("create registers a passkey through the signer", func(t *testing.T) { + tab := env.open(t, login+"/page") + result := tab.settle(t, createCall) + require.Empty(t, result.Error) + registered = result.Credential + requireHookCredential(t, registered, "attestation") + require.Equal(t, -7, registered.Algorithm) + require.Equal(t, fakeTransports, registered.Transports) + + request := env.metro.last(t) + require.Equal(t, "create", request.op) + require.Equal(t, "Bearer instance-jwt", request.auth) + require.Equal(t, login, request.body.Origin) + require.Equal(t, login, request.body.TopOrigin) + require.Empty(t, request.body.Mediation) + var options struct { + Challenge string `json:"challenge"` + User struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"user"` + PubKeyCredParams []struct { + Alg int `json:"alg"` + } `json:"pubKeyCredParams"` + } + require.NoError(t, json.Unmarshal(request.body.PublicKey, &options)) + require.Equal(t, b64(challenge), options.Challenge) + require.Equal(t, b64(userID), options.User.ID) + require.Equal(t, "alice", options.User.Name) + require.Equal(t, -7, options.PubKeyCredParams[0].Alg) + + require.JSONEq(t, string(env.metro.lastCredential()), string(registered.JSON)) + verifyAttestation(t, registered, "login.example.test") + }) + + t.Run("get signs in with the registered passkey", func(t *testing.T) { + tab := env.open(t, login+"/page") + result := tab.settle(t, pageGet(registered.ID)) + require.Empty(t, result.Error) + requireHookCredential(t, result.Credential, "assertion") + require.Equal(t, registered.ID, result.Credential.ID) + require.Equal(t, b64(userID), result.Credential.UserHandle) + require.JSONEq(t, string(env.metro.lastCredential()), string(result.Credential.JSON)) + verifyAssertion(t, registered, result.Credential, "login.example.test", login, false) + + request := env.metro.last(t) + require.Equal(t, "get", request.op) + require.Equal(t, login, request.body.Origin) + require.Contains(t, string(request.body.PublicKey), `"id":"`+registered.ID+`"`) + }) + + t.Run("conditional get at page load is answered", func(t *testing.T) { + before := env.metro.count() + tab := env.open(t, login+"/conditional") + result := tab.settle(t, "window.conditional") + require.Empty(t, result.Error) + requireHookCredential(t, result.Credential, "assertion") + require.Equal(t, before+1, env.metro.count()) + require.Equal(t, "conditional", env.metro.last(t).body.Mediation) + }) + + // Decision pinned here: targets are not paused on attach, so a new tab's + // first document runs its load-time script before the hook is installed. + // The call goes to the browser; a reload is answered. + t.Run("a new tab's first document is unhooked at load, and a reload is answered", func(t *testing.T) { + before := env.metro.count() + tab := env.openDirect(t, login+"/conditional") + tab.waitHooked(t) + require.JSONEq(t, `"pending"`, string(tab.eval(t, + `Promise.race([window.conditional.then(() => 'answered', () => 'rejected'), new Promise((r) => setTimeout(() => r('pending'), 500))])`))) + require.Equal(t, before, env.metro.count()) + + tab.env.driver.call(t, tab.sessionID, "Page.reload", nil) + require.Eventually(t, func() bool { + value, err := tab.evalRaw(`document.readyState === 'complete' && typeof window.conditional === 'object'`) + return err == nil && string(value) == "true" + }, 15*time.Second, 50*time.Millisecond) + result := tab.settle(t, "window.conditional") + require.Empty(t, result.Error) + requireHookCredential(t, result.Credential, "assertion") + require.Equal(t, before+1, env.metro.count()) + }) + + t.Run("capabilities report a platform passkey authenticator", func(t *testing.T) { + tab := env.open(t, login+"/page") + value := tab.eval(t, `Promise.all([ + PublicKeyCredential.isConditionalMediationAvailable(), + PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(), + PublicKeyCredential.getClientCapabilities(), + ]).then(([conditional, uvpa, capabilities]) => ({conditional, uvpa, capabilities}))`) + var capabilities struct { + Conditional bool `json:"conditional"` + UVPA bool `json:"uvpa"` + Capabilities map[string]bool `json:"capabilities"` + } + require.NoError(t, json.Unmarshal(value, &capabilities)) + require.True(t, capabilities.Conditional) + require.True(t, capabilities.UVPA) + for _, key := range []string{"conditionalGet", "conditionalCreate", "passkeyPlatformAuthenticator", "userVerifyingPlatformAuthenticator"} { + require.True(t, capabilities.Capabilities[key], key) + } + }) + + t.Run("an error answer rejects with that DOMException", func(t *testing.T) { + env.metro.setMode("error") + defer env.metro.setMode("sign") + tab := env.open(t, login+"/page") + result := tab.settle(t, createCall) + require.Equal(t, "InvalidStateError", result.Error) + require.Equal(t, "credential already registered", result.Message) + require.True(t, result.DOMException) + }) + + t.Run("not_handled falls back to the browser", func(t *testing.T) { + env.metro.setMode("not_handled") + defer env.metro.setMode("sign") + tab := env.open(t, login+"/page") + tab.addVirtualAuthenticator(t) + before := env.metro.count() + result := tab.settle(t, createCall) + require.Empty(t, result.Error) + require.False(t, result.Credential.Own, "the browser's own credential has no own properties") + require.True(t, result.Credential.Instance) + require.Equal(t, before+1, env.metro.count()) + }) + + t.Run("non-publicKey requests go straight to the browser", func(t *testing.T) { + tab := env.open(t, login+"/page") + before := env.metro.count() + require.JSONEq(t, `"null"`, string(tab.eval(t, `navigator.credentials.get({password: true, mediation: 'silent'}).then(String)`))) + require.JSONEq(t, `"[object PasswordCredential]"`, string(tab.eval(t, + `navigator.credentials.create({password: {id: 'alice', password: 'secret'}}).then(c => Object.prototype.toString.call(c))`))) + require.Equal(t, before, env.metro.count()) + }) + + t.Run("an aborted signal rejects without asking the signer", func(t *testing.T) { + tab := env.open(t, login+"/page") + before := env.metro.count() + result := tab.settle(t, `(() => { + const controller = new AbortController(); + controller.abort(); + return navigator.credentials.get({signal: controller.signal, publicKey: {challenge: new Uint8Array(32)}}); + })()`) + require.Equal(t, "AbortError", result.Error) + require.Equal(t, before, env.metro.count()) + }) + + t.Run("a cross-site iframe sends its own origin and the top origin", func(t *testing.T) { + widget := env.site("widget.other.test") + tab := env.open(t, login+"/page?frame="+url.QueryEscape(widget+"/page")+"&allow=publickey-credentials-get") + frame := tab.oopif(t, widget) + result := frame.settle(t, pageGet(registered.ID)) + require.Empty(t, result.Error) + requireHookCredential(t, result.Credential, "assertion") + request := env.metro.last(t) + require.Equal(t, widget, request.body.Origin) + require.Equal(t, login, request.body.TopOrigin) + verifyAssertion(t, registered, result.Credential, "widget.other.test", widget, true) + }) + + t.Run("a same-site cross-origin iframe sends its own origin", func(t *testing.T) { + other := env.site("other.example.test") + tab := env.open(t, login+"/page?frame="+url.QueryEscape(other+"/page")+"&allow=publickey-credentials-get") + frame := tab.childFrame(t, other) + result := frame.settle(t, pageGet(registered.ID)) + require.Empty(t, result.Error) + request := env.metro.last(t) + require.Equal(t, other, request.body.Origin) + require.Equal(t, login, request.body.TopOrigin) + }) + + t.Run("an iframe without permission is left to the browser", func(t *testing.T) { + widget := env.site("widget.other.test") + tab := env.open(t, login+"/page?frame="+url.QueryEscape(widget+"/page")) + frame := tab.oopif(t, widget) + before := env.metro.count() + result := frame.settle(t, pageGet(registered.ID)) + require.Equal(t, "NotAllowedError", result.Error) + require.Equal(t, before, env.metro.count()) + }) + + t.Run("telemetry capture on and off leaves the hook answering", func(t *testing.T) { + tab := env.open(t, login+"/page") + interaction := func(want string) { + require.Eventually(t, func() bool { + value, err := tab.evalRaw(`window.__kernelEventInjected === true`) + return err == nil && string(value) == want + }, 15*time.Second, 50*time.Millisecond, "telemetry interaction capture did not reach %s", want) + } + answers := func() { + result := tab.settle(t, pageGet(registered.ID)) + require.Empty(t, result.Error) + requireHookCredential(t, result.Credential, "assertion") + } + + require.NoError(t, env.monitor.SetTelemetry(true)) + interaction("true") + answers() + + // Telemetry cleanup disables Runtime and Page on its own sessions. + require.NoError(t, env.monitor.SetTelemetry(false)) + interaction("false") + answers() + tab.navigate(t, login+"/page?after-telemetry") + tab.waitHooked(t) + answers() + }) + + t.Run("disabling suspends hooked documents and stops new ones from getting the hook", func(t *testing.T) { + tab := env.open(t, login+"/page") + require.NoError(t, env.hook.Set(ctx, false)) + defer func() { require.NoError(t, env.hook.Set(ctx, true)) }() + // Chromium drops a tab's virtual authenticators when another DevTools + // session on it detaches, so add one only after the hook's has. + tab.addVirtualAuthenticator(t) + + before := env.metro.count() + started := time.Now() + result := tab.settle(t, createCall) + require.Empty(t, result.Error) + require.False(t, result.Credential.Own, "an already-hooked document falls back to the browser") + require.Less(t, time.Since(started), 2*time.Second, "a suspended document falls back without waiting") + require.Equal(t, before, env.metro.count()) + + tab.navigate(t, login+"/page?reloaded") + require.JSONEq(t, `false`, string(tab.eval(t, hookedExpression))) + require.JSONEq(t, `true`, string(tab.eval(t, `CredentialsContainer.prototype.get.toString().includes('[native code]')`))) + }) + + t.Run("re-enabling resumes a suspended document", func(t *testing.T) { + tab := env.open(t, login+"/page") + require.NoError(t, env.hook.Set(ctx, false)) + require.NoError(t, env.hook.Set(ctx, true)) + result := tab.settle(t, pageGet(registered.ID)) + require.Empty(t, result.Error) + requireHookCredential(t, result.Credential, "assertion") + }) + + t.Run("a missing binding falls back after three seconds", func(t *testing.T) { + tab := env.open(t, login+"/page") + tab.addVirtualAuthenticator(t) + tab.eval(t, `delete window.__kernelPasskey`) + started := time.Now() + result := tab.settle(t, createCall) + require.Empty(t, result.Error) + require.False(t, result.Credential.Own) + require.GreaterOrEqual(t, time.Since(started), 3*time.Second) + require.Less(t, time.Since(started), 6*time.Second) + }) + + t.Run("an abort stops kernel-images asking metro-api", func(t *testing.T) { + env.metro.setMode("unavailable") + defer env.metro.setMode("sign") + tab := env.open(t, login+"/page") + before := env.metro.count() + result := tab.settle(t, `(() => { + const controller = new AbortController(); + const promise = navigator.credentials.get({signal: controller.signal, publicKey: {challenge: new Uint8Array(32)}}); + setTimeout(() => controller.abort(), 300); + return promise; + })()`) + require.Equal(t, "AbortError", result.Error) + attempts := env.metro.count() - before + time.Sleep(3 * time.Second) + require.Equal(t, attempts, env.metro.count()-before, "an aborted request stops retrying") + }) + + t.Run("an unavailable signer falls back after the window", func(t *testing.T) { + env.metro.setMode("unavailable") + defer env.metro.setMode("sign") + tab := env.open(t, login+"/page") + tab.addVirtualAuthenticator(t) + started := time.Now() + result := tab.settle(t, createCall) + require.Empty(t, result.Error) + require.False(t, result.Credential.Own) + require.GreaterOrEqual(t, time.Since(started), signerWindow) + }) + + t.Run("a new upstream URL re-dials and the same document is answered", func(t *testing.T) { + tab := env.open(t, login+"/page") + before := env.hook.currentConnection() + env.upstream.set(env.browser, true) + require.Eventually(t, func() bool { + conn := env.hook.currentConnection() + return conn != nil && conn != before + }, 15*time.Second, 50*time.Millisecond, "the hook did not reconnect") + result := tab.settle(t, pageGet(registered.ID)) + require.Empty(t, result.Error) + requireHookCredential(t, result.Credential, "assertion") + }) + + t.Run("a lost connection falls back, and a new one answers the same document", func(t *testing.T) { + tab := env.open(t, login+"/page") + env.upstream.set("", false) + _ = env.hook.currentConnection().protocol.Close() + require.Eventually(t, func() bool { return env.hook.currentConnection() == nil }, 5*time.Second, 50*time.Millisecond) + tab.addVirtualAuthenticator(t) + before := env.metro.count() + started := time.Now() + result := tab.settle(t, createCall) + require.Empty(t, result.Error) + require.False(t, result.Credential.Own) + require.GreaterOrEqual(t, time.Since(started), 10*time.Second) + require.Equal(t, before, env.metro.count()) + + env.upstream.set(env.browser, true) + result = tab.settle(t, pageGet(registered.ID)) + require.Empty(t, result.Error) + requireHookCredential(t, result.Credential, "assertion") + }) +} + +var ( + challenge = []byte("0123456789abcdef0123456789abcdef") + userID = []byte{1, 2, 3, 4} +) + +const hookedExpression = `Object.prototype.hasOwnProperty.call(window, '__kernelPasskeyReply')` + +var createCall = fmt.Sprintf(`navigator.credentials.create({publicKey: { + challenge: unb64(%q), + rp: {name: 'Example'}, + user: {id: unb64(%q), name: 'alice', displayName: 'Alice'}, + pubKeyCredParams: [{type: 'public-key', alg: -7}], +}})`, b64(challenge), b64(userID)) + +func pageGet(credentialID string) string { + return fmt.Sprintf(`navigator.credentials.get({publicKey: { + challenge: unb64(%q), + allowCredentials: [{type: 'public-key', id: unb64(%q)}], + }})`, b64(challenge), credentialID) +} + +const pageHelpers = `` + +type pageCredential struct { + Instance bool `json:"instance"` + Tag string `json:"tag"` + Own bool `json:"own"` + JSON json.RawMessage `json:"json"` + ID string `json:"id"` + Type string `json:"type"` + Attachment string `json:"attachment"` + RawID string `json:"rawId"` + Extensions map[string]any `json:"extensions"` + ClientDataJSON string `json:"clientDataJSON"` + Kind string `json:"kind"` + AttestationObject string `json:"attestationObject"` + AuthenticatorData string `json:"authenticatorData"` + PublicKey string `json:"publicKey"` + Algorithm int `json:"algorithm"` + Transports []string `json:"transports"` + Signature string `json:"signature"` + UserHandle string `json:"userHandle"` +} + +type pageResult struct { + Credential pageCredential `json:"credential"` + Error string `json:"error"` + Message string `json:"message"` + DOMException bool `json:"dom"` +} + +// requireHookCredential checks a credential built by the hook: native +// prototypes, and toJSON agreeing with every buffer the page can read. +func requireHookCredential(t *testing.T, c pageCredential, kind string) { + t.Helper() + require.True(t, c.Own, "expected a credential from the hook, not the browser") + require.True(t, c.Instance) + require.Equal(t, "[object PublicKeyCredential]", c.Tag) + require.Equal(t, kind, c.Kind) + require.Equal(t, "public-key", c.Type) + require.Equal(t, "platform", c.Attachment) + require.Empty(t, c.Extensions) + require.Equal(t, c.ID, c.RawID) + + var round struct { + ID string `json:"id"` + RawID string `json:"rawId"` + Type string `json:"type"` + Response map[string]any `json:"response"` + Attach string `json:"authenticatorAttachment"` + Ext map[string]any `json:"clientExtensionResults"` + } + require.NoError(t, json.Unmarshal(c.JSON, &round)) + require.Equal(t, c.ID, round.ID) + require.Equal(t, c.RawID, round.RawID) + require.Equal(t, "public-key", round.Type) + require.Equal(t, "platform", round.Attach) + require.Equal(t, c.ClientDataJSON, round.Response["clientDataJSON"]) + require.Equal(t, c.AuthenticatorData, round.Response["authenticatorData"]) + if kind == "attestation" { + require.Equal(t, c.AttestationObject, round.Response["attestationObject"]) + require.Equal(t, c.PublicKey, round.Response["publicKey"]) + } else { + require.Equal(t, c.Signature, round.Response["signature"]) + require.Equal(t, c.UserHandle, round.Response["userHandle"]) + } +} + +func verifyAttestation(t *testing.T, c pageCredential, rpID string) { + t.Helper() + object, err := cborDecode(unb64(t, c.AttestationObject)) + require.NoError(t, err) + attestation := object.(map[any]any) + require.Equal(t, "none", attestation["fmt"]) + require.Empty(t, attestation["attStmt"]) + authData := attestation["authData"].([]byte) + require.Equal(t, unb64(t, c.AuthenticatorData), authData) + + rpHash := sha256.Sum256([]byte(rpID)) + require.Equal(t, rpHash[:], authData[:32]) + require.Equal(t, byte(flagUP|flagUV|flagBE|flagBS|flagAT), authData[32]) + require.Zero(t, binary.BigEndian.Uint32(authData[33:37])) + require.Equal(t, make([]byte, 16), authData[37:53]) + idLen := int(binary.BigEndian.Uint16(authData[53:55])) + require.Equal(t, unb64(t, c.RawID), authData[55:55+idLen]) + + key, err := cborDecode(authData[55+idLen:]) + require.NoError(t, err) + cose := key.(map[any]any) + public := parsePublicKey(t, c.PublicKey) + require.Equal(t, int64(2), cose[int64(1)]) + require.Equal(t, int64(-7), cose[int64(3)]) + require.Equal(t, public.X.FillBytes(make([]byte, 32)), cose[int64(-2)]) + require.Equal(t, public.Y.FillBytes(make([]byte, 32)), cose[int64(-3)]) + + var clientData map[string]any + require.NoError(t, json.Unmarshal(unb64(t, c.ClientDataJSON), &clientData)) + require.Equal(t, "webauthn.create", clientData["type"]) + require.Equal(t, b64(challenge), clientData["challenge"]) +} + +func verifyAssertion(t *testing.T, registered, c pageCredential, rpID, origin string, crossOrigin bool) { + t.Helper() + authData := unb64(t, c.AuthenticatorData) + rpHash := sha256.Sum256([]byte(rpID)) + require.Equal(t, rpHash[:], authData[:32]) + require.Equal(t, byte(flagUP|flagUV|flagBE|flagBS), authData[32]) + require.Zero(t, binary.BigEndian.Uint32(authData[33:37])) + + clientDataJSON := unb64(t, c.ClientDataJSON) + var clientData map[string]any + require.NoError(t, json.Unmarshal(clientDataJSON, &clientData)) + require.Equal(t, "webauthn.get", clientData["type"]) + require.Equal(t, origin, clientData["origin"]) + require.Equal(t, crossOrigin, clientData["crossOrigin"]) + + clientHash := sha256.Sum256(clientDataJSON) + digest := sha256.Sum256(append(authData, clientHash[:]...)) + require.True(t, ecdsa.VerifyASN1(parsePublicKey(t, registered.PublicKey), digest[:], unb64(t, c.Signature)), "assertion signature does not verify") +} + +func parsePublicKey(t *testing.T, spki string) *ecdsa.PublicKey { + t.Helper() + key, err := x509.ParsePKIXPublicKey(unb64(t, spki)) + require.NoError(t, err) + return key.(*ecdsa.PublicKey) +} + +func b64(data []byte) string { return base64.RawURLEncoding.EncodeToString(data) } + +func unb64(t *testing.T, text string) []byte { + t.Helper() + data, err := base64.RawURLEncoding.DecodeString(text) + require.NoError(t, err) + return data +} + +// --- environment --- + +type chromeEnv struct { + ctx context.Context + port string + browser string + driver *driver + metro *fakeMetro + monitor *cdpmonitor.Monitor + upstream *switchableUpstream + hook *Hook +} + +func newChromeEnv(t *testing.T, ctx context.Context) *chromeEnv { + sites := httptest.NewTLSServer(http.HandlerFunc(serveSite)) + t.Cleanup(sites.Close) + siteURL, err := url.Parse(sites.URL) + require.NoError(t, err) + + metro := newFakeMetro() + metroServer := httptest.NewServer(metro) + t.Cleanup(metroServer.Close) + + browser := launchChromium(t, ctx, "--ignore-certificate-errors", "--host-resolver-rules=MAP *.test 127.0.0.1") + monitor := cdpmonitor.New(&switchableUpstream{url: browser}, func(events.Event) (events.Envelope, bool) { return events.Envelope{}, false }, 0, discard, nil) + require.NoError(t, monitor.SetTelemetry(false)) + require.NoError(t, monitor.Start(ctx)) + t.Cleanup(monitor.Stop) + + upstream := &switchableUpstream{url: browser} + hook := New(Config{ + StatePath: filepath.Join(t.TempDir(), "webauthn-hook.json"), + Signer: &MetroSigner{ + BaseURL: func() string { return metroServer.URL }, + Token: func() string { return "instance-jwt" }, + Client: metroServer.Client(), + Logger: discard, + }, + Logger: discard, + }, upstream) + hook.Start(ctx) + t.Cleanup(hook.Stop) + require.NoError(t, hook.Set(ctx, true)) + + return &chromeEnv{ + ctx: ctx, port: siteURL.Port(), browser: browser, driver: dialDriver(t, ctx, browser), + metro: metro, monitor: monitor, upstream: upstream, hook: hook, + } +} + +func (e *chromeEnv) site(host string) string { + return "https://" + host + ":" + e.port +} + +func serveSite(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Header().Set("Cache-Control", "no-store") + body := pageHelpers + switch r.URL.Path { + case "/page": + if frame := r.URL.Query().Get("frame"); frame != "" { + body += fmt.Sprintf(``, frame, r.URL.Query().Get("allow")) + } + case "/conditional": + body += `` + default: + http.NotFound(w, r) + return + } + _, _ = io.WriteString(w, ""+body+"") +} + +type tab struct { + env *chromeEnv + sessionID string + contextID int +} + +func (e *chromeEnv) open(t *testing.T, pageURL string) *tab { + t.Helper() + // Start on a page the hook reaches through runImmediately, so its + // new-document registration exists before the real navigation. + tb := e.openDirect(t, e.site("login.example.test")+"/page") + tb.waitHooked(t) + tb.navigate(t, pageURL) + tb.waitHooked(t) + return tb +} + +// openDirect opens a tab on pageURL, racing the hook's attachment as a new +// tab would. +func (e *chromeEnv) openDirect(t *testing.T, pageURL string) *tab { + t.Helper() + raw := e.driver.call(t, "", "Target.createTarget", map[string]any{"url": pageURL}) + var target struct { + TargetID string `json:"targetId"` + } + require.NoError(t, json.Unmarshal(raw, &target)) + t.Cleanup(func() { + _, _ = e.driver.client.Send(context.Background(), "Target.closeTarget", map[string]any{"targetId": target.TargetID}, "") + }) + tb := &tab{env: e, sessionID: e.driver.attach(t, target.TargetID)} + tb.waitLoaded(t, pageURL) + return tb +} + +func (tb *tab) navigate(t *testing.T, pageURL string) { + t.Helper() + tb.env.driver.call(t, tb.sessionID, "Page.navigate", map[string]any{"url": pageURL}) + tb.waitLoaded(t, pageURL) +} + +func (tb *tab) waitLoaded(t *testing.T, pageURL string) { + t.Helper() + require.Eventually(t, func() bool { + var state string + value, err := tb.evalRaw("document.readyState === 'complete' && location.href") + return err == nil && json.Unmarshal(value, &state) == nil && state == pageURL + }, 15*time.Second, 50*time.Millisecond, "page %s did not load", pageURL) +} + +func (tb *tab) waitHooked(t *testing.T) { + t.Helper() + require.Eventually(t, func() bool { + value, err := tb.evalRaw(hookedExpression) + return err == nil && string(value) == "true" + }, 15*time.Second, 50*time.Millisecond, "the hook was not installed") +} + +func (tb *tab) evalRaw(expression string) (json.RawMessage, error) { + params := map[string]any{"expression": expression, "awaitPromise": true, "returnByValue": true} + if tb.contextID != 0 { + params["contextId"] = tb.contextID + } + ctx, cancel := context.WithTimeout(tb.env.ctx, 30*time.Second) + defer cancel() + raw, err := tb.env.driver.client.Send(ctx, "Runtime.evaluate", params, tb.sessionID) + if err != nil { + return nil, err + } + var result struct { + Result struct { + Value json.RawMessage `json:"value"` + } `json:"result"` + ExceptionDetails json.RawMessage `json:"exceptionDetails"` + } + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + if result.ExceptionDetails != nil { + return nil, fmt.Errorf("evaluation threw: %s", result.ExceptionDetails) + } + return result.Result.Value, nil +} + +func (tb *tab) eval(t *testing.T, expression string) json.RawMessage { + t.Helper() + value, err := tb.evalRaw(expression) + require.NoError(t, err) + return value +} + +func (tb *tab) settle(t *testing.T, expression string) pageResult { + t.Helper() + var result pageResult + require.NoError(t, json.Unmarshal(tb.eval(t, "settle("+expression+")"), &result)) + return result +} + +func (tb *tab) addVirtualAuthenticator(t *testing.T) { + t.Helper() + tb.env.driver.call(t, tb.sessionID, "WebAuthn.enable", nil) + tb.env.driver.call(t, tb.sessionID, "WebAuthn.addVirtualAuthenticator", map[string]any{"options": map[string]any{ + "protocol": "ctap2", "transport": "internal", "hasResidentKey": true, "hasUserVerification": true, + "isUserVerified": true, "automaticPresenceSimulation": true, + }}) +} + +// oopif attaches to the out-of-process iframe showing origin. +func (tb *tab) oopif(t *testing.T, origin string) *tab { + t.Helper() + var targetID string + require.Eventually(t, func() bool { + raw, err := tb.env.driver.client.Send(tb.env.ctx, "Target.getTargets", nil, "") + if err != nil { + return false + } + var result struct { + TargetInfos []struct { + TargetID string `json:"targetId"` + Type string `json:"type"` + URL string `json:"url"` + } `json:"targetInfos"` + } + _ = json.Unmarshal(raw, &result) + for _, target := range result.TargetInfos { + if target.Type == "iframe" && strings.HasPrefix(target.URL, origin+"/") { + targetID = target.TargetID + return true + } + } + return false + }, 15*time.Second, 50*time.Millisecond, "no out-of-process iframe for %s", origin) + frame := &tab{env: tb.env, sessionID: tb.env.driver.attach(t, targetID)} + frame.waitHooked(t) + return frame +} + +// childFrame evaluates in a same-process child frame's main world. +func (tb *tab) childFrame(t *testing.T, origin string) *tab { + t.Helper() + tb.env.driver.call(t, tb.sessionID, "Runtime.enable", nil) + frame := &tab{env: tb.env, sessionID: tb.sessionID} + require.Eventually(t, func() bool { + frame.contextID = tb.env.driver.contextFor(tb.sessionID, origin) + return frame.contextID != 0 + }, 15*time.Second, 50*time.Millisecond, "no same-process frame for %s", origin) + frame.waitHooked(t) + return frame +} + +// --- CDP driver, independent of the monitor's connection --- + +type driver struct { + ctx context.Context + client *cdpclient.Client + + mu sync.Mutex + contexts map[string]map[int]string // session -> context ID -> origin, main worlds only +} + +func dialDriver(t *testing.T, ctx context.Context, browser string) *driver { + client, err := cdpclient.DialWithEvents(ctx, browser) + require.NoError(t, err) + t.Cleanup(func() { _ = client.Close() }) + d := &driver{ctx: ctx, client: client, contexts: make(map[string]map[int]string)} + go func() { + for message := range client.Events() { + if message.Method != "Runtime.executionContextCreated" { + continue + } + var event struct { + Context struct { + ID int `json:"id"` + Origin string `json:"origin"` + AuxData struct { + IsDefault bool `json:"isDefault"` + } `json:"auxData"` + } `json:"context"` + } + if json.Unmarshal(message.Params, &event) == nil && event.Context.AuxData.IsDefault { + d.mu.Lock() + if d.contexts[message.SessionID] == nil { + d.contexts[message.SessionID] = make(map[int]string) + } + d.contexts[message.SessionID][event.Context.ID] = event.Context.Origin + d.mu.Unlock() + } + } + }() + return d +} + +func (d *driver) call(t *testing.T, sessionID, method string, params any) json.RawMessage { + t.Helper() + ctx, cancel := context.WithTimeout(d.ctx, 30*time.Second) + defer cancel() + raw, err := d.client.Send(ctx, method, params, sessionID) + require.NoError(t, err, method) + return raw +} + +func (d *driver) attach(t *testing.T, targetID string) string { + t.Helper() + var result struct { + SessionID string `json:"sessionId"` + } + require.NoError(t, json.Unmarshal(d.call(t, "", "Target.attachToTarget", map[string]any{"targetId": targetID, "flatten": true}), &result)) + return result.SessionID +} + +func (d *driver) contextFor(sessionID, origin string) int { + d.mu.Lock() + defer d.mu.Unlock() + for id, contextOrigin := range d.contexts[sessionID] { + if contextOrigin == origin { + return id + } + } + return 0 +} + +func (h *Hook) currentConnection() *connection { + h.mu.Lock() + defer h.mu.Unlock() + return h.conn +} + +var devToolsURL = regexp.MustCompile(`ws://\S+`) + +func launchChromium(t *testing.T, ctx context.Context, extraArgs ...string) string { + t.Helper() + var chrome string + for _, name := range []string{"chromium", "chromium-browser", "google-chrome", "chrome"} { + if path, err := exec.LookPath(name); err == nil { + chrome = path + break + } + } + if chrome == "" { + t.Skip("chromium not found on PATH") + } + args := append([]string{ + "--headless=new", "--no-sandbox", "--disable-gpu", + "--remote-debugging-port=0", "--user-data-dir=" + t.TempDir(), + }, extraArgs...) + cmd := exec.CommandContext(ctx, chrome, append(args, "about:blank")...) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + stderr, err := cmd.StderrPipe() + require.NoError(t, err) + require.NoError(t, cmd.Start()) + t.Cleanup(func() { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + _ = cmd.Wait() + }) + found := make(chan string, 1) + go func() { + var seen []byte + buf := make([]byte, 4096) + for { + n, err := stderr.Read(buf) + seen = append(seen, buf[:n]...) + if match := devToolsURL.Find(seen); match != nil { + found <- string(match) + _, _ = io.Copy(io.Discard, stderr) + return + } + if err != nil { + return + } + } + }() + select { + case url := <-found: + return url + case <-time.After(time.Minute): + t.Fatal("chromium did not report a DevTools URL") + return "" + } +} diff --git a/server/lib/webauthnhook/connection.go b/server/lib/webauthnhook/connection.go new file mode 100644 index 000000000..4a8f098d7 --- /dev/null +++ b/server/lib/webauthnhook/connection.go @@ -0,0 +1,194 @@ +package webauthnhook + +import ( + "context" + "time" + + "github.com/kernel/kernel-images/server/lib/browsersurface" + "github.com/kernel/kernel-images/server/lib/cdpclient" +) + +const ( + dialTimeout = 5 * time.Second + probeInterval = 5 * time.Second + probeTimeout = 5 * time.Second + reconnectMin = 250 * time.Millisecond + reconnectMax = 5 * time.Second + installWait = 15 * time.Second + installPollGap = 50 * time.Millisecond +) + +// connection is one DevTools connection and the page and iframe targets it +// has attached. Its sessions, bindings, and scripts end with it. +type connection struct { + protocol *cdpclient.Client + surface *browsersurface.Tracker + done chan struct{} // closed once its events are consumed +} + +// run keeps a connection open until ctx ends, replacing it after socket loss, +// a failed probe, or a new upstream URL (Chromium restarted). +func (h *Hook) run(ctx context.Context) { + updates, unsubscribe := h.upstream.Subscribe() + defer unsubscribe() + backoff := reconnectMin + for { + if url := h.upstream.Current(); url != "" { + conn, err := h.connect(ctx, url) + if err == nil { + backoff = reconnectMin + h.serve(ctx, conn, updates) + h.disconnect(conn) + } else if ctx.Err() == nil { + h.log.Warn("webauthn: failed to connect to Chromium", "err", err) + } + } + select { + case <-ctx.Done(): + return + case <-updates: + case <-time.After(backoff): + } + backoff = min(2*backoff, reconnectMax) + } +} + +func (h *Hook) connect(ctx context.Context, url string) (*connection, error) { + dialCtx, cancel := context.WithTimeout(ctx, dialTimeout) + defer cancel() + protocol, err := cdpclient.DialWithEvents(dialCtx, url) + if err != nil { + return nil, err + } + conn := &connection{ + protocol: protocol, + surface: browsersurface.New(protocol, browsersurface.WithoutLocations()), + done: make(chan struct{}), + } + events, unsubscribe := conn.surface.Subscribe() + go func() { + defer close(conn.done) + defer unsubscribe() + for event := range events { + h.handleEvent(conn, event) + } + }() + startCtx, cancel := context.WithTimeout(ctx, setupTimeout) + defer cancel() + if err := conn.surface.Start(startCtx); err != nil { + h.disconnect(conn) + return nil, err + } + h.mu.Lock() + h.conn = conn + h.mu.Unlock() + return conn, nil +} + +func (h *Hook) serve(ctx context.Context, conn *connection, updates <-chan string) { + probe := time.NewTicker(probeInterval) + defer probe.Stop() + for { + select { + case <-ctx.Done(): + return + case <-conn.protocol.Done(): + h.log.Warn("webauthn: DevTools connection lost; reconnecting") + return + case <-updates: + return + case <-probe.C: + // A bounded round trip also catches half-open sockets after suspend. + probeCtx, cancel := context.WithTimeout(ctx, probeTimeout) + _, err := conn.protocol.GetBrowserVersion(probeCtx) + cancel() + if err != nil && ctx.Err() == nil { + h.log.Warn("webauthn: DevTools connection unresponsive; reconnecting", "err", err) + return + } + } + } +} + +// disconnect closes conn and forgets its sessions. Unanswered requests are +// cancelled: their documents resend to the next connection. +func (h *Hook) disconnect(conn *connection) { + _ = conn.protocol.Close() + <-conn.done + h.mu.Lock() + defer h.mu.Unlock() + if h.conn == conn { + h.conn = nil + } + for _, s := range h.sessions { + for _, c := range s.calls { + if c.reply == nil { + c.cancel() + } + } + } + clear(h.sessions) +} + +func (h *Hook) handleEvent(conn *connection, event browsersurface.Event) { + switch event.Kind { + case browsersurface.EventSessionAttached: + if event.Target.Type != "page" && event.Target.Type != "iframe" { + return + } + go h.reconcile(h.track(&session{ + id: event.SessionID, targetID: event.Target.ID, targetType: event.Target.Type, + parentFrameID: event.Target.ParentFrameID, cdp: cdpSession{protocol: conn.protocol, id: event.SessionID}, + })) + case browsersurface.EventSessionRemoved: + h.mu.Lock() + delete(h.sessions, event.SessionID) + h.mu.Unlock() + case browsersurface.EventProtocol: + if event.Message.SessionID != "" { + h.protocolEvent(event.Message.SessionID, event.Message.Method, event.Message.Params) + } + } +} + +// waitInstalled waits until every page and iframe open when the connection +// started has had its first setup attempt, so a caller that turns the hook on +// can rely on documents that are already open. Without a browser there is +// nothing to wait for. +func (h *Hook) waitInstalled(ctx context.Context) { + if h.upstream.Current() == "" { + return + } + ctx, cancel := context.WithTimeout(ctx, installWait) + defer cancel() + tick := time.NewTicker(installPollGap) + defer tick.Stop() + for !h.installed() { + select { + case <-ctx.Done(): + return + case <-tick.C: + } + } +} + +func (h *Hook) installed() bool { + h.mu.Lock() + conn := h.conn + h.mu.Unlock() + if conn == nil { + return false + } + ids, ok := conn.surface.CaptureSessions() + if !ok { + return false + } + h.mu.Lock() + defer h.mu.Unlock() + for _, id := range ids { + if s := h.sessions[id]; s == nil || !s.attempted { + return false + } + } + return true +} diff --git a/server/lib/webauthnhook/fake_metro_test.go b/server/lib/webauthnhook/fake_metro_test.go new file mode 100644 index 000000000..a68589be0 --- /dev/null +++ b/server/lib/webauthnhook/fake_metro_test.go @@ -0,0 +1,283 @@ +package webauthnhook + +import ( + "bytes" + "cmp" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +const ( + flagUP = 0x01 + flagUV = 0x04 + flagBE = 0x08 + flagBS = 0x10 + flagAT = 0x40 +) + +var fakeTransports = []string{"internal", "hybrid"} + +type metroRequest struct { + op string + auth string + body Request +} + +// fakeMetro stands in for metro-api: in "sign" mode it registers one ES256 +// passkey and signs assertions with it, building client data from the +// origins it is sent. +type fakeMetro struct { + mu sync.Mutex + mode string + requests []metroRequest + credentials []json.RawMessage + key *ecdsa.PrivateKey + keyID []byte + userHandle []byte +} + +func newFakeMetro() *fakeMetro { return &fakeMetro{mode: "sign"} } + +func (f *fakeMetro) setMode(mode string) { + f.mu.Lock() + defer f.mu.Unlock() + f.mode = mode +} + +func (f *fakeMetro) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.requests) +} + +func (f *fakeMetro) last(t *testing.T) metroRequest { + t.Helper() + f.mu.Lock() + defer f.mu.Unlock() + require.NotEmpty(t, f.requests) + return f.requests[len(f.requests)-1] +} + +func (f *fakeMetro) lastCredential() json.RawMessage { + f.mu.Lock() + defer f.mu.Unlock() + return f.credentials[len(f.credentials)-1] +} + +func (f *fakeMetro) ServeHTTP(w http.ResponseWriter, r *http.Request) { + op := strings.TrimPrefix(r.URL.Path, "/webauthn/") + var body Request + if r.Method != http.MethodPost || (op != "get" && op != "create") || json.NewDecoder(r.Body).Decode(&body) != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + f.mu.Lock() + defer f.mu.Unlock() + f.requests = append(f.requests, metroRequest{op: op, auth: r.Header.Get("Authorization"), body: body}) + var answer any + switch f.mode { + case "unavailable": + http.Error(w, "unavailable", http.StatusServiceUnavailable) + return + case "not_handled": + answer = notHandled + case "error": + answer = Answer{Result: "error", Name: "InvalidStateError", Message: "credential already registered"} + default: + credential, err := f.sign(op, body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + f.credentials = append(f.credentials, credential) + answer = Answer{Result: "credential", Credential: credential} + } + _ = json.NewEncoder(w).Encode(answer) +} + +func (f *fakeMetro) sign(op string, req Request) (json.RawMessage, error) { + var options struct { + Challenge string `json:"challenge"` + RPID string `json:"rpId"` + RP struct { + ID string `json:"id"` + } `json:"rp"` + User struct { + ID string `json:"id"` + } `json:"user"` + } + if err := json.Unmarshal(req.PublicKey, &options); err != nil { + return nil, err + } + origin, err := url.Parse(req.Origin) + if err != nil { + return nil, err + } + rpID := cmp.Or(options.RPID, options.RP.ID, origin.Hostname()) + clientData := map[string]any{"type": "webauthn." + op, "challenge": options.Challenge, "origin": req.Origin, "crossOrigin": req.Origin != req.TopOrigin} + if req.Origin != req.TopOrigin { + clientData["topOrigin"] = req.TopOrigin + } + clientDataJSON, _ := json.Marshal(clientData) + rpHash := sha256.Sum256([]byte(rpID)) + authData := append(rpHash[:], flagUP|flagUV|flagBE|flagBS, 0, 0, 0, 0) + + if op == "create" { + f.key, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + f.keyID = make([]byte, 32) + _, _ = rand.Read(f.keyID) + if f.userHandle, err = base64.RawURLEncoding.DecodeString(options.User.ID); err != nil { + return nil, err + } + cose := cborMap([][2][]byte{ + {cborInt(1), cborInt(2)}, + {cborInt(3), cborInt(-7)}, + {cborInt(-1), cborInt(1)}, + {cborInt(-2), cborBytes(f.key.X.FillBytes(make([]byte, 32)))}, + {cborInt(-3), cborBytes(f.key.Y.FillBytes(make([]byte, 32)))}, + }) + authData[32] |= flagAT + authData = append(authData, make([]byte, 16)...) + authData = binary.BigEndian.AppendUint16(authData, uint16(len(f.keyID))) + authData = append(append(authData, f.keyID...), cose...) + attestation := cborMap([][2][]byte{ + {cborText("fmt"), cborText("none")}, + {cborText("attStmt"), cborMap(nil)}, + {cborText("authData"), cborBytes(authData)}, + }) + spki, err := x509.MarshalPKIXPublicKey(&f.key.PublicKey) + if err != nil { + return nil, err + } + return json.Marshal(map[string]any{ + "id": b64(f.keyID), "rawId": b64(f.keyID), "type": "public-key", "authenticatorAttachment": "platform", + "clientExtensionResults": map[string]any{}, + "response": map[string]any{ + "clientDataJSON": b64(clientDataJSON), "attestationObject": b64(attestation), "authenticatorData": b64(authData), + "publicKey": b64(spki), "publicKeyAlgorithm": -7, "transports": fakeTransports, + }, + }) + } + if f.key == nil { + return nil, fmt.Errorf("no passkey registered") + } + clientHash := sha256.Sum256(clientDataJSON) + digest := sha256.Sum256(append(bytes.Clone(authData), clientHash[:]...)) + signature, err := ecdsa.SignASN1(rand.Reader, f.key, digest[:]) + if err != nil { + return nil, err + } + return json.Marshal(map[string]any{ + "id": b64(f.keyID), "rawId": b64(f.keyID), "type": "public-key", "authenticatorAttachment": "platform", + "clientExtensionResults": map[string]any{}, + "response": map[string]any{ + "clientDataJSON": b64(clientDataJSON), "authenticatorData": b64(authData), + "signature": b64(signature), "userHandle": b64(f.userHandle), + }, + }) +} + +// --- minimal CBOR for attestation objects and COSE keys --- + +func cborHead(major byte, n uint64) []byte { + switch { + case n < 24: + return []byte{major<<5 | byte(n)} + case n < 1<<8: + return []byte{major<<5 | 24, byte(n)} + default: + return binary.BigEndian.AppendUint16([]byte{major<<5 | 25}, uint16(n)) + } +} + +func cborInt(n int64) []byte { + if n < 0 { + return cborHead(1, uint64(-1-n)) + } + return cborHead(0, uint64(n)) +} + +func cborBytes(b []byte) []byte { return append(cborHead(2, uint64(len(b))), b...) } +func cborText(s string) []byte { return append(cborHead(3, uint64(len(s))), s...) } + +func cborMap(pairs [][2][]byte) []byte { + out := cborHead(5, uint64(len(pairs))) + for _, pair := range pairs { + out = append(append(out, pair[0]...), pair[1]...) + } + return out +} + +func cborDecode(data []byte) (any, error) { + value, rest, err := cborItem(data) + if err == nil && len(rest) != 0 { + err = fmt.Errorf("cbor: %d trailing bytes", len(rest)) + } + return value, err +} + +func cborItem(data []byte) (any, []byte, error) { + if len(data) == 0 { + return nil, nil, io.ErrUnexpectedEOF + } + major, info := data[0]>>5, data[0]&0x1f + data = data[1:] + var n uint64 + switch { + case info < 24: + n = uint64(info) + case info == 24 && len(data) >= 1: + n, data = uint64(data[0]), data[1:] + case info == 25 && len(data) >= 2: + n, data = uint64(binary.BigEndian.Uint16(data)), data[2:] + default: + return nil, nil, fmt.Errorf("cbor: unsupported head %#x", info) + } + switch major { + case 0: + return int64(n), data, nil + case 1: + return -1 - int64(n), data, nil + case 2, 3: + if uint64(len(data)) < n { + return nil, nil, io.ErrUnexpectedEOF + } + if major == 2 { + return bytes.Clone(data[:n]), data[n:], nil + } + return string(data[:n]), data[n:], nil + case 5: + out := make(map[any]any, n) + for range n { + key, rest, err := cborItem(data) + if err != nil { + return nil, nil, err + } + value, rest, err := cborItem(rest) + if err != nil { + return nil, nil, err + } + out[key], data = value, rest + } + return out, data, nil + } + return nil, nil, fmt.Errorf("cbor: unsupported major type %d", major) +} diff --git a/server/lib/webauthnhook/hook.go b/server/lib/webauthnhook/hook.go new file mode 100644 index 000000000..e61f95e74 --- /dev/null +++ b/server/lib/webauthnhook/hook.go @@ -0,0 +1,570 @@ +// Package webauthnhook answers pages' navigator.credentials.get/create +// publicKey requests with passkeys held outside the VM. +// +// While the hook is on it keeps its own DevTools connection to Chromium, +// separate from telemetry's. Over it, a script installed in every page and +// iframe document replaces the WebAuthn entry points and sends each request +// through a CDP binding. The hook adds the calling document's origin as +// Chromium reports it, forwards the request to a Signer (metro-api), and +// replies in the same execution context. Requests nothing answers fall back +// to the browser's own implementation. Turning the hook off closes the +// connection, which removes its bindings and scripts. +package webauthnhook + +import ( + "context" + _ "embed" + "encoding/json" + "errors" + "log/slog" + "net/url" + "strings" + "sync" + "time" + + "github.com/kernel/kernel-images/server/lib/cdpclient" +) + +// bindingName must match BINDING and replyFunction must match REPLY in hook.js. +const ( + bindingName = "__kernelPasskey" + replyFunction = "__kernelPasskeyReply" +) + +const ( + setupTimeout = 10 * time.Second + setupRetryDelay = time.Second + deliverTimeout = 5 * time.Second + maxCallBytes = 256 << 10 + maxRequestIDLen = 64 + maxPendingCalls = 16 // unanswered calls per session + maxTrackedCalls = 256 // answered calls kept per session for resends + answeredRetained = 30 * time.Second + maxFrameDepth = 32 +) + +// ErrNotConfigured means the hook cannot be turned on because the VM has no +// metro API to answer it. +var ErrNotConfigured = errors.New("WebAuthn hook is not configured: KERNEL_METRO_API_BASE_URL is not set") + +// suspendExpression tells a hooked document to leave every request to the +// browser until the hook script runs in it again. +const suspendExpression = "typeof " + replyFunction + ` === "function" && ` + replyFunction + `({"suspend":true})` + +//go:embed hook.js +var hookSource string + +type Config struct { + // StatePath keeps the enabled flag across restarts of this process. + StatePath string + Signer Signer + Logger *slog.Logger +} + +// Upstream reports Chromium's DevTools URL and its changes. +// *devtoolsproxy.UpstreamManager implements it. +type Upstream interface { + Current() string + Subscribe() (<-chan string, func()) +} + +type executionContext struct { + uniqueID string + origin string + frameID string +} + +type callKey struct { + contextID string // unique execution context ID + requestID string +} + +// call is one page request. reply is empty until the request is answered; +// the page resends until it sees that reply. cancel ends the request when the +// page aborts it. +type call struct { + reply json.RawMessage + answeredAt time.Time + cancel context.CancelFunc +} + +type sender interface { + Send(ctx context.Context, method string, params any) (json.RawMessage, error) +} + +// cdpSession sends commands to one target over the hook's connection. +type cdpSession struct { + protocol *cdpclient.Client + id string +} + +func (c cdpSession) Send(ctx context.Context, method string, params any) (json.RawMessage, error) { + return c.protocol.Send(ctx, method, params, c.id) +} + +type session struct { + id string + targetID string + targetType string + parentFrameID string + cdp sender + + attempted bool // Hook.mu; first setup attempt has finished + contexts map[int]executionContext // Hook.mu; main-world contexts only + frameContexts map[string]int // Hook.mu; frame ID -> its newest main-world context + calls map[callKey]*call // Hook.mu +} + +type Hook struct { + cfg Config + upstream Upstream + log *slog.Logger + + setMu sync.Mutex // serializes Start, Stop, and Set + lifecycle context.Context // setMu; set by Start + stopRun context.CancelFunc // setMu; non-nil while the connection loop runs + runDone chan struct{} // setMu + + mu sync.Mutex + enabled bool + conn *connection // started connection, nil while there is none + sessions map[string]*session +} + +// New restores the flag a previous run of the process left. Nothing connects +// until Start. +func New(cfg Config, upstream Upstream) *Hook { + return &Hook{ + cfg: cfg, upstream: upstream, log: cfg.Logger, + enabled: loadEnabled(cfg.StatePath, cfg.Logger), + sessions: make(map[string]*session), + } +} + +// Start connects to Chromium if the hook is on. Connections live until ctx +// ends, Stop, or the hook is turned off. +func (h *Hook) Start(ctx context.Context) { + h.setMu.Lock() + defer h.setMu.Unlock() + h.lifecycle = ctx + h.mu.Lock() + enabled := h.enabled + h.mu.Unlock() + if enabled { + h.startLocked() + } +} + +// Stop closes the connection without changing the flag. +func (h *Hook) Stop() { + h.setMu.Lock() + defer h.setMu.Unlock() + h.stopLocked() +} + +// Set turns the hook on or off, persisting the flag first. Turning it on +// fails with ErrNotConfigured when the signer is not configured, and otherwise +// connects and waits, within ctx and installWait, until the documents already +// open are hooked. Turning it off tells hooked documents to leave requests to +// the browser, then closes the connection. +func (h *Hook) Set(ctx context.Context, enabled bool) error { + h.setMu.Lock() + defer h.setMu.Unlock() + if enabled && !h.cfg.Signer.Configured() { + return ErrNotConfigured + } + if err := saveEnabled(h.cfg.StatePath, enabled); err != nil { + return err + } + h.mu.Lock() + h.enabled = enabled + h.mu.Unlock() + if enabled { + h.startLocked() + h.waitInstalled(ctx) + return nil + } + h.suspendAll() + h.stopLocked() + return nil +} + +func (h *Hook) startLocked() { + if h.lifecycle == nil || h.stopRun != nil { + return + } + ctx, cancel := context.WithCancel(h.lifecycle) + done := make(chan struct{}) + h.stopRun, h.runDone = cancel, done + go func() { + defer close(done) + h.run(ctx) + }() +} + +func (h *Hook) stopLocked() { + if h.stopRun == nil { + return + } + h.stopRun() + <-h.runDone + h.stopRun, h.runDone = nil, nil +} + +// suspendAll makes every hooked document fall back at once, rather than after +// the page's timeout once the connection is gone. +func (h *Hook) suspendAll() { + type target struct { + s *session + ec executionContext + } + var targets []target + h.mu.Lock() + for _, s := range h.sessions { + for _, ec := range s.contexts { + targets = append(targets, target{s, ec}) + } + } + h.mu.Unlock() + var wg sync.WaitGroup + for _, t := range targets { + wg.Go(func() { + ctx, cancel := context.WithTimeout(context.Background(), deliverTimeout) + defer cancel() + _, _ = t.s.cdp.Send(ctx, "Runtime.evaluate", map[string]any{ + "expression": suspendExpression, "uniqueContextId": t.ec.uniqueID, "silent": true, + }) + }) + } + wg.Wait() +} + +func (h *Hook) track(s *session) *session { + s.contexts = make(map[int]executionContext) + s.frameContexts = make(map[string]int) + s.calls = make(map[callKey]*call) + h.mu.Lock() + h.sessions[s.id] = s + h.mu.Unlock() + return s +} + +// reconcile hooks a newly attached session, retrying once after a short +// delay so a tab is not left unhooked by a transient failure. +func (h *Hook) reconcile(s *session) { + err := h.install(s) + if err != nil { + time.Sleep(setupRetryDelay) + if err = h.install(s); err != nil && h.live(s) { + h.log.Error("webauthn: failed to install hook", "session", s.id, "target_type", s.targetType, "err", err) + } + } + h.mu.Lock() + s.attempted = true + h.mu.Unlock() +} + +func (h *Hook) live(s *session) bool { + h.mu.Lock() + defer h.mu.Unlock() + return h.sessions[s.id] == s +} + +func (h *Hook) install(s *session) error { + h.mu.Lock() + enabled, live := h.enabled, h.sessions[s.id] == s + h.mu.Unlock() + if !enabled || !live { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), setupTimeout) + defer cancel() + for _, command := range []struct { + method string + params any + }{ + {"Runtime.enable", nil}, + {"Page.enable", nil}, + {"Runtime.addBinding", map[string]any{"name": bindingName}}, + {"Page.addScriptToEvaluateOnNewDocument", map[string]any{"source": hookSource, "runImmediately": true}}, + } { + if _, err := s.cdp.Send(ctx, command.method, command.params); err != nil { + return err + } + } + return nil +} + +func (h *Hook) protocolEvent(sessionID, method string, params json.RawMessage) { + switch method { + case "Runtime.executionContextCreated": + var event struct { + Context struct { + ID int `json:"id"` + UniqueID string `json:"uniqueId"` + Origin string `json:"origin"` + AuxData struct { + FrameID string `json:"frameId"` + IsDefault bool `json:"isDefault"` + } `json:"auxData"` + } `json:"context"` + } + if json.Unmarshal(params, &event) != nil || !event.Context.AuxData.IsDefault { + return + } + h.mu.Lock() + if s := h.sessions[sessionID]; s != nil { + s.contexts[event.Context.ID] = executionContext{ + uniqueID: event.Context.UniqueID, origin: event.Context.Origin, frameID: event.Context.AuxData.FrameID, + } + s.frameContexts[event.Context.AuxData.FrameID] = event.Context.ID + } + h.mu.Unlock() + case "Runtime.executionContextDestroyed": + var event struct { + ID int `json:"executionContextId"` + } + if json.Unmarshal(params, &event) != nil { + return + } + h.mu.Lock() + if s := h.sessions[sessionID]; s != nil { + if destroyed, ok := s.contexts[event.ID]; ok { + delete(s.contexts, event.ID) + if s.frameContexts[destroyed.frameID] == event.ID { + delete(s.frameContexts, destroyed.frameID) + } + for key := range s.calls { + if key.contextID == destroyed.uniqueID { + delete(s.calls, key) + } + } + } + } + h.mu.Unlock() + case "Runtime.executionContextsCleared": + h.mu.Lock() + if s := h.sessions[sessionID]; s != nil { + clear(s.contexts) + clear(s.frameContexts) + clear(s.calls) + } + h.mu.Unlock() + case "Runtime.bindingCalled": + var event struct { + Name string `json:"name"` + Payload string `json:"payload"` + ContextID int `json:"executionContextId"` + } + if json.Unmarshal(params, &event) == nil && event.Name == bindingName && len(event.Payload) <= maxCallBytes { + h.handleCall(sessionID, event.ContextID, event.Payload) + } + } +} + +type pageCall struct { + ID string `json:"id"` + Op string `json:"op"` + Mediation string `json:"mediation"` + PublicKey json.RawMessage `json:"publicKey"` +} + +type pageReply struct { + ID string `json:"id"` + Answer +} + +func (h *Hook) handleCall(sessionID string, contextID int, payload string) { + var pc pageCall + if json.Unmarshal([]byte(payload), &pc) != nil || pc.ID == "" || len(pc.ID) > maxRequestIDLen || + (pc.Op != "get" && pc.Op != "create" && pc.Op != "abort") { + return + } + h.mu.Lock() + s := h.sessions[sessionID] + if s == nil { + h.mu.Unlock() + return + } + ec, ok := s.contexts[contextID] + if !ok { + h.mu.Unlock() + return + } + key := callKey{contextID: ec.uniqueID, requestID: pc.ID} + existing := s.calls[key] + if pc.Op == "abort" { + // A request the page discarded is not sent to metro-api if it has not + // been already, and stops retrying if it has. + if existing != nil && existing.reply == nil { + existing.cancel() + delete(s.calls, key) + } + h.mu.Unlock() + return + } + if existing != nil { + reply := existing.reply + h.mu.Unlock() + if reply == nil { + reply = ack(pc.ID) + } + go h.deliver(s, ec, reply) + return + } + if !h.admitLocked(s) { + h.mu.Unlock() + h.log.Warn("webauthn: too many unanswered requests; leaving the request to the browser", "session", sessionID, "limit", maxPendingCalls) + go h.deliver(s, ec, encodeReply(pc.ID, notHandled)) + return + } + callCtx, cancel := context.WithCancel(context.Background()) + c := &call{cancel: cancel} + s.calls[key] = c + enabled := h.enabled + topOrigin, topKnown := h.topOriginLocked(s) + h.mu.Unlock() + + go func() { + defer cancel() + h.deliver(s, ec, ack(pc.ID)) + answer := notHandled + origin, secure := secureOrigin(ec.origin) + top, topSecure := secureOrigin(topOrigin) + if enabled && secure && topKnown && topSecure && h.permitted(s, ec.frameID, pc.Op) && callCtx.Err() == nil { + answer = h.cfg.Signer.Answer(callCtx, pc.Op, Request{ + Origin: origin, TopOrigin: top, Mediation: pc.Mediation, PublicKey: pc.PublicKey, + }) + } + if callCtx.Err() != nil { + h.log.Debug("webauthn: page aborted request", "op", pc.Op, "origin", ec.origin) + return + } + h.log.Debug("webauthn: answered page request", "op", pc.Op, "origin", ec.origin, "top_origin", topOrigin, "result", answer.Result) + reply := encodeReply(pc.ID, answer) + h.mu.Lock() + c.reply, c.answeredAt = reply, time.Now() + h.mu.Unlock() + h.deliver(s, ec, reply) + }() +} + +// admitLocked reports whether the session may start another request. Only +// unanswered requests count; answered ones are kept for resends until they +// expire, or until the oldest is evicted to bound memory. +func (h *Hook) admitLocked(s *session) bool { + pending := 0 + var oldest callKey + var oldestAt time.Time + for key, c := range s.calls { + switch { + case c.reply == nil: + pending++ + case time.Since(c.answeredAt) > answeredRetained: + delete(s.calls, key) + case oldestAt.IsZero() || c.answeredAt.Before(oldestAt): + oldest, oldestAt = key, c.answeredAt + } + } + if pending >= maxPendingCalls { + return false + } + if len(s.calls) >= maxTrackedCalls { + delete(s.calls, oldest) + } + return true +} + +// topOriginLocked follows out-of-process iframes up to their page. Every +// frame in a session shares the session root's top-level document. +func (h *Hook) topOriginLocked(s *session) (string, bool) { + for range maxFrameDepth { + if s.targetType == "page" { + id, ok := s.frameContexts[s.targetID] + return s.contexts[id].origin, ok + } + if s = h.frameOwnerLocked(s.parentFrameID); s == nil { + return "", false + } + } + return "", false +} + +func (h *Hook) frameOwnerLocked(frameID string) *session { + if frameID == "" { + return nil + } + for _, s := range h.sessions { + if _, ok := s.frameContexts[frameID]; ok { + return s + } + } + return nil +} + +// permitted applies the document's permissions policy, which governs the +// WebAuthn API in cross-origin frames. A refused request falls back to the +// browser, which rejects it the way it always would. +func (h *Hook) permitted(s *session, frameID, op string) bool { + ctx, cancel := context.WithTimeout(context.Background(), deliverTimeout) + defer cancel() + raw, err := s.cdp.Send(ctx, "Page.getPermissionsPolicyState", map[string]any{"frameId": frameID}) + if err != nil { + return false + } + var result struct { + States []struct { + Feature string `json:"feature"` + Allowed bool `json:"allowed"` + } `json:"states"` + } + if json.Unmarshal(raw, &result) != nil { + return false + } + feature := "publickey-credentials-" + op + for _, state := range result.States { + if state.Feature == feature { + return state.Allowed + } + } + return false +} + +// secureOrigin accepts the origins WebAuthn serves: https, and http on +// localhost and its subdomains. Opaque origins do not parse to a host. +func secureOrigin(origin string) (string, bool) { + u, err := url.Parse(origin) + if err != nil || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" { + return "", false + } + switch { + case u.Scheme == "https", u.Scheme == "http" && (u.Hostname() == "localhost" || strings.HasSuffix(u.Hostname(), ".localhost")): + return origin, true + } + return "", false +} + +func (h *Hook) deliver(s *session, ec executionContext, reply json.RawMessage) { + ctx, cancel := context.WithTimeout(context.Background(), deliverTimeout) + defer cancel() + if _, err := s.cdp.Send(ctx, "Runtime.evaluate", map[string]any{ + "expression": replyFunction + "(" + string(reply) + ")", + "uniqueContextId": ec.uniqueID, + "silent": true, + }); err != nil { + h.log.Debug("webauthn: failed to deliver reply", "session", s.id, "err", err) + } +} + +func ack(id string) json.RawMessage { + data, _ := json.Marshal(struct { + ID string `json:"id"` + Ack bool `json:"ack"` + }{id, true}) + return data +} + +func encodeReply(id string, answer Answer) json.RawMessage { + data, _ := json.Marshal(pageReply{ID: id, Answer: answer}) + return data +} diff --git a/server/lib/webauthnhook/hook.js b/server/lib/webauthnhook/hook.js new file mode 100644 index 000000000..77894385d --- /dev/null +++ b/server/lib/webauthnhook/hook.js @@ -0,0 +1,239 @@ +// Answers navigator.credentials.get/create publicKey requests through the +// kernel-images binding instead of the browser's authenticators. Requests +// carry no origin: kernel-images takes it from Chromium. Anything the binding +// does not answer with a credential or an error goes to the browser's own +// implementation. +// +// Protocol: the page calls BINDING with {id, op, mediation?, publicKey} and +// resends the same id every RESEND_MS until it has a final answer, and with +// {id, op: "abort"} when the caller aborts. kernel-images replies through +// REPLY with {id, ack: true} for every send it receives and {id, result, ...} +// once answered. Silence for SILENCE_MS (no connection, or kernel-images +// restarted) or a missing binding for MISSING_MS falls back to the browser. +// {suspend: true} sends every request to the browser until the script runs +// in the document again. +(function () { + const BINDING = '__kernelPasskey'; + const REPLY = '__kernelPasskeyReply'; + const RESEND_MS = 1000; + const SILENCE_MS = 10000; + const MISSING_MS = 3000; + + if (Object.prototype.hasOwnProperty.call(globalThis, REPLY)) { + globalThis[REPLY]({ resume: true }); + return; + } + if (typeof CredentialsContainer !== 'function' || typeof PublicKeyCredential !== 'function') return; + + const container = CredentialsContainer.prototype; + const nativeGet = container.get; + const nativeCreate = container.create; + const nativeCapabilities = PublicKeyCredential.getClientCapabilities; + const setTimer = globalThis.setTimeout.bind(globalThis); + const clearTimer = globalThis.clearTimeout.bind(globalThis); + const now = Date.now.bind(Date); + const stringify = JSON.stringify; + const pending = new Map(); + let suspended = false; + + Object.defineProperty(globalThis, REPLY, { + value: function (message) { + if (message && message.suspend) { + suspended = true; + for (const request of [...pending.values()]) request.finish(null); + return; + } + if (message && message.resume) { + suspended = false; + return; + } + const request = message && pending.get(message.id); + if (!request) return; + if (message.ack) { + request.heard = now(); + return; + } + request.finish(message); + }, + }); + + function isBuffer(value) { + return value instanceof ArrayBuffer || ArrayBuffer.isView(value); + } + + function bytes(value) { + return value instanceof ArrayBuffer ? new Uint8Array(value) : new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + } + + function encode(buffer) { + let binary = ''; + for (const byte of bytes(buffer)) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + } + + function decode(text) { + const base64 = text.replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(base64 + '='.repeat((4 - (base64.length % 4)) % 4)); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i); + return out.buffer; + } + + // WebAuthn L3 JSON form of creation and request options: every buffer + // becomes base64url. + function toJSONValue(value) { + if (isBuffer(value)) return encode(value); + if (Array.isArray(value)) return value.map(toJSONValue); + if (value && typeof value === 'object') { + const out = {}; + for (const key of Object.keys(value)) { + const converted = toJSONValue(value[key]); + if (converted !== undefined) out[key] = converted; + } + return out; + } + if (typeof value === 'function' || typeof value === 'symbol') return undefined; + return value; + } + + function define(target, values) { + for (const key of Object.keys(values)) { + Object.defineProperty(target, key, { value: values[key], enumerable: true }); + } + return target; + } + + function credentialFrom(json) { + json.type = 'public-key'; + json.authenticatorAttachment = json.authenticatorAttachment || 'platform'; + json.clientExtensionResults = json.clientExtensionResults || {}; + const r = json.response; + let response; + if (r.attestationObject !== undefined) { + response = define(Object.create(AuthenticatorAttestationResponse.prototype), { + clientDataJSON: decode(r.clientDataJSON), + attestationObject: decode(r.attestationObject), + getAuthenticatorData: () => decode(r.authenticatorData), + getPublicKey: () => (r.publicKey ? decode(r.publicKey) : null), + getPublicKeyAlgorithm: () => r.publicKeyAlgorithm, + getTransports: () => (r.transports || []).slice(), + }); + } else { + response = define(Object.create(AuthenticatorAssertionResponse.prototype), { + clientDataJSON: decode(r.clientDataJSON), + authenticatorData: decode(r.authenticatorData), + signature: decode(r.signature), + userHandle: r.userHandle ? decode(r.userHandle) : null, + }); + } + return define(Object.create(PublicKeyCredential.prototype), { + id: json.id, + rawId: decode(json.rawId), + type: json.type, + authenticatorAttachment: json.authenticatorAttachment, + response, + getClientExtensionResults: () => structuredClone(json.clientExtensionResults), + toJSON: () => structuredClone(json), + }); + } + + function abortReason(signal) { + return signal.reason !== undefined ? signal.reason : new DOMException('The operation was aborted.', 'AbortError'); + } + + function callBinding(payload) { + try { + globalThis[BINDING](payload); + return true; + } catch { + return false; + } + } + + // Resolves with the final answer, or null when kernel-images stays silent. + function exchange(op, options, signal) { + return new Promise((resolve, reject) => { + const id = encode(crypto.getRandomValues(new Uint8Array(16))); + const message = { id, op, publicKey: toJSONValue(options.publicKey) }; + if (options.mediation !== undefined) message.mediation = String(options.mediation); + const payload = stringify(message); + let timer; + let missingSince = 0; + const request = { heard: now(), finish: (answer) => settle(() => resolve(answer)) }; + function settle(done) { + pending.delete(id); + clearTimer(timer); + if (signal) signal.removeEventListener('abort', onAbort); + done(); + } + function onAbort() { + settle(() => reject(abortReason(signal))); + callBinding(stringify({ id, op: 'abort' })); + } + function send() { + if (now() - request.heard >= SILENCE_MS) { + request.finish(null); + return; + } + if (callBinding(payload)) { + missingSince = 0; + } else if (!missingSince) { + missingSince = now(); + } else if (now() - missingSince >= MISSING_MS) { + request.finish(null); + return; + } + timer = setTimer(send, RESEND_MS); + } + if (signal) signal.addEventListener('abort', onAbort); + pending.set(id, request); + send(); + }); + } + + async function intercept(op, native, container, options) { + const signal = options.signal; + if (signal && signal.aborted) throw abortReason(signal); + if (suspended) return native.call(container, options); + const answer = await exchange(op, options, signal); + if (answer && answer.result === 'credential') return credentialFrom(answer.credential); + if (answer && answer.result === 'error') throw new DOMException(answer.message, answer.name); + return native.call(container, options); + } + + function handles(options) { + return options !== null && typeof options === 'object' && options.publicKey !== undefined; + } + + container.get = function get(options) { + if (!handles(options)) return nativeGet.apply(this, arguments); + return intercept('get', nativeGet, this, options); + }; + container.create = function create(options) { + if (!handles(options)) return nativeCreate.apply(this, arguments); + return intercept('create', nativeCreate, this, options); + }; + + PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable = function isUserVerifyingPlatformAuthenticatorAvailable() { + return Promise.resolve(true); + }; + PublicKeyCredential.isConditionalMediationAvailable = function isConditionalMediationAvailable() { + return Promise.resolve(true); + }; + if (typeof nativeCapabilities === 'function') { + PublicKeyCredential.getClientCapabilities = async function getClientCapabilities() { + let capabilities = {}; + try { + capabilities = await nativeCapabilities.call(PublicKeyCredential); + } catch { + // Report the passkey capabilities alone. + } + return Object.assign({}, capabilities, { + conditionalCreate: true, + conditionalGet: true, + passkeyPlatformAuthenticator: true, + userVerifyingPlatformAuthenticator: true, + }); + }; + } +})(); diff --git a/server/lib/webauthnhook/hook_test.go b/server/lib/webauthnhook/hook_test.go new file mode 100644 index 000000000..6eb89d119 --- /dev/null +++ b/server/lib/webauthnhook/hook_test.go @@ -0,0 +1,585 @@ +package webauthnhook + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/kernel/kernel-images/server/lib/browsersurface" + "github.com/stretchr/testify/require" +) + +var discard = slog.New(slog.NewTextHandler(io.Discard, nil)) + +type command struct { + method string + params map[string]any +} + +// fakeSession records commands and answers the few whose results the hook reads. +type fakeSession struct { + mu sync.Mutex + commands []command + replies []map[string]any + suspended []string // contexts told to suspend + forbidden map[string]bool // permissions-policy features reported as not allowed + failures map[string]int // method -> number of calls still to fail + policy chan struct{} // when set, holds permissions-policy lookups until closed +} + +func (f *fakeSession) Send(_ context.Context, method string, params any) (json.RawMessage, error) { + var decoded map[string]any + if params != nil { + data, _ := json.Marshal(params) + _ = json.Unmarshal(data, &decoded) + } + f.mu.Lock() + policy := f.policy + f.mu.Unlock() + if method == "Page.getPermissionsPolicyState" && policy != nil { + <-policy + } + f.mu.Lock() + defer f.mu.Unlock() + f.commands = append(f.commands, command{method, decoded}) + if f.failures[method] > 0 { + f.failures[method]-- + return nil, errors.New("injected failure") + } + switch method { + case "Page.addScriptToEvaluateOnNewDocument": + return json.RawMessage(`{"identifier":"script-1"}`), nil + case "Page.getPermissionsPolicyState": + states := []map[string]any{} + for _, feature := range []string{"publickey-credentials-get", "publickey-credentials-create"} { + states = append(states, map[string]any{"feature": feature, "allowed": !f.forbidden[feature]}) + } + return json.Marshal(map[string]any{"states": states}) + case "Runtime.evaluate": + expression := decoded["expression"].(string) + if expression == suspendExpression { + f.suspended = append(f.suspended, decoded["uniqueContextId"].(string)) + break + } + argument := strings.TrimSuffix(strings.TrimPrefix(expression, replyFunction+"("), ")") + var reply map[string]any + if err := json.Unmarshal([]byte(argument), &reply); err != nil { + return nil, err + } + reply["context"] = decoded["uniqueContextId"] + f.replies = append(f.replies, reply) + } + return json.RawMessage(`{}`), nil +} + +func (f *fakeSession) methods() []string { + f.mu.Lock() + defer f.mu.Unlock() + methods := make([]string, 0, len(f.commands)) + for _, c := range f.commands { + methods = append(methods, c.method) + } + return methods +} + +func (f *fakeSession) finalReply(t *testing.T, id string) map[string]any { + t.Helper() + var found map[string]any + require.Eventually(t, func() bool { + f.mu.Lock() + defer f.mu.Unlock() + for _, reply := range f.replies { + if reply["id"] == id && reply["ack"] == nil { + found = reply + return true + } + } + return false + }, 5*time.Second, 10*time.Millisecond) + return found +} + +func (f *fakeSession) countReplies(id string, ack bool) int { + f.mu.Lock() + defer f.mu.Unlock() + n := 0 + for _, reply := range f.replies { + if reply["id"] == id && (reply["ack"] == true) == ack { + n++ + } + } + return n +} + +type recordedRequest struct { + op string + req Request + ctx context.Context +} + +type fakeSigner struct { + mu sync.Mutex + requests []recordedRequest + answer Answer + release chan struct{} +} + +func (f *fakeSigner) Configured() bool { return true } + +func (f *fakeSigner) Answer(ctx context.Context, op string, req Request) Answer { + f.mu.Lock() + f.requests = append(f.requests, recordedRequest{op, req, ctx}) + release := f.release + f.mu.Unlock() + if release != nil { + <-release + } + return f.answer +} + +func (f *fakeSigner) calls() []recordedRequest { + f.mu.Lock() + defer f.mu.Unlock() + return append([]recordedRequest(nil), f.requests...) +} + +// switchableUpstream is a DevTools URL that tests can change, notifying +// subscribers the way the upstream manager does after a Chromium restart. +type switchableUpstream struct { + mu sync.Mutex + url string + subs []chan string +} + +func (u *switchableUpstream) Current() string { + u.mu.Lock() + defer u.mu.Unlock() + return u.url +} + +func (u *switchableUpstream) Subscribe() (<-chan string, func()) { + u.mu.Lock() + defer u.mu.Unlock() + ch := make(chan string, 1) + u.subs = append(u.subs, ch) + return ch, func() {} +} + +// set changes the URL; notify also tells subscribers, as a restart would. +func (u *switchableUpstream) set(url string, notify bool) { + u.mu.Lock() + defer u.mu.Unlock() + u.url = url + if !notify { + return + } + for _, ch := range u.subs { + select { + case ch <- url: + default: + } + } +} + +type harness struct { + hook *Hook + signer *fakeSigner + path string + logs *bytes.Buffer +} + +func newHarness(t *testing.T) *harness { + t.Helper() + return newHarnessAt(t, filepath.Join(t.TempDir(), "webauthn-hook.json")) +} + +func newHarnessAt(t *testing.T, path string) *harness { + t.Helper() + h := &harness{ + path: path, logs: &bytes.Buffer{}, + signer: &fakeSigner{answer: Answer{Result: "credential", Credential: json.RawMessage(`{"id":"cred"}`)}}, + } + logger := slog.New(slog.NewTextHandler(&lockedWriter{w: h.logs}, nil)) + h.hook = New(Config{StatePath: path, Signer: h.signer, Logger: logger}, &switchableUpstream{}) + ctx, cancel := context.WithCancel(context.Background()) + h.hook.Start(ctx) + t.Cleanup(func() { + cancel() + h.hook.Stop() + }) + return h +} + +type lockedWriter struct { + mu sync.Mutex + w io.Writer +} + +func (l *lockedWriter) Write(p []byte) (int, error) { + l.mu.Lock() + defer l.mu.Unlock() + return l.w.Write(p) +} + +func (h *harness) attach(t *testing.T, id, targetType, parentFrameID string) *fakeSession { + t.Helper() + return h.attachFake(id, targetType, parentFrameID, &fakeSession{}) +} + +func (h *harness) attachFake(id, targetType, parentFrameID string, fake *fakeSession) *fakeSession { + if fake.forbidden == nil { + fake.forbidden = map[string]bool{} + } + h.hook.reconcile(h.hook.track(&session{id: id, targetID: id, targetType: targetType, parentFrameID: parentFrameID, cdp: fake})) + return fake +} + +func (h *harness) context(sessionID string, id int, origin, frameID string) { + params, _ := json.Marshal(map[string]any{"context": map[string]any{ + "id": id, "uniqueId": sessionID + "-" + origin, "origin": origin, + "auxData": map[string]any{"frameId": frameID, "isDefault": true}, + }}) + h.hook.protocolEvent(sessionID, "Runtime.executionContextCreated", params) +} + +func (h *harness) call(sessionID string, contextID int, payload map[string]any) { + data, _ := json.Marshal(payload) + params, _ := json.Marshal(map[string]any{"name": bindingName, "payload": string(data), "executionContextId": contextID}) + h.hook.protocolEvent(sessionID, "Runtime.bindingCalled", params) +} + +func getCall(id string) map[string]any { + return map[string]any{"id": id, "op": "get", "publicKey": map[string]any{"challenge": "AAAA"}} +} + +func TestSetInstallsAndSuspends(t *testing.T) { + h := newHarness(t) + page := h.attach(t, "page", "page", "") + require.Empty(t, page.methods(), "a disabled hook must not touch sessions") + h.hook.handleEvent(&connection{}, browsersurface.Event{ + Kind: browsersurface.EventSessionAttached, SessionID: "worker", Target: browsersurface.SessionTarget{Type: "worker"}, + }) + require.NotContains(t, h.hook.sessions, "worker", "only page and iframe documents can call WebAuthn") + + require.NoError(t, h.hook.Set(context.Background(), true)) + page = h.attach(t, "page", "page", "") + require.Equal(t, []string{"Runtime.enable", "Page.enable", "Runtime.addBinding", "Page.addScriptToEvaluateOnNewDocument"}, page.methods()) + require.Equal(t, bindingName, page.commands[2].params["name"]) + require.Equal(t, true, page.commands[3].params["runImmediately"]) + require.Equal(t, hookSource, page.commands[3].params["source"]) + + h.context("page", 1, "https://example.com", "page") + h.context("page", 2, "https://example.com", "child") + require.NoError(t, h.hook.Set(context.Background(), false)) + require.ElementsMatch(t, []string{"page-https://example.com", "page-https://example.com"}, page.suspended) + require.Equal(t, []string{"Runtime.evaluate", "Runtime.evaluate"}, page.methods()[4:], "the closed connection removes the binding and script") +} + +func TestSetStartsAndStopsConnectionLoop(t *testing.T) { + h := newHarness(t) + require.Nil(t, h.hook.stopRun) + require.NoError(t, h.hook.Set(context.Background(), true)) + require.NotNil(t, h.hook.stopRun) + require.NoError(t, h.hook.Set(context.Background(), true)) + require.NoError(t, h.hook.Set(context.Background(), false)) + require.Nil(t, h.hook.stopRun) +} + +func TestSetupFailureIsRetriedOnce(t *testing.T) { + h := newHarness(t) + require.NoError(t, h.hook.Set(context.Background(), true)) + page := h.attachFake("page", "page", "", &fakeSession{failures: map[string]int{"Runtime.addBinding": 1}}) + require.Equal(t, []string{ + "Runtime.enable", "Page.enable", "Runtime.addBinding", + "Runtime.enable", "Page.enable", "Runtime.addBinding", "Page.addScriptToEvaluateOnNewDocument", + }, page.methods()) + require.NotContains(t, h.logs.String(), "level=ERROR") + + h.attachFake("broken", "page", "", &fakeSession{failures: map[string]int{"Runtime.enable": 2}}) + require.Contains(t, h.logs.String(), "level=ERROR") + require.Contains(t, h.logs.String(), "session=broken") +} + +func TestEnabledFlagSurvivesRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "state", "webauthn-hook.json") + first := newHarnessAt(t, path) + require.NoError(t, first.hook.Set(context.Background(), true)) + + restarted := newHarnessAt(t, path) + require.NotNil(t, restarted.hook.stopRun, "a restored hook connects at start") + page := restarted.attach(t, "page", "page", "") + require.Contains(t, page.methods(), "Page.addScriptToEvaluateOnNewDocument") + + require.NoError(t, restarted.hook.Set(context.Background(), false)) + again := newHarnessAt(t, path) + require.Nil(t, again.hook.stopRun) + require.Empty(t, again.attach(t, "page", "page", "").methods()) +} + +func TestUnreadableStateLeavesHookOff(t *testing.T) { + path := filepath.Join(t.TempDir(), "webauthn-hook.json") + require.NoError(t, os.WriteFile(path, []byte("{"), 0o644)) + h := newHarnessAt(t, path) + require.Empty(t, h.attach(t, "page", "page", "").methods()) +} + +func TestCallForwardsChromiumOriginAndRepliesInContext(t *testing.T) { + h := newHarness(t) + require.NoError(t, h.hook.Set(context.Background(), true)) + page := h.attach(t, "page", "page", "") + h.context("page", 1, "https://example.com", "page") + + payload := getCall("r1") + payload["mediation"] = "conditional" + payload["origin"] = "https://evil.example" + h.call("page", 1, payload) + reply := page.finalReply(t, "r1") + require.Equal(t, "credential", reply["result"]) + require.Equal(t, map[string]any{"id": "cred"}, reply["credential"]) + require.Equal(t, "page-https://example.com", reply["context"]) + require.Equal(t, 1, page.countReplies("r1", true)) + + calls := h.signer.calls() + require.Len(t, calls, 1) + require.Equal(t, "get", calls[0].op) + require.Equal(t, "https://example.com", calls[0].req.Origin) + require.Equal(t, "https://example.com", calls[0].req.TopOrigin) + require.Equal(t, "conditional", calls[0].req.Mediation) + require.JSONEq(t, `{"challenge":"AAAA"}`, string(calls[0].req.PublicKey)) + + // A resend after the answer re-delivers it without asking again. + h.call("page", 1, getCall("r1")) + require.Eventually(t, func() bool { + page.mu.Lock() + defer page.mu.Unlock() + return len(page.replies) == 3 + }, 5*time.Second, 10*time.Millisecond) + require.Len(t, h.signer.calls(), 1) +} + +func TestResendWhilePendingIsAcknowledged(t *testing.T) { + h := newHarness(t) + h.signer.release = make(chan struct{}) + require.NoError(t, h.hook.Set(context.Background(), true)) + page := h.attach(t, "page", "page", "") + h.context("page", 1, "https://example.com", "page") + + h.call("page", 1, getCall("r1")) + require.Eventually(t, func() bool { return len(h.signer.calls()) == 1 }, 5*time.Second, 10*time.Millisecond) + h.call("page", 1, getCall("r1")) + require.Eventually(t, func() bool { return page.countReplies("r1", true) == 2 }, 5*time.Second, 10*time.Millisecond) + close(h.signer.release) + require.Equal(t, "credential", page.finalReply(t, "r1")["result"]) + require.Len(t, h.signer.calls(), 1) + + // The same request ID from another document is a different request. + h.signer.release = nil + h.context("page", 2, "https://other.example.com", "page") + h.call("page", 2, getCall("r1")) + require.Eventually(t, func() bool { return len(h.signer.calls()) == 2 }, 5*time.Second, 10*time.Millisecond) +} + +func TestCrossOriginIframeGetsTopOrigin(t *testing.T) { + h := newHarness(t) + require.NoError(t, h.hook.Set(context.Background(), true)) + h.attach(t, "page", "page", "") + h.context("page", 1, "https://shop.example", "page") + h.context("page", 2, "https://shop.example", "child-frame") + h.attach(t, "outer", "iframe", "child-frame") + h.context("outer", 1, "https://widgets.example", "outer") + inner := h.attach(t, "inner", "iframe", "outer") + h.context("inner", 1, "https://login.example.com", "inner") + + h.call("inner", 1, getCall("r1")) + require.Equal(t, "credential", inner.finalReply(t, "r1")["result"]) + calls := h.signer.calls() + require.Len(t, calls, 1) + require.Equal(t, "https://login.example.com", calls[0].req.Origin) + require.Equal(t, "https://shop.example", calls[0].req.TopOrigin) + require.Empty(t, calls[0].req.Mediation) +} + +func TestRequestsLeftToBrowser(t *testing.T) { + for name, tc := range map[string]struct { + origin string + forbidden string + parent string + }{ + "opaque origin": {origin: "://"}, + "null origin": {origin: "null"}, + "insecure origin": {origin: "http://example.com"}, + "permissions policy denies": {origin: "https://example.com", forbidden: "publickey-credentials-get"}, + "unknown top frame": {origin: "https://example.com", parent: "missing"}, + } { + t.Run(name, func(t *testing.T) { + h := newHarness(t) + require.NoError(t, h.hook.Set(context.Background(), true)) + h.attach(t, "page", "page", "") + h.context("page", 1, "https://example.com", "page") + parent := "page" + if tc.parent != "" { + parent = tc.parent + } + frame := h.attach(t, "frame", "iframe", parent) + frame.forbidden[tc.forbidden] = true + h.context("frame", 1, tc.origin, "frame") + h.call("frame", 1, getCall("r1")) + require.Equal(t, "not_handled", frame.finalReply(t, "r1")["result"]) + require.Empty(t, h.signer.calls()) + }) + } +} + +func TestLocalhostIsServed(t *testing.T) { + for _, origin := range []string{"http://localhost:8080", "http://app.localhost:3000"} { + h := newHarness(t) + require.NoError(t, h.hook.Set(context.Background(), true)) + page := h.attach(t, "page", "page", "") + h.context("page", 1, origin, "page") + h.call("page", 1, getCall("r1")) + require.Equal(t, "credential", page.finalReply(t, "r1")["result"], origin) + } +} + +func TestTopOriginFollowsNewestMainFrameContext(t *testing.T) { + h := newHarness(t) + require.NoError(t, h.hook.Set(context.Background(), true)) + h.attach(t, "page", "page", "") + frame := h.attach(t, "frame", "iframe", "page") + h.context("frame", 1, "https://login.example.com", "frame") + // The navigated-to document's context arrives before the old one is destroyed. + for i := 1; i <= 20; i++ { + h.context("page", i, fmt.Sprintf("https://site%d.example", i), "page") + } + destroyed, _ := json.Marshal(map[string]any{"executionContextId": 3}) + h.hook.protocolEvent("page", "Runtime.executionContextDestroyed", destroyed) + + h.call("frame", 1, getCall("r1")) + require.Equal(t, "credential", frame.finalReply(t, "r1")["result"]) + require.Equal(t, "https://site20.example", h.signer.calls()[0].req.TopOrigin) + + destroyed, _ = json.Marshal(map[string]any{"executionContextId": 20}) + h.hook.protocolEvent("page", "Runtime.executionContextDestroyed", destroyed) + h.call("frame", 1, getCall("r2")) + require.Equal(t, "not_handled", frame.finalReply(t, "r2")["result"], "a destroyed main-frame document has no top origin") +} + +func TestDisabledHookAnswersNotHandled(t *testing.T) { + h := newHarness(t) + require.NoError(t, h.hook.Set(context.Background(), true)) + page := h.attach(t, "page", "page", "") + h.context("page", 1, "https://example.com", "page") + require.NoError(t, h.hook.Set(context.Background(), false)) + + h.call("page", 1, getCall("r1")) + require.Equal(t, "not_handled", page.finalReply(t, "r1")["result"]) + require.Empty(t, h.signer.calls()) +} + +func TestIgnoredCalls(t *testing.T) { + h := newHarness(t) + require.NoError(t, h.hook.Set(context.Background(), true)) + page := h.attach(t, "page", "page", "") + h.context("page", 1, "https://example.com", "page") + + h.call("page", 9, getCall("unknown-context")) + h.call("page", 1, map[string]any{"id": "bad-op", "op": "store"}) + h.call("page", 1, map[string]any{"op": "get"}) + h.call("page", 1, map[string]any{"id": strings.Repeat("x", maxRequestIDLen+1), "op": "get"}) + other, _ := json.Marshal(map[string]any{"name": "__kernelEvent", "payload": `{"id":"x","op":"get"}`, "executionContextId": 1}) + h.hook.protocolEvent("page", "Runtime.bindingCalled", other) + + destroyed, _ := json.Marshal(map[string]any{"executionContextId": 1}) + h.hook.protocolEvent("page", "Runtime.executionContextDestroyed", destroyed) + h.call("page", 1, getCall("after-destroy")) + + time.Sleep(50 * time.Millisecond) + page.mu.Lock() + defer page.mu.Unlock() + require.Empty(t, page.replies) + require.Empty(t, h.signer.calls()) +} + +func TestOnlyUnansweredCallsAreBounded(t *testing.T) { + h := newHarness(t) + require.NoError(t, h.hook.Set(context.Background(), true)) + page := h.attach(t, "page", "page", "") + h.context("page", 1, "https://example.com", "page") + for i := range 2 * maxPendingCalls { + id := fmt.Sprintf("answered-%d", i) + h.call("page", 1, getCall(id)) + require.Equal(t, "credential", page.finalReply(t, id)["result"]) + } + + h.signer.release = make(chan struct{}) + defer close(h.signer.release) + for i := range maxPendingCalls { + h.call("page", 1, getCall(fmt.Sprintf("pending-%d", i))) + } + require.Eventually(t, func() bool { return len(h.signer.calls()) == 3*maxPendingCalls }, 5*time.Second, 10*time.Millisecond) + require.NotContains(t, h.logs.String(), "too many unanswered requests") + h.call("page", 1, getCall("overflow")) + require.Equal(t, "not_handled", page.finalReply(t, "overflow")["result"]) + require.Contains(t, h.logs.String(), "level=WARN") + require.Contains(t, h.logs.String(), "too many unanswered requests") +} + +func TestAnsweredCallsAreEvictedOldestFirst(t *testing.T) { + h := newHarness(t) + require.NoError(t, h.hook.Set(context.Background(), true)) + page := h.attach(t, "page", "page", "") + h.context("page", 1, "https://example.com", "page") + for i := range maxTrackedCalls + 1 { + id := fmt.Sprintf("r%d", i) + h.call("page", 1, getCall(id)) + page.finalReply(t, id) + } + h.hook.mu.Lock() + defer h.hook.mu.Unlock() + calls := h.hook.sessions["page"].calls + require.Len(t, calls, maxTrackedCalls) + require.NotContains(t, calls, callKey{contextID: "page-https://example.com", requestID: "r0"}) +} + +func TestAbortBeforeDispatchSkipsSigner(t *testing.T) { + h := newHarness(t) + require.NoError(t, h.hook.Set(context.Background(), true)) + page := h.attachFake("page", "page", "", &fakeSession{policy: make(chan struct{})}) + h.context("page", 1, "https://example.com", "page") + + h.call("page", 1, getCall("r1")) + require.Eventually(t, func() bool { return page.countReplies("r1", true) == 1 }, 5*time.Second, 10*time.Millisecond) + h.call("page", 1, map[string]any{"id": "r1", "op": "abort"}) + close(page.policy) + + time.Sleep(100 * time.Millisecond) + require.Empty(t, h.signer.calls()) + require.Zero(t, page.countReplies("r1", false), "an aborted request gets no answer") +} + +func TestAbortAfterDispatchCancelsSigner(t *testing.T) { + h := newHarness(t) + h.signer.release = make(chan struct{}) + require.NoError(t, h.hook.Set(context.Background(), true)) + page := h.attach(t, "page", "page", "") + h.context("page", 1, "https://example.com", "page") + + h.call("page", 1, getCall("r1")) + require.Eventually(t, func() bool { return len(h.signer.calls()) == 1 }, 5*time.Second, 10*time.Millisecond) + h.call("page", 1, map[string]any{"id": "r1", "op": "abort"}) + require.Error(t, h.signer.calls()[0].ctx.Err()) + close(h.signer.release) + + time.Sleep(100 * time.Millisecond) + require.Zero(t, page.countReplies("r1", false)) +} diff --git a/server/lib/webauthnhook/signer.go b/server/lib/webauthnhook/signer.go new file mode 100644 index 000000000..9ee2ad341 --- /dev/null +++ b/server/lib/webauthnhook/signer.go @@ -0,0 +1,201 @@ +package webauthnhook + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "strings" + "time" +) + +const ( + // signerWindow bounds one request's exchange with metro-api, retries + // included, so the page is answered well before it would give up. + signerWindow = 10 * time.Second + signerRetryDelay = 250 * time.Millisecond + signerRetryMax = 2 * time.Second + maxAnswerBytes = 64 << 10 +) + +// Request is what metro-api receives for a page's get or create call. +type Request struct { + Origin string `json:"origin"` + TopOrigin string `json:"top_origin"` + Mediation string `json:"mediation,omitempty"` + PublicKey json.RawMessage `json:"public_key"` +} + +// Answer is metro-api's response, relayed to the page. +type Answer struct { + Result string `json:"result"` + Credential json.RawMessage `json:"credential,omitempty"` + Name string `json:"name,omitempty"` + Message string `json:"message,omitempty"` +} + +var notHandled = Answer{Result: "not_handled"} + +// Signer answers page requests. op is "get" or "create". +type Signer interface { + // Configured reports whether the signer has somewhere to send requests. + Configured() bool + Answer(ctx context.Context, op string, req Request) Answer +} + +// MetroSigner forwards requests to metro-api, authenticated as the instance. +// Both values are read per request: a forked VM learns them after boot. +// +// HTTP interface: +// +// POST {KERNEL_METRO_API_BASE_URL}/webauthn/get +// POST {KERNEL_METRO_API_BASE_URL}/webauthn/create +// Authorization: Bearer +// Content-Type: application/json +// +// {"origin": "https://login.example.com", "top_origin": "https://example.com", +// "mediation": "conditional", "public_key": {...}} +// +// origin is the calling document's origin and top_origin its top-level +// document's, both as Chromium reports them, never as the page states them; +// they are equal for a top-level document. mediation is the page's value and +// is omitted when the page set none. public_key is the WebAuthn L3 +// PublicKeyCredentialRequestOptionsJSON (get) or CreationOptionsJSON (create), +// base64url-encoded. The page supplies it, so it is untrusted. +// +// A 200 response is one of: +// +// {"result": "credential", "credential": {...}} +// {"result": "not_handled"} +// {"result": "error", "name": "NotAllowedError", "message": "..."} +// +// credential is the WebAuthn L3 AuthenticationResponseJSON or +// RegistrationResponseJSON. It must carry id, rawId, response.clientDataJSON, +// and response.authenticatorData, plus response.signature for get or +// response.attestationObject for create; anything less is treated as +// not_handled. not_handled sends the page to the browser's own implementation. +// error rejects the page's call with a DOMException of that name, one of +// SecurityError, NotAllowedError, InvalidStateError, or NotSupportedError; +// another name becomes NotAllowedError. +// +// 5xx responses and transport failures are retried with backoff for up to +// signerWindow, each with an identical body, so the server can replay the +// answer to a retried create. 401, 403, every other status, and malformed +// bodies are treated as not_handled. +type MetroSigner struct { + BaseURL func() string + Token func() string + Client *http.Client + Logger *slog.Logger +} + +// Configured reports whether a metro API URL is set. +func (m *MetroSigner) Configured() bool { + return strings.TrimSpace(m.BaseURL()) != "" +} + +// Answer posts to {BaseURL}/webauthn/{op}. Server errors and transport +// failures are retried within signerWindow; anything else that is not a valid +// answer leaves the request to the browser. +func (m *MetroSigner) Answer(ctx context.Context, op string, req Request) Answer { + base, token := strings.TrimRight(m.BaseURL(), "/"), m.Token() + if base == "" || token == "" { + m.Logger.Warn("webauthn: metro-api is not configured; leaving the request to the browser") + return notHandled + } + body, err := json.Marshal(req) + if err != nil { + return notHandled + } + ctx, cancel := context.WithTimeout(ctx, signerWindow) + defer cancel() + url := base + "/webauthn/" + op + delay := signerRetryDelay + for { + answer, retry := m.post(ctx, url, token, body) + if !retry { + return validAnswer(op, answer) + } + select { + case <-ctx.Done(): + m.Logger.Warn("webauthn: metro-api did not answer in time", "op", op) + return notHandled + case <-time.After(delay): + } + delay = min(2*delay, signerRetryMax) + } +} + +func (m *MetroSigner) post(ctx context.Context, url, token string, body []byte) (Answer, bool) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return notHandled, false + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := m.Client.Do(req) + if err != nil { + m.Logger.Warn("webauthn: metro-api request failed", "url", url, "err", err) + return notHandled, ctx.Err() == nil + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + m.Logger.Warn("webauthn: metro-api refused the request", "url", url, "status", resp.StatusCode) + return notHandled, resp.StatusCode >= 500 + } + var answer Answer + if err := json.NewDecoder(io.LimitReader(resp.Body, maxAnswerBytes)).Decode(&answer); err != nil { + m.Logger.Warn("webauthn: metro-api answer is malformed", "url", url, "err", err) + return notHandled, false + } + return answer, false +} + +var errorNames = map[string]bool{ + "SecurityError": true, + "NotAllowedError": true, + "InvalidStateError": true, + "NotSupportedError": true, +} + +// validAnswer keeps the page from receiving a credential it cannot build or +// an exception type the WebAuthn API does not throw. +func validAnswer(op string, answer Answer) Answer { + switch answer.Result { + case "credential": + if !validCredential(op, answer.Credential) { + return notHandled + } + return Answer{Result: answer.Result, Credential: answer.Credential} + case "error": + if !errorNames[answer.Name] { + answer.Name = "NotAllowedError" + } + return Answer{Result: answer.Result, Name: answer.Name, Message: answer.Message} + default: + return notHandled + } +} + +func validCredential(op string, raw json.RawMessage) bool { + var credential struct { + ID string `json:"id"` + RawID string `json:"rawId"` + Response map[string]any `json:"response"` + } + if json.Unmarshal(raw, &credential) != nil || credential.ID == "" || credential.RawID == "" { + return false + } + required := []string{"clientDataJSON", "authenticatorData", "signature"} + if op == "create" { + required = []string{"clientDataJSON", "authenticatorData", "attestationObject"} + } + for _, field := range required { + if value, ok := credential.Response[field].(string); !ok || value == "" { + return false + } + } + return true +} diff --git a/server/lib/webauthnhook/signer_test.go b/server/lib/webauthnhook/signer_test.go new file mode 100644 index 000000000..4bc73ea6c --- /dev/null +++ b/server/lib/webauthnhook/signer_test.go @@ -0,0 +1,131 @@ +package webauthnhook + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +const assertion = `{"id":"Y3JlZA","rawId":"Y3JlZA","type":"public-key","response":{"clientDataJSON":"e30","authenticatorData":"AA","signature":"AA"}}` + +func metroSigner(url string) *MetroSigner { + return &MetroSigner{ + BaseURL: func() string { return url + "/" }, + Token: func() string { return "instance-jwt" }, + Client: &http.Client{}, + Logger: discard, + } +} + +func TestMetroSignerPostsRequest(t *testing.T) { + var got struct { + path, auth, contentType string + body map[string]any + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got.path, got.auth, got.contentType = r.URL.Path, r.Header.Get("Authorization"), r.Header.Get("Content-Type") + data, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(data, &got.body) + _, _ = io.WriteString(w, `{"result":"credential","credential":`+assertion+`}`) + })) + defer server.Close() + + answer := metroSigner(server.URL).Answer(context.Background(), "get", Request{ + Origin: "https://login.example.com", TopOrigin: "https://example.com", PublicKey: json.RawMessage(`{"challenge":"AAAA"}`), + }) + require.Equal(t, "credential", answer.Result) + require.JSONEq(t, assertion, string(answer.Credential)) + require.Equal(t, "/webauthn/get", got.path) + require.Equal(t, "Bearer instance-jwt", got.auth) + require.Equal(t, "application/json", got.contentType) + require.Equal(t, map[string]any{ + "origin": "https://login.example.com", "top_origin": "https://example.com", "public_key": map[string]any{"challenge": "AAAA"}, + }, got.body) +} + +func TestMetroSignerRetriesServerErrorsWithIdenticalBody(t *testing.T) { + var mu sync.Mutex + var bodies []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data, _ := io.ReadAll(r.Body) + mu.Lock() + bodies = append(bodies, string(data)) + attempt := len(bodies) + mu.Unlock() + if attempt < 3 { + w.WriteHeader(http.StatusBadGateway) + return + } + _, _ = io.WriteString(w, `{"result":"not_handled"}`) + })) + defer server.Close() + + answer := metroSigner(server.URL).Answer(context.Background(), "create", Request{ + Origin: "https://example.com", TopOrigin: "https://example.com", PublicKey: json.RawMessage(`{"challenge":"AAAA"}`), + }) + require.Equal(t, notHandled, answer) + require.Len(t, bodies, 3) + require.Equal(t, bodies[0], bodies[1]) + require.Equal(t, bodies[0], bodies[2]) +} + +func TestMetroSignerGivesUpAtDeadline(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 600*time.Millisecond) + defer cancel() + start := time.Now() + require.Equal(t, notHandled, metroSigner(server.URL).Answer(ctx, "get", Request{})) + require.Less(t, time.Since(start), 2*time.Second) +} + +func TestMetroSignerDoesNotRetryRefusals(t *testing.T) { + for _, status := range []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound} { + var attempts atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts.Add(1) + w.WriteHeader(status) + })) + require.Equal(t, notHandled, metroSigner(server.URL).Answer(context.Background(), "get", Request{})) + require.EqualValues(t, 1, attempts.Load(), "status %d", status) + server.Close() + } +} + +func TestMetroSignerWithoutConfigurationLeavesRequestToBrowser(t *testing.T) { + signer := metroSigner("") + signer.BaseURL = func() string { return "" } + require.Equal(t, notHandled, signer.Answer(context.Background(), "get", Request{})) +} + +func TestValidAnswer(t *testing.T) { + registration := `{"id":"Y3JlZA","rawId":"Y3JlZA","type":"public-key","response":{"clientDataJSON":"e30","authenticatorData":"AA","attestationObject":"oA"}}` + for name, tc := range map[string]struct { + op string + answer Answer + want Answer + }{ + "assertion": {"get", Answer{Result: "credential", Credential: json.RawMessage(assertion)}, Answer{Result: "credential", Credential: json.RawMessage(assertion)}}, + "registration": {"create", Answer{Result: "credential", Credential: json.RawMessage(registration)}, Answer{Result: "credential", Credential: json.RawMessage(registration)}}, + "assertion for create": {"create", Answer{Result: "credential", Credential: json.RawMessage(assertion)}, notHandled}, + "credential without id": {"get", Answer{Result: "credential", Credential: json.RawMessage(`{"response":{}}`)}, notHandled}, + "known error": {"get", Answer{Result: "error", Name: "SecurityError", Message: "rp mismatch"}, Answer{Result: "error", Name: "SecurityError", Message: "rp mismatch"}}, + "unknown error name": {"get", Answer{Result: "error", Name: "TypeError", Message: "x"}, Answer{Result: "error", Name: "NotAllowedError", Message: "x"}}, + "unknown result": {"get", Answer{Result: "maybe"}, notHandled}, + } { + t.Run(name, func(t *testing.T) { + require.Equal(t, tc.want, validAnswer(tc.op, tc.answer)) + }) + } +} diff --git a/server/lib/webauthnhook/state.go b/server/lib/webauthnhook/state.go new file mode 100644 index 000000000..dd582b62b --- /dev/null +++ b/server/lib/webauthnhook/state.go @@ -0,0 +1,59 @@ +// The on/off switch set by PUT /webauthn/hook is written to +// /run/kernel/webauthn-hook.json. If the kernel-images API process restarts, +// it reads the file and turns the hook back on, so browsers with armed +// passkeys keep working. /run is per boot, so a new VM always starts with the +// hook off. A file that cannot be read means off. + +package webauthnhook + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" +) + +// DefaultStatePath sits with the other per-boot state the control plane +// hands the VM. +const DefaultStatePath = "/run/kernel/webauthn-hook.json" + +type persisted struct { + Enabled bool `json:"enabled"` +} + +// loadEnabled reads the flag a previous run left. Anything unreadable leaves +// the hook off, which is the browser's own behavior. +func loadEnabled(path string, log *slog.Logger) bool { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return false + } + var p persisted + if err == nil { + err = json.Unmarshal(data, &p) + } + if err != nil { + log.Error("webauthn: could not read the hook state; leaving the hook off", "path", path, "err", err) + return false + } + return p.Enabled +} + +func saveEnabled(path string, enabled bool) error { + data, err := json.Marshal(persisted{Enabled: enabled}) + if err != nil { + return fmt.Errorf("encode webauthn hook state: %w", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create webauthn hook state directory: %w", err) + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, append(data, '\n'), 0o644); err != nil { + return fmt.Errorf("write webauthn hook state: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("replace webauthn hook state: %w", err) + } + return nil +} diff --git a/server/openapi.yaml b/server/openapi.yaml index 94f668576..0a203d426 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -1776,6 +1776,46 @@ paths: $ref: "#/components/responses/BadRequestError" "500": $ref: "#/components/responses/InternalError" + /webauthn/hook: + put: + summary: Turn the WebAuthn page hook on or off + description: > + While on, every page and iframe document gets a script that answers + `navigator.credentials.get` and `navigator.credentials.create` calls + for public-key credentials through the VM: the request goes to the + metro API with the origin Chromium reports for the calling document, + and anything the metro API does not answer falls back to the + browser's own implementation. Private keys never enter the VM. + + + Turning it off stops new documents from getting the script and tells + documents that already have it to leave every request to the browser. + Idempotent. The flag is persisted, so it survives a restart of the + instance API process. + + + Turning it on returns 400 when the VM has no metro API URL + (`KERNEL_METRO_API_BASE_URL`) to answer it. Turning it off always + succeeds. + operationId: putWebAuthnHook + x-telemetry-category: platform + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/WebAuthnHook" + responses: + "200": + description: Hook state applied + content: + application/json: + schema: + $ref: "#/components/schemas/WebAuthnHook" + "400": + $ref: "#/components/responses/BadRequestError" + "500": + $ref: "#/components/responses/InternalError" /telemetry: get: summary: Get telemetry configuration @@ -6598,6 +6638,14 @@ components: true, the VM refuses requests that would reach destinations without going through the proxy that enforces it. additionalProperties: false + WebAuthnHook: + type: object + description: Whether the WebAuthn page hook is installed in new documents. + required: [enabled] + properties: + enabled: + type: boolean + additionalProperties: false TelemetryState: type: object description: Current telemetry configuration.