OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / core / core / delegation.js

183 lines · javascript ref:
/**
 * Delegation cert verification + revocation
 *
 * Pure functions for verifying device-attestation certs produced by
 * `HiveRelayClient.createDeviceAttestation`. The cert payload format is:
 *
 *   payload = primaryPubkey(32) || devicePubkey(32) || expiresAt(8 bytes BE) || label(utf8)
 *   signature = ed25519_sign(payload, primaryPrivateKey)
 *
 * Revocations let the primary identity invalidate a previously-issued cert
 * before its natural `expiresAt`. A revocation is signed by the primary key
 * and refers to the cert's signature (64-byte uniqueness) so lookups are
 * cheap. Revocation payload:
 *
 *   payload = primaryPubkey(32) || revokedCertSig(64) || revokedAt(8 BE) || reason(utf8, ≤256 bytes)
 *   signature = ed25519_sign(payload, primaryPrivateKey)
 *
 * This module is I/O-free so it can be imported from both the Node and
 * Bare runtimes. Persistence and broadcast live above this layer.
 */

import b4a from 'b4a'
import sodium from 'sodium-universal'

const MAX_REASON_BYTES = 256

/**
 * Verify a device delegation cert.
 *
 * @param {object} cert - The cert to verify (shape produced by createDeviceAttestation)
 * @returns {{valid: boolean, primaryPubkey?: string, reason?: string}}
 */
export function verifyDelegationCert (cert) {
  try {
    if (!cert || typeof cert !== 'object') {
      return { valid: false, reason: 'missing cert' }
    }
    if (cert.version !== 1) {
      return { valid: false, reason: 'unsupported version' }
    }
    if (typeof cert.primaryPubkey !== 'string' || typeof cert.devicePubkey !== 'string') {
      return { valid: false, reason: 'missing pubkey' }
    }
    if (typeof cert.signature !== 'string') {
      return { valid: false, reason: 'missing signature' }
    }
    if (typeof cert.expiresAt !== 'number' || !Number.isFinite(cert.expiresAt)) {
      return { valid: false, reason: 'missing expiresAt' }
    }
    if (Date.now() > cert.expiresAt) {
      return { valid: false, reason: 'expired' }
    }

    let primaryPk, devicePk, sig
    try {
      primaryPk = b4a.from(cert.primaryPubkey, 'hex')
      devicePk = b4a.from(cert.devicePubkey, 'hex')
      sig = b4a.from(cert.signature, 'hex')
    } catch (err) {
      return { valid: false, reason: 'malformed hex' }
    }

    if (primaryPk.length !== 32 || devicePk.length !== 32 || sig.length !== 64) {
      return { valid: false, reason: 'malformed' }
    }

    const expBuf = b4a.alloc(8)
    new DataView(expBuf.buffer, expBuf.byteOffset).setBigUint64(0, BigInt(cert.expiresAt), false)
    const labelBuf = b4a.from(typeof cert.label === 'string' ? cert.label : '', 'utf8')
    const payload = b4a.concat([primaryPk, devicePk, expBuf, labelBuf])

    const ok = sodium.crypto_sign_verify_detached(sig, payload, primaryPk)
    if (!ok) return { valid: false, reason: 'bad signature' }

    return { valid: true, primaryPubkey: cert.primaryPubkey }
  } catch (err) {
    return { valid: false, reason: err && err.message ? err.message : String(err) }
  }
}

/**
 * Build a revocation message for a specific cert. Called by the primary
 * device (the one with the primary secret key) when a previously-issued
 * cert needs to be invalidated early (e.g. a paired device is lost).
 *
 * @param {object} cert - The cert being revoked (produced by createDeviceAttestation)
 * @param {Buffer|Uint8Array} primarySecretKey - The primary device's ed25519 secret key
 * @param {object} [opts]
 * @param {string} [opts.reason] - Human-readable reason, ≤256 UTF-8 bytes
 * @returns {{version, primaryPubkey, revokedCertSignature, revokedAt, reason, signature}}
 */
export function createRevocation (cert, primarySecretKey, opts = {}) {
  if (!cert || typeof cert !== 'object' || typeof cert.signature !== 'string') {
    throw new Error('createRevocation: invalid cert')
  }
  if (!primarySecretKey || primarySecretKey.length !== 64) {
    throw new Error('createRevocation: primarySecretKey must be a 64-byte ed25519 secret key')
  }

  const reason = typeof opts.reason === 'string' ? opts.reason : ''
  const reasonBuf = b4a.from(reason, 'utf8')
  if (reasonBuf.length > MAX_REASON_BYTES) {
    throw new Error(`createRevocation: reason exceeds ${MAX_REASON_BYTES} bytes`)
  }

  const primaryPk = b4a.from(cert.primaryPubkey, 'hex')
  const revokedSig = b4a.from(cert.signature, 'hex')
  if (primaryPk.length !== 32 || revokedSig.length !== 64) {
    throw new Error('createRevocation: malformed cert primaryPubkey/signature')
  }

  const revokedAt = Date.now()
  const tsBuf = b4a.alloc(8)
  new DataView(tsBuf.buffer, tsBuf.byteOffset).setBigUint64(0, BigInt(revokedAt), false)

  const payload = b4a.concat([primaryPk, revokedSig, tsBuf, reasonBuf])
  const sig = b4a.alloc(sodium.crypto_sign_BYTES)
  sodium.crypto_sign_detached(sig, payload, primarySecretKey)

  return {
    version: 1,
    primaryPubkey: cert.primaryPubkey,
    revokedCertSignature: cert.signature,
    revokedAt,
    reason,
    signature: b4a.toString(sig, 'hex')
  }
}

/**
 * Verify a revocation message. Must be signed by the same primary pubkey
 * that signed the original cert — anyone else's "revocation" is a forgery
 * and must be rejected. Revocation expiry is tied to the cert it invalidates
 * (handled by the caller: drop the revocation after the cert's expiresAt).
 *
 * @param {object} rev - The revocation message
 * @returns {{valid: boolean, revokedCertSignature?: string, reason?: string}}
 */
export function verifyRevocation (rev) {
  try {
    if (!rev || typeof rev !== 'object') return { valid: false, reason: 'missing revocation' }
    if (rev.version !== 1) return { valid: false, reason: 'unsupported version' }
    if (typeof rev.primaryPubkey !== 'string' || typeof rev.revokedCertSignature !== 'string' ||
        typeof rev.signature !== 'string') {
      return { valid: false, reason: 'missing fields' }
    }
    if (typeof rev.revokedAt !== 'number' || !Number.isFinite(rev.revokedAt)) {
      return { valid: false, reason: 'missing revokedAt' }
    }

    let primaryPk, revokedSig, sig
    try {
      primaryPk = b4a.from(rev.primaryPubkey, 'hex')
      revokedSig = b4a.from(rev.revokedCertSignature, 'hex')
      sig = b4a.from(rev.signature, 'hex')
    } catch (_) {
      return { valid: false, reason: 'malformed hex' }
    }
    if (primaryPk.length !== 32 || revokedSig.length !== 64 || sig.length !== 64) {
      return { valid: false, reason: 'malformed' }
    }

    const reason = typeof rev.reason === 'string' ? rev.reason : ''
    const reasonBuf = b4a.from(reason, 'utf8')
    if (reasonBuf.length > MAX_REASON_BYTES) {
      return { valid: false, reason: 'reason-too-long' }
    }

    const tsBuf = b4a.alloc(8)
    new DataView(tsBuf.buffer, tsBuf.byteOffset).setBigUint64(0, BigInt(rev.revokedAt), false)
    const payload = b4a.concat([primaryPk, revokedSig, tsBuf, reasonBuf])

    const ok = sodium.crypto_sign_verify_detached(sig, payload, primaryPk)
    if (!ok) return { valid: false, reason: 'bad signature' }

    return { valid: true, revokedCertSignature: rev.revokedCertSignature }
  } catch (err) {
    return { valid: false, reason: err && err.message ? err.message : String(err) }
  }
}

export default verifyDelegationCert