forked from snxraven/peardock
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.
158 lines
4.9 KiB
JavaScript
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))
|
|
})
|