Secure connections with AutoPass invites, HMAC capabilities, and viewer default.
Release rolling / release (push) Successful in 12m24s

Default peers are read-only; admin requires seed proof and operators redeem AutoPass packages with signed grants. ACL UI and docs match the new trust model.
This commit is contained in:
Raven Scott
2026-07-14 19:38:53 -04:00
parent b8bd4eb902
commit c818edac9d
31 changed files with 2138 additions and 233 deletions
+185
View File
@@ -0,0 +1,185 @@
/**
* Client-side AutoPass invite redeem → connection package.
*
* Package shape (JSON in peardock:pkg):
* { v:1, publicKeyHex, capability, role, alias?, expiresAt?, jti? }
*/
import path from 'path'
import fs from 'fs'
import os from 'os'
import crypto from 'crypto'
import Corestore from 'corestore'
import { classifyConnectionInput } from '../shared/crypto-auth.js'
const PKG_KEY = 'peardock:pkg'
/**
* @returns {string}
*/
function autopassRoot() {
const home =
process.env.PEARDOCK_HOME ||
process.env.HOME ||
process.env.USERPROFILE ||
(typeof os.homedir === 'function' ? os.homedir() : '') ||
''
return path.join(home, '.config', 'peardock', 'autopass')
}
/**
* @param {string} invite
* @returns {string}
*/
function storeDirForInvite(invite) {
const hash = crypto.createHash('sha256').update(String(invite)).digest('hex').slice(0, 16)
return path.join(autopassRoot(), hash)
}
/**
* Redeem an AutoPass invite and extract the PearDock connection package.
* @param {string} inviteZ32
* @param {{ name?: string, timeoutMs?: number }} [opts]
* @returns {Promise<{ publicKeyHex: string, capability: string, role: string|null, alias: string|null, jti?: string, expiresAt?: string }>}
*/
export async function redeemAutopassInvite(inviteZ32, opts = {}) {
const invite = String(inviteZ32 || '').trim()
if (!invite) {
const err = new Error('AutoPass invite required')
err.code = 'AUTOPASS_PAIR_FAILED'
throw err
}
if (classifyConnectionInput(invite) === 'publicKey') {
const err = new Error('Value looks like a public key, not an AutoPass invite')
err.code = 'AUTOPASS_PAIR_FAILED'
throw err
}
let Autopass
try {
const mod = await import('autopass')
Autopass = mod.default || mod
} catch (err) {
const e = new Error(`autopass not available: ${err.message}`)
e.code = 'AUTOPASS_UNAVAILABLE'
throw e
}
const dir = storeDirForInvite(invite)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
}
const store = new Corestore(path.join(dir, 'store'))
const pair = Autopass.pair(store, invite, { name: opts.name || 'peardock-client' })
const timeoutMs = opts.timeoutMs ?? 60_000
let pass
try {
pass = await Promise.race([
pair.finished(),
new Promise((_, reject) => {
setTimeout(() => reject(new Error(`AutoPass pairing timed out after ${timeoutMs}ms`)), timeoutMs)
}),
])
await pass.ready()
// Wait briefly for package sync if not yet present
let record = await pass.get(PKG_KEY)
if (!record) {
record = await waitForPackage(pass, timeoutMs / 2)
}
if (!record?.value) {
const err = new Error('AutoPass paired but peardock:pkg package not found')
err.code = 'AUTOPASS_PAIR_FAILED'
throw err
}
let pkg
try {
pkg = typeof record.value === 'string' ? JSON.parse(record.value) : record.value
} catch {
const err = new Error('Invalid peardock package JSON in AutoPass')
err.code = 'AUTOPASS_PAIR_FAILED'
throw err
}
const publicKeyHex = String(pkg.publicKeyHex || '').toLowerCase()
const capability = String(pkg.capability || '')
if (!/^[0-9a-f]{64}$/.test(publicKeyHex)) {
const err = new Error('Package missing valid publicKeyHex')
err.code = 'AUTOPASS_PAIR_FAILED'
throw err
}
if (!capability || !capability.includes('.')) {
const err = new Error('Package missing HMAC capability')
err.code = 'AUTOPASS_PAIR_FAILED'
throw err
}
return {
publicKeyHex,
capability,
role: pkg.role || null,
alias: pkg.alias || null,
jti: pkg.jti || null,
expiresAt: pkg.expiresAt || null,
}
} catch (err) {
if (!err.code) err.code = 'AUTOPASS_PAIR_FAILED'
throw err
} finally {
try {
await pair.close?.()
} catch {
// ignore
}
try {
if (pass) await pass.close()
} catch {
// ignore
}
try {
await store.close()
} catch {
// ignore
}
}
}
/**
* @param {any} pass
* @param {number} timeoutMs
*/
function waitForPackage(pass, timeoutMs) {
return new Promise((resolve) => {
const start = Date.now()
const check = async () => {
try {
const rec = await pass.get(PKG_KEY)
if (rec?.value) {
cleanup()
resolve(rec)
return
}
} catch {
// retry
}
if (Date.now() - start >= timeoutMs) {
cleanup()
resolve(null)
}
}
const onUpdate = () => {
check()
}
const cleanup = () => {
clearInterval(timer)
pass.off?.('update', onUpdate)
}
pass.on?.('update', onUpdate)
const timer = setInterval(check, 500)
check()
})
}
export { PKG_KEY, classifyConnectionInput }
+46 -18
View File
@@ -1,5 +1,10 @@
/**
* Single peardock server connection via HyperDHT + protomux-rpc.
*
* Auth modes:
* - viewer: public key only
* - seed: adminProof HMAC from SERVER_SEED
* - capability: HMAC grant (AutoPass package or direct)
*/
import DHT from 'hyperdht'
import ProtomuxRPC from 'protomux-rpc'
@@ -8,6 +13,8 @@ import { EventEmitter } from 'events'
import { PROTOCOL, Pushes, PushToType, Methods } from '../shared/protocol.js'
import { encodings } from '../shared/encodings.js'
import { normalizeRpcError } from './errors.js'
import { getClientIdentity } from './identity.js'
import { createAdminProof } from '../shared/crypto-auth.js'
/**
* @param {unknown} err
@@ -20,6 +27,9 @@ function improveRpcError(err, method) {
/**
* @typedef {object} ConnectionOptions
* @property {number} [timeoutMs=30000]
* @property {string|null} [inviteToken]
* @property {string|null} [capability]
* @property {string|null} [adminSeed] - SERVER_SEED hex for admin proof (session-only)
*/
export class PearDockConnection extends EventEmitter {
@@ -37,6 +47,9 @@ export class PearDockConnection extends EventEmitter {
this.id = this.publicKeyHex.slice(0, 12)
this.timeoutMs = opts.timeoutMs ?? 30000
this.inviteToken = opts.inviteToken || null
this.capability = opts.capability || null
/** @type {string|null} session-only admin seed — never persist */
this.adminSeed = opts.adminSeed || null
this.dht = null
this.swarm = null
@@ -50,8 +63,10 @@ export class PearDockConnection extends EventEmitter {
/** @type {'idle'|'dialing'|'handshaking'|'ready'|'degraded'|'closed'} */
this.state = 'idle'
this.role = null
this.authMode = null
this.protocolVersion = null
this.dockerHealth = null
this.clientPublicKeyHex = null
}
/**
@@ -62,7 +77,9 @@ export class PearDockConnection extends EventEmitter {
if (this.connected) return this
this.state = 'dialing'
this.dht = new DHT()
const identity = getClientIdentity()
this.clientPublicKeyHex = identity.publicKeyHex
this.dht = new DHT({ keyPair: identity.keyPair })
this.socket = this.dht.connect(this.publicKey)
await new Promise((resolve, reject) => {
@@ -103,7 +120,6 @@ export class PearDockConnection extends EventEmitter {
this.socket.off('close', onClose)
}
// HyperDHT secret-stream: 'connect' after Noise handshake; some builds use 'open'
this.socket.once('open', onOpen)
this.socket.once('connect', onOpen)
this.socket.once('error', onError)
@@ -130,18 +146,40 @@ export class PearDockConnection extends EventEmitter {
})
this.rpc.on('close', () => this._onDisconnect())
// Application-layer handshake (role + protocol version)
// Application-layer handshake (role + protocol version + auth)
this.state = 'handshaking'
try {
const hs = await this.request(Methods.handshake, {
const hsArgs = {
clientName: 'peardock-ui',
clientVersion: '2.0.0',
inviteToken: this.inviteToken || undefined,
})
}
if (this.capability) hsArgs.capability = this.capability
if (this.inviteToken) hsArgs.inviteToken = this.inviteToken
if (this.adminSeed) {
hsArgs.adminProof = createAdminProof(this.adminSeed, {
peerId: this.clientPublicKeyHex,
serverPublicKeyHex: this.publicKeyHex,
})
}
const hs = await this.request(Methods.handshake, hsArgs)
this.role = hs?.role || null
this.authMode = hs?.auth?.mode || null
this.protocolVersion = hs?.protocolVersion ?? null
this.emit('handshake', hs)
} catch (err) {
// Fatal for auth failures — tear down so UI can show error
if (
err?.code === 'ADMIN_PROOF_FAILED' ||
err?.code === 'CAPABILITY_INVALID' ||
err?.code === 'CAPABILITY_EXPIRED' ||
err?.code === 'CAPABILITY_SPENT' ||
err?.code === 'PEER_DENIED' ||
err?.code === 'INVITE_INVALID'
) {
await this.close().catch(() => {})
throw improveRpcError(err, Methods.handshake)
}
// Older servers without handshake still usable
this.emit('handshake-error', err)
}
@@ -152,20 +190,19 @@ export class PearDockConnection extends EventEmitter {
this.state = 'ready'
this.emit('connect')
// Non-blocking Docker health sample
this.ping().catch(() => {})
return this
}
/**
* Register server → client push methods.
* Server uses rpc.event(pushName, payload); we respond and re-emit.
*/
_registerPushHandlers() {
for (const push of Object.values(Pushes)) {
this.rpc.respond(push, encodings, (payload) => {
const type = PushToType[push] || payload?.type || push
const message = payload && typeof payload === 'object' ? { ...payload, type } : { type, data: payload }
const message =
payload && typeof payload === 'object' ? { ...payload, type } : { type, data: payload }
this.emit('message', message)
this.emit(type, message)
return null
@@ -174,9 +211,6 @@ export class PearDockConnection extends EventEmitter {
}
/**
* RPC request to the server.
* protomux wraps handler throws as REQUEST_ERROR("Request failed", cause);
* we rethrow with the unwrapped server message so UI does not show that junk.
* @param {string} method
* @param {object} [args]
* @param {object} [opts]
@@ -200,8 +234,6 @@ export class PearDockConnection extends EventEmitter {
}
/**
* Fire-and-forget event (terminal input, etc.).
* Prefer request() when a response is useful.
* @param {string} method
* @param {object} [args]
*/
@@ -211,7 +243,6 @@ export class PearDockConnection extends EventEmitter {
}
/**
* Health check round-trip.
* @returns {Promise<number>} latency ms
*/
async ping() {
@@ -246,9 +277,6 @@ export class PearDockConnection extends EventEmitter {
this.emit('disconnect')
}
/**
* Tear down connection and DHT.
*/
async close() {
this.connected = false
try {
+35 -8
View File
@@ -52,9 +52,40 @@ const CODE_MAP = {
},
INVITE_INVALID: {
title: 'Invite invalid',
recovery: 'Request a new invite token from an admin.',
recovery: 'Request a new AutoPass invite from an admin.',
severity: 'warning',
},
CAPABILITY_INVALID: {
title: 'Capability invalid',
recovery: 'The connection grant failed HMAC verification. Request a new AutoPass invite.',
severity: 'danger',
},
CAPABILITY_EXPIRED: {
title: 'Capability expired',
recovery: 'This invite grant has expired. Ask an admin for a new AutoPass invite.',
severity: 'warning',
},
CAPABILITY_SPENT: {
title: 'Capability already used',
recovery: 'This single-use grant was already redeemed. Request a new invite.',
severity: 'warning',
},
ADMIN_PROOF_FAILED: {
title: 'Admin authentication failed',
recovery: 'Check that SERVER_SEED matches this server public key (64 hex characters).',
severity: 'danger',
},
AUTOPASS_PAIR_FAILED: {
title: 'AutoPass pairing failed',
recovery: 'Verify the invite string and network, then try again or request a new invite.',
severity: 'warning',
},
AUTOPASS_UNAVAILABLE: {
title: 'AutoPass unavailable',
recovery: 'Install the autopass dependency on this client, or connect with public key + capability.',
severity: 'danger',
},
UNKNOWN_METHOD: {
title: 'Unsupported operation',
recovery: 'The peer server is missing this method. Update peardock server and reconnect.',
@@ -250,15 +281,11 @@ function classifyDockerMessage(message, method) {
}
}
if (
err?.code === 'CONTAINER_NAME_CONFLICT' ||
(/container name|already in use by container|name .* already|already exists/i.test(lower) &&
/use|conflict|taken|exists|replace/i.test(lower))
/container name|already in use by container|name .* already|already exists/i.test(lower) &&
/use|conflict|taken|exists|replace/i.test(lower)
) {
return {
code:
err?.code === 'CONTAINER_NAME_CONFLICT'
? 'CONTAINER_NAME_CONFLICT'
: 'DOCKER_CONFLICT',
code: 'CONTAINER_NAME_CONFLICT',
title: 'Container name already taken',
message: m,
recovery:
+85
View File
@@ -0,0 +1,85 @@
/**
* Persistent client DHT identity for stable peerId across reconnects.
* Stored at ~/.config/peardock/identity.json (mode 0600).
*/
import fs from 'fs'
import path from 'path'
import os from 'os'
import crypto from 'crypto'
import DHT from 'hyperdht'
import b4a from 'b4a'
const IDENTITY_VERSION = 1
/**
* @returns {string}
*/
export function getIdentityPath() {
const home =
process.env.PEARDOCK_HOME ||
process.env.HOME ||
process.env.USERPROFILE ||
(typeof os.homedir === 'function' ? os.homedir() : '') ||
''
return path.join(home, '.config', 'peardock', 'identity.json')
}
/**
* @returns {{ seed: Uint8Array, keyPair: { publicKey: Uint8Array, secretKey: Uint8Array }, publicKeyHex: string, seedHex: string }}
*/
export function loadOrCreateClientIdentity() {
const filePath = getIdentityPath()
let seedHex = null
try {
if (fs.existsSync(filePath)) {
const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'))
if (raw?.seedHex && /^[0-9a-fA-F]{64}$/.test(raw.seedHex)) {
seedHex = String(raw.seedHex).toLowerCase()
}
}
} catch {
// regenerate
}
if (!seedHex) {
seedHex = crypto.randomBytes(32).toString('hex')
try {
const dir = path.dirname(filePath)
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
fs.writeFileSync(
filePath,
JSON.stringify(
{
version: IDENTITY_VERSION,
seedHex,
createdAt: new Date().toISOString(),
},
null,
2
),
{ mode: 0o600 }
)
try {
fs.chmodSync(filePath, 0o600)
} catch {
// ignore
}
} catch {
// In-memory only if FS unavailable (browser-ish)
}
}
const seed = b4a.from(seedHex, 'hex')
const keyPair = DHT.keyPair(seed)
const publicKeyHex = b4a.toString(keyPair.publicKey, 'hex')
return { seed, keyPair, publicKeyHex, seedHex }
}
/** Cached identity for process lifetime */
let cached = null
export function getClientIdentity() {
if (!cached) cached = loadOrCreateClientIdentity()
return cached
}
+24 -8
View File
@@ -45,13 +45,16 @@ export class ConnectionManager extends EventEmitter {
/**
* @param {string} publicKeyHex
* @param {{ alias?: string, skipReconnectReset?: boolean }} [meta]
* @param {{
* alias?: string,
* skipReconnectReset?: boolean,
* inviteToken?: string,
* capability?: string,
* adminSeed?: string,
* setActive?: boolean,
* }} [meta]
* @returns {Promise<PearDockConnection>}
*/
/**
* @param {string} publicKeyHex
* @param {{ alias?: string, skipReconnectReset?: boolean, inviteToken?: string }} [meta]
*/
async connect(publicKeyHex, meta = {}) {
const key = publicKeyHex.toLowerCase()
const id = key.slice(0, 12)
@@ -63,6 +66,9 @@ export class ConnectionManager extends EventEmitter {
publicKeyHex: key,
alias: meta.alias ?? prev?.alias ?? null,
inviteToken: meta.inviteToken ?? prev?.inviteToken ?? null,
capability: meta.capability ?? prev?.capability ?? null,
// adminSeed is session-only; keep across reconnect within process
adminSeed: meta.adminSeed ?? prev?.adminSeed ?? null,
attempts: 0,
timer: null,
intentional: false,
@@ -72,16 +78,21 @@ export class ConnectionManager extends EventEmitter {
publicKeyHex: key,
alias: meta.alias || null,
inviteToken: meta.inviteToken || null,
capability: meta.capability || null,
adminSeed: meta.adminSeed || null,
attempts: 0,
timer: null,
intentional: false,
}
if (meta.alias) entry.alias = meta.alias
if (meta.inviteToken) entry.inviteToken = meta.inviteToken
if (meta.capability) entry.capability = meta.capability
if (meta.adminSeed) entry.adminSeed = meta.adminSeed
this._reconnect.set(id, entry)
}
const shouldActivate = meta.setActive !== false
const recon = this._reconnect.get(id)
if (this.connections.has(id)) {
const existing = this.connections.get(id)
@@ -95,10 +106,12 @@ export class ConnectionManager extends EventEmitter {
const conn = new PearDockConnection(key, {
timeoutMs: CONFIG.CONNECTION.TIMEOUT_MS,
inviteToken: meta.inviteToken || undefined,
inviteToken: meta.inviteToken || recon?.inviteToken || undefined,
capability: meta.capability || recon?.capability || undefined,
adminSeed: meta.adminSeed || recon?.adminSeed || undefined,
})
if (meta.alias) conn.alias = meta.alias
else if (this._reconnect.get(id)?.alias) conn.alias = this._reconnect.get(id).alias
else if (recon?.alias) conn.alias = recon.alias
conn.on('message', (msg) => this.emit('message', msg, conn))
conn.on('health', (info) => this.emit('health', info, conn))
@@ -120,7 +133,6 @@ export class ConnectionManager extends EventEmitter {
}
this.connections.set(id, conn)
this.persist()
// Boot restore dials all peers with setActive:false so last-known wins later
if (shouldActivate) this.setActive(id)
this._ensureHealthLoop()
this.emit('connect', conn)
@@ -239,6 +251,8 @@ export class ConnectionManager extends EventEmitter {
await this.connect(entry.publicKeyHex, {
alias: entry.alias || undefined,
inviteToken: entry.inviteToken || undefined,
capability: entry.capability || undefined,
adminSeed: entry.adminSeed || undefined,
skipReconnectReset: true,
// Restore last-active peer when it comes back; don't steal active on
// background peer reconnect unless nothing is active
@@ -354,6 +368,7 @@ export class ConnectionManager extends EventEmitter {
publicKeyHex: key,
alias: conn.alias || recon?.alias || serializable[id]?.alias || null,
inviteToken: recon?.inviteToken || serializable[id]?.inviteToken || null,
capability: recon?.capability || serializable[id]?.capability || null,
}
}
@@ -364,6 +379,7 @@ export class ConnectionManager extends EventEmitter {
publicKeyHex: recon.publicKeyHex,
alias: recon.alias || null,
inviteToken: recon.inviteToken || null,
capability: recon.capability || null,
}
}
+17 -5
View File
@@ -44,7 +44,7 @@ export function getPeersCacheDir() {
* Normalize one peer entry.
* @param {object|string} value
* @param {string} [idHint]
* @returns {{ id: string, publicKeyHex: string, alias: string|null, inviteToken: string|null }|null}
* @returns {{ id: string, publicKeyHex: string, alias: string|null, inviteToken: string|null, capability: string|null }|null}
*/
export function normalizePeerEntry(value, idHint = '') {
if (!value) return null
@@ -56,6 +56,7 @@ export function normalizePeerEntry(value, idHint = '') {
publicKeyHex,
alias: null,
inviteToken: null,
capability: null,
}
}
const publicKeyHex = String(
@@ -65,11 +66,19 @@ export function normalizePeerEntry(value, idHint = '') {
.toLowerCase()
if (!/^[0-9a-f]{64}$/.test(publicKeyHex)) return null
const id = String(idHint || value.id || publicKeyHex.slice(0, 12)).slice(0, 12)
// Prefer capability; migrate inviteToken if it looks like an HMAC grant
let capability = value.capability || null
let inviteToken = value.inviteToken || null
if (!capability && inviteToken && String(inviteToken).includes('.')) {
capability = inviteToken
inviteToken = null
}
return {
id,
publicKeyHex,
alias: value.alias || null,
inviteToken: value.inviteToken || null,
inviteToken,
capability,
}
}
@@ -97,7 +106,7 @@ export function parsePeersPayload(raw) {
? parsed.peers
: parsed
/** @type {Record<string, { publicKeyHex: string, alias: string|null, inviteToken: string|null }>} */
/** @type {Record<string, { publicKeyHex: string, alias: string|null, inviteToken: string|null, capability: string|null }>} */
const out = {}
for (const [key, value] of Object.entries(source)) {
// Skip meta keys if someone stored a flat object with version
@@ -108,6 +117,7 @@ export function parsePeersPayload(raw) {
publicKeyHex: entry.publicKeyHex,
alias: entry.alias,
inviteToken: entry.inviteToken,
capability: entry.capability,
}
}
return out
@@ -115,7 +125,7 @@ export function parsePeersPayload(raw) {
/**
* Build on-disk payload.
* @param {Record<string, { publicKeyHex?: string, topicHex?: string, alias?: string|null, inviteToken?: string|null }>} peersMap
* @param {Record<string, { publicKeyHex?: string, topicHex?: string, alias?: string|null, inviteToken?: string|null, capability?: string|null }>} peersMap
* @param {{ activePeerId?: string|null }} [opts]
*/
export function buildPeersPayload(peersMap, opts = {}) {
@@ -127,6 +137,7 @@ export function buildPeersPayload(peersMap, opts = {}) {
publicKeyHex: entry.publicKeyHex,
alias: entry.alias,
inviteToken: entry.inviteToken,
capability: entry.capability,
}
}
const payload = {
@@ -352,7 +363,7 @@ export function clearPeers() {
/**
* List form used by ConnectionManager.loadSaved()
* @returns {Array<{ id: string, publicKeyHex: string, alias: string|null, inviteToken: string|null }>}
* @returns {Array<{ id: string, publicKeyHex: string, alias: string|null, inviteToken: string|null, capability: string|null }>}
*/
export function listSavedPeers() {
const map = loadPeers()
@@ -361,6 +372,7 @@ export function listSavedPeers() {
publicKeyHex: value.publicKeyHex,
alias: value.alias || null,
inviteToken: value.inviteToken || null,
capability: value.capability || null,
}))
}