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 agent or LLM is semi-trusted. It may be prompt-injected and must be able to request a login without receiving the password or TOTP seed as a tool result.
- The page is untrusted. It can attempt a fill on the wrong origin, transform a credential in JavaScript, transmit values over the network, or expose values through pixels and browser state.
gb-server,gb-kmsd,gb-noded, the host kernel, and the rootful Docker daemon are trusted service components in the hosted deployment. Execution cells isolate browser payloads from one another; they do not make those host components untrusted.- The public client-to-plane hop is behind TLS. The server-to-KMS application protocol uses bearer-authenticated HTTP and relies on loopback co-location (
gb-kmsdstill runs on the same host asgb-server); TLS is not enforced there. The plane-to-node protocol is no longer always loopback: production now runs the render node on a separate host (see Platform architecture), and that real hop is bearer-authenticated HTTPS with SHA-256 node-certificate pinning (GB_NODE_TLS_CERT/GB_NODE_TLS_KEYon the node;tls_fingerprintin the plane'sNODE_CREDENTIALSentry), verified live 2026-08-17. A loopback or self-hosted node may still register over plain HTTP by design — see below.
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:
- A root master key wraps a per-workspace key-encryption key (KEK).
- The workspace KEK wraps a fresh per-secret data-encryption key (DEK).
- 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:
gb-serverauthenticates the workspace API key and checks that its named secret scopes permit X.- The plane asks the exact live node session for
location.origin. The broker does not trust an origin supplied by the external caller. - The broker compares that live origin with the secret's allowlist. Matching is exact enough to distinguish hosts such as
localhostand127.0.0.1. - 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.
- On approval, the broker calls KMS Open, deserializes the credential in
gb-servermemory, derives any RFC 6238 TOTP code at that time, and sends the transient injection material over the bearer-authenticated node request. - 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:
- arbitrary JavaScript and predicate execution;
- screenshots, PDFs, cookies, and Set-of-Mark/pixel-derived results;
- starting a new recording after the session is armed.
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:
- a distinct non-root host UID and explicit shared service GID;
- a read-only root filesystem and private mount, PID, network, IPC, and cgroup namespaces;
- hard memory, CPU, and PID limits, capped local logs, and bounded tmpfs mounts;
network=noneand a single private bind containing only CDP and egress sockets;no_new_privs, enforced Moby-derived seccomp, and exactly enforceddocker-defaultAppArmor;- zero effective, permitted, inheritable, and ambient capabilities in the payload and renderer.
SYS_CHROOTremains only in the bounding set to admit Chromium's inner-sandbox syscall.
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:
- accepts only supported URL/authority forms;
- rejects credentials embedded in URLs and ambiguous numeric hosts;
- rejects loopback, private, link-local, unspecified, multicast, and metadata addresses;
- requires every DNS answer for a hostname to be public;
- sorts and pins the validated address set for the actual dial.
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
- API keys are hashed at rest, scoped, revocable, and always bound to the authenticated workspace. Full API keys are rejected in CDP URLs.
- Account-reset, verification, and invite tokens use a dedicated persisted random HMAC key, not a render-node credential. Team invites remain bound to the invited email and pending record.
- Webhook and scheduled-job URL admission rejects non-public targets, and the delivery client rechecks the dialled address and refuses redirects. Delivery admission is bounded, but a durable outbox is not yet implemented.
- Plane and node serialize action, recording finalization, and close for an exact session identity. Idle and maximum-age reapers bound abandoned browser lifetime.
- Internal errors are logged rather than returned in generic 500 responses, and request/response paths apply explicit size/time bounds.
- Mobile clients use OS-backed credential stores and server-side session revocation; their platform-specific details are documented with the clients rather than used to infer server or node custody.
Current guarantees and open boundaries
Implemented and tested in source:
- origin-bound credential authorization, human approval, TOTP-at-fill, scoped redaction, and sensitive-session return-path gates;
- sealed envelope storage (bbolt or Postgres, see Platform architecture) plus remote
gb-kmsdsupport and sealed master storage; - per-node identity/endpoint/capacity binding;
- opt-in, SHA-256-fingerprint-pinned TLS for the plane↔node hop, live in production on the real render node (see above);
- ticketed raw-CDP access with process-per-session hosted isolation;
- Docker execution-cell construction, reconciliation, public-only egress, and the destructive real-cell verification gate;
- immutable recording READY/ACK transfer and filesystem startup reconciliation.
Not claimed as complete:
- a control plane or node that is blind to credential plaintext;
- mutual TLS or attested execution on internal service hops — the plane↔node hop is pinned server-cert TLS plus a bearer token, not client-certificate mTLS, and server-to-KMS remains loopback bearer HTTP;
- horizontal control-plane replication or adoption of live sessions after restart;
- automatic post-restart READY-spool recovery or R2 object/index reconciliation;
- a durable webhook outbox or artifact-download egress meter;
- a published production cell image and live hosted-cell deployment solely from a passing build gate;
- cryptographically tamper-evident audit export.
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.