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() {
|
||||
|
||||
Reference in New Issue
Block a user