Move away from Autopass for AlmaLInux Compatibility
Release rolling / release (push) Successful in 8m18s

This commit is contained in:
Raven Scott
2026-07-14 22:19:33 -04:00
parent c6557b487f
commit f4a97aad2a
26 changed files with 372 additions and 1632 deletions
+1 -1
View File
@@ -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)
-376
View File
@@ -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 }
+189
View File
@@ -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
}
+4 -4
View File
@@ -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