ADR-0003: Peer Enrollment Approval Gate and Client-Side Key Generation
- Status: Adopted (standalone path, Release-N scope — client keygen + opt-in approval gate, API + CLI; K8s CRD path, dashboard UI and audit actor wiring remain follow-ups)
- Date: 2026-09-17
- Related: ADR-0004 (LRP per-peer authentication — transport plane), 2026-09-17 codebase review (docs/reviews/2026-09-17-codebase-review.md)
Context
Today's admission flow
Enrollment is already gated by Enrollment Tokens (single-use, TTL, usage limit — t_enrollment_tokens, EnrollmentTokens() store; LatticePeer + AgentIdentity auto-created on the K8s path). But presenting a valid token is sufficient: the peer is admitted immediately, with no approval step and no human in the loop. Whoever obtains a token (they are shared with device owners by copy/paste) can enroll an arbitrary device into the workspace.
There is no lifecycle state on a peer beyond liveness: models/peer.go has no pending/approved/revoked status, and the netmap builder admits every registered peer unconditionally.
The deeper gap: server-side key generation
The two enrollment paths disagree about who owns the WireGuard keypair:
- Sandbox HTTP enroll (
agent_registration.go): the agent generates the keypair locally and submits only the public key (// PublicKey is the WireGuard public key generated by the Agent). - Regular
lattice upregistration (NATSregister→peerService.registerStandalone,peer.go:479-516): the control plane generates the private key on first enrollment, stores it int_peer, and returns it to the agent insideinfra.Peer.PrivateKey.
Consequences of the server-side path:
- A database leak leaks every peer's private key — the entire mesh's identity material at once.
- The control plane can impersonate any peer (it holds every private key), which weakens the meaning of peer identity everywhere else, including the relay authentication design in ADR-0004.
- Private keys traverse the wire twice in plaintext contexts (NATS JSON signal payloads at registration, DB at rest), contradicting the zero-trust positioning of the product.
The two paths should converge on client-side key generation. Because the approval gate and the key-hand-off touch the same registration code path, they are designed together in this ADR.
Existing precedent
The policy layer already implements a review workflow with audit: PolicyStatusPending → PolicyStatusApproved (models/policy.go) and policy_version.go records "who changed it, who approved it". The approval gate proposed here reuses that pattern rather than inventing a new one.
Decision
- Approval gate: a peer becomes network-reachable only after explicit approval. Lifecycle:
pending → approved, plusrevoked(andexpiredfor TTL scenarios). Unapproved peers exist in the registry (so they show up in the dashboard and can be approved) but receive an empty netmap and are excluded from every other peer's netmap. - Client-side key generation on every path: the agent always generates its WireGuard keypair locally and submits only the public key. The control plane stores public keys only;
infra.Peer.PrivateKeyis removed from the wire struct. A one-release compatibility window accepts legacy agents that arrive without a public key (server keeps generating for them, with a loud warning). - Re-registration binds to (AppID, PublicKey): an existing AppID re-registering with a different public key is treated as a new device — it re-enters
pending(or is rejected, per workspace policy) instead of silently taking over the old identity. Same key resumes as today.
Detailed design
Peer lifecycle state machine
token valid
(absent) ───────────────► pending ──approve──► approved ──revoke──► revoked
│ ▲ │
│ └─ re-register,
│ same pubkey │ re-register,
▼ ▼ different pubkey → pending
(heartbeat wait) approved (new state recorded)New columns on t_peer (and status condition on LatticePeer CRD):
| Field | Type | Notes |
|---|---|---|
| ApprovalStatus | string | pending / approved / revoked; default approved for compatibility (see Rollout) |
| ApprovedBy | string | user identity of the approver |
| ApprovedAt | time |
The default of approved is deliberate: approval is opt-in per workspace (Workspace.RequirePeerApproval bool, default false), so single-user / homelab deployments (the community majority) see zero behavior change until they flip the switch. Enterprises set it per workspace.
Registration with a pending peer
registerStandalone/ K8s register succeed (token validated, peer row created) but the response carriesApprovalStatus: "pending", no overlay address and no netmap.- The agent prints a first-class status —
awaiting approval from the workspace administrator— and pollsGetNetmapon a slow backoff (30s → 5min cap). Heartbeats continue so the peer shows as "pending, seen 12s ago" in the dashboard rather than as a ghost. - When approved, the control plane publishes
PublishNetmapChangedfor the new peer; its next refresh assigns the address and it converges like any other join (no re-registration needed). - The netmap builders (both
reconcilers.NetmapBuilderand the K8s ConfigMap path) skip peers whose approval status is notapproved.
Approval UX
- Dashboard: peer list gains status badges with approve/reject actions (RBAC-gated, same permission model as workspace admin).
- CLI:
lattice peer approve <app-id>,lattice peer reject <app-id>. - API:
POST /api/v1/workspaces/{ws}/peers/{app-id}/approvalwith{ "status": "approved" | "revoked" }. - Every transition writes an audit record (pattern:
policy_version.go) with actor, timestamp, and reason (reason optional).
Client-side key generation
- Agent (
internal/agent): beforectrClient.Register, generatewgtypes.GeneratePrivateKey()locally, includedto.PeerDto.PublicKeyin the register request, and ignore anyPrivateKeypresent in the response (defense in depth during the compat window). The generated private key never leaves the process; it persists in the local store so re-registration presents the same key. - Control plane: on requests carrying
PublicKey, store it and never generate.infra.Peer.PrivateKeyfield removal from the wire struct is the final step of the rollout; the compat shim reads the legacy field only when the request lacked a public key. - Key rotation: user-initiated via
lattice rotate-keys(regenerates locally, re-registers with the same AppID + new public key). With the binding rule in Decision 3, rotation of an approved peer re-enterspendingif the workspace requires approval — documented behavior, matching Tailscale's re-approval semantics for new keys.
Rollout
- Release N — client keygen + server compat shim (both registration paths converge; wire still tolerates legacy agents).
- Release N — approval state machine with per-workspace opt-in (
RequirePeerApproval=falsedefault), dashboard/CLI/API, netmap gating. - Release N+1 — flip K8s/standalone defaults for new workspaces to
RequirePeerApproval=true; dropinfra.Peer.PrivateKeyfrom the wire struct; server-side keygen code deleted.
Alternatives considered
- Approval gate only, keep server-side keygen — rejected: it fixes the cheaper half while leaving the identity root of trust on the server; the two changes share the registration path, so deferring keygen means paying the migration twice.
- Admission via mTLS device certificates — heavier infrastructure (CA, rotation, revocation) than the problem needs at this stage; revisit if device-posture requirements arrive.
- No approval, rely on short-lived tokens only — rejected: does not cover leaked or socially-engineered tokens, and provides no audit trail for compliance-oriented users.
Consequences
- Token possession becomes * candidacy, not admission*: a leaked token surfaces as a visible pending peer instead of a silent network join.
- The control plane database no longer contains any private key material.
- Admin overhead grows for workspaces that enable approval — mitigated by opt-in default and by auto-approve remaining available.
- ADR-0004's relay identity proof becomes stronger once keygen is client-side: the relay challenge-response then proves possession of a secret the control plane itself never had.
Non-goals / future work
- QUIC relay
InsecureSkipVerify→ certificate fingerprint pinned via the control plane (TOFU) — separate small ADR. - Rate limiting of relay forwarding per member (internal DoS).
- Device posture checks (OS version, disk encryption) as approval inputs.