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
+331
View File
@@ -0,0 +1,331 @@
/**
* HMAC capability grants + admin seed proof for peardock handshake auth.
*
* Pure helpers shared by server and client. Never logs secrets.
*
* Capability token format:
* base64url(JSON payload) + "." + base64url(HMAC-SHA256(macKey, payloadBytes))
*
* Admin proof (single-round):
* mac = HMAC-SHA256(macKey, "peardock-admin-v1" || nonce || peerId || serverPubKey)
*/
import crypto from 'crypto'
import { Roles } from './protocol.js'
const SALT = Buffer.from('peardock-hmac-v1', 'utf8')
const INFO_CAPABILITY = Buffer.from('capability', 'utf8')
const ADMIN_PREFIX = Buffer.from('peardock-admin-v1', 'utf8')
const VALID_ROLES = new Set([Roles.viewer, Roles.operator, Roles.admin])
/**
* @param {string|Uint8Array|Buffer} seedHexOrBuf - 32-byte seed as 64-hex or raw bytes
* @returns {Buffer} 32-byte MAC key
*/
export function deriveMacKey(seedHexOrBuf) {
const ikm = toSeedBuffer(seedHexOrBuf)
// Node 15+ hkdfSync; fall back to HMAC-based extract/expand if needed
if (typeof crypto.hkdfSync === 'function') {
return Buffer.from(crypto.hkdfSync('sha256', ikm, SALT, INFO_CAPABILITY, 32))
}
// HKDF-Extract
const prk = crypto.createHmac('sha256', SALT).update(ikm).digest()
// HKDF-Expand (single block for 32 bytes)
const info = Buffer.concat([INFO_CAPABILITY, Buffer.from([0x01])])
return crypto.createHmac('sha256', prk).update(info).digest()
}
/**
* @param {string|Uint8Array|Buffer} seedHexOrBuf
* @returns {Buffer}
*/
function toSeedBuffer(seedHexOrBuf) {
if (Buffer.isBuffer(seedHexOrBuf) || seedHexOrBuf instanceof Uint8Array) {
const buf = Buffer.from(seedHexOrBuf)
if (buf.length !== 32) throw new Error('Seed must be 32 bytes')
return buf
}
const hex = String(seedHexOrBuf || '')
.trim()
.toLowerCase()
if (!/^[0-9a-f]{64}$/.test(hex)) {
throw new Error('Seed must be 64 hex characters (32 bytes)')
}
return Buffer.from(hex, 'hex')
}
/**
* @param {Buffer|Uint8Array} buf
* @returns {string}
*/
export function b64url(buf) {
return Buffer.from(buf)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
/**
* @param {string} s
* @returns {Buffer}
*/
export function b64urlDecode(s) {
const str = String(s || '').replace(/-/g, '+').replace(/_/g, '/')
const pad = str.length % 4 === 0 ? '' : '='.repeat(4 - (str.length % 4))
return Buffer.from(str + pad, 'base64')
}
/**
* Canonical JSON for signing (stable key order).
* @param {object} payload
* @returns {Buffer}
*/
export function canonicalizePayload(payload) {
const ordered = {
v: payload.v,
role: payload.role,
peerId: payload.peerId ?? null,
exp: payload.exp,
jti: payload.jti,
iat: payload.iat,
}
return Buffer.from(JSON.stringify(ordered), 'utf8')
}
/**
* Mint an HMAC capability grant.
* @param {Buffer|string} macKeyOrSeed - mac key (32 bytes) or seed hex
* @param {{ role: string, ttlMs?: number, peerId?: string|null, jti?: string }} opts
* @returns {{ token: string, payload: object }}
*/
export function signCapability(macKeyOrSeed, opts = {}) {
const macKey = resolveMacKey(macKeyOrSeed)
const role = String(opts.role || Roles.operator).toLowerCase()
if (!VALID_ROLES.has(role)) throw new Error(`Invalid capability role: ${role}`)
const now = Date.now()
const ttlMs = Math.min(
Math.max(Number(opts.ttlMs) || 72 * 3600 * 1000, 60_000),
30 * 24 * 3600 * 1000
)
const payload = {
v: 1,
role,
peerId: opts.peerId ? String(opts.peerId).toLowerCase() : null,
exp: now + ttlMs,
jti: opts.jti || crypto.randomBytes(16).toString('hex'),
iat: now,
}
const body = canonicalizePayload(payload)
const mac = crypto.createHmac('sha256', macKey).update(body).digest()
const token = `${b64url(body)}.${b64url(mac)}`
return { token, payload }
}
/**
* @param {Buffer|string} macKeyOrSeed
* @param {string} token
* @param {{ peerId?: string, now?: number, allowSpentCheck?: (jti: string) => boolean }} [opts]
* @returns {{ ok: true, payload: object } | { ok: false, error: string, code: string }}
*/
export function verifyCapability(macKeyOrSeed, token, opts = {}) {
if (!token || typeof token !== 'string') {
return { ok: false, error: 'Missing capability token', code: 'CAPABILITY_INVALID' }
}
const parts = token.split('.')
if (parts.length !== 2) {
return { ok: false, error: 'Malformed capability token', code: 'CAPABILITY_INVALID' }
}
let body
let mac
try {
body = b64urlDecode(parts[0])
mac = b64urlDecode(parts[1])
} catch {
return { ok: false, error: 'Malformed capability encoding', code: 'CAPABILITY_INVALID' }
}
if (mac.length !== 32) {
return { ok: false, error: 'Invalid capability MAC length', code: 'CAPABILITY_INVALID' }
}
const macKey = resolveMacKey(macKeyOrSeed)
const expected = crypto.createHmac('sha256', macKey).update(body).digest()
if (!safeEqual(mac, expected)) {
return { ok: false, error: 'Capability MAC verification failed', code: 'CAPABILITY_INVALID' }
}
let payload
try {
payload = JSON.parse(body.toString('utf8'))
} catch {
return { ok: false, error: 'Capability payload not JSON', code: 'CAPABILITY_INVALID' }
}
if (payload.v !== 1) {
return { ok: false, error: 'Unsupported capability version', code: 'CAPABILITY_INVALID' }
}
if (!VALID_ROLES.has(payload.role)) {
return { ok: false, error: 'Invalid capability role', code: 'CAPABILITY_INVALID' }
}
const now = opts.now ?? Date.now()
if (typeof payload.exp !== 'number' || payload.exp < now) {
return { ok: false, error: 'Capability expired', code: 'CAPABILITY_EXPIRED' }
}
if (payload.peerId) {
const want = String(payload.peerId).toLowerCase()
const have = String(opts.peerId || '').toLowerCase()
if (!have || want !== have) {
return {
ok: false,
error: 'Capability bound to a different peer identity',
code: 'CAPABILITY_PEER_MISMATCH',
}
}
}
if (typeof opts.allowSpentCheck === 'function' && !opts.allowSpentCheck(payload.jti)) {
return { ok: false, error: 'Capability already used or revoked', code: 'CAPABILITY_SPENT' }
}
return {
ok: true,
payload: {
v: 1,
role: payload.role,
peerId: payload.peerId || null,
exp: payload.exp,
jti: payload.jti,
iat: payload.iat,
},
}
}
/**
* Build admin seed proof for handshake.
* @param {Buffer|string} macKeyOrSeed
* @param {{ nonce: string, peerId: string, serverPublicKeyHex: string }} opts
* @returns {{ nonce: string, mac: string }}
*/
export function createAdminProof(macKeyOrSeed, opts) {
const nonce = String(opts.nonce || crypto.randomBytes(16).toString('hex'))
if (!/^[0-9a-fA-F]{16,64}$/.test(nonce)) {
throw new Error('Admin proof nonce must be 16-64 hex characters')
}
const peerId = String(opts.peerId || '').toLowerCase()
const serverPk = String(opts.serverPublicKeyHex || '').toLowerCase()
if (!/^[0-9a-f]{64}$/.test(peerId)) throw new Error('peerId required for admin proof')
if (!/^[0-9a-f]{64}$/.test(serverPk)) throw new Error('serverPublicKeyHex required for admin proof')
const macKey = resolveMacKey(macKeyOrSeed)
const mac = hmacAdmin(macKey, nonce, peerId, serverPk)
return { nonce: nonce.toLowerCase(), mac: mac.toString('hex') }
}
/**
* Verify admin seed ownership proof.
* @param {Buffer|string} macKeyOrSeed
* @param {{ nonce?: string, mac?: string }|null} proof
* @param {{ peerId: string, serverPublicKeyHex: string }} ctx
* @returns {{ ok: true } | { ok: false, error: string, code: string }}
*/
export function verifyAdminProof(macKeyOrSeed, proof, ctx) {
if (!proof || !proof.nonce || !proof.mac) {
return { ok: false, error: 'Missing admin proof', code: 'ADMIN_PROOF_FAILED' }
}
const nonce = String(proof.nonce).toLowerCase()
const macHex = String(proof.mac).toLowerCase()
if (!/^[0-9a-f]{16,64}$/.test(nonce) || !/^[0-9a-f]{64}$/.test(macHex)) {
return { ok: false, error: 'Malformed admin proof', code: 'ADMIN_PROOF_FAILED' }
}
const peerId = String(ctx.peerId || '').toLowerCase()
const serverPk = String(ctx.serverPublicKeyHex || '').toLowerCase()
if (!/^[0-9a-f]{64}$/.test(peerId) || !/^[0-9a-f]{64}$/.test(serverPk)) {
return { ok: false, error: 'Invalid proof context', code: 'ADMIN_PROOF_FAILED' }
}
const macKey = resolveMacKey(macKeyOrSeed)
const expected = hmacAdmin(macKey, nonce, peerId, serverPk)
const got = Buffer.from(macHex, 'hex')
if (!safeEqual(got, expected)) {
return { ok: false, error: 'Admin proof verification failed', code: 'ADMIN_PROOF_FAILED' }
}
return { ok: true }
}
/**
* @param {Buffer} macKey
* @param {string} nonce
* @param {string} peerId
* @param {string} serverPk
*/
function hmacAdmin(macKey, nonce, peerId, serverPk) {
return crypto
.createHmac('sha256', macKey)
.update(ADMIN_PREFIX)
.update(Buffer.from(nonce, 'utf8'))
.update(Buffer.from(peerId, 'utf8'))
.update(Buffer.from(serverPk, 'utf8'))
.digest()
}
/**
* @param {Buffer|string} macKeyOrSeed
* @returns {Buffer}
*/
function resolveMacKey(macKeyOrSeed) {
if (Buffer.isBuffer(macKeyOrSeed) || macKeyOrSeed instanceof Uint8Array) {
const buf = Buffer.from(macKeyOrSeed)
// 32-byte key as-is; 64-hex string may arrive as buffer of ascii — handle hex seed separately
if (buf.length === 32) return buf
}
if (typeof macKeyOrSeed === 'string' && /^[0-9a-fA-F]{64}$/.test(macKeyOrSeed.trim())) {
return deriveMacKey(macKeyOrSeed.trim())
}
if (Buffer.isBuffer(macKeyOrSeed) || macKeyOrSeed instanceof Uint8Array) {
return deriveMacKey(macKeyOrSeed)
}
throw new Error('Invalid mac key or seed')
}
/**
* Constant-time equality for equal-length buffers.
* @param {Buffer} a
* @param {Buffer} b
*/
export function safeEqual(a, b) {
if (!Buffer.isBuffer(a)) a = Buffer.from(a)
if (!Buffer.isBuffer(b)) b = Buffer.from(b)
if (a.length !== b.length) return false
return crypto.timingSafeEqual(a, b)
}
/**
* Classify a connection paste string.
* @param {string} input
* @returns {'publicKey' | 'autopassInvite' | 'legacyInvite' | 'unknown'}
*/
export function classifyConnectionInput(input) {
const t = String(input || '').trim()
if (!t) return 'unknown'
if (/^[0-9a-fA-F]{64}$/.test(t)) return 'publicKey'
if (/^[0-9a-fA-F]{48}$/.test(t)) return 'legacyInvite'
// z32 alphabet is lowercase + digits excluding ilou; invites are longer than 64
if (t.length >= 80 && /^[0-9a-z]+$/i.test(t) && !/^[0-9a-f]+$/i.test(t)) {
return 'autopassInvite'
}
// pure z32 can be all-hex-looking rarely; try length heuristic
if (t.length >= 80 && /^[0-9a-z]+$/i.test(t)) return 'autopassInvite'
return 'unknown'
}
/**
* Whether insecure open-admin mode is enabled (dev escape hatch).
*/
export function isInsecureOpenAdmin() {
const v = process.env.PEARDOCK_INSECURE_OPEN_ADMIN
return v === '1' || v === 'true'
}
export { VALID_ROLES, ADMIN_PREFIX }
+3 -2
View File
@@ -7,9 +7,10 @@
*/
export const PROTOCOL = 'peardock/rpc'
export const PROTOCOL_VERSION = 2
/** Bumped for HMAC auth handshake (capability + adminProof). */
export const PROTOCOL_VERSION = 3
/** Roles for capability ACL (Phase 2; default admin until configured). */
/** Roles for capability ACL. Secure default peer role is viewer. */
export const Roles = Object.freeze({
viewer: 'viewer',
operator: 'operator',
+7 -2
View File
@@ -89,7 +89,12 @@ export const MethodSchemas = Object.freeze({
handshake: {
clientName: { type: 'string', required: false, maxLen: 64 },
clientVersion: { type: 'string', required: false, maxLen: 32 },
inviteToken: { type: 'string', required: false, maxLen: 128 },
/** @deprecated Prefer capability (HMAC grant) or AutoPass package */
inviteToken: { type: 'string', required: false, maxLen: 512 },
/** HMAC capability grant (base64url.body.mac) */
capability: { type: 'string', required: false, maxLen: 1024 },
/** Admin seed proof: { nonce, mac } */
adminProof: { type: 'object', required: false },
},
binaryStreamOpen: {
kind: { type: 'string', required: true, enum: ['imageSave', 'imageLoad', 'containerExport', 'upload'] },
@@ -188,4 +193,4 @@ function validateField(key, val, field) {
/**
* Schema version for handshake negotiation (independent of PROTOCOL_VERSION).
*/
export const SCHEMA_VERSION = 1
export const SCHEMA_VERSION = 2