OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / core / transports / tor / redaction.js

87 lines · javascript ref:
/**
 * Tor-specific observability redaction (`hiverelay.onion/1` §12, RA-07).
 *
 * Two tools:
 *
 * 1. redactTorInfo() — shape transport info for public vs operator surfaces.
 *    Public surfaces get coarse health only: no onion address, no roster size,
 *    no descriptor counters (fine-grained timing), no vports detail.
 *
 * 2. auditPayload() — the forbidden-field privacy gate. Deep-walks a payload
 *    and reports material that must never appear in default/public logs,
 *    metrics, or status: onion addresses, client auth pubkeys, hidden-service
 *    key blobs, roster file contents. Intentionally advertised endpoints (the
 *    capability doc's own onion address) are whitelisted explicitly by the
 *    caller — everything else is a violation.
 */

export const FORBIDDEN_PATTERNS = Object.freeze([
  { name: 'onion-address', re: /\b[a-z2-7]{56}\.onion\b/ },
  { name: 'client-auth-pubkey', re: /\b[a-z2-7]{52}\b/ },
  { name: 'hs-key-blob', re: /ED25519-V3:[A-Za-z0-9+/=]{20,}/ },
  { name: 'auth-private-line', re: /:descriptor:x25519:[A-Za-z2-7]{52}\b/ },
  { name: 'roster-file', re: /"keys"\s*:\s*\[\s*\{\s*"pub"\s*:/ }
])

/**
 * @param {object|null} info  TorTransport.getInfo() payload
 * @param {object} [opts]
 * @param {boolean} [opts.operator]  true = full operator diagnostics
 * @returns {object} surface-appropriate info
 */
export function redactTorInfo (info, { operator = false } = {}) {
  if (!info || typeof info !== 'object') return null
  if (operator) return { ...info }
  const out = {
    running: !!info.running,
    health: typeof info.health === 'string' ? info.health : 'unknown',
    activeConnections: typeof info.activeConnections === 'number' ? info.activeConnections : 0
  }
  // Coarse auth-gate health bit — safe on public surfaces (no addresses).
  if (info.negativeProbe === true || info.negativeProbe === false) {
    out.negativeProbe = info.negativeProbe
  }
  return out
}

/**
 * Deep-walk a payload and report forbidden fields.
 *
 * @param {*} payload
 * @param {object} [opts]
 * @param {string[]} [opts.allowOnionAddresses]  intentionally advertised endpoints
 * @param {number} [opts.maxViolations]
 * @returns {{ ok: boolean, violations: Array<{ name: string, path: string, sample: string }> }}
 */
export function auditPayload (payload, { allowOnionAddresses = [], maxViolations = 50 } = {}) {
  const violations = []
  const allowed = new Set(allowOnionAddresses)

  const check = (value, path) => {
    if (violations.length >= maxViolations) return
    if (typeof value !== 'string') return
    for (const { name, re } of FORBIDDEN_PATTERNS) {
      const m = value.match(re)
      if (!m) continue
      if (name === 'onion-address' && allowed.has(m[0])) continue
      violations.push({ name, path, sample: m[0].slice(0, 24) + (m[0].length > 24 ? '…' : '') })
      break // one violation per string is enough
    }
  }

  const walk = (value, path) => {
    if (violations.length >= maxViolations) return
    if (typeof value === 'string') return check(value, path)
    if (Array.isArray(value)) {
      for (let i = 0; i < value.length; i++) walk(value[i], `${path}[${i}]`)
      return
    }
    if (value && typeof value === 'object') {
      for (const [k, v] of Object.entries(value)) walk(v, path ? `${path}.${k}` : k)
    }
  }
  walk(payload, '')

  return { ok: violations.length === 0, violations }
}