360 lines
11 KiB
JavaScript
360 lines
11 KiB
JavaScript
/**
|
|
* 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.<base64url JSON { publicKeyHex, capability, role, ... }>
|
|
*/
|
|
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
|
|
}
|
|
}
|