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() {