From a5be5a46250b7ba61620c20007ab0f12b8bdeb9e Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:16:57 +0000 Subject: [PATCH 1/3] Add WebAuthn page hook answered through metro-api --- .github/workflows/server-test.yaml | 6 + server/cmd/api/api/api.go | 9 + server/cmd/api/api/api_test.go | 11 +- server/cmd/api/api/display_test.go | 2 +- server/cmd/api/api/telemetry_test.go | 2 +- server/cmd/api/api/webauthn.go | 22 + server/cmd/api/api/webauthn_test.go | 31 + ...tlp_credential.go => instance_identity.go} | 51 +- ...tial_test.go => instance_identity_test.go} | 22 +- server/cmd/api/main.go | 33 +- server/cmd/config/config.go | 7 + server/lib/cdpmonitor/README.md | 12 + server/lib/cdpmonitor/domains.go | 12 +- server/lib/cdpmonitor/extension.go | 50 ++ server/lib/cdpmonitor/extension_test.go | 139 +++ server/lib/cdpmonitor/monitor.go | 19 + server/lib/events/category_gen.go | 1 + server/lib/oapi/oapi.go | 696 ++++++++++----- server/lib/webauthnhook/chrome_e2e_test.go | 801 ++++++++++++++++++ server/lib/webauthnhook/fake_metro_test.go | 281 ++++++ server/lib/webauthnhook/hook.go | 479 +++++++++++ server/lib/webauthnhook/hook.js | 207 +++++ server/lib/webauthnhook/hook_test.go | 394 +++++++++ server/lib/webauthnhook/signer.go | 157 ++++ server/lib/webauthnhook/signer_test.go | 120 +++ server/lib/webauthnhook/state.go | 53 ++ server/openapi.yaml | 43 + 27 files changed, 3395 insertions(+), 265 deletions(-) create mode 100644 server/cmd/api/api/webauthn.go create mode 100644 server/cmd/api/api/webauthn_test.go rename server/cmd/api/{otlp_credential.go => instance_identity.go} (55%) rename server/cmd/api/{otlp_credential_test.go => instance_identity_test.go} (75%) create mode 100644 server/lib/cdpmonitor/extension.go create mode 100644 server/lib/cdpmonitor/extension_test.go create mode 100644 server/lib/webauthnhook/chrome_e2e_test.go create mode 100644 server/lib/webauthnhook/fake_metro_test.go create mode 100644 server/lib/webauthnhook/hook.go create mode 100644 server/lib/webauthnhook/hook.js create mode 100644 server/lib/webauthnhook/hook_test.go create mode 100644 server/lib/webauthnhook/signer.go create mode 100644 server/lib/webauthnhook/signer_test.go create mode 100644 server/lib/webauthnhook/state.go 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/cmd/api/api/api.go b/server/cmd/api/api/api.go index 9cde77239..c0b8da8b4 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 the CDP monitor's + // 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: @@ -181,6 +187,8 @@ func New( screenshotEnabled := func() bool { return telemetrySession.CategoryEnabled(events.Screenshot) } mon := cdpmonitor.New(upstreamMgr, telemetrySession.Publish, displayNum, slog.Default(), screenshotEnabled) _ = mon.SetTelemetry(false) + webauthnHook := webauthnhook.New(webauthn, mon.RetainPageDomains) + mon.SetExtension(webauthnHook) ctx, cancel := context.WithCancel(context.Background()) s := &ApiService{ @@ -199,6 +207,7 @@ func New( otlpExport: otlpExport, s2Storage: s2Storage, egressPolicy: egressPolicy, + webauthnHook: webauthnHook, webmcp: webmcpclient.NewManager(upstreamMgr), browserRepl: newBrowserReplManager(), lifecycleCtx: ctx, diff --git a/server/cmd/api/api/api_test.go b/server/cmd/api/api/api_test.go index 4300a4546..dbdc2dbca 100644 --- a/server/cmd/api/api/api_test.go +++ b/server/cmd/api/api/api_test.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/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -404,11 +405,19 @@ func testEgressPolicy(t *testing.T) *egresspolicy.State { return egresspolicy.Load(filepath.Join(t.TempDir(), "egress-policy.json"), slog.New(slog.NewTextHandler(io.Discard, nil))) } +func testWebAuthn(t *testing.T) webauthnhook.Config { + t.Helper() + return webauthnhook.Config{ + StatePath: filepath.Join(t.TempDir(), "webauthn-hook.json"), + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } +} + // 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..c85fe1e89 --- /dev/null +++ b/server/cmd/api/api/webauthn.go @@ -0,0 +1,22 @@ +package api + +import ( + "context" + + "github.com/kernel/kernel-images/server/lib/logger" + oapi "github.com/kernel/kernel-images/server/lib/oapi" +) + +// 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); 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..6cb31c99f --- /dev/null +++ b/server/cmd/api/api/webauthn_test.go @@ -0,0 +1,31 @@ +package api + +import ( + "context" + "testing" + + oapi "github.com/kernel/kernel-images/server/lib/oapi" + "github.com/stretchr/testify/require" +) + +func TestPutWebAuthnHook(t *testing.T) { + svc, err := newSvc(t, newMockRecordManager()) + require.NoError(t, err) + + 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) + } +} + +func TestPutWebAuthnHookRejectsMissingBody(t *testing.T) { + svc, err := newSvc(t, newMockRecordManager()) + require.NoError(t, err) + + 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..cc6ee170f 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/SESSION_INTEL_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.ExtensionAPIURL(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 75% rename from server/cmd/api/otlp_credential_test.go rename to server/cmd/api/instance_identity_test.go index 273de9dca..b09acf8ee 100644 --- a/server/cmd/api/otlp_credential_test.go +++ b/server/cmd/api/instance_identity_test.go @@ -38,25 +38,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-intel.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-intel.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 +67,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://intel.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..d0924cd92 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: forkidentity.FirstNonEmpty(config.SessionIntelURL, 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) @@ -500,7 +513,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..4e6f2a1cc 100644 --- a/server/cmd/config/config.go +++ b/server/cmd/config/config.go @@ -75,6 +75,11 @@ 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. + // Image variants set one or the other; SESSION_INTEL_URL wins. + SessionIntelURL string `envconfig:"SESSION_INTEL_URL" default:""` + MetroAPIBaseURL string `envconfig:"KERNEL_METRO_API_BASE_URL" default:""` } // LogValue implements slog.LogValuer, redacting secret fields. @@ -116,6 +121,8 @@ 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("session_intel_url", c.SessionIntelURL), + slog.String("metro_api_base_url", c.MetroAPIBaseURL), ) } diff --git a/server/lib/cdpmonitor/README.md b/server/lib/cdpmonitor/README.md index a150a9a07..a198daf33 100644 --- a/server/lib/cdpmonitor/README.md +++ b/server/lib/cdpmonitor/README.md @@ -134,6 +134,7 @@ targets, not requests issued before their capture domains finish initializing. | Screenshot capture via ffmpeg | `screenshot.go` | | CDP protocol types | `cdp_proto.go`, `types.go` | | Interaction tracking injected into the page | `interaction.js` | +| Non-telemetry consumers of the monitor's sessions (`Extension`) | `extension.go` | | Body/MIME capture sizing, text truncation, and typed payload helpers | `util.go` | ## Internals @@ -152,6 +153,17 @@ Only `worker` targets match this auto-attach filter, avoiding duplicate attachme of explicitly discovered OOPIFs. WebMCP's default tracker still tracks page/frame locations and does not subscribe to workers. +### Extensions + +An `Extension` (the WebAuthn page hook in `../webauthnhook`) receives every +attached session, its removal, every session-scoped protocol event, and a reset +when a connection is replaced, regardless of telemetry state. It sends commands +through the `Session` it was given, which stays bound to that connection. +Telemetry cleanup normally disables Page and Runtime on page and iframe +sessions; Chromium then drops `Runtime.addBinding` bindings and stops applying +new-document scripts. `RetainPageDomains` makes cleanup leave both enabled for +the rest of the monitor's lifetime, and waits for any disable already in flight. + ### Reconnect and shutdown `Start` subscribes before reading the current URL. The supervisor reacts to socket diff --git a/server/lib/cdpmonitor/domains.go b/server/lib/cdpmonitor/domains.go index b2aa7e853..a86fdbc56 100644 --- a/server/lib/cdpmonitor/domains.go +++ b/server/lib/cdpmonitor/domains.go @@ -133,6 +133,9 @@ func (m *Monitor) disableOptionalDomains(ctx context.Context, sessionID, scriptI if !exists { return nil } + m.pageDomainsMu.RLock() + defer m.pageDomainsMu.RUnlock() + keepDomains := m.pageDomainsRetained && isPageLikeTarget(info.targetType) var cleanupErr error if scriptID != "" { _, cleanupErr = m.send(ctx, "Page.removeScriptToEvaluateOnNewDocument", map[string]any{"identifier": scriptID}, sessionID) @@ -150,13 +153,18 @@ func (m *Monitor) disableOptionalDomains(ctx context.Context, sessionID, scriptI {"Inspector.disable", nil}, {"Page.disable", nil}, } { + if keepDomains && command.method == "Page.disable" { + continue + } if _, err := m.send(ctx, command.method, command.params, sessionID); err != nil { cleanupErr = errors.Join(cleanupErr, err) } } } - _, err := m.send(ctx, "Runtime.disable", nil, sessionID) - cleanupErr = errors.Join(cleanupErr, err) + if !keepDomains { + _, err := m.send(ctx, "Runtime.disable", nil, sessionID) + cleanupErr = errors.Join(cleanupErr, err) + } if cleanupErr == nil { m.sessionsMu.Lock() delete(m.interactionTargets, info.targetID) diff --git a/server/lib/cdpmonitor/extension.go b/server/lib/cdpmonitor/extension.go new file mode 100644 index 000000000..4a273499e --- /dev/null +++ b/server/lib/cdpmonitor/extension.go @@ -0,0 +1,50 @@ +package cdpmonitor + +import ( + "context" + "encoding/json" + + "github.com/kernel/kernel-images/server/lib/cdpclient" +) + +// Extension adds behavior to the monitor's sessions independently of +// telemetry, on every connection the monitor opens. Methods run on the +// connection's event loop and must not block. +type Extension interface { + SessionAttached(Session) + SessionRemoved(sessionID string) + // ProtocolEvent receives every session-scoped CDP event, including those + // telemetry ignores while capture is off. + ProtocolEvent(sessionID, method string, params json.RawMessage) + // Reset follows the loss of a connection: its sessions are gone. + Reset() +} + +// Session is an attached target on one monitor connection. Send stays bound +// to that connection, so it fails rather than reaching a later one. +type Session struct { + ID string + TargetID string + TargetType string + ParentFrameID string + protocol *cdpclient.Client +} + +func (s Session) Send(ctx context.Context, method string, params any) (json.RawMessage, error) { + return s.protocol.Send(ctx, method, params, s.ID) +} + +// SetExtension registers ext. Call it before Start. +func (m *Monitor) SetExtension(ext Extension) { + m.extension = ext +} + +// RetainPageDomains stops telemetry cleanup from disabling the Page and +// Runtime domains on page and iframe sessions, for the rest of the monitor's +// lifetime. Disabling them would drop an extension's bindings and pause its +// new-document scripts. Once it returns, no disable is still in flight. +func (m *Monitor) RetainPageDomains() { + m.pageDomainsMu.Lock() + m.pageDomainsRetained = true + m.pageDomainsMu.Unlock() +} diff --git a/server/lib/cdpmonitor/extension_test.go b/server/lib/cdpmonitor/extension_test.go new file mode 100644 index 000000000..b629160bb --- /dev/null +++ b/server/lib/cdpmonitor/extension_test.go @@ -0,0 +1,139 @@ +package cdpmonitor + +import ( + "context" + "encoding/json" + "slices" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type extensionRecord struct { + attached []Session + removed []string + events []string + resets int +} + +type recordingExtension struct { + mu sync.Mutex + extensionRecord +} + +func (e *recordingExtension) SessionAttached(s Session) { + e.mu.Lock() + defer e.mu.Unlock() + e.attached = append(e.attached, s) +} + +func (e *recordingExtension) SessionRemoved(sessionID string) { + e.mu.Lock() + defer e.mu.Unlock() + e.removed = append(e.removed, sessionID) +} + +func (e *recordingExtension) ProtocolEvent(sessionID, method string, _ json.RawMessage) { + e.mu.Lock() + defer e.mu.Unlock() + e.events = append(e.events, sessionID+" "+method) +} + +func (e *recordingExtension) Reset() { + e.mu.Lock() + defer e.mu.Unlock() + e.resets++ +} + +func (e *recordingExtension) snapshot() extensionRecord { + e.mu.Lock() + defer e.mu.Unlock() + return extensionRecord{attached: slices.Clone(e.attached), removed: slices.Clone(e.removed), events: slices.Clone(e.events), resets: e.resets} +} + +func TestExtensionReceivesSessionsWithTelemetryOff(t *testing.T) { + srv := newTestServer(t) + defer srv.close() + stop := make(chan struct{}) + defer close(stop) + var mu sync.Mutex + var sent []cdpMessage + go listenAndRespond(srv, stop, func(msg cdpMessage) any { + mu.Lock() + sent = append(sent, msg) + mu.Unlock() + return nil + }) + ext := &recordingExtension{} + m := New(newTestUpstream(srv.wsURL()), newEventCollector().publishFn(), 0, discardLogger, nil) + m.SetExtension(ext) + require.NoError(t, m.SetTelemetry(false)) + require.NoError(t, m.Start(context.Background())) + <-srv.connCh + + srv.sendToMonitor(t, map[string]any{"method": "Target.attachedToTarget", "params": map[string]any{ + "sessionId": "s", "targetInfo": map[string]any{"targetId": "frame", "type": "iframe", "parentFrameId": "parent"}, + }}) + require.Eventually(t, func() bool { return len(ext.snapshot().attached) == 1 }, 3*time.Second, 10*time.Millisecond) + session := ext.snapshot().attached[0] + require.Equal(t, "s", session.ID) + require.Equal(t, "frame", session.TargetID) + require.Equal(t, "iframe", session.TargetType) + require.Equal(t, "parent", session.ParentFrameID) + + _, err := session.Send(context.Background(), "Runtime.enable", nil) + require.NoError(t, err) + mu.Lock() + require.True(t, slices.ContainsFunc(sent, func(msg cdpMessage) bool { return msg.Method == "Runtime.enable" && msg.SessionID == "s" })) + mu.Unlock() + + srv.sendToMonitor(t, map[string]any{"method": "Runtime.bindingCalled", "sessionId": "s", "params": map[string]any{"name": "x", "payload": "{}"}}) + require.Eventually(t, func() bool { return slices.Contains(ext.snapshot().events, "s Runtime.bindingCalled") }, 3*time.Second, 10*time.Millisecond) + + srv.sendToMonitor(t, map[string]any{"method": "Target.detachedFromTarget", "params": map[string]any{"sessionId": "s"}}) + require.Eventually(t, func() bool { return slices.Contains(ext.snapshot().removed, "s") }, 3*time.Second, 10*time.Millisecond) + + m.Stop() + require.Positive(t, ext.snapshot().resets) +} + +func TestRetainedPageDomainsSurviveTelemetryCleanup(t *testing.T) { + srv := newTestServer(t) + defer srv.close() + var mu sync.Mutex + var methods []string + stop := make(chan struct{}) + defer close(stop) + go listenAndRespond(srv, stop, func(msg cdpMessage) any { + mu.Lock() + methods = append(methods, msg.Method) + mu.Unlock() + if msg.Method == "Page.addScriptToEvaluateOnNewDocument" { + return map[string]any{"id": msg.ID, "result": map[string]any{"identifier": "script"}} + } + return nil + }) + m := New(newTestUpstream(srv.wsURL()), newEventCollector().publishFn(), 0, discardLogger, nil) + m.RetainPageDomains() + require.NoError(t, m.SetTelemetry(true)) + require.NoError(t, m.Start(context.Background())) + defer m.Stop() + <-srv.connCh + srv.sendToMonitor(t, map[string]any{"method": "Target.attachedToTarget", "params": map[string]any{"sessionId": "s", "targetInfo": map[string]any{"targetId": "t", "type": "page"}}}) + require.Eventually(t, func() bool { + mu.Lock() + defer mu.Unlock() + return slices.Contains(methods, "Runtime.evaluate") + }, 3*time.Second, time.Millisecond) + + require.NoError(t, m.SetTelemetry(false)) + waitForTelemetryReconcile(t, m, false) + mu.Lock() + defer mu.Unlock() + require.Contains(t, methods, "Runtime.removeBinding") + require.Contains(t, methods, "Page.removeScriptToEvaluateOnNewDocument") + require.NotContains(t, methods, "Page.disable") + require.NotContains(t, methods, "Runtime.disable") +} diff --git a/server/lib/cdpmonitor/monitor.go b/server/lib/cdpmonitor/monitor.go index 4e2cfa63d..aa2d21e72 100644 --- a/server/lib/cdpmonitor/monitor.go +++ b/server/lib/cdpmonitor/monitor.go @@ -84,6 +84,10 @@ type Monitor struct { proxyRateMu sync.Mutex proxyLastEmit map[string]time.Time + extension Extension + pageDomainsMu sync.RWMutex + pageDomainsRetained bool + // Lifecycle workers survive reconnects. Capture work is drained before // replacing a connection so stale sessions cannot write into the next one. asyncWg sync.WaitGroup @@ -281,6 +285,12 @@ func (m *Monitor) handleSurfaceEvent(conn *monitorConnection, event browsersurfa if !conn.surface.SessionExists(event.SessionID) { return } + if m.extension != nil { + m.extension.SessionAttached(Session{ + ID: event.SessionID, TargetID: event.Target.ID, TargetType: event.Target.Type, + ParentFrameID: event.Target.ParentFrameID, protocol: conn.protocol, + }) + } m.handleAttachedToTarget(conn.ctx, cdpTargetAttachedToTargetParams{ SessionID: event.SessionID, TargetInfo: cdpTargetTargetInfo{ @@ -290,6 +300,9 @@ func (m *Monitor) handleSurfaceEvent(conn *monitorConnection, event browsersurfa }, }) case browsersurface.EventSessionRemoved: + if m.extension != nil { + m.extension.SessionRemoved(event.SessionID) + } m.handleDetachedFromTarget(cdpTargetDetachedFromTargetParams{SessionID: event.SessionID}) case browsersurface.EventProtocol: if event.Message.Method == "Target.targetDestroyed" { @@ -313,6 +326,9 @@ func (m *Monitor) handleSurfaceEvent(conn *monitorConnection, event browsersurfa if event.Message.Method == "Target.attachedToTarget" || event.Message.Method == "Target.detachedFromTarget" { return } + if m.extension != nil && event.Message.SessionID != "" { + m.extension.ProtocolEvent(event.Message.SessionID, event.Message.Method, event.Message.Params) + } // Preserve crash reporting even if Chrome has already detached the session. if event.Message.SessionID != "" && event.Message.Method != "Inspector.targetCrashed" { m.sessionsMu.RLock() @@ -352,6 +368,9 @@ func (m *Monitor) clearState() { m.proxyRateMu.Lock() m.proxyLastEmit = make(map[string]time.Time) m.proxyRateMu.Unlock() + if m.extension != nil { + m.extension.Reset() + } } const pendingRequestTTL = 5 * time.Minute 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..bb7e35523 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 @@ -27127,222 +27385,228 @@ var swaggerSpec = []string{ "oabg/pCBtALpE4J8L5QP2ABGDmNRnJhiOtg+KuPVdort4be/HMK++IwY+8MLVY3+FzdIjrBDKOuvtUkH", "FiMY0DXAe9LHlUvlDB9AKxrQXijgFIpTsn3UcOjnHPiOJd2U5kZyFuay4ejaQp4a66wDKuK+gojzyutc", "WxdLnLcUS9CQK14lYjMungozSOYcNDZhYAzNpPonaWcYz/4cuKzjl4I0X9R2UKnEPQP3VyySWyIBOxyy", - "9/DgT4LINu0A27MyEwoqoFf36UKVzRA39mmros3jcPi0EeuDN1rXGvNUdpZK0ELJ27OolH2Sgsmbihhv", - "LkXcvpgTdaUpPxqkMCzMrqsJbjpBxdeFS/RCjLy3RqP+LeOYo5bH+s4hub4O3KZ6dKvrvFUOLWGMNrG0", - "+S1WRJ5aTdqwM4WlwiszMQjV+r1nV6h1YalKkjOCo2WWW5ZrJ5STHEwOEFiVYLCl9+TzLopFg7KEleYg", - "UbktJv7hxSsWnCmtBrxwGn9yLBVJxg0pVvgWiEnsdA7KFuhPhUPZS629Ql5e9SBCjlwjEuAnVBUN97HX", - "b5rFdv/8+i7HJW4+9Mtb5qvCnWzv0yQSgSXQCfTpUkSXpadQ58pnt8CrpRVGQihcVO63B/BUKvSVltzt", - "7wIgG0eoc/9azU739NmfdstOF+H0/b607/m5r6W+w1Z3u9uW4Cs321sWbq/vzOq7Cf9XIZgRU2FQEYVH", - "E0C9S1KQpWXihieOEqUYMZPW+Xsa+K1NBPA/oMQrnsk0ZGqw0TbDKvaE4FyJLzJY4y3tcActDwP+2YvG", - "gc2H8kIp7XCeu/qIQ++SM03EnF9JjZGg+AQUn4n+8fblKYMphUdRItaf90Lty4VOReYLaeZGO53obCj1", - "Qa35wbPDZ98Mnj4dPPvmgGwSBwDT/t/w333SqXA0WhpbWVvFllUOfC0mC57MpRKZ4EZBPdiZdPNiAqPD", - "j0l+8H+nMnGpmA5gHF4CBGXifC6s8MvlRrAicm0K4KTiQjFz1WTpzXdNhrOSwTb7oSVa0R0ImPv3UrU0", - "SwUVcpJXor2RTMWCBEV7G50L9ZM2WdrexAiegm7W3iJuiM9Q3tby40YUfW18foldH14b4zvehjslykip", - "EiM43s2bHkTFYiIwwSjCq7ruBJPJCs1uD9LZmNuFhoEMLzExxtRwqgpcsW9t5gRx7TTYZvqP3G934mcL", - "35dNNTzm2pLc8ecmknsQWv+A3FYYkbaOuxOpmwgPBxxeKNK7zsicjtl1wk1SG8oeAXMKPsgw10Yar/Pa", - "buKpyqBXY4Ibdc440W1qyhpWhORILWrqLaFiItztOrvPRVRdX301m/H4VpcuOrBuZ7F2v/vYJ0rrHr+4", - "ytI+rgX/v6rzG2+kC9iISA/Y1WfaMPAiLu3y5KkKb1mIjpVOZIWv+xx/DPVZwtm0WJ7h55gOpTZCi3/X", - "TIy6Jq9amfEuDA+LAN+Ru1O2nfvg6FuSy5R5Zcg1rMIlnlh/UMHXi0+eh2VH1lLqWMRYmrbjWqpUX995", - "R8LSCdw9bM4KjZfTjCdYQ8QKBgXi2kz1tnOSyzrh4052TqFVjrc17QgBbpy0kU68zGQ+0dykt7t9brbp", - "1aL5QgnYMOD2+0OzeesjyhQK5PDcoucfoU/oJeHF6Umv37sSxtJ0DodPh4dBheS57B31vhoeDr/yBVBx", - "IQchE+TBNOOz4C6YNPgLvhVmJjDFF7YkNiFupHWUnlDYPivylDvBVoA25JK8kpzZIhcGQ5bSPqkSWGS/", - "UE5muHOx9bG4QhxjFz182gWt4aKH2fszqQTqARPUT2LiLarVDjMLSU9RDYAzJA+yFD1GXTIPo7zG9dNR", - "COu+0+mSDGioMMdXPa8YQf1r+GajKF5Re8NurrChsCTaQ6fZArfVV23+x0VvMLiU2l5SLqLBIJUWhMVg", - "lhcXvZ/3b5+GhybUjFZlOy+SQr5ZHOfZ4WGDazPOn86bbllxaf6wVyvIf+z3vj48bKPwOOLBdzzQJGUw", - "/NjvfdOlH5bMUTzzvbDm/WLBwQOi9yPhZZxixguVzP0hwOT9nHv93s0gvqkOSh+K0s8BAJf4jUmSpdhO", - "N4UVppQfcSICJp0baWO+5dJJMEYMTnj8Geuu9y/UVoJiu9PThdqVoF4K47hULOwCW3DFZ3TBvvT+MWpq", - "eChp7PGcxdr+Z8IB97D9C4XJlwdGkO0yQKR1RPgBUVFOvjw+PQgJ5LXax2v+BN7VRXqh0OgR9nIr7Z+G", - "Y7w9+Xc3Q3Y5/CH7IaSA8z/hY8GF2vOJxvyrzEutL6Wwfh8veuTRe8Wzwj/sVDJ509fhhToTgoXrGGKy", - "KGcynGk9y0RE7APSlWIu5PCdtlRQojVY/3fcyuRF4ebvr4T53rn8FSbnSMMeNE4YVRVobH/MZ4anwsZe", - "Xuy+5Tcvo1+RPfW13SCpb793qvMit5A45Fqkr7X50YAq8Y9eWJuf3TDRi97PH++L8wVc+WKZ3yrawVru", - "wgOLHByoBiIQNTFDbZteU7AplSQ3bKGNYLEb+0XmjJtkLq/8hMSNMzxB54YFK1QqzIU6mOuFOCDmclAO", - "eXBRHB5+lQCR4L+gyARGTUHCCJVTNp+yOXn7gXEOT1BW3HqBBC8UXBeCb6/fF2B9YQ/R+yJD6pKGGWCx", - "CxHECdCsMJZ80IB6pgXyPoSCbJN4CqOd8ZMh9x7KZed1qhy1OnqYoJEUKJhldVDKAI5ucBfqVbk6f1tF", - "1G7gMtbJLGvI7x0kBsw5iAgQNTyVSli7k2pFJ13OaSNnpcADbtwBoNcgWMFK5rrpThDHoCHfBljdCPxp", - "Uw6DuJGE2sGMHPDlM1MzklB1QjV6asD2u1P0gKt0EPjD3am730DarCtl9y+UFS4SXTkCkZ9Ut7h+RNy/", - "UJ/w+rFKIy9U+iHy4C+YWvoN4pC2OO4kPqjxdPlbIKUt1MP2wKdO5na/KiXjEjsTl3frOZiEa0IzEb0K", - "mUwVw9wNIqXks3oaPYMY+WFbCikBE1Im4PIfklL7X70ggimARlw+WmVLL74gCjokKblJsgJC9Rho0Jgf", - "VmkfWIa2XxZx17KEK3KXygS/Eih7QtCldTq3wZ+Jsk8L2PqYKj4cDZOxLAl5MtKifP5sfNOTiCeFxcAa", - "EErJnEskqlRQDjUQlKVLIqbHorJJMNqlWKLBJWxXmVw850uA4p3cmdGFSgfOyJzB7UMllMUFS8fALK9k", - "WvDMg2ki5O/wLuFP50Vwgb/tTWKjE+76SDEB3y31WQQZEuj+htTWSAgMKaaRAKo43U6IIVqhTocJ1PMd", - "IbpUqbF+slj0F90bH+hAywHueo5vCfGJiiLdf9YjPJPo+A1nSGSJex7m2OL0uOshklX1AMRJ+zl+EDx9", - "WbHANm3nfZ0nDeKVfDrOFQNAaMP8kCgL1yjvztsPiyY32egC0mCMvuV+o427fcPrRvYHIp5mS/5tCQit", - "96EGpdPlJv12eOJP9LAQXIfv40Cp7mTrOcbEIA90hGuJR7qf3r2MX6nO1kSpODUG1VMnMpNuGY0+vxmU", - "+F6mPhW7vq5XEt0JD8CNf10YrgaDCEuWGUyfE5j6pHBOq350xciWpRmBw7yMIy96DLhTVEcSo9BxvjN5", - "JahAo9evM8GtIKcu+iwt41EJ/sdNny1/rmbFybk0jfer4xCa8EC4G+HflfMAoN+IyMaplEVc6Zg481lb", - "dkIpeIpHKKPc19ltZzN/Fa5WkfchRXRz6d9m6sendtqKuIj72Oa/Cld7zachiHTjSPeiIQG1bdNyY+ng", - "ByKUtdLEd9Nx/TbByj4vsbwNFXFrxxckc0w8VPIqey9HmhthLWQ628KqhV2ZCEZWTalsVHSci2mR6Mmp", - "zM9VKdhzoZrK8PgSVLgOI+ZCkf1gvd5Pn1khLpSbU+2b9Zo9jLvyRWom3XBqhEiFvXQ6H2ozO7iB/6Cb", - "4sHN06f0jzzjUh0QsFRMh3MSGT5Oeq6VNrYai+d9nMJ6LSusT/qT+K3A9E7W2yPpmHTa+Hjoi0g9EL2s", - "1qi6LbnggSK2/JY0FlxdzeqGeHkflFFJNtDK7M75pShzMT6UWruWUvKjP8SNQg3DfQ9ySoFajhTjYif4", - "otXgQbQmu8oJUAzxZz3xmICIlQcUnA/vet46y9rZIGXTZFc+4yRlND7QwB1CFkz45iqKaIVZ11XamsW0", - "VkvN66q1dJZkfpUKXglgaOZkcmnZntLOp1r1ybdKFIuRG5g16Iqb5XPmCrR3LjA8tppAGQNhMblSuRR6", - "+/drZZiLk6YR/E76tQTQPo4Tn2BqxuG9CAP19XIA7/GN9jiKAA0ZWwIzHYeAX7L0DAZG5II79o4NBhRJ", - "e8joQYduDfjvkMBk5eYZklo+EH1W0qzelr969PqNGNtoMqU6QsfDHeP3q1GGhB0t7NWH2T/Qwa1G8d/J", - "2EOh478ZwQhrI+POnY7JJwBp54plRcjw+sfgP5RjZBk9i4HUQ6avesoGqVB8hzhJkYba+8MLdWr0VGbA", - "PJVWYpG7ZcWZK37yD7PeyyM+IFp6y47Ph9bp/ABnRWnLLhQVLCgTyg2ZrwCOWeNoztASXJfpk58a5h1c", - "YlAgJiq4UOM48gjeAUfo/C1my7/kGFs4StJ8jPnDnINJY1dwOgvv7piZBRjkil+JT/FRrWWb06Y07ok2", - "K9syZK+iy8bAu2xUhwB3o+PTC1WZxpRiwzG8PsPofZg4bGV4roS5VGv5hgPDHAf/KoRZAg7whXDCMGkv", - "VAjgnyyZzlJhSI2wIe+IEY57eGV+OBoqiLHhhYqYNo6HMmaptLmXIJxNhHUDMZ1qU3NsIaeXOtaRdcg7", - "o5S76yOVywAgSgv1HTdEQNbH/1XLW47DZWNM+XQYV7h+x5a6YKm+UEo7pgTk23nhWCa4dbid/qqAnnDQ", - "HMO+J/HMYzLF1QQ01onc+oLKcBGSll53jyqIUJ5xH/cILWaeUFAQeywKbhR0djEZGmWCuVDOcGXDleaI", - "ySkYzziV7vdzg9kgzsCo3GRSmAoXZJgcUkynInEhg+GCI83THY7ycSSidILCyPRnNzf+rTc3Oucz7tBN", - "8BSpiaL4NCgeVgCmOcHGpaPGv48pKduBX/iY6kETfcW8pB7D4Kl2NhOg+l4o2lniXJEv1ZlWo/9n4DEv", - "QyP0ivcUYDGCqMXRoUr/gWd4NNKYfZmN/UTHodAtaFysUHAL9U6ia9SC+Bt5FbB0/9ZdosWQjausyfMl", - "S4ypyg20YSKTMznJmj0aYKunuL+NrIJKKHhVE2O/ekc95BG9EJrWa+GdmPAtyPGyQmDpWhgrLsUv5ZKa", - "Mgz8fCdPmJUgMzhyMQoU1+DAdP568CefXq7ukMsWPGf/53/9b2KeViy4cjLBwqqnL85ffs/WXcKb66D6", - "VqOW+IDKDMhNlY1/vSDf/YveUTU84OeP444TIqHSNBtPbF2msQCOjRp+s8UCfvaS2OtFY7aHtRcOqPLC", - "gXDJMKR/pSLHIdvMOllTvh3ie/AzJtGNOdA834laQpmGtO6LW+YaZlolItzzfDnKhrJGVQfWNuIHTyB8", - "cgizx3hw8H65ElVaBTrxyyjTJm10rNuv1Reuo24ICG0shT+kWvjPvvmmOS/NLzIfgeDoZkqoh43Evp7y", - "mwKqVisjeDk18nJqfTcdN8NfrAuCrMrtSKGwuL3jmksgRlD4pJ00QHg+t0PmhUx0H6bc9UZrx4RyRla9", - "4X1n+I89CLWHw03Yigz67yEPJf9xNva+6wenMemEHe9TOtYx7Fs+KkliXKmQT8ftPazCYtE51xcmt6Ba", - "YINrw/NcGFaZTy0jWttx9XtRs2og4w9v4mOxV67EimoltmhKQVHqswyTkQBRJZxozbFnh1//iWqv9UvS", - "gwNMMIKFVEXkEf4AaBaTTLTUyq3v5YarS5l9LuwgPhWWfSkRspE5OT+s4GTEij3QXGKJER8uCaQK+7bf", - "qQjvb+rBuq4xE798Xhr7IxbE/D+16+/wLvffrw//vL0fTDCTydqt+X6cb1Z1unDLbt0nEa5OxMtjoFLK", - "8jm3YuWC/oLux3h5Ke/GaBLzd+a61g/F+df2nl43O/mMVuRzDC5riGLycvehHiPWRfunxnk/esg4un6c", - "P3qvDL9h9WP4bDh955Cd5uV0RJ6pPUiMgJrCsb4+IlLR5OaIDWNFkIfydayPshMyPd1UwITW+Ruy5NFK", - "Gcdw6bSyrV1PjupzdDi5Y2z40CdHo5xyN7+zq0s8NFpiejfq/Hp7v3favQYH73v0kcGZM36Xkw36+IZD", - "fU1q92/7PGGSv4ej9HeczqfoK+kAhY5+kVg6ZCZcU3EhVxhM3vj3k1MWby2V2064xMRiD2XBqoBew3XX", - "Nj/+sTR/l/k201Ws6xUhkrbsdLyVgBITFtVm8/FOYHUsqRp8tpYC+3knJcHv653ewWHXwxpjzRREveoG", - "f4mY6w+ryoYYD4jml3xrjLYu7YDS4R6/57ipXOYXweUEdWqAtb8R8y/UBtRnf7cuZXo6FcYyK2cKk5Nh", - "jmGfmj0M6HXxC5WK6if4Nzd0m4UgRDIeQQoycQUzmQi3CgUJrdmltEJ3sEdfCuH11xKRVJaLflFD9r2c", - "zYWhv2xIgM/sAjPclqYVSCsLiaMZuIYKeOUZ0ElYd8T+J5w2gWBP+8znQoWDhdpQ//Orw8PBN4eH7O13", - "B3YfOnoTcb3jV3024RlXiUip5wGeANv7n0+/qfSlg6t3/Y++/8xCl28OB3+qdVqb5tM+fo09nh0Ovo49", - "Wk6kgi0jBNNi+A7/Kg3ffqt6/cpvNGX8h3WtVvDufNNT750Y5/mKje4PwjxXTJM7MFA0L4UETZ5x1pkH", - "6EpYirsr10Be4TcewDNt6krBb0FK76Z5xj1oQDn4jUlFyHrni/tnQSzw666sgPEJOQCsnd4OiJVJ6/C+", - "YFsx6420WAjX3lIgfZm4VK66AZnKi2ZG6TS+QGyCBdI7BYV63wZ7wEu+9aIJDuyn5Rk/hN//fVwyAU7F", - "uPMFniSuAB/48V3wbgzBCJ5GA0IjP4DIT28+6MYOcDpBNQX4vxWOoBMn3ICqM91Zp0EB0xhr+4WhE5wv", - "qz2B7oA+VpA4GVVKe7dyiPUK6w8XCNpSyv3WibpKUCFs8ws86jPh1plFtSr7AVZ9t3OZ74AD9DLd7iL6", - "gtKIlw/YPsuINqU3FgkmH+1kxEJ7PkIhycOWdDdBTbk3r56oGbW4TqTCutGWevfQRir/aOe5oE/i6lXv", - "LpXu+73bell462M51e1uFg0Qbpve82mzWbeS/uoLZ5cNiX6mHg13I5hg6t2Y/4qjmYk8NStZ7KR36wvO", - "CSsGrxUMbCMfsvbeG/HsShwlB3I6rgxWEr1bdDdKuSefpE0Uc0vU/7vM63nf/DJ/N2TAq7nYVlD0FhTh", - "jU1bSGJXU3Eb5Vyo7aSz3WRcsxBfqBUTcXuuNm/zvTfya/WQO5+LuGVht4IY6uAT9tnIutmDq61K4bvu", - "Tlw+A76fG8x5D+taAjoNBthmUPbbH+5WPLS09j0AQ3nh9/B3zlRW0fXWjOV6NUHeyo3EceNe25+w1QPd", - "RSpD7O6lcsuM8bjsxsIRPyoJFc8aykSUdHvtt6OTt+JqQWqXzNl9py3+TOhIi6ma9X3iQDXbSd/D/Tz4", - "NRzKRzqVTFDOq1WM1HmJkCsGFzSieKuJt6HEk95kR9luNvm6qbY4HSU5w3/hRwnbSniNHtS3MpWtHuNB", - "WaO80XB2hoam1/bVlTeqfLLTXDWCOXHjaLaN1q9tbyxneAnHZTSmBzh7FUqe62nl1u6juXv93lzwFFf9", - "a++/BmdnrwY+od3gvLFe/1uRSu6LGU8ZgMd6ZASO7a0ywv3ae2l4G11t1fQU+vFLRGTc6LVd9hmyAuvu", - "jNNGbnMgwzxxXQzAxxUlkK8Zgz+hP8L7siJ4JthCp4Lt6cTxjFGfPsPCB99+/fX+kFXrkn/79ddt01xQ", - "8eHGaf3jcPAfP//6Vf/rppCZzVFn92ievqVlJmYp/NKFNZrYQD4Hf9ld3PAyPbMH5dY3P4zqmSXya+Hl", - "KyjjK7Vuwu3ArDwRlGnEGwskNw9DZRqbfUZqxfsqhTlXESEGt0usLTdlNHcmbcjWtoF02yXTLuNU1t48", - "Wtlg5OuU9T6bVHyjZx3FISDWb1oCNkkXmDTFV5+dvepKQj4D9oGYGWGtTzaw1fEPgyixRwiTDd58Rmcs", - "z7gSLOPWVYtaYPi3j5gZshfhn0xph+rdnFs2EUJV+xiRa+MsG09l5oQR6RHDqjCN6WL+Ktw7Ws0rnNop", - "reUB3Q+ahtuQRbC2Yy3pAP2uPLErrTscZ7/XWL/+XECYN8D+21sISqSE7s6uDyUtMwKQLPF10bliHNhU", - "JkOeiPgnQBDZFPrEosq6cJg4gMbql0WWK6uB2j0UKSgtS0UmJ8JQ2RWlHbOwTXNhxFEI1LxZQkP4A7mQ", - "HwtrUmLSA4LFGSV4Y4nOl8FfqmreoimryvRYglnuEGpq5NSnSJaQwYSyOf3tLSsog560hIzTwooyQh/D", - "OK8RitGFE8wZPp3KhHFMJ0+/4xKG7FynfElf/HIgjUiiFwtMenCOdSmHJAS/o0iyl1SZF/IgGIPZ6scI", - "7AyTN42ZNv7Dd8ucWwueIuOQm+M048trA0HXTNzkGlbBLRv7GLWhEtce+t6vfpM/7o+PyARboTUQROPK", - "SXBlr4WxTFI2EUprgnWDfdoHqawT8M4yBc32mhs06UkMPw2hebi/J7Hsdb9MgkW5RtY5CSa5itVVA99A", - "buJYkSMA+FlgUitVRVLLeErlOsn3AjNG0klzZgpFBToCUzovS2VJW5bwwVoI0jFbmCsqTRQzJXhUi/kw", - "oAaDl3PPGQ+4QgefYM3hABYAGs9OwSHWcCRMN+fKsz2680b+GpFtIlimrWtigadFKwu8fxtZK/f7dGGB", - "HRnwq5qo8rj2eVN13Z3dg/TOI50f+PINHcqKmIl0hptllUskOhXkYTY1woZiED6FCgaQ8xkHLK/VpvUE", - "dKGAw+qEZ3Nt3dGfnz17RrSEUEGuc7ykABU+yflMPOmzJx4u/NODe0Ks4AmVAH9yoa64kRApbofh4xCo", - "GuvH7O0/ieRTmdLgGji8r0WOaeis4rmda+IzFyoAkupKXwoAtQfNPohpn6qc/D999itzciF04c5E8v+w", - "j/tPGLW2VCaFJ26lVPDc6GI296lxIEnSE8vsUiVzo5UubJjPi9OTYfh34AwxbxMAuWbc/15FOOS1F8Bh", - "XGFf6lT0cWP7OKvEEz/t3USnS59ygk7ACFtkkU3heZRHK63fQigO41M5BfRFLv3qSpgl5uGBoyYXROUB", - "aHMEFjlBJVxS9k6nYvhPG7gfCQjpLNPXKpwOvLOFSn5D9hLzz+DbEzzUoYbkQVOipEQrnx05Wz736YB0", - "JYNVaA2NAQcFpbqDwwMF0x9inyWG2zmm2fLFIUklZ5nWecgYFGGlWljURTimP7pQVAEnzmzIXmD6BB7g", - "43xi90shcovrDhuB4R1G+68NO4HiBY+GT7hKtSrlYBjCiw4ADsrKVYifCQI9zOlC4VoZWpXWF0vZVLCE", - "HB4qbiYvt90XqItcIFSWBlyg0wopt56ELk98Sqv6LsDlL2VP/I32SVBLeHbNl5bK21mvBF6oeJ4c7q0W", - "s0g+KZHsIlyML3pPhuyknnEsNOt7xgI4PSHtSzOufIo0qi9OUpU2F5URgeDIr2ng62x7hoyJZ6j6EAw3", - "1UbMvFbHJ5TC5kJFP6h6rW7Kc6FzoXwZLBHvMSGBmJ9RHvuE4kgXyopMoP7tf5CWFSo+dQ9LxhnmVlcX", - "6UD1tWeLAdqFwkLx7GTqxx/QjqC9jwKeEmGDVufLpNObSb/CMeopo2B/a6UKKSFK4OdDP1Pk1U77uWC2", - "aJpqSNcmbkD+S5ctEdNOOVLN0teeAlyC7oBnyMQAc1FUEZYFFCCGRwWnsI1fjBLX2PJCkXoNdDhkr3gy", - "XwFAu8bphE+crcKC07QeGCy/RAa2kgOvSuCwSi/U1rETDwoAaAUkapY+RxvNHUPUpMOUhRaHH7L3ORae", - "Y1CkLrIpOiScQgWz9JQ4tT8cwqXnYLupzjUcBIhopX2r/oXyWEuL1orwJpTpl5YlmbYiXeMm5R55bqzS", - "iPlPYFd9lD5dLJ5AFWC/88j9Eq5iYUo4HTp+xye2lLAl9uOuBs3hOYsukP5GF/PPcNew+YhnLyLdwshz", - "foXyY6GtY39aQQ37vJHrlMIi0YVyIHRgJy5URVsPhk0vR4LO8vXhnwlBq3zTPveB1MgoSOwfv3rz6vwV", - "W1f1tLEHv8I0Pw7Zu/psfRYfFy88oUhyUiYyJr4L9xUQ7c/pfgUINy+cxXhEPN8lwopwiEtJfyyEB00X", - "ETpZUWqYL8kS/xD3kLWxPlOOkoZ5gPLVnHwmKmL3+kr75/vL+RJXEYj0jVxI15pO5zxw5hWsL2llIpaa", - "0IflwgwC7WUA9l7LxZVTx1zBZzhLr2CtX2I6JtVtIr9WM+kbSfahBo6BMsVn84QGK3wmJjKZLNnp+7Pz", - "BroX646hMN76gdmHNH024YdtxHWQWuUC7y8qqmbjKld828P0vLTu5bHyRoCVLasM+0lU8wPvj0YljdwU", - "U6x6TUR5I5ml2pOo7UW8uOY2JNEYsu9icuPV0WoCmDmtSeuBqaMUm4gLZURhIeVXXTr7+xgLShDpMnF8", - "7lXbYPxcQ1xvHCYXoYqdz4uGo9qdp8zH2Gfcy8RdNobV9mWlhOy/ClF4WYQa04WiqyEoiFr5hcVjOfHa", - "sNKwPrxrPcHtGzk+eRJUH8p1S3NrjPqndClrSN/JPwT/Z9OD4m6EBsK+MaAfVtWAMPXXchi77bk87stO", - "75Kd/JJeRZXpy88U08B5ujIewkrkOu3+jafUCrbswcrLxBE+k7JUm0GbmlQWFTa+zW+iGm14tqmY+LLu", - "JluPAjbn12orDpxhqwdFAhzi82KBn0IbGuDPIv2NnT6/0/H/6v+BLomXMsu2osIPMss6iZsS8kahE92T", - "igJb3toD6lZHDqv5TRb8fP/DFxkiCuxIzhTPKOF88LS5PU5Szu2tWPmBmv1u8JLW84iZ9xenDvsJStP5", - "fw8mVIfgPtCTXsNavf6CYKFWnxo7H1ha0qKaBKX/5YtMmhPujjac2V2QI5UddCts9bvhXLicz6zH0RTa", - "9Ljvlk7Y4Ln8xTorl/KVWY9Bd8JUXbhtPszl9oJTwiZn5s/E0+7glBvXBt06uueG/deFywtHliA5FVQZ", - "7DF+5cHiVyp4rwu3s6+xEQlW1pwdlBbWZg5NppYPof2D5kCOo2yv07rqDO07fj6b1mdKTh8tYbkRVxLv", - "v4wOV6QM6ozqncI4KnjhszK2csKQtrGKGhvDm07KkNmYvzIcW/AkdTrmX+0HJxTyGa1MDU3QvniRXkjn", - "wgN4WfxuFa60EW57GS3kuM0BSnzwy4vB3w8Hfx78/D/+7VZ8Gc/iYJF/fefEWSWy+5Otcdf46+C1VNLO", - "RTp40eSKLhfCOr7I4SyiQ6+pgKbOQ/bXghuunKBjmAj24fXLr7766s/DzZErtamc0aPBrWbiHxxuOxGY", - "yrPDZ5t4hrTe2UFiWT/0/uyzPBPcCubMkt4q6YWjvt0fkJrQ46vhjaqYzSg7K1RFRAcyGIHc4y2biKk2", - "sFCHLuW1RcRsCU8bsiV8/IJTvFJBXIskis6E98OsMmldK6OCJ0o6bHH3h9iY12qTNAujUW7VtWRRaxT9", - "xlfpN3GW9/Y0i++glcXvuLELbi7b/ZhpnTb6f/nal4pw3WcJ4Sp4Q0WwWMdzKhVWtiKc4OZSmFCn+5/k", - "didDehmvXL49/RpkQjLnuRMm9Fl/g3/LzeVDKyy1MR4wLcUOc2i7673FfYqE9odRjV6kacRMwhUfSCXV", - "ILD5Eid3pw0UTFtSozw0GtYH2ag2P90kAr2Q/QKrM+EOMM5sYoRQVR7zHrwPqZZqWGYuDDs59u4ZPnJA", - "GCqf573Vb4MHOt+EBjp/eCyojHH7u5P3E/m80ThO53UFsPuB5Nn2kJv/5Ffcu6Gh43KILFMuhk14L272", - "4dXpmyE7ixW7qbTvXGT5EbjmjMfjf9oLBcMO4ePe/nPW9n8HBwyl8UK4uU5rvS56SSaTy4teU/eDA3YG", - "T0muMKIfqxP3w0UnFm3H0ph1qGvBNTACzhodbq+4zLBytp8SLKk4PPwq+b8GA/bdq7+evGN/ffXu1YcX", - "56+OcSfY21fn378/Zm9Ozs7ZYICNBRRxgB+P2DiOPe77PzA1RvxLLKQ7gYCcMXT6rizR54zOjtgYqij3", - "2Tg1XCpK+AJ/gub8Whv8AH/PtNM/mgz+CZ7EoGDBvymoSU5kJt3yzIcZwQ+4t/APUMHOIYazj1GV2YnK", - "C/wjN/AEKpbwb5sYnSHwhOew52fIVAIwjHjiE5yYj4Y55xPseC1dMvd/KHHt/4VOPP7fQtnCiA+CZ/6D", - "nMJdmcJNw1LfVhf9RvO0ugcZxtpWvviQu5M0w23+J3YuYmJX+GvuXP5X4XDPKZzpiI1XA7jG/fitRJfK", - "R56mLwvr9GK1NUBo/oVc0crfxlX0evXuuBtyXSiPJwOPJwFbUU/kEYcxKpUyEmVLJAmtYojtmPzyMKxl", - "avQvQl2ophC1JJOCKreXgLkNK/Il0X3Hof/Y97VjgXsmuNgLhaFuPE37GOlMFErbEWY/XD+CvX0sxZ+A", - "Hc9itFzF037811fn7IC6HDi/0zid9Qi6/VCsfUw+q74XtYBY/Zd6sdCKAeJMIa1wlZdxvKh6Gtu76IV6", - "s57BDBO9IC5CDSt4ugdfE40OvUYI9hdGTRoJs9I4k+qS/QX7DJVOhR1OpUr34J/sL4QGDP4YGp0J9pe/", - "/IVd9KDPRY/Br8++pf9SG3S9pDZvtalVrxgOhzRzOWV7/xdA2PdhfkpcMxREewQZnfWnoL9Wl4p8ZA/7", - "Pb9QYfaYIDEsdY1l7F30DtwiP8BwnFzNLnp9dPnrs6ffHh6WsOvsce9XgPqxwqvPdT6g6jgTqQDVfCDb", - "UvGFDyOQCx9PtdBpkYkYMM0K5SSWtxcU40NWY5IUnIL3IfYjVh+64lnhqwPLmdIGnFcFFtOnKxiFpZfs", - "fW84HO6P+8DHrQYR5FEcQ/xWGD+1HbJTqZRIgYG7ZI6+e564Sl/gQaKNGDMols9nNJ8LVZKlp4ywBWxM", - "69/bD3SRpDlQQm70FcaeFKqSTeFYXCHNeeSMfrYGNmshQuRdxn+RnptIDJ0sYwEroeVD9kFY4fr4zXvH", - "9mO8ow8C7LP379/2/QFQkH6odJvCvPSSIhKtI0/9NOTIw68vfzw5fkabOZIp0HB0xqOTssJIKhINrE5a", - "JlTqr+IrS1+NOi2zMMRYKcgWATfYK54Jf5k3BVAqYqC/kFcDKtdcMAHyi9MT+5xJnAEQlOUqneibjREq", - "ntd/AD3uYfTkygi1K3tdVcRK+354COMN9coxPIKMj39ib+V3wYJHu4tpbOnice5RxAgwZFjG4fpp5zwT", - "KUu5APYbwOO7DZqu8fsT60ErcZ1JJQapCKP6HgP4TFOhGklKqwEiUUFGlq8PD4e9T/ngXNvUblE2ez7M", - "hmGca6zy7Gli/0630KdffV6vxPoFoxIViopOR+dkm/BMOP2LMBrqTgPTa7/anBr/MOqzqWBKDYCAHvya", - "AZQ+XHa5STPc9Sn7/vz8NCZJIVd7DAIPTv+AvhiSLC2AvAbr+DXUmZOOTUTCCyvYj0peGj519OtKXBu0", - "xYx6ahmR3TOPv71tdOunZZ7Bys/134XRvS7O7Nh+4PQAVsn8XqX3cqJlYpRsGSDjvoqwq5Ut6mw3qB6t", - "UJtP9oOwThthmQI9JmO2tthwHazMok8JV8pML/Xp0jsGhRemmaAjp77x4eVvb5nSvpwgU0Kk1r+2zEWW", - "gqpzKRp5uLr76Qn1QIdHgO9+drFJp6xc/orIYq966fAhC42/Pvwaw4FjOxkiWal5s9T8q3DncT4PyN/j", - "IODS1ugNed68wNsajNeTcbXA75aIKySwXmGa3DiJrIqq0dCRtR4V2hL9CFLYSpw8syJy+orSQMG6pMal", - "z8MzdRWEEY5TP2kirljhIErc7oQc7Ix6+dj1OHXA+bArgPaevnziNpZkghsbkjw1I+afiTGMrdMGrjEe", - "xBhmQVCwzmuZdQnDrTHPTGYET5dlKmuC8Hx9PICViSlcVvx+NeZTgnOsI/yDKYhxmJc4z0/twndrivt8", - "lZU+k/H/R1zvHRlEU6a+M+G2cIRAI88On9bp85oTgVYcckpafR6zDjw7PIR+aEaxQMKZSAI16NwNpDpa", - "zVpGxAfQq3xkj5fJE3AcuqSHXI5WuBBriDdQzyMCWwga034rO3ge06/IKFO9urCJJ/0meUjhPh8H+c1z", - "jPt8mr79hKxwX+Ir5N3UlJpyWUmL33wtOFHerqB8QqnoKFFOgTx0+2wWUhhUs9KsTLTKzA7LVCjcQhiT", - "SJlQVyLTuSgvCX5Yy3ga/O+eHX7d8HtIYMn2lA7DB588bcq2T2zJkqQtuRKyrK8PDxnZJmXKffIjtBQ0", - "0/gkk7bUFMiP+YHc/WksHOIzufuX6/SH1Bi8i8eR02zRpBtONOEGhUjtvC0sRSViSFyhKYQcAfIkETmi", - "V+HKk96Ma89JNoapDG9P6zX68yfRgSR2J8e1iID1pBqWcXodrjvHl2MTSftcV/jwhwLOJzRbsLFMj9iv", - "Vvzr48WFAmf3I/ZrOKQBYAR8v7hQY9AU6HSYwoJNZJv0BtnBQivttJIJmmbDKwBPjLZ2hdF6EzPkRn3D", - "rRvgmQ5Ojn1KVrWMGgwrE+QBcSIdwrQxFHwRTEa07CE7xgR7MCmKgiSUmPHchmvSWKZjSo14FNJgGZEI", - "CWmN8DdpqS4gZmB9yvjcJ7hwc8plPbACFAoJz6O0s+xaGGAlZObGFUDjSQHpDIfsJb7tWWbnmBLHGUyb", - "tgYN3Y+FE4nD+Q7Za8y9Vy7fBt1qZcsoA20ctrzN+aOiJ0m8LApK9oGzfo7+zWz8/+BzzPIvPMvGVGWr", - "Bk5nqbDOXxhjFkGf+IPMzdcS9nvOcxFy0c2EEgbeRuqccDxkr7WJGqPfPeGvp552KVU05c9ke9CcUk8K", - "Y1lhfQEYnRQLoaAXPq6P9/s1dj4GLB4jzmmziEUWF6GEzNhrSv+O0zrGxsTU+jFVHoxLwJs4PUVC1Je3", - "teY6mKgXgnFM0ujzoNfoacje43uXYVaolB02nEc43lxbSZeBbjSJGU9qhIXPbURGAsjM+CdsPxT3mbWH", - "7JxfCgv9EpHiQPhwMCa8GZPgxVdwGlhYTOdsNTIkMM0OjPBoXA6XCQ44WcsgMyCQcEJzaZ02S9p69Jcn", - "z+fSYb5GBLsVMsDMguNdEH7IPsCsiKThtdUy7tjTw2dfP8cOEZl5hRMA4dnCTHki6LmEEgkisc+M4C7k", - "eX5aWeZKWADtSHOMUZb1fr5NNMAdorQ6Sfw3HYTRF1dVYXUFcKKUgn1wJpRjkQN0EvDeM4LcNgboVrHV", - "rEuX54rfIPWm9MYxzS6yXtS7T4776IBqc56IPjE/YeiROqZFXgjHYR+bk4mRDw35qOCz9UPaetcG2xQE", - "H2op+D2oZHq2w/tzYG+A3uERrd9WSjv6BOORpGwCRwLYXDlJyiBW/h38RcGRIwTOawNlA/xfQ8iUWv6G", - "MMJrZ1B6oDO7krzyQDisgFjpBA2hLAEmyobhC4sVH9ik8pjo++xZIdgY/R/H+97ZiarCLArrmAAhwyla", - "lp6LxSJ3YJ82fHmh9BS8CaWSFf3mxw9vAq7afh1J2V7p8cQV06FSlPcPIUTa9xm+w2qmhSL1iWZHex4L", - "AnCnvR/LkL31o2JmeDRau+j9EjQOS8AVxdagMm2rjli6cAM9HcQEaNTiOcNMplH/wFVJ64sygHqWCJ/S", - "+kJBPjLbMHQokICWMqlYg2cWuZNob1hhWgkcirzB6EYC7fohvSgwDGATCDlN8e+ad86ZcBdUamMEQp8K", - "jkUM9qVZoLnnTjHTLK2wzG1I7cvdfn6hwrOENt4Sx9M0uJ+F/NIEneq9eshaCbjLhD/L5OdUGliqovFq", - "/iL4Ea5ys11u5t4XDVlfkuYjIh/4q5rpgoqgSpdFUur1e6htQcu4G72jXsW3DcQVEk7vqPePXy8UY5dS", - "pUfsAga66PXhC+LPEfu1MNnIBwjaI/aPRl+5g3+/6P38EbvBlh4xhMnwMACqETwdwRpwon4AxiocC1rh", - "YkLG39qShrELpss/Q9o7Yr+CNIWeVGUU3M54mkoi1NNY5tQ/A9H86L+eXo8ooRLbGyHcPvsVVy3sx/3o", - "lLfn14ITOvIucP+0e3tlk0A0Q2zTZ79SLuuT9MgzF/uPw5+HTucj+gGi8PdpNvA/H3++wFJbPeCc24+5", - "wmbvdNQA4KHOGlfS7bChKa3sHk+ZeTa9AUZe6fmrP9zYjPSzCziVWIQMN8Sv6edNyFTFHQ+4jiHVU//Y", - "NSdjE0/5TOFYOypPpbpBcjBWicA+yEi5VJbsBsjDxXW2pLYVLesLM5SDAnY7la5FZz/4da0g8KruDr7X", - "lPC/qtRNljVFfcheqIoki29PlZjg+MzFVSIyiiOvS7gPFbf3EhE6JQjZkhikEoyeuBHEo/8M/zkc/Pnn", - "X5999bFbTHqDJbeChyF/7PCz5qiCKTDegCO7oQg5vG96Q7nCMjKUo5Unjuy3tWIyscY3Gca0zkZGTIfs", - "nVYDsKnYYgKmolQkGYcuV2TMpRCAUNAlmpqENL4eDBlpdF5knKpLUOZdlKFSzUYIl95IKAPTkL2nBPwA", - "90JBO1+u1gmzgDhan5WzqgZ7o6/iV3Lm36wr140qJZBQsIxP9JW4UM++PmQ/yO/i/ENO4GpZLM6cKRSV", - "m6GhY2GC+GQtFwLzwvgdKGnrQk3ETFJhD8Es3uDLuhyFS/RCjAp1qfS1Qr2YyokZ4eJbBc675nLYpHDS", - "IdcI8SGegWgAGmyDXzGyd37NVv2L8cWr4lz8FD2A86yw7Gs8CD0tH2zgCoDmcO98HL2OP6nPb7li6r7B", - "8deXmisfi6hIW8lnVcqERMwqSYLuqRUaw0cSbcrCMWuoP/wyslhBr68f7Bxe07to00GUjdhEzLjq46ud", - "xNoq/gIPdOdrMSrt2EQwPUHqTIcrbJpwnfEYsyTS27PqbqY3HsuHAT1UBrNVe9xk6W/BOhcKb0xYi2vJ", - "xGIiQHPyxWAUhbLgpUFbvFtRIMh7RY9yaCb2RNoPDyI+MmShlXTarFV+gthAHC8UH0ILPoaqkTJxoXBa", - "3AjPp0MedRi+H7rRpzBhFuYLXYXPs44g8BVrtaLIEIwPqFNWaoXEd3nYEG1K2PQSidOsbKKbiwXltUcP", - "fS9BhAUJRXWB8BnT2xdwHZaMQfxfhYiCkuBGIUrBLC2ydsXK1L9QsAeVAC4QTr7AF21Tn3LIQ7NohGpa", - "l48BSimbPBRFKuenfTW5UHprtZxci9tt3Xyx8bnp1Q1uT8266I9jQ/J3Qb1G1Ks5Azxe6zqlgL9vtt/Z", - "MNxU++9zKpfHnlnVuYfXk3asQ/Lx4/9/AJEO1cgp1QMA", + "9/DgT4LINu0A27MyEwoqoFf36UKVzRA39mmros3jcPi0EeuDN1rXGvM/icmLws3V91pfbqXv9peKAIai", + "0eZaX0YszDLCNSWuozLf4MtDjqlp00P2ylpCy5blvH15ShV1oYLvWdQxP0n95001mTdXVm5fzIm60pTu", + "DTIyFmbX1QSvo3Bj0YVL9EKMvPNJ43VCxjFHLb4HnSOMfVm7TeX1Vtd5q5RgwhhtYqX2W6yIHM+alHtn", + "Ckt1ZGZikBsNdqhQSyyU7rBU9MkZwdHQzC3LtRPKSQ4WFIgTSzB21Dsmeo/LokH3w8J5kHfdFhP/juT1", + "JM6UVgNeOI0/OZaKJOOG9ER82sScfDoH3RHUwcKhKkGt/f2ivLlCwB95eiTAHqnIG+5jr980i+3hBvVd", + "jkvcfOiXt0y/hTvZ3qdJwgNLoBPo0x2P7n5PoWyXT9aBN2UrjITIvnhX2R6PVCk4WBqmtz9zgKgf4RXi", + "12qyvafP/rRbsr0Ip+/3pX3Pz31p+B22uttVvQRfuajfsg59fWdWn4H4vwrBjJgKg3o1vAEB6l2Svi8t", + "Ezc8cZT3xYiZtM5fO8ENbyKA/wElXvFMpiHxhI3SiVXMI8FXFB+YsGRd2uFKXR4G/LMXbR2bD+WFUtrh", + "PHd1eYfeJWeaiDm/khoDW/FFK756/ePty1MGUwpvvESsP++FUp4LnYrM1wXNjXY60dlQ6oNa84Nnh8++", + "GTx9Onj2zQGZWA4Apv2/4b/7pCLiaLQ0trK2immuHPhaTBY8mUslMsGNgvK2M+nmxQRGhx+T/OD/TmXi", + "UjEdwDi8BAi60flcWOGXy41gReTaFI9KtZJiIq7J0lsjm+yAJYNtdqtLtKIrHTD376VqaZYKqkslr0R7", + "I5mKBQmK9jY6F+onbbK0vYkRPAVVs71F3BCfcL2t5ceNKPra+HQZu74jN4arvA1XZJSRUiVGcDQ1NL3v", + "isVEYL5UhFf1RAoWoBWa3R5ztDFVDQ0DCWtino+p4VTkuGKu28wJ4tppsM30H7nf7sTPFr4vm2p4m7Yl", + "uePPTST3ILT+AbmtMCJtHXcnUjcRHg44vFCkd53R6wAmCwoXY23o+gFzCi7VMNdGGq/z2m7iqcqgV0Oc", + "G3XOONFtasoaVoRcTy1q6i2hYl7f7Tq7T61UXV99NZvx+FaXLjqwbmexdr/72CdK6x6OucrSPq7lMnhV", + "5zfe5hiwEZEesKvPtGHgFF0+M5DjLTzNITpWOtGjQt2F+mMoNxPOpsWQDj/H7C61EVrc1WZi1DUX18qM", + "d2F4WNP4jtydkgfdB0ffkiunTJNDnm4VLvHE+oMKrmt88jwsO7KWUscixtK0HddSpfr6zjsSlk7g7mFz", + "Vmi8nGY8wRoiVjAoENdmqredc3bWCR93snNGsHK8rVlUCHDjpI104mUm84nmJr3d7XOzibIWnBgq2oYB", + "t98fmq11H1GmUFyK5xY9/6Z+Qg8jL05Pev3elTCWpnM4fDo8DCokz2XvqPfV8HD4la/nigs5CIktD6YZ", + "nwXvx6TB/fGtMDOBdjxsSWxC3EjrKNuisH1W5Cl3gq0AbUiNeSU5s0UuDEZgpX1SJcDoALq7zHDnYutj", + "cYU4xi56+FINWsNFD4sRZFIJ1AMmqJ/EPGJUeh5mFnK4ohoAZ0gOcSk6wLpkHkZ5jeunoxDWfafTJRnQ", + "UGGOj5ReMYJy3vDNRlG8ovaG3VxhQ2FJtIdOswVuqy9C/Y+L3mBwKbW9pNRKg0EqLQiLwSwvLno/798+", + "qxBNqBmtynZeJIX0uTjOs8PDBk9tnD+dN92y4tL8Ya8WxP/Y7319eNhG4XHEg+94oElKyPix3/umSz+s", + "AKR45nthCf/FgoNDR+9Hwss4xYwXKpn7Q4DJ+zn3+r2bQXwiHpQuIaXbBgAu8RtzPkuxnW4KK0wpP+JE", + "BEw6N9LG9NGlz2MMgJzw+DOWke9fqK0ExXanpwu1K0G9FMZxqVjYBbbgis/ogn3p3X3U1PBQodnjOXsV", + "6s6fCQfcw/YvFOaSHhhBtssAkdYR4QdERTn58vj0IOTD12ofr/kTcBMQ6YVCo0fYy620fxqO8fbk390M", + "2eXwh+yHkNHO/4SPBRdqz+dN849ML7W+lML6fbzokYPyFc8K/05VSUxOX4cX6kwIFq5jiMminMlwpvUs", + "ExGxD0hXiqmdw3faUkF542D933ErE3gFen8lzPfO5a/Ckw7tQeOEUVWBxvbHfGZ4Kmzs5cXuW37zMrpJ", + "2VNfqg5yFPd7pzovcgt5UK5F+lqbHw2oEv/ohbX52Q0Tvej9/PG+OF/AlS+W+a2iHazlLjywyMEfbCAC", + "URMz1LbpNQWbUoV1wxbaCBa7sV9kzrhJ5vLKT0jcOMMT9NVYsEKlwlyog7leiANiLgflkAcXxeHhVwkQ", + "Cf4LamZgEBjkv1A5JScqm5PzIhjn8ARlxUsZSPBCwXUhuCr7fQHWF/YQnUkypC5pmAEWuxBBnADNCmPJ", + "pQ6oZ1og70MoyDaJpzDaGT8Z8lai1Hxep8pRq6OHCRpJgYJZFjulhObo1XehXpWr87dVRO0GLmOdzLKG", + "dOVBYsCcg4gAUcNTqYS1O6lWdNLlnDZyVoqj4MYdAHoNghWsZK6b7gRxDBrybYDVjcCfNqVkiBtJqB3M", + "yAFfPjM1IwlVJ1SjpwZsvztFD7hKB4E/3J26+w2kzbpSdv9CWeEi0ZUjEPlJdYvrR8T9C/UJrx+rNPJC", + "pR8iD/6CqaXfIA5pi+NO4oMaT5e/BVLaQj1sD1wEZW73q1IyLrEzcXkvpYNJuCY0E9GrkJhVMUxFIVLK", + "paun0dGJkVu5pQgZMCFlAi7/Ice2/9ULIpgCaMTlo1W29OILgrpDzpWbJCsg8pCBBo3pbpX2cXJo+2UR", + "dy1LuCLvr0zwK4GyJ8SQWqdzG9yzKJm2gK2Pme/D0TAZq6yQYyYtyqcDxzc9iXhSWIwTAqGUzLlEokoF", + "pYQDQVl6WGK2L6oCBaNdiiUaXMJ2lbnSc74EKN5nnxldqHTgjMwZ3D5UQklpsBIOzPJKpgXPPJgmQv4O", + "7xL+dF4Ej/7b3iQ2+hSvjxTzCd5Sn0WQIR/wb0htjYTAkGIaCaCK0+2EGIIv6nSYQHniEaJLlRrrJ4s1", + "jNFb84EOtBzgruf4lhCfqCjS/Wc9wjOJfuxwhkSWuOdhji0+nLseIllVD0CctJ/jB8HTlxULbNN23td5", + "0iBeyafjXDEAhDbMD4mycI3y7rz9sGjy+o0uIA3G6FvuN9q42ze8bmR/IOJptuTfloDQeh9KajpdbtJv", + "hyf+RA8LwRP6Pg6Uymi2nmPMc/JAR7iWR6X76d3L+JVic02UilNjUAx2IjPpltHo85tBie9l6jPL6+t6", + "YdSd8ACiEtaF4Wpsi7BkmcFsQIGpTwrntOpHV4xsWZoROMzLOAoKwPhBRWUxMage5zuTV4LqTXr9OhPc", + "CnLqos/SMh6V4H/c9Nny52qSn5xL03i/Og6RFg+EuxH+XTkPAPqNiGycSlmTlo6JM5+EZieUgqd4hDLK", + "fdngdjbzV+FqBYYfUkQ3VzJupn58aqetiIu4j23+q3C113wagkg3jnQvGhJQ2zYtN1ZCfiBCWau0fDcd", + "128TrOzzEsvbUOC3dnxBMsc8SiWvsvdypLkR1kLiti2sWtiViWCg2JSqYEXHuZjliZ6cynRjlfpDF6qp", + "qpCvqIXrMGIuFNkP1ssX9ZkV4kK5OZXyWS9BxLgrX6Rm0g2nRohU2Eun86E2s4Mb+A+6KR7cPH1K/8gz", + "LtUBAUvFdDgnkeHDvudaaWOroYXexyms17LC+hxGid8KzFZlvT2SjkmnjY+HvibWA9HLasmt25ILHihi", + "y29JY8HV1axuiJf3QRmV3AmtzO6cX4oyteRDqbVrGTI/+kPcKNQwevkgp4yu5UgxzHeCL1oNHkRrsquc", + "AIVEf9YTj/mUWHlAwfnwruets6ydDVJyUHblE2hSguYDDdwhJPWEb66iiFaYdV2lrVlMa6XhvK5ay85J", + "5lep4JUAhmZOJpeW7SntfOZYn0usRLEYuYFJkK64WT5nrkB75wKjfav5oDGuF3NFlUuht3+/VoapRWka", + "we+kX8tn7cNS8QmmZhzeizBQXy8H8B7faI+jgNaQgCYw03GIXyZLz2BgRC64Y+/YYECBwYeMHnTo1oD/", + "DvlYVm6eIUfnA9FnJWvsbfmrR6/fiLGNJlOqI3Q83DF+vxplyD/Swl591oAHOrjVpAR3MvZQJPxvRjDC", + "2si4c6dj8vlM2rliWeAyvP4x+A+lTFlGz2Ig9ZC4rJ6BQioU3yFOUqTMH/XwQp0aPZUZME+llVjkbllx", + "5oqf/MOs9/KID4iW3rLj86F1Oj/AWVEWtgtF9RfK/HhD5guaYxI8mjO0BNdl+uSnhmkUlxgUiHkXLtQ4", + "jjyCd8AROn+L2fIvOcYWjpI0H2M6NOdg0tgVnM7CuzsmmgEGueJX4jOWVEvz5rQpjXuizcq2DNmr6LIx", + "8C4b1SHA3ej49EJVpjGl2HDMFpBhMgKYOGxleK6EuVRLE4cDw5QN/yqEWQIO8IVwwjBpL1TIRzBZMp2l", + "wpAaYUMaFSMc9/DKdHc0VBBjwwsVMW0cD2XMUmlzL0E4mwjrBmI61abm2EJOL3WsI+uQd0Ypd9dHKpcB", + "QJTl6jtuiICsj/+rVusch8vGmNIDMa5w/Y4tdcFSfaGUdkwJSB/0wrFMcOtwO/1VAT3hoDmGfU/imcfc", + "kKv5dKwTufX1oeEiJC297h5VEKE84z7uEVrMPKGgIPZYFNwo6OxibjdKbHOhnOHKhivNEZNTMJ5x2M7o", + "PQmzQZyBUbnJpDAVLsgw16WYTkXiQkLGBUeapzscpRdJROkEhZHpz25u/FtvbnTOZ9yhm+ApUhNF8WlQ", + "PKwATHOCjUtHjX8fU465A7/wMZW3JvqKaVY9hsFT7WwmQPW9ULSzxLkiX6ozrUb/z8BjXoZG6BXvKcBi", + "BFGLo0OV/gPP8GikMZk0G/uJjkPdXtC4WKHgFuqdRNeoBfE38ipg6f6tu0SLIRtXWZPnS5YYU5UbaMNE", + "JmdykjV7NMBWT3F/G1kFVYTwqibGfvWOesgjeiE0rdfCOzF/XZDjZcHD0rUwFpCKX8olNWUY+PlOnjAr", + "QWZw5GIUKK7Bgen89eBPPlte3SGXLXjO/s//+t/EPK1YcOVkgnViT1+cv/yerbuEN5d19a1GLfEBlRmQ", + "myob/3pBvvsXvaNqeMDPH8cdJ0RCpWk2nti6TGMBHBs1/GaLBfzsJbHXi8ZsD0tJHFAhiQPhkmHIZks1", + "m0PynHWypvRBxPfgZ8wJHFO6eb4TtYQyq2rdF7dMncy0SkS45/nqmg1VmqoOrG3ED55A+OQQZo/x4OD9", + "ciWqtAp04pdRZoHa6Fi3XyuXXEfdEBDaWNl/SKX9n33zTXNeml9kPgLB0c2UUA8biX095TcFVK0WevBy", + "auTl1PpuOm6Gv1gXBFmV25FCYXF7xzWXQIyg8DlIaYDwfG6HzAuZ6D5MqfiN1o4J5YysesP7zvAfexBK", + "KYebsBUZ9N9DHkr+42zsfdcPTmPSCTvep+yyY9i3fFSSxLhS8J+O23tYhcWic66vs25BtcAG14bnuTCs", + "Mp9agre24+r3ombVQMYf3sTHYq9ciRXVSmzRlIKi1GcZJiMBoko40Zpjzw6//hOVkuuXpAcHmGAEC6mK", + "yCP8AdAsJploKf1b38sNV5cymV7YQXwqLPtSXmcjc3J+WMHJiBV7oLnEiik+XBJIFfZtv1NN4d/Ug3Vd", + "YyZ++bw09kcsiPl/atff4V3uv18f/nl7P5hgJpO1W/P9ON+s6nThlt26TyJcnYiXx0CllOVzbsXKBf0F", + "3Y/x8lLejdEk5u/Mda0/zwq7tvf0utnJZ7Qin2NwWUMUk5e7D/UYsS7aPzXO+9FDAtX14/zRe2X4Dasf", + "w2fD6TuH7DQvpyPyTO1BYgSUSA4yhRCpaHJzxIaxwMlD+TrWR9kJmZ5uqsdC6/wNWfJopYxT2sPKtnY9", + "OSo30uHkjrHhQ58cjXLK3fzOri7x0GiJ6d2o8+vt/d5p9xocvO/RRwZnzvhdTjbo4xsO9TWp3b/t84RJ", + "/h6O0t9xOp+iLwwEFDr6RWIllJlwTbWSXGEweePfT05ZvLVUbjvhEhNrV5T1twJ6Dddd2/z4x9L8Xebb", + "TFexTFmESNqy0/FWAkpMWFSbzcc7gdWxpGrw2VrZ7OedlAS/r3d6B4ddD2uMJWAQ9aob/CVirj+sKhti", + "PCCaX/KtMdq6tANKh3v8nuOmcplfBJcT1KkB1v5GzL9QG1Cf/d26lOnpVBjLrJwpTE6GKZN9pvkwoNfF", + "L1Qqqp/g39zQbRaCEMl4BCnIxBXMZCLcKhQktGaX0grdwR59KYTXX0tEUlku+kUN2fdyNheG/rIhnz+z", + "C8xwW5pWIK0s5MFm4Boq4JVnQCdh3RH7n3DaBII97TOfCxUOFkpd/c+vDg8H3xwesrffHdh96OhNxPWO", + "X/XZhGdcJSKlngd4Amzvfz79ptKXDq7e9T/6/jMLXb45HPyp1mltmk/7+DX2eHY4+Dr2aDmRCraMEEyL", + "4Tv8qzR8+63q9Su/0ZTxH9a1WsG7801PvXdinOcrNro/CPNcMU3uwEDRvBQSNHnGWWceoCthZfGuXAN5", + "hd94AM+0qSsFvwUpvZvmGfegAeXgNyYVIeudL+6fBbHAr7uyAsYn5ACwdno7IFYmrcP7gm3FrDfSYl1f", + "e0uB9GXiUrnqBmQqL5oZpdP4ArEJFkjvFBTqfRvsAS/51osmOLCflmf8EH7/93HJBDgV484XeJK4Anzg", + "x3fBuzEEI3gaDQiN/AAiP735oBs7wOkE1RTg/1Y4gk6ccAMqNnVnnQYFTGOs7ReGTnC+rPYEugP6WEHi", + "ZFSpVN7KIdYLxj9cIGhLZfpbJ+oqQYWwzS/wqM+EW2cW1SLzB1jE3s5lvgMO0Mt0u4voC0ojXj5g+ywj", + "2pTeWCSYfLSTEQvt+QiFJA9b0t0ENeXevHqiZtTiOpEK60ZbyvdDG6n8o53ngj6Jq1e9uxTu7/du62Xh", + "rY/lVLe7WTRAuG16z6fNZt1K+qsvnF02JPqZejTcjWCCqXdj/iuOZiby1KxksZPerS84J6wYvFYwsI18", + "yNp7b8SzK3GUHMjpuDJYSfRu0d0o5Z58kjZRzC1R/+8yr+d988v83ZABr+ZiW0HRW1CENzZtIYldTcVt", + "lHOhtpPOdpNxzUJ8oVZMxO252rzN997Ir9VD7nwu4paF3QpiqINP2Gcj62YPrraii++6O3H5DPh+bjDn", + "PSzTCeg0GGCbQdlvf7hbLdTS2vcADOWF38PfOVNZRddbM5br1QR5KzcSx417bX/CVg90F6kMsbuXyi0z", + "xuOyGwtH/KgkVDxrKBNR0u21345O3oqr9bVdMmf3nbb4M6EjLaZq1veJA9VsJ30P9/Pg13AoH+lUMkE5", + "r1YxUuclQq4YXNCI4q0m3oYST3qTHWW72eTrplLpdJTkDP+FHyVsK+E1elDfylS2eowHZcn1RsPZGRqa", + "XttXV96o8slOc9UI5sSNo9k2Wr+2vbGc4SUcl9GYHuDsVajgrqeVW7uP5u71e3PBU1z1r73/GpydvRr4", + "hHaDcx/+u1rlIJXc12aeMgCP9cgIHNtbZYT7tffS8Da62qrpKfTjl4jIuNFru+wzZAXW3RmnjdzmQIZ5", + "4roYgI8rSiBfMwZ/Qn+E92WB80ywhU4F29OJ4xmjPn2GhQ++/frr/SGrlln/9uuv26a5oOLDjdP6x+Hg", + "P37+9av+100hM5ujzu7RPH1Ly0zMUvilC2s0sYF8Dv6yu7jhZXpmD8qtb34Y1TNL5NfCy1dQxldq3YTb", + "gVl5IijTiDcWSG4ehso0NvuM1Ir3VQpzriJCDG6XWFtuymjuUP/fT20D6bZLpl3Gqay9ebSywcjXKet9", + "Nqn4Rs86ikNArN+0BGySLjBpiq8+O3vVlYR8BuwDMTPCWp9sYKvjHwZRYo8QJhu8+YzOWJ5xJVjGrasW", + "tcDwbx8xM2Qvwj+Z0g7Vuzm3bCKEqvYxItfGWTaeyswJI9IjhlVhGtPF/FW4d7SaVzi1U1rLA7ofNA23", + "IYtgbcda0gH6XXliV1p3OM5+r7F+/bmAMG+A/be3EJRICd2dXR9KWmYEIFni66JzxTiwqUyGPBHxT4Ag", + "sin0iUWVdeEwcQCN1S+LLFdWA7V7KFJQWpaKTE6EobIrSjtmYZvmwoijEKh5s4SG8AdyIT8W1qTEpAcE", + "izNK8MYSnS+Dv1TVvEVTVpXpsQSz3CHU1MipT5EsIYMJZXP621tWUAY9aQkZp4UVZYQ+hnFeIxSjCyeY", + "M3w6lQnjmE6efsclDNm5TvmSvvjlQBqRRC8WmPTgHOtSDkkIfkeRZC+pMi/kQTAGs9WPEdgZJm8aM238", + "h++WObcWPEXGITfHacaX1waCrpm4yTWsgls29jFqQyWuPfS9X/0mf9wfH5EJtkJrIIjGlZPgyl4LY5mk", + "bCKU1gTrBvu0D1JZJ+CdZQqa7TU3aNKTGH4aQvNwf09i2et+mQSLco2scxJMchWrqwa+gdzEsSJHAPCz", + "wKRWqoqklvGUynWS7wVmjKST5swUigp0BKZ0XpbKkrYs4YO1EKRjtjBXVJooZkrwqBbzYUANBi/nnjMe", + "cIUOPsGawwEsADSenYJDrOFImG7OlWd7dOeN/DUi20SwTFvXxAJPi1YWeP82slbu9+nCAjsy4Fc1UeVx", + "7fOm6ro7uwfpnUc6P/DlGzqUFTET6Qw3yyqXSHQqyMNsaoQNxSB8ChUMIOczDlheq03rCehCAYfVCc/m", + "2rqjPz979oxoCaGCXOd4SQEqfJLzmXjSZ088XPinB/eEWMETKgH+5EJdcSMhUtwOw8chUDXWj9nbfxLJ", + "pzKlwTVweF+LHNPQWcVzO9fEZy5UACTVlb4UAGoPmn0Q0z5VOfl/+uxX5uRC6MKdieT/YR/3nzBqbalM", + "Ck/cSqngudHFbO5T40CSpCeW2aVK5kYrXdgwnxenJ8Pw78AZYt4mAHLNuP+9inDIay+Aw7jCvtSp6OPG", + "9nFWiSd+2ruJTpc+5QSdgBG2yCKbwvMoj1Zav4VQHMancgroi1z61ZUwS8zDA0dNLojKA9DmCCxygkq4", + "pOydTsXwnzZwPxIQ0lmmr1U4HXhnC5X8huwl5p/Btyd4qEMNyYOmREmJVj47crZ87tMB6UoGq9AaGgMO", + "Ckp1B4cHCqY/xD5LDLdzTLPli0OSSs4yrfOQMSjCSrWwqItwTH90oagCTpzZkL3A9Ak8wMf5xO6XQuQW", + "1x02AsM7jPZfG3YCxQseDZ9wlWpVysEwhBcdAByUlasQPxMEepjThcK1MrQqrS+WsqlgCTk8VNxMXm67", + "L1AXuUCoLA24QKcVUm49CV2e+JRW9V2Ay1/Knvgb7ZOglvDsmi8tlbezXgm8UPE8OdxbLWaRfFIi2UW4", + "GF/0ngzZST3jWGjW94wFcHpC2pdmXPkUaVRfnKQqbS4qIwLBkV/TwNfZ9gwZE89Q9SEYbqqNmHmtjk8o", + "hc2Fin5Q9VrdlOdC50L5Mlgi3mNCAjE/ozz2CcWRLpQVmUD92/8gLStUfOoelowzzK2uLtKB6mvPFgO0", + "C4WF4tnJ1I8/oB1Bex8FPCXCBq3Ol0mnN5N+hWPUU0bB/tZKFVJClMDPh36myKud9nPBbNE01ZCuTdyA", + "/JcuWyKmnXKkmqWvPQW4BN0Bz5CJAeaiqCIsCyhADI8KTmEbvxglrrHlhSL1GuhwyF7xZL4CgHaN0wmf", + "OFuFBadpPTBYfokMbCUHXpXAYZVeqK1jJx4UANAKSNQsfY42mjuGqEmHKQstDj9k73MsPMegSF1kU3RI", + "OIUKZukpcWp/OIRLz8F2U51rOAgQ0Ur7Vv0L5bGWFq0V4U0o0y8tSzJtRbrGTco98txYpRHzn8Cu+ih9", + "ulg8gSrAfueR+yVcxcKUcDp0/I5PbClhS+zHXQ2aw3MWXSD9jS7mn+GuYfMRz15EuoWR5/wK5cdCW8f+", + "tIIa9nkj1ymFRaIL5UDowE5cqIq2HgybXo4EneXrwz8Tglb5pn3uA6mRUZDYP3715tX5K7au6mljD36F", + "aX4csnf12fosPi5eeEKR5KRMZEx8F+4rINqf0/0KEG5eOIvxiHi+S4QV4RCXkv5YCA+aLiJ0sqLUMF+S", + "Jf4h7iFrY32mHCUN8wDlqzn5TFTE7vWV9s/3l/MlriIQ6Ru5kK41nc554MwrWF/SykQsNaEPy4UZBNrL", + "AOy9losrp465gs9wll7BWr/EdEyq20R+rWbSN5LsQw0cA2WKz+YJDVb4TExkMlmy0/dn5w10L9YdQ2G8", + "9QOzD2n6bMIP24jrILXKBd5fVFTNxlWu+LaH6Xlp3ctj5Y0AK1tWGfaTqOYH3h+NShq5KaZY9ZqI8kYy", + "S7UnUduLeHHNbUiiMWTfxeTGq6PVBDBzWpPWA1NHKTYRF8qIwkLKr7p09vcxFpQg0mXi+NyrtsH4uYa4", + "3jhMLkIVO58XDUe1O0+Zj7HPuJeJu2wMq+3LSgnZfxWi8LIINaYLRVdDUBC18guLx3LitWGlYX1413qC", + "2zdyfPIkqD6U65bm1hj1T+lS1pC+k38I/s+mB8XdCA2EfWNAP6yqAWHqr+UwdttzedyXnd4lO/klvYoq", + "05efKaaB83RlPISVyHXa/RtPqRVs2YOVl4kjfCZlqTaDNjWpLCpsfJvfRDXa8GxTMfFl3U22HgVszq/V", + "Vhw4w1YPigQ4xOfFAj+FNjTAn0X6Gzt9fqfj/9X/A10SL2WWbUWFH2SWdRI3JeSNQie6JxUFtry1B9St", + "jhxW85ss+Pn+hy8yRBTYkZwpnlHC+eBpc3ucpJzbW7HyAzX73eAlrecRM+8vTh32E5Sm8/8eTKgOwX2g", + "J72GtXr9BcFCrT41dj6wtKRFNQlK/8sXmTQn3B1tOLO7IEcqO+hW2Op3w7lwOZ9Zj6MptOlx3y2dsMFz", + "+Yt1Vi7lK7Meg+6Eqbpw23yYy+0Fp4RNzsyfiafdwSk3rg26dXTPDfuvC5cXjixBciqoMthj/MqDxa9U", + "8F4XbmdfYyMSrKw5OygtrM0cmkwtH0L7B82BHEfZXqd11Rnad/x8Nq3PlJw+WsJyI64k3n8ZHa5IGdQZ", + "1TuFcVTwwmdlbOWEIW1jFTU2hjedlCGzMX9lOLbgSep0zL/aD04o5DNamRqaoH3xIr2QzoUH8LL43Spc", + "aSPc9jJayHGbA5T44JcXg78fDv48+Pl//Nut+DKexcEi//rOibNKZPcnW+Ou8dfBa6mknYt08KLJFV0u", + "hHV8kcNZRIdeUwFNnYfsrwU3XDlBxzAR7MPrl1999dWfh5sjV2pTOaNHg1vNxD843HYiMJVnh8828Qxp", + "vbODxLJ+6P3ZZ3kmuBXMmSW9VdILR327PyA1ocdXwxtVMZtRdlaoiogOZDACucdbNhFTbWChDl3Ka4uI", + "2RKeNmRL+PgFp3ilgrgWSRSdCe+HWWXSulZGBU+UdNji7g+xMa/VJmkWRqPcqmvJotYo+o2v0m/iLO/t", + "aRbfQSuL33FjF9xctvsx0zpt9P/ytS8V4brPEsJV8IaKYLGO51QqrGxFOMHNpTChTvc/ye1OhvQyXrl8", + "e/o1yIRkznMnTOiz/gb/lpvLh1ZYamM8YFqKHebQdtd7i/sUCe0Poxq9SNOImYQrPpBKqkFg8yVO7k4b", + "KJi2pEZ5aDSsD7JRbX66SQR6IfsFVmfCHWCc2cQIoao85j14H1It1bDMXBh2cuzdM3zkgDBUPs97q98G", + "D3S+CQ10/vBYUBnj9ncn7yfyeaNxnM7rCmD3A8mz7SE3/8mvuHdDQ8flEFmmXAyb8F7c7MOr0zdDdhYr", + "dlNp37nI8iNwzRmPx/+0FwqGHcLHvf3nrO3/Dg4YSuOFcHOd1npd9JJMJpcXvabuBwfsDJ6SXGFEP1Yn", + "7oeLTizajqUx61DXgmtgBJw1OtxecZlh5Ww/JVhScXj4VfJ/DQbsu1d/PXnH/vrq3asPL85fHeNOsLev", + "zr9/f8zenJyds8EAGwso4gA/HrFxHHvc939gaoz4l1hIdwIBOWPo9F1Zos8ZnR2xMVRR7rNxarhUlPAF", + "/gTN+bU2+AH+nmmnfzQZ/BM8iUHBgn9TUJOcyEy65ZkPM4IfcG/hH6CCnUMMZx+jKrMTlRf4R27gCVQs", + "4d82MTpD4AnPYc/PkKkEYBjxxCc4MR8Nc84n2PFaumTu/1Di2v8LnXj8v4WyhREfBM/8BzmFuzKFm4al", + "vq0u+o3maXUPMoy1rXzxIXcnaYbb/E/sXMTErvDX3Ln8r8LhnlM40xEbrwZwjfvxW4kulY88TV8W1unF", + "amuA0PwLuaKVv42r6PXq3XE35LpQHk8GHk8CtqKeyCMOY1QqZSTKlkgSWsUQ2zH55WFYy9ToX4S6UE0h", + "akkmBVVuLwFzG1bkS6L7jkP/se9rxwL3THCxFwpD3Xia9jHSmSiUtiPMfrh+BHv7WIo/ATuexWi5iqf9", + "+K+vztkBdTlwfqdxOusRdPuhWPuYfFZ9L2oBsfov9WKhFQPEmUJa4Sov43hR9TS2d9EL9WY9gxkmekFc", + "hBpW8HQPviYaHXqNEOwvjJo0EmalcSbVJfsL9hkqnQo7nEqV7sE/2V8IDRj8MTQ6E+wvf/kLu+hBn4se", + "g1+ffUv/pTboeklt3mpTq14xHA5p5nLK9v4vgLDvw/yUuGYoiPYIMjrrT0F/rS4V+cge9nt+ocLsMUFi", + "WOoay9i76B24RX6A4Ti5ml30+ujy12dPvz08LGHX2ePerwD1Y4VXn+t8QNVxJlIBqvlAtqXiCx9GIBc+", + "nmqh0yITMWCaFcpJLG8vKMaHrMYkKTgF70PsR6w+dMWzwlcHljOlDTivCiymT1cwCksv2fvecDjcH/eB", + "j1sNIsijOIb4rTB+ajtkp1IpkQIDd8kcffc8cZW+wINEGzFmUCyfz2g+F6okS08ZYQvYmNa/tx/oIklz", + "oITc6CuMPSlUJZvCsbhCmvPIGf1sDWzWQoTIu4z/Ij03kRg6WcYCVkLLh+yDsML18Zv3ju3HeEcfBNhn", + "79+/7fsDoCD9UOk2hXnpJUUkWkee+mnIkYdfX/54cvyMNnMkU6Dh6IxHJ2WFkVQkGlidtEyo1F/FV5a+", + "GnVaZmGIsVKQLQJusFc8E/4ybwqgVMRAfyGvBlSuuWAC5BenJ/Y5kzgDICjLVTrRNxsjVDyv/wB63MPo", + "yZURalf2uqqIlfb98BDGG+qVY3gEGR//xN7K74IFj3YX09jSxePco4gRYMiwjMP10855JlKWcgHsN4DH", + "dxs0XeP3J9aDVuI6k0oMUhFG9T0G8JmmQjWSlFYDRKKCjCxfHx4Oe5/ywbm2qd2ibPZ8mA3DONdY5dnT", + "xP6dbqFPv/q8Xon1C0YlKhQVnY7OyTbhmXD6F2E01J0Gptd+tTk1/mHUZ1PBlBoAAT34NQMofbjscpNm", + "uOtT9v35+WlMkkKu9hgEHpz+AX0xJFlaAHkN1vFrqDMnHZuIhBdWsB+VvDR86ujXlbg2aIsZ9dQyIrtn", + "Hn972+jWT8s8g5Wf678Lo3tdnNmx/cDpAayS+b1K7+VEy8Qo2TJAxn0VYVcrW9TZblA9WqE2n+wHYZ02", + "wjIFekzGbG2x4TpYmUWfEq6UmV7q06V3DAovTDNBR05948PL394ypX05QaaESK1/bZmLLAVV51I08nB1", + "99MT6oEOjwDf/exik05ZufwVkcVe9dLhQxYaf334NYYDx3YyRLJS82ap+VfhzuN8HpC/x0HApa3RG/K8", + "eYG3NRivJ+Nqgd8tEVdIYL3CNLlxElkVVaOhI2s9KrQl+hGksJU4eWZF5PQVpYGCdUmNS5+HZ+oqCCMc", + "p37SRFyxwkGUuN0JOdgZ9fKx63HqgPNhVwDtPX35xG0syQQ3NiR5akbMPxNjGFunDVxjPIgxzIKgYJ3X", + "MusShltjnpnMCJ4uy1TWBOH5+ngAKxNTuKz4/WrMpwTnWEf4B1MQ4zAvcZ6f2oXv1hT3+SorfSbj/4+4", + "3jsyiKZMfWfCbeEIgUaeHT6t0+c1JwKtOOSUtPo8Zh14dngI/dCMYoGEM5EEatC5G0h1tJq1jIgPoFf5", + "yB4vkyfgOHRJD7kcrXAh1hBvoJ5HBLYQNKb9VnbwPKZfkVGmenVhE0/6TfKQwn0+DvKb5xj3+TR9+wlZ", + "4b7EV8i7qSk15bKSFr/5WnCivF1B+YRS0VGinAJ56PbZLKQwqGalWZlolZkdlqlQuIUwJpEyoa5EpnNR", + "XhL8sJbxNPjfPTv8uuH3kMCS7Skdhg8+edqUbZ/YkiVJW3IlZFlfHx4ysk3KlPvkR2gpaKbxSSZtqSmQ", + "H/MDufvTWDjEZ3L3L9fpD6kxeBePI6fZokk3nGjCDQqR2nlbWIpKxJC4QlMIOQLkSSJyRK/ClSe9Gdee", + "k2wMUxnentZr9OdPogNJ7E6OaxEB60k1LOP0Olx3ji/HJpL2ua7w4Q8FnE9otmBjmR6xX63418eLCwXO", + "7kfs13BIA8AI+H5xocagKdDpMIUFm8g26Q2yg4VW2mklEzTNhlcAnhht7Qqj9SZmyI36hls3wDMdnBz7", + "lKxqGTUYVibIA+JEOoRpYyj4IpiMaNlDdowJ9mBSFAVJKDHjuQ3XpLFMx5Qa8SikwTIiERLSGuFv0lJd", + "QMzA+pTxuU9w4eaUy3pgBSgUEp5HaWfZtTDASsjMjSuAxpMC0hkO2Ut827PMzjEljjOYNm0NGrofCycS", + "h/MdsteYe69cvg261cqWUQbaOGx5m/NHRU+SeFkUlOwDZ/0c/ZvZ+P/B55jlX3iWjanKVg2czlJhnb8w", + "xiyCPvEHmZuvJez3nOci5KKbCSUMvI3UOeF4yF5rEzVGv3vCX0897VKqaMqfyfagOaWeFMaywvoCMDop", + "FkJBL3xcH+/3a+x8DFg8RpzTZhGLLC5CCZmx15T+Had1jI2JqfVjqjwYl4A3cXqKhKgvb2vNdTBRLwTj", + "mKTR50Gv0dOQvcf3LsOsUCk7bDiPcLy5tpIuA91oEjOe1AgLn9uIjASQmfFP2H4o7jNrD9k5vxQW+iUi", + "xYHw4WBMeDMmwYuv4DSwsJjO2WpkSGCaHRjh0bgcLhMccLKWQWZAIOGE5tI6bZa09egvT57PpcN8jQh2", + "K2SAmQXHuyD8kH2AWRFJw2urZdyxp4fPvn6OHSIy8wonAMKzhZnyRNBzCSUSRGKfGcFdyPP8tLLMlbAA", + "2pHmGKMs6/18m2iAO0RpdZL4bzoIoy+uqsLqCuBEKQX74EwoxyIH6CTgr8WEF26uDuZaX1aK96wU8cLs", + "j1r1/cUZs2TimzBJ8cAG2Uw4S26IMvf39ZCjfaz4lZxxp80wMQKjYHhmh+D3Q8/Vzb9TeqjgzwHyFtWm", + "ZHAplqzSMD6Hk/n+qGaKnOmyLDRsiMannajjayNnUsWE8DFHZKhpl/g3obBMnyJeLcm4UIda5uvFha+k", + "KK28Mj+hVMQSnEoAqjepnBp5xZ1gl2JpGaWQF8oJU74aXajzwvjEm1BTFF0WLUrUMENLbGTmTaLQlc5k", + "yI5jEzoeb13AdJPSp/SVfolklo0FBmApTSsZsvKhwWc9zfjsHlLWt5gvfhKTF4C03wPOPsylpjbEJ77N", + "rI9dp0b4HrwkfgOp4wEZ8QTDvIk9AENhdMHV0+kO/Ag8tciNbIBuXlufmYgnVfyYqTelW49pv1EVRDvA", + "yXEfHeJtzhPRJ2VMGML4mKZ9IRwHvt6c3JB8+shnDt1oHvLtaW2wTUk5Qm0XvweVzPN2eH8BNQ3QOzzq", + "99tK+8cYBTySlE3gSIAxVE6SMhqWfwf/dUC2kMhDGyhj4v8aQubm8jeEEbwvwiUMOrMrySsOC8MKiJVO", + "0BDKpGDifhi+sFiBhk0qzg2+z54Vgo3RH3u8750vqUrVorCOCVB6OUXvk/uKWOQO3ssMX14oPQXvZqlk", + "5b7144c3AVdtv46kbK/0wOSK6VC5zvurESLt+4oDYTXTQtF1jmZHex4LlHCnvV/dkL31oyK7wUc0F73x", + "SomDwBXF+qFaYKuOobpwAz0dxISM1OI5w8zK8T6Eq5LWF4mB62IifIr9CwX5EW3D0KFgC1rupWINnqIk", + "NbU39DKtBA5F3qlkIYF2/ZDuGBgGsAmEnKb4d81b8Ey4Cyr9M4JLCBVAjBjsS0VBc8+dYuZrWmGZa5Xa", + "l7v9/EKFZ1Jt/MsAT9PgDhvy3RN0qj/tIWslwLYS/iyLMVCpcqmKRlPhi+DXvMrNdhGq3jcWWV+S5iMi", + "H/irmnmHijJLl0VS6vV7ePuDlnE3eke9iq8tqM9IOL2j3j9+vVCMXUqVHrELGOii14cviD9H7NfCZCMf", + "sGyP2D8afXcP/v2i9/NH7AZbesQQJsPDAKigDo1gDThRPwBjFY4FrXAxIQN5bUnD2AXLd5wh7R2xX0G7", + "h55U9RjcYHmaSiLU01h22T9L0/zov55ejyjBG9sbIdw++xVXLezH/egkvOfXghM68i65/7R7e2WTQDRD", + "bNNnv1Ju/ZP0yDMX+4/Dn4dO5yP6AbKC7NNs4H8+/nyBpf96wDm3H3OFzd7pqAHAQ501rqTbYUNTWtk9", + "njLzbHoDjLzS81d/uLEZ3Rcv4FRiUUTcEL+mnzchUxV3POA6hlRP/WPXHLFNPOUzhYfuqDyV6gbJwVi1", + "BvsgI+VSWbJjIg8X19mS2la0rC/s4Q4UsNupdC06+8GvawXKV3V3iAWhAiRVpW6yrCnqQ/ZCVSRZvK1W", + "chTEZ3euEpFRXou6hPtQCcMpEaFTwqItiYoqyTESN4L8GD/Dfw4Hf/7512dffeyWI6PhZamChyGf9fCz", + "5syDKTDegCO7oQgF4Gx6073CslaUM5onjt6TasWtZEigQoZ6rbOREdMhe6fVAGy8tpiA6ToVScahyxU9", + "LlFIUigwFU3fQhpfn4qMxjovMk7VbigTOMpQqWYjhEtvtpQRbsjeU0EQgHuhoJ0vn+2EWUBcv88SXFWD", + "/SOUt3VR2anyulGlBBIKlvGJvhIX6tnXh+wH+V2cf8hRXi3Tx5kzhaLyVzR0LJQSXWjkQmCeKr8DJW1d", + "qImYSSo0JJhFi2JZJ6hwiV6IUaEuFRitQC8m25QRLr6d4rxrLtBNCicdco0QH8iC8/blKQ22Ic4B2Tu/", + "ZqvxDvgCXwl2eIoRCXlWWPY1HoSelg/IcAXA5zkfDBGjIHqf2G7kV0zdNwQiBEtnfLwmC1zJZ1XKhETM", + "KkmC7qkVGsNHW23KQlZrqD/8MrLqQa+vH+wcXpOfRtNBlI3YRMy46qMXgcRaT/4CD3Tna8Mq7dhEMD1B", + "6kyHK2yacJ3xGEMp0tuz6m6mNx7LGQI9VAazVXvcZOlvwToXCm9MWBtwycRiIkBz8sWpFIXW4aVBW7xb", + "UWDae0VOAvhs5Ym0Hx5ofaTaQivptFmrRAexyjheKIZGZm8jgjJxoXBa3AjPp0NdBxi+H7rRpzBhFuYL", + "XYWv+4Ag8FV9tcLREIwPqFNWahdFPyHYEG1K2PSmgtOsbKKbiwXV2cCIIS9BhAUJRXXK0K3C2xdwHZaM", + "QfxfhYiCkuBGIUrBdS2ydsXK1L9Q+OZTBpSCcPIFB2mb+lTTAppFI1TTunxMYkrVLaBIWzk/7atbhlKA", + "q+UtW8IA6uaLjc/fr25we2rWRX8cG4pRCOo1ol7NFSnwWtepJMV9s/3OhuGmWqSfU7k89syqzj28nrRj", + "XaSPH///AwD12IA3iNoDAA==", } // 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..2c5ea2396 --- /dev/null +++ b/server/lib/webauthnhook/chrome_e2e_test.go @@ -0,0 +1,801 @@ +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 and the real +// cdpmonitor connection, answered by a fake metro-api that signs with ES256. +// 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, []string{"internal", "hybrid"}, 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) + }) + + 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 on and off leaves the hook working", func(t *testing.T) { + tab := env.open(t, login+"/page") + require.NoError(t, env.monitor.SetTelemetry(true)) + time.Sleep(time.Second) + require.NoError(t, env.monitor.SetTelemetry(false)) + time.Sleep(2 * time.Second) + result := tab.settle(t, pageGet(registered.ID)) + require.Empty(t, result.Error) + requireHookCredential(t, result.Credential, "assertion") + + tab.navigate(t, login+"/page?after-telemetry") + tab.waitHooked(t) + result = tab.settle(t, pageGet(registered.ID)) + require.Empty(t, result.Error) + requireHookCredential(t, result.Credential, "assertion") + }) + + t.Run("disabling stops new documents from getting the hook", func(t *testing.T) { + tab := env.open(t, login+"/page") + tab.addVirtualAuthenticator(t) + require.NoError(t, env.hook.Set(ctx, false)) + defer func() { require.NoError(t, env.hook.Set(ctx, true)) }() + + before := env.metro.count() + 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.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("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 lost connection falls back, and a new one answers the same document", func(t *testing.T) { + tab := env.open(t, login+"/page") + env.monitor.Stop() + // Chromium drops a tab's virtual authenticators when another DevTools + // session on it detaches, so add one only after the monitor'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) + require.GreaterOrEqual(t, time.Since(started), 10*time.Second) + require.Equal(t, before, env.metro.count()) + + require.NoError(t, env.monitor.Start(ctx)) + 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 + 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(staticUpstream(browser), func(events.Event) (events.Envelope, bool) { return events.Envelope{}, false }, 0, discard, nil) + require.NoError(t, monitor.SetTelemetry(false)) + 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, + }, monitor.RetainPageDomains) + monitor.SetExtension(hook) + require.NoError(t, monitor.Start(ctx)) + t.Cleanup(monitor.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, 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. + raw := e.driver.call(t, "", "Target.createTarget", map[string]any{"url": e.site("login.example.test") + "/page"}) + 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.waitHooked(t) + tb.navigate(t, pageURL) + tb.waitHooked(t) + 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}) + 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 +} + +type staticUpstream string + +func (u staticUpstream) Current() string { return string(u) } + +func (u staticUpstream) Subscribe() (<-chan string, func()) { + return make(chan string), func() {} +} + +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/fake_metro_test.go b/server/lib/webauthnhook/fake_metro_test.go new file mode 100644 index 000000000..0a8bccd64 --- /dev/null +++ b/server/lib/webauthnhook/fake_metro_test.go @@ -0,0 +1,281 @@ +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 +) + +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": []string{"internal", "hybrid"}, + }, + }) + } + 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..2b3ef6d87 --- /dev/null +++ b/server/lib/webauthnhook/hook.go @@ -0,0 +1,479 @@ +// Package webauthnhook answers pages' navigator.credentials.get/create +// publicKey requests with passkeys held outside the VM. +// +// A script installed in every page and iframe document replaces the WebAuthn +// entry points and sends each request through a CDP binding on the +// cdpmonitor's persistent connection. 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. +package webauthnhook + +import ( + "context" + _ "embed" + "encoding/json" + "log/slog" + "net/url" + "sync" + "time" + + "github.com/kernel/kernel-images/server/lib/cdpmonitor" +) + +// bindingName must match BINDING and replyFunction must match REPLY in hook.js. +const ( + bindingName = "__kernelPasskey" + replyFunction = "__kernelPasskeyReply" +) + +const ( + setupTimeout = 10 * time.Second + deliverTimeout = 5 * time.Second + maxCallBytes = 256 << 10 + maxRequestIDLen = 64 + maxSessionCalls = 16 + answeredRetained = 30 * time.Second + maxFrameDepth = 32 +) + +//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 +} + +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. +type call struct { + reply json.RawMessage + answeredAt time.Time +} + +type sender interface { + Send(ctx context.Context, method string, params any) (json.RawMessage, error) +} + +type session struct { + id string + targetID string + targetType string + parentFrameID string + cdp sender + + setupMu sync.Mutex + domains bool // setupMu: Runtime, Page, and the binding are enabled + scriptID string // setupMu: new-document registration while enabled + + contexts map[int]executionContext // Hook.mu; main-world contexts only + calls map[callKey]*call // Hook.mu +} + +// Hook implements cdpmonitor.Extension. +type Hook struct { + cfg Config + retain func() + log *slog.Logger + + setMu sync.Mutex // serializes Set + + mu sync.Mutex + enabled bool + active bool // sessions get the binding; stays true once enabled + sessions map[string]*session +} + +var _ cdpmonitor.Extension = (*Hook)(nil) + +// New restores the flag a previous run of the process left. retainPageDomains +// is called before the hook first enables domains, so telemetry cleanup stops +// disabling them. +func New(cfg Config, retainPageDomains func()) *Hook { + h := &Hook{cfg: cfg, retain: retainPageDomains, log: cfg.Logger, sessions: make(map[string]*session)} + if loadEnabled(cfg.StatePath, cfg.Logger) { + h.retain() + h.enabled, h.active = true, true + } + return h +} + +// Set turns the hook on or off, persisting the flag first, and waits until +// the current sessions are reconciled or ctx ends. Pages hooked while it was +// on keep their hook while it is off; their requests are left to the browser. +func (h *Hook) Set(ctx context.Context, enabled bool) error { + h.setMu.Lock() + defer h.setMu.Unlock() + if err := saveEnabled(h.cfg.StatePath, enabled); err != nil { + return err + } + h.mu.Lock() + activate := enabled && !h.active + h.mu.Unlock() + if activate { + h.retain() + } + h.mu.Lock() + h.enabled = enabled + h.active = h.active || enabled + sessions := make([]*session, 0, len(h.sessions)) + for _, s := range h.sessions { + sessions = append(sessions, s) + } + h.mu.Unlock() + + var wg sync.WaitGroup + for _, s := range sessions { + wg.Go(func() { h.reconcile(s) }) + } + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-ctx.Done(): + } + return nil +} + +func (h *Hook) SessionAttached(s cdpmonitor.Session) { + if s.TargetType != "page" && s.TargetType != "iframe" { + return + } + go h.reconcile(h.track(&session{ + id: s.ID, targetID: s.TargetID, targetType: s.TargetType, parentFrameID: s.ParentFrameID, cdp: s, + })) +} + +func (h *Hook) track(s *session) *session { + s.contexts = make(map[int]executionContext) + s.calls = make(map[callKey]*call) + h.mu.Lock() + h.sessions[s.id] = s + h.mu.Unlock() + return s +} + +func (h *Hook) SessionRemoved(sessionID string) { + h.mu.Lock() + delete(h.sessions, sessionID) + h.mu.Unlock() +} + +func (h *Hook) Reset() { + h.mu.Lock() + clear(h.sessions) + h.mu.Unlock() +} + +// reconcile brings one session to the current flag. Domains and the binding +// stay once added so already-hooked documents keep an answer. +func (h *Hook) reconcile(s *session) { + s.setupMu.Lock() + defer s.setupMu.Unlock() + h.mu.Lock() + active, enabled, live := h.active, h.enabled, h.sessions[s.id] == s + h.mu.Unlock() + if !active || !live { + return + } + ctx, cancel := context.WithTimeout(context.Background(), setupTimeout) + defer cancel() + if err := h.reconcileSession(ctx, s, enabled); err != nil { + h.mu.Lock() + live = h.sessions[s.id] == s + h.mu.Unlock() + if live { + h.log.Warn("webauthn: failed to set up hook", "session", s.id, "target_type", s.targetType, "err", err) + } + } +} + +func (h *Hook) reconcileSession(ctx context.Context, s *session, enabled bool) error { + if !s.domains { + for _, command := range []struct { + method string + params any + }{ + {"Runtime.enable", nil}, + {"Page.enable", nil}, + {"Runtime.addBinding", map[string]any{"name": bindingName}}, + } { + if _, err := s.cdp.Send(ctx, command.method, command.params); err != nil { + return err + } + } + s.domains = true + } + switch { + case enabled && s.scriptID == "": + raw, err := s.cdp.Send(ctx, "Page.addScriptToEvaluateOnNewDocument", map[string]any{ + "source": hookSource, "runImmediately": true, + }) + if err != nil { + return err + } + var result struct { + Identifier string `json:"identifier"` + } + if err := json.Unmarshal(raw, &result); err != nil { + return err + } + s.scriptID = result.Identifier + case !enabled && s.scriptID != "": + if _, err := s.cdp.Send(ctx, "Page.removeScriptToEvaluateOnNewDocument", map[string]any{"identifier": s.scriptID}); err != nil { + return err + } + s.scriptID = "" + } + 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, + } + } + 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) + 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.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") { + 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} + if existing := s.calls[key]; existing != nil { + reply := existing.reply + h.mu.Unlock() + if reply == nil { + reply = ack(pc.ID) + } + go h.deliver(s, ec, reply) + return + } + for k, c := range s.calls { + if c.reply != nil && time.Since(c.answeredAt) > answeredRetained { + delete(s.calls, k) + } + } + if len(s.calls) >= maxSessionCalls { + h.mu.Unlock() + go h.deliver(s, ec, encodeReply(pc.ID, notHandled)) + return + } + c := &call{} + s.calls[key] = c + enabled := h.enabled + topOrigin, topKnown := h.topOriginLocked(s) + h.mu.Unlock() + + go func() { + 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) { + answer = h.cfg.Signer.Answer(context.Background(), pc.Op, Request{ + Origin: origin, TopOrigin: top, Mediation: pc.Mediation, PublicKey: pc.PublicKey, + }) + } + h.log.Info("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) + }() +} + +// 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" { + for _, ec := range s.contexts { + if ec.frameID == s.targetID { + return ec.origin, true + } + } + return "", false + } + 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 { + for _, ec := range s.contexts { + if ec.frameID == frameID { + 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. 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": + 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..d21bcda65 --- /dev/null +++ b/server/lib/webauthnhook/hook.js @@ -0,0 +1,207 @@ +// 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. +// 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) falls back to the browser. +(function () { + const BINDING = '__kernelPasskey'; + const REPLY = '__kernelPasskeyReply'; + const RESEND_MS = 1000; + const SILENCE_MS = 10000; + + if (Object.prototype.hasOwnProperty.call(globalThis, REPLY)) 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(); + + Object.defineProperty(globalThis, REPLY, { + value: function (message) { + 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'); + } + + // 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; + 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))); + } + function send() { + if (now() - request.heard >= SILENCE_MS) { + request.finish(null); + return; + } + try { + globalThis[BINDING](payload); + } catch { + // No binding yet: same as an unanswered send. + } + 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); + 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..54e97fa94 --- /dev/null +++ b/server/lib/webauthnhook/hook_test.go @@ -0,0 +1,394 @@ +package webauthnhook + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/kernel/kernel-images/server/lib/cdpmonitor" + "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 + forbidden map[string]bool // permissions-policy features reported as not allowed +} + +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() + defer f.mu.Unlock() + f.commands = append(f.commands, command{method, decoded}) + 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) + 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 +} + +type fakeSigner struct { + mu sync.Mutex + requests []recordedRequest + answer Answer + release chan struct{} +} + +func (f *fakeSigner) Answer(_ context.Context, op string, req Request) Answer { + f.mu.Lock() + f.requests = append(f.requests, recordedRequest{op, req}) + 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...) +} + +type harness struct { + hook *Hook + signer *fakeSigner + path string + retained int +} + +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, signer: &fakeSigner{answer: Answer{Result: "credential", Credential: json.RawMessage(`{"id":"cred"}`)}}} + h.hook = New(Config{StatePath: path, Signer: h.signer, Logger: discard}, func() { h.retained++ }) + return h +} + +func (h *harness) attach(t *testing.T, id, targetType, parentFrameID string) *fakeSession { + t.Helper() + fake := &fakeSession{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 TestSetInstallsAndRemovesHook(t *testing.T) { + h := newHarness(t) + page := h.attach(t, "page", "page", "") + require.Empty(t, page.methods(), "an inactive hook must not touch sessions") + h.hook.SessionAttached(cdpmonitor.Session{ID: "worker", TargetType: "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)) + require.Equal(t, 1, h.retained) + 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"]) + + require.NoError(t, h.hook.Set(context.Background(), true)) + require.Len(t, page.methods(), 4, "enabling twice must not register the script twice") + + require.NoError(t, h.hook.Set(context.Background(), false)) + require.Equal(t, "Page.removeScriptToEvaluateOnNewDocument", page.methods()[4]) + require.Equal(t, "script-1", page.commands[4].params["identifier"]) + require.Len(t, page.methods(), 5, "disabling keeps the domains and binding for hooked documents") + + require.NoError(t, h.hook.Set(context.Background(), true)) + require.Equal(t, []string{"Page.addScriptToEvaluateOnNewDocument"}, page.methods()[5:]) + require.Equal(t, 1, h.retained) + + later := h.attach(t, "later", "iframe", "page") + require.Equal(t, []string{"Runtime.enable", "Page.enable", "Runtime.addBinding", "Page.addScriptToEvaluateOnNewDocument"}, later.methods()) +} + +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.Equal(t, 1, restarted.retained, "a restored hook retains page domains before the monitor starts") + 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.Zero(t, again.retained) + 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) { + h := newHarness(t) + require.NoError(t, h.hook.Set(context.Background(), true)) + page := h.attach(t, "page", "page", "") + h.context("page", 1, "http://localhost:8080", "page") + h.call("page", 1, getCall("r1")) + require.Equal(t, "credential", page.finalReply(t, "r1")["result"]) +} + +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 TestSessionCallsAreBounded(t *testing.T) { + h := newHarness(t) + h.signer.release = make(chan struct{}) + defer close(h.signer.release) + 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 maxSessionCalls { + h.call("page", 1, getCall("r"+string(rune('a'+i)))) + } + h.call("page", 1, getCall("overflow")) + require.Equal(t, "not_handled", page.finalReply(t, "overflow")["result"]) +} diff --git a/server/lib/webauthnhook/signer.go b/server/lib/webauthnhook/signer.go new file mode 100644 index 000000000..d4b6824bc --- /dev/null +++ b/server/lib/webauthnhook/signer.go @@ -0,0 +1,157 @@ +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 { + 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. +type MetroSigner struct { + BaseURL func() string + Token func() string + Client *http.Client + Logger *slog.Logger +} + +// 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..4b63db974 --- /dev/null +++ b/server/lib/webauthnhook/signer_test.go @@ -0,0 +1,120 @@ +package webauthnhook + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "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 TestMetroSignerRetriesServerErrors(t *testing.T) { + var attempts atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if attempts.Add(1) < 3 { + w.WriteHeader(http.StatusBadGateway) + return + } + _, _ = io.WriteString(w, `{"result":"not_handled"}`) + })) + defer server.Close() + + answer := metroSigner(server.URL).Answer(context.Background(), "create", Request{}) + require.Equal(t, notHandled, answer) + require.EqualValues(t, 3, attempts.Load()) +} + +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..9efd269ed --- /dev/null +++ b/server/lib/webauthnhook/state.go @@ -0,0 +1,53 @@ +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, so it survives restarts of the API process. +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..1f4b4a58e 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -1776,6 +1776,41 @@ 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. Documents + that already have it keep it, and their requests fall back to the + browser. Idempotent. The flag is persisted, so it survives a restart + of the instance API process. + 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 +6633,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. From 221567c7dad31d9e5ee8d8e818dfcdf9b3d8f426 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:50:04 +0000 Subject: [PATCH 2/3] Address WebAuthn hook review: call cap, abort, release domains, retries --- server/README.md | 3 + server/cmd/api/api/api.go | 2 +- server/lib/cdpmonitor/README.md | 6 +- server/lib/cdpmonitor/extension.go | 34 ++- server/lib/cdpmonitor/extension_test.go | 60 +++++ server/lib/oapi/oapi.go | 58 ++--- server/lib/webauthnhook/chrome_e2e_test.go | 90 ++++++- server/lib/webauthnhook/fake_metro_test.go | 4 +- server/lib/webauthnhook/hook.go | 273 ++++++++++++++------- server/lib/webauthnhook/hook.js | 50 +++- server/lib/webauthnhook/hook_test.go | 222 ++++++++++++++--- server/lib/webauthnhook/signer_test.go | 21 +- server/openapi.yaml | 8 +- 13 files changed, 650 insertions(+), 181 deletions(-) diff --git a/server/README.md b/server/README.md index bef0c9e0f..7f4628d90 100644 --- a/server/README.md +++ b/server/README.md @@ -57,6 +57,9 @@ 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 | +| `SESSION_INTEL_URL` | | Metro API base URL; answers the WebAuthn page hook (`PUT /webauthn/hook`) | +| `KERNEL_METRO_API_BASE_URL` | | Used when `SESSION_INTEL_URL` is unset | +| `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 c0b8da8b4..3516c6258 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -187,7 +187,7 @@ func New( screenshotEnabled := func() bool { return telemetrySession.CategoryEnabled(events.Screenshot) } mon := cdpmonitor.New(upstreamMgr, telemetrySession.Publish, displayNum, slog.Default(), screenshotEnabled) _ = mon.SetTelemetry(false) - webauthnHook := webauthnhook.New(webauthn, mon.RetainPageDomains) + webauthnHook := webauthnhook.New(webauthn, mon) mon.SetExtension(webauthnHook) ctx, cancel := context.WithCancel(context.Background()) diff --git a/server/lib/cdpmonitor/README.md b/server/lib/cdpmonitor/README.md index a198daf33..588f30dbf 100644 --- a/server/lib/cdpmonitor/README.md +++ b/server/lib/cdpmonitor/README.md @@ -161,8 +161,10 @@ when a connection is replaced, regardless of telemetry state. It sends commands through the `Session` it was given, which stays bound to that connection. Telemetry cleanup normally disables Page and Runtime on page and iframe sessions; Chromium then drops `Runtime.addBinding` bindings and stops applying -new-document scripts. `RetainPageDomains` makes cleanup leave both enabled for -the rest of the monitor's lifetime, and waits for any disable already in flight. +new-document scripts. While an extension holds `RetainPageDomains`, cleanup +leaves both enabled, and retaining waits for any disable already in flight. +`ReleasePageDomains` disables them on the sessions telemetry is not capturing, +restoring what cleanup would have left. ### Reconnect and shutdown diff --git a/server/lib/cdpmonitor/extension.go b/server/lib/cdpmonitor/extension.go index 4a273499e..587fd4002 100644 --- a/server/lib/cdpmonitor/extension.go +++ b/server/lib/cdpmonitor/extension.go @@ -3,6 +3,7 @@ package cdpmonitor import ( "context" "encoding/json" + "errors" "github.com/kernel/kernel-images/server/lib/cdpclient" ) @@ -40,11 +41,40 @@ func (m *Monitor) SetExtension(ext Extension) { } // RetainPageDomains stops telemetry cleanup from disabling the Page and -// Runtime domains on page and iframe sessions, for the rest of the monitor's -// lifetime. Disabling them would drop an extension's bindings and pause its +// Runtime domains on page and iframe sessions until ReleasePageDomains. +// Disabling them would drop an extension's bindings and pause its // new-document scripts. Once it returns, no disable is still in flight. func (m *Monitor) RetainPageDomains() { m.pageDomainsMu.Lock() m.pageDomainsRetained = true m.pageDomainsMu.Unlock() } + +// ReleasePageDomains undoes RetainPageDomains and disables Page and Runtime on +// the page and iframe sessions telemetry is not capturing, leaving them as +// telemetry cleanup would have. +func (m *Monitor) ReleasePageDomains(ctx context.Context) error { + // Excludes telemetry setup and cleanup, which decide what it owns. + m.telemetryMu.Lock() + defer m.telemetryMu.Unlock() + m.pageDomainsMu.Lock() + m.pageDomainsRetained = false + m.pageDomainsMu.Unlock() + m.sessionsMu.RLock() + var idle []string + for id, info := range m.sessions { + if _, captured := m.optionalSessions[id]; !captured && isPageLikeTarget(info.targetType) { + idle = append(idle, id) + } + } + m.sessionsMu.RUnlock() + var err error + for _, id := range idle { + for _, method := range []string{"Page.disable", "Runtime.disable"} { + if _, sendErr := m.send(ctx, method, nil, id); sendErr != nil { + err = errors.Join(err, sendErr) + } + } + } + return err +} diff --git a/server/lib/cdpmonitor/extension_test.go b/server/lib/cdpmonitor/extension_test.go index b629160bb..f2972a2b9 100644 --- a/server/lib/cdpmonitor/extension_test.go +++ b/server/lib/cdpmonitor/extension_test.go @@ -137,3 +137,63 @@ func TestRetainedPageDomainsSurviveTelemetryCleanup(t *testing.T) { require.NotContains(t, methods, "Page.disable") require.NotContains(t, methods, "Runtime.disable") } + +func TestReleasePageDomainsRestoresTelemetryCleanup(t *testing.T) { + srv := newTestServer(t) + defer srv.close() + var mu sync.Mutex + var sent []cdpMessage + stop := make(chan struct{}) + defer close(stop) + go listenAndRespond(srv, stop, func(msg cdpMessage) any { + mu.Lock() + sent = append(sent, msg) + mu.Unlock() + if msg.Method == "Page.addScriptToEvaluateOnNewDocument" { + return map[string]any{"id": msg.ID, "result": map[string]any{"identifier": "script"}} + } + return nil + }) + disables := func(sessionID string) []string { + mu.Lock() + defer mu.Unlock() + var methods []string + for _, msg := range sent { + if msg.SessionID == sessionID && (msg.Method == "Page.disable" || msg.Method == "Runtime.disable") { + methods = append(methods, msg.Method) + } + } + return methods + } + m := New(newTestUpstream(srv.wsURL()), newEventCollector().publishFn(), 0, discardLogger, nil) + require.NoError(t, m.SetTelemetry(false)) + require.NoError(t, m.Start(context.Background())) + defer m.Stop() + <-srv.connCh + srv.sendToMonitor(t, map[string]any{"method": "Target.attachedToTarget", "params": map[string]any{"sessionId": "s", "targetInfo": map[string]any{"targetId": "t", "type": "page"}}}) + require.Eventually(t, func() bool { + m.sessionsMu.RLock() + defer m.sessionsMu.RUnlock() + return m.networkReady["s"] + }, 3*time.Second, 10*time.Millisecond) + + // Telemetry is off, so release disables what the extension had enabled. + m.RetainPageDomains() + require.NoError(t, m.ReleasePageDomains(context.Background())) + require.Equal(t, []string{"Page.disable", "Runtime.disable"}, disables("s")) + + // A session telemetry captures keeps its domains until telemetry cleanup, + // which disables them again once nothing retains them. + require.NoError(t, m.SetTelemetry(true)) + require.Eventually(t, func() bool { + m.sessionsMu.RLock() + defer m.sessionsMu.RUnlock() + return m.optionalSessions["s"] != "" + }, 3*time.Second, 10*time.Millisecond) + m.RetainPageDomains() + require.NoError(t, m.ReleasePageDomains(context.Background())) + require.Len(t, disables("s"), 2) + require.NoError(t, m.SetTelemetry(false)) + waitForTelemetryReconcile(t, m, false) + require.Equal(t, []string{"Page.disable", "Runtime.disable", "Page.disable", "Runtime.disable"}, disables("s")) +} diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index bb7e35523..a0532e030 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -27578,35 +27578,35 @@ var swaggerSpec = []string{ "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", - "XaSPH///AwD12IA3iNoDAA==", + "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", + "SB8//v8HAMST7MqC2gMA", } // 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 index 2c5ea2396..834ed2d89 100644 --- a/server/lib/webauthnhook/chrome_e2e_test.go +++ b/server/lib/webauthnhook/chrome_e2e_test.go @@ -53,7 +53,7 @@ func TestChromiumPageHook(t *testing.T) { registered = result.Credential requireHookCredential(t, registered, "attestation") require.Equal(t, -7, registered.Algorithm) - require.Equal(t, []string{"internal", "hybrid"}, registered.Transports) + require.Equal(t, fakeTransports, registered.Transports) request := env.metro.last(t) require.Equal(t, "create", request.op) @@ -107,6 +107,28 @@ func TestChromiumPageHook(t *testing.T) { 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([ @@ -222,16 +244,18 @@ func TestChromiumPageHook(t *testing.T) { requireHookCredential(t, result.Credential, "assertion") }) - t.Run("disabling stops new documents from getting the hook", func(t *testing.T) { + t.Run("disabling suspends hooked documents and stops new ones from getting the hook", func(t *testing.T) { tab := env.open(t, login+"/page") tab.addVirtualAuthenticator(t) require.NoError(t, env.hook.Set(ctx, false)) defer func() { require.NoError(t, env.hook.Set(ctx, true)) }() 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") @@ -239,6 +263,44 @@ func TestChromiumPageHook(t *testing.T) { 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") @@ -502,7 +564,7 @@ func newChromeEnv(t *testing.T, ctx context.Context) *chromeEnv { Logger: discard, }, Logger: discard, - }, monitor.RetainPageDomains) + }, monitor) monitor.SetExtension(hook) require.NoError(t, monitor.Start(ctx)) t.Cleanup(monitor.Stop) @@ -543,7 +605,18 @@ 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. - raw := e.driver.call(t, "", "Target.createTarget", map[string]any{"url": e.site("login.example.test") + "/page"}) + 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"` } @@ -552,15 +625,18 @@ func (e *chromeEnv) open(t *testing.T, pageURL string) *tab { _, _ = 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.waitHooked(t) - tb.navigate(t, pageURL) - tb.waitHooked(t) + 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") diff --git a/server/lib/webauthnhook/fake_metro_test.go b/server/lib/webauthnhook/fake_metro_test.go index 0a8bccd64..a68589be0 100644 --- a/server/lib/webauthnhook/fake_metro_test.go +++ b/server/lib/webauthnhook/fake_metro_test.go @@ -30,6 +30,8 @@ const ( flagAT = 0x40 ) +var fakeTransports = []string{"internal", "hybrid"} + type metroRequest struct { op string auth string @@ -170,7 +172,7 @@ func (f *fakeMetro) sign(op string, req Request) (json.RawMessage, error) { "clientExtensionResults": map[string]any{}, "response": map[string]any{ "clientDataJSON": b64(clientDataJSON), "attestationObject": b64(attestation), "authenticatorData": b64(authData), - "publicKey": b64(spki), "publicKeyAlgorithm": -7, "transports": []string{"internal", "hybrid"}, + "publicKey": b64(spki), "publicKeyAlgorithm": -7, "transports": fakeTransports, }, }) } diff --git a/server/lib/webauthnhook/hook.go b/server/lib/webauthnhook/hook.go index 2b3ef6d87..760e61a39 100644 --- a/server/lib/webauthnhook/hook.go +++ b/server/lib/webauthnhook/hook.go @@ -15,6 +15,7 @@ import ( "encoding/json" "log/slog" "net/url" + "strings" "sync" "time" @@ -29,14 +30,20 @@ const ( const ( setupTimeout = 10 * time.Second + setupRetryDelay = time.Second deliverTimeout = 5 * time.Second maxCallBytes = 256 << 10 maxRequestIDLen = 64 - maxSessionCalls = 16 + maxPendingCalls = 16 // unanswered calls per session + maxTrackedCalls = 256 // answered calls kept per session for resends answeredRetained = 30 * time.Second maxFrameDepth = 32 ) +// 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 @@ -47,6 +54,13 @@ type Config struct { Logger *slog.Logger } +// PageDomains keeps telemetry cleanup from disabling the Page and Runtime +// domains the hook needs while it is on. *cdpmonitor.Monitor implements it. +type PageDomains interface { + RetainPageDomains() + ReleasePageDomains(ctx context.Context) error +} + type executionContext struct { uniqueID string origin string @@ -59,10 +73,12 @@ type callKey struct { } // call is one page request. reply is empty until the request is answered; -// the page resends until it sees that reply. +// 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 { @@ -77,44 +93,44 @@ type session struct { cdp sender setupMu sync.Mutex - domains bool // setupMu: Runtime, Page, and the binding are enabled + domains bool // setupMu: the binding may be installed scriptID string // setupMu: new-document registration while enabled - contexts map[int]executionContext // Hook.mu; main-world contexts only - calls map[callKey]*call // Hook.mu + 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 } // Hook implements cdpmonitor.Extension. type Hook struct { - cfg Config - retain func() - log *slog.Logger + cfg Config + domains PageDomains + log *slog.Logger setMu sync.Mutex // serializes Set mu sync.Mutex enabled bool - active bool // sessions get the binding; stays true once enabled sessions map[string]*session } var _ cdpmonitor.Extension = (*Hook)(nil) -// New restores the flag a previous run of the process left. retainPageDomains -// is called before the hook first enables domains, so telemetry cleanup stops -// disabling them. -func New(cfg Config, retainPageDomains func()) *Hook { - h := &Hook{cfg: cfg, retain: retainPageDomains, log: cfg.Logger, sessions: make(map[string]*session)} +// New restores the flag a previous run of the process left. While the hook +// is on it retains domains, so telemetry cleanup does not disable them. +func New(cfg Config, domains PageDomains) *Hook { + h := &Hook{cfg: cfg, domains: domains, log: cfg.Logger, sessions: make(map[string]*session)} if loadEnabled(cfg.StatePath, cfg.Logger) { - h.retain() - h.enabled, h.active = true, true + h.domains.RetainPageDomains() + h.enabled = true } return h } -// Set turns the hook on or off, persisting the flag first, and waits until -// the current sessions are reconciled or ctx ends. Pages hooked while it was -// on keep their hook while it is off; their requests are left to the browser. +// Set turns the hook on or off, persisting the flag first, and waits until the +// current sessions are reconciled. Turning it off tells hooked documents to +// leave requests to the browser, removes the script and binding, and releases +// the Page and Runtime domains to telemetry. func (h *Hook) Set(ctx context.Context, enabled bool) error { h.setMu.Lock() defer h.setMu.Unlock() @@ -122,14 +138,13 @@ func (h *Hook) Set(ctx context.Context, enabled bool) error { return err } h.mu.Lock() - activate := enabled && !h.active + changed := h.enabled != enabled h.mu.Unlock() - if activate { - h.retain() + if changed && enabled { + h.domains.RetainPageDomains() } h.mu.Lock() h.enabled = enabled - h.active = h.active || enabled sessions := make([]*session, 0, len(h.sessions)) for _, s := range h.sessions { sessions = append(sessions, s) @@ -140,11 +155,11 @@ func (h *Hook) Set(ctx context.Context, enabled bool) error { for _, s := range sessions { wg.Go(func() { h.reconcile(s) }) } - done := make(chan struct{}) - go func() { wg.Wait(); close(done) }() - select { - case <-done: - case <-ctx.Done(): + wg.Wait() + if changed && !enabled { + if err := h.domains.ReleasePageDomains(ctx); err != nil { + h.log.Warn("webauthn: failed to release page domains", "err", err) + } } return nil } @@ -160,6 +175,7 @@ func (h *Hook) SessionAttached(s cdpmonitor.Session) { 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 @@ -179,66 +195,101 @@ func (h *Hook) Reset() { h.mu.Unlock() } -// reconcile brings one session to the current flag. Domains and the binding -// stay once added so already-hooked documents keep an answer. +// reconcile brings one session to the current flag, 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.reconcileOnce(s) + if err == nil { + return + } + time.Sleep(setupRetryDelay) + if err = h.reconcileOnce(s); err != nil && h.live(s) { + h.log.Error("webauthn: failed to reconcile hook", "session", s.id, "target_type", s.targetType, "err", err) + } +} + +func (h *Hook) live(s *session) bool { + h.mu.Lock() + defer h.mu.Unlock() + return h.sessions[s.id] == s +} + +func (h *Hook) reconcileOnce(s *session) error { s.setupMu.Lock() defer s.setupMu.Unlock() h.mu.Lock() - active, enabled, live := h.active, h.enabled, h.sessions[s.id] == s + enabled, live := h.enabled, h.sessions[s.id] == s + contexts := make([]executionContext, 0, len(s.contexts)) + for _, ec := range s.contexts { + contexts = append(contexts, ec) + } h.mu.Unlock() - if !active || !live { - return + if !live { + return nil } ctx, cancel := context.WithTimeout(context.Background(), setupTimeout) defer cancel() - if err := h.reconcileSession(ctx, s, enabled); err != nil { - h.mu.Lock() - live = h.sessions[s.id] == s - h.mu.Unlock() - if live { - h.log.Warn("webauthn: failed to set up hook", "session", s.id, "target_type", s.targetType, "err", err) + if enabled { + return h.install(ctx, s) + } + return h.uninstall(ctx, s, contexts) +} + +func (h *Hook) install(ctx context.Context, s *session) error { + if s.scriptID != "" { + return nil + } + for _, command := range []struct { + method string + params any + }{ + {"Runtime.enable", nil}, + {"Page.enable", nil}, + {"Runtime.addBinding", map[string]any{"name": bindingName}}, + } { + if _, err := s.cdp.Send(ctx, command.method, command.params); err != nil { + return err } } + s.domains = true + raw, err := s.cdp.Send(ctx, "Page.addScriptToEvaluateOnNewDocument", map[string]any{ + "source": hookSource, "runImmediately": true, + }) + if err != nil { + return err + } + var result struct { + Identifier string `json:"identifier"` + } + if err := json.Unmarshal(raw, &result); err != nil { + return err + } + s.scriptID = result.Identifier + return nil } -func (h *Hook) reconcileSession(ctx context.Context, s *session, enabled bool) error { +// uninstall suspends the hook in the session's documents before removing the +// binding, so they fall back at once rather than after a timeout. The domains +// themselves are released together, once every session is done. +func (h *Hook) uninstall(ctx context.Context, s *session, contexts []executionContext) error { if !s.domains { - for _, command := range []struct { - method string - params any - }{ - {"Runtime.enable", nil}, - {"Page.enable", nil}, - {"Runtime.addBinding", map[string]any{"name": bindingName}}, - } { - if _, err := s.cdp.Send(ctx, command.method, command.params); err != nil { - return err - } - } - s.domains = true + return nil } - switch { - case enabled && s.scriptID == "": - raw, err := s.cdp.Send(ctx, "Page.addScriptToEvaluateOnNewDocument", map[string]any{ - "source": hookSource, "runImmediately": true, + for _, ec := range contexts { + _, _ = s.cdp.Send(ctx, "Runtime.evaluate", map[string]any{ + "expression": suspendExpression, "uniqueContextId": ec.uniqueID, "silent": true, }) - if err != nil { - return err - } - var result struct { - Identifier string `json:"identifier"` - } - if err := json.Unmarshal(raw, &result); err != nil { - return err - } - s.scriptID = result.Identifier - case !enabled && s.scriptID != "": + } + if s.scriptID != "" { if _, err := s.cdp.Send(ctx, "Page.removeScriptToEvaluateOnNewDocument", map[string]any{"identifier": s.scriptID}); err != nil { return err } s.scriptID = "" } + if _, err := s.cdp.Send(ctx, "Runtime.removeBinding", map[string]any{"name": bindingName}); err != nil { + return err + } + s.domains = false return nil } @@ -264,6 +315,7 @@ func (h *Hook) ProtocolEvent(sessionID, method string, params json.RawMessage) { 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": @@ -277,6 +329,9 @@ func (h *Hook) ProtocolEvent(sessionID, method string, params json.RawMessage) { 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) @@ -289,6 +344,7 @@ func (h *Hook) ProtocolEvent(sessionID, method string, params json.RawMessage) { h.mu.Lock() if s := h.sessions[sessionID]; s != nil { clear(s.contexts) + clear(s.frameContexts) clear(s.calls) } h.mu.Unlock() @@ -319,7 +375,7 @@ type pageReply struct { 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 != "get" && pc.Op != "create" && pc.Op != "abort") { return } h.mu.Lock() @@ -334,7 +390,18 @@ func (h *Hook) handleCall(sessionID string, contextID int, payload string) { return } key := callKey{contextID: ec.uniqueID, requestID: pc.ID} - if existing := s.calls[key]; existing != nil { + 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 { @@ -343,33 +410,35 @@ func (h *Hook) handleCall(sessionID string, contextID int, payload string) { go h.deliver(s, ec, reply) return } - for k, c := range s.calls { - if c.reply != nil && time.Since(c.answeredAt) > answeredRetained { - delete(s.calls, k) - } - } - if len(s.calls) >= maxSessionCalls { + 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 } - c := &call{} + 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) { - answer = h.cfg.Signer.Answer(context.Background(), pc.Op, Request{ + 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, }) } - h.log.Info("webauthn: answered page request", "op", pc.Op, "origin", ec.origin, "top_origin", topOrigin, "result", answer.Result) + 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() @@ -378,17 +447,39 @@ func (h *Hook) handleCall(sessionID string, contextID int, payload string) { }() } +// 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" { - for _, ec := range s.contexts { - if ec.frameID == s.targetID { - return ec.origin, true - } - } - return "", false + id, ok := s.frameContexts[s.targetID] + return s.contexts[id].origin, ok } if s = h.frameOwnerLocked(s.parentFrameID); s == nil { return "", false @@ -402,10 +493,8 @@ func (h *Hook) frameOwnerLocked(frameID string) *session { return nil } for _, s := range h.sessions { - for _, ec := range s.contexts { - if ec.frameID == frameID { - return s - } + if _, ok := s.frameContexts[frameID]; ok { + return s } } return nil @@ -440,14 +529,14 @@ func (h *Hook) permitted(s *session, frameID, op string) bool { } // secureOrigin accepts the origins WebAuthn serves: https, and http on -// localhost. Opaque origins do not parse to a host. +// 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": + case u.Scheme == "https", u.Scheme == "http" && (u.Hostname() == "localhost" || strings.HasSuffix(u.Hostname(), ".localhost")): return origin, true } return "", false diff --git a/server/lib/webauthnhook/hook.js b/server/lib/webauthnhook/hook.js index d21bcda65..77894385d 100644 --- a/server/lib/webauthnhook/hook.js +++ b/server/lib/webauthnhook/hook.js @@ -5,17 +5,24 @@ // 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. -// 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) falls back to the browser. +// 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)) return; + if (Object.prototype.hasOwnProperty.call(globalThis, REPLY)) { + globalThis[REPLY]({ resume: true }); + return; + } if (typeof CredentialsContainer !== 'function' || typeof PublicKeyCredential !== 'function') return; const container = CredentialsContainer.prototype; @@ -27,9 +34,19 @@ 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) { @@ -124,6 +141,15 @@ 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) => { @@ -132,6 +158,7 @@ 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); @@ -141,16 +168,20 @@ } function onAbort() { settle(() => reject(abortReason(signal))); + callBinding(stringify({ id, op: 'abort' })); } function send() { if (now() - request.heard >= SILENCE_MS) { request.finish(null); return; } - try { - globalThis[BINDING](payload); - } catch { - // No binding yet: same as an unanswered send. + 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); } @@ -163,6 +194,7 @@ 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); diff --git a/server/lib/webauthnhook/hook_test.go b/server/lib/webauthnhook/hook_test.go index 54e97fa94..baf48c948 100644 --- a/server/lib/webauthnhook/hook_test.go +++ b/server/lib/webauthnhook/hook_test.go @@ -1,8 +1,11 @@ package webauthnhook import ( + "bytes" "context" "encoding/json" + "errors" + "fmt" "io" "log/slog" "os" @@ -28,7 +31,10 @@ 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) { @@ -38,8 +44,18 @@ func (f *fakeSession) Send(_ context.Context, method string, params any) (json.R _ = 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 @@ -51,6 +67,10 @@ func (f *fakeSession) Send(_ context.Context, method string, params any) (json.R 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 { @@ -104,6 +124,7 @@ func (f *fakeSession) countReplies(id string, ack bool) int { type recordedRequest struct { op string req Request + ctx context.Context } type fakeSigner struct { @@ -113,9 +134,9 @@ type fakeSigner struct { release chan struct{} } -func (f *fakeSigner) Answer(_ context.Context, op string, req Request) Answer { +func (f *fakeSigner) Answer(ctx context.Context, op string, req Request) Answer { f.mu.Lock() - f.requests = append(f.requests, recordedRequest{op, req}) + f.requests = append(f.requests, recordedRequest{op, req, ctx}) release := f.release f.mu.Unlock() if release != nil { @@ -130,11 +151,23 @@ func (f *fakeSigner) calls() []recordedRequest { return append([]recordedRequest(nil), f.requests...) } +type fakeDomains struct { + retained, released int +} + +func (d *fakeDomains) RetainPageDomains() { d.retained++ } + +func (d *fakeDomains) ReleasePageDomains(context.Context) error { + d.released++ + return nil +} + type harness struct { - hook *Hook - signer *fakeSigner - path string - retained int + hook *Hook + signer *fakeSigner + path string + domains *fakeDomains + logs *bytes.Buffer } func newHarness(t *testing.T) *harness { @@ -144,14 +177,35 @@ func newHarness(t *testing.T) *harness { func newHarnessAt(t *testing.T, path string) *harness { t.Helper() - h := &harness{path: path, signer: &fakeSigner{answer: Answer{Result: "credential", Credential: json.RawMessage(`{"id":"cred"}`)}}} - h.hook = New(Config{StatePath: path, Signer: h.signer, Logger: discard}, func() { h.retained++ }) + h := &harness{ + path: path, domains: &fakeDomains{}, 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}, h.domains) 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() - fake := &fakeSession{forbidden: map[string]bool{}} + 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 } @@ -177,31 +231,56 @@ func getCall(id string) map[string]any { func TestSetInstallsAndRemovesHook(t *testing.T) { h := newHarness(t) page := h.attach(t, "page", "page", "") - require.Empty(t, page.methods(), "an inactive hook must not touch sessions") + require.Empty(t, page.methods(), "a disabled hook must not touch sessions") h.hook.SessionAttached(cdpmonitor.Session{ID: "worker", TargetType: "worker"}) require.NotContains(t, h.hook.sessions, "worker", "only page and iframe documents can call WebAuthn") + installed := []string{"Runtime.enable", "Page.enable", "Runtime.addBinding", "Page.addScriptToEvaluateOnNewDocument"} require.NoError(t, h.hook.Set(context.Background(), true)) - require.Equal(t, 1, h.retained) - require.Equal(t, []string{"Runtime.enable", "Page.enable", "Runtime.addBinding", "Page.addScriptToEvaluateOnNewDocument"}, page.methods()) + require.Equal(t, 1, h.domains.retained) + require.Equal(t, installed, 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"]) require.NoError(t, h.hook.Set(context.Background(), true)) require.Len(t, page.methods(), 4, "enabling twice must not register the script twice") + require.Equal(t, 1, h.domains.retained) + + 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.removeScriptToEvaluateOnNewDocument", "Runtime.removeBinding"}, page.methods()[4:]) + require.Equal(t, "script-1", page.commands[6].params["identifier"]) + require.Equal(t, bindingName, page.commands[7].params["name"]) + require.Equal(t, 1, h.domains.released) require.NoError(t, h.hook.Set(context.Background(), false)) - require.Equal(t, "Page.removeScriptToEvaluateOnNewDocument", page.methods()[4]) - require.Equal(t, "script-1", page.commands[4].params["identifier"]) - require.Len(t, page.methods(), 5, "disabling keeps the domains and binding for hooked documents") + require.Len(t, page.methods(), 8) + require.Equal(t, 1, h.domains.released) require.NoError(t, h.hook.Set(context.Background(), true)) - require.Equal(t, []string{"Page.addScriptToEvaluateOnNewDocument"}, page.methods()[5:]) - require.Equal(t, 1, h.retained) + require.Equal(t, installed, page.methods()[8:]) + require.Equal(t, 2, h.domains.retained) later := h.attach(t, "later", "iframe", "page") - require.Equal(t, []string{"Runtime.enable", "Page.enable", "Runtime.addBinding", "Page.addScriptToEvaluateOnNewDocument"}, later.methods()) + require.Equal(t, installed, later.methods()) +} + +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) { @@ -210,13 +289,13 @@ func TestEnabledFlagSurvivesRestart(t *testing.T) { require.NoError(t, first.hook.Set(context.Background(), true)) restarted := newHarnessAt(t, path) - require.Equal(t, 1, restarted.retained, "a restored hook retains page domains before the monitor starts") + require.Equal(t, 1, restarted.domains.retained, "a restored hook retains page domains before the monitor starts") 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.Zero(t, again.retained) + require.Zero(t, again.domains.retained) require.Empty(t, again.attach(t, "page", "page", "").methods()) } @@ -335,12 +414,37 @@ func TestRequestsLeftToBrowser(t *testing.T) { } 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)) - page := h.attach(t, "page", "page", "") - h.context("page", 1, "http://localhost:8080", "page") - h.call("page", 1, getCall("r1")) - require.Equal(t, "credential", page.finalReply(t, "r1")["result"]) + 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) { @@ -379,16 +483,76 @@ func TestIgnoredCalls(t *testing.T) { require.Empty(t, h.signer.calls()) } -func TestSessionCallsAreBounded(t *testing.T) { +func TestOnlyUnansweredCallsAreBounded(t *testing.T) { h := newHarness(t) - h.signer.release = make(chan struct{}) - defer close(h.signer.release) 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 maxSessionCalls { - h.call("page", 1, getCall("r"+string(rune('a'+i)))) + 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_test.go b/server/lib/webauthnhook/signer_test.go index 4b63db974..4bc73ea6c 100644 --- a/server/lib/webauthnhook/signer_test.go +++ b/server/lib/webauthnhook/signer_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "sync" "sync/atomic" "testing" "time" @@ -50,10 +51,16 @@ func TestMetroSignerPostsRequest(t *testing.T) { }, got.body) } -func TestMetroSignerRetriesServerErrors(t *testing.T) { - var attempts atomic.Int32 +func TestMetroSignerRetriesServerErrorsWithIdenticalBody(t *testing.T) { + var mu sync.Mutex + var bodies []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if attempts.Add(1) < 3 { + 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 } @@ -61,9 +68,13 @@ func TestMetroSignerRetriesServerErrors(t *testing.T) { })) defer server.Close() - answer := metroSigner(server.URL).Answer(context.Background(), "create", Request{}) + 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.EqualValues(t, 3, attempts.Load()) + require.Len(t, bodies, 3) + require.Equal(t, bodies[0], bodies[1]) + require.Equal(t, bodies[0], bodies[2]) } func TestMetroSignerGivesUpAtDeadline(t *testing.T) { diff --git a/server/openapi.yaml b/server/openapi.yaml index 1f4b4a58e..a57a1ff64 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -1788,10 +1788,10 @@ paths: browser's own implementation. Private keys never enter the VM. - Turning it off stops new documents from getting the script. Documents - that already have it keep it, and their requests fall back to the - browser. Idempotent. The flag is persisted, so it survives a restart - of the instance API process. + 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. operationId: putWebAuthnHook x-telemetry-category: platform requestBody: From 501c80c286328173010c759fcbcc47c5714423ba Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:07:37 +0000 Subject: [PATCH 3/3] Give the WebAuthn hook its own DevTools connection; use KERNEL_METRO_API_BASE_URL --- server/README.md | 3 +- server/cmd/api/api/api.go | 13 +- server/cmd/api/instance_identity.go | 4 +- server/cmd/api/instance_identity_test.go | 15 +- server/cmd/api/main.go | 3 +- server/cmd/config/config.go | 3 - server/lib/cdpmonitor/README.md | 14 -- server/lib/cdpmonitor/domains.go | 12 +- server/lib/cdpmonitor/extension.go | 80 ------ server/lib/cdpmonitor/extension_test.go | 199 --------------- server/lib/cdpmonitor/monitor.go | 19 -- server/lib/webauthnhook/chrome_e2e_test.go | 103 +++++--- server/lib/webauthnhook/connection.go | 194 +++++++++++++++ server/lib/webauthnhook/hook.go | 267 ++++++++++----------- server/lib/webauthnhook/hook_test.go | 113 +++++---- server/lib/webauthnhook/signer.go | 37 +++ server/lib/webauthnhook/state.go | 8 +- 17 files changed, 528 insertions(+), 559 deletions(-) delete mode 100644 server/lib/cdpmonitor/extension.go delete mode 100644 server/lib/cdpmonitor/extension_test.go create mode 100644 server/lib/webauthnhook/connection.go diff --git a/server/README.md b/server/README.md index 7f4628d90..d656b1713 100644 --- a/server/README.md +++ b/server/README.md @@ -57,8 +57,7 @@ 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 | -| `SESSION_INTEL_URL` | | Metro API base URL; answers the WebAuthn page hook (`PUT /webauthn/hook`) | -| `KERNEL_METRO_API_BASE_URL` | | Used when `SESSION_INTEL_URL` is unset | +| `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 3516c6258..bb977b22b 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -143,7 +143,7 @@ type ApiService struct { // decide whether to refuse a context that would set its own proxy. egressPolicy *egresspolicy.State - // webauthnHook answers pages' WebAuthn calls over the CDP monitor's + // webauthnHook answers pages' WebAuthn calls over its own DevTools // connection; PUT /webauthn/hook turns it on and off. webauthnHook *webauthnhook.Hook @@ -187,8 +187,6 @@ func New( screenshotEnabled := func() bool { return telemetrySession.CategoryEnabled(events.Screenshot) } mon := cdpmonitor.New(upstreamMgr, telemetrySession.Publish, displayNum, slog.Default(), screenshotEnabled) _ = mon.SetTelemetry(false) - webauthnHook := webauthnhook.New(webauthn, mon) - mon.SetExtension(webauthnHook) ctx, cancel := context.WithCancel(context.Background()) s := &ApiService{ @@ -207,7 +205,7 @@ func New( otlpExport: otlpExport, s2Storage: s2Storage, egressPolicy: egressPolicy, - webauthnHook: webauthnHook, + webauthnHook: webauthnhook.New(webauthn, upstreamMgr), webmcp: webmcpclient.NewManager(upstreamMgr), browserRepl: newBrowserReplManager(), lifecycleCtx: ctx, @@ -480,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 @@ -491,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/instance_identity.go b/server/cmd/api/instance_identity.go index cc6ee170f..8e6e1d22a 100644 --- a/server/cmd/api/instance_identity.go +++ b/server/cmd/api/instance_identity.go @@ -29,7 +29,7 @@ type instanceIdentity struct { // 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/SESSION_INTEL_URL) in the applied identity payload +// (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 @@ -97,6 +97,6 @@ func (p *instanceIdentityProvider) resolve() (instanceIdentity, bool) { 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.ExtensionAPIURL(payload), p.boot.metroAPIURL), + metroAPIURL: forkidentity.FirstNonEmpty(forkidentity.MetroAPIURL(payload), p.boot.metroAPIURL), }, true } diff --git a/server/cmd/api/instance_identity_test.go b/server/cmd/api/instance_identity_test.go index b09acf8ee..9780d2fe8 100644 --- a/server/cmd/api/instance_identity_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) @@ -39,7 +40,7 @@ func writeAppliedPayload(t *testing.T, jwt, instanceName, metro string) { } func bootIdentity() instanceIdentity { - return instanceIdentity{jwt: "boot-jwt", instanceName: "boot-inst", metro: "boot-metro", metroAPIURL: "https://boot-intel.example"} + return instanceIdentity{jwt: "boot-jwt", instanceName: "boot-inst", metro: "boot-metro", metroAPIURL: "https://boot-metro.example"} } func TestInstanceIdentityProvider(t *testing.T) { @@ -48,7 +49,7 @@ func TestInstanceIdentityProvider(t *testing.T) { t.Setenv(forkidentity.WaitEnv, "") writeAppliedPayload(t, "payload-jwt", "payload-inst", "payload-metro") // ignored in non-fork p := newInstanceIdentityProvider(bootIdentity(), slog.Default()) - if p.Token() != "boot-jwt" || p.InstanceName() != "boot-inst" || p.Metro() != "boot-metro" || p.MetroAPIURL() != "https://boot-intel.example" { + 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()) } }) @@ -69,7 +70,7 @@ func TestInstanceIdentityProvider(t *testing.T) { // the applied payload. 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" || p.MetroAPIURL() != "https://intel.example" { + 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()) } }) diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index d0924cd92..2f05ffd64 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -152,7 +152,7 @@ func main() { jwt: config.InstanceJWT, instanceName: config.InstanceName, metro: config.MetroName, - metroAPIURL: forkidentity.FirstNonEmpty(config.SessionIntelURL, config.MetroAPIBaseURL), + metroAPIURL: config.MetroAPIBaseURL, }, slogger) var otlpExporter api.OTLPExporter @@ -230,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)) diff --git a/server/cmd/config/config.go b/server/cmd/config/config.go index 4e6f2a1cc..068f01247 100644 --- a/server/cmd/config/config.go +++ b/server/cmd/config/config.go @@ -77,8 +77,6 @@ type Config struct { MetroName string `envconfig:"METRO_NAME" default:""` // Metro API base URL, which answers the WebAuthn page hook's requests. - // Image variants set one or the other; SESSION_INTEL_URL wins. - SessionIntelURL string `envconfig:"SESSION_INTEL_URL" default:""` MetroAPIBaseURL string `envconfig:"KERNEL_METRO_API_BASE_URL" default:""` } @@ -121,7 +119,6 @@ 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("session_intel_url", c.SessionIntelURL), slog.String("metro_api_base_url", c.MetroAPIBaseURL), ) } diff --git a/server/lib/cdpmonitor/README.md b/server/lib/cdpmonitor/README.md index 588f30dbf..a150a9a07 100644 --- a/server/lib/cdpmonitor/README.md +++ b/server/lib/cdpmonitor/README.md @@ -134,7 +134,6 @@ targets, not requests issued before their capture domains finish initializing. | Screenshot capture via ffmpeg | `screenshot.go` | | CDP protocol types | `cdp_proto.go`, `types.go` | | Interaction tracking injected into the page | `interaction.js` | -| Non-telemetry consumers of the monitor's sessions (`Extension`) | `extension.go` | | Body/MIME capture sizing, text truncation, and typed payload helpers | `util.go` | ## Internals @@ -153,19 +152,6 @@ Only `worker` targets match this auto-attach filter, avoiding duplicate attachme of explicitly discovered OOPIFs. WebMCP's default tracker still tracks page/frame locations and does not subscribe to workers. -### Extensions - -An `Extension` (the WebAuthn page hook in `../webauthnhook`) receives every -attached session, its removal, every session-scoped protocol event, and a reset -when a connection is replaced, regardless of telemetry state. It sends commands -through the `Session` it was given, which stays bound to that connection. -Telemetry cleanup normally disables Page and Runtime on page and iframe -sessions; Chromium then drops `Runtime.addBinding` bindings and stops applying -new-document scripts. While an extension holds `RetainPageDomains`, cleanup -leaves both enabled, and retaining waits for any disable already in flight. -`ReleasePageDomains` disables them on the sessions telemetry is not capturing, -restoring what cleanup would have left. - ### Reconnect and shutdown `Start` subscribes before reading the current URL. The supervisor reacts to socket diff --git a/server/lib/cdpmonitor/domains.go b/server/lib/cdpmonitor/domains.go index a86fdbc56..b2aa7e853 100644 --- a/server/lib/cdpmonitor/domains.go +++ b/server/lib/cdpmonitor/domains.go @@ -133,9 +133,6 @@ func (m *Monitor) disableOptionalDomains(ctx context.Context, sessionID, scriptI if !exists { return nil } - m.pageDomainsMu.RLock() - defer m.pageDomainsMu.RUnlock() - keepDomains := m.pageDomainsRetained && isPageLikeTarget(info.targetType) var cleanupErr error if scriptID != "" { _, cleanupErr = m.send(ctx, "Page.removeScriptToEvaluateOnNewDocument", map[string]any{"identifier": scriptID}, sessionID) @@ -153,18 +150,13 @@ func (m *Monitor) disableOptionalDomains(ctx context.Context, sessionID, scriptI {"Inspector.disable", nil}, {"Page.disable", nil}, } { - if keepDomains && command.method == "Page.disable" { - continue - } if _, err := m.send(ctx, command.method, command.params, sessionID); err != nil { cleanupErr = errors.Join(cleanupErr, err) } } } - if !keepDomains { - _, err := m.send(ctx, "Runtime.disable", nil, sessionID) - cleanupErr = errors.Join(cleanupErr, err) - } + _, err := m.send(ctx, "Runtime.disable", nil, sessionID) + cleanupErr = errors.Join(cleanupErr, err) if cleanupErr == nil { m.sessionsMu.Lock() delete(m.interactionTargets, info.targetID) diff --git a/server/lib/cdpmonitor/extension.go b/server/lib/cdpmonitor/extension.go deleted file mode 100644 index 587fd4002..000000000 --- a/server/lib/cdpmonitor/extension.go +++ /dev/null @@ -1,80 +0,0 @@ -package cdpmonitor - -import ( - "context" - "encoding/json" - "errors" - - "github.com/kernel/kernel-images/server/lib/cdpclient" -) - -// Extension adds behavior to the monitor's sessions independently of -// telemetry, on every connection the monitor opens. Methods run on the -// connection's event loop and must not block. -type Extension interface { - SessionAttached(Session) - SessionRemoved(sessionID string) - // ProtocolEvent receives every session-scoped CDP event, including those - // telemetry ignores while capture is off. - ProtocolEvent(sessionID, method string, params json.RawMessage) - // Reset follows the loss of a connection: its sessions are gone. - Reset() -} - -// Session is an attached target on one monitor connection. Send stays bound -// to that connection, so it fails rather than reaching a later one. -type Session struct { - ID string - TargetID string - TargetType string - ParentFrameID string - protocol *cdpclient.Client -} - -func (s Session) Send(ctx context.Context, method string, params any) (json.RawMessage, error) { - return s.protocol.Send(ctx, method, params, s.ID) -} - -// SetExtension registers ext. Call it before Start. -func (m *Monitor) SetExtension(ext Extension) { - m.extension = ext -} - -// RetainPageDomains stops telemetry cleanup from disabling the Page and -// Runtime domains on page and iframe sessions until ReleasePageDomains. -// Disabling them would drop an extension's bindings and pause its -// new-document scripts. Once it returns, no disable is still in flight. -func (m *Monitor) RetainPageDomains() { - m.pageDomainsMu.Lock() - m.pageDomainsRetained = true - m.pageDomainsMu.Unlock() -} - -// ReleasePageDomains undoes RetainPageDomains and disables Page and Runtime on -// the page and iframe sessions telemetry is not capturing, leaving them as -// telemetry cleanup would have. -func (m *Monitor) ReleasePageDomains(ctx context.Context) error { - // Excludes telemetry setup and cleanup, which decide what it owns. - m.telemetryMu.Lock() - defer m.telemetryMu.Unlock() - m.pageDomainsMu.Lock() - m.pageDomainsRetained = false - m.pageDomainsMu.Unlock() - m.sessionsMu.RLock() - var idle []string - for id, info := range m.sessions { - if _, captured := m.optionalSessions[id]; !captured && isPageLikeTarget(info.targetType) { - idle = append(idle, id) - } - } - m.sessionsMu.RUnlock() - var err error - for _, id := range idle { - for _, method := range []string{"Page.disable", "Runtime.disable"} { - if _, sendErr := m.send(ctx, method, nil, id); sendErr != nil { - err = errors.Join(err, sendErr) - } - } - } - return err -} diff --git a/server/lib/cdpmonitor/extension_test.go b/server/lib/cdpmonitor/extension_test.go deleted file mode 100644 index f2972a2b9..000000000 --- a/server/lib/cdpmonitor/extension_test.go +++ /dev/null @@ -1,199 +0,0 @@ -package cdpmonitor - -import ( - "context" - "encoding/json" - "slices" - "sync" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -type extensionRecord struct { - attached []Session - removed []string - events []string - resets int -} - -type recordingExtension struct { - mu sync.Mutex - extensionRecord -} - -func (e *recordingExtension) SessionAttached(s Session) { - e.mu.Lock() - defer e.mu.Unlock() - e.attached = append(e.attached, s) -} - -func (e *recordingExtension) SessionRemoved(sessionID string) { - e.mu.Lock() - defer e.mu.Unlock() - e.removed = append(e.removed, sessionID) -} - -func (e *recordingExtension) ProtocolEvent(sessionID, method string, _ json.RawMessage) { - e.mu.Lock() - defer e.mu.Unlock() - e.events = append(e.events, sessionID+" "+method) -} - -func (e *recordingExtension) Reset() { - e.mu.Lock() - defer e.mu.Unlock() - e.resets++ -} - -func (e *recordingExtension) snapshot() extensionRecord { - e.mu.Lock() - defer e.mu.Unlock() - return extensionRecord{attached: slices.Clone(e.attached), removed: slices.Clone(e.removed), events: slices.Clone(e.events), resets: e.resets} -} - -func TestExtensionReceivesSessionsWithTelemetryOff(t *testing.T) { - srv := newTestServer(t) - defer srv.close() - stop := make(chan struct{}) - defer close(stop) - var mu sync.Mutex - var sent []cdpMessage - go listenAndRespond(srv, stop, func(msg cdpMessage) any { - mu.Lock() - sent = append(sent, msg) - mu.Unlock() - return nil - }) - ext := &recordingExtension{} - m := New(newTestUpstream(srv.wsURL()), newEventCollector().publishFn(), 0, discardLogger, nil) - m.SetExtension(ext) - require.NoError(t, m.SetTelemetry(false)) - require.NoError(t, m.Start(context.Background())) - <-srv.connCh - - srv.sendToMonitor(t, map[string]any{"method": "Target.attachedToTarget", "params": map[string]any{ - "sessionId": "s", "targetInfo": map[string]any{"targetId": "frame", "type": "iframe", "parentFrameId": "parent"}, - }}) - require.Eventually(t, func() bool { return len(ext.snapshot().attached) == 1 }, 3*time.Second, 10*time.Millisecond) - session := ext.snapshot().attached[0] - require.Equal(t, "s", session.ID) - require.Equal(t, "frame", session.TargetID) - require.Equal(t, "iframe", session.TargetType) - require.Equal(t, "parent", session.ParentFrameID) - - _, err := session.Send(context.Background(), "Runtime.enable", nil) - require.NoError(t, err) - mu.Lock() - require.True(t, slices.ContainsFunc(sent, func(msg cdpMessage) bool { return msg.Method == "Runtime.enable" && msg.SessionID == "s" })) - mu.Unlock() - - srv.sendToMonitor(t, map[string]any{"method": "Runtime.bindingCalled", "sessionId": "s", "params": map[string]any{"name": "x", "payload": "{}"}}) - require.Eventually(t, func() bool { return slices.Contains(ext.snapshot().events, "s Runtime.bindingCalled") }, 3*time.Second, 10*time.Millisecond) - - srv.sendToMonitor(t, map[string]any{"method": "Target.detachedFromTarget", "params": map[string]any{"sessionId": "s"}}) - require.Eventually(t, func() bool { return slices.Contains(ext.snapshot().removed, "s") }, 3*time.Second, 10*time.Millisecond) - - m.Stop() - require.Positive(t, ext.snapshot().resets) -} - -func TestRetainedPageDomainsSurviveTelemetryCleanup(t *testing.T) { - srv := newTestServer(t) - defer srv.close() - var mu sync.Mutex - var methods []string - stop := make(chan struct{}) - defer close(stop) - go listenAndRespond(srv, stop, func(msg cdpMessage) any { - mu.Lock() - methods = append(methods, msg.Method) - mu.Unlock() - if msg.Method == "Page.addScriptToEvaluateOnNewDocument" { - return map[string]any{"id": msg.ID, "result": map[string]any{"identifier": "script"}} - } - return nil - }) - m := New(newTestUpstream(srv.wsURL()), newEventCollector().publishFn(), 0, discardLogger, nil) - m.RetainPageDomains() - require.NoError(t, m.SetTelemetry(true)) - require.NoError(t, m.Start(context.Background())) - defer m.Stop() - <-srv.connCh - srv.sendToMonitor(t, map[string]any{"method": "Target.attachedToTarget", "params": map[string]any{"sessionId": "s", "targetInfo": map[string]any{"targetId": "t", "type": "page"}}}) - require.Eventually(t, func() bool { - mu.Lock() - defer mu.Unlock() - return slices.Contains(methods, "Runtime.evaluate") - }, 3*time.Second, time.Millisecond) - - require.NoError(t, m.SetTelemetry(false)) - waitForTelemetryReconcile(t, m, false) - mu.Lock() - defer mu.Unlock() - require.Contains(t, methods, "Runtime.removeBinding") - require.Contains(t, methods, "Page.removeScriptToEvaluateOnNewDocument") - require.NotContains(t, methods, "Page.disable") - require.NotContains(t, methods, "Runtime.disable") -} - -func TestReleasePageDomainsRestoresTelemetryCleanup(t *testing.T) { - srv := newTestServer(t) - defer srv.close() - var mu sync.Mutex - var sent []cdpMessage - stop := make(chan struct{}) - defer close(stop) - go listenAndRespond(srv, stop, func(msg cdpMessage) any { - mu.Lock() - sent = append(sent, msg) - mu.Unlock() - if msg.Method == "Page.addScriptToEvaluateOnNewDocument" { - return map[string]any{"id": msg.ID, "result": map[string]any{"identifier": "script"}} - } - return nil - }) - disables := func(sessionID string) []string { - mu.Lock() - defer mu.Unlock() - var methods []string - for _, msg := range sent { - if msg.SessionID == sessionID && (msg.Method == "Page.disable" || msg.Method == "Runtime.disable") { - methods = append(methods, msg.Method) - } - } - return methods - } - m := New(newTestUpstream(srv.wsURL()), newEventCollector().publishFn(), 0, discardLogger, nil) - require.NoError(t, m.SetTelemetry(false)) - require.NoError(t, m.Start(context.Background())) - defer m.Stop() - <-srv.connCh - srv.sendToMonitor(t, map[string]any{"method": "Target.attachedToTarget", "params": map[string]any{"sessionId": "s", "targetInfo": map[string]any{"targetId": "t", "type": "page"}}}) - require.Eventually(t, func() bool { - m.sessionsMu.RLock() - defer m.sessionsMu.RUnlock() - return m.networkReady["s"] - }, 3*time.Second, 10*time.Millisecond) - - // Telemetry is off, so release disables what the extension had enabled. - m.RetainPageDomains() - require.NoError(t, m.ReleasePageDomains(context.Background())) - require.Equal(t, []string{"Page.disable", "Runtime.disable"}, disables("s")) - - // A session telemetry captures keeps its domains until telemetry cleanup, - // which disables them again once nothing retains them. - require.NoError(t, m.SetTelemetry(true)) - require.Eventually(t, func() bool { - m.sessionsMu.RLock() - defer m.sessionsMu.RUnlock() - return m.optionalSessions["s"] != "" - }, 3*time.Second, 10*time.Millisecond) - m.RetainPageDomains() - require.NoError(t, m.ReleasePageDomains(context.Background())) - require.Len(t, disables("s"), 2) - require.NoError(t, m.SetTelemetry(false)) - waitForTelemetryReconcile(t, m, false) - require.Equal(t, []string{"Page.disable", "Runtime.disable", "Page.disable", "Runtime.disable"}, disables("s")) -} diff --git a/server/lib/cdpmonitor/monitor.go b/server/lib/cdpmonitor/monitor.go index aa2d21e72..4e2cfa63d 100644 --- a/server/lib/cdpmonitor/monitor.go +++ b/server/lib/cdpmonitor/monitor.go @@ -84,10 +84,6 @@ type Monitor struct { proxyRateMu sync.Mutex proxyLastEmit map[string]time.Time - extension Extension - pageDomainsMu sync.RWMutex - pageDomainsRetained bool - // Lifecycle workers survive reconnects. Capture work is drained before // replacing a connection so stale sessions cannot write into the next one. asyncWg sync.WaitGroup @@ -285,12 +281,6 @@ func (m *Monitor) handleSurfaceEvent(conn *monitorConnection, event browsersurfa if !conn.surface.SessionExists(event.SessionID) { return } - if m.extension != nil { - m.extension.SessionAttached(Session{ - ID: event.SessionID, TargetID: event.Target.ID, TargetType: event.Target.Type, - ParentFrameID: event.Target.ParentFrameID, protocol: conn.protocol, - }) - } m.handleAttachedToTarget(conn.ctx, cdpTargetAttachedToTargetParams{ SessionID: event.SessionID, TargetInfo: cdpTargetTargetInfo{ @@ -300,9 +290,6 @@ func (m *Monitor) handleSurfaceEvent(conn *monitorConnection, event browsersurfa }, }) case browsersurface.EventSessionRemoved: - if m.extension != nil { - m.extension.SessionRemoved(event.SessionID) - } m.handleDetachedFromTarget(cdpTargetDetachedFromTargetParams{SessionID: event.SessionID}) case browsersurface.EventProtocol: if event.Message.Method == "Target.targetDestroyed" { @@ -326,9 +313,6 @@ func (m *Monitor) handleSurfaceEvent(conn *monitorConnection, event browsersurfa if event.Message.Method == "Target.attachedToTarget" || event.Message.Method == "Target.detachedFromTarget" { return } - if m.extension != nil && event.Message.SessionID != "" { - m.extension.ProtocolEvent(event.Message.SessionID, event.Message.Method, event.Message.Params) - } // Preserve crash reporting even if Chrome has already detached the session. if event.Message.SessionID != "" && event.Message.Method != "Inspector.targetCrashed" { m.sessionsMu.RLock() @@ -368,9 +352,6 @@ func (m *Monitor) clearState() { m.proxyRateMu.Lock() m.proxyLastEmit = make(map[string]time.Time) m.proxyRateMu.Unlock() - if m.extension != nil { - m.extension.Reset() - } } const pendingRequestTTL = 5 * time.Minute diff --git a/server/lib/webauthnhook/chrome_e2e_test.go b/server/lib/webauthnhook/chrome_e2e_test.go index 834ed2d89..0fcb0791e 100644 --- a/server/lib/webauthnhook/chrome_e2e_test.go +++ b/server/lib/webauthnhook/chrome_e2e_test.go @@ -29,8 +29,9 @@ import ( "github.com/stretchr/testify/require" ) -// TestChromiumPageHook drives the hook through a real Chromium and the real -// cdpmonitor connection, answered by a fake metro-api that signs with ES256. +// 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. // @@ -227,28 +228,40 @@ func TestChromiumPageHook(t *testing.T) { require.Equal(t, before, env.metro.count()) }) - t.Run("telemetry on and off leaves the hook working", func(t *testing.T) { + 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)) - time.Sleep(time.Second) - require.NoError(t, env.monitor.SetTelemetry(false)) - time.Sleep(2 * time.Second) - result := tab.settle(t, pageGet(registered.ID)) - require.Empty(t, result.Error) - requireHookCredential(t, result.Credential, "assertion") + 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) - result = tab.settle(t, pageGet(registered.ID)) - require.Empty(t, result.Error) - requireHookCredential(t, result.Credential, "assertion") + 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") - tab.addVirtualAuthenticator(t) 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() @@ -313,11 +326,24 @@ func TestChromiumPageHook(t *testing.T) { 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.monitor.Stop() - // Chromium drops a tab's virtual authenticators when another DevTools - // session on it detaches, so add one only after the monitor's has. + 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() @@ -327,7 +353,7 @@ func TestChromiumPageHook(t *testing.T) { require.GreaterOrEqual(t, time.Since(started), 10*time.Second) require.Equal(t, before, env.metro.count()) - require.NoError(t, env.monitor.Start(ctx)) + env.upstream.set(env.browser, true) result = tab.settle(t, pageGet(registered.ID)) require.Empty(t, result.Error) requireHookCredential(t, result.Credential, "assertion") @@ -533,13 +559,14 @@ func unb64(t *testing.T, text string) []byte { // --- environment --- type chromeEnv struct { - ctx context.Context - port string - browser string - driver *driver - metro *fakeMetro - monitor *cdpmonitor.Monitor - hook *Hook + 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 { @@ -553,8 +580,12 @@ func newChromeEnv(t *testing.T, ctx context.Context) *chromeEnv { t.Cleanup(metroServer.Close) browser := launchChromium(t, ctx, "--ignore-certificate-errors", "--host-resolver-rules=MAP *.test 127.0.0.1") - monitor := cdpmonitor.New(staticUpstream(browser), func(events.Event) (events.Envelope, bool) { return events.Envelope{}, false }, 0, discard, nil) + 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{ @@ -564,13 +595,15 @@ func newChromeEnv(t *testing.T, ctx context.Context) *chromeEnv { Logger: discard, }, Logger: discard, - }, monitor) - monitor.SetExtension(hook) - require.NoError(t, monitor.Start(ctx)) - t.Cleanup(monitor.Stop) + }, 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, hook: hook} + 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 { @@ -815,12 +848,10 @@ func (d *driver) contextFor(sessionID, origin string) int { return 0 } -type staticUpstream string - -func (u staticUpstream) Current() string { return string(u) } - -func (u staticUpstream) Subscribe() (<-chan string, func()) { - return make(chan string), func() {} +func (h *Hook) currentConnection() *connection { + h.mu.Lock() + defer h.mu.Unlock() + return h.conn } var devToolsURL = regexp.MustCompile(`ws://\S+`) 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/hook.go b/server/lib/webauthnhook/hook.go index 760e61a39..169a1d641 100644 --- a/server/lib/webauthnhook/hook.go +++ b/server/lib/webauthnhook/hook.go @@ -1,12 +1,14 @@ // Package webauthnhook answers pages' navigator.credentials.get/create // publicKey requests with passkeys held outside the VM. // -// A script installed in every page and iframe document replaces the WebAuthn -// entry points and sends each request through a CDP binding on the -// cdpmonitor's persistent connection. 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. +// 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 ( @@ -19,7 +21,7 @@ import ( "sync" "time" - "github.com/kernel/kernel-images/server/lib/cdpmonitor" + "github.com/kernel/kernel-images/server/lib/cdpclient" ) // bindingName must match BINDING and replyFunction must match REPLY in hook.js. @@ -54,11 +56,11 @@ type Config struct { Logger *slog.Logger } -// PageDomains keeps telemetry cleanup from disabling the Page and Runtime -// domains the hook needs while it is on. *cdpmonitor.Monitor implements it. -type PageDomains interface { - RetainPageDomains() - ReleasePageDomains(ctx context.Context) error +// 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 { @@ -85,6 +87,16 @@ 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 @@ -92,45 +104,63 @@ type session struct { parentFrameID string cdp sender - setupMu sync.Mutex - domains bool // setupMu: the binding may be installed - scriptID string // setupMu: new-document registration while enabled - + 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 } -// Hook implements cdpmonitor.Extension. type Hook struct { - cfg Config - domains PageDomains - log *slog.Logger + cfg Config + upstream Upstream + log *slog.Logger - setMu sync.Mutex // serializes Set + 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 } -var _ cdpmonitor.Extension = (*Hook)(nil) +// 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), + } +} -// New restores the flag a previous run of the process left. While the hook -// is on it retains domains, so telemetry cleanup does not disable them. -func New(cfg Config, domains PageDomains) *Hook { - h := &Hook{cfg: cfg, domains: domains, log: cfg.Logger, sessions: make(map[string]*session)} - if loadEnabled(cfg.StatePath, cfg.Logger) { - h.domains.RetainPageDomains() - h.enabled = true +// 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() } - return h } -// Set turns the hook on or off, persisting the flag first, and waits until the -// current sessions are reconciled. Turning it off tells hooked documents to -// leave requests to the browser, removes the script and binding, and releases -// the Page and Runtime domains to telemetry. +// 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 +// 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() @@ -138,39 +168,66 @@ func (h *Hook) Set(ctx context.Context, enabled bool) error { return err } h.mu.Lock() - changed := h.enabled != enabled + h.enabled = enabled h.mu.Unlock() - if changed && enabled { - h.domains.RetainPageDomains() + 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() - h.enabled = enabled - sessions := make([]*session, 0, len(h.sessions)) for _, s := range h.sessions { - sessions = append(sessions, s) + for _, ec := range s.contexts { + targets = append(targets, target{s, ec}) + } } h.mu.Unlock() - var wg sync.WaitGroup - for _, s := range sessions { - wg.Go(func() { h.reconcile(s) }) + 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() - if changed && !enabled { - if err := h.domains.ReleasePageDomains(ctx); err != nil { - h.log.Warn("webauthn: failed to release page domains", "err", err) - } - } - return nil -} - -func (h *Hook) SessionAttached(s cdpmonitor.Session) { - if s.TargetType != "page" && s.TargetType != "iframe" { - return - } - go h.reconcile(h.track(&session{ - id: s.ID, targetID: s.TargetID, targetType: s.TargetType, parentFrameID: s.ParentFrameID, cdp: s, - })) } func (h *Hook) track(s *session) *session { @@ -183,29 +240,19 @@ func (h *Hook) track(s *session) *session { return s } -func (h *Hook) SessionRemoved(sessionID string) { - h.mu.Lock() - delete(h.sessions, sessionID) - h.mu.Unlock() -} - -func (h *Hook) Reset() { - h.mu.Lock() - clear(h.sessions) - h.mu.Unlock() -} - -// reconcile brings one session to the current flag, retrying once after a -// short delay so a tab is not left unhooked by a transient failure. +// 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.reconcileOnce(s) - if err == nil { - return - } - time.Sleep(setupRetryDelay) - if err = h.reconcileOnce(s); err != nil && h.live(s) { - h.log.Error("webauthn: failed to reconcile hook", "session", s.id, "target_type", s.targetType, "err", err) + 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 { @@ -214,31 +261,15 @@ func (h *Hook) live(s *session) bool { return h.sessions[s.id] == s } -func (h *Hook) reconcileOnce(s *session) error { - s.setupMu.Lock() - defer s.setupMu.Unlock() +func (h *Hook) install(s *session) error { h.mu.Lock() enabled, live := h.enabled, h.sessions[s.id] == s - contexts := make([]executionContext, 0, len(s.contexts)) - for _, ec := range s.contexts { - contexts = append(contexts, ec) - } h.mu.Unlock() - if !live { + if !enabled || !live { return nil } ctx, cancel := context.WithTimeout(context.Background(), setupTimeout) defer cancel() - if enabled { - return h.install(ctx, s) - } - return h.uninstall(ctx, s, contexts) -} - -func (h *Hook) install(ctx context.Context, s *session) error { - if s.scriptID != "" { - return nil - } for _, command := range []struct { method string params any @@ -246,54 +277,16 @@ func (h *Hook) install(ctx context.Context, s *session) error { {"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 } } - s.domains = true - raw, err := s.cdp.Send(ctx, "Page.addScriptToEvaluateOnNewDocument", map[string]any{ - "source": hookSource, "runImmediately": true, - }) - if err != nil { - return err - } - var result struct { - Identifier string `json:"identifier"` - } - if err := json.Unmarshal(raw, &result); err != nil { - return err - } - s.scriptID = result.Identifier - return nil -} - -// uninstall suspends the hook in the session's documents before removing the -// binding, so they fall back at once rather than after a timeout. The domains -// themselves are released together, once every session is done. -func (h *Hook) uninstall(ctx context.Context, s *session, contexts []executionContext) error { - if !s.domains { - return nil - } - for _, ec := range contexts { - _, _ = s.cdp.Send(ctx, "Runtime.evaluate", map[string]any{ - "expression": suspendExpression, "uniqueContextId": ec.uniqueID, "silent": true, - }) - } - if s.scriptID != "" { - if _, err := s.cdp.Send(ctx, "Page.removeScriptToEvaluateOnNewDocument", map[string]any{"identifier": s.scriptID}); err != nil { - return err - } - s.scriptID = "" - } - if _, err := s.cdp.Send(ctx, "Runtime.removeBinding", map[string]any{"name": bindingName}); err != nil { - return err - } - s.domains = false return nil } -func (h *Hook) ProtocolEvent(sessionID, method string, params json.RawMessage) { +func (h *Hook) protocolEvent(sessionID, method string, params json.RawMessage) { switch method { case "Runtime.executionContextCreated": var event struct { diff --git a/server/lib/webauthnhook/hook_test.go b/server/lib/webauthnhook/hook_test.go index baf48c948..bc8881bbb 100644 --- a/server/lib/webauthnhook/hook_test.go +++ b/server/lib/webauthnhook/hook_test.go @@ -15,7 +15,7 @@ import ( "testing" "time" - "github.com/kernel/kernel-images/server/lib/cdpmonitor" + "github.com/kernel/kernel-images/server/lib/browsersurface" "github.com/stretchr/testify/require" ) @@ -151,23 +151,49 @@ func (f *fakeSigner) calls() []recordedRequest { return append([]recordedRequest(nil), f.requests...) } -type fakeDomains struct { - retained, released int +// 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 (d *fakeDomains) RetainPageDomains() { d.retained++ } +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() {} +} -func (d *fakeDomains) ReleasePageDomains(context.Context) error { - d.released++ - return nil +// 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 - domains *fakeDomains - logs *bytes.Buffer + hook *Hook + signer *fakeSigner + path string + logs *bytes.Buffer } func newHarness(t *testing.T) *harness { @@ -178,11 +204,17 @@ func newHarness(t *testing.T) *harness { func newHarnessAt(t *testing.T, path string) *harness { t.Helper() h := &harness{ - path: path, domains: &fakeDomains{}, logs: &bytes.Buffer{}, + 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}, h.domains) + 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 } @@ -215,57 +247,50 @@ func (h *harness) context(sessionID string, id int, origin, frameID string) { "id": id, "uniqueId": sessionID + "-" + origin, "origin": origin, "auxData": map[string]any{"frameId": frameID, "isDefault": true}, }}) - h.hook.ProtocolEvent(sessionID, "Runtime.executionContextCreated", params) + 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) + 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 TestSetInstallsAndRemovesHook(t *testing.T) { +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.SessionAttached(cdpmonitor.Session{ID: "worker", TargetType: "worker"}) + 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") - installed := []string{"Runtime.enable", "Page.enable", "Runtime.addBinding", "Page.addScriptToEvaluateOnNewDocument"} require.NoError(t, h.hook.Set(context.Background(), true)) - require.Equal(t, 1, h.domains.retained) - require.Equal(t, installed, page.methods()) + 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"]) - require.NoError(t, h.hook.Set(context.Background(), true)) - require.Len(t, page.methods(), 4, "enabling twice must not register the script twice") - require.Equal(t, 1, h.domains.retained) - 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.removeScriptToEvaluateOnNewDocument", "Runtime.removeBinding"}, page.methods()[4:]) - require.Equal(t, "script-1", page.commands[6].params["identifier"]) - require.Equal(t, bindingName, page.commands[7].params["name"]) - require.Equal(t, 1, h.domains.released) - - require.NoError(t, h.hook.Set(context.Background(), false)) - require.Len(t, page.methods(), 8) - require.Equal(t, 1, h.domains.released) + 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.Equal(t, installed, page.methods()[8:]) - require.Equal(t, 2, h.domains.retained) - - later := h.attach(t, "later", "iframe", "page") - require.Equal(t, installed, later.methods()) + 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) { @@ -289,13 +314,13 @@ func TestEnabledFlagSurvivesRestart(t *testing.T) { require.NoError(t, first.hook.Set(context.Background(), true)) restarted := newHarnessAt(t, path) - require.Equal(t, 1, restarted.domains.retained, "a restored hook retains page domains before the monitor starts") + 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.Zero(t, again.domains.retained) + require.Nil(t, again.hook.stopRun) require.Empty(t, again.attach(t, "page", "page", "").methods()) } @@ -435,14 +460,14 @@ func TestTopOriginFollowsNewestMainFrameContext(t *testing.T) { 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.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.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") } @@ -470,10 +495,10 @@ func TestIgnoredCalls(t *testing.T) { 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) + h.hook.protocolEvent("page", "Runtime.bindingCalled", other) destroyed, _ := json.Marshal(map[string]any{"executionContextId": 1}) - h.hook.ProtocolEvent("page", "Runtime.executionContextDestroyed", destroyed) + h.hook.protocolEvent("page", "Runtime.executionContextDestroyed", destroyed) h.call("page", 1, getCall("after-destroy")) time.Sleep(50 * time.Millisecond) diff --git a/server/lib/webauthnhook/signer.go b/server/lib/webauthnhook/signer.go index d4b6824bc..1f7e86ac9 100644 --- a/server/lib/webauthnhook/signer.go +++ b/server/lib/webauthnhook/signer.go @@ -45,6 +45,43 @@ type Signer interface { // 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 diff --git a/server/lib/webauthnhook/state.go b/server/lib/webauthnhook/state.go index 9efd269ed..dd582b62b 100644 --- a/server/lib/webauthnhook/state.go +++ b/server/lib/webauthnhook/state.go @@ -1,3 +1,9 @@ +// 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 ( @@ -9,7 +15,7 @@ import ( ) // DefaultStatePath sits with the other per-boot state the control plane -// hands the VM, so it survives restarts of the API process. +// hands the VM. const DefaultStatePath = "/run/kernel/webauthn-hook.json" type persisted struct {