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:
+22
-7
@@ -1,10 +1,10 @@
|
||||
import test from 'brittle'
|
||||
import { Roles, roleAllows, MethodRoles, Methods, PROTOCOL_VERSION } from '../shared/protocol.js'
|
||||
import { resolveRole, assertAllowed } from '../server/core/acl.js'
|
||||
import { resolveRole, assertAllowed, maxRole } from '../server/core/acl.js'
|
||||
|
||||
test('PROTOCOL_VERSION is frozen semver integer', (t) => {
|
||||
test('PROTOCOL_VERSION is v3 for HMAC auth', (t) => {
|
||||
t.is(typeof PROTOCOL_VERSION, 'number')
|
||||
t.ok(PROTOCOL_VERSION >= 2)
|
||||
t.ok(PROTOCOL_VERSION >= 3)
|
||||
})
|
||||
|
||||
test('viewer can list but not remove', (t) => {
|
||||
@@ -59,10 +59,25 @@ test('assertAllowed throws PERMISSION_DENIED', (t) => {
|
||||
}
|
||||
})
|
||||
|
||||
test('resolveRole defaults to admin without env policy', (t) => {
|
||||
// Without PEARDOCK_ADMIN_KEYS / DEFAULT_ROLE override in this process
|
||||
const role = resolveRole('abc123')
|
||||
t.ok([Roles.admin, Roles.operator, Roles.viewer].includes(role))
|
||||
test('resolveRole defaults to viewer without env policy', (t) => {
|
||||
const prevAdmin = process.env.PEARDOCK_ADMIN_KEYS
|
||||
const prevInsecure = process.env.PEARDOCK_INSECURE_OPEN_ADMIN
|
||||
delete process.env.PEARDOCK_ADMIN_KEYS
|
||||
delete process.env.PEARDOCK_INSECURE_OPEN_ADMIN
|
||||
// DEFAULT_ROLE is captured at module load (default viewer). Without admin keys / insecure open,
|
||||
// a random peer must not become admin.
|
||||
const role = resolveRole('abc123' + '0'.repeat(58))
|
||||
t.is(role, Roles.viewer)
|
||||
if (prevAdmin !== undefined) process.env.PEARDOCK_ADMIN_KEYS = prevAdmin
|
||||
else delete process.env.PEARDOCK_ADMIN_KEYS
|
||||
if (prevInsecure !== undefined) process.env.PEARDOCK_INSECURE_OPEN_ADMIN = prevInsecure
|
||||
else delete process.env.PEARDOCK_INSECURE_OPEN_ADMIN
|
||||
})
|
||||
|
||||
test('maxRole elevates correctly', (t) => {
|
||||
t.is(maxRole(Roles.viewer, Roles.operator), Roles.operator)
|
||||
t.is(maxRole(Roles.admin, Roles.viewer), Roles.admin)
|
||||
t.is(maxRole(Roles.operator, Roles.viewer), Roles.operator)
|
||||
})
|
||||
|
||||
test('every Methods entry has MethodRoles mapping', (t) => {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import test from 'brittle'
|
||||
import crypto from 'crypto'
|
||||
import {
|
||||
deriveMacKey,
|
||||
signCapability,
|
||||
verifyCapability,
|
||||
createAdminProof,
|
||||
verifyAdminProof,
|
||||
classifyConnectionInput,
|
||||
safeEqual,
|
||||
} from '../shared/crypto-auth.js'
|
||||
import { Roles } from '../shared/protocol.js'
|
||||
|
||||
const SEED = 'ab'.repeat(32)
|
||||
const SEED2 = 'cd'.repeat(32)
|
||||
const PEER = '11'.repeat(32)
|
||||
const SERVER_PK = '22'.repeat(32)
|
||||
|
||||
test('deriveMacKey is deterministic and differs by seed', (t) => {
|
||||
const a = deriveMacKey(SEED)
|
||||
const b = deriveMacKey(SEED)
|
||||
const c = deriveMacKey(SEED2)
|
||||
t.is(a.length, 32)
|
||||
t.ok(safeEqual(a, b))
|
||||
t.absent(safeEqual(a, c))
|
||||
})
|
||||
|
||||
test('sign and verify capability', (t) => {
|
||||
const { token, payload } = signCapability(SEED, {
|
||||
role: Roles.operator,
|
||||
ttlMs: 3600_000,
|
||||
})
|
||||
t.ok(token.includes('.'))
|
||||
t.is(payload.role, Roles.operator)
|
||||
const res = verifyCapability(SEED, token, { peerId: PEER })
|
||||
t.ok(res.ok)
|
||||
t.is(res.payload.role, Roles.operator)
|
||||
t.ok(res.payload.jti)
|
||||
})
|
||||
|
||||
test('tampered capability fails', (t) => {
|
||||
const { token } = signCapability(SEED, { role: Roles.admin, ttlMs: 3600_000 })
|
||||
const [body, mac] = token.split('.')
|
||||
// Flip last char of body (base64url)
|
||||
const flipped =
|
||||
body.slice(0, -1) + (body.endsWith('A') ? 'B' : 'A') + '.' + mac
|
||||
const res = verifyCapability(SEED, flipped)
|
||||
t.absent(res.ok)
|
||||
t.is(res.code, 'CAPABILITY_INVALID')
|
||||
})
|
||||
|
||||
test('wrong seed fails verify', (t) => {
|
||||
const { token } = signCapability(SEED, { role: Roles.operator, ttlMs: 3600_000 })
|
||||
const res = verifyCapability(SEED2, token)
|
||||
t.absent(res.ok)
|
||||
t.is(res.code, 'CAPABILITY_INVALID')
|
||||
})
|
||||
|
||||
test('expired capability fails', (t) => {
|
||||
const { token } = signCapability(SEED, { role: Roles.viewer, ttlMs: 60_000 })
|
||||
const res = verifyCapability(SEED, token, { now: Date.now() + 120_000 })
|
||||
t.absent(res.ok)
|
||||
t.is(res.code, 'CAPABILITY_EXPIRED')
|
||||
})
|
||||
|
||||
test('peer-bound capability mismatch', (t) => {
|
||||
const { token } = signCapability(SEED, {
|
||||
role: Roles.operator,
|
||||
ttlMs: 3600_000,
|
||||
peerId: PEER,
|
||||
})
|
||||
const bad = verifyCapability(SEED, token, { peerId: '33'.repeat(32) })
|
||||
t.absent(bad.ok)
|
||||
t.is(bad.code, 'CAPABILITY_PEER_MISMATCH')
|
||||
const good = verifyCapability(SEED, token, { peerId: PEER })
|
||||
t.ok(good.ok)
|
||||
})
|
||||
|
||||
test('spent jti check', (t) => {
|
||||
const { token, payload } = signCapability(SEED, { role: Roles.operator, ttlMs: 3600_000 })
|
||||
const spent = new Set([payload.jti])
|
||||
const res = verifyCapability(SEED, token, {
|
||||
allowSpentCheck: (jti) => !spent.has(jti),
|
||||
})
|
||||
t.absent(res.ok)
|
||||
t.is(res.code, 'CAPABILITY_SPENT')
|
||||
})
|
||||
|
||||
test('admin proof round-trip', (t) => {
|
||||
const proof = createAdminProof(SEED, {
|
||||
peerId: PEER,
|
||||
serverPublicKeyHex: SERVER_PK,
|
||||
})
|
||||
t.ok(proof.nonce)
|
||||
t.ok(proof.mac)
|
||||
const ok = verifyAdminProof(SEED, proof, {
|
||||
peerId: PEER,
|
||||
serverPublicKeyHex: SERVER_PK,
|
||||
})
|
||||
t.ok(ok.ok)
|
||||
const bad = verifyAdminProof(SEED2, proof, {
|
||||
peerId: PEER,
|
||||
serverPublicKeyHex: SERVER_PK,
|
||||
})
|
||||
t.absent(bad.ok)
|
||||
t.is(bad.code, 'ADMIN_PROOF_FAILED')
|
||||
})
|
||||
|
||||
test('classifyConnectionInput', (t) => {
|
||||
t.is(classifyConnectionInput(SERVER_PK), 'publicKey')
|
||||
t.is(classifyConnectionInput('aa'.repeat(24)), 'legacyInvite')
|
||||
t.is(classifyConnectionInput('ynr' + 'a'.repeat(100)), 'autopassInvite')
|
||||
t.is(classifyConnectionInput('not-valid'), 'unknown')
|
||||
t.is(classifyConnectionInput(''), 'unknown')
|
||||
})
|
||||
|
||||
test('safeEqual length mismatch', (t) => {
|
||||
t.absent(safeEqual(Buffer.from('ab'), Buffer.from('abc')))
|
||||
t.ok(safeEqual(crypto.randomBytes(0), Buffer.alloc(0)))
|
||||
})
|
||||
@@ -2,34 +2,74 @@ 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,
|
||||
revokePeer,
|
||||
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('invite redeem registers peer', (t) => {
|
||||
test('capability invite redeem registers peer', (t) => {
|
||||
const inv = createInvite({ role: 'operator', maxUses: 1, ttlHours: 1 })
|
||||
t.ok(inv.token.length >= 32)
|
||||
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))
|
||||
@@ -44,3 +84,12 @@ test('setPeerRole updates', (t) => {
|
||||
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
|
||||
})
|
||||
|
||||
@@ -3,20 +3,32 @@
|
||||
*/
|
||||
import test from 'brittle'
|
||||
import DHT from 'hyperdht'
|
||||
import b4a from 'b4a'
|
||||
import createTestnet from 'hyperdht/testnet.js'
|
||||
import { PeerSession } from '../server/rpc/session.js'
|
||||
import { registerAllHandlers } from '../server/rpc/register.js'
|
||||
import { Methods } from '../shared/protocol.js'
|
||||
import { Methods, Roles } from '../shared/protocol.js'
|
||||
import { initAuthKeys } from '../server/core/auth-keys.js'
|
||||
import { createAdminProof } from '../shared/crypto-auth.js'
|
||||
import { getClientIdentity } from '../client/identity.js'
|
||||
|
||||
// Allow browse in tests (production default is deny without PEARDOCK_BROWSE_ROOTS)
|
||||
process.env.PEARDOCK_BROWSE_ROOTS = process.env.PEARDOCK_BROWSE_ROOTS || '/'
|
||||
|
||||
const SEED = 'ab'.repeat(32)
|
||||
|
||||
test('PearDockConnection + PeerSession ping round-trip', async (t) => {
|
||||
const testnet = await createTestnet()
|
||||
const { bootstrap } = testnet
|
||||
|
||||
const serverDht = new DHT({ bootstrap })
|
||||
const keyPair = DHT.keyPair()
|
||||
// Deterministic seed for admin proof verification
|
||||
const seedBuf = b4a.from(SEED, 'hex')
|
||||
const keyPair = DHT.keyPair(seedBuf)
|
||||
initAuthKeys({
|
||||
seedHex: SEED,
|
||||
publicKeyHex: b4a.toString(keyPair.publicKey, 'hex'),
|
||||
})
|
||||
const server = serverDht.createServer()
|
||||
|
||||
server.on('connection', (socket) => {
|
||||
@@ -29,9 +41,8 @@ test('PearDockConnection + PeerSession ping round-trip', async (t) => {
|
||||
await server.listen(keyPair)
|
||||
const publicKeyHex = Buffer.from(keyPair.publicKey).toString('hex')
|
||||
|
||||
// Inject bootstrap into HyperDHT by temporarily using a custom connect path:
|
||||
// PearDockConnection creates its own DHT; for testnet we connect manually.
|
||||
const clientDht = new DHT({ bootstrap })
|
||||
const clientIdentity = getClientIdentity()
|
||||
const clientDht = new DHT({ bootstrap, keyPair: clientIdentity.keyPair })
|
||||
const socket = clientDht.connect(keyPair.publicKey)
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('timeout')), 15000)
|
||||
@@ -47,9 +58,6 @@ test('PearDockConnection + PeerSession ping round-trip', async (t) => {
|
||||
})
|
||||
})
|
||||
|
||||
// Use raw protomux via a temporary connection object by reusing PearDockConnection
|
||||
// after monkey-patching connect to use testnet DHT is heavy — call session methods via
|
||||
// a lightweight client instead:
|
||||
const { default: ProtomuxRPC } = await import('protomux-rpc')
|
||||
const { PROTOCOL } = await import('../shared/protocol.js')
|
||||
const { encodings } = await import('../shared/encodings.js')
|
||||
@@ -60,11 +68,42 @@ test('PearDockConnection + PeerSession ping round-trip', async (t) => {
|
||||
...encodings,
|
||||
})
|
||||
|
||||
// Viewer without proof: ping ok, mutate denied
|
||||
const hsViewer = await rpc.request(
|
||||
Methods.handshake,
|
||||
{ clientName: 'test', clientVersion: '0' },
|
||||
encodings
|
||||
)
|
||||
t.is(hsViewer.role, Roles.viewer)
|
||||
|
||||
const pong = await rpc.request(Methods.ping, {}, encodings)
|
||||
t.ok(pong.success)
|
||||
t.ok(pong.pong)
|
||||
|
||||
// browseDirectory should work without Docker
|
||||
try {
|
||||
await rpc.request(Methods.browseDirectory, { path: '/' }, encodings)
|
||||
t.fail('viewer should not browse')
|
||||
} catch (err) {
|
||||
t.ok(
|
||||
err.code === 'PERMISSION_DENIED' ||
|
||||
err.cause?.code === 'PERMISSION_DENIED' ||
|
||||
/Permission denied/i.test(err.message + (err.cause?.message || ''))
|
||||
)
|
||||
}
|
||||
|
||||
// Admin via seed HMAC proof
|
||||
const adminProof = createAdminProof(SEED, {
|
||||
peerId: clientIdentity.publicKeyHex,
|
||||
serverPublicKeyHex: publicKeyHex,
|
||||
})
|
||||
const hsAdmin = await rpc.request(
|
||||
Methods.handshake,
|
||||
{ clientName: 'test', clientVersion: '0', adminProof },
|
||||
encodings
|
||||
)
|
||||
t.is(hsAdmin.role, Roles.admin)
|
||||
t.is(hsAdmin.auth?.mode, 'seed')
|
||||
|
||||
const browse = await rpc.request(Methods.browseDirectory, { path: '/' }, encodings)
|
||||
t.ok(browse.success)
|
||||
t.ok(Array.isArray(browse.contents))
|
||||
|
||||
Reference in New Issue
Block a user