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
|
||||
|
||||
Reference in New Issue
Block a user