Secure connections with AutoPass invites, HMAC capabilities, and viewer default.
Release rolling / release (push) Successful in 12m24s

Default peers are read-only; admin requires seed proof and operators redeem AutoPass packages with signed grants. ACL UI and docs match the new trust model.
This commit is contained in:
Raven Scott
2026-07-14 19:38:53 -04:00
parent b8bd4eb902
commit c818edac9d
31 changed files with 2138 additions and 233 deletions
+43 -9
View File
@@ -1,15 +1,21 @@
/**
* Capability / role ACL for peardock RPC.
*
* Default: every peer is admin (backward compatible).
* Set PEARDOCK_DEFAULT_ROLE=viewer|operator|admin to tighten.
* Set PEARDOCK_ADMIN_KEYS=hex,hex to force those peers to admin and others to default.
* Peer policy file (invite/revoke) can override role per peer.
* Secure default: every peer is viewer (read-only).
* Elevate via:
* - admin seed HMAC proof (handshake)
* - HMAC capability grant (AutoPass / invite)
* - PEARDOCK_ADMIN_KEYS peer allowlist
* - peer policy registered role
* - PEARDOCK_INSECURE_OPEN_ADMIN=1 (dev escape hatch → admin for all)
*
* Set PEARDOCK_DEFAULT_ROLE=viewer|operator|admin to change baseline.
*/
import { Roles, roleAllows, MethodRoles } from '../../shared/protocol.js'
import { resolvePeerRole } from './peer-policy.js'
import { isInsecureOpenAdmin } from '../../shared/crypto-auth.js'
const DEFAULT_ROLE = (process.env.PEARDOCK_DEFAULT_ROLE || Roles.admin).toLowerCase()
const DEFAULT_ROLE = (process.env.PEARDOCK_DEFAULT_ROLE || Roles.viewer).toLowerCase()
const ADMIN_KEYS = new Set(
(process.env.PEARDOCK_ADMIN_KEYS || '')
.split(',')
@@ -18,18 +24,33 @@ const ADMIN_KEYS = new Set(
)
/**
* Resolve role for a peer public key hex.
* Resolve baseline role for a peer public key hex (before handshake elevation).
* @param {string} peerIdHex
* @returns {string}
*/
export function resolveRole(peerIdHex) {
const id = (peerIdHex || '').toLowerCase()
let envRole = Roles.admin
if (isInsecureOpenAdmin()) {
try {
return resolvePeerRole(id, Roles.admin)
} catch {
return Roles.admin
}
}
let envRole = Roles.viewer
if (ADMIN_KEYS.size > 0) {
envRole = ADMIN_KEYS.has(id) ? Roles.admin : DEFAULT_ROLE === Roles.admin ? Roles.operator : DEFAULT_ROLE
envRole = ADMIN_KEYS.has(id) ? Roles.admin : DEFAULT_ROLE
} else if ([Roles.viewer, Roles.operator, Roles.admin].includes(DEFAULT_ROLE)) {
envRole = DEFAULT_ROLE
}
// Never default to admin unless explicitly configured
if (envRole === Roles.admin && ADMIN_KEYS.size === 0 && DEFAULT_ROLE !== Roles.admin) {
envRole = Roles.viewer
}
try {
return resolvePeerRole(id, envRole)
} catch {
@@ -50,4 +71,17 @@ export function assertAllowed(role, method) {
}
}
export { Roles }
/**
* Rank helper for elevating (never demote via capability below policy max without care).
* Handshake elevation: max(baseline, elevated).
* @param {string} a
* @param {string} b
*/
export function maxRole(a, b) {
const rank = { [Roles.viewer]: 1, [Roles.operator]: 2, [Roles.admin]: 3 }
const ra = rank[a] || 0
const rb = rank[b] || 0
return ra >= rb ? a : b
}
export { Roles, ADMIN_KEYS, DEFAULT_ROLE }
+12 -1
View File
@@ -96,7 +96,18 @@ export function audit(entry) {
function sanitizeArgs(args) {
if (!args || typeof args !== 'object') return null
const out = {}
const keys = ['id', 'name', 'stackName', 'image', 'operation', 'path', 'term', 'containerIds']
const keys = [
'id',
'name',
'stackName',
'image',
'operation',
'path',
'term',
'containerIds',
'authMode',
'role',
]
for (const k of keys) {
if (args[k] != null) {
if (k === 'containerIds' && Array.isArray(args[k])) {
+47
View File
@@ -0,0 +1,47 @@
/**
* Server-side MAC key for capability + admin proof verification.
* Derived once from SERVER_SEED after keys are loaded.
*/
import { deriveMacKey } from '../../shared/crypto-auth.js'
/** @type {Buffer|null} */
let macKey = null
/** @type {string|null} */
let seedHex = null
/** @type {string|null} */
let publicKeyHex = null
/**
* @param {{ seedHex: string, publicKeyHex: string }} opts
*/
export function initAuthKeys({ seedHex: seed, publicKeyHex: pub }) {
if (!seed || !/^[0-9a-fA-F]{64}$/.test(seed)) {
throw new Error('initAuthKeys requires 64-hex seedHex')
}
seedHex = seed.toLowerCase()
publicKeyHex = String(pub || '').toLowerCase()
macKey = deriveMacKey(seedHex)
}
export function getMacKey() {
if (!macKey) {
// Lazy init from env (tests / late import)
const seed = process.env.SERVER_SEED || process.env.SERVER_KEY
if (seed && /^[0-9a-fA-F]{64}$/.test(seed)) {
seedHex = seed.toLowerCase()
macKey = deriveMacKey(seedHex)
publicKeyHex = (process.env.SERVER_PUBLIC_KEY || '').toLowerCase() || null
}
}
if (!macKey) throw new Error('Auth keys not initialized (SERVER_SEED missing)')
return macKey
}
export function getSeedHex() {
if (!seedHex) getMacKey()
return seedHex
}
export function getServerPublicKeyHex() {
return publicKeyHex || (process.env.SERVER_PUBLIC_KEY || '').toLowerCase() || null
}
+197
View File
@@ -0,0 +1,197 @@
/**
* Server-side AutoPass vault for distributing connection packages.
*
* Stores { publicKeyHex, capability, role, alias } — never SERVER_SEED.
* Admins create AutoPass invites; operators pair and receive the package.
*/
import path from 'path'
import fs from 'fs'
import Corestore from 'corestore'
import logger from '../utils/logger.js'
import { mintCapability } from './peer-policy.js'
import { getServerPublicKeyHex } from './auth-keys.js'
import { Roles } from '../../shared/protocol.js'
const log = logger.child('autopass')
const PKG_KEY = 'peardock:pkg'
/** @type {import('autopass')|null} */
let pass = null
/** @type {import('corestore')|null} */
let store = null
/** @type {Promise<any>|null} */
let opening = null
/** @type {{ invite: string, role: string, createdAt: string, expiresAt: string, jti: string }|null} */
let lastInviteMeta = null
function vaultDir() {
return process.env.PEARDOCK_AUTOPASS_DIR || path.join(process.cwd(), 'peardock-autopass')
}
/**
* Lazy-open Autopass instance.
*/
export async function getAutopass() {
if (pass) return pass
if (opening) return opening
opening = (async () => {
let Autopass
try {
const mod = await import('autopass')
Autopass = mod.default || mod
} catch (err) {
const e = new Error(
`autopass package not available: ${err.message}. Run npm install autopass.`
)
e.code = 'AUTOPASS_UNAVAILABLE'
throw e
}
const dir = vaultDir()
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
}
store = new Corestore(path.join(dir, 'store'))
pass = new Autopass(store)
await pass.ready()
log.info('AutoPass vault ready', { dir })
return pass
})()
try {
return await opening
} catch (err) {
opening = null
pass = null
store = null
throw err
}
}
/**
* Create connection invite: mint HMAC capability, write package, return z32 invite.
* @param {{ role?: string, ttlHours?: number, maxUses?: number, note?: string, alias?: string, peerId?: string }} opts
*/
export async function createConnectionInvite(opts = {}) {
const vault = await getAutopass()
const publicKeyHex = getServerPublicKeyHex()
if (!publicKeyHex || !/^[0-9a-f]{64}$/.test(publicKeyHex)) {
throw new Error('Server public key not available for AutoPass package')
}
const role = opts.role || Roles.operator
const cap = mintCapability({
role,
ttlHours: opts.ttlHours,
maxUses: opts.maxUses,
note: opts.note,
peerId: opts.peerId || null,
})
const packageValue = JSON.stringify({
v: 1,
publicKeyHex,
capability: cap.capability,
role: cap.role,
alias: opts.alias || null,
note: opts.note || null,
expiresAt: cap.expiresAt,
jti: cap.jti,
// Explicit: never include seed
})
await vault.add(PKG_KEY, packageValue)
// AutoPass single active invite — readOnly so invitees cannot rewrite the vault
const readOnly = role === Roles.viewer
const invite = await vault.createInvite({ readOnly })
lastInviteMeta = {
invite,
role: cap.role,
createdAt: new Date().toISOString(),
expiresAt: cap.expiresAt,
jti: cap.jti,
maxUses: cap.maxUses,
note: opts.note || null,
}
log.info('Created AutoPass connection invite', {
role: cap.role,
jti: cap.jti.slice(0, 8),
expiresAt: cap.expiresAt,
})
return {
kind: 'autopass',
invite,
role: cap.role,
expiresAt: cap.expiresAt,
maxUses: cap.maxUses,
jti: cap.jti,
note: opts.note || null,
publicKeyHex,
// capability intentionally omitted from list responses; included once for admin copy if needed
capability: cap.capability,
redeemHint:
'Share the AutoPass invite string. Recipients paste it in Add peer — no SERVER_SEED required.',
}
}
/**
* List active invite metadata (no capability secret).
*/
export function listAutopassInvites() {
if (!lastInviteMeta) return []
if (new Date(lastInviteMeta.expiresAt).getTime() < Date.now()) return []
return [
{
kind: 'autopass',
invite: lastInviteMeta.invite,
role: lastInviteMeta.role,
expiresAt: lastInviteMeta.expiresAt,
jti: lastInviteMeta.jti,
maxUses: lastInviteMeta.maxUses,
uses: 0,
note: lastInviteMeta.note,
createdAt: lastInviteMeta.createdAt,
token: lastInviteMeta.invite,
},
]
}
export async function deleteAutopassInvite() {
if (!pass) return { success: true }
try {
await pass.deleteInvite()
} catch (err) {
log.warn('deleteInvite failed', { error: err.message })
}
lastInviteMeta = null
return { success: true }
}
export async function closeAutopassVault() {
try {
if (pass) await pass.close()
} catch {
// ignore
}
try {
if (store) await store.close()
} catch {
// ignore
}
pass = null
store = null
opening = null
}
export function isAutopassReady() {
return Boolean(pass)
}
export { PKG_KEY }
+222 -27
View File
@@ -1,17 +1,24 @@
/**
* Peer allowlist / revoke / invite policy (Phase 2 multi-operator).
* Peer allowlist / revoke / capability grant policy.
*
* File: PEARDOCK_PEER_POLICY (default ./peardock-peers.json)
*
* Modes:
* - Open (default): any peer may connect; role from PEARDOCK_DEFAULT_ROLE / ADMIN_KEYS
* - Allowlist (PEARDOCK_PEER_ALLOWLIST=1 or non-empty allowlist with enforce):
* only listed peers (or valid invite redeemers) may connect
* - 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.
* AutoPass distributes 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() {
@@ -24,30 +31,60 @@ function enforceAllowlist() {
)
}
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 }} Invite
* @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, Invite> }}
* @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: 1, revoked: [], peers: {}, invites: {} }
return {
version: 2,
revoked: [],
peers: {},
invites: {},
capabilities: {},
spentJtis: [],
}
}
try {
const raw = JSON.parse(fs.readFileSync(POLICY_PATH, 'utf8'))
return {
version: raw.version || 1,
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: 1, revoked: [], peers: {}, invites: {} }
return {
version: 2,
revoked: [],
peers: {},
invites: {},
capabilities: {},
spentJtis: [],
}
}
}
@@ -58,6 +95,10 @@ 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)
@@ -77,17 +118,22 @@ export function isPeerRevoked(peerIdHex) {
}
/**
* Whether a peer may establish an RPC session (after handshake / invite redeem).
* 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) {
export function isPeerAllowed(peerIdHex, opts = {}) {
const id = (peerIdHex || '').toLowerCase()
const policy = loadPolicy()
if (policy.revoked.includes(id)) return false
// Seed / capability elevation always allowed (unless revoked)
if (opts.authMode === 'seed' || opts.authMode === 'capability') return true
if (!enforceAllowlist()) return true
// Allowlist mode: must be registered
if (policy.peers[id]) return true
// Also allow env admin keys as bootstrap owners
const adminKeys = (process.env.PEARDOCK_ADMIN_KEYS || '')
.split(',')
.map((s) => s.trim().toLowerCase())
@@ -99,12 +145,12 @@ export function isPeerAllowed(peerIdHex) {
/**
* Resolve effective role: policy peer entry overrides env defaults.
* @param {string} peerIdHex
* @param {string} envRole - role from resolveRole()
* @param {string} envRole
*/
export function resolvePeerRole(peerIdHex, envRole) {
const id = (peerIdHex || '').toLowerCase()
const policy = loadPolicy()
if (policy.revoked.includes(id)) return Roles.viewer // should not connect
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
@@ -182,10 +228,119 @@ export function setPeerRole(peerIdHex, role) {
}
/**
* Create a redeemable invite token.
* @param {{ role?: string, ttlHours?: number, maxUses?: number, note?: string }} opts
* Mint an HMAC capability grant and track it for spend accounting.
* @param {{ role?: string, ttlHours?: number, maxUses?: number, note?: string, peerId?: string|null }} opts
*/
export function mintCapability(opts = {}) {
const role = opts.role || Roles.operator
const ttlHours = Math.min(Number(opts.ttlHours) || 72, 24 * 30)
const maxUses = Math.min(Number(opts.maxUses) || 1, 100)
const macKey = getMacKey()
const { token, payload } = signCapability(macKey, {
role,
ttlMs: ttlHours * 3600 * 1000,
peerId: opts.peerId || null,
})
const policy = loadPolicy()
policy.capabilities[payload.jti] = {
jti: payload.jti,
role: payload.role,
exp: payload.exp,
maxUses,
uses: 0,
note: opts.note || null,
peerId: opts.peerId || null,
createdAt: new Date().toISOString(),
}
savePolicy(policy)
return {
capability: token,
role: payload.role,
expiresAt: new Date(payload.exp).toISOString(),
maxUses,
jti: payload.jti,
note: opts.note || null,
redeemHint: 'Pass capability in handshake (or redeem via AutoPass package)',
}
}
/**
* Verify capability token and record a use. Registers peer with grant role.
* @param {string} token
* @param {string} peerIdHex
* @returns {{ role: string, jti: string, entry: PeerEntry }}
*/
export function redeemCapability(token, peerIdHex) {
const id = (peerIdHex || '').toLowerCase()
const policy = loadPolicy()
const spent = new Set(policy.spentJtis)
const res = verifyCapability(getMacKey(), token, {
peerId: id,
allowSpentCheck: (jti) => {
if (spent.has(jti)) return false
const meta = policy.capabilities[jti]
if (!meta) {
// Accept signatures even if mint wasn't persisted (e.g. multi-instance) unless spent
return true
}
if (meta.uses >= meta.maxUses) return false
if (meta.exp < Date.now()) return false
return true
},
})
if (!res.ok) {
const err = new Error(res.error || 'Invalid capability')
err.code = res.code || 'CAPABILITY_INVALID'
throw err
}
const jti = res.payload.jti
const meta = policy.capabilities[jti]
if (meta) {
meta.uses += 1
if (meta.uses >= meta.maxUses) {
spent.add(jti)
delete policy.capabilities[jti]
} else {
policy.capabilities[jti] = meta
}
} else {
// One-shot for untracked (still HMAC-valid) tokens
spent.add(jti)
}
policy.spentJtis = Array.from(spent)
savePolicy(policy)
const entry = registerPeer(id, {
role: res.payload.role,
note: `capability:${String(jti).slice(0, 8)}`,
})
return { role: res.payload.role, jti, entry }
}
/**
* @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)
@@ -207,35 +362,59 @@ export function createInvite(opts = {}) {
expiresAt: invite.expiresAt,
maxUses: invite.maxUses,
note: invite.note,
// Client redeems by connecting and sending token in handshake
kind: 'legacy',
redeemHint: 'Pass inviteToken in handshake args within expiry',
}
}
/**
* Redeem invite for a connecting peer.
* 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 an AutoPass invite or HMAC capability grant.'
)
err.code = 'INVITE_INVALID'
throw err
}
const policy = loadPolicy()
const invite = policy.invites[token]
if (!invite) throw new Error('Invalid invite token')
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()) {
throw new Error('Invite token expired')
const err = new Error('Invite token expired')
err.code = 'INVITE_INVALID'
throw err
}
if (invite.uses >= invite.maxUses) {
throw new Error('Invite token already used')
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[token]
delete policy.invites[t]
} else {
policy.invites[token] = invite
policy.invites[t] = invite
}
savePolicy(policy)
return registerPeer(peerIdHex, { role: invite.role, note: `invite:${token.slice(0, 8)}` })
return registerPeer(peerIdHex, { role: invite.role, note: `invite:${t.slice(0, 8)}` })
}
export function listPeers() {
@@ -250,7 +429,7 @@ export function listPeers() {
export function listInvites() {
const policy = loadPolicy()
const now = Date.now()
return Object.values(policy.invites)
const legacy = Object.values(policy.invites)
.filter((i) => new Date(i.expiresAt).getTime() >= now)
.map((i) => ({
token: i.token,
@@ -260,7 +439,23 @@ export function listInvites() {
uses: i.uses,
note: i.note,
createdAt: i.createdAt,
kind: 'legacy',
}))
const caps = Object.values(policy.capabilities)
.filter((c) => c.exp >= now && c.uses < c.maxUses)
.map((c) => ({
jti: c.jti,
role: c.role,
expiresAt: new Date(c.exp).toISOString(),
maxUses: c.maxUses,
uses: c.uses,
note: c.note,
createdAt: c.createdAt,
kind: 'capability',
// Do not re-emit full capability secret
token: `(capability ${c.jti.slice(0, 8)}…)`,
}))
return [...caps, ...legacy]
}
export function policyPath() {
+66 -10
View File
@@ -1,7 +1,9 @@
/**
* Peer invite / revoke / list ACL management (admin).
* Invites are AutoPass z32 strings carrying HMAC capability packages.
*/
import * as peerPolicy from '../core/peer-policy.js'
import * as autopassVault from '../core/autopass-vault.js'
import { peers } from '../core/peer-registry.js'
import { Roles } from '../../shared/protocol.js'
import * as validation from '../utils/validation.js'
@@ -14,6 +16,7 @@ export function registerPeerHandlers(session) {
live.push({
peerId: s.id,
role: s.role,
authMode: s.authMode || null,
connected: !s.closed,
clientInfo: s.clientInfo || null,
})
@@ -27,15 +30,22 @@ export function registerPeerHandlers(session) {
})
session.respond('listInvites', async () => {
const caps = peerPolicy.listInvites()
let autopass = []
try {
autopass = autopassVault.listAutopassInvites()
} catch {
autopass = []
}
return {
success: true,
type: 'invites',
data: peerPolicy.listInvites(),
data: [...autopass, ...caps],
}
})
session.respond('invitePeer', async (args) => {
// Either create redeemable token OR register known peer key
// Register a known peer public key (no AutoPass)
if (args.peerId) {
const peerId = validation.sanitizeString(args.peerId, 64).toLowerCase()
if (!/^[0-9a-f]{64}$/.test(peerId)) throw new Error('peerId must be 64 hex characters')
@@ -47,20 +57,66 @@ export function registerPeerHandlers(session) {
})
return { success: true, type: 'peerRegistered', data: entry }
}
const invite = peerPolicy.createInvite({
role: args.role || Roles.operator,
ttlHours: args.ttlHours,
maxUses: args.maxUses,
note: args.note,
})
return { success: true, type: 'invite', data: invite }
// Default: AutoPass connection invite (HMAC capability + package)
try {
const invite = await autopassVault.createConnectionInvite({
role: args.role || Roles.operator,
ttlHours: args.ttlHours,
maxUses: args.maxUses,
note: args.note,
alias: args.alias,
})
return {
success: true,
type: 'invite',
data: {
// Primary share string
token: invite.invite,
invite: invite.invite,
kind: 'autopass',
role: invite.role,
expiresAt: invite.expiresAt,
maxUses: invite.maxUses,
jti: invite.jti,
publicKeyHex: invite.publicKeyHex,
redeemHint: invite.redeemHint,
note: invite.note,
},
}
} catch (err) {
// Fallback: capability-only invite if AutoPass unavailable
if (err.code === 'AUTOPASS_UNAVAILABLE') {
const cap = peerPolicy.mintCapability({
role: args.role || Roles.operator,
ttlHours: args.ttlHours,
maxUses: args.maxUses,
note: args.note,
})
return {
success: true,
type: 'invite',
data: {
token: cap.capability,
capability: cap.capability,
kind: 'capability',
role: cap.role,
expiresAt: cap.expiresAt,
maxUses: cap.maxUses,
jti: cap.jti,
redeemHint:
'AutoPass unavailable — share server public key + this capability token. Install autopass for automatic package invites.',
},
}
}
throw err
}
})
session.respond('revokePeer', async (args) => {
const peerId = validation.sanitizeString(args.peerId || args.id, 64).toLowerCase()
if (!peerId) throw new Error('peerId required')
const result = peerPolicy.revokePeer(peerId)
// Drop live session if connected
const live = peers.get(peerId)
if (live) {
try {
+84 -12
View File
@@ -3,16 +3,18 @@
*/
import ProtomuxRPC from 'protomux-rpc'
import b4a from 'b4a'
import { PROTOCOL, PROTOCOL_VERSION } from '../../shared/protocol.js'
import { PROTOCOL, PROTOCOL_VERSION, Roles } from '../../shared/protocol.js'
import { encodings } from '../../shared/encodings.js'
import rateLimiter from '../utils/rateLimiter.js'
import logger from '../utils/logger.js'
import { resolveRole, assertAllowed } from '../core/acl.js'
import { resolveRole, assertAllowed, maxRole } from '../core/acl.js'
import { audit, shouldAudit } from '../core/audit.js'
import { recordRpc } from '../services/metrics.js'
import { redeemInvite, isPeerAllowed } from '../core/peer-policy.js'
import { redeemInvite, redeemCapability, isPeerAllowed } from '../core/peer-policy.js'
import { validateMethodArgs, SCHEMA_VERSION } from '../../shared/schema.js'
import { sanitizeClientError } from '../utils/dockerErrors.js'
import { verifyAdminProof } from '../../shared/crypto-auth.js'
import { getMacKey, getServerPublicKeyHex } from '../core/auth-keys.js'
export class PeerSession {
/**
@@ -200,26 +202,93 @@ export function registerHandshake(session) {
version: args.clientVersion || null,
}
}
// Redeem invite token if provided (registers peer + assigns role)
if (args?.inviteToken) {
let authMode = 'viewer'
let elevatedRole = null
// 1) Admin seed HMAC proof → full admin (never trust client role field)
if (args?.adminProof) {
const serverPk =
getServerPublicKeyHex() ||
(session.stream?.publicKey ? b4a.toString(session.stream.publicKey, 'hex') : null)
const proofRes = verifyAdminProof(getMacKey(), args.adminProof, {
peerId: session.id,
serverPublicKeyHex: serverPk || '',
})
if (!proofRes.ok) {
const e = new Error(proofRes.error || 'Admin proof failed')
e.code = proofRes.code || 'ADMIN_PROOF_FAILED'
audit({
method: 'handshake',
peerId: session.id,
role: session.role,
ok: false,
error: e.message,
force: true,
})
throw e
}
elevatedRole = Roles.admin
authMode = 'seed'
}
// 2) HMAC capability grant (AutoPass package or direct)
const capabilityToken = args?.capability || null
if (capabilityToken && authMode !== 'seed') {
try {
const entry = redeemInvite(String(args.inviteToken), session.id)
if (entry?.role) session.role = entry.role
const { role } = redeemCapability(String(capabilityToken), session.id)
elevatedRole = role
authMode = 'capability'
} catch (err) {
const e = new Error(err.message || 'Invite redeem failed')
e.code = 'INVITE_INVALID'
const e = new Error(err.message || 'Capability redeem failed')
e.code = err.code || 'CAPABILITY_INVALID'
audit({
method: 'handshake',
peerId: session.id,
role: session.role,
ok: false,
error: e.message,
force: true,
})
throw e
}
}
// Refresh role from policy after possible invite
session.role = resolveRole(session.id)
if (!isPeerAllowed(session.id)) {
// 3) Legacy invite token (capability string or PEARDOCK_LEGACY_INVITES)
if (args?.inviteToken && authMode === 'viewer') {
try {
const entry = redeemInvite(String(args.inviteToken), session.id)
if (entry?.role) {
elevatedRole = entry.role
authMode = String(args.inviteToken).includes('.') ? 'capability' : 'legacy-invite'
}
} catch (err) {
const e = new Error(err.message || 'Invite redeem failed')
e.code = err.code || 'INVITE_INVALID'
throw e
}
}
// Baseline from env / peer policy, then elevate
const baseline = resolveRole(session.id)
session.role = elevatedRole ? maxRole(baseline, elevatedRole) : baseline
session.authMode = authMode
if (!isPeerAllowed(session.id, { authMode })) {
const err = new Error('Peer not allowed (revoked or not on allowlist)')
err.code = 'PEER_DENIED'
throw err
}
audit({
method: 'handshake',
peerId: session.id,
role: session.role,
ok: true,
force: true,
args: { authMode },
})
return {
success: true,
protocol: PROTOCOL,
@@ -228,9 +297,12 @@ export function registerHandshake(session) {
role: session.role,
peerId: session.id,
serverTime: Date.now(),
auth: { mode: authMode },
features: {
binaryStreams: true,
schemaValidation: true,
hmacAuth: true,
autopassInvites: true,
},
}
})
+24 -3
View File
@@ -8,6 +8,7 @@ import DHT from 'hyperdht'
import b4a from 'b4a'
import gracefulGoodbye from 'graceful-goodbye'
import { loadOrCreateKeyPair } from './core/keys.js'
import { initAuthKeys } from './core/auth-keys.js'
import { peers } from './core/peer-registry.js'
import { PeerSession } from './rpc/session.js'
import { registerAllHandlers, cleanupSession } from './rpc/register.js'
@@ -15,6 +16,8 @@ import { startDockerEventStream, stopDockerEventStream } from './services/events
import { startStatsBroadcast, stopStatsBroadcast } from './services/stats.js'
import { isPeerRevoked } from './core/peer-policy.js'
import { recordPeerConnect, recordPeerDisconnect } from './services/metrics.js'
import { isInsecureOpenAdmin } from '../shared/crypto-auth.js'
import { closeAutopassVault } from './core/autopass-vault.js'
import {
closeAllTunnels,
isHolesailEnabled,
@@ -30,16 +33,24 @@ import { isPluginsEnabled } from './handlers/plugins.js'
const bootStarted = Date.now()
const log = logger.child('server')
/** @type {{ keyPair: any, publicKeyHex: string }} */
/** @type {{ keyPair: any, publicKeyHex: string, seedHex: string }} */
let keyPair
let publicKeyHex
let seedHex
try {
;({ keyPair, publicKeyHex } = loadOrCreateKeyPair())
;({ keyPair, publicKeyHex, seedHex } = loadOrCreateKeyPair())
initAuthKeys({ seedHex, publicKeyHex })
} catch (err) {
log.error('Failed to load server keypair', { error: err.message })
process.exit(1)
}
if (isInsecureOpenAdmin()) {
log.warn(
'PEARDOCK_INSECURE_OPEN_ADMIN=1 — every peer is admin. Disable for production multi-operator use.'
)
}
const dht = new DHT()
const server = dht.createServer()
@@ -112,9 +123,12 @@ const bootMs = Date.now() - bootStarted
logger.banner([
'peardock server ready',
'',
'Public key (paste into the Pear client):',
'Public key (viewer / read-only by default):',
publicKeyHex,
'',
'Admin: paste public key + SERVER_SEED in the client.',
'Operators: create an AutoPass invite from Access (never share SERVER_SEED).',
'',
`Docker: ${dockerStatus.ok ? `ok · API ${dockerStatus.apiVersion || '?'} · ${dockerStatus.os || '?'}` : `unavailable · ${dockerStatus.error || 'socket error'}`}`,
`Holesail: ${
!isHolesailEnabled()
@@ -123,6 +137,7 @@ logger.banner([
? `on · max ${hs.max} tunnels`
: `on but package missing${hs?.loadError ? ` · ${String(hs.loadError).slice(0, 80)}` : ''}`
}`,
`Auth: HMAC capabilities · default role viewer${isInsecureOpenAdmin() ? ' · INSECURE OPEN ADMIN' : ''}`,
`Swarm RPC: ${isSwarmEnabled() ? 'on' : 'off'} · Plugins: ${isPluginsEnabled() ? 'on' : 'off'}`,
`Log: ${logger.format} · level ${['error', 'warn', 'info', 'debug'][logger.level] || 'info'}`,
`Boot ${bootMs}ms · pid ${process.pid} · Node ${process.version}`,
@@ -180,6 +195,12 @@ async function shutdown(signal = 'shutdown') {
peers.clear()
try {
await closeAutopassVault()
} catch (err) {
log.debug('autopass.close', { error: err.message })
}
try {
await server.close()
} catch (err) {