# Pairing and per-peer keys

> X25519 + HKDF-SHA256 handshake, the base32 pairing code, the per-peer key file at `~/.config/agentcookie/keys/<peer>.json`, rate-limiting on `/pair`, and the sealed master key.

- Repository: mvanhorn/agentcookie
- GitHub: https://github.com/mvanhorn/agentcookie
- Human docs: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae
- Complete Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/llms-full.txt

## Source Files

- `internal/pairing/pairing.go`
- `internal/pairing/ratelimit.go`
- `internal/keystore/keystore.go`
- `internal/keystore/master.go`
- `internal/keystore/seal.go`
- `internal/cli/pair.go`

---

---
title: "Pairing and per-peer keys"
description: "X25519 + HKDF-SHA256 handshake, the base32 pairing code, the per-peer key file at `~/.config/agentcookie/keys/<peer>.json`, rate-limiting on `/pair`, and the sealed master key."
---

`agentcookie pair` runs a one-shot HTTP listener on the source and POSTs a single envelope from the sink to derive a 32-byte symmetric key. The source binds its `/pair` endpoint on the Tailscale tailnet address at port `9998`, generates an ephemeral X25519 keypair plus a 12-character base32 pairing code, and waits up to `PairTimeout = 10 * time.Minute` for the sink to connect. The sink POSTs its public key with the operator-pasted code; both sides compute the X25519 shared secret and run `hkdf.Key(sha256.New, shared, salt=code, info="agentcookie-pair-v1", 32)` to produce the per-peer key written to `~/.config/agentcookie/keys/<peer>.json` mode `0600`. The same package also owns the sink-side master key (`agentcookie-master` Keychain item) used by `Seal`/`Unseal` to envelope sidecar databases and adapter session files.

## Handshake protocol v1

The protocol version constant is `pairing.ProtocolVersion = 1`. Both sides reject envelopes with a mismatched version. The salt mixed into HKDF is the normalized pairing code (uppercase, hyphen-separated 4-character groups). The `info` parameter is the literal string `"agentcookie-pair-v1"` and should be bumped together with `ProtocolVersion` on any incompatible change.

```text
  source                                  sink
  ──────                                  ────
  ephemeral X25519 keypair
  pairing code  (12 base32 chars)
  listen on tailnet 100.x:9998
  POST /pair  ◄──── SinkRequest ────  ephemeral X25519 keypair
   verify code (constant time)         (operator pasted the code)
   ECDH( source_priv, sink_pub )
   HKDF-SHA256(shared, code, info)
   fingerprint = hex( sha256(key)[0:4] )
  ─── SourceResponse ─────────►       ECDH( sink_priv, source_pub )
                                       HKDF-SHA256(...)
                                       check fingerprint matches
  write keys/<sink-host>.json         write keys/<source-host>.json
```

### Wire envelopes

Both directions exchange a JSON object on the single `POST /pair` round-trip. Field names below are the JSON keys.

<ParamField body="protocol_version" type="int" required>
Must equal `1` or the source returns `400 protocol mismatch: sink=N source=1`.
</ParamField>

<ParamField body="code" type="string" required>
The pairing code as shown by the source, in any case, with or without hyphens. The source normalizes both sides before a constant-time compare and returns `401 invalid pairing code` on mismatch.
</ParamField>

<ParamField body="sink_public_key" type="bytes" required>
Raw X25519 public key (32 bytes, base64-encoded by Go's `encoding/json` byte slice rule). Bad-shape keys return `400 bad sink public key`.
</ParamField>

<ParamField body="sink_hostname" type="string" required>
The sink's `os.Hostname()` (or `"unknown-host"` fallback). The source persists this as the `peer` filename when it writes its own key file.
</ParamField>

The successful response is a `SourceResponse`:

<ResponseField name="protocol_version" type="int">Always `1` for this build.</ResponseField>
<ResponseField name="source_public_key" type="bytes">Raw X25519 public key, 32 bytes.</ResponseField>
<ResponseField name="source_hostname" type="string">The source's `os.Hostname()`.</ResponseField>
<ResponseField name="fingerprint" type="string">Hex of `sha256(key)[:4]` (8 hex characters). The sink computes the same hash locally and aborts with `fingerprint mismatch (man-in-the-middle?)` if they differ.</ResponseField>

### HTTP error map

| Status | Trigger |
| --- | --- |
| `405` | Non-`POST` method on `/pair` |
| `429` | Per-IP token bucket exhausted (see Rate limiting) |
| `400` | Body decode error, protocol mismatch, or bad sink public key |
| `401` | Pairing code does not match |
| `500` | ECDH or HKDF failed (should not occur on healthy hosts) |

## Pairing code

The code is a 12-character substring of a standard base32 encoding over 8 random bytes (64 bits of entropy). It is displayed and persisted in canonical form `XXXX-XXXX-XXXX`. `pairing.Code.Normalize` strips dashes and whitespace, upper-cases, and re-inserts dashes; `Code.Equal` compares normalized forms with `crypto/subtle.ConstantTimeCompare`.

```go
// internal/pairing/pairing.go
CodeLength      = 12   // v0.12 bumped from 8 (40 bits) to 12 (64 bits)
HKDFInfo        = "agentcookie-pair-v1"
PairTimeout     = 10 * time.Minute
```

The code is the MITM defense even when the transport itself is confidential: an attacker who relays the X25519 exchange without knowing the code derives a different key under HKDF, and the first sealed message past pairing fails its AEAD tag.

## Rate limiting on `/pair`

`internal/pairing/ratelimit.go` runs a per-IP token bucket scoped to the lifetime of the listener (≈10 minutes). State is in-memory only; persisting it would not improve the security model because the listener exits when pairing completes or times out.

<ParamField body="AttemptLimit" type="int" default="5">
Maximum concurrent in-flight tokens per remote IP. The first request from an unseen IP starts with a full bucket and consumes one. Exhausted buckets return `429 too many pairing attempts`.
</ParamField>

<ParamField body="RefillInterval" type="duration" default="500ms">
Time for a single token to regenerate. A legitimate operator typing or pasting the code never hits the limit; an automated guesser is paced into meaningful wall time.
</ParamField>

The client IP comes from `net.SplitHostPort(r.RemoteAddr)`. Parse failures fall back to the raw `RemoteAddr` string so the limiter still applies a stable key.

### Listener hardening

The `/pair` listener uses the `httpserver.Pair` profile, which caps body size at 16 KB and applies per-phase timeouts:

| Setting | Value |
| --- | --- |
| `ReadHeaderTimeout` | `5s` |
| `ReadTimeout` | `30s` |
| `WriteTimeout` | `30s` |
| `IdleTimeout` | `60s` |
| `MaxHeaderBytes` | `16 KiB` |
| `MaxBodyBytes` | `16 KiB` |

The sink-side client uses `httpserver.PairClient` with `ClientTimeout = 30s`.

The listen address is validated by `internal/cli/wizard.validateListenAddr`. Empty triggers `tsclient.RequireTailnetIP` and binds to `100.x.y.z:9998`; an explicit value must be a Tailscale `100.x` address or one of `127.0.0.1`/`::1`/`localhost`. Binding `0.0.0.0`/`::` is refused with a hard error.

## Per-peer key file

`Save`, `Load`, `Delete`, and `List` in `internal/keystore/keystore.go` own the file format. The directory is created with `0700`; each key file is written atomically via tempfile-plus-rename, `Chmod`-ed to `0600`, then renamed into place.

```json
// ~/.config/agentcookie/keys/<peer>.json
{
  "peer": "lab-mini",
  "announced_as": "lab-mini.tail-scale.ts.net",
  "key": "BASE64(32 bytes)",
  "paired_at": "2026-05-31T14:22:18-07:00",
  "fingerprint": "1a2b3c4d",
  "protocol_version": 1
}
```

<ResponseField name="peer" type="string">
Lookup key the running daemon resolves from `peer.hostname` in `source.yaml` / `sink.yaml`. Also the sanitized on-disk filename.
</ResponseField>
<ResponseField name="announced_as" type="string">
What the remote announced itself as during the handshake. Diagnostic only; often differs from `peer` when `peer` is an operator-pasted Tailscale name and `announced_as` is an `os.Hostname` FQDN. Omitted from JSON when empty for backward compatibility with pre-beta.2 files.
</ResponseField>
<ResponseField name="key" type="bytes">
The 32-byte HKDF output. The transport layer hashes it through SHA-256 to derive the AES-GCM key on both sides.
</ResponseField>
<ResponseField name="paired_at" type="time">RFC3339 timestamp of the handshake.</ResponseField>
<ResponseField name="fingerprint" type="string">Same 8-hex-character digest shown during pairing; useful for diagnosing replaced keys.</ResponseField>
<ResponseField name="protocol_version" type="int">Matches `pairing.ProtocolVersion` at pairing time.</ResponseField>

### Filename sanitization

`sanitize` in `keystore.go` replaces every character outside `[A-Za-z0-9._-]` with `_` and strips leading dots, so an attacker-controlled peer name cannot create dotfiles or traverse out of the keys directory. `Path` rejects empty peer names with `peer name cannot be empty`. `List` skips entries whose name does not end in `.json` or that start with `.` (the tempfile pattern `.tmp-*.json` from atomic writes is one example).

### Transport key resolution

`resolveTransportSecret` in `internal/cli/common.go` prefers the paired key over the legacy `security.shared_secret` YAML field. When `peer.hostname` is set and `keystore.Load` succeeds, the raw 32-byte `PeerKey.Key` becomes the AES seed for the wire envelope. If the paired key is missing and no legacy secret is configured, the call fails with `no key for peer "<host>" (run \`agentcookie pair\`)`.

## Running `agentcookie pair`

`internal/cli/pair.go` wires the source and sink flows behind the `--as` flag.

<Steps>

<Step title="Source: start the listener">
```bash
agentcookie pair --as source
```

With no `--listen`, the CLI calls `tsclient.RequireTailnetIP` and binds to `100.x.y.z:9998`. The code, source hostname, and the exact sink command line are printed to stderr; the listener stays open for `PairTimeout = 10m`.
</Step>

<Step title="Sink: complete the handshake">
```bash
agentcookie pair --as sink \
  --peer <source-hostname> \
  --pair-url http://<source-tailnet-ip>:9998/pair \
  --code XXXX-XXXX-XXXX
```

`--peer` is required and becomes the filename of the saved key. `--code` accepts any case and is normalized before transmission.
</Step>

<Step title="Both sides write the key file">
On success each side writes `~/.config/agentcookie/keys/<peer>.json` with mode `0600` and prints the fingerprint. The wizard install path takes the same code over SSH; `wizard install --as sink` skips pairing when an existing key file is present unless `--repair` is passed.
</Step>

</Steps>

### Flags

<ParamField body="--as" type="source | sink" required>
Selects the role. Anything else exits with `--as is required and must be 'source' or 'sink'`.
</ParamField>

<ParamField body="--listen" type="host:port">
Source listener address. Empty auto-detects the tailnet 100.x address. Explicit values must be a Tailscale 100.x address or a loopback alias. Binding every interface is refused.
</ParamField>

<ParamField body="--local-name" type="string">
Hostname announced to the peer. Defaults to `os.Hostname()` (or `"unknown-host"` if that errors).
</ParamField>

<ParamField body="--pair-url" type="url">
Sink-only. Full URL of the source's `/pair` endpoint, e.g. `http://100.64.0.1:9998/pair`.
</ParamField>

<ParamField body="--code" type="string">
Sink-only. The pairing code from the source's stderr.
</ParamField>

<ParamField body="--peer" type="string">
Sink-only. Source hostname; also the filename under `~/.config/agentcookie/keys/`.
</ParamField>

## Sealed master key

`internal/keystore/master.go` and `seal.go` own a second, sink-local store distinct from the per-peer keys: a 32-byte random master key in the macOS login keychain under service `agentcookie-master`, account `agentcookie`, value hex-encoded.

```go
const (
    MasterKeychainService = "agentcookie-master"
    MasterKeychainAccount = "agentcookie"
    MasterKeyBytes        = 32
)
```

The master key wraps the sealed sidecar SQLite and adapter session files so a non-agentcookie process running as the same user cannot read those plaintexts even on a box where Chrome Safe Storage would otherwise hand over its key.

### Creation

`CreateMasterKey(agentcookieBinary, extraBinaries)` is idempotent: it deletes any prior `(service, account)` item, generates 32 fresh random bytes, and reinstalls via `/usr/bin/security add-generic-password` with a `-T` ACL listing each allowlisted binary. The ACL is anchored to each binary's Developer-ID-signed designated requirement rather than its cdhash, so rebuilding the binary with the same identity does not invalidate the ACL. The wizard install is the only intended caller; runtime paths read the existing key.

### Reading

`ReadMasterKey()` shells `security find-generic-password -s agentcookie-master -a agentcookie -w`. Absence is reported as `ErrMasterKeyMissing`, which routes the operator to `agentcookie wizard install --as sink`. Decode errors and unexpected lengths surface as fatal "delete and re-run wizard install" messages because they signal a corrupted Keychain entry. `MasterKeyExists()` checks presence without reading the value; the wizard uses it to keep installs idempotent.

### Envelope format

`Seal` and `Unseal` produce and consume a fixed AES-256-GCM envelope:

```text
+---------------------+----------------------+----------------+
| 12-byte GCM nonce   | ciphertext           | 16-byte tag    |
+---------------------+----------------------+----------------+
        nonceSize             len(plaintext)         gcmTagLen
```

Total overhead is 28 bytes. Both helpers reject any input where the master key length differs from `MasterKeyBytes`. `Unseal` rejects envelopes shorter than `nonceSize+gcmTagLen` before calling `gcm.Open`, and any tag-check failure is surfaced as `envelope corrupt or master key mismatch`. The envelope is safe to write under the user's regular file permissions because the master key itself is gated by the Keychain ACL.

## Repair, rotation, and diagnostics

- **Re-pairing**: delete `~/.config/agentcookie/keys/<peer>.json` (or run the wizard with `--repair`) and re-run `agentcookie pair`. The next handshake overwrites the file atomically.
- **Fingerprint comparison**: the 8-hex-character `fingerprint` is printed on both sides at pairing time and stored in the JSON. Reading both files and comparing the field is the quickest sanity check that a single handshake produced both copies.
- **Rate-limit lockout**: a `429` from `/pair` clears the moment a token refills (`500ms`) or when the listener exits.
- **`announced_as` drift**: when `peer` (operator-pasted Tailscale name) differs from `announced_as` (`os.Hostname` FQDN), the daemon's lookup key is still `peer`; `announced_as` is purely diagnostic.
- **Master key recovery**: `ErrMasterKeyMissing` or any decode/length error from `ReadMasterKey` is unrecoverable at runtime; delete the Keychain item and re-run `wizard install --as sink` to regenerate and re-grant the binary ACL.

## Related pages

<CardGroup>
  <Card title="Quickstart" href="/quickstart">The five-minute laptop + second-Mac pairing walkthrough that wraps these primitives.</Card>
  <Card title="Headless second-Mac install" href="/headless-install">SSH-only pairing with the degraded-mode fallback and the `wizard set-keychain-access` upgrade path.</Card>
  <Card title="Configure source and sink" href="/configure-source-sink">Where `peer.hostname` is set so the daemon picks the right `keys/<peer>.json`, plus listen-address validation rules.</Card>
  <Card title="Wire protocol v1" href="/wire-protocol">How the derived key feeds AES-256-GCM on `/sync` after pairing completes.</Card>
  <Card title="LaunchAgent management" href="/launchagent-management">The post-pairing daemon lifecycle that consumes the per-peer key.</Card>
  <Card title="Troubleshooting" href="/troubleshooting">`connection refused`, sink Keychain prompts, and other pairing failure recovery.</Card>
</CardGroup>
