/** * Peer allowlist / revoke / capability grant policy. * * File: PEARDOCK_PEER_POLICY (default ./peardock-peers.json) * * Modes: * - Open (default): any peer may connect as viewer (or elevated via capability/seed) * - Allowlist (PEARDOCK_PEER_ALLOWLIST=1): only listed peers, seed-admin, or * holders of a valid capability may connect * * Invites: HMAC capability grants (minted via signCapability) tracked by jti. * pd1. invites embed the package; this file tracks spent jtis + peer roles. * * Legacy random invite tokens: only when PEARDOCK_LEGACY_INVITES=1. */ import fs from 'fs' import path from 'path' import crypto from 'crypto' import { Roles } from '../../shared/protocol.js' import { signCapability, verifyCapability } from '../../shared/crypto-auth.js' import { getMacKey } from './auth-keys.js' import logger from '../utils/logger.js' function policyFilePath() { return process.env.PEARDOCK_PEER_POLICY || path.join(process.cwd(), 'peardock-peers.json') } function enforceAllowlist() { return ( process.env.PEARDOCK_PEER_ALLOWLIST === '1' || process.env.PEARDOCK_PEER_ALLOWLIST === 'true' ) } function legacyInvitesEnabled() { return ( process.env.PEARDOCK_LEGACY_INVITES === '1' || process.env.PEARDOCK_LEGACY_INVITES === 'true' ) } /** * @typedef {{ peerId: string, role: string, alias?: string|null, invitedAt?: string, note?: string }} PeerEntry * @typedef {{ token: string, role: string, expiresAt: string, maxUses: number, uses: number, note?: string, createdAt: string }} LegacyInvite * @typedef {{ jti: string, role: string, exp: number, maxUses: number, uses: number, note?: string|null, createdAt: string, peerId?: string|null }} CapabilityMeta */ /** * @returns {{ * version: number, * revoked: string[], * peers: Record, * invites: Record, * capabilities: Record, * spentJtis: string[], * }} */ function loadPolicy() { const POLICY_PATH = policyFilePath() if (!fs.existsSync(POLICY_PATH)) { return { version: 2, revoked: [], peers: {}, invites: {}, capabilities: {}, spentJtis: [], } } try { const raw = JSON.parse(fs.readFileSync(POLICY_PATH, 'utf8')) return { version: raw.version || 2, revoked: Array.isArray(raw.revoked) ? raw.revoked.map((s) => String(s).toLowerCase()) : [], peers: raw.peers && typeof raw.peers === 'object' ? raw.peers : {}, invites: raw.invites && typeof raw.invites === 'object' ? raw.invites : {}, capabilities: raw.capabilities && typeof raw.capabilities === 'object' ? raw.capabilities : {}, spentJtis: Array.isArray(raw.spentJtis) ? raw.spentJtis.map(String) : [], } } catch (err) { logger.warn('peer policy load failed', { error: err.message }) return { version: 2, revoked: [], peers: {}, invites: {}, capabilities: {}, spentJtis: [], } } } /** * @param {ReturnType} policy */ function savePolicy(policy) { const POLICY_PATH = policyFilePath() const dir = path.dirname(POLICY_PATH) if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) // Cap spent jti list growth if (policy.spentJtis.length > 5000) { policy.spentJtis = policy.spentJtis.slice(-2500) } fs.writeFileSync(POLICY_PATH, JSON.stringify(policy, null, 2), { mode: 0o600 }) try { fs.chmodSync(POLICY_PATH, 0o600) } catch { // ignore } } /** * Whether a peer is revoked (hard deny at TCP/DHT accept). * @param {string} peerIdHex */ export function isPeerRevoked(peerIdHex) { const id = (peerIdHex || '').toLowerCase() const policy = loadPolicy() return policy.revoked.includes(id) } /** * Whether a peer may establish an RPC session (after handshake auth). * Seed-admin and capability holders are allowed even under allowlist. * @param {string} peerIdHex * @param {{ authMode?: string }} [opts] */ export function isPeerAllowed(peerIdHex, opts = {}) { const id = (peerIdHex || '').toLowerCase() const policy = loadPolicy() if (policy.revoked.includes(id)) return false // Seed / capability / prior registration always allowed (unless revoked) if ( opts.authMode === 'seed' || opts.authMode === 'capability' || opts.authMode === 'registered' ) { return true } if (!enforceAllowlist()) return true if (policy.peers[id]) return true const adminKeys = (process.env.PEARDOCK_ADMIN_KEYS || '') .split(',') .map((s) => s.trim().toLowerCase()) .filter(Boolean) if (adminKeys.includes(id)) return true return false } /** * Resolve effective role: policy peer entry overrides env defaults. * @param {string} peerIdHex * @param {string} envRole */ export function resolvePeerRole(peerIdHex, envRole) { const id = (peerIdHex || '').toLowerCase() const policy = loadPolicy() if (policy.revoked.includes(id)) return Roles.viewer const entry = policy.peers[id] if (entry?.role && [Roles.viewer, Roles.operator, Roles.admin].includes(entry.role)) { return entry.role } return envRole } /** * @param {string} peerIdHex * @param {{ role?: string, alias?: string, note?: string }} meta */ export function registerPeer(peerIdHex, meta = {}) { const id = peerIdHex.toLowerCase() if (!/^[0-9a-f]{64}$/.test(id)) throw new Error('peerId must be 64 hex characters') const policy = loadPolicy() policy.revoked = policy.revoked.filter((r) => r !== id) policy.peers[id] = { peerId: id, role: meta.role || Roles.operator, alias: meta.alias || null, note: meta.note || null, invitedAt: policy.peers[id]?.invitedAt || new Date().toISOString(), updatedAt: new Date().toISOString(), } savePolicy(policy) return policy.peers[id] } /** * @param {string} peerIdHex */ export function revokePeer(peerIdHex) { const id = peerIdHex.toLowerCase() const policy = loadPolicy() if (!policy.revoked.includes(id)) policy.revoked.push(id) delete policy.peers[id] savePolicy(policy) return { success: true, peerId: id, revoked: true } } /** * @param {string} peerIdHex */ export function unrevokePeer(peerIdHex) { const id = peerIdHex.toLowerCase() if (!/^[0-9a-f]{64}$/.test(id)) throw new Error('peerId must be 64 hex characters') const policy = loadPolicy() const before = policy.revoked.length policy.revoked = policy.revoked.filter((r) => r !== id) savePolicy(policy) return { success: true, peerId: id, revoked: false, removed: before !== policy.revoked.length, } } /** * Remove all entries from the revoke list. * @returns {{ success: boolean, cleared: number }} */ export function clearRevokedPeers() { const policy = loadPolicy() const cleared = policy.revoked.length policy.revoked = [] savePolicy(policy) return { success: true, cleared } } /** * @returns {string[]} */ export function listRevokedPeers() { return loadPolicy().revoked.slice() } /** * @param {string} peerIdHex * @param {string} role */ export function setPeerRole(peerIdHex, role) { if (![Roles.viewer, Roles.operator, Roles.admin].includes(role)) { throw new Error('Invalid role') } const id = peerIdHex.toLowerCase() const policy = loadPolicy() if (!policy.peers[id]) { policy.peers[id] = { peerId: id, role, alias: null, invitedAt: new Date().toISOString(), } } else { policy.peers[id].role = role policy.peers[id].updatedAt = new Date().toISOString() } savePolicy(policy) return policy.peers[id] } /** * Parse invite TTL hours. 0 / omitted = forever (default). * Env PEARDOCK_INVITE_TTL_HOURS overrides default when opts unset. * @param {number|undefined|null} ttlHours * @returns {{ forever: boolean, ttlHours: number }} */ function resolveInviteTtl(ttlHours) { const raw = ttlHours !== undefined && ttlHours !== null && ttlHours !== '' ? Number(ttlHours) : process.env.PEARDOCK_INVITE_TTL_HOURS !== undefined ? Number(process.env.PEARDOCK_INVITE_TTL_HOURS) : 0 if (!Number.isFinite(raw) || raw <= 0) return { forever: true, ttlHours: 0 } return { forever: false, ttlHours: Math.min(raw, 24 * 365 * 100) } } /** * Parse max uses. 0 / omitted = unlimited (default). * Env PEARDOCK_INVITE_MAX_USES overrides default when opts unset. * @param {number|undefined|null} maxUses * @returns {number} 0 = unlimited */ function resolveInviteMaxUses(maxUses) { const raw = maxUses !== undefined && maxUses !== null && maxUses !== '' ? Number(maxUses) : process.env.PEARDOCK_INVITE_MAX_USES !== undefined ? Number(process.env.PEARDOCK_INVITE_MAX_USES) : 0 if (!Number.isFinite(raw) || raw <= 0) return 0 return Math.min(Math.floor(raw), 1_000_000) } /** * Mint an HMAC capability grant and track it for spend accounting. * Defaults: never expires, unlimited uses (persist forever unless configured). * @param {{ role?: string, ttlHours?: number, maxUses?: number, note?: string, peerId?: string|null }} opts */ export function mintCapability(opts = {}) { const role = opts.role || Roles.operator const { forever, ttlHours } = resolveInviteTtl(opts.ttlHours) const maxUses = resolveInviteMaxUses(opts.maxUses) const macKey = getMacKey() const { token, payload } = signCapability(macKey, { role, forever, ttlMs: forever ? 0 : ttlHours * 3600 * 1000, peerId: opts.peerId || null, }) const policy = loadPolicy() // Never leave a freshly minted jti on the spent list (corrupt state after bad deletes) policy.spentJtis = (policy.spentJtis || []).filter((j) => j !== payload.jti) policy.capabilities[payload.jti] = { jti: payload.jti, role: payload.role, exp: payload.exp, // null = never maxUses, // 0 = unlimited uses: 0, note: opts.note || null, peerId: opts.peerId || null, createdAt: new Date().toISOString(), } savePolicy(policy) return { capability: token, role: payload.role, expiresAt: payload.exp == null ? null : new Date(payload.exp).toISOString(), maxUses, jti: payload.jti, note: opts.note || null, persistent: forever && maxUses === 0, redeemHint: forever && maxUses === 0 ? 'Persistent grant — reconnect anytime with this capability (or after first redeem, as the registered peer).' : 'Pass capability in handshake (or paste a full pd1. invite in Add peer)', } } /** * Look up a registered peer entry (or null). * @param {string} peerIdHex */ export function getPeerEntry(peerIdHex) { const id = (peerIdHex || '').toLowerCase() if (!id) return null const policy = loadPolicy() return policy.peers[id] || null } /** * Verify capability token and register peer with grant role. * - Unlimited invites (maxUses=0): never spent; safe for reconnect. * - Limited invites: count a use only when a *new* peer is registered (not reconnect). * - If grant is spent/expired but this peer is already registered → reconnect with stored role. * @param {string} token * @param {string} peerIdHex * @returns {{ role: string, jti: string, entry: PeerEntry, reconnected?: boolean }} */ export function redeemCapability(token, peerIdHex) { const id = (peerIdHex || '').toLowerCase() const policy = loadPolicy() let spent = new Set(policy.spentJtis) const existing = policy.peers[id] || null const res = verifyCapability(getMacKey(), token, { peerId: id, allowSpentCheck: (jti) => { // Reconnect of an already-registered elevated peer always allowed if (existing?.role && existing.role !== 'viewer') return true const meta = policy.capabilities[jti] // Active unlimited (or not-yet-exhausted) grant must not be blocked by a stale spent list if (meta) { if (meta.exp != null && meta.exp < Date.now()) return false if (meta.maxUses > 0 && meta.uses >= meta.maxUses) return false // Heal: grant still active in policy but jti was marked spent (e.g. bad delete) if (spent.has(jti)) { spent.delete(jti) policy.spentJtis = Array.from(spent) savePolicy(policy) logger.info('Healed spent jti still present as active capability', { jti: String(jti).slice(0, 8), }) } return true } if (spent.has(jti)) return false // Untracked but valid HMAC — allow (persistent default); not auto-spent return true }, }) if (!res.ok) { // Soft reconnect: registered elevated peer keeps role even if grant later revoked if ( existing?.role && existing.role !== 'viewer' && (res.code === 'CAPABILITY_SPENT' || res.code === 'CAPABILITY_EXPIRED') ) { return { role: existing.role, jti: existing.note?.startsWith('capability:') ? existing.note.slice('capability:'.length) : null, entry: existing, reconnected: true, } } const err = new Error( res.error || 'Invalid capability' + (res.code === 'CAPABILITY_SPENT' ? ' (grant was deleted or replaced — request a new peardock invite)' : '') ) err.code = res.code || 'CAPABILITY_INVALID' // Best-effort jti for logs (payload may be unreadable if MAC failed) try { const body = token?.split?.('.')?.[0] if (body) { const json = JSON.parse( Buffer.from(body.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8') ) err.jti = json?.jti || null err.grantRole = json?.role || null } } catch { // ignore } throw err } const jti = res.payload.jti const meta = policy.capabilities[jti] const alreadyRegistered = Boolean(existing?.role) // Count use only for first-time peer registration on limited grants if (meta && !alreadyRegistered && meta.maxUses > 0) { meta.uses += 1 if (meta.uses >= meta.maxUses) { spent.add(jti) delete policy.capabilities[jti] } else { policy.capabilities[jti] = meta } policy.spentJtis = Array.from(spent) savePolicy(policy) } else if (meta && meta.exp != null && meta.exp < Date.now() && !alreadyRegistered) { const err = new Error('Capability expired') err.code = 'CAPABILITY_EXPIRED' throw err } // Prefer grant role; never demote an existing higher registration const grantRole = res.payload.role const entry = registerPeer(id, { role: existing?.role ? maxRoleLocal(existing.role, grantRole) : grantRole, note: existing?.note || `capability:${String(jti).slice(0, 8)}`, }) return { role: entry.role, jti, entry, reconnected: alreadyRegistered, } } function maxRoleLocal(a, b) { const rank = { viewer: 1, operator: 2, admin: 3 } return (rank[a] || 0) >= (rank[b] || 0) ? a : b } /** * @deprecated Prefer mintCapability. Kept for PEARDOCK_LEGACY_INVITES=1. */ export function createInvite(opts = {}) { if (!legacyInvitesEnabled()) { // Bridge: mint capability and expose as token field for transitional clients const cap = mintCapability(opts) return { token: cap.capability, role: cap.role, expiresAt: cap.expiresAt, maxUses: cap.maxUses, note: cap.note, jti: cap.jti, kind: 'capability', redeemHint: cap.redeemHint, } } const policy = loadPolicy() const token = crypto.randomBytes(24).toString('hex') const ttlHours = Math.min(Number(opts.ttlHours) || 72, 24 * 30) const expiresAt = new Date(Date.now() + ttlHours * 3600 * 1000).toISOString() const invite = { token, role: opts.role || Roles.operator, expiresAt, maxUses: Math.min(Number(opts.maxUses) || 1, 100), uses: 0, note: opts.note || null, createdAt: new Date().toISOString(), } policy.invites[token] = invite savePolicy(policy) return { token, role: invite.role, expiresAt: invite.expiresAt, maxUses: invite.maxUses, note: invite.note, kind: 'legacy', redeemHint: 'Pass inviteToken in handshake args within expiry', } } /** * Redeem legacy invite token OR capability token. * @param {string} token * @param {string} peerIdHex */ export function redeemInvite(token, peerIdHex) { if (!token) return null const t = String(token) // Capability tokens: base64url.body.mac if (t.includes('.')) { const { role, entry } = redeemCapability(t, peerIdHex) return entry || registerPeer(peerIdHex, { role }) } if (!legacyInvitesEnabled()) { const err = new Error( 'Legacy invite tokens disabled. Use a pd1. invite or HMAC capability grant.' ) err.code = 'INVITE_INVALID' throw err } const policy = loadPolicy() const invite = policy.invites[t] if (!invite) { const err = new Error('Invalid invite token') err.code = 'INVITE_INVALID' throw err } if (new Date(invite.expiresAt).getTime() < Date.now()) { const err = new Error('Invite token expired') err.code = 'INVITE_INVALID' throw err } if (invite.uses >= invite.maxUses) { const err = new Error('Invite token already used') err.code = 'INVITE_INVALID' throw err } invite.uses += 1 if (invite.uses >= invite.maxUses) { delete policy.invites[t] } else { policy.invites[t] = invite } savePolicy(policy) return registerPeer(peerIdHex, { role: invite.role, note: `invite:${t.slice(0, 8)}` }) } export function listPeers() { const policy = loadPolicy() return { enforceAllowlist: enforceAllowlist(), peers: Object.values(policy.peers), revoked: policy.revoked, } } export function listInvites() { const policy = loadPolicy() const now = Date.now() const legacy = Object.values(policy.invites) .filter((i) => new Date(i.expiresAt).getTime() >= now) .map((i) => ({ token: i.token, role: i.role, expiresAt: i.expiresAt, maxUses: i.maxUses, uses: i.uses, note: i.note, createdAt: i.createdAt, kind: 'legacy', })) const caps = Object.values(policy.capabilities) .filter((c) => { if (c.exp != null && c.exp < now) return false if (c.maxUses > 0 && c.uses >= c.maxUses) return false return true }) .map((c) => ({ jti: c.jti, role: c.role, expiresAt: c.exp == null ? null : new Date(c.exp).toISOString(), maxUses: c.maxUses, uses: c.uses, note: c.note, createdAt: c.createdAt, kind: 'capability', persistent: c.exp == null && c.maxUses === 0, // Do not re-emit full capability secret token: `(capability ${c.jti.slice(0, 8)}…)`, })) return [...caps, ...legacy] } /** * Revoke a capability jti (and/or remove a legacy invite token). * Marks jti spent so HMAC tokens cannot be redeemed later. * @param {{ jti?: string, token?: string }} opts * @returns {{ success: boolean, deleted: string[], jti?: string|null }} */ export function deleteInvite(opts = {}) { const policy = loadPolicy() const deleted = [] let jti = opts.jti ? String(opts.jti).toLowerCase() : null const token = opts.token ? String(opts.token) : null // Capability body.mac → derive jti if possible if (!jti && token && token.includes('.')) { try { const body = Buffer.from( token.split('.')[0].replace(/-/g, '+').replace(/_/g, '/'), 'base64' ).toString('utf8') const payload = JSON.parse(body) if (payload?.jti) jti = String(payload.jti).toLowerCase() } catch { // ignore parse errors } } if (jti) { if (policy.capabilities[jti]) { delete policy.capabilities[jti] deleted.push(`capability:${jti.slice(0, 8)}`) } if (!policy.spentJtis.includes(jti)) { policy.spentJtis.push(jti) deleted.push(`spent:${jti.slice(0, 8)}`) } } if (token && policy.invites[token]) { delete policy.invites[token] deleted.push(`legacy:${token.slice(0, 8)}`) } // Legacy invite looked up by short prefix is not supported — require full token if (deleted.length === 0 && !jti && !token) { const err = new Error('jti or token required to delete invite') err.code = 'INVALID_ARGS' throw err } savePolicy(policy) return { success: true, deleted, jti: jti || null } } export function policyPath() { return policyFilePath() }