Stop AutoPass invites from silently connecting as viewer.

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
+31 -11
View File
@@ -8184,6 +8184,7 @@ async function addConnection(input, meta = {}) {
let alias = meta.alias || null; let alias = meta.alias || null;
// AutoPass invite path: pair → package → dial with capability // AutoPass invite path: pair → package → dial with capability
let fromAutopass = false;
if (raw && !/^[0-9a-f]{64}$/i.test(raw)) { if (raw && !/^[0-9a-f]{64}$/i.test(raw)) {
try { try {
if (!meta.quiet) { if (!meta.quiet) {
@@ -8200,11 +8201,22 @@ async function addConnection(input, meta = {}) {
} }
}, },
}); });
publicKeyHex = pkg.publicKeyHex; publicKeyHex = String(pkg.publicKeyHex || '').toLowerCase();
capability = pkg.capability; capability = String(pkg.capability || '');
alias = alias || pkg.alias || null; alias = alias || pkg.alias || null;
inviteToken = null; // never mix AutoPass z32 into inviteToken
fromAutopass = true;
if (!capability || !capability.includes('.')) {
hideStatusIndicator();
showAlert(
'danger',
'AutoPass package missing capability grant. Ask the admin to create a new invite.'
);
return;
}
if (!meta.quiet) { if (!meta.quiet) {
showAlert('success', 'AutoPass paired — connecting with capability grant…'); const roleHint = pkg.role ? ` (grant role: ${pkg.role})` : '';
showAlert('success', `AutoPass paired${roleHint} — connecting with capability…`);
} }
} catch (err) { } catch (err) {
console.error('[ERROR] AutoPass pair failed', err); console.error('[ERROR] AutoPass pair failed', err);
@@ -8236,8 +8248,11 @@ async function addConnection(input, meta = {}) {
// Keep welcome visible until HyperDHT + RPC are actually connected // Keep welcome visible until HyperDHT + RPC are actually connected
const topicId = publicKeyHex.substring(0, 12); const topicId = publicKeyHex.substring(0, 12);
alias = alias || connections[topicId]?.alias || null; alias = alias || connections[topicId]?.alias || null;
capability = capability || connections[topicId]?.capability || null; // After AutoPass pair: never fall back to a stale spent capability for this server
inviteToken = inviteToken || connections[topicId]?.inviteToken || null; if (!fromAutopass) {
capability = capability || connections[topicId]?.capability || null;
inviteToken = inviteToken || connections[topicId]?.inviteToken || null;
}
// Restore saved admin seed for auto-reconnect as admin // Restore saved admin seed for auto-reconnect as admin
adminSeed = adminSeed || connections[topicId]?.adminSeed || null; adminSeed = adminSeed || connections[topicId]?.adminSeed || null;
if (adminSeed && !/^[0-9a-f]{64}$/i.test(String(adminSeed))) adminSeed = null; if (adminSeed && !/^[0-9a-f]{64}$/i.test(String(adminSeed))) adminSeed = null;
@@ -8245,8 +8260,8 @@ async function addConnection(input, meta = {}) {
// Boot restore dials every peer without flipping active / workspace // Boot restore dials every peer without flipping active / workspace
const skipActivate = meta.skipActivate === true || meta.restore === true; const skipActivate = meta.skipActivate === true || meta.restore === true;
// Already live — activate only when this is an intentional select // Already live — activate only when this is an intentional select (not a fresh invite pair)
if (connections[topicId]?.peer?.connected) { if (connections[topicId]?.peer?.connected && !fromAutopass) {
if (!skipActivate) { if (!skipActivate) {
manager.setActive(connections[topicId].peer.id || topicId); manager.setActive(connections[topicId].peer.id || topicId);
switchConnection(topicId); switchConnection(topicId);
@@ -8261,8 +8276,8 @@ async function addConnection(input, meta = {}) {
topicHex: publicKeyHex, topicHex: publicKeyHex,
peer: null, peer: null,
alias, alias,
inviteToken, inviteToken: fromAutopass ? null : inviteToken,
capability, capability: capability || null,
adminSeed: adminSeed || connections[topicId]?.adminSeed || null, adminSeed: adminSeed || connections[topicId]?.adminSeed || null,
connectedAt: null, connectedAt: null,
lastHealthCheck: null, lastHealthCheck: null,
@@ -8285,13 +8300,18 @@ async function addConnection(input, meta = {}) {
if (!skipActivate) refreshContainerStats(); if (!skipActivate) refreshContainerStats();
try { try {
if (!meta.quiet) showStatusIndicator(adminSeed ? 'Connecting as admin…' : 'Connecting…'); if (!meta.quiet) {
showStatusIndicator(
adminSeed ? 'Connecting as admin…' : capability ? 'Connecting with invite grant…' : 'Connecting…'
);
}
const conn = await manager.connect(publicKeyHex, { const conn = await manager.connect(publicKeyHex, {
inviteToken: inviteToken || undefined, inviteToken: fromAutopass ? undefined : inviteToken || undefined,
capability: capability || undefined, capability: capability || undefined,
adminSeed: adminSeed || undefined, adminSeed: adminSeed || undefined,
alias: alias || undefined, alias: alias || undefined,
setActive: !skipActivate, setActive: !skipActivate,
forceAuth: fromAutopass,
}); });
connections[topicId].peer = conn; connections[topicId].peer = conn;
connections[topicId].connectedAt = Date.now(); connections[topicId].connectedAt = Date.now();
+18 -11
View File
@@ -178,7 +178,8 @@ export class PearDockConnection extends EventEmitter {
/already used or revoked|Capability expired/i.test(msg)) /already used or revoked|Capability expired/i.test(msg))
if (softSpent) { if (softSpent) {
const savedCap = this.capability // Only retry without capability when we might already be a registered elevated peer.
// Never "succeed" as a plain viewer after a failed invite grant.
this.capability = null this.capability = null
this.inviteToken = null this.inviteToken = null
this.emit('capability-invalidated', { code, publicKeyHex: this.publicKeyHex }) this.emit('capability-invalidated', { code, publicKeyHex: this.publicKeyHex })
@@ -195,20 +196,26 @@ export class PearDockConnection extends EventEmitter {
try { try {
hs = await this.request(Methods.handshake, retryArgs) hs = await this.request(Methods.handshake, retryArgs)
} catch (retryErr) { } catch (retryErr) {
// Restore so caller can inspect; still fail const e = new Error(
this.capability = savedCap msg ||
throw retryErr 'Invite grant was revoked or replaced. Ask an admin for a new AutoPass invite and paste the full string (not a saved public-key peer).'
)
e.code = code || 'CAPABILITY_SPENT'
e.cause = retryErr
throw e
} }
// If we only got viewer after dropping the grant, the invite did not register us
const retryRole = hs?.role || 'viewer' const retryRole = hs?.role || 'viewer'
const retryMode = hs?.auth?.mode || 'viewer' const retryMode = hs?.auth?.mode || 'viewer'
if ( const elevatedOk =
retryRole === 'viewer' && this.adminSeed ||
retryMode !== 'seed' && retryMode === 'seed' ||
!this.adminSeed retryMode === 'registered' ||
) { retryMode === 'capability' ||
(retryRole && retryRole !== 'viewer')
if (!elevatedOk) {
const e = new Error( const e = new Error(
'Invite grant was revoked or replaced, and this client is not a registered elevated peer. Ask an admin for a new AutoPass invite and paste the full string.' 'Invite grant was revoked or replaced, and this client is not registered as operator/admin. ' +
'Delete the peer from the client list, ask for a new AutoPass invite, and paste the full invite string.'
) )
e.code = code || 'CAPABILITY_SPENT' e.code = code || 'CAPABILITY_SPENT'
throw e throw e
+25 -7
View File
@@ -60,13 +60,19 @@ export class ConnectionManager extends EventEmitter {
const id = key.slice(0, 12) const id = key.slice(0, 12)
this._clearReconnectTimer(id) this._clearReconnectTimer(id)
// forceAuth: after AutoPass pair, replace (never merge) invite/capability for this peer
const forceAuth = meta.forceAuth === true
if (!meta.skipReconnectReset) { if (!meta.skipReconnectReset) {
const prev = this._reconnect.get(id) const prev = this._reconnect.get(id)
this._reconnect.set(id, { this._reconnect.set(id, {
publicKeyHex: key, publicKeyHex: key,
alias: meta.alias ?? prev?.alias ?? null, alias: meta.alias ?? prev?.alias ?? null,
inviteToken: meta.inviteToken ?? prev?.inviteToken ?? null, inviteToken: forceAuth
capability: meta.capability ?? prev?.capability ?? null, ? meta.inviteToken || null
: (meta.inviteToken ?? prev?.inviteToken ?? null),
capability: forceAuth
? meta.capability || null
: (meta.capability ?? prev?.capability ?? null),
// adminSeed persisted in peer cache for auto-reconnect as admin // adminSeed persisted in peer cache for auto-reconnect as admin
adminSeed: meta.adminSeed ?? prev?.adminSeed ?? null, adminSeed: meta.adminSeed ?? prev?.adminSeed ?? null,
attempts: 0, attempts: 0,
@@ -85,8 +91,13 @@ export class ConnectionManager extends EventEmitter {
intentional: false, intentional: false,
} }
if (meta.alias) entry.alias = meta.alias if (meta.alias) entry.alias = meta.alias
if (meta.inviteToken) entry.inviteToken = meta.inviteToken if (forceAuth) {
if (meta.capability) entry.capability = meta.capability entry.inviteToken = meta.inviteToken || null
entry.capability = meta.capability || null
} else {
if (meta.inviteToken) entry.inviteToken = meta.inviteToken
if (meta.capability) entry.capability = meta.capability
}
if (meta.adminSeed) entry.adminSeed = meta.adminSeed if (meta.adminSeed) entry.adminSeed = meta.adminSeed
this._reconnect.set(id, entry) this._reconnect.set(id, entry)
} }
@@ -96,18 +107,25 @@ export class ConnectionManager extends EventEmitter {
if (this.connections.has(id)) { if (this.connections.has(id)) {
const existing = this.connections.get(id) const existing = this.connections.get(id)
if (existing.connected) { // After a fresh AutoPass pair we must re-handshake with the new capability
if (existing.connected && !forceAuth) {
if (shouldActivate) this.setActive(id) if (shouldActivate) this.setActive(id)
return existing return existing
} }
if (recon) recon.intentional = true
await existing.close().catch(() => {}) await existing.close().catch(() => {})
this.connections.delete(id) this.connections.delete(id)
if (recon) recon.intentional = false
} }
const conn = new PearDockConnection(key, { const conn = new PearDockConnection(key, {
timeoutMs: CONFIG.CONNECTION.TIMEOUT_MS, timeoutMs: CONFIG.CONNECTION.TIMEOUT_MS,
inviteToken: meta.inviteToken || recon?.inviteToken || undefined, inviteToken: forceAuth
capability: meta.capability || recon?.capability || undefined, ? meta.inviteToken || undefined
: meta.inviteToken || recon?.inviteToken || undefined,
capability: forceAuth
? meta.capability || undefined
: meta.capability || recon?.capability || undefined,
adminSeed: meta.adminSeed || recon?.adminSeed || undefined, adminSeed: meta.adminSeed || recon?.adminSeed || undefined,
}) })
if (meta.alias) conn.alias = meta.alias if (meta.alias) conn.alias = meta.alias
+42 -11
View File
@@ -311,6 +311,8 @@ export function mintCapability(opts = {}) {
}) })
const policy = loadPolicy() 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] = { policy.capabilities[payload.jti] = {
jti: payload.jti, jti: payload.jti,
role: payload.role, role: payload.role,
@@ -361,31 +363,41 @@ export function getPeerEntry(peerIdHex) {
export function redeemCapability(token, peerIdHex) { export function redeemCapability(token, peerIdHex) {
const id = (peerIdHex || '').toLowerCase() const id = (peerIdHex || '').toLowerCase()
const policy = loadPolicy() const policy = loadPolicy()
const spent = new Set(policy.spentJtis) let spent = new Set(policy.spentJtis)
const existing = policy.peers[id] || null const existing = policy.peers[id] || null
const res = verifyCapability(getMacKey(), token, { const res = verifyCapability(getMacKey(), token, {
peerId: id, peerId: id,
allowSpentCheck: (jti) => { allowSpentCheck: (jti) => {
// Reconnect of an already-registered peer always allowed // Reconnect of an already-registered elevated peer always allowed
if (existing?.role) return true if (existing?.role && existing.role !== 'viewer') return true
if (spent.has(jti)) return false
const meta = policy.capabilities[jti] const meta = policy.capabilities[jti]
if (!meta) { // Active unlimited (or not-yet-exhausted) grant must not be blocked by a stale spent list
// Untracked but valid HMAC — allow (persistent default); not auto-spent 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 return true
} }
// maxUses === 0 → unlimited if (spent.has(jti)) return false
if (meta.maxUses > 0 && meta.uses >= meta.maxUses) return false // Untracked but valid HMAC — allow (persistent default); not auto-spent
if (meta.exp != null && meta.exp < Date.now()) return false
return true return true
}, },
}) })
if (!res.ok) { 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 ( if (
existing?.role && existing?.role &&
existing.role !== 'viewer' &&
(res.code === 'CAPABILITY_SPENT' || res.code === 'CAPABILITY_EXPIRED') (res.code === 'CAPABILITY_SPENT' || res.code === 'CAPABILITY_EXPIRED')
) { ) {
return { return {
@@ -397,8 +409,27 @@ export function redeemCapability(token, peerIdHex) {
reconnected: true, 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' 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 throw err
} }
+15 -3
View File
@@ -251,12 +251,24 @@ export function registerHandshake(session) {
if (registered?.role && registered.role !== Roles.viewer) { if (registered?.role && registered.role !== Roles.viewer) {
elevatedRole = registered.role elevatedRole = registered.role
authMode = 'registered' authMode = 'registered'
} else if (registered?.role === Roles.viewer) { logger.info('Capability failed; using registered peer role', {
elevatedRole = Roles.viewer peerId: session.id.slice(0, 12),
authMode = 'registered' role: registered.role,
code: err.code,
jti: err.jti ? String(err.jti).slice(0, 8) : null,
})
} else { } else {
// Do NOT fall through to bare viewer when a capability was presented and failed —
// that silently grants "success" without the invite role.
const e = new Error(err.message || 'Capability redeem failed') const e = new Error(err.message || 'Capability redeem failed')
e.code = err.code || 'CAPABILITY_INVALID' e.code = err.code || 'CAPABILITY_INVALID'
logger.warn('Capability redeem failed', {
peerId: session.id.slice(0, 12),
code: e.code,
jti: err.jti ? String(err.jti).slice(0, 8) : null,
grantRole: err.grantRole || null,
registered: registered?.role || null,
})
audit({ audit({
method: 'handshake', method: 'handshake',
peerId: session.id, peerId: session.id,