forked from snxraven/peardock
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:
@@ -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;
|
||||||
|
// After AutoPass pair: never fall back to a stale spent capability for this server
|
||||||
|
if (!fromAutopass) {
|
||||||
capability = capability || connections[topicId]?.capability || null;
|
capability = capability || connections[topicId]?.capability || null;
|
||||||
inviteToken = inviteToken || connections[topicId]?.inviteToken || 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
@@ -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
|
||||||
|
|||||||
+23
-5
@@ -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 (forceAuth) {
|
||||||
|
entry.inviteToken = meta.inviteToken || null
|
||||||
|
entry.capability = meta.capability || null
|
||||||
|
} else {
|
||||||
if (meta.inviteToken) entry.inviteToken = meta.inviteToken
|
if (meta.inviteToken) entry.inviteToken = meta.inviteToken
|
||||||
if (meta.capability) entry.capability = meta.capability
|
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
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user