Secure connections with AutoPass invites, HMAC capabilities, and viewer default.
Release rolling / release (push) Successful in 12m24s
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:
+43
-9
@@ -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
@@ -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])) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user