179 lines
4.8 KiB
JavaScript
179 lines
4.8 KiB
JavaScript
/**
|
|
* Peer roles, revocations, capability spend tracking.
|
|
* File-backed under PEARDATA_DATA_DIR (default ./data).
|
|
*/
|
|
import fs from 'fs'
|
|
import path from 'path'
|
|
import { Roles } from '../../shared/protocol.js'
|
|
import { verifyCapability } from '../../shared/crypto-auth.js'
|
|
import { getMacKey } from './auth-keys.js'
|
|
import logger from '../utils/logger.js'
|
|
|
|
const log = logger.child('peer-policy')
|
|
|
|
function dataDir() {
|
|
return process.env.PEARDATA_DATA_DIR || path.resolve('data')
|
|
}
|
|
|
|
function policyPath() {
|
|
return path.join(dataDir(), 'peer-policy.json')
|
|
}
|
|
|
|
/** @type {{ peers: Record<string, { role: string, displayName?: string, firstSeen: string, lastSeen: string }>, revoked: string[], spentJti: string[] }} */
|
|
let state = { peers: {}, revoked: [], spentJti: [] }
|
|
|
|
export function loadPeerPolicy() {
|
|
try {
|
|
const p = policyPath()
|
|
if (fs.existsSync(p)) {
|
|
const raw = JSON.parse(fs.readFileSync(p, 'utf8'))
|
|
state = {
|
|
peers: raw.peers || {},
|
|
revoked: Array.isArray(raw.revoked) ? raw.revoked : [],
|
|
spentJti: Array.isArray(raw.spentJti) ? raw.spentJti.slice(-5000) : [],
|
|
}
|
|
log.info('Loaded peer policy', {
|
|
peers: Object.keys(state.peers).length,
|
|
revoked: state.revoked.length,
|
|
})
|
|
}
|
|
} catch (err) {
|
|
log.warn('Failed to load peer policy', { error: err.message })
|
|
}
|
|
}
|
|
|
|
function save() {
|
|
try {
|
|
const dir = dataDir()
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
fs.writeFileSync(policyPath(), JSON.stringify(state, null, 2), { mode: 0o600 })
|
|
} catch (err) {
|
|
log.warn('Failed to save peer policy', { error: err.message })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} peerId
|
|
* @param {string} fallback
|
|
*/
|
|
export function resolvePeerRole(peerId, fallback) {
|
|
const id = String(peerId || '').toLowerCase()
|
|
const entry = state.peers[id]
|
|
if (entry?.role) return entry.role
|
|
return fallback
|
|
}
|
|
|
|
/**
|
|
* @param {string} peerId
|
|
*/
|
|
export function getPeerEntry(peerId) {
|
|
return state.peers[String(peerId || '').toLowerCase()] || null
|
|
}
|
|
|
|
/**
|
|
* @param {string} peerId
|
|
* @param {{ role: string, displayName?: string }} info
|
|
*/
|
|
export function registerPeer(peerId, info) {
|
|
const id = String(peerId || '').toLowerCase()
|
|
const now = new Date().toISOString()
|
|
const prev = state.peers[id]
|
|
state.peers[id] = {
|
|
role: info.role,
|
|
displayName: info.displayName || prev?.displayName,
|
|
firstSeen: prev?.firstSeen || now,
|
|
lastSeen: now,
|
|
}
|
|
save()
|
|
}
|
|
|
|
/**
|
|
* @param {string} peerId
|
|
* @param {string} [displayName]
|
|
*/
|
|
export function touchPeer(peerId, displayName) {
|
|
const id = String(peerId || '').toLowerCase()
|
|
const prev = state.peers[id]
|
|
if (!prev) return
|
|
prev.lastSeen = new Date().toISOString()
|
|
if (displayName) prev.displayName = displayName
|
|
save()
|
|
}
|
|
|
|
/**
|
|
* @param {string} peerId
|
|
*/
|
|
export function isPeerRevoked(peerId) {
|
|
return state.revoked.includes(String(peerId || '').toLowerCase())
|
|
}
|
|
|
|
/**
|
|
* @param {string} peerId
|
|
* @param {{ authMode?: string }} [opts]
|
|
*/
|
|
export function isPeerAllowed(peerId, opts = {}) {
|
|
const id = String(peerId || '').toLowerCase()
|
|
if (isPeerRevoked(id)) return false
|
|
const allow = (process.env.PEARDATA_ALLOWLIST || '')
|
|
.split(',')
|
|
.map((s) => s.trim().toLowerCase())
|
|
.filter(Boolean)
|
|
if (allow.length === 0) return true
|
|
// Seed/capability auth can onboard onto allowlist environments
|
|
if (opts.authMode === 'seed' || opts.authMode === 'capability') return true
|
|
return allow.includes(id) || Boolean(state.peers[id])
|
|
}
|
|
|
|
/**
|
|
* @param {string} peerId
|
|
*/
|
|
export function revokePeer(peerId) {
|
|
const id = String(peerId || '').toLowerCase()
|
|
if (!state.revoked.includes(id)) state.revoked.push(id)
|
|
delete state.peers[id]
|
|
save()
|
|
}
|
|
|
|
/**
|
|
* @param {string} token
|
|
* @param {string} peerId
|
|
* @returns {{ role: string, reconnected: boolean }}
|
|
*/
|
|
export function redeemCapability(token, peerId) {
|
|
const spent = new Set(state.spentJti)
|
|
const res = verifyCapability(getMacKey(), token, {
|
|
peerId,
|
|
allowSpentCheck: (jti) => {
|
|
// Already-registered peers may reconnect with same grant
|
|
if (spent.has(jti) && getPeerEntry(peerId)?.role) return true
|
|
return !spent.has(jti)
|
|
},
|
|
})
|
|
if (!res.ok) {
|
|
const err = new Error(res.error)
|
|
err.code = res.code
|
|
throw err
|
|
}
|
|
|
|
const jti = res.payload.jti
|
|
const existing = getPeerEntry(peerId)
|
|
const reconnected = Boolean(existing?.role) && spent.has(jti)
|
|
|
|
if (!spent.has(jti)) {
|
|
state.spentJti.push(jti)
|
|
if (state.spentJti.length > 5000) state.spentJti = state.spentJti.slice(-4000)
|
|
}
|
|
|
|
registerPeer(peerId, { role: res.payload.role })
|
|
save()
|
|
return { role: res.payload.role, reconnected }
|
|
}
|
|
|
|
export function listPolicyPeers() {
|
|
return Object.entries(state.peers).map(([id, p]) => ({
|
|
peerId: id,
|
|
...p,
|
|
revoked: isPeerRevoked(id),
|
|
}))
|
|
}
|