Security model¶
greasewood is a control plane for a WireGuard mesh. WireGuard itself (the Noise protocol) provides confidentiality, integrity, and forward secrecy for traffic on the wire; greasewood decides who is allowed into the mesh and who each node will form a tunnel with. This document states the threat model, the trust boundaries, what is enforced, and the accepted risks.
Threat model¶
Assets, in order of value: the CA key (mesh admission), node identity keys (who a node is), tunnel keys (traffic), the signed directory + policy (topology), and control-plane availability.
Adversary positions, weakest to strongest — what each can and cannot achieve:
| Adversary position | Outcome |
|---|---|
| Internet attacker — knows a node's IP and port, holds no secret | Nothing observable. The only underlay surface is WireGuard UDP, which silently drops anything that isn't a valid handshake from a pinned key — no reply, no banner, no HTTP (Network exposure). |
| On-path attacker — can read, modify, or replay underlay packets | Sees only WireGuard ciphertext (Noise: confidentiality, integrity, forward secrecy). Control-plane requests are additionally replay-bounded (nonce + skew) even though they already ride inside the tunnel. Can drop packets — underlay availability is out of scope. |
| Join-token holder — stole an unexpired invite | Enrolls one node, during one time-boxed window, with exactly the roles the token (or its menu) grants — no self-asserted roles, no pinned name. gw watch shows door activity; gw revoke evicts the result. |
| Malicious member — legitimately enrolled, tries to escalate | Bounded by its CA-signed credential: cannot self-assert roles/caps/hostname, forge directory records, obtain TLS certs for names it doesn't own, or reach anything the grant table doesn't allow (default-closed) — mechanisms in What is enforced. Residual: first-come claim of unused hostnames (pin names that matter), plus whatever traffic the grants do permit. |
Compromised node — attacker holds its id_priv/wg_priv |
Is that node until revoked: its reachability, its names, its certs — but no other node's, and no wider grants. gw revoke cuts it off immediately at the anchor and fleet-wide within one credential TTL (an already-issued TLS service cert can outlive that — see Accepted risks). |
Compromised anchor — attacker holds ca.key |
Full admission control: mint identities, assign roles, sign policy. Cannot passively decrypt tunnels or take over an existing node's overlay address (addresses are self-certifying, keys never leave nodes) — but can mint a new node and re-bind mesh names to it, hijacking future by-name connections. No defense inside the model: this is the root of trust. Recovery is a re-root (RUNBOOK). |
| Local non-root user on a member host | Network reachability over the overlay, not the node's identity — see Multi-user hosts. |
Keys and trust boundaries¶
| Secret | Held by | What it authorizes (if compromised) | Enforced protection |
|---|---|---|---|
ca.key (Ed25519) |
the anchor | Total. Issue credentials for any identity → join the mesh as anyone. Revocation does not help (the attacker is the CA). | 0600 in a root-owned dir; optional encryption at rest (ca_key_passphrase_env); never carried by any greasewood protocol — it exists on the anchor only (gw anchor-transfer moves it over SSH, out of band). |
id_priv (Ed25519) |
each node | Impersonate that one node: renew its credential, publish its record, request its TLS certs. | 0600 on disk; no hardware backing on server VMs (accepted risk — hardware-backed identity is a v2 item). |
wg_priv (X25519) |
each node | Impersonate that node on the wire. Contained by expiry, not a CRL: a wg_pub is accepted only while a live credential binds it, so gw revoke (or rotating wg_priv) drops it fleet-wide within one credential TTL. Not auto-contained while the node keeps renewing — act on a known leak. |
0600 on disk; acceptance is credential-bound (expiry + revocation are the structural containment). |
| join token / door seed | transient | Enroll one node during a single open window. The anchor still enforces revoke + unique hostname, and the door admits one peer. | High-entropy 32-byte seed; time-boxed (door_window, default 15m); single-slot. |
Network exposure¶
- Underlay (the real NIC): only WireGuard UDP is reachable — the mesh port (51900) always, and the door port (51901) only while an enrollment window is open. There is no HTTP on the underlay.
- Control plane (TCP 51902) and enrollment RPC (TCP 51903): bound to the
node's overlay address and loopback only — never
::. They are therefore unreachable from the underlay by construction, independent of any firewall. The firewall rules are defense in depth, not the access control. - The enrollment exchange runs inside the transient door WireGuard tunnel, so even during a window nothing greasewood-specific is exposed in cleartext.
What is enforced¶
Every peer a node installs has passed the 7-step reconcile check (reconcile.py,
wire.NodeRecord.verify):
- CA signature — the credential is signed by a currently-trusted CA.
- Expiry — the credential has not expired.
- Self-signature — the record is signed by the identity it claims.
- Address derivation —
addr == truncate64(blake2s(id_pub)); addresses are self-certifying, so a node cannot claim an address it didn't derive. - Revocation — the identity is not on the anchor's revoke list.
- Authorization policy — capability check (e.g.
mesh ↔ mesh). - Data plane — install/remove the WireGuard peer to match.
Additional control-plane protections:
- Request authentication —
/renewand/certrequire a signature by the requester'sid_priv; the leaf TLS private key never leaves the node. - TLS SAN authorization —
/certissues a leaf only for names the requester owns: its CA-registered<hostname>.<mesh_domain>, subdomains of it, and its own overlay address (derive_addr(id_pub)). Atls-capable node therefore cannot obtain a cert for another node's name, so it cannot impersonate a service it doesn't run to averify-fullclient. (TLS here is for service identity, not extra encryption — WireGuard already encrypts the tunnel.) - Replay protection —
/renewand/certare bounded by a ±300s timestamp skew window and a single-use nonce cache, so a captured request cannot be replayed. - Structural verification on ingest — the directory merges by highest
sequence number, so before a record is cached it must pass the checks that
depend only on the record itself, not on the CA or the clock: self-signature,
address-derives-from-
id_pub, andid_pub↔credential match. Forging these needs the target'sid_priv, so a malicious or tampered/directoryresponse can't slip in a high-seqfake to evict ("shadow") a node's real record — a cache-poisoning DoS. The CA-signature and expiry checks run later, at reconcile, where they're re-evaluated against the current trust set and time (and the cache may legitimately hold expired records). - Hostname is CA-attested — the mesh hostname lives in the CA-signed
credential, not as a self-asserted
NodeRecordfield. A node therefore cannot publish a name the CA didn't issue it, so plain name resolution (the managed/etc/hostsblock) can't be hijacked by a member claiming another's name. One deliberate consequence: unused names are first-come-first-served — a joiner names itself unless the anchor pins the name, so any enrolled node could claim a sensitive name nobody holds yet (and, with thetlscap, get a cert for it). Pin names that matter (gw invite --hostname db): a pinned name is checked free at invite, assigned by the anchor, and the node can't rename. - Name resolution follows the trust gate — the
/etc/hostsblock is built from the records that pass the reconcile loop's full verification (CA signature, expiry, revocation), never from the raw directory cache. Revoking a node removes its name on the same reconcile cycle that removes its tunnel; an expired credential drops out of resolution the same way — except on the anchor, which deliberately admits expired-but-not-revoked records so a lapsed node can renew over its tunnel. On the anchor host such a node's name keeps resolving (and its address keeps passing the port filter) until it is revoked or aged past the drop-grace window (default 7d); on every other node it drops within one credential TTL as stated. Revoke to drop a name immediately. - Caps/roles are anchor-decided, not self-asserted — a node's capabilities
(e.g.
tls) and roles (role:<name>tags, the grant-table vocabulary) are chosen by the anchor atgw inviteand bound into the CA-signed credential; the enroll server issues from the door window and ignores anything the joiner sends. A member cannot grant itself a capability or a role it wasn't issued (e.g.role:prod, or the reach-allrole:*). Renewal re-issues from the tags the anchor already recorded, so they can't drift upward at renew either — andgw set-roles/gw set-capslet the anchor change them later (effective next renewal). This is what makes the grant-table policy a real boundary, not just honest-node configuration. - Trust is a static set (
[ca] trusted_pubs) — nodes accept credentials only from the CA keys they are configured to trust. Moving the CA is a deliberate re-root (a config change to that set), not an automatic runtime handoff, so a decommissioned or leaked anchor key cannot inject itself into the fleet's trust; it stays trusted only as long as it's intrusted_pubs. - Port enforcement (grants → nftables) — a second layer below tunnel
existence: on by default, the daemon realizes each grant's port scopes in
greasewood's own
table inet greasewood_<mesh>, scoped to the mesh interface — it never touches your own firewall or a physical NIC, so it can only ever tighten mesh traffic. A fresh anchor ships default-closed (only granted flows pass). The table persists across daemon stop/crash (fail closed), removed only bygw purge. Caveats worth knowing:enforce_ports = falseopts a host out (grants still gate which tunnels exist; per-port scopes go advisory), and if nftables is unusable whileenforce_ports = truethe daemon degrades to unenforced with a loud error rather than crash-looping — an operational gap to monitor, not a remote exposure.
Accepted risks / non-goals¶
- A malicious current anchor can deny service (withhold directory entries,
refuse renewals). Trust is rooted in the anchor by design; the "anchor may be offline
for one credential TTL" window limits the damage, but a live, malicious anchor is
outside the threat model. It still cannot intercept traffic — it never
holds any node's
wg_privorid_priv. - Revocation is expiry-based on nodes (no CRL push). At the anchor a revocation
is immediate (refuses renew/publish and evicts locally, live — no restart). On
other nodes a revoked peer falls out within at most one credential TTL as its
credential expires. Shorten
credential_ttlif you need a tighter bound. - Issued TLS service certs are a separate, longer window. A leaf lives for
tls_cert_ttl(default 7d) and is not CRL-revocable, so a revoked node keeps a CA-valid,verify-full-trusted service cert until that leaf expires — potentially several credential TTLs. Re-issuance is blocked (gw revokedeletes the registry entry, so/certrefuses), and the revoked node loses mesh reachability within one credential TTL, so exploiting the tail needs an off-mesh path to the service. Settls_cert_ttl ≤ credential_ttlif a service cert must not outlive mesh membership. - Clock integrity is a security dependency. Every allow/deny is a timestamp comparison (expiry, skew). Run NTP/chrony and treat it as part of your security posture.
Multi-user hosts¶
The unit of identity is the machine, not the user. gw-myfleet is a kernel
interface in the host's single network namespace, so — like any VPN or route on
a shared host — every local user can send and receive over the overlay once
it's up. There is no per-user access control on the tunnel; a local user can
reach mesh peers, be reached, and read the (non-secret) directory over ::1.
What a non-root local user still cannot do:
- read the private keys —
id_priv.pem,ca.key,wg.keyare each their own0600, root-owned file → no impersonation, no CA abuse, no TLS cert. (The data dir itself is0755by design, so root-free reads likegw watch --snapshotcan see the public files; confidentiality is carried per-file, not by the directory mode.) - administer or tear down the interface (needs
CAP_NET_ADMIN); - forge control-plane requests (
/renew,/cert,/publishrequire signatures).
So a co-tenant gets network reachability to the mesh, not the node's identity.
If that reachability itself is unacceptable, enforce it at the OS layer — greasewood
never edits your firewall or underlay rules, and its only nftables state is the
mesh-scoped table above (What is enforced); a per-user gate
is yours to add (e.g. an nftables owner-match on the gw-<mesh> interface).
Reporting¶
This is a personal project. File issues on the GitHub repository. For the operational response to key loss or compromise, see operations.md.