OverviewFilesCommitsIssuesDocsReleasesPeers

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

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

export const WITNESSLOG_NAMESPACE = 'witness'
export const WITNESSLOG_RECORD_VERSION = 1
export const WITNESSLOG_RECORD_TYPE_AVAILABILITY = 'hiverelay.witness.availability'
export const WITNESSLOG_SIGNATURE_DOMAIN = 'hiverelay.witnesslog.v1'
export const WITNESSLOG_DEFAULT_MAX_CLOCK_SKEW_MS = 5 * 60 * 1000
export const WITNESSLOG_DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000
export const WITNESSLOG_DEFAULT_MAX_TTL_MS = 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

export function createWitnessRecord (input = {}) {
  const core = normalizeWitnessCore(input, { allowMissingId: true })
  const withId = {
    id: core.id || witnessRecordId(core),
    ...without(core, ['id'])
  }
  return normalizeWitnessCore(withId)
}

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

export function verifyWitnessRecord (input, opts = {}) {
  let record
  try {
    record = normalizeWitnessRecord(input)
    assertWitnessTimestamps(record, opts)
  } catch {
    return false
  }

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

export function assertWitnessRecord (input, opts = {}) {
  const record = normalizeWitnessRecord(input)
  assertWitnessTimestamps(record, opts)
  if (!verifyWitnessRecord(record, opts)) throw new Error('WitnessLog: bad signature')
  return record
}

export function canonicalWitnessRecord (input) {
  const record = input && input.signature
    ? unsignedWitnessRecord(normalizeWitnessRecord(input))
    : createWitnessRecord(input)
  return stable(record)
}

export function witnessRecordBytes (input) {
  const record = input && input.signature ? unsignedWitnessRecord(normalizeWitnessRecord(input)) : createWitnessRecord(input)
  return b4a.from(WITNESSLOG_SIGNATURE_DOMAIN + '|' + stable(record), 'utf8')
}

// blake2b-256 of the raw hypercore key -> the public target id, so a witness
// can attest availability 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 redactWitnessRecord (input) {
  const record = normalizeWitnessRecord(input)
  const target = { ...record.target }
  // Read-path redaction ALWAYS strips the raw key — deriving keyHash for a
  // key-only record so no public read leaks the raw hypercore key.
  if (target.key) {
    if (!target.keyHash) target.keyHash = hashTargetKey(target.key)
    delete target.key
  }
  return {
    id: record.id,
    version: record.version,
    type: record.type,
    observedAt: record.observedAt,
    expiresAt: record.expiresAt,
    observer: { ...record.observer },
    relay: { ...record.relay },
    target,
    probe: { ...record.probe },
    result: { ...record.result },
    signature: record.signature
  }
}

export function witnessRecordMarker (input) {
  const record = normalizeWitnessRecord(input)
  return {
    id: record.id,
    observedAt: record.observedAt,
    expiresAt: record.expiresAt,
    observer: record.observer.publicKey,
    relay: record.relay.url,
    // Never expose the raw key on the public marker/SSE surface.
    target: record.target.keyHash || (record.target.key ? hashTargetKey(record.target.key) : null),
    targetKind: record.target.kind,
    ok: record.result.ok,
    latencyMs: record.result.latencyMs,
    httpStatus: record.result.httpStatus == null ? null : record.result.httpStatus,
    errorCode: record.result.errorCode == null ? null : record.result.errorCode
  }
}

export function targetMatchesWitnessRecord (record, target) {
  if (!target) return true
  const normalized = normalizeTargetQuery(target)
  if (!normalized) return false
  const witness = normalizeWitnessRecord(record).target
  return witness.key === normalized || witness.keyHash === normalized
}

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

export function normalizeWitnessOutboxRecord (input) {
  const record = normalizeWitnessRecord(input)
  return {
    ...record,
    _ns: WITNESSLOG_NAMESPACE
  }
}

export function verifyWitnessOutboxRecord ({ appId, data }, opts = {}) {
  if (!data || typeof data !== 'object') return false
  if (data._ns !== WITNESSLOG_NAMESPACE) return false
  const record = stripOutboxFields(data)
  if (!verifyWitnessRecord(record, opts)) return false
  return typeof appId === 'string' && appId.toLowerCase() === record.observer.publicKey.toLowerCase()
}

export function stripOutboxFields (data) {
  const clone = { ...data }
  delete clone._ns
  return clone
}

function normalizeWitnessCore (input, opts = {}) {
  if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('WitnessLog: record must be an object')
  const version = input.version == null ? WITNESSLOG_RECORD_VERSION : input.version
  const type = input.type || WITNESSLOG_RECORD_TYPE_AVAILABILITY
  if (version !== WITNESSLOG_RECORD_VERSION) throw new Error('WitnessLog: unsupported record version')
  if (type !== WITNESSLOG_RECORD_TYPE_AVAILABILITY) throw new Error('WitnessLog: unsupported record type')

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

  const out = {
    version,
    type,
    observedAt,
    expiresAt,
    observer: normalizeObserver(input.observer),
    relay: normalizeRelay(input.relay),
    target: normalizeTarget(input.target),
    probe: normalizeProbe(input.probe),
    result: normalizeResult(input.result)
  }
  if (input.id != null) out.id = normalizeId(input.id)
  else if (!opts.allowMissingId) throw new Error('WitnessLog: missing id')
  return out
}

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

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

function normalizeTarget (target) {
  if (!target || typeof target !== 'object' || Array.isArray(target)) throw new Error('WitnessLog: 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('WitnessLog: target key or keyHash required')
  return {
    kind: normalizeShortString(target.kind || 'hypercore', 'target.kind', 64),
    key,
    keyHash
  }
}

function normalizeProbe (probe) {
  if (!probe || typeof probe !== 'object' || Array.isArray(probe)) throw new Error('WitnessLog: probe required')
  return {
    nonce: normalizeShortString(probe.nonce, 'probe.nonce', 256),
    method: normalizeShortString(probe.method || 'gateway-head', 'probe.method', 64),
    range: probe.range == null ? null : normalizeRange(probe.range)
  }
}

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

function normalizeRange (range) {
  if (!range || typeof range !== 'object' || Array.isArray(range)) throw new Error('WitnessLog: probe.range must be an object')
  return {
    start: range.start == null ? null : normalizeInteger(range.start, 'probe.range.start', 0),
    end: range.end == null ? null : normalizeInteger(range.end, 'probe.range.end', 0)
  }
}

function assertWitnessTimestamps (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('WitnessLog: invalid now')
  const observed = Date.parse(record.observedAt)
  const expires = Date.parse(record.expiresAt)
  const maxSkew = positiveMs(opts.maxClockSkewMs, WITNESSLOG_DEFAULT_MAX_CLOCK_SKEW_MS)
  const maxAge = positiveMs(opts.maxAgeMs, WITNESSLOG_DEFAULT_MAX_AGE_MS)
  const maxTtl = positiveMs(opts.maxTtlMs, WITNESSLOG_DEFAULT_MAX_TTL_MS)
  if (observed > now + maxSkew) throw new Error('WitnessLog: observation is from the future')
  if (observed < now - maxAge) throw new Error('WitnessLog: observation is stale')
  if (expires < now - maxSkew) throw new Error('WitnessLog: observation is expired')
  if (expires - observed > maxTtl) throw new Error('WitnessLog: observation TTL too long')
}

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

function unsignedWitnessRecord (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 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('WitnessLog: invalid ' + label)
  const normalized = value.trim().toLowerCase()
  if (length === 64 && !HEX_32.test(normalized)) throw new Error('WitnessLog: invalid ' + label)
  if (length === 128 && !HEX_64.test(normalized)) throw new Error('WitnessLog: invalid ' + label)
  return normalized
}

function normalizeId (value) {
  return normalizeHex(value, 64, 'id')
}

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('WitnessLog: invalid ' + label)
  const ms = Date.parse(value)
  if (!Number.isFinite(ms)) throw new Error('WitnessLog: invalid ' + label)
  return new Date(ms).toISOString()
}

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

function normalizeShortString (value, label, max) {
  if (typeof value !== 'string' || !value || value.length > max) throw new Error('WitnessLog: 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('WitnessLog: invalid ' + label)
  return number
}

function normalizeNumber (value, label, min) {
  const number = Number(value)
  if (!Number.isFinite(number) || number < min) throw new Error('WitnessLog: 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('WitnessLog: invalid ' + label)
    return b4a.from(hex, 'hex')
  }
  if (value && typeof value.byteLength === 'number' && value.byteLength === bytes) return value
  throw new Error('WitnessLog: invalid ' + label)
}