OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / services / builtin / repairticket / record.js

412 lines · javascript ref:
import { createHash } from 'node:crypto'
import sodium from 'sodium-universal'
import b4a from 'b4a'

export const REPAIRTICKET_NAMESPACE = 'repair'
export const REPAIRTICKET_RECORD_VERSION = 1
export const REPAIRTICKET_RECORD_TYPE_TICKET = 'hiverelay.repair.ticket'
export const REPAIRTICKET_RECORD_TYPE_CLAIM = 'hiverelay.repair.claim'
export const REPAIRTICKET_RECORD_TYPE_RECEIPT = 'hiverelay.repair.receipt'
export const REPAIRTICKET_RECORD_TYPE_CLOSE = 'hiverelay.repair.close'
export const REPAIRTICKET_SIGNATURE_DOMAIN = 'hiverelay.repairticket.v1'
export const REPAIRTICKET_DEFAULT_MAX_CLOCK_SKEW_MS = 5 * 60 * 1000
export const REPAIRTICKET_DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
export const REPAIRTICKET_DEFAULT_MAX_TTL_MS = 7 * 24 * 60 * 60 * 1000

const HEX_32 = /^[0-9a-f]{64}$/i
const HEX_64 = /^[0-9a-f]{128}$/i
const PUBKEY_BYTES = 32
const SIG_BYTES = 64
const URL_MAX = 2048
const SHORT = 256
const MAX_EVIDENCE = 32
const RECORD_TYPES = new Set([
  REPAIRTICKET_RECORD_TYPE_TICKET,
  REPAIRTICKET_RECORD_TYPE_CLAIM,
  REPAIRTICKET_RECORD_TYPE_RECEIPT,
  REPAIRTICKET_RECORD_TYPE_CLOSE
])

export function createRepairRecord (input = {}) {
  const core = normalizeRepairCore(input, { allowMissingId: true })
  const withId = {
    id: core.id || repairRecordId(core),
    ...without(core, ['id'])
  }
  return normalizeRepairCore(withId)
}

export function signRepairRecord (input, secretKey) {
  const record = createRepairRecord(input)
  const key = decodeHexOrBuffer(secretKey, 64, 'secret key')
  const signature = b4a.alloc(SIG_BYTES)
  sodium.crypto_sign_detached(signature, repairRecordBytes(record), key)
  return {
    ...record,
    signature: b4a.toString(signature, 'hex')
  }
}

export function verifyRepairRecord (input, opts = {}) {
  let record
  try {
    record = normalizeRepairRecord(input)
    assertRepairTimestamps(record, opts)
  } catch {
    return false
  }

  const signature = b4a.from(record.signature, 'hex')
  const publicKey = b4a.from(record.signer.publicKey, 'hex')
  if (signature.byteLength !== SIG_BYTES || publicKey.byteLength !== PUBKEY_BYTES) return false
  return sodium.crypto_sign_verify_detached(signature, repairRecordBytes(record), publicKey)
}

export function assertRepairRecord (input, opts = {}) {
  const record = normalizeRepairRecord(input)
  assertRepairTimestamps(record, opts)
  if (!verifyRepairRecord(record, opts)) throw new Error('RepairTicket: bad signature')
  return record
}

export function canonicalRepairRecord (input) {
  const record = input && input.signature
    ? unsignedRepairRecord(normalizeRepairRecord(input))
    : createRepairRecord(input)
  return stable(record)
}

export function repairRecordBytes (input) {
  const record = input && input.signature ? unsignedRepairRecord(normalizeRepairRecord(input)) : createRepairRecord(input)
  return b4a.from(REPAIRTICKET_SIGNATURE_DOMAIN + '|' + stable(record), 'utf8')
}

// blake2b-256 of the raw hypercore key -> the public target id. Lets a
// repair record identify a target without ever exposing the raw key.
export function hashTargetKey (keyHex) {
  const out = b4a.alloc(32)
  sodium.crypto_generichash(out, b4a.from(String(keyHex).toLowerCase(), 'hex'))
  return b4a.toString(out, 'hex')
}

export function redactRepairRecord (input) {
  const record = normalizeRepairRecord(input)
  const out = clone(record)
  // Read-path redaction must ALWAYS strip the raw key — deriving keyHash when a
  // key-only record was submitted, so no public read ever leaks the raw key.
  if (out.target && out.target.key) {
    if (!out.target.keyHash) out.target.keyHash = hashTargetKey(out.target.key)
    delete out.target.key
  }
  return out
}

export function repairRecordMarker (input, ticket = null) {
  const record = normalizeRepairRecord(input)
  const ticketRecord = ticket ? normalizeRepairRecord(ticket) : null
  const target = record.target || (ticketRecord && ticketRecord.target) || null
  return {
    id: record.id,
    type: record.type,
    ticketId: repairRecordTicketId(record),
    createdAt: record.createdAt,
    expiresAt: record.expiresAt,
    signer: record.signer.publicKey,
    // Never expose the raw key on the public marker/SSE surface.
    target: target ? (target.keyHash || (target.key ? hashTargetKey(target.key) : null)) : null,
    targetKind: target ? target.kind : null,
    reason: record.repair ? record.repair.reason : null,
    action: record.action || null,
    outcome: record.outcome || null,
    ok: record.result ? record.result.ok : null
  }
}

export function targetMatchesRepairRecord (record, target, ticket = null) {
  if (!target) return true
  const normalized = normalizeTargetQuery(target)
  if (!normalized) return false
  const repair = normalizeRepairRecord(record)
  const ticketRecord = ticket ? normalizeRepairRecord(ticket) : null
  const repairTarget = repair.target || (ticketRecord && ticketRecord.target) || null
  if (!repairTarget) return false
  return repairTarget.key === normalized || repairTarget.keyHash === normalized
}

export function normalizeRepairRecord (input) {
  if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('RepairTicket: record must be an object')
  if (input.signature == null) throw new Error('RepairTicket: missing signature')
  const signature = normalizeHex(input.signature, 128, 'signature')
  return {
    ...normalizeRepairCore(input),
    signature
  }
}

export function normalizeRepairOutboxRecord (input) {
  const record = normalizeRepairRecord(input)
  return {
    ...record,
    _ns: REPAIRTICKET_NAMESPACE
  }
}

export function verifyRepairOutboxRecord ({ appId, data }, opts = {}) {
  if (!data || typeof data !== 'object') return false
  if (data._ns !== REPAIRTICKET_NAMESPACE) return false
  const record = stripRepairOutboxFields(data)
  if (!verifyRepairRecord(record, opts)) return false
  return typeof appId === 'string' && appId.toLowerCase() === record.signer.publicKey.toLowerCase()
}

export function stripRepairOutboxFields (data) {
  const copy = { ...data }
  delete copy._ns
  return copy
}

export function repairRecordTicketId (record) {
  const normalized = normalizeRepairRecord(record)
  return normalized.type === REPAIRTICKET_RECORD_TYPE_TICKET ? normalized.id : normalized.ticketId
}

export function repairRecordOpType (record) {
  const type = typeof record === 'string' ? record : normalizeRepairRecord(record).type
  if (type === REPAIRTICKET_RECORD_TYPE_TICKET) return 'ticket'
  if (type === REPAIRTICKET_RECORD_TYPE_CLAIM) return 'claim'
  if (type === REPAIRTICKET_RECORD_TYPE_RECEIPT) return 'receipt'
  if (type === REPAIRTICKET_RECORD_TYPE_CLOSE) return 'close'
  throw new Error('RepairTicket: unsupported record type')
}

function normalizeRepairCore (input, opts = {}) {
  if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('RepairTicket: record must be an object')
  const version = input.version == null ? REPAIRTICKET_RECORD_VERSION : input.version
  const type = input.type || REPAIRTICKET_RECORD_TYPE_TICKET
  if (version !== REPAIRTICKET_RECORD_VERSION) throw new Error('RepairTicket: unsupported record version')
  if (!RECORD_TYPES.has(type)) throw new Error('RepairTicket: unsupported record type')

  const createdAt = normalizeIsoTime(input.createdAt, 'createdAt')
  const expiresAt = normalizeIsoTime(input.expiresAt, 'expiresAt')
  if (Date.parse(expiresAt) <= Date.parse(createdAt)) throw new Error('RepairTicket: expiresAt must be after createdAt')

  const out = {
    version,
    type,
    createdAt,
    expiresAt,
    signer: normalizeSigner(input.signer)
  }

  if (type === REPAIRTICKET_RECORD_TYPE_TICKET) {
    out.target = normalizeTarget(input.target)
    out.repair = normalizeRepairRequest(input.repair)
  } else {
    out.ticketId = normalizeHex(input.ticketId, 64, 'ticketId')
    if (type === REPAIRTICKET_RECORD_TYPE_CLAIM) {
      out.relay = normalizeRelay(input.relay)
      out.action = normalizeAction(input.action)
      if (input.note != null) out.note = normalizeShortString(input.note, 'note', SHORT)
    } else if (type === REPAIRTICKET_RECORD_TYPE_RECEIPT) {
      out.relay = normalizeRelay(input.relay)
      out.proof = normalizeProof(input.proof)
      out.result = normalizeResult(input.result)
    } else {
      out.outcome = normalizeOutcome(input.outcome)
      out.evidence = normalizeEvidence(input.evidence)
      if (input.note != null) out.note = normalizeShortString(input.note, 'note', SHORT)
    }
  }

  if (input.id != null) out.id = normalizeHex(input.id, 64, 'id')
  else if (!opts.allowMissingId) throw new Error('RepairTicket: missing id')
  return out
}

function normalizeSigner (signer) {
  if (!signer || typeof signer !== 'object' || Array.isArray(signer)) throw new Error('RepairTicket: signer required')
  return {
    publicKey: normalizeHex(signer.publicKey, 64, 'signer.publicKey'),
    role: signer.role == null ? null : normalizeShortString(signer.role, 'signer.role', 64),
    appId: signer.appId == null ? null : normalizeShortString(signer.appId, 'signer.appId', SHORT)
  }
}

function normalizeTarget (target) {
  if (!target || typeof target !== 'object' || Array.isArray(target)) throw new Error('RepairTicket: target required')
  const key = target.key == null ? null : normalizeHex(target.key, 64, 'target.key')
  const keyHash = target.keyHash == null ? null : normalizeHex(target.keyHash, 64, 'target.keyHash')
  if (!key && !keyHash) throw new Error('RepairTicket: target key or keyHash required')
  return {
    kind: normalizeShortString(target.kind || 'hypercore', 'target.kind', 64),
    key,
    keyHash
  }
}

function normalizeRepairRequest (repair) {
  if (!repair || typeof repair !== 'object' || Array.isArray(repair)) throw new Error('RepairTicket: repair required')
  return {
    reason: normalizeShortString(repair.reason, 'repair.reason', 128),
    priority: repair.priority == null ? 50 : normalizeInteger(repair.priority, 'repair.priority', 0, 100),
    desiredReplicas: repair.desiredReplicas == null ? 3 : normalizeInteger(repair.desiredReplicas, 'repair.desiredReplicas', 1, 1000),
    observedReplicas: repair.observedReplicas == null ? null : normalizeInteger(repair.observedReplicas, 'repair.observedReplicas', 0, 1000),
    evidence: normalizeEvidence(repair.evidence),
    note: repair.note == null ? null : normalizeShortString(repair.note, 'repair.note', SHORT)
  }
}

function normalizeRelay (relay) {
  if (!relay || typeof relay !== 'object' || Array.isArray(relay)) throw new Error('RepairTicket: relay required')
  return {
    url: normalizeHttpUrl(relay.url),
    operatorKey: relay.operatorKey == null ? null : normalizeHex(relay.operatorKey, 64, 'relay.operatorKey')
  }
}

function normalizeProof (proof) {
  if (!proof || typeof proof !== 'object' || Array.isArray(proof)) throw new Error('RepairTicket: proof required')
  return {
    kind: normalizeShortString(proof.kind || 'witnesslog', 'proof.kind', 64),
    witnessId: proof.witnessId == null ? null : normalizeHex(proof.witnessId, 64, 'proof.witnessId'),
    observedAt: proof.observedAt == null ? null : normalizeIsoTime(proof.observedAt, 'proof.observedAt'),
    headSeq: proof.headSeq == null ? null : normalizeInteger(proof.headSeq, 'proof.headSeq', 0),
    digest: proof.digest == null ? null : normalizeShortString(proof.digest, 'proof.digest', SHORT)
  }
}

function normalizeResult (result) {
  if (!result || typeof result !== 'object' || Array.isArray(result)) throw new Error('RepairTicket: result required')
  return {
    ok: result.ok === true,
    latencyMs: result.latencyMs == null ? null : normalizeNumber(result.latencyMs, 'result.latencyMs', 0),
    bytes: result.bytes == null ? null : normalizeInteger(result.bytes, 'result.bytes', 0),
    errorCode: result.errorCode == null ? null : normalizeShortString(result.errorCode, 'result.errorCode', 128)
  }
}

function normalizeEvidence (evidence) {
  if (evidence == null) return []
  if (!Array.isArray(evidence) || evidence.length > MAX_EVIDENCE) throw new Error('RepairTicket: invalid evidence')
  return evidence.map((id, index) => normalizeHex(id, 64, 'evidence.' + index))
}

function normalizeAction (action) {
  return normalizeShortString(action || 'seed', 'action', 64)
}

function normalizeOutcome (outcome) {
  return normalizeShortString(outcome || 'repaired', 'outcome', 64)
}

function assertRepairTimestamps (record, opts = {}) {
  const now = opts.now == null
    ? Date.now()
    : (opts.now instanceof Date ? opts.now.getTime() : (typeof opts.now === 'number' ? opts.now : Date.parse(opts.now)))
  if (!Number.isFinite(now)) throw new Error('RepairTicket: invalid now')
  const created = Date.parse(record.createdAt)
  const expires = Date.parse(record.expiresAt)
  const maxSkew = positiveMs(opts.maxClockSkewMs, REPAIRTICKET_DEFAULT_MAX_CLOCK_SKEW_MS)
  const maxAge = positiveMs(opts.maxAgeMs, REPAIRTICKET_DEFAULT_MAX_AGE_MS)
  const maxTtl = positiveMs(opts.maxTtlMs, REPAIRTICKET_DEFAULT_MAX_TTL_MS)
  if (created > now + maxSkew) throw new Error('RepairTicket: record is from the future')
  if (created < now - maxAge) throw new Error('RepairTicket: record is stale')
  if (expires < now - maxSkew) throw new Error('RepairTicket: record is expired')
  if (expires - created > maxTtl) throw new Error('RepairTicket: record TTL too long')
}

function repairRecordId (record) {
  return createHash('sha256')
    .update(REPAIRTICKET_SIGNATURE_DOMAIN + '.id|' + stable(record))
    .digest('hex')
}

function unsignedRepairRecord (record) {
  return without(record, ['signature'])
}

function without (obj, keys) {
  const blocked = new Set(keys)
  const out = {}
  for (const key of Object.keys(obj)) {
    if (!blocked.has(key)) out[key] = obj[key]
  }
  return out
}

function clone (value) {
  return value == null ? value : JSON.parse(JSON.stringify(value))
}

function stable (value) {
  if (value === null || typeof value !== 'object') return JSON.stringify(value)
  if (Array.isArray(value)) return '[' + value.map(stable).join(',') + ']'
  return '{' + Object.keys(value).sort().map(key => JSON.stringify(key) + ':' + stable(value[key])).join(',') + '}'
}

function normalizeHex (value, length, label) {
  if (typeof value !== 'string') throw new Error('RepairTicket: invalid ' + label)
  const normalized = value.trim().toLowerCase()
  if (length === 64 && !HEX_32.test(normalized)) throw new Error('RepairTicket: invalid ' + label)
  if (length === 128 && !HEX_64.test(normalized)) throw new Error('RepairTicket: invalid ' + label)
  return normalized
}

function normalizeTargetQuery (value) {
  if (typeof value !== 'string') return null
  const normalized = value.trim().toLowerCase()
  return HEX_32.test(normalized) ? normalized : null
}

function normalizeIsoTime (value, label) {
  if (typeof value !== 'string' || !value) throw new Error('RepairTicket: invalid ' + label)
  const ms = Date.parse(value)
  if (!Number.isFinite(ms)) throw new Error('RepairTicket: invalid ' + label)
  return new Date(ms).toISOString()
}

function normalizeHttpUrl (value) {
  if (typeof value !== 'string' || !value || value.length > URL_MAX) throw new Error('RepairTicket: invalid relay.url')
  let url
  try {
    url = new URL(value)
  } catch {
    throw new Error('RepairTicket: invalid relay.url')
  }
  if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('RepairTicket: invalid relay.url')
  url.hash = ''
  return url.toString()
}

function normalizeShortString (value, label, max) {
  if (typeof value !== 'string' || !value || value.length > max) throw new Error('RepairTicket: invalid ' + label)
  return value
}

function normalizeInteger (value, label, min, max = Number.MAX_SAFE_INTEGER) {
  const number = Number(value)
  if (!Number.isSafeInteger(number) || number < min || number > max) throw new Error('RepairTicket: invalid ' + label)
  return number
}

function normalizeNumber (value, label, min) {
  const number = Number(value)
  if (!Number.isFinite(number) || number < min) throw new Error('RepairTicket: invalid ' + label)
  return number
}

function positiveMs (value, fallback) {
  const number = Number(value)
  return Number.isFinite(number) && number > 0 ? number : fallback
}

function decodeHexOrBuffer (value, bytes, label) {
  if (typeof value === 'string') {
    const hex = value.trim().toLowerCase()
    if (!/^[0-9a-f]+$/i.test(hex) || hex.length !== bytes * 2) throw new Error('RepairTicket: invalid ' + label)
    return b4a.from(hex, 'hex')
  }
  if (value && typeof value.byteLength === 'number' && value.byteLength === bytes) return value
  throw new Error('RepairTicket: invalid ' + label)
}