forked from snxraven/peardock
Document seed proof, roles, pd1 capabilities, redeem/reconnect, and revoke, and link it from the README, threat model, operator, and protocol docs.
537 lines
19 KiB
Markdown
537 lines
19 KiB
Markdown
# 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**, 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.
|