Files
peardock/test/peer-policy.test.js
T
Raven Scott 660d901386 Fix invite rotation after delete and add revoke list management.
Force-wipe AutoPass invite packages on create/delete so spent capabilities cannot poison new invites, clear stale grants on the client, and let admins unrevoke or clear revoked peers from Access.
2026-07-14 21:11:48 -04:00

158 lines
4.9 KiB
JavaScript

import test from 'brittle'
import fs from 'fs'
import path from 'path'
import os from 'os'
import crypto from 'crypto'
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'peardock-peers-'))
process.env.PEARDOCK_PEER_POLICY = path.join(tmp, 'peers.json')
delete process.env.PEARDOCK_PEER_ALLOWLIST
delete process.env.PEARDOCK_LEGACY_INVITES
// Capability minting requires a seed-derived MAC key
process.env.SERVER_SEED = crypto.randomBytes(32).toString('hex')
const {
createInvite,
redeemInvite,
redeemCapability,
mintCapability,
deleteInvite,
revokePeer,
unrevokePeer,
clearRevokedPeers,
listRevokedPeers,
isPeerAllowed,
isPeerRevoked,
listPeers,
listInvites,
setPeerRole,
registerPeer,
} = await import('../server/core/peer-policy.js')
const { initAuthKeys } = await import('../server/core/auth-keys.js')
initAuthKeys({
seedHex: process.env.SERVER_SEED,
publicKeyHex: 'aa'.repeat(32),
})
const peerA = '11'.repeat(32)
const peerB = '22'.repeat(32)
test('capability invite redeem registers peer', (t) => {
const inv = createInvite({ role: 'operator', maxUses: 1, ttlHours: 1 })
t.ok(inv.token.includes('.'), 'capability tokens are body.mac')
t.is(inv.kind, 'capability')
const entry = redeemInvite(inv.token, peerA)
t.is(entry.role, 'operator')
t.ok(isPeerAllowed(peerA))
t.exception(() => redeemInvite(inv.token, peerB))
})
test('mintCapability + redeemCapability elevates role', (t) => {
const peerC = '33'.repeat(32)
const cap = mintCapability({ role: 'admin', maxUses: 2, ttlHours: 1 })
t.ok(cap.capability.includes('.'))
t.ok(cap.jti)
const { role, jti } = redeemCapability(cap.capability, peerC)
t.is(role, 'admin')
t.is(jti, cap.jti)
t.ok(listPeers().peers.some((p) => p.peerId === peerC && p.role === 'admin'))
})
test('tampered capability is rejected', (t) => {
const cap = mintCapability({ role: 'operator', ttlHours: 1 })
const [body, mac] = cap.capability.split('.')
const flipped = body.slice(0, -1) + (body.endsWith('A') ? 'B' : 'A') + '.' + mac
t.exception(() => redeemCapability(flipped, peerA))
})
test('listInvites includes unspent capabilities', (t) => {
const before = listInvites().length
mintCapability({ role: 'viewer', maxUses: 3, ttlHours: 1, note: 'list-test' })
const after = listInvites()
t.ok(after.length >= before)
t.ok(after.some((i) => i.kind === 'capability' || i.note === 'list-test' || i.jti))
})
test('revoke denies peer', (t) => {
registerPeer(peerB, { role: 'viewer' })
t.ok(isPeerAllowed(peerB))
revokePeer(peerB)
t.ok(isPeerRevoked(peerB))
t.not(isPeerAllowed(peerB))
t.ok(listRevokedPeers().includes(peerB))
})
test('unrevoke removes peer from revoke list', (t) => {
const peerF = '77'.repeat(32)
registerPeer(peerF, { role: 'operator' })
revokePeer(peerF)
t.ok(isPeerRevoked(peerF))
const res = unrevokePeer(peerF)
t.ok(res.success)
t.ok(res.removed)
t.absent(isPeerRevoked(peerF))
t.ok(isPeerAllowed(peerF))
t.absent(listRevokedPeers().includes(peerF))
})
test('clearRevokedPeers empties revoke list', (t) => {
const a = '88'.repeat(32)
const b = '99'.repeat(32)
revokePeer(a)
revokePeer(b)
t.ok(listRevokedPeers().length >= 2)
const res = clearRevokedPeers()
t.ok(res.success)
t.ok(res.cleared >= 2)
t.is(listRevokedPeers().length, 0)
})
test('setPeerRole updates', (t) => {
registerPeer(peerA, { role: 'viewer' })
const e = setPeerRole(peerA, 'admin')
t.is(e.role, 'admin')
t.ok(listPeers().peers.some((p) => p.peerId === peerA && p.role === 'admin'))
})
test('seed/capability auth modes bypass allowlist', (t) => {
process.env.PEARDOCK_PEER_ALLOWLIST = '1'
const unknown = '44'.repeat(32)
t.not(isPeerAllowed(unknown))
t.ok(isPeerAllowed(unknown, { authMode: 'seed' }))
t.ok(isPeerAllowed(unknown, { authMode: 'capability' }))
delete process.env.PEARDOCK_PEER_ALLOWLIST
})
test('deleteInvite revokes capability jti so redeem fails', (t) => {
const peerD = '55'.repeat(32)
const cap = mintCapability({ role: 'operator', maxUses: 5, ttlHours: 1 })
t.ok(listInvites().some((i) => i.jti === cap.jti))
const res = deleteInvite({ jti: cap.jti })
t.ok(res.success)
t.ok(res.deleted.length > 0)
t.absent(listInvites().some((i) => i.jti === cap.jti))
t.exception(() => redeemCapability(cap.capability, peerD))
})
test('persistent invite allows reconnect after first redeem', (t) => {
const peerE = '66'.repeat(32)
// Defaults: forever + unlimited uses
const cap = mintCapability({ role: 'operator' })
t.ok(cap.persistent)
t.is(cap.maxUses, 0)
t.is(cap.expiresAt, null)
const first = redeemCapability(cap.capability, peerE)
t.is(first.role, 'operator')
t.absent(first.reconnected)
// Second handshake with same capability + same peer identity (app restart)
const second = redeemCapability(cap.capability, peerE)
t.is(second.role, 'operator')
t.ok(second.reconnected)
// Still listed as active capability (not spent)
t.ok(listInvites().some((i) => i.jti === cap.jti))
})