HydraIssues

Phase 2: iPad should generate its own WireGuard keypair so the private key never leaves the device
open improvement Project: hydraheadipad Reporter: cederik 19 Aug 2026 18:55

Description

Self-service WireGuard enrollment ships in hydraheadipad v0.2.156 (#449), but the tunnel's private key is still generated on HydraGuard, sent over the wire, and stored at rest. The device should generate its own keypair and send only the public half.

Where the key lives today

  1. handleFleetEnrollHead calls provisionHeadIPadWireGuard, HydraGuard generates a keypair and returns the full config including PrivateKey.
  2. hydracluster writes that config to the node record, so it sits in nodes.yaml on disk indefinitely.
  3. The iPad fetches it over HTTPS from GET /api/v1/heads/{id}/wireguard-config and writes it into its App Group.

So the private key exists in at least three places, only one of which is the device that needs it. HydraGuard itself is the strict one here: it shows a private key only at creation and never stores it, which is why a lost config means re-keying rather than re-reading. hydracluster is the weak link.

What phase 2 changes

The iPad generates a Curve25519 keypair locally, keeps the private half in its App Group (or the keychain), and sends only the public key at enrollment. HydraGuard registers that public key against a slot and returns the peer half: server public key, endpoint, allowed IPs, assigned address. hydracluster stores none of it.

That removes the class of problem rather than guarding it: there is no private key to leak from nodes.yaml, none in transit, and none in a QR on an admin's screen.

Why it was not done in phase 1

It needs a HydraGuard provisioning API change. POST /api/v1/headipad/provision currently takes {"name": ...} and generates the keypair server-side (hydraguard/pkg/api/handlers.go, around the 409-on-duplicate path). It would need to accept an optional public key and skip generation when one is supplied.

Phase 1 was deliberately the smaller move: wire up what already existed on the device and fix the auth so a head can fetch its own config. The app work is compatible with phase 2 — same "Set up WireGuard" button, with the config assembled locally instead of fetched whole.

Worth doing at the same time

  • Re-provisioning. provisionHeadIPadWireGuard is still called only from the enrollment handler, so a head whose provisioning failed cannot recover without being deleted and re-enrolled. With device-generated keys this becomes cheap: the device can just re-register a fresh public key.
  • Peer removal. hydraguard #457 — there is no supported way to remove a head iPad peer, so rotating or retiring a key is a hand edit to mesh.yaml today.
  • Name drift. Renaming a head does not rename its HydraGuard peer, because the peer is keyed by the name held at provisioning. ipad-head-cederik currently has a peer named ipad-head-2. A key-based identity would make the name cosmetic.

Related

  • #449 self-service WireGuard enrollment, delivered in v0.2.156
  • #457 no supported way to remove a head iPad peer
  • #456 auth scoping on the head routes, which had to be fixed before the device could fetch anything with its own token