Docs / Security & credential vault

Security and Credential Vault

Status: current implementation and honest limits. This document describes the security boundaries in the repository now. It supersedes earlier Cloudflare Worker/D1, cloud-KMS, node-only-plaintext, universal-TLS, and shared-context descriptions. See Platform architecture for topology; the exact verification and remaining operator work are tracked in an internal engineering document, not published here.

Threat model

AgentBrowser handles credentials on behalf of agents that may be influenced by hostile pages. The current model assumes:

The most important correction to the early design is plaintext custody: a hosted secret is not decrypted only inside the browser node. It exists transiently in the KMS Open path, in gb-server's broker, in the authenticated node request, and in the renderer at fill time. It is never intentionally stored at rest in plaintext, returned to the agent, written to an artifact, or placed in a model context.

Stored-secret encryption

The implemented vault uses a three-level AES-256-GCM envelope:

  1. A root master key wraps a per-workspace key-encryption key (KEK).
  2. The workspace KEK wraps a fresh per-secret data-encryption key (DEK).
  3. The DEK encrypts the serialized username, password, and optional TOTP seed.

The secret ciphertext is authenticated with context derived from workspace ID and secret name. Moving ciphertext or wrapped keys to another workspace/name therefore fails authentication rather than relying only on an application lookup.

The control-plane database — bbolt for local/self-hosted, Postgres for the hosted production deployment (internal/platform.Store; see Platform architecture) — stores wrapped KEKs, wrapped DEKs, ciphertext, origin allowlists, and approval flags. It does not store the root master, unwrapped KEKs/DEKs, or credential plaintext.

Root-key custody

Production configuration prefers gb-kmsd, a small Go daemon that exposes only new-KEK, Seal, Open, and health operations behind a bearer token. Its root master is sealed on disk under an operator passphrase using scrypt and AES-GCM, then unsealed into daemon memory at startup. The expected endpoint is loopback, with the default 127.0.0.1:8093.

If KMS_ENDPOINT is absent, gb-server constructs an in-process KMS from KMS_MASTER_KEY. That is an explicit development/legacy single-binary fallback. It gives a server compromise direct access to the root key and should not be described as equivalent to the separate-daemon custody model.

Separating gb-kmsd keeps the root and unwrapped workspace keys out of gb-server, but it does not make the server unable to decrypt online. The KMS client's Open response contains secret plaintext so that the broker can construct the injection. A server compromise with the KMS token and endpoint reachability can abuse that online operation while access remains available; the improvement is that it cannot steal the root and decrypt the vault offline forever.

The KMS client accepts the configured HTTP(S) endpoint and bearer token; it does not implement mTLS or remote attestation. Keep this boundary on loopback or provide a reviewed encrypted transport before separating it across hosts.

Fill-time authorization and injection

An agent requests an action such as “login with secret X”; it does not request the secret value. The hosted path is:

  1. gb-server authenticates the workspace API key and checks that its named secret scopes permit X.
  2. The plane asks the exact live node session for location.origin. The broker does not trust an origin supplied by the external caller.
  3. The broker compares that live origin with the secret's allowlist. Matching is exact enough to distinguish hosts such as localhost and 127.0.0.1.
  4. If the secret requires approval, the broker places one bounded pending request on the human console path and waits for an explicit approve/deny decision. Per-session, per-workspace, and global limits prevent unbounded pending work; notifier execution is also bounded.
  5. On approval, the broker calls KMS Open, deserializes the credential in gb-server memory, derives any RFC 6238 TOTP code at that time, and sends the transient injection material over the bearer-authenticated node request.
  6. The node rechecks the live target and fills through CDP. The external tool sees an action result, never the credential value or seed.

Every broker allow/deny decision is recorded without secret material. The current audit view is a bounded rolling slice in gb-server memory, not a durable bbolt record, cryptographically tamper-evident log, or externally anchored export. It is lost on restart, so documentation must not promise any of those stronger properties.

Redaction and sensitive-session gates

After a hosted fill, the exact browser/CDP session becomes sensitive until it is destroyed. The injected plaintext and JSON-escaped form are armed only for that session. Text tool returns and recorded CDP payloads pass through literal redaction; another open session is unaffected, and closing a session clears only its own values.

Literal redaction is defence in depth, not a complete confidentiality boundary. A same-origin page could split, hash, encode, render, or transmit the value. For that reason the node also refuses return paths that can bypass substring filtering:

If recording was active before the login, the node finalizes it before the fill. Bundle assembly scans that session's live raw and JSON-escaped secret canaries and destroys a contaminated bundle rather than publishing it. Navigation, ordinary clicks, and form submission remain available so the authenticated flow can finish.

These gates reduce agent-visible exfiltration. They cannot stop the legitimate page origin from seeing a credential typed into its own form, nor can they prove that the page will not send it elsewhere. Origin selection and human approval are therefore security decisions, not cosmetic controls.

Node identity and transport

Each render node has a unique bearer credential configured in gb-server and gb-noded. The server binds it to an immutable node ID, endpoint, mode, region, warm-pool size, and maximum session count. Registration claims that differ from the configuration are rejected. Plane RPC resolves the target endpoint back to that same configured credential, preventing a fleet-wide shared token or caller-chosen node target.

The node makes outbound registration and heartbeat calls. Session allocation, actions, streams, recording transfer, deletion, and CDP tunnelling are direct plane-to-node calls; there is no persistent reverse tunnel in the application.

gb-noded's listener defaults to plain HTTP on loopback, unchanged for local and self-hosted deployments. It can also serve HTTPS: setting both GB_NODE_TLS_CERT/GB_NODE_TLS_KEY wraps the daemon's listener in TLS and its advertised/registered endpoint switches to https://; setting only one of the two is a hard startup error, never a silent partial fallback. On the plane side, a NODE_CREDENTIALS entry with tls_fingerprint set (the SHA-256 digest of the node's DER certificate) makes gb-server pin that exact certificate — it constructs a dedicated *http.Transport whose VerifyPeerCertificate does a constant-time full match against the expected fingerprint (crypto/subtle .ConstantTimeCompare) instead of normal CA-chain verification, and every call path that reaches the node (JSON RPC, the two streaming clients, and all three WebSocket tunnels — CDP, Playwright, live events) shares that one pinned transport. A non-loopback node endpoint must be either https:// with tls_fingerprint configured, or http:// with an explicit, logged allow_insecure_transport: true opt-in; anything else is rejected at startup with an actionable error. Loopback (127.0.0.0/8, ::1, localhost) stays unrestricted, any scheme, since that traffic never leaves the machine.

This is encrypted, node-cert-pinned transport, not mutual TLS: the node does not authenticate the plane by a client certificate, so the existing per-node bearer token remains the plane→node direction's own authentication, unchanged. Production runs the real render node on a separate host from the control plane (see Platform architecture), and that hop is configured this way — verified live 2026-08-17 by inspecting the production NODE_CREDENTIALS entry (endpoint: "https://…:8092" with a tls_fingerprint set) and the node's own TLS-serving config. A deployment that instead opts a non-loopback node into allow_insecure_transport is choosing to run that hop in plaintext on purpose, not falling back to it silently. Hardware identity and execution attestation remain optional future defence in depth.

Hosted execution cells

Hosted normal and raw-CDP allocations each own one whole Chrome process in one disposable Docker cell. BrowserContexts are not used as the hostile multi-tenant boundary. The current browser is the digest-pinned Playwright chromium_headless_shell; hosted mode is headless and does not wire Xorg or /dev/uinput into gb-noded.

Each cell uses:

The bind is writable only for the trusted entrypoint handshake. The host verifies the exact two Unix-socket inodes, then changes the directory from writable 0770 to sealed 0550. The entrypoint refuses to launch Chrome until it observes the sealed mode. The browser cannot create host files or replace the node-dialled socket paths after that handshake.

A stable per-node singleton lock and listener are acquired before execution-cell reconciliation and held through shutdown. Reconciliation removes only containers and private temporary roots that carry the exact node/cell owner markers; foreign, malformed, or ambiguous state survives fail-closed. This prevents a duplicate same-identity daemon from deleting a live incumbent's cells.

The rootful Docker daemon and node remain trusted and Docker-socket access is root-equivalent. Only the dedicated node/gate account should receive it; cells never do. Hosted cells are supported only on dedicated disposable render hosts until a narrower supervisor replaces this boundary. Self mode is a direct browser process and does not inherit the execution-cell claims.

scripts/browser-cell-ci.sh is the destructive local/Cloud Build proof for this boundary. It verifies namespaces, UIDs, cgroups, capabilities, seccomp/AppArmor, renderer sandboxing, direct-network and socket denials, filesystem isolation, public-proxy behavior, OOM/crash containment, cleanup, and exact-owner reconciliation. Passing it is evidence for the tested image and host. A production image publication and live hosted deployment are separate operator evidence and are not claimed merely because the gate passes.

Hosted egress policy

The execution cell has no direct network. Chrome sends HTTP/HTTPS and CONNECT traffic through a node-owned Unix-socket proxy. Before dialing, the proxy:

The proxy does not follow application redirects on behalf of the browser; each new browser request is checked again. This blocks direct private-network/metadata access and basic DNS rebinding. It is a public-address policy, not a domain allowlist or content-inspection firewall. It also means hosted sessions cannot use localhost or private preview targets without a separately designed reachability mechanism.

Raw CDP isolation

Every hosted raw-CDP session receives its own execution cell and browser process. The browser launches with a fixed internal sentinel --remote-allow-origins value—never *—and only node-owned CDP dialers add that Origin. A hostile page cannot use its browser process as a general bridge to another tenant's DevTools endpoint.

The public cdp_url carries a signed, short-lived ticket scoped to the exact session. It does not contain the reusable account API key. Alternatively, a caller may authenticate with a scoped API key in an Authorization header. gb-server checks session ownership before it tunnels bytes to the node, and the node hop uses the configured per-node bearer.

Recording and artifact security

Recording finalization is a READY/ACK protocol. The node assembles and syncs one immutable local tarball and atomically publishes a secret-free READY sidecar with the session ID, relative archive path, exact length, and SHA-256. Repeated GETs before ACK return that same spool. Startup revalidates and reindexes intact sidecars and quarantines corrupt, partial, traversing, or symlinked entries. gb-server pulls it with the node credential, enforces bounds, verifies both values, and only then commits object plus bbolt index and sends a checksum/size- bound ACK. Only ACK removes the node copy and sidecar.

With R2 configured, the plane—not the node—performs the signed PUT. Authenticated downloads receive a one-hour pre-signed R2 GET. With the default filesystem backend, the plane stores and streams the object itself; there is no signed download URL. Artifact egress usage is not currently recorded by the download handlers, so signed URLs must not be described as a universal egress meter.

Filesystem object/index reconciliation runs at startup. Equivalent R2 startup reconciliation remains unfinished. READY bytes and sidecars remain on node disk after a failed drain; startup rebuilds the READY lookup index and quarantines invalid entries instead of serving them. Active plane session routing and close flags are also in memory and are not adopted after a plane restart.

Other platform hardening

Current guarantees and open boundaries

Implemented and tested in source:

Not claimed as complete:

Security documentation must keep both lists. Turning an open boundary into a guarantee requires code, tests, and operator evidence in the same task—not only a design decision or deployment intention.