OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / blind-client / qualification.js

169 lines · javascript ref:
import b4a from 'b4a'
import {
  FAMILY,
  OPERATION,
  operationBit
} from '@hiverelay/blind-protocol/wire-runtime-authority'
import { asBytes } from './bytes.js'
import { BlindDescriptorBootstrapHttpClient } from './bootstrap-http.js'
import {
  DescriptorTrustStore,
  createHealthChallenge,
  qualifyDescribeControlEndpoint,
  qualifyRelay,
  verifyHealthResultBytes
} from './describe.js'
import { BlindDirectHttpClient } from './direct-http.js'
import { fail } from './errors.js'

function epoch (value) {
  if (!Number.isSafeInteger(value) || value < 0 || value > 0xffffffff) {
    fail('BAD_CLIENT_INPUT', 'nowEpoch is outside u32')
  }
  return value
}

function requirement (value) {
  if (!value || typeof value !== 'object') fail('BAD_CLIENT_INPUT', 'qualification requirement is required')
  const bit = operationBit(value.familyId, value.operationId)
  if (bit === 0) fail('BAD_CLIENT_INPUT', 'qualification operation is not registered')
  for (const field of ['endpointId', 'requiredRoleBits', 'privacyProfileBit', 'transportSupportBit']) {
    if (!Number.isSafeInteger(value[field]) || value[field] <= 0) {
      fail('BAD_CLIENT_INPUT', `qualification ${field} is invalid`)
    }
  }
  return { ...value, operationBit: bit }
}

function profilePins (values, hashField, label) {
  return Object.freeze(values.map((value, index) => Object.freeze({
    ...value,
    [hashField]: b4a.from(asBytes(value && value[hashField], `${label}[${index}].${hashField}`, 32))
  })))
}

function currentMonotonicMillis () {
  return globalThis.performance && typeof globalThis.performance.now === 'function'
    ? globalThis.performance.now()
    : Date.now()
}

// One bounded qualification attempt. Candidate discovery and source diversity
// stay outside this class; every app can feed it DHT, directory, peer, release-pin
// or user-pin candidates without granting any source relay-membership authority.
export class BlindRelayQualifier {
  constructor (options = {}) {
    this.runtime = options.runtime
    this.nowEpoch = typeof options.nowEpoch === 'function' ? options.nowEpoch : null
    if (!this.nowEpoch) fail('BAD_CLIENT_INPUT', 'nowEpoch provider is required')
    this.monotonicMillis = typeof options.monotonicMillis === 'function'
      ? options.monotonicMillis
      : currentMonotonicMillis
    if (!Array.isArray(options.supportedProtocolProfiles) ||
        !Array.isArray(options.supportedTransportProfiles)) {
      fail('BAD_CLIENT_INPUT', 'supported protocol and transport profile pins are required')
    }
    this.supportedProtocolProfiles = profilePins(
      options.supportedProtocolProfiles, 'profileHash', 'supportedProtocolProfiles')
    this.supportedTransportProfiles = profilePins(
      options.supportedTransportProfiles, 'transportProfileHash', 'supportedTransportProfiles')
    this.trustStore = options.trustStore || new DescriptorTrustStore()
    this.bootstrapClient = options.bootstrapClient || new BlindDescriptorBootstrapHttpClient({
      runtime: this.runtime,
      fetch: options.fetch,
      allowInsecureLoopback: options.allowInsecureLoopback
    })
    this.directClient = options.directClient || new BlindDirectHttpClient({
      runtime: this.runtime,
      fetch: options.fetch,
      allowInsecureLoopback: options.allowInsecureLoopback
    })
  }

  async qualifyCandidate (candidate, requested, options = {}) {
    if (!candidate || typeof candidate !== 'object') fail('BAD_CLIENT_INPUT', 'relay candidate is required')
    const needed = requirement(requested)
    const nowEpoch = epoch(this.nowEpoch())
    const expectedDescriptorHash = b4a.from(asBytes(
      candidate.expectedDescriptorHash, 'candidate expectedDescriptorHash', 32))
    const canonicalUrl = b4a.from(asBytes(candidate.canonicalUrl, 'candidate canonicalUrl'))
    const continuityRootRelayPublicKey = candidate.continuityRootRelayPublicKey == null
      ? null
      : b4a.from(asBytes(candidate.continuityRootRelayPublicKey,
        'candidate continuityRootRelayPublicKey', 32))
    const verifiedDescriptor = await this.bootstrapClient.fetchVerifiedDescriptor({
      canonicalUrl,
      expectedDescriptorHash,
      nowEpoch,
      supportedProtocolProfiles: this.supportedProtocolProfiles,
      supportedTransportProfiles: this.supportedTransportProfiles,
      timeoutMillis: options.timeoutMillis,
      signal: options.signal
    })
    const acceptOptions = verifiedDescriptor.descriptorSequence === 0n
      ? {
          pinnedDescriptorHash: expectedDescriptorHash,
          continuityRootRelayPublicKey
        }
      : {
          continuityRootRelayPublicKey
        }
    const trustedDescriptor = await this.trustStore.accept(verifiedDescriptor, acceptOptions)
    const challenge = createHealthChallenge({
      runtime: this.runtime,
      trustedDescriptor,
      endpointId: needed.endpointId,
      transportSupportBit: needed.transportSupportBit,
      requestedRoleBits: needed.requiredRoleBits,
      requestedOperationBits: needed.operationBit
    })
    const controlEndpoint = qualifyDescribeControlEndpoint({
      trustedDescriptor,
      nowEpoch,
      familyId: FAMILY.DESCRIBE,
      operationId: OPERATION.DESCRIBE.CHALLENGE,
      endpointId: needed.endpointId,
      requiredRoleBits: needed.requiredRoleBits,
      privacyProfileBit: needed.privacyProfileBit,
      transportSupportBit: needed.transportSupportBit
    })
    const healthResult = await this.directClient.request({
      endpoint: controlEndpoint,
      familyId: FAMILY.DESCRIBE,
      operationId: OPERATION.DESCRIBE.CHALLENGE,
      expectedResultBodyBytes: challenge.wire.expectedResultBodyBytes,
      body: challenge.requestBytes,
      timeoutMillis: options.timeoutMillis,
      signal: options.signal
    })
    if (!healthResult.ok) fail('RELAY_NOT_QUALIFIED', 'relay rejected its signed health challenge')
    const healthObservedMonotonicMillis = this.monotonicMillis()
    const health = verifyHealthResultBytes(
      healthResult.body,
      trustedDescriptor,
      challenge.request,
      { nowEpoch, observedMonotonicMillis: healthObservedMonotonicMillis }
    )
    const endpoint = qualifyRelay({
      trustedDescriptor,
      health,
      nowEpoch,
      nowMonotonicMillis: healthObservedMonotonicMillis,
      familyId: needed.familyId,
      operationId: needed.operationId,
      endpointId: needed.endpointId,
      requiredRoleBits: needed.requiredRoleBits,
      privacyProfileBit: needed.privacyProfileBit,
      transportSupportBit: needed.transportSupportBit
    })
    return Object.freeze({
      endpoint,
      trustedDescriptor,
      verifiedDescriptor,
      health,
      descriptorHash: verifiedDescriptor.descriptorHash,
      continuityRootRelayPublicKey: trustedDescriptor.rootRelayPublicKey
    })
  }
}