Files
peardata/shared/crypto-auth.js
T
Raven Scott f7e26d1aac
Release rolling / release (push) Successful in 25s
CI / test (push) Successful in 28s
Testing
2026-07-18 16:33:43 -04:00

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
}
}