Stop AutoPass invites from silently connecting as viewer.
Release rolling / release (push) Successful in 8m40s
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:
+42
-11
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user