network-security
Networking security — deny by default, three layers
How a Zero node talks to other nodes over [iroh](https://iroh.computer), and why the
default answer to *every* unproven request — even opening a connection — is no.
Companion to [remote-access.md](remote-access.md) (the browser→node tunnel) and
[../trust/AUTH.md](../trust/AUTH.md) (identity & verification). Keep this in sync when
you touch mesh/, network/, registry/, ~/zero.9/mesh/src/, or _core/_gate*.
The one idea
A node is a single, private computer on a public network. Nothing about it is
public unless you are *granted* it: not its data, not its actions, not its address,
and not even the right to open a socket to it. Access is something the node *gives*,
never something a caller *takes*. iroh is the wire that carries this today; it is a
swappable pipe — the trust lives above it, so replacing iroh changes nothing here.
Everything below is deny by default. You become reachable only because a member
invited you and verified you; the same grant that lets you *act* is the grant
that lets you *connect*, which is the grant that even lets you *find the door*. One
fact — membership — drives all three.
The three layers (each independently fail-closed)
graph TD
S["a stranger"] -->|"1. resolve the address?"| D{discovery}
D -->|not granted| X1["{} — no address.<br/>cannot even dial"]
D -->|granted| C{"2. open a connection?"}
C -->|"id not in admit set"| X2["QUIC connection refused<br/>'not admitted'"]
C -->|admitted| A{"3. run the action?"}
A -->|"not a verified member / owner"| X3["gate denies →<br/>sign-in / waiting-to-verify"]
A -->|verified member or owner| OK["the action runs<br/>(folder→folder, thing→thing)"]
style X1 fill:#fdecea,stroke:#c33
style X2 fill:#fdecea,stroke:#c33
style X3 fill:#fdecea,stroke:#c33
style OK fill:#eef7ee,stroke:#4a4
Layer 1 — Discovery: the address is not a public fact
A node dials another by its public endpoint id (an iroh key — "the key is the
address"). That id, and the relay to reach it through, are handed out only to a
recognised principal: registry.resolve is access = member, access_public
empty (registry/defaults.data). A stranger asking for a name gets {} — no id, no
relay — so they never learn *where to knock*. Editing the directory stays owner-only.
You learn a node's address the legitimate way: a member invites you and shares it
(mesh.announce prints the record; network.invite tells the inviter to). To everyone
else the node is undiscoverable. *(The public-directory "hub" posture re-opens
resolve per-object when the anonymous browser-login path is enabled — see "the anon
posture" below. That is opt-in, never the default.)*
Layer 2 — Connection: the door admits only the allowed, live
The mesh organ (~/zero.9/mesh/src/lib.rs) holds two iroh doors open. Both now
gate admission against a live allowlist before any byte is bridged
(serve_door → load_admit):
• Deny by default. The allowlist is the directory ~/.zero/admit.d/ — one
fragment file per network this node hosts — unioned with the static --fleet
(the owner's own devices). Empty ⇒ admit no one. A connection from an endpoint
id not in the set is closed at QUIC with not admitted; nothing is forwarded.
• N networks, automatic. Each network writes its own fragment from its member
list; the organ unions the whole directory on every connection. A node in N
networks admits the union with no extra wiring — add a network, add a fragment.
• Live invite / revoke, no restart. network.invite / network.revoke rebuild
that network's fragment (network/_admit_fragment → _admit_emit → resolve each
member to their public endpoint id). The organ re-reads per connection, so a grant
or a revocation takes effect on the next dial — admission is a live fact, not a
spawn-time snapshot. (This was the last pending item of connect-and-verify /
network-wire-identity: live admission.)
• Outbound matches inbound. A node dials *from* its own public endpoint
(serve_control / zero-mesh call --name public), so the far door recognises it by
its published id — the same id the admit set is keyed on. A throwaway key is
refused.
• The door still assigns NO identity. It is admission/DoS only. The real principal
is attributed above, by the engine, from the signed envelope (Layer 3). The door
decides *whether to let the connection live*, never *who you are*.
The IP stays private throughout: the public door is relay-only
(clear_ip_transports), so neither side ever learns the other's IP.
Layer 3 — Access: the proven principal at the existing gate
Once a connection is admitted, every request rides the signed remote-call envelope
(network/_remote_seal → _core/_apply_remote): freshness, root-pin, key-proof,
revocation, canonical-hash p256_verify, atomic nonce burn. Only a fully-verified
envelope promotes __principal from anonymous to the real user. Then the same local
gate runs (_core/_gate + _recognized + the kernel grant_<action> checks): the
owner always passes; a verified member passes; a signed-in-but-unverified visitor
is held at "waiting to be verified"; anonymous gets the sign-in door. Remote invocation
is just local invocation with a *verified principal* delivered to that gate — so
folder→folder and thing→thing calls reuse the entire authorization path unchanged.
ABAC — one attribute, every layer
The model is attribute-based, and the load-bearing attribute is network
membership, expressed as the grant list itself. A network's members are
person|role pairs; being granted the network IS being on it IS being reachable.
• The same fact drives all three layers. A member's grant is what writes their
endpoint id into the admit fragment (Layer 2) *and* what the engine gate honours
(Layer 3) *and* what lets them resolve peers (Layer 1). There is no separate ACL to
drift out of sync with the grant — the edge in the graph and the access grant are
one record (network/defaults.data).
• Attributes compose. A request is authorized from owner? + verified? +
member-of-network-X? + the per-action grant_<action> = <role>=<perms>. A network
can grant differentiated roles (alice|admin, bob|member) and an action gates
on the role — POSIX-style user/role control over C++-style inherited folder actions.
• Verification is the membership attribute's truth. A *local* ID is freely made
and unverified; a verified ID is vouched for by a member (person→person,
many-to-many, depth-on-demand — trust/). Only verification flips "known" to
"admitted across the network."
Modes
• Single node / your own machine. You are the owner. Local use is loopback — it
never touches a door and is never gated. No verification, no admit set needed: it
just works. A fresh node with no networks denies all inbound by default until you
invite someone.
• Your own fleet (live, from the directory). Your other devices live in your node
directory (~/.zero/registry: name → endpoint_id node_pub=…), populated by
mesh.announce + registry.add, by pairing, or by mesh.sync gossip. mesh.fleetsync
rebuilds a live admit fragment (~/.zero/admit.d/_fleet) from that directory — every
device you know is one of *yours*, so each is admitted to connect on the private door.
The organ unions the whole admit.d dir on every connection, so a device you learn
becomes reachable with no organ restart — exactly how a network's _admit_fragment
works, now for your own devices. It runs in lockstep with the directory: mesh.serve /
announce / sync and registry.add / remove each rebuild it, so discovery (Layer 1)
and admission (Layer 2) never drift — adding a device makes it reachable, removing it
revokes the connect right, both live. The static mesh.fleet field is still passed as
--fleet (a seed), so the two admission sources compose. This is what makes the *same
identity on many devices* — your twin on your Mac and your phone — mutually reachable
whenever both are online, scaling to N devices as the directory gossips and converges.
• Inviting people. network.invite <user> (members only; users only — resolved in
the identity directory) grants membership → writes their endpoint id into the admit
fragment → they can now connect and reach what the network grants. network.revoke
removes all three in one act, live.
• N networks / other territories. A node hosts or joins many networks; the admit
set is their union, automatically. The same primitives scale from two friends to a
node-backed private hosting/compute/AI fabric — every product sits on the one access
graph.
The anon posture (deferred, opt-in)
Anonymous reach — the browser→node passkey-login surface
([remote-access.md](remote-access.md)) — is off by default. A node opts in with
mesh.posture = open, which passes --open 1: the public door then admits anyone
(to reach the sign-in/verify surface only — the private door is never open, and the
engine gate still denies every action to the unproven). This is the future "anon is
itself an ABAC attribute" lane; enabling it is a deliberate node-operator choice, not
the shipped default.
What a reviewer can check
• registry.resolve returns {} to a non-member; the address never leaks. (Layer 1)
• A dial from an un-admitted endpoint id is refused at QUIC (not admitted in the
organ log); adding the id live admits it with no restart; removing it denies again.
See ~/zero.9/mesh/test-admission.sh. (Layer 2)
• mesh.fleetsync rebuilds ~/.zero/admit.d/_fleet from ~/.zero/registry (dial key only,
this node's own key skipped); registry.add / mesh.sync admit a learned device live and
registry.remove revokes it — both with no organ restart. (Layer 1 → Layer 2 bridge.)
• The _apply_remote security matrix (replay, root-pin, revocation, forged signature)
• the gate decision matrix still hold. (Layer 3 — connect-and-verify.)
• No ?as= / cookie self-promotion: the door strips door-owned params + Cookie /
Authorization and stamps __remote=1 (bridge in lib.rs).