Add SECURITY_AUTH.md for admin access and invite model.
Release rolling / release (push) Successful in 9m8s

Document seed proof, roles, pd1 capabilities, redeem/reconnect, and revoke,
and link it from the README, threat model, operator, and protocol docs.
This commit is contained in:
Raven Scott
2026-07-14 22:50:52 -04:00
parent abc46c60de
commit 54d3d00901
6 changed files with 544 additions and 2 deletions
+1 -1
View File
@@ -118,7 +118,7 @@ PEARDOCK_AUDIT=1
| Public key + `SERVER_SEED` | `admin` (HMAC seed proof; seed stays on the client) |
| peardock invite (`pd1.…`) | Role in package (`viewer` / `operator` / `admin`) |
See [THREAT_MODEL.md](./THREAT_MODEL.md) for hardening rationale.
See [SECURITY_AUTH.md](./SECURITY_AUTH.md) for the full auth and invite model, and [THREAT_MODEL.md](./THREAT_MODEL.md) for hardening rationale.
## Registry vault
+2
View File
@@ -46,6 +46,8 @@ Default peer role when unconfigured: **viewer** (`resolveRole` / `PEARDOCK_DEFAU
Admin proof and capabilities are verified with `HMAC-SHA256` over a key derived from `SERVER_SEED` via HKDF (`shared/crypto-auth.js`). The seed is never sent on the wire.
Full model (roles, pd1 packages, redeem/reconnect, revoke): [SECURITY_AUTH.md](./SECURITY_AUTH.md).
---
## Session flow
+1
View File
@@ -33,6 +33,7 @@ PearDock is **decentralized Docker management**: a HyperDHT control plane, struc
| Document | What you will learn |
|----------|---------------------|
| **[OPERATOR.md](./OPERATOR.md)** | Install, systemd, ACL, env flags, logging, backup |
| **[SECURITY_AUTH.md](./SECURITY_AUTH.md)** | Admin seed proof, roles, pd1 invites, redeem/revoke (full model) |
| **[HOLESAIL.md](./HOLESAIL.md)** | Data-plane tunnels in depth |
| **[THREAT_MODEL.md](./THREAT_MODEL.md)** | Assets, adversaries, controls, hardening checklist |
| **[RELEASE.md](./RELEASE.md)** | Host matrix, Bare + Electron builds, rolling CI |
+536
View File
@@ -0,0 +1,536 @@
# 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.
+2
View File
@@ -3,6 +3,8 @@
**Audience:** operators deploying peardock in multi-operator or semi-trusted environments.
**Scope:** HyperDHT P2P control plane + local Docker Engine socket (+ optional Holesail data plane).
For a full walkthrough of admin seed proof, roles, `pd1.` invites, redeem/reconnect, and revoke, see **[SECURITY_AUTH.md](./SECURITY_AUTH.md)**.
---
## 1. Assets