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

19 KiB
Raw Blame History

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:

  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:

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:

  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:

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):

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

  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:
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).

  1. Server recomputes the MAC with its seed-derived key and constant-time compares.
  2. Success → role admin, authMode: seed.
  3. 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

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)

  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:
pd1.<base64url JSON {
  v, publicKeyHex, capability, role, jti, alias?, expiresAt?
}>
  1. 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:

{
  "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_SEEDadmin
  3. Operate Docker yourself
  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

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:

  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

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

  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.