# agentcookie architecture ## The picture ``` SOURCE (laptop) SINK (Mac mini / cloud VM) +---------------------------+ +-----------------------------+ | Chrome stable | | Chrome stable | | Cookies SQLite | | Cookies SQLite | | Safe Storage (Keychain)| | Safe Storage (Keychain) | | | | | | agentcookie source | | agentcookie sink (launchd) | | - read SQLite (RO) | | - listen :9999/sync | | - decrypt w/ local key | AES-GCM | - decrypt seal | | - filter by blocklist | over HTTP | - check proto + seq | | - wrap in envelope | ================> | - filter by blocklist | | - seal w/ peer key | on tailnet | - write Chrome SQLite | | | (WireGuard) | - write sealed sidecar | +---------------------------+ +-----------------------------+ ^ ^ | | +----------- Tailscale tailnet ------------------+ | (WireGuard, ACLs) | +--------------------------------------------------+ ``` ## Module layout | Package | Purpose | |---------|---------| | `cmd/agentcookie` | CLI entry point (cobra). | | `internal/cli` | Subcommand implementations: `source`, `sink`, `pair`, `status`, `version`. | | `internal/chrome` | Read + decrypt Chrome cookies on macOS via Keychain Safe Storage + SQLite. Schema-aware INSERT for the write path. | | `internal/transport` | AES-GCM seal/open with key = SHA-256(secret). | | `internal/config` | YAML loaders for `source.yaml`, `sink.yaml`, `blocklist.yaml`. Tilde expansion, defaults, validation. | | `internal/pairing` | X25519 + HKDF handshake. Source listens for pairing; sink connects with the printed code. Both sides derive identical 32-byte keys. | | `internal/keystore` | Per-peer key files at `~/.config/agentcookie/keys/.json` mode 0600. | | `internal/protocol` | `SyncEnvelope` (versioned), `SequenceTracker` (in-memory replay defense), `BlocklistMatcher` (SQLite-LIKE patterns, case-insensitive). | | `internal/cdp` | Tiny Chrome DevTools Protocol client: `Probe` + `Dial` + `Call`. One method we care about: `Storage.setCookies`. | ## Lifecycle: one sync 1. `agentcookie source --once` runs on the laptop. 2. Reads `~/.config/agentcookie/source.yaml` for sink URL and `peer.hostname`. 3. Reads `~/.config/agentcookie/blocklist.yaml` for opt-out domain patterns. 4. Loads the paired key for `peer.hostname` from `~/.config/agentcookie/keys/`. 5. Calls `security find-generic-password` to get Chrome Safe Storage; derives the per-machine AES key. 6. Opens Chrome's Cookies SQLite read-only with `immutable=1`. Drops rows matching each blocklist pattern. 7. Decrypts each `encrypted_value` (v10 prefix, AES-128-CBC, IV = 16 spaces, PKCS#7). 8. Wraps the cookies in a `SyncEnvelope` with version, hostname, monotonic Sequence. 9. AES-GCM-seals the envelope with the paired key. 10. POSTs to the sink's `/sync` URL. On the sink, in the `/sync` handler: 1. Reads the raw bytes. 2. Loads the paired key for the configured source hostname; AES-GCM-opens the payload. Wrong key -> 401. 3. JSON-unmarshals the `SyncEnvelope`. 4. Checks `ProtocolVersion == 1`. Mismatch -> 400. 5. Checks `Sequence` against the in-memory `SequenceTracker`. Replay -> 409. 6. Filters cookies against the sink's own `blocklist.yaml`. Dropped hosts are counted for logging. 7. If `cdp.enabled`, probes `http://:/json/version`, dials the browser-level WebSocket, sends `Storage.setCookies`. On any failure, falls back to step 8. 8. Otherwise opens the sink's Cookies SQLite read-write, re-encrypts each value with the SINK's Chrome Safe Storage key, upserts rows via a schema-aware INSERT ... ON CONFLICT (handles Chrome's `top_frame_site_key`, `source_type`, `has_cross_site_ancestor` columns dynamically). ## Lifecycle: pairing 1. Source: `agentcookie pair --as source` generates an X25519 ephemeral keypair and a fresh base32 code (e.g. `YILU-OIVK`). Listens on `:9998/pair`. Prints the code and the sink-run command. 2. Sink: `agentcookie pair --as sink --peer --pair-url ... --code YILU-OIVK` generates its own X25519 keypair, POSTs `(code, sink_pub, sink_hostname)` to source. 3. Source checks the code (constant-time compare). Computes `shared = X25519(source_priv, sink_pub)`. Derives `key = HKDF-SHA256(shared, salt=code, info="agentcookie-pair-v1")[:32]`. Replies with `(source_pub, source_hostname, fingerprint)`. 4. Sink computes the same `shared`, derives the same key. Verifies the source's fingerprint matches its own. Writes the key to `~/.config/agentcookie/keys/.json` mode 0600. 5. Source's listener shuts down; the key it derived is also written to disk on the source side, keyed by the sink's hostname. ## Where the security boundaries are | Boundary | Enforced by | |----------|------------| | OS user separation on source and sink | macOS user accounts, file mode 0600 on key files and configs | | Cookie value at rest | Chrome Safe Storage per-machine AES key + Keychain access prompt | | Cookie value in transit | AES-GCM with paired key + Tailscale WireGuard channel | | Pairing authenticity | Pairing code mixed into HKDF salt; MITM derives different key | | Sink-side opt-out | `blocklist.yaml` on sink; cookies for blocklisted hosts are dropped before writing | | Replay defense | `SequenceTracker` in sink memory; rejects equal-or-lower Sequence | | Protocol stability | `ProtocolVersion` int in every envelope; breaking changes bump the number | See [threat-model.md](threat-model.md) for what each of these protects against and what they don't.