Stop AutoPass invites from silently connecting as viewer.
Release rolling / release (push) Successful in 8m40s

Force fresh capability auth after pairing, heal active grants stuck on the spent list, and fail closed when a grant is spent instead of falling back to bare viewer.
This commit is contained in:
Raven Scott
2026-07-14 21:41:26 -04:00
parent 0e07074291
commit b76d5cc02c
5 changed files with 131 additions and 43 deletions
+42 -11
View File
@@ -311,6 +311,8 @@ export function mintCapability(opts = {}) {
})
const policy = loadPolicy()
// Never leave a freshly minted jti on the spent list (corrupt state after bad deletes)
policy.spentJtis = (policy.spentJtis || []).filter((j) => j !== payload.jti)
policy.capabilities[payload.jti] = {
jti: payload.jti,
role: payload.role,
@@ -361,31 +363,41 @@ export function getPeerEntry(peerIdHex) {
export function redeemCapability(token, peerIdHex) {
const id = (peerIdHex || '').toLowerCase()
const policy = loadPolicy()
const spent = new Set(policy.spentJtis)
let spent = new Set(policy.spentJtis)
const existing = policy.peers[id] || null
const res = verifyCapability(getMacKey(), token, {
peerId: id,
allowSpentCheck: (jti) => {
// Reconnect of an already-registered peer always allowed
if (existing?.role) return true
if (spent.has(jti)) return false
// Reconnect of an already-registered elevated peer always allowed
if (existing?.role && existing.role !== 'viewer') return true
const meta = policy.capabilities[jti]
if (!meta) {
// Untracked but valid HMAC — allow (persistent default); not auto-spent
// Active unlimited (or not-yet-exhausted) grant must not be blocked by a stale spent list
if (meta) {
if (meta.exp != null && meta.exp < Date.now()) return false
if (meta.maxUses > 0 && meta.uses >= meta.maxUses) return false
// Heal: grant still active in policy but jti was marked spent (e.g. bad delete)
if (spent.has(jti)) {
spent.delete(jti)
policy.spentJtis = Array.from(spent)
savePolicy(policy)
logger.info('Healed spent jti still present as active capability', {
jti: String(jti).slice(0, 8),
})
}
return true
}
// maxUses === 0 → unlimited
if (meta.maxUses > 0 && meta.uses >= meta.maxUses) return false
if (meta.exp != null && meta.exp < Date.now()) return false
if (spent.has(jti)) return false
// Untracked but valid HMAC — allow (persistent default); not auto-spent
return true
},
})
if (!res.ok) {
// Soft reconnect: registered peer keeps role even if grant later revoked as spent/expired
// Soft reconnect: registered elevated peer keeps role even if grant later revoked
if (
existing?.role &&
existing.role !== 'viewer' &&
(res.code === 'CAPABILITY_SPENT' || res.code === 'CAPABILITY_EXPIRED')
) {
return {
@@ -397,8 +409,27 @@ export function redeemCapability(token, peerIdHex) {
reconnected: true,
}
}
const err = new Error(res.error || 'Invalid capability')
const err = new Error(
res.error ||
'Invalid capability' +
(res.code === 'CAPABILITY_SPENT'
? ' (grant was deleted or replaced — request a new AutoPass invite)'
: '')
)
err.code = res.code || 'CAPABILITY_INVALID'
// Best-effort jti for logs (payload may be unreadable if MAC failed)
try {
const body = token?.split?.('.')?.[0]
if (body) {
const json = JSON.parse(
Buffer.from(body.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')
)
err.jti = json?.jti || null
err.grantRole = json?.role || null
}
} catch {
// ignore
}
throw err
}