# peardock security: admin access and invites **Audience:** operators and multi-user fleets who need a full mental model of authentication, roles, and invites. **Related:** [THREAT_MODEL.md](./THREAT_MODEL.md) (assets / adversaries / checklist) · [OPERATOR.md](./OPERATOR.md) (install and env) · [PROTOCOL.md](./PROTOCOL.md) (handshake and methods) · Website: [peardock.boats/docs/security](https://peardock.boats/docs/security) Peardock is a **self-hosted P2P Docker control plane**. There is no central login service. Security is built from: 1. **Cryptographic identity** (who is the server / who is the client) 2. **Encrypted transport** (HyperDHT Noise) 3. **Roles** enforced on every RPC method 4. **Elevation proofs** at handshake (seed admin, capability / `pd1.` invite, registration) Anyone who learns the **server public key** can *dial* the server. That alone must not mean full Docker control. Default access is **viewer**. Admin and operator access need separate proofs. --- ## 1. What you are protecting | Asset | Sensitivity | Notes | |-------|-------------|--------| | `SERVER_SEED` | Critical | Server identity + HMAC signing material + registry vault derivation | | `SERVER_PUBLIC_KEY` | Public / low | How clients dial; alone = read-only | | Docker socket | Critical | Full container/host control on that machine | | Capability / `pd1.` invites | Medium | Elevation without sharing the seed | | Client identity key | High | Stable “who is this laptop” for reconnect and revoke | | `hs://` tunnel URLs | High | Separate data-plane access to a published port | Trust boundary: ```text Desktop client --Noise/HyperDHT--> peardock-server --unix socket--> dockerd | +-- /opt/peardock/.env (seed + public key) +-- peardock-peers.json (roles, jtis, revokes) +-- peardock-vault.json (encrypted registry creds) +-- peardock-audit.log (optional) ``` Peardock does **not** open a public Docker API port. Peers hole-punch. That removes one class of attack (internet-facing dockerd) but **does not** replace key discipline. --- ## 2. Server identity: seed vs public key On first successful start the server generates or loads a 32-byte seed and writes (typically `/opt/peardock/.env`, mode 600): ```bash SERVER_SEED=<64 hex> SERVER_PUBLIC_KEY=<64 hex> ``` | Variable | Who may see it | Client use | |----------|----------------|------------| | `SERVER_PUBLIC_KEY` | Safe to share for read-only | Paste alone → **viewer** | | `SERVER_SEED` | **Admins only** | Paste with public key → **admin** (seed stays on the client; never on the wire) | Rotating the seed **changes the public key**. Every client must re-add the peer. From the seed, peardock derives a **MAC key** via HKDF (`peardock-hmac-v1` / `capability`). That key signs: - Admin proofs at handshake - HMAC capability grants used by invites The seed itself is **never sent on the wire**. After install, always check the env file: ```bash sudo grep -E '^(SERVER_PUBLIC_KEY|SERVER_SEED)=' /opt/peardock/.env ``` --- ## 3. Client identity Each desktop client keeps a **stable DHT keypair** (e.g. `~/.config/peardock/identity.json`). The server sees the client as a 64-hex **peer id** (client public key). Used for: - Role registration after first successful invite redeem - Reconnect without re-spending limited invites - Revoke / allowlist decisions - Binding admin proofs to “this client right now” If you wipe client identity, the server treats you as a **new peer** even if you still know the server public key. --- ## 4. Transport Connection lifecycle: 1. Client dials `SERVER_PUBLIC_KEY` on HyperDHT 2. Noise establishes an encrypted, mutually authenticated stream 3. protomux-rpc opens on that stream 4. Client calls `handshake` with optional elevation fields 5. Server assigns a **role** for the session 6. Every later method is checked against that role There is **no password login**. Auth is “can dial this public key” plus “what elevation did handshake prove?” --- ## 5. Roles (the real ACL) Three roles, ordered: ```text viewer < operator < admin ``` Every RPC method has a **minimum role** in `MethodRoles` (`shared/protocol.js`). Unknown methods default to **admin** (fail closed). The UI may hide actions, but **the server is authoritative**. | Role | Typical powers | |------|----------------| | **viewer** | List/inspect containers, images, volumes, logs, stats, Swarm inspect, list peers. No mutate. **Cannot** create/list/delete invites. | | **operator** | Day-to-day mutate: start/stop/kill, pull, stacks, many Swarm ops, terminals, etc. Still **cannot** manage invites/ACL. | | **admin** | Everything operator can + invite/revoke ACL, vault management, destructive prune/recreate, unrestricted CLI if enabled, etc. | Invite management is **admin-only**: - `invitePeer`, `listInvites`, `deleteInvite` - `revokePeer`, `unrevokePeer`, `clearRevokedPeers`, `listRevokedPeers` Operators run Docker work. Only admins mint access for other people. --- ## 6. How a session gets its role ### Baseline (before elevation) When a peer connects, baseline role comes from `resolveRole(peerId)` (`server/core/acl.js`): 1. If `PEARDOCK_INSECURE_OPEN_ADMIN=1` → everyone is admin (**dev only**; loud warning). 2. Else if peer id is in `PEARDOCK_ADMIN_KEYS` → admin. 3. Else `PEARDOCK_DEFAULT_ROLE` (default **`viewer`**). 4. Peer policy (`peardock-peers.json`) can apply registered role; revoke can block the peer. Secure default: **public key alone = viewer**. ### Handshake elevation Handshake tries elevations in order (`server/rpc/session.js`): ```text 1) adminProof → seed ownership → admin (authMode: seed) 2) capability → HMAC grant (often from pd1) (authMode: capability | registered) 3) inviteToken → legacy / capability string path (if still applicable) 4) otherwise → keep baseline (usually viewer) ``` **Fail closed:** if the client **presents a capability** and it is invalid, spent, or expired, and the peer is **not** already registered as elevated, the handshake **fails**. It does **not** silently succeed as viewer. If capability fails **but** this peer id is already registered as operator/admin, reconnect can fall back to the **registered** role so restarts keep working after grants are rotated or spent. Final session role is effectively: ```text session.role = max(baseline, elevated) ``` --- ## 7. Admin access (seed proof) ### Goal Prove “I know `SERVER_SEED`” without sending the seed over the network. ### Flow 1. Operator pastes **public key + `SERVER_SEED`** in Add peer. 2. Seed is kept **local/session** on the client. 3. At handshake the client builds: ```text adminProof = { nonce: random hex, mac: HMAC-SHA256(macKey, "peardock-admin-v1" || nonce || peerId || serverPublicKey) } ``` where `macKey` is derived from the seed (same HKDF family as capabilities). 4. Server recomputes the MAC with **its** seed-derived key and constant-time compares. 5. Success → role **admin**, `authMode: seed`. 6. Failure → `ADMIN_PROOF_FAILED` (handshake does not demote to a silent viewer). Why bind **nonce + peerId + server public key**: - **nonce** — replay resistance within practical bounds - **peerId** — proof is for this client identity - **serverPublicKey** — proof is for this server ### Alternate admin paths | Path | Use | |------|-----| | Seed proof | Primary interactive admin | | `PEARDOCK_ADMIN_KEYS=` | Fixed admin machines without pasting seed each time | | `PEARDOCK_INSECURE_OPEN_ADMIN=1` | Dev only: every peer is admin | | Capability with `role=admin` | Possible via invite; treat carefully | **Best practice:** one or two human admins use seed (or `PEARDOCK_ADMIN_KEYS`). Operators get **invites**, never the seed. --- ## 8. Invite system (capabilities + pd1) Autopass / RocksDB is **not** used for invites. Invites are pure crypto plus a small policy file. ### Two layers 1. **Capability token** — cryptographic grant (what handshake verifies) 2. **`pd1.` package** — share string that embeds public key + capability for convenience Auth is always the capability. The pd1 string is packaging so operators paste one thing. ### Capability token format ```text base64url(JSON payload) + "." + base64url(HMAC-SHA256(macKey, payloadBytes)) ``` Canonical payload: ```json { "v": 1, "role": "operator", "peerId": null, "exp": null, "jti": "", "iat": 1710000000000 } ``` | Field | Meaning | |--------|---------| | `role` | Role granted when redeemed | | `exp` | Unix ms expiry, or `null` = never | | `jti` | Unique grant id (spend / delete / audit) | | `peerId` | Optional bind to one client; usually `null` so the first redeemer can use it | | `iat` | Issued-at | Server verification (`shared/crypto-auth.js` + `server/core/peer-policy.js`): - MAC over canonical payload with server MAC key - Version / role validity - Expiry if present - Optional peer binding - Spend / revocation via policy (`spentJtis` + capability metadata) Forging a valid capability without the seed requires breaking HMAC-SHA256 under the derived key. ### Defaults when minting invites Unless you set TTL / max uses: - **Never expires** (`exp = null`) - **Unlimited uses** (`maxUses = 0`) - Intended as a durable operator grant package You can tighten with TTL hours, max uses (e.g. `1` for one-shot), or env defaults such as `PEARDOCK_INVITE_MAX_USES`. ### Mint (admin: Access → Create invite) 1. Server requires **admin** for `invitePeer`. 2. `mintCapability({ role, ttlHours, maxUses })` signs a token. 3. Metadata stored in `peardock-peers.json` under `capabilities[jti]`. 4. Server wraps a **pd1.** string: ```text pd1. ``` 5. Admin copies the **full** string (truncation breaks decode). ### Redeem (operator pastes pd1 in Add peer) Client: 1. Collapses whitespace 2. Decodes `pd1.` → `{ publicKeyHex, capability, role, jti, ... }` 3. Dials `publicKeyHex` 4. Handshakes with `capability` (not the seed) 5. Caches capability for reconnect; uses stable client identity Server: 1. `redeemCapability(token, peerId)` 2. Verifies HMAC 3. Checks spent / expiry / use limits 4. **Registers** peer id with granted role in `peers{}` 5. Session role becomes that grant (e.g. operator) ### First redeem vs reconnect **First time** a new client identity redeems a **limited** grant: - `uses` may increment - if `maxUses` reached, jti is spent and removed from active capabilities **Unlimited / persistent grants** (default): - jti stays active - multiple clients *can* use the same invite string unless you restrict maxUses or delete/rotate **Reconnect after successful first elevate:** - Peer is in `peers[peerId].role = operator` (or admin) - Later handshakes can succeed as `authMode: registered` even if that jti was later deleted or spent Implications: - **Revoking a person** means **revoke their peer id**, not only delete the invite string. - **Delete invite** stops *new* redemptions of that jti; it does not always kick already-registered operators. - For one-time share: set max uses = 1; after they connect, optionally delete invite and rely on registration + future revoke. ### Error codes | Code | Meaning | |------|---------| | `CAPABILITY_INVALID` | Bad MAC / malformed / wrong server seed | | `CAPABILITY_EXPIRED` | Past `exp` | | `CAPABILITY_SPENT` | jti deleted/exhausted and peer not already elevated | | `CAPABILITY_PEER_MISMATCH` | Grant bound to another peer id | | `ADMIN_PROOF_FAILED` | Seed MAC proof failed | | `INVITE_INVALID` | Invite management / not found | If a saved peer still has a **stale spent capability** cached, reconnect can fail with `CAPABILITY_SPENT`. Fix: paste a **new full pd1 string**, clear the cached capability, or rely on registration if already elevated. ### Why not Autopass Older builds used Autopass (HyperDB / Corestore / RocksDB) to distribute packages. That path caused freezes, stale grants, and RocksDB/glibc issues on older hosts. **pd1 embeds everything needed at paste time.** Auth is pure HMAC. Server policy tracks jtis and peer roles. No RocksDB on the invite path. --- ## 9. Peer policy file Default path: `./peardock-peers.json` (override with `PEARDOCK_PEER_POLICY`). Typical fields: ```json { "version": 2, "revoked": ["deadbeef..."], "peers": { "clientpublickeyhex...": { "role": "operator", "alias": "alice-laptop", "note": "capability:3b3fdaa8" } }, "capabilities": { "jti...": { "role": "operator", "exp": null, "maxUses": 0, "uses": 0 } }, "spentJtis": ["oldjti..."], "invites": {} } ``` | Piece | Role | |--------|------| | `peers` | Registered elevated clients for reconnect | | `capabilities` | Active grants admins minted | | `spentJtis` | Dead jtis (deleted / exhausted) | | `revoked` | Hard ban list of peer ids | | `invites` | Legacy tokens only if `PEARDOCK_LEGACY_INVITES=1` | ### Revoke Admin `revokePeer(peerId)`: - Peer id added to `revoked` - Live session destroyed if connected - Further connects from that identity should be refused Unrevoke / clear revoked are admin recovery tools. ### Allowlist mode `PEARDOCK_PEER_ALLOWLIST=1` tightens who may connect (registered / allowlisted peers, seed-admins, or valid capability holders, per policy). Use after operators are onboarded. Without allowlist, dial is open to public-key holders as **viewer**. --- ## 10. End-to-end workflows ### Single admin 1. Install server → keys in `/opt/peardock/.env` 2. Client Add peer: public key + `SERVER_SEED` → **admin** 3. Operate Docker yourself ### Multi-operator (recommended) 1. Admin connects with seed proof 2. Access → Create invite (usually **operator**, persistent by default) 3. Share **only** the full `pd1.…` string 4. Operator pastes invite → elevated role; peer registered 5. Never share `SERVER_SEED` 6. Lost laptop → Access → **revoke** that peer id 7. Leaked invite before use → delete invite, mint a new one ### Viewer guest 1. Share only `SERVER_PUBLIC_KEY` 2. Observe-only; no mutate, no invites ### Connect summary | Paste | Access | |-------|--------| | Public key only | `viewer` | | Public key + `SERVER_SEED` | `admin` | | Full `pd1.…` invite | Role in package (`viewer` / `operator` / `admin`) | --- ## 11. What each party knows ```text Public / intentionally shared SERVER_PUBLIC_KEY Admin secret SERVER_SEED → mint any capability, admin proof, vault key material Operator credential A pd1 invite and/or registered peer identity after redeem Viewer credential SERVER_PUBLIC_KEY only Server private state .env seed, peers policy, vault, audit ``` Knowing the public key is like knowing a hostname fingerprint: you can reach the service. It is **not** root. --- ## 12. Mental model ```text ┌─────────────────────────────┐ │ SERVER_SEED (admin secret) │ │ derives: DHT keys + MAC key│ └─────────────┬───────────────┘ │ signs │ proves ownership ┌────────────────────────────┼────────────────────────────┐ │ │ │ ▼ ▼ ▼ capability token SERVER_PUBLIC_KEY adminProof HMAC (role, jti, exp) (dial target) (handshake) │ │ │ │ packaged as pd1. │ dial only │ ▼ ▼ ▼ operator invite viewer session admin session │ └─ first success → register peerId → reconnect as registered ``` Three legitimate ways in: 1. **Viewer** — public key 2. **Admin** — public key + seed proof (or admin keys / insecure flag) 3. **Operator (or other grant)** — pd1 invite → capability redeem → registration --- ## 13. Related controls (outside invite/admin) | Control | Mechanism | |---------|-----------| | Transport E2E | HyperDHT Noise | | Rate limit | Per-peer limiter on RPC | | Audit | Append-only log for privileged methods (`PEARDOCK_AUDIT=1`) | | Feature gates | `ENABLE_SWARM`, `ENABLE_HOLESAIL`, `ENABLE_PLUGINS` | | Host FS browse | Default-deny unless roots / open mode | | Tunnel targets | Loopback / allowlisted hosts only | | Registry vault | AES-256-GCM keyed from server material | Holesail `hs://` is a **separate** data-plane capability for a port. It is not the Docker control role. Treat tunnel URLs as secrets. --- ## 14. Threats (summary) | Threat | Outcome with current model | |--------|----------------------------| | Only public key | Viewer (read-only) | | Stolen pd1 invite | Elevate to grant role until delete/spend/expire; after redeem, revoke peer | | Stolen `SERVER_SEED` | Full admin + mint grants + vault risk — catastrophic | | Compromised admin laptop | Until revoke / seed rotate | | Compromised operator laptop | Operator powers until revoke | | Attacker with server filesystem | Read `.env` if perms wrong — full compromise | | Bad invite silent viewer | Mitigated: fail closed on bad capability | Residual risks and the hardening checklist live in [THREAT_MODEL.md](./THREAT_MODEL.md). --- ## 15. Operator rules of thumb 1. Treat `/opt/peardock/.env` like root SSH keys (`600`, backed up offline). 2. Share **public key** only if read-only exposure is acceptable. 3. Share **seed** only with true admins (ideally never over chat). 4. Onboard operators with **full `pd1.` strings**, not the seed. 5. To kick someone: **revoke peer id**, don’t only delete the invite. 6. After delete/replace invite, old cached capabilities on clients can break until they paste the new string. 7. Never set `PEARDOCK_INSECURE_OPEN_ADMIN=1` on multi-user production hosts. 8. Prefer persistent invites for trusted operators; use maxUses/TTL when sharing more widely. --- ## 16. Code map | Area | Location | |------|----------| | HMAC capability + admin proof + pd1 encode/decode | `shared/crypto-auth.js` | | Roles + `MethodRoles` | `shared/protocol.js` | | Baseline role resolution | `server/core/acl.js` | | Mint / redeem / register / revoke | `server/core/peer-policy.js` | | pd1 invite create/list/delete | `server/core/connection-invites.js` | | Handshake elevation | `server/rpc/session.js` | | Admin RPC surface | `server/handlers/peers.js` | | Client identity | `client/identity.js` | | Client handshake auth | `client/connection.js` | | Keys on disk | `server/core/keys.js`, `/opt/peardock/.env` | Implementation details can change; this document describes the security **model**. When behavior and docs diverge, trust the code paths above and update this file.