Move away from Autopass for AlmaLInux Compatibility
Release rolling / release (push) Successful in 8m18s
Release rolling / release (push) Successful in 8m18s
This commit is contained in:
+1
-1
@@ -4,7 +4,7 @@
|
||||
* Secure default: every peer is viewer (read-only).
|
||||
* Elevate via:
|
||||
* - admin seed HMAC proof (handshake)
|
||||
* - HMAC capability grant (AutoPass / invite)
|
||||
* - HMAC capability grant (pd1. invite)
|
||||
* - PEARDOCK_ADMIN_KEYS peer allowlist
|
||||
* - peer policy registered role
|
||||
* - PEARDOCK_INSECURE_OPEN_ADMIN=1 (dev escape hatch → admin for all)
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
/**
|
||||
* Server-side AutoPass vault for distributing connection packages.
|
||||
*
|
||||
* Stores { publicKeyHex, capability, role, alias } — never SERVER_SEED.
|
||||
* Admins create AutoPass invites; operators pair and receive the package.
|
||||
*
|
||||
* Important: Autopass.createInvite() reuses an existing invite if one is still
|
||||
* in the view. After delete we must wipe invite + package, and on create we
|
||||
* always force-rotate so a new z32 string and new capability jti are issued.
|
||||
*/
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import Corestore from 'corestore'
|
||||
import logger from '../utils/logger.js'
|
||||
import { mintCapability } from './peer-policy.js'
|
||||
import { getServerPublicKeyHex } from './auth-keys.js'
|
||||
import { Roles } from '../../shared/protocol.js'
|
||||
import { encodePeardockInvite } from '../../shared/crypto-auth.js'
|
||||
|
||||
const log = logger.child('autopass')
|
||||
|
||||
const PKG_KEY = 'peardock:pkg'
|
||||
|
||||
/** @type {import('autopass')|null} */
|
||||
let pass = null
|
||||
/** @type {import('corestore')|null} */
|
||||
let store = null
|
||||
/** @type {Promise<any>|null} */
|
||||
let opening = null
|
||||
/** @type {{ invite: string, role: string, createdAt: string, expiresAt: string|null, jti: string, maxUses?: number, note?: string|null }|null} */
|
||||
let lastInviteMeta = null
|
||||
|
||||
function vaultDir() {
|
||||
return process.env.PEARDOCK_AUTOPASS_DIR || path.join(process.cwd(), 'peardock-autopass')
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-open Autopass instance.
|
||||
*/
|
||||
export async function getAutopass() {
|
||||
if (pass) return pass
|
||||
if (opening) return opening
|
||||
|
||||
opening = (async () => {
|
||||
let Autopass
|
||||
try {
|
||||
const mod = await import('autopass')
|
||||
Autopass = mod.default || mod
|
||||
} catch (err) {
|
||||
const e = new Error(
|
||||
`autopass package not available: ${err.message}. Run npm install autopass.`
|
||||
)
|
||||
e.code = 'AUTOPASS_UNAVAILABLE'
|
||||
throw e
|
||||
}
|
||||
|
||||
const dir = vaultDir()
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
||||
}
|
||||
|
||||
store = new Corestore(path.join(dir, 'store'))
|
||||
pass = new Autopass(store)
|
||||
await pass.ready()
|
||||
try {
|
||||
await restoreLastInviteMeta(pass)
|
||||
} catch (err) {
|
||||
log.debug('invite meta restore skipped', { error: err.message })
|
||||
}
|
||||
log.info('AutoPass vault ready', { dir })
|
||||
return pass
|
||||
})()
|
||||
|
||||
try {
|
||||
return await opening
|
||||
} catch (err) {
|
||||
opening = null
|
||||
pass = null
|
||||
store = null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe active Autopass invite + peardock package so the next create is clean.
|
||||
* @param {import('autopass')} vault
|
||||
*/
|
||||
async function wipeInviteAndPackage(vault) {
|
||||
try {
|
||||
await vault.deleteInvite()
|
||||
} catch (err) {
|
||||
log.debug('deleteInvite during wipe', { error: err.message })
|
||||
}
|
||||
try {
|
||||
if (typeof vault.remove === 'function') {
|
||||
await vault.remove(PKG_KEY)
|
||||
}
|
||||
} catch (err) {
|
||||
log.debug('remove package during wipe', { error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create connection invite: mint HMAC capability, write package, return z32 invite.
|
||||
* Always force-rotates the Autopass invite string (never reuses a deleted/stale one).
|
||||
* @param {{ role?: string, ttlHours?: number, maxUses?: number, note?: string, alias?: string, peerId?: string }} opts
|
||||
*/
|
||||
export async function createConnectionInvite(opts = {}) {
|
||||
const vault = await getAutopass()
|
||||
const publicKeyHex = getServerPublicKeyHex()
|
||||
if (!publicKeyHex || !/^[0-9a-f]{64}$/.test(publicKeyHex)) {
|
||||
throw new Error('Server public key not available for AutoPass package')
|
||||
}
|
||||
|
||||
if (vault.opened === false) await vault.ready()
|
||||
|
||||
// Critical: Autopass.createInvite() returns the *existing* invite if one remains.
|
||||
// After admin deletes an invite we must wipe before minting a new package + invite,
|
||||
// otherwise clients re-pair the same z32 and may read a spent capability package.
|
||||
await wipeInviteAndPackage(vault)
|
||||
lastInviteMeta = null
|
||||
|
||||
if (vault.member) {
|
||||
try {
|
||||
await vault.member.flushed()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const role = opts.role || Roles.operator
|
||||
const cap = mintCapability({
|
||||
role,
|
||||
ttlHours: opts.ttlHours,
|
||||
maxUses: opts.maxUses,
|
||||
note: opts.note,
|
||||
peerId: opts.peerId || null,
|
||||
})
|
||||
|
||||
const packageValue = JSON.stringify({
|
||||
v: 1,
|
||||
publicKeyHex,
|
||||
capability: cap.capability,
|
||||
role: cap.role,
|
||||
alias: opts.alias || null,
|
||||
note: opts.note || null,
|
||||
expiresAt: cap.expiresAt,
|
||||
jti: cap.jti,
|
||||
})
|
||||
|
||||
await vault.add(PKG_KEY, packageValue)
|
||||
|
||||
// Always readOnly — role is in the HMAC capability, not Autopass write rights.
|
||||
const invite = await vault.createInvite({ readOnly: true })
|
||||
|
||||
// Sanity: package must match what we just wrote (detect stale view issues early)
|
||||
try {
|
||||
const check = await vault.get(PKG_KEY)
|
||||
const raw = check?.value
|
||||
const pkg = typeof raw === 'string' ? JSON.parse(raw) : raw
|
||||
if (!pkg?.jti || pkg.jti !== cap.jti) {
|
||||
log.warn('Package jti mismatch after write — re-adding package', {
|
||||
expected: cap.jti?.slice(0, 8),
|
||||
got: pkg?.jti?.slice?.(0, 8),
|
||||
})
|
||||
await vault.add(PKG_KEY, packageValue)
|
||||
}
|
||||
} catch (err) {
|
||||
log.debug('package verify skipped', { error: err.message })
|
||||
}
|
||||
|
||||
// Share string embeds pubkey + capability so clients never depend on Autopass
|
||||
// HyperDB package sync (which was returning stale spent grants after invite rotate).
|
||||
const share = encodePeardockInvite({
|
||||
publicKeyHex,
|
||||
capability: cap.capability,
|
||||
role: cap.role,
|
||||
jti: cap.jti,
|
||||
alias: opts.alias || null,
|
||||
expiresAt: cap.expiresAt,
|
||||
autopass: invite,
|
||||
})
|
||||
|
||||
lastInviteMeta = {
|
||||
invite: share,
|
||||
autopassInvite: invite,
|
||||
role: cap.role,
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: cap.expiresAt,
|
||||
jti: cap.jti,
|
||||
maxUses: cap.maxUses,
|
||||
note: opts.note || null,
|
||||
capability: cap.capability,
|
||||
persistent: cap.persistent,
|
||||
publicKeyHex,
|
||||
}
|
||||
|
||||
log.info('Created AutoPass connection invite', {
|
||||
role: cap.role,
|
||||
jti: cap.jti.slice(0, 8),
|
||||
expiresAt: cap.expiresAt,
|
||||
maxUses: cap.maxUses,
|
||||
inviteLen: share?.length,
|
||||
autopassLen: invite?.length,
|
||||
format: 'pd1',
|
||||
})
|
||||
|
||||
return {
|
||||
kind: 'autopass',
|
||||
// Primary share string for operators (pd1 envelope)
|
||||
invite: share,
|
||||
token: share,
|
||||
share,
|
||||
autopassInvite: invite,
|
||||
role: cap.role,
|
||||
expiresAt: cap.expiresAt,
|
||||
maxUses: cap.maxUses,
|
||||
jti: cap.jti,
|
||||
note: opts.note || null,
|
||||
publicKeyHex,
|
||||
persistent: cap.persistent,
|
||||
capability: cap.capability,
|
||||
redeemHint:
|
||||
'Share this peardock invite string (starts with pd1.). Paste it in Add peer — it includes the public key and operator grant. Do not share SERVER_SEED.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List active invite metadata (no capability secret).
|
||||
*/
|
||||
export function listAutopassInvites() {
|
||||
if (!lastInviteMeta) return []
|
||||
if (
|
||||
lastInviteMeta.expiresAt &&
|
||||
new Date(lastInviteMeta.expiresAt).getTime() < Date.now()
|
||||
) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{
|
||||
kind: 'autopass',
|
||||
invite: lastInviteMeta.invite,
|
||||
role: lastInviteMeta.role,
|
||||
expiresAt: lastInviteMeta.expiresAt,
|
||||
jti: lastInviteMeta.jti,
|
||||
maxUses: lastInviteMeta.maxUses ?? 0,
|
||||
uses: 0,
|
||||
note: lastInviteMeta.note,
|
||||
createdAt: lastInviteMeta.createdAt,
|
||||
token: lastInviteMeta.invite,
|
||||
share: lastInviteMeta.invite,
|
||||
publicKeyHex: lastInviteMeta.publicKeyHex || null,
|
||||
persistent:
|
||||
lastInviteMeta.persistent ??
|
||||
(lastInviteMeta.expiresAt == null && (lastInviteMeta.maxUses ?? 0) === 0),
|
||||
capability: lastInviteMeta.capability || null,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete active AutoPass invite + package. Returns jti that should be spent in policy.
|
||||
* @returns {Promise<{ success: boolean, jti: string|null }>}
|
||||
*/
|
||||
export async function deleteAutopassInvite() {
|
||||
let jti = lastInviteMeta?.jti || null
|
||||
|
||||
// Prefer jti from live package if meta is stale
|
||||
if (pass) {
|
||||
try {
|
||||
const rec = await pass.get(PKG_KEY)
|
||||
if (rec?.value) {
|
||||
const pkg = typeof rec.value === 'string' ? JSON.parse(rec.value) : rec.value
|
||||
if (pkg?.jti) jti = pkg.jti
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
await wipeInviteAndPackage(pass)
|
||||
}
|
||||
|
||||
lastInviteMeta = null
|
||||
log.info('Deleted AutoPass invite', { jti: jti ? String(jti).slice(0, 8) : null })
|
||||
return { success: true, jti: jti && jti !== 'unknown' ? jti : null }
|
||||
}
|
||||
|
||||
export async function closeAutopassVault() {
|
||||
try {
|
||||
if (pass) await pass.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (store) await store.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
pass = null
|
||||
store = null
|
||||
opening = null
|
||||
lastInviteMeta = null
|
||||
}
|
||||
|
||||
export function isAutopassReady() {
|
||||
return Boolean(pass)
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort warm at server boot so BlindPairing is listening before clients redeem.
|
||||
*/
|
||||
export async function warmAutopass() {
|
||||
try {
|
||||
await getAutopass()
|
||||
return true
|
||||
} catch (err) {
|
||||
log.warn('AutoPass warm failed (invites unavailable until first create)', {
|
||||
error: err.message,
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('autopass')} vault
|
||||
*/
|
||||
async function restoreLastInviteMeta(vault) {
|
||||
if (lastInviteMeta) return
|
||||
let existing = null
|
||||
try {
|
||||
existing = await vault.base?.view?.findOne?.('@autopass/invite', {})
|
||||
} catch {
|
||||
existing = null
|
||||
}
|
||||
if (!existing) return
|
||||
|
||||
let jti = null
|
||||
let role = Roles.operator
|
||||
let expiresAt = null
|
||||
let maxUses = 0
|
||||
try {
|
||||
const rec = await vault.get(PKG_KEY)
|
||||
if (rec?.value) {
|
||||
const pkg = typeof rec.value === 'string' ? JSON.parse(rec.value) : rec.value
|
||||
jti = pkg.jti || null
|
||||
role = pkg.role || role
|
||||
expiresAt = pkg.expiresAt ?? null
|
||||
if (pkg.maxUses != null) maxUses = Number(pkg.maxUses) || 0
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
let inviteStr = null
|
||||
try {
|
||||
const z32 = (await import('z32')).default
|
||||
if (existing.invite) inviteStr = z32.encode(existing.invite)
|
||||
} catch {
|
||||
inviteStr = null
|
||||
}
|
||||
if (!inviteStr) return
|
||||
|
||||
lastInviteMeta = {
|
||||
invite: inviteStr,
|
||||
role,
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt,
|
||||
jti: jti || 'unknown',
|
||||
maxUses,
|
||||
note: 'restored-after-restart',
|
||||
}
|
||||
log.info('Restored active AutoPass invite metadata after restart', {
|
||||
jti: jti ? String(jti).slice(0, 8) : null,
|
||||
})
|
||||
}
|
||||
|
||||
export { PKG_KEY }
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Connection invites for peardock (no Autopass / no RocksDB).
|
||||
*
|
||||
* Share string is always:
|
||||
* pd1.<base64url JSON { publicKeyHex, capability, role, jti, ... }>
|
||||
*
|
||||
* Auth is HMAC capability verified at handshake (shared/crypto-auth.js).
|
||||
*/
|
||||
import logger from '../utils/logger.js'
|
||||
import { mintCapability, deleteInvite as policyDeleteInvite } from './peer-policy.js'
|
||||
import { getServerPublicKeyHex } from './auth-keys.js'
|
||||
import { Roles } from '../../shared/protocol.js'
|
||||
import { encodePeardockInvite, decodePeardockInvite } from '../../shared/crypto-auth.js'
|
||||
|
||||
const log = logger.child('invites')
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* invite: string,
|
||||
* role: string,
|
||||
* createdAt: string,
|
||||
* expiresAt: string|null,
|
||||
* jti: string,
|
||||
* maxUses: number,
|
||||
* note?: string|null,
|
||||
* capability: string,
|
||||
* persistent: boolean,
|
||||
* publicKeyHex: string,
|
||||
* }} InviteMeta
|
||||
*/
|
||||
|
||||
/** @type {InviteMeta|null} */
|
||||
let lastInviteMeta = null
|
||||
|
||||
/**
|
||||
* Create a pd1 connection invite (operator/admin/viewer grant).
|
||||
* @param {{ role?: string, ttlHours?: number, maxUses?: number, note?: string, alias?: string, peerId?: string }} opts
|
||||
*/
|
||||
export function createConnectionInvite(opts = {}) {
|
||||
const publicKeyHex = getServerPublicKeyHex()
|
||||
if (!publicKeyHex || !/^[0-9a-f]{64}$/.test(publicKeyHex)) {
|
||||
throw new Error('Server public key not available for invite package')
|
||||
}
|
||||
|
||||
const role = opts.role || Roles.operator
|
||||
const cap = mintCapability({
|
||||
role,
|
||||
ttlHours: opts.ttlHours,
|
||||
maxUses: opts.maxUses,
|
||||
note: opts.note,
|
||||
peerId: opts.peerId || null,
|
||||
})
|
||||
|
||||
const share = encodePeardockInvite({
|
||||
publicKeyHex,
|
||||
capability: cap.capability,
|
||||
role: cap.role,
|
||||
jti: cap.jti,
|
||||
alias: opts.alias || null,
|
||||
expiresAt: cap.expiresAt,
|
||||
})
|
||||
|
||||
lastInviteMeta = {
|
||||
invite: share,
|
||||
role: cap.role,
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: cap.expiresAt,
|
||||
jti: cap.jti,
|
||||
maxUses: cap.maxUses,
|
||||
note: opts.note || null,
|
||||
capability: cap.capability,
|
||||
persistent: cap.persistent,
|
||||
publicKeyHex,
|
||||
}
|
||||
|
||||
log.info('Created connection invite', {
|
||||
role: cap.role,
|
||||
jti: cap.jti.slice(0, 8),
|
||||
expiresAt: cap.expiresAt,
|
||||
maxUses: cap.maxUses,
|
||||
inviteLen: share.length,
|
||||
format: 'pd1',
|
||||
})
|
||||
|
||||
return {
|
||||
kind: 'pd1',
|
||||
invite: share,
|
||||
token: share,
|
||||
share,
|
||||
role: cap.role,
|
||||
expiresAt: cap.expiresAt,
|
||||
maxUses: cap.maxUses,
|
||||
jti: cap.jti,
|
||||
note: opts.note || null,
|
||||
publicKeyHex,
|
||||
persistent: cap.persistent,
|
||||
capability: cap.capability,
|
||||
redeemHint:
|
||||
'Share this peardock invite (starts with pd1.). Paste the full string in Add peer. Do not share SERVER_SEED.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Active invite list for Access UI (single in-memory slot; capability mint is in peer-policy).
|
||||
*/
|
||||
export function listConnectionInvites() {
|
||||
if (!lastInviteMeta) return []
|
||||
if (
|
||||
lastInviteMeta.expiresAt &&
|
||||
new Date(lastInviteMeta.expiresAt).getTime() < Date.now()
|
||||
) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{
|
||||
kind: 'pd1',
|
||||
invite: lastInviteMeta.invite,
|
||||
token: lastInviteMeta.invite,
|
||||
share: lastInviteMeta.invite,
|
||||
role: lastInviteMeta.role,
|
||||
expiresAt: lastInviteMeta.expiresAt,
|
||||
jti: lastInviteMeta.jti,
|
||||
maxUses: lastInviteMeta.maxUses ?? 0,
|
||||
uses: 0,
|
||||
note: lastInviteMeta.note,
|
||||
createdAt: lastInviteMeta.createdAt,
|
||||
publicKeyHex: lastInviteMeta.publicKeyHex || null,
|
||||
persistent:
|
||||
lastInviteMeta.persistent ??
|
||||
(lastInviteMeta.expiresAt == null && (lastInviteMeta.maxUses ?? 0) === 0),
|
||||
capability: lastInviteMeta.capability || null,
|
||||
grant: {
|
||||
kind: 'capability',
|
||||
jti: lastInviteMeta.jti,
|
||||
role: lastInviteMeta.role,
|
||||
expiresAt: lastInviteMeta.expiresAt,
|
||||
maxUses: lastInviteMeta.maxUses ?? 0,
|
||||
uses: 0,
|
||||
persistent: lastInviteMeta.persistent,
|
||||
embeddedInInvite: true,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the active invite and spend its capability jti.
|
||||
* @param {{ jti?: string|null, token?: string|null }} [opts]
|
||||
* @returns {{ success: boolean, jti: string|null, deleted: string[] }}
|
||||
*/
|
||||
export function deleteConnectionInvite(opts = {}) {
|
||||
const deleted = []
|
||||
let jti = opts.jti || lastInviteMeta?.jti || null
|
||||
|
||||
// Decode pd1 share string if provided
|
||||
if (!jti && opts.token) {
|
||||
const pkg = decodePeardockInvite(opts.token)
|
||||
if (pkg?.jti) jti = pkg.jti
|
||||
}
|
||||
|
||||
if (jti && jti !== 'unknown') {
|
||||
try {
|
||||
const r = policyDeleteInvite({ jti })
|
||||
for (const d of r.deleted || []) deleted.push(d)
|
||||
} catch (err) {
|
||||
log.debug('policy deleteInvite', { error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
if (lastInviteMeta) {
|
||||
deleted.push('pd1')
|
||||
lastInviteMeta = null
|
||||
}
|
||||
|
||||
log.info('Deleted connection invite', {
|
||||
jti: jti ? String(jti).slice(0, 8) : null,
|
||||
})
|
||||
|
||||
return {
|
||||
success: deleted.length > 0,
|
||||
jti: jti && jti !== 'unknown' ? jti : null,
|
||||
deleted,
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear in-memory invite slot (server shutdown). */
|
||||
export async function closeConnectionInvites() {
|
||||
lastInviteMeta = null
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
* holders of a valid capability may connect
|
||||
*
|
||||
* Invites: HMAC capability grants (minted via signCapability) tracked by jti.
|
||||
* AutoPass distributes the package; this file tracks spent jtis + peer roles.
|
||||
* pd1. invites embed the package; this file tracks spent jtis + peer roles.
|
||||
*
|
||||
* Legacy random invite tokens: only when PEARDOCK_LEGACY_INVITES=1.
|
||||
*/
|
||||
@@ -336,7 +336,7 @@ export function mintCapability(opts = {}) {
|
||||
redeemHint:
|
||||
forever && maxUses === 0
|
||||
? 'Persistent grant — reconnect anytime with this capability (or after first redeem, as the registered peer).'
|
||||
: 'Pass capability in handshake (or redeem via AutoPass package)',
|
||||
: 'Pass capability in handshake (or paste a full pd1. invite in Add peer)',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,7 +413,7 @@ export function redeemCapability(token, peerIdHex) {
|
||||
res.error ||
|
||||
'Invalid capability' +
|
||||
(res.code === 'CAPABILITY_SPENT'
|
||||
? ' (grant was deleted or replaced — request a new AutoPass invite)'
|
||||
? ' (grant was deleted or replaced — request a new peardock invite)'
|
||||
: '')
|
||||
)
|
||||
err.code = res.code || 'CAPABILITY_INVALID'
|
||||
@@ -537,7 +537,7 @@ export function redeemInvite(token, peerIdHex) {
|
||||
|
||||
if (!legacyInvitesEnabled()) {
|
||||
const err = new Error(
|
||||
'Legacy invite tokens disabled. Use an AutoPass invite or HMAC capability grant.'
|
||||
'Legacy invite tokens disabled. Use a pd1. invite or HMAC capability grant.'
|
||||
)
|
||||
err.code = 'INVITE_INVALID'
|
||||
throw err
|
||||
|
||||
+78
-139
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* Peer invite / revoke / list ACL management (admin).
|
||||
* Invites are AutoPass z32 strings carrying HMAC capability packages.
|
||||
* Invites are pd1. share strings embedding public key + HMAC capability.
|
||||
*/
|
||||
import * as peerPolicy from '../core/peer-policy.js'
|
||||
import * as autopassVault from '../core/autopass-vault.js'
|
||||
import * as connectionInvites from '../core/connection-invites.js'
|
||||
import { peers } from '../core/peer-registry.js'
|
||||
import { Roles } from '../../shared/protocol.js'
|
||||
import * as validation from '../utils/validation.js'
|
||||
import { decodePeardockInvite } from '../../shared/crypto-auth.js'
|
||||
|
||||
export function registerPeerHandlers(session) {
|
||||
session.respond('listPeers', async () => {
|
||||
@@ -30,60 +31,45 @@ export function registerPeerHandlers(session) {
|
||||
})
|
||||
|
||||
session.respond('listInvites', async () => {
|
||||
const active = connectionInvites.listConnectionInvites()
|
||||
const caps = peerPolicy.listInvites()
|
||||
let autopass = []
|
||||
try {
|
||||
autopass = autopassVault.listAutopassInvites()
|
||||
} catch {
|
||||
autopass = []
|
||||
}
|
||||
const usedJtis = new Set(active.map((a) => a.jti).filter(Boolean))
|
||||
|
||||
// Pair AutoPass invite + its HMAC capability grant into one card (by jti)
|
||||
const capByJti = new Map()
|
||||
for (const c of caps) {
|
||||
if (c.jti) capByJti.set(c.jti, c)
|
||||
}
|
||||
const usedJtis = new Set()
|
||||
const paired = autopass.map((a) => {
|
||||
const cap = a.jti ? capByJti.get(a.jti) : null
|
||||
if (a.jti) usedJtis.add(a.jti)
|
||||
return {
|
||||
kind: 'autopass',
|
||||
// Share string for operators
|
||||
invite: a.invite || a.token,
|
||||
token: a.invite || a.token,
|
||||
// Linked capability grant metadata (same jti as peardock:pkg)
|
||||
jti: a.jti || cap?.jti || null,
|
||||
role: a.role || cap?.role || null,
|
||||
expiresAt: a.expiresAt ?? cap?.expiresAt ?? null,
|
||||
maxUses: a.maxUses ?? cap?.maxUses ?? 0,
|
||||
uses: cap?.uses ?? 0,
|
||||
persistent: a.persistent ?? cap?.persistent ?? false,
|
||||
note: a.note || cap?.note || null,
|
||||
createdAt: a.createdAt || cap?.createdAt || null,
|
||||
// Explicit pairing fields for UI
|
||||
share: a.invite || a.token,
|
||||
grant: {
|
||||
kind: 'capability',
|
||||
jti: a.jti || cap?.jti || null,
|
||||
role: a.role || cap?.role || null,
|
||||
expiresAt: a.expiresAt ?? cap?.expiresAt ?? null,
|
||||
maxUses: a.maxUses ?? cap?.maxUses ?? 0,
|
||||
uses: cap?.uses ?? 0,
|
||||
persistent: a.persistent ?? cap?.persistent ?? false,
|
||||
// Full secret is only in the AutoPass package; do not re-emit here
|
||||
embeddedInAutopass: true,
|
||||
},
|
||||
}
|
||||
})
|
||||
// Active pd1 invites (one card each, grant embedded)
|
||||
const paired = active.map((a) => ({
|
||||
kind: 'pd1',
|
||||
invite: a.invite || a.share || a.token,
|
||||
token: a.invite || a.share || a.token,
|
||||
share: a.share || a.invite || a.token,
|
||||
jti: a.jti || null,
|
||||
role: a.role || null,
|
||||
expiresAt: a.expiresAt ?? null,
|
||||
maxUses: a.maxUses ?? 0,
|
||||
uses: a.uses ?? 0,
|
||||
persistent: a.persistent ?? false,
|
||||
note: a.note || null,
|
||||
createdAt: a.createdAt || null,
|
||||
publicKeyHex: a.publicKeyHex || null,
|
||||
grant: a.grant || {
|
||||
kind: 'capability',
|
||||
jti: a.jti,
|
||||
role: a.role,
|
||||
expiresAt: a.expiresAt,
|
||||
maxUses: a.maxUses,
|
||||
uses: a.uses,
|
||||
persistent: a.persistent,
|
||||
embeddedInInvite: true,
|
||||
},
|
||||
}))
|
||||
|
||||
// Standalone capability grants (no AutoPass package), e.g. Autopass unavailable fallback
|
||||
// Other capability grants still in policy (not the active pd1 slot)
|
||||
const standaloneCaps = caps
|
||||
.filter((c) => c.jti && !usedJtis.has(c.jti))
|
||||
.filter((c) => c.kind === 'capability' && c.jti && !usedJtis.has(c.jti))
|
||||
.map((c) => ({
|
||||
kind: 'capability',
|
||||
invite: null,
|
||||
token: c.token || null,
|
||||
share: null,
|
||||
jti: c.jti,
|
||||
role: c.role,
|
||||
expiresAt: c.expiresAt,
|
||||
@@ -92,7 +78,6 @@ export function registerPeerHandlers(session) {
|
||||
persistent: c.persistent,
|
||||
note: c.note,
|
||||
createdAt: c.createdAt,
|
||||
share: null,
|
||||
grant: {
|
||||
kind: 'capability',
|
||||
jti: c.jti,
|
||||
@@ -101,7 +86,7 @@ export function registerPeerHandlers(session) {
|
||||
maxUses: c.maxUses,
|
||||
uses: c.uses,
|
||||
persistent: c.persistent,
|
||||
embeddedInAutopass: false,
|
||||
embeddedInInvite: false,
|
||||
token: c.token || null,
|
||||
},
|
||||
}))
|
||||
@@ -112,6 +97,7 @@ export function registerPeerHandlers(session) {
|
||||
kind: 'legacy',
|
||||
invite: null,
|
||||
token: c.token,
|
||||
share: c.token,
|
||||
jti: null,
|
||||
role: c.role,
|
||||
expiresAt: c.expiresAt,
|
||||
@@ -119,7 +105,6 @@ export function registerPeerHandlers(session) {
|
||||
uses: c.uses,
|
||||
note: c.note,
|
||||
createdAt: c.createdAt,
|
||||
share: c.token,
|
||||
grant: { kind: 'legacy', token: c.token, role: c.role },
|
||||
}))
|
||||
|
||||
@@ -131,7 +116,7 @@ export function registerPeerHandlers(session) {
|
||||
})
|
||||
|
||||
session.respond('invitePeer', async (args) => {
|
||||
// Register a known peer public key (no AutoPass)
|
||||
// Register a known peer public key (no invite string)
|
||||
if (args.peerId) {
|
||||
const peerId = validation.sanitizeString(args.peerId, 64).toLowerCase()
|
||||
if (!/^[0-9a-f]{64}$/.test(peerId)) throw new Error('peerId must be 64 hex characters')
|
||||
@@ -144,60 +129,31 @@ export function registerPeerHandlers(session) {
|
||||
return { success: true, type: 'peerRegistered', data: entry }
|
||||
}
|
||||
|
||||
// Default: AutoPass connection invite (HMAC capability + package)
|
||||
try {
|
||||
const invite = await autopassVault.createConnectionInvite({
|
||||
role: args.role || Roles.operator,
|
||||
ttlHours: args.ttlHours,
|
||||
maxUses: args.maxUses,
|
||||
note: args.note,
|
||||
alias: args.alias,
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
type: 'invite',
|
||||
data: {
|
||||
// Primary share string
|
||||
token: invite.invite,
|
||||
invite: invite.invite,
|
||||
kind: 'autopass',
|
||||
role: invite.role,
|
||||
expiresAt: invite.expiresAt,
|
||||
maxUses: invite.maxUses,
|
||||
jti: invite.jti,
|
||||
publicKeyHex: invite.publicKeyHex,
|
||||
redeemHint: invite.redeemHint,
|
||||
note: invite.note,
|
||||
persistent: invite.persistent,
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
// Fallback: capability-only invite if AutoPass unavailable
|
||||
if (err.code === 'AUTOPASS_UNAVAILABLE') {
|
||||
const cap = peerPolicy.mintCapability({
|
||||
role: args.role || Roles.operator,
|
||||
ttlHours: args.ttlHours,
|
||||
maxUses: args.maxUses,
|
||||
note: args.note,
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
type: 'invite',
|
||||
data: {
|
||||
token: cap.capability,
|
||||
capability: cap.capability,
|
||||
kind: 'capability',
|
||||
role: cap.role,
|
||||
expiresAt: cap.expiresAt,
|
||||
maxUses: cap.maxUses,
|
||||
jti: cap.jti,
|
||||
persistent: cap.persistent,
|
||||
redeemHint:
|
||||
'AutoPass unavailable — share server public key + this capability token. Install autopass for automatic package invites. Grant persists for reconnect by default.',
|
||||
},
|
||||
}
|
||||
}
|
||||
throw err
|
||||
const invite = connectionInvites.createConnectionInvite({
|
||||
role: args.role || Roles.operator,
|
||||
ttlHours: args.ttlHours,
|
||||
maxUses: args.maxUses,
|
||||
note: args.note,
|
||||
alias: args.alias,
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
type: 'invite',
|
||||
data: {
|
||||
token: invite.invite,
|
||||
invite: invite.invite,
|
||||
share: invite.share || invite.invite,
|
||||
kind: 'pd1',
|
||||
role: invite.role,
|
||||
expiresAt: invite.expiresAt,
|
||||
maxUses: invite.maxUses,
|
||||
jti: invite.jti,
|
||||
publicKeyHex: invite.publicKeyHex,
|
||||
redeemHint: invite.redeemHint,
|
||||
note: invite.note,
|
||||
persistent: invite.persistent,
|
||||
capability: invite.capability,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -218,45 +174,28 @@ export function registerPeerHandlers(session) {
|
||||
}
|
||||
|
||||
const deleted = []
|
||||
let active = []
|
||||
try {
|
||||
active = autopassVault.listAutopassInvites()
|
||||
} catch {
|
||||
active = []
|
||||
}
|
||||
const looksLikePd1 = Boolean(tokenStr) && tokenStr.toLowerCase().startsWith('pd1.')
|
||||
const active = connectionInvites.listConnectionInvites()
|
||||
|
||||
// Share strings: pd1.<base64url> envelope or legacy raw Autopass z32
|
||||
const looksLikePeardockInvite =
|
||||
Boolean(tokenStr) && tokenStr.toLowerCase().startsWith('pd1.')
|
||||
const looksLikeAutopassInvite =
|
||||
Boolean(tokenStr) && !tokenStr.includes('.') && tokenStr.length >= 80
|
||||
|
||||
const matchesAutopass =
|
||||
kind === 'autopass' ||
|
||||
looksLikePeardockInvite ||
|
||||
looksLikeAutopassInvite ||
|
||||
const matchesActive =
|
||||
kind === 'pd1' ||
|
||||
looksLikePd1 ||
|
||||
(jti && active.some((i) => i.jti === jti)) ||
|
||||
(tokenStr && active.some((i) => i.invite === tokenStr || i.token === tokenStr))
|
||||
(tokenStr && active.some((i) => i.invite === tokenStr || i.token === tokenStr || i.share === tokenStr))
|
||||
|
||||
if (matchesAutopass) {
|
||||
// Wipe vault invite + package first (returns package jti to invalidate)
|
||||
const wiped = await autopassVault.deleteAutopassInvite()
|
||||
const spendJti =
|
||||
(jti && jti !== 'unknown' ? jti : null) ||
|
||||
wiped?.jti ||
|
||||
active[0]?.jti ||
|
||||
null
|
||||
if (spendJti && spendJti !== 'unknown') {
|
||||
const r = peerPolicy.deleteInvite({ jti: spendJti })
|
||||
for (const d of r.deleted || []) {
|
||||
if (!deleted.includes(d)) deleted.push(d)
|
||||
}
|
||||
if (matchesActive || looksLikePd1) {
|
||||
if (!jti && tokenStr) {
|
||||
const pkg = decodePeardockInvite(tokenStr)
|
||||
if (pkg?.jti) jti = pkg.jti
|
||||
}
|
||||
const wiped = connectionInvites.deleteConnectionInvite({ jti, token: tokenStr })
|
||||
for (const d of wiped.deleted || []) {
|
||||
if (!deleted.includes(d)) deleted.push(d)
|
||||
}
|
||||
if (!deleted.includes('autopass')) deleted.push('autopass')
|
||||
}
|
||||
|
||||
// Capability HMAC (body.mac) or legacy 48-hex token — not Autopass z32 strings
|
||||
const isCapabilityToken = Boolean(tokenStr && tokenStr.includes('.'))
|
||||
// Standalone capability / legacy policy entries
|
||||
const isCapabilityToken = Boolean(tokenStr && tokenStr.includes('.') && !looksLikePd1)
|
||||
const isLegacyToken = Boolean(tokenStr && /^[0-9a-f]{48}$/i.test(tokenStr))
|
||||
if (jti || isCapabilityToken || isLegacyToken) {
|
||||
const r = peerPolicy.deleteInvite({
|
||||
|
||||
@@ -237,7 +237,7 @@ export function registerHandshake(session) {
|
||||
authMode = 'seed'
|
||||
}
|
||||
|
||||
// 2) HMAC capability grant (AutoPass package or direct)
|
||||
// 2) HMAC capability grant (pd1 invite embeds this; or direct capability token)
|
||||
// Persistent by default; reconnect of registered peers never hard-fails on spent jti.
|
||||
const capabilityToken = args?.capability || null
|
||||
if (capabilityToken && authMode !== 'seed') {
|
||||
@@ -354,7 +354,7 @@ export function registerHandshake(session) {
|
||||
binaryStreams: true,
|
||||
schemaValidation: true,
|
||||
hmacAuth: true,
|
||||
autopassInvites: true,
|
||||
connectionInvites: true,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
+4
-9
@@ -17,7 +17,7 @@ import { startStatsBroadcast, stopStatsBroadcast } from './services/stats.js'
|
||||
import { isPeerRevoked } from './core/peer-policy.js'
|
||||
import { recordPeerConnect, recordPeerDisconnect } from './services/metrics.js'
|
||||
import { isInsecureOpenAdmin } from '../shared/crypto-auth.js'
|
||||
import { closeAutopassVault, warmAutopass } from './core/autopass-vault.js'
|
||||
import { closeConnectionInvites } from './core/connection-invites.js'
|
||||
import {
|
||||
closeAllTunnels,
|
||||
isHolesailEnabled,
|
||||
@@ -127,7 +127,7 @@ logger.banner([
|
||||
publicKeyHex,
|
||||
'',
|
||||
'Admin: paste public key + SERVER_SEED in the client.',
|
||||
'Operators: create an AutoPass invite from Access (never share SERVER_SEED).',
|
||||
'Operators: create a pd1. invite from Access (never share SERVER_SEED).',
|
||||
'',
|
||||
`Docker: ${dockerStatus.ok ? `ok · API ${dockerStatus.apiVersion || '?'} · ${dockerStatus.os || '?'}` : `unavailable · ${dockerStatus.error || 'socket error'}`}`,
|
||||
`Holesail: ${
|
||||
@@ -143,11 +143,6 @@ logger.banner([
|
||||
`Boot ${bootMs}ms · pid ${process.pid} · Node ${process.version}`,
|
||||
])
|
||||
|
||||
// Open AutoPass early so BlindPairing is ready when clients redeem invites
|
||||
warmAutopass().then((ok) => {
|
||||
if (ok) log.info('AutoPass vault warmed (invite pairing ready)')
|
||||
})
|
||||
|
||||
log.info('Listening on HyperDHT', {
|
||||
publicKey: publicKeyHex.slice(0, 16) + '…',
|
||||
docker: dockerStatus.ok,
|
||||
@@ -201,9 +196,9 @@ async function shutdown(signal = 'shutdown') {
|
||||
peers.clear()
|
||||
|
||||
try {
|
||||
await closeAutopassVault()
|
||||
await closeConnectionInvites()
|
||||
} catch (err) {
|
||||
log.debug('autopass.close', { error: err.message })
|
||||
log.debug('invites.close', { error: err.message })
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user