Document seed proof, roles, pd1 capabilities, redeem/reconnect, and revoke, and link it from the README, threat model, operator, and protocol docs.
19 KiB
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 (assets / adversaries / checklist) · OPERATOR.md (install and env) · PROTOCOL.md (handshake and methods) · Website: peardock.boats/docs/security
Peardock is a self-hosted P2P Docker control plane. There is no central login service. Security is built from:
- Cryptographic identity (who is the server / who is the client)
- Encrypted transport (HyperDHT Noise)
- Roles enforced on every RPC method
- 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:
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):
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:
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:
- Client dials
SERVER_PUBLIC_KEYon HyperDHT - Noise establishes an encrypted, mutually authenticated stream
- protomux-rpc opens on that stream
- Client calls
handshakewith optional elevation fields - Server assigns a role for the session
- 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:
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,deleteInviterevokePeer,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):
- If
PEARDOCK_INSECURE_OPEN_ADMIN=1→ everyone is admin (dev only; loud warning). - Else if peer id is in
PEARDOCK_ADMIN_KEYS→ admin. - Else
PEARDOCK_DEFAULT_ROLE(defaultviewer). - 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):
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:
session.role = max(baseline, elevated)
7. Admin access (seed proof)
Goal
Prove “I know SERVER_SEED” without sending the seed over the network.
Flow
- Operator pastes public key +
SERVER_SEEDin Add peer. - Seed is kept local/session on the client.
- At handshake the client builds:
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).
- Server recomputes the MAC with its seed-derived key and constant-time compares.
- Success → role admin,
authMode: seed. - 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
- Capability token — cryptographic grant (what handshake verifies)
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
base64url(JSON payload) + "." + base64url(HMAC-SHA256(macKey, payloadBytes))
Canonical payload:
{
"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)
- Server requires admin for
invitePeer. mintCapability({ role, ttlHours, maxUses })signs a token.- Metadata stored in
peardock-peers.jsonundercapabilities[jti]. - Server wraps a pd1. string:
pd1.<base64url JSON {
v, publicKeyHex, capability, role, jti, alias?, expiresAt?
}>
- Admin copies the full string (truncation breaks decode).
Redeem (operator pastes pd1 in Add peer)
Client:
- Collapses whitespace
- Decodes
pd1.→{ publicKeyHex, capability, role, jti, ... } - Dials
publicKeyHex - Handshakes with
capability(not the seed) - Caches capability for reconnect; uses stable client identity
Server:
redeemCapability(token, peerId)- Verifies HMAC
- Checks spent / expiry / use limits
- Registers peer id with granted role in
peers{} - Session role becomes that grant (e.g. operator)
First redeem vs reconnect
First time a new client identity redeems a limited grant:
usesmay increment- if
maxUsesreached, 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: registeredeven 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:
{
"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
- Install server → keys in
/opt/peardock/.env - Client Add peer: public key +
SERVER_SEED→ admin - Operate Docker yourself
Multi-operator (recommended)
- Admin connects with seed proof
- Access → Create invite (usually operator, persistent by default)
- Share only the full
pd1.…string - Operator pastes invite → elevated role; peer registered
- Never share
SERVER_SEED - Lost laptop → Access → revoke that peer id
- Leaked invite before use → delete invite, mint a new one
Viewer guest
- Share only
SERVER_PUBLIC_KEY - 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
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
┌─────────────────────────────┐
│ 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:
- Viewer — public key
- Admin — public key + seed proof (or admin keys / insecure flag)
- 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.
15. Operator rules of thumb
- Treat
/opt/peardock/.envlike root SSH keys (600, backed up offline). - Share public key only if read-only exposure is acceptable.
- Share seed only with true admins (ideally never over chat).
- Onboard operators with full
pd1.strings, not the seed. - To kick someone: revoke peer id, don’t only delete the invite.
- After delete/replace invite, old cached capabilities on clients can break until they paste the new string.
- Never set
PEARDOCK_INSECURE_OPEN_ADMIN=1on multi-user production hosts. - 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.