Files
peardock/server/core/peer-policy.js
T
Raven Scott f4a97aad2a
Release rolling / release (push) Successful in 8m18s
Move away from Autopass for AlmaLInux Compatibility
2026-07-14 22:19:33 -04:00

676 lines
20 KiB
JavaScript

/**
* 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<string, PeerEntry>,
* invites: Record<string, LegacyInvite>,
* capabilities: Record<string, CapabilityMeta>,
* 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<typeof loadPolicy>} 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()
}