Secure connections with AutoPass invites, HMAC capabilities, and viewer default.
Release rolling / release (push) Successful in 12m24s
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:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user