# Covalent Bond: Architecture & Security Deep reference for how Covalent Bond works and why it's secure. For setup and usage, see the [README](../README.md). For contributor rules and the security invariants, see [CONTRIBUTING.md](../CONTRIBUTING.md). ## What it's for Covalent Bond exists so two AI coding agents on different machines can work as one: the channel lets a well-tuned setup share the files, context, and conventions that make its agent good, so both agents converge on the same high-quality way of working. That goal sets the security bar: you are moving the very material that steers an agent's behavior between two machines through infrastructure you don't control, so the channel must be **authenticated** (not just encrypted), and every incoming file must pass through a **human consent gate** and reach the other agent wrapped as untrusted data. The rest of this document is how that bar is met. --- ## System architecture Every box below runs either on one of the two users' machines or on Cloudflare. Nothing else exists: there is no account system, no database, and no server that ever holds plaintext or keys. ```mermaid flowchart LR subgraph MA["Machine A (host)"] direction TB AgentA["AI agent
(any MCP client)"] subgraph SrvA["Covalent Bond MCP server (local process)"] direction TB ToolsA["mcp/server.js + tools.js
9 bond_* tools, consent flow"] SessA["daemon/session-manager.js
session state, keys (memory-only)"] CryptoA["daemon/crypto.js
X25519 · PBKDF2 · HKDF · AES-256-GCM"] PollA["relay/poll.js + client.js
5s poll loop, protocol state machine"] XferA["transfer/sender.js + receiver.js
validation, consent gate, untrusted wrap"] SecA["security/index.js
sanitize · path safety · audit · limits"] end HumanA(("Human A")) DiskA[("~/.covalent/
audit.log + incoming/")] end subgraph CF["Cloudflare (untrusted relay)"] direction TB Worker["Worker (stateless router)
routes by routing ID only"] DO["Durable Object per session
peers' public keys · seq-numbered
ciphertext queue · 30-min alarm expiry"] end subgraph MB["Machine B (guest)"] direction TB AgentB["AI agent
(any MCP client)"] SrvB["Covalent Bond MCP server
(same stack as Machine A)"] HumanB(("Human B")) end HumanA -. "session code, out-of-band
(chat / voice · never the relay)" .-> HumanB AgentA <-- "MCP over stdio" --> ToolsA ToolsA --> SessA SessA --> CryptoA ToolsA --> XferA XferA --> SecA SecA --> DiskA ToolsA --> PollA HumanA -- "bond_accept / bond_decline" --> ToolsA PollA <-- "HTTPS: routing ID +
AES-256-GCM ciphertext only" --> Worker Worker <--> DO SrvB <-- "HTTPS (same API)" --> Worker AgentB <-- "MCP over stdio" --> SrvB HumanB -- consent --> SrvB ``` Reading it like a system-design walkthrough: - **Clients**: each machine runs a small stdio MCP server that the agent talks to; stdout is reserved for JSON-RPC, all logging goes to stderr. - **Transport**: outbound HTTPS polling only (5s interval), so no inbound ports, NAT traversal, or firewall rules are needed on either machine. - **State**: session keys live only in the MCP server's memory; the relay's Durable Object holds only public keys and a sequence-numbered ciphertext queue, wiped by alarm 30 minutes after the last activity. - **Trust boundaries**: the two dashed edges are the interesting ones: the session code crosses human-to-human (never the relay), and everything that crosses the relay is ciphertext addressed by a code-derived routing ID. --- ## Security model Covalent Bond uses an **authenticated key exchange**. The security rests on two independent secrets: 1. **X25519 ephemeral keys**: each peer generates a keypair per session; only public keys are exchanged. 2. **The session code**: 12 uniform Base58 characters (`XXXX-XXXX-XXXX`, ≈70 bits of entropy) shared **out-of-band** (chat, voice) between the two humans. **The session code is never sent to the relay.** The session key binds *both* secrets together, plus a transcript of both public keys: ``` sharedSecret = X25519(myPrivate, theirPublic) codeKey = PBKDF2(sessionCode, "CovalentBond-CodeKey", 600k) # never leaves the machine transcript = SHA-256(hostPublicKey || guestPublicKey) sessionKey = HKDF(sharedSecret || codeKey, salt = transcript) routingId = PBKDF2(sessionCode, "CovalentBond-Routing", 100k) # the only code-derived value the relay sees ``` Then a **key-confirmation handshake** runs: each peer proves it derived the same `sessionKey` with an HMAC tag over its role and the transcript. Only after mutual confirmation are file transfers allowed. Messages use AES-256-GCM under the session key, with a per-message nonce + timestamp for replay protection. ### What the relay sees, and why it can't attack The relay only ever receives: - a **routing ID** (`PBKDF2(sessionCode)`): it routes by this and cannot recover the code from it; - the two **public keys**; - **AES-256-GCM ciphertext**. A **passive** relay learns nothing about message contents. An **active** relay that tries the classic key-substitution MITM (swapping in its own public keys) still cannot derive `sessionKey`, because it does not know the session code, and therefore `codeKey`, which is mixed into the key. The result: the two peers derive *different* keys, all forwarded traffic fails GCM authentication, key confirmation fails, and **the session aborts before any data is exchanged**. This is proven by `test/mitm.test.js`. **The session code is the trust anchor.** Its secrecy is what upgrades this from "encrypted against a passive relay" to "authenticated against an active relay." Share it over a channel the relay operator doesn't control, and never paste it into the relay. ### Threat coverage | Threat | Mitigation | |--------|-----------| | Passive relay reads traffic | E2EE: relay sees only ciphertext + public keys | | **Active relay MITM (key substitution)** | Session key binds the out-of-band code; key confirmation fails on substitution → session aborts | | Relay learns the session code | Relay only sees `PBKDF2(code)` routing ID, never the code | | Replay | Per-message nonce + timestamp window, enforced centrally for every packet type; replays are dropped without tearing the session down | | Message tampering | AES-256-GCM authenticated encryption | | Prompt injection via received files | Content wrapped in untrusted-data markers + high-confidence markers redacted + suspicious phrases surfaced in the consent prompt | | Unwanted file writes | Explicit human consent (`bond_accept`) before any write | | Path traversal / Windows device names | `safePath` basename + reserved-name blocking | | Symlink escape | Real-path verification before write | | Data exfiltration | File-type whitelist, per-file (256 KB default, `COVALENT_MAX_FILE_KB` up to a 384 KB ceiling bounded by the relay wire limit) and per-session (8× the per-file cap) limits, sensitive-name blocklist, enforced on both the sending and receiving side | | Session hijacking | 256-bit session IDs, ~70-bit session codes, strict format validation (HashDoS) | | Relay abuse | Per-session rate limiting inside the session object, optional per-IP throttle, payload caps enforced on actual bytes received | | Local network exposure | Localhost-only binding (never `0.0.0.0`) | | Silent operations | Every operation logged to `~/.covalent/audit.log` | ### Honest limitations - **The session code must be shared over a channel the relay doesn't see.** If an attacker controls both the relay *and* the code-sharing channel, they can MITM. This is inherent to any code-based pairing (the same trust model as Magic Wormhole / SAS-authenticated pairing). - The prompt-injection scanner is **defense-in-depth, not a guarantee.** The real protections are the untrusted-content wrapper and the human consent gate. Never rely on the regex list alone. - Session state (the code, private keys, and the derived session key) is **memory-only** and never written to disk; ephemeral private keys are wiped from memory once the session key is derived. Keys still live in ordinary process memory (not a hardware-backed keystore), and a process restart ends the session. --- ## Repository layout ``` covalent-bond/ ├── bin/cli.js # entry point: starts the MCP server ├── mcp/ │ ├── server.js # MCP server: tool routing, handshake orchestration, consent flow │ ├── tools.js # tool schemas + arg validation │ └── consent-ui.js # formatted tool-response text ├── daemon/ │ ├── crypto.js # X25519, routing ID, session key, key confirmation, AES-GCM │ └── session-manager.js# session state machine (memory-only) ├── relay/ │ ├── client.js # transport to the relay (routing ID only) │ └── poll.js # poll loop + protocol state machine (key exchange, confirm, dispatch) ├── transfer/ │ ├── sender.js # outbound files (validation + rate limit) │ ├── receiver.js # inbound files (consent + untrusted-content wrapping) │ └── preview.js ├── security/index.js # sanitization, path safety, audit, limits, stderr logger ├── cloudflare-worker/ # the relay (Cloudflare Worker + Durable Objects) └── test/ # suites + in-process mock relay + runner ``` The audit log and accepted files live in `~/.covalent/` (`audit.log`, `incoming/`); set `COVALENT_HOME` to relocate that directory (the test runner uses this to keep suites out of the real one). Session state is memory-only and never touches disk. ### Audit log Every operation appends a JSON line to `audit.log`, so a session can be reconstructed after the fact on each machine. The trail covers the full lifecycle: `session_created` / `session_joined`, `key_exchange_completed` / `key_confirmed`, `bond_message` (character count only), `sendFile` and `outbound` for transfers out, `transfer_offer_received` the moment an inbound file passes validation (so an offer is recorded even if the human never responds), `acceptTransfer` / `declineTransfer` / `safeWrite`, security drops (`packet_dropped_replay`, `packet_dropped_undecryptable`, `transfer_blocked_*`, `chat_dropped_malformed`, `session_aborted`, `relay_gap_detected`), and `peer_disconnected` / `session_ended`. Entries carry timestamps, safe identifiers (routing-ID and peer-ID prefixes, byte/character counts), and never the session code, keys, or message and file content. --- ## Handshake flow From session creation through a consent-gated transfer to teardown: ```mermaid sequenceDiagram autonumber actor HA as Human A participant H as Host MCP server participant R as Relay (untrusted) participant G as Guest MCP server actor HB as Human B Note over H: bond_connect:
generate code + X25519 keypair
routingId = PBKDF2(code, "Routing") H->>R: create(routingId, hostPubKey, peerId) H-->>HA: session code AbCd-1234-XyZw HA-->>HB: share code OUT-OF-BAND (never via relay) Note over G: bond_join:
same routingId from same code
generate own X25519 keypair G->>R: join(routingId, guestPubKey, peerId) R-->>G: hostPubKey R-->>H: system peer_joined(guestPubKey) Note over H,G: each side independently derives
sessionKey = HKDF(X25519 shared secret || PBKDF2(code), salt = SHA-256(both pubkeys))
the relay lacks the code, so it cannot derive this key G->>R: confirm tag (HMAC over role + transcript, encrypted) R-->>H: guest confirm Note over H: verify tag · wrong key or MITM aborts here H->>R: confirm tag (host) R-->>G: host confirm Note over H,G: state: confirmed · transfers now allowed
all traffic AES-256-GCM, nonce + timestamp vs replay H->>R: bond_send file.md (validated, encrypted) R-->>G: seq-numbered ciphertext via 5s poll G-->>HB: consent prompt: preview + scan findings HB->>G: bond_accept Note over G: only now written to ~/.covalent/incoming/
content wrapped as UNTRUSTED for the agent G->>R: bond_end → disconnect R-->>H: system disconnect (poller self-stops) ``` Rules enforced by `relay/poll.js`: - Before key confirmation, a peer message that fails to **decrypt** aborts the session: that is the signature of key substitution (or a wrong session code). After confirmation the peer has already proven the key, so undecryptable data can only be relay-injected junk: it is **dropped and audited**, denying a malicious relay a one-message teardown lever. - A `key_confirm` whose tag fails HMAC verification aborts the session. - A `file_transfer` / `chat` received before the session is `confirmed` aborts the session. - A replayed or stale packet (duplicate nonce, out-of-window timestamp) is **dropped and audited, not aborted on**; otherwise a malicious relay could tear sessions down at will by replaying old ciphertext. - On abort: polling stops, the relay session is disconnected, local state is cleared, and it is audited. Session states: `waiting → keyed → confirmed`. Transfers require `confirmed`. Valid peer traffic refreshes the 30-minute session lifetime on both ends. --- ## MCP integration The server exposes nine tools: | Tool | Purpose | |------|---------| | `bond_connect` | Create a session as host; returns the code to share out-of-band | | `bond_join` | Join a session with a code (derives the session key, sends key confirmation) | | `bond_status` | Connection + handshake state, pending transfers, unread-event count, and events since the last call | | `bond_send` | Send a file (requires a confirmed session; validated + rate-limited) | | `bond_message` | Send a short encrypted text message to the peer (agent-to-agent conversation) | | `bond_wait` | Long-poll: block until the next peer event arrives or the timeout passes | | `bond_accept` | Accept a pending transfer (writes it, injects a wrapped excerpt) | | `bond_decline` | Decline a pending transfer | | `bond_end` | Disconnect and clear session state | Events that arrive between tool calls (peer joined, incoming transfer or message, handshake result, disconnect) are queued and appended to the next tool response, surfaced with an unread count at the top of `bond_status`, and, unless disabled, raised as a desktop notification the moment they arrive. `bond_wait` lets an agent hold a live conversation by blocking on that queue; the 5-second background poll keeps receiving regardless, so a message is never lost even if no tool is called. Peer message text is sanitized, injection-scanned, and wrapped as untrusted content exactly like file content; it is never treated as instructions. **Desktop notifications** are alert-only and carry no peer-controlled bytes: the toast is built from a fixed template plus a whitelist-sanitized filename and an 8-char peer prefix, spawned detached, and disabled by `COVALENT_NOTIFICATIONS=off`. Nothing a peer sends can reach a shell. **stdio discipline:** the MCP server speaks JSON-RPC over stdout. Nothing in the process tree writes to stdout; all logging goes to **stderr** and the audit file, enforced by the shared `logger` in `security/index.js`. A stdout leak would corrupt the protocol. --- ## Testing `npm test` runs every suite via `test/run-all.js` against an in-process mock relay (`test/mock-relay-server.js`) that mirrors the Worker API; no network or `wrangler` needed. The runner points `COVALENT_HOME` at a temp directory, so tests never write to the user's real audit log or incoming folder. - `crypto-session`: X25519, routing ID, session key, key confirmation, replay, offline handshake - `handshake`: full host↔guest handshake + file transfer through the mock relay - `mcp-routing`: two MCP servers driven by tool calls end-to-end - `mitm`: **key-substituting malicious relay is defeated** - `security`: sanitization, path safety, exfiltration limits - `payload-hardening`: relay size limits, error sanitization, security headers - `rate-limiting`: per-session and per-IP throttling - `hardening-regressions`: centralized replay drop, inbound validation, collision-safe writes, sequence-number ordering, TTL refresh, local transfer IDs (consent TOCTOU), drop-vs-abort on decrypt failure, key-confirmation retry, delivery-gap detection - `relay-parity`: worker.js constants can never drift from `relay/constants.js` - `worker-do`: SessionDO storage layout under the real 128 KiB Durable Object value cap: chunked large transfers, queue pruning with `oldestSeq`, poll byte budget, poll TTL refresh --- ## Relay The relay is a Cloudflare Worker (`cloudflare-worker/`) with one **Durable Object per routing ID**. The single-threaded object makes every operation an atomic read-modify-write (no lost messages, no eventual-consistency lag), and an alarm deletes all state 30 minutes after the last activity. Messages carry a relay-assigned sequence number, so delivery cursors never depend on timestamps. The relay stores only routing IDs, public keys, and ciphertext; it never has the means to decrypt traffic. Deploy details are in `cloudflare-worker/README.md`.