Files
peardock/docs/SECURITY_AUTH.md
T
Raven Scott 54d3d00901 Add SECURITY_AUTH.md for admin access and invite model.
Document seed proof, roles, pd1 capabilities, redeem/reconnect, and revoke,
and link it from the README, threat model, operator, and protocol docs.
2026-07-14 22:50:52 -04:00

537 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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=<client peer public 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": "<random 16-byte hex>",
"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.<base64url JSON {
v, publicKeyHex, capability, role, jti, alias?, expiresAt?
}>
```
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**, dont 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.