/** * HMAC capability grants + admin seed proof for handshake auth. * * Pure helpers shared by server and client. Never logs secrets. * Uses `b4a` + `crypto` (mapped to bare-crypto under Bare/Pear). * * Capability token format: * base64url(JSON payload) + "." + base64url(HMAC-SHA256(macKey, payloadBytes)) * * Admin proof (single-round): * mac = HMAC-SHA256(macKey, "peardata-admin-v1" || nonce || peerId || serverPubKey) * * Invite envelope: * pd1. */ import crypto from 'crypto' import b4a from 'b4a' import { Roles } from './protocol.js' const SALT = b4a.from('peardata-hmac-v1') const INFO_CAPABILITY = b4a.from('capability') const ADMIN_PREFIX = b4a.from('peardata-admin-v1') const VALID_ROLES = new Set([Roles.viewer, Roles.operator, Roles.admin]) export const INVITE_PREFIX = 'pd1.' function asU8(value) { if (value == null) return b4a.alloc(0) if (value instanceof Uint8Array) return b4a.from(value) return b4a.from(value) } /** * @param {string|Uint8Array} seedHexOrBuf * @returns {Uint8Array} */ export function deriveMacKey(seedHexOrBuf) { const ikm = toSeedBuffer(seedHexOrBuf) if (typeof crypto.hkdfSync === 'function') { return asU8(crypto.hkdfSync('sha256', ikm, SALT, INFO_CAPABILITY, 32)) } const prk = crypto.createHmac('sha256', SALT).update(ikm).digest() const info = b4a.concat([INFO_CAPABILITY, b4a.from([0x01])]) return asU8(crypto.createHmac('sha256', prk).update(info).digest()) } function toSeedBuffer(seedHexOrBuf) { if (seedHexOrBuf instanceof Uint8Array) { const buf = b4a.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 b4a.from(hex, 'hex') } export function b64url(buf) { return b4a .toString(asU8(buf), 'base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, '') } export function b64urlDecode(s) { const str = String(s || '').replace(/-/g, '+').replace(/_/g, '/') const pad = str.length % 4 === 0 ? '' : '='.repeat(4 - (str.length % 4)) return b4a.from(str + pad, 'base64') } 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 b4a.from(JSON.stringify(ordered)) } function resolveMacKey(macKeyOrSeed) { if (macKeyOrSeed instanceof Uint8Array) { const buf = b4a.from(macKeyOrSeed) if (buf.length === 32) return buf return deriveMacKey(macKeyOrSeed) } if (typeof macKeyOrSeed === 'string' && /^[0-9a-fA-F]{64}$/.test(macKeyOrSeed.trim())) { return deriveMacKey(macKeyOrSeed.trim()) } throw new Error('Invalid mac key or seed') } export function safeEqual(a, b) { const aa = asU8(a) const bb = asU8(b) if (aa.length !== bb.length) return false return crypto.timingSafeEqual(aa, bb) } /** * @param {Uint8Array|string} macKeyOrSeed * @param {{ role?: string, ttlMs?: number|null, peerId?: string|null, jti?: string, forever?: boolean }} opts */ 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 rawTtl = opts.ttlMs let exp = null if (opts.forever === true || rawTtl === 0 || rawTtl === null || rawTtl === undefined) { exp = null } else { const ttlMs = Math.min( Math.max(Number(rawTtl) || 72 * 3600 * 1000, 60_000), 100 * 365 * 24 * 3600 * 1000 ) exp = now + ttlMs } const payload = { v: 1, role, peerId: opts.peerId ? String(opts.peerId).toLowerCase() : null, exp, jti: opts.jti || b4a.toString(crypto.randomBytes(16), 'hex'), iat: now, } const body = canonicalizePayload(payload) const mac = crypto.createHmac('sha256', macKey).update(body).digest() return { token: `${b64url(body)}.${b64url(mac)}`, payload } } /** * @param {Uint8Array|string} macKeyOrSeed * @param {string} token * @param {{ peerId?: string, now?: number, allowSpentCheck?: (jti: string) => boolean }} [opts] */ 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(b4a.toString(body, '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 (payload.exp != null) { 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, }, } } function hmacAdmin(macKey, nonce, peerId, serverPk) { return crypto .createHmac('sha256', macKey) .update(ADMIN_PREFIX) .update(b4a.from(nonce)) .update(b4a.from(peerId)) .update(b4a.from(serverPk)) .digest() } /** * @param {Uint8Array|string} macKeyOrSeed * @param {{ nonce?: string, peerId: string, serverPublicKeyHex: string }} opts */ export function createAdminProof(macKeyOrSeed, opts) { const nonce = String(opts.nonce || b4a.toString(crypto.randomBytes(16), '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: b4a.toString(asU8(mac), 'hex') } } /** * @param {Uint8Array|string} macKeyOrSeed * @param {{ nonce?: string, mac?: string }|null} proof * @param {{ peerId: string, serverPublicKeyHex: string }} ctx */ 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 = b4a.from(macHex, 'hex') if (!safeEqual(got, expected)) { return { ok: false, error: 'Admin proof verification failed', code: 'ADMIN_PROOF_FAILED' } } return { ok: true } } /** * @param {object} pkg * @returns {string} */ export function encodeInvite(pkg) { const body = { v: 1, publicKeyHex: String(pkg.publicKeyHex || '').toLowerCase(), capability: String(pkg.capability || ''), role: pkg.role || null, jti: pkg.jti || null, alias: pkg.alias || null, expiresAt: pkg.expiresAt ?? null, } if (!/^[0-9a-f]{64}$/.test(body.publicKeyHex)) { throw new Error('encodeInvite: invalid publicKeyHex') } if (!body.capability || !body.capability.includes('.')) { throw new Error('encodeInvite: invalid capability') } return `${INVITE_PREFIX}${b64url(b4a.from(JSON.stringify(body)))}` } /** * @param {string} invite * @returns {{ ok: true, package: object } | { ok: false, error: string, code: string }} */ export function decodeInvite(invite) { const s = String(invite || '').trim() if (!s.startsWith(INVITE_PREFIX)) { return { ok: false, error: 'Not a pd1 invite', code: 'INVITE_INVALID' } } try { const json = b4a.toString(b64urlDecode(s.slice(INVITE_PREFIX.length)), 'utf8') const pkg = JSON.parse(json) if (!pkg?.publicKeyHex || !pkg?.capability) { return { ok: false, error: 'Invite missing fields', code: 'INVITE_INVALID' } } return { ok: true, package: pkg } } catch { return { ok: false, error: 'Malformed invite', code: 'INVITE_INVALID' } } } /** * Classify free-form connection input (public key, invite, capability). * @param {string} input */ export function classifyConnectionInput(input) { const s = String(input || '').trim() if (!s) return { kind: 'empty' } if (s.startsWith(INVITE_PREFIX)) { const dec = decodeInvite(s) if (!dec.ok) return { kind: 'invalid', error: dec.error, code: dec.code } return { kind: 'invite', publicKeyHex: dec.package.publicKeyHex, capability: dec.package.capability, role: dec.package.role, alias: dec.package.alias, } } if (/^[0-9a-fA-F]{64}$/.test(s)) { return { kind: 'publicKey', publicKeyHex: s.toLowerCase() } } if (s.includes('.') && s.split('.').length === 2) { return { kind: 'capability', capability: s } } return { kind: 'unknown', error: 'Expected 64-hex public key or pd1. invite' } } export function isInsecureOpenAdmin() { try { const env = typeof process !== 'undefined' ? process.env : null const v = String(env?.PEARDATA_INSECURE_OPEN_ADMIN || '').toLowerCase() return v === '1' || v === 'true' || v === 'yes' } catch { return false } }