OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / blind-protocol / client-composition-v3.js

411 lines · javascript ref:
import b4a from 'b4a'
import c from 'compact-encoding'
import { decodeCanonical } from './codec.js'
import { protocolError } from './errors.js'
import {
  FORWARD_HTTPS_REQUEST_KIND_V1,
  FORWARD_HTTPS_REQUEST_ROLE_V1,
  FORWARD_HTTPS_RESULT_ROLE_V1,
  blindForwardHttpsOriginForwardTurnRequestV1,
  blindForwardHttpsOriginForwardTurnResultV1,
  forwardHttpsCapabilityPrefixHashV1,
  forwardHttpsOriginRequestCommitmentV1,
  forwardHttpsParentCapabilityPrefixHashV1,
  forwardHttpsStableSessionIdV1,
  forwardHttpsTargetResultChainHashV1,
  verifyForwardHttpsParentCapabilitySignatureV1,
  verifyForwardHttpsResultSignatureV1
} from './wire-v3.js'

const MAX_U64 = (1n << 64n) - 1n

export const CLIENT_COMPOSITION_V3_PROTOCOL = Object.freeze({ authorityVersion: 3, formatMajor: 3, formatMinor: 0 })

export const CLIENT_COMPOSITION_SCHEMA_V3 = Object.freeze({
  ForwardHttpsVerifiedEndpointV3: 9,
  ForwardHttpsSessionV3: 10
})

export const FORWARD_HTTPS_OUTSTANDING_STATE_V3 = Object.freeze({
  NONE: 0,
  PERSISTED_BEFORE_FETCH: 1,
  AWAITING_DEFINITIVE_TARGET: 2
})

export const CLIENT_COMPOSITION_V3_SCHEMA_DECLARATIONS = Object.freeze([
  Object.freeze({
    schemaId: 9,
    schemaName: 'ForwardHttpsVerifiedEndpointV3',
    fields: Object.freeze([
      'version:u8=3', 'releaseProfileId:u8=2', 'routeKind:u8=7', 'wireV3AbiHash:fixed32[nonzero]',
      'verifiedEndpointHandleHash:fixed32[nonzero;opaque-exact-FORWARD-operation]',
      'sourceRelayPublicKey:fixed32[nonzero]', 'sourceDescriptorSequence:u64be[nonzero]', 'sourceDescriptorHash:fixed32[nonzero]',
      'targetCatalogEntryId:fixed32[nonzero]', 'targetRelayPublicKey:fixed32[nonzero;different-source]',
      'targetDescriptorSequence:u64be[nonzero]', 'targetDescriptorHash:fixed32[nonzero]',
      'signedDescriptorHash:fixed32[nonzero]', 'signedHealthHash:fixed32[nonzero]',
      'descriptorFresh:true', 'signedHealthFresh:true', 'credentialFreeHttps:true',
      'cookies:false', 'authorization:false', 'referrer:false', 'redirect:false',
      'exactRequestBytes:65536', 'exactResultBytes:65536', 'continuityBackend:INDEXEDDB_PERSISTENT',
      'no-url-host-ip-dial-or-credential-fields'
    ])
  }),
  Object.freeze({
    schemaId: 10,
    schemaName: 'ForwardHttpsSessionV3',
    fields: Object.freeze([
      'version:u8=3', 'verifiedEndpoint:ForwardHttpsVerifiedEndpointV3',
      'stableSessionId:fixed32[nonzero]', 'capabilityPrefixHash:fixed32[nonzero]', 'clientSessionNonce:fixed32[nonzero]',
      'nextSequence:u64be', 'previousTargetResultHash:fixed32[zero-iff-nextSequence-zero]',
      'terminal:u8[0|1]', 'outstandingState:u8[NONE=0|PERSISTED_BEFORE_FETCH=1|AWAITING_DEFINITIVE_TARGET=2]',
      'outstandingOriginRequestCommitment:fixed32[zero-iff-NONE]',
      'outstandingOriginRequest:bytes[zero-iff-NONE|exact65536-otherwise]',
      'lastDefinitiveTargetResult:bytes[zero-iff-nextSequence-zero|exact65536-otherwise]',
      'indexeddb-transactional-persistence-required', 'source-results-never-advance', 'forwardReadinessOperationBits:0'
    ])
  })
])

function fail (message) {
  protocolError('BAD_CLIENT_COMPOSITION_V3', message)
}

function bytes (value, length, field, nonzero = false) {
  if (!value || typeof value.byteLength !== 'number') fail(`${field} must be bytes`)
  value = b4a.isBuffer(value)
    ? value
    : ArrayBuffer.isView(value)
      ? b4a.from(value.buffer, value.byteOffset, value.byteLength)
      : b4a.from(value)
  if (value.byteLength !== length) fail(`${field} must be exactly ${length} bytes`)
  if (nonzero && isZero(value)) fail(`${field} must be nonzero`)
  return b4a.from(value)
}

function variableBytes (value, field) {
  if (value == null) return b4a.alloc(0)
  if (typeof value.byteLength !== 'number') fail(`${field} must be bytes`)
  return b4a.from(value)
}

function isZero (value) {
  for (const byte of value) if (byte !== 0) return false
  return true
}

function u64 (value, field, nonzero = false) {
  if (typeof value === 'number') {
    if (!Number.isSafeInteger(value) || value < 0) fail(`${field} must be an unsigned safe integer or bigint`)
    value = BigInt(value)
  }
  if (typeof value !== 'bigint' || value < 0n || value > MAX_U64 || (nonzero && value === 0n)) fail(`${field} is outside u64`)
  return value
}

function exactObjectKeys (value, expected, field) {
  const actual = Object.keys(value).sort()
  const wanted = [...expected].sort()
  if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) {
    fail(`${field} has an unknown or missing field`)
  }
}

const VERIFIED_ENDPOINT_FIELDS_V3 = Object.freeze([
  'version', 'releaseProfileId', 'routeKind', 'wireV3AbiHash', 'verifiedEndpointHandleHash',
  'sourceRelayPublicKey', 'sourceDescriptorSequence', 'sourceDescriptorHash', 'targetCatalogEntryId',
  'targetRelayPublicKey', 'targetDescriptorSequence', 'targetDescriptorHash', 'signedDescriptorHash',
  'signedHealthHash', 'descriptorFresh', 'signedHealthFresh', 'credentialFreeHttps', 'cookies',
  'authorization', 'referrer', 'redirect', 'exactRequestBytes', 'exactResultBytes', 'continuityBackend'
])

const SESSION_FIELDS_V3 = Object.freeze([
  'version', 'verifiedEndpoint', 'stableSessionId', 'capabilityPrefixHash', 'clientSessionNonce',
  'nextSequence', 'previousTargetResultHash', 'terminal', 'outstandingState',
  'outstandingOriginRequestCommitment', 'outstandingOriginRequest', 'lastDefinitiveTargetResult'
])

function assertCapabilityMatchesEndpoint (capability, endpoint) {
  for (const field of ['sourceRelayPublicKey', 'sourceDescriptorHash', 'targetRelayPublicKey', 'targetDescriptorHash', 'targetCatalogEntryId']) {
    if (!b4a.equals(capability[field], endpoint[field])) fail(`session capability ${field} does not match the verified endpoint`)
  }
  if (capability.sourceDescriptorSequence !== endpoint.sourceDescriptorSequence ||
      capability.targetDescriptorSequence !== endpoint.targetDescriptorSequence) {
    fail('session capability descriptor sequence does not match the verified endpoint')
  }
}

export function assertForwardHttpsVerifiedEndpointV3 (value, expectedWireV3AbiHash = null) {
  if (!value || typeof value !== 'object') fail('verified endpoint must be an object')
  for (const forbidden of [
    'url', 'host', 'hostname', 'ip', 'ipAddress', 'dialAddress', 'credentials', 'username', 'password', 'authorizationHeader'
  ]) {
    if (forbidden in value) fail(`verified endpoint ${forbidden} is forbidden`)
  }
  exactObjectKeys(value, VERIFIED_ENDPOINT_FIELDS_V3, 'verified endpoint')
  if (value.version !== 3 || value.releaseProfileId !== 2 || value.routeKind !== 7) fail('verified endpoint fixed profile is invalid')
  const output = {
    version: 3,
    releaseProfileId: 2,
    routeKind: 7,
    wireV3AbiHash: bytes(value.wireV3AbiHash, 32, 'wireV3AbiHash', true),
    verifiedEndpointHandleHash: bytes(value.verifiedEndpointHandleHash, 32, 'verifiedEndpointHandleHash', true),
    sourceRelayPublicKey: bytes(value.sourceRelayPublicKey, 32, 'sourceRelayPublicKey', true),
    sourceDescriptorSequence: u64(value.sourceDescriptorSequence, 'sourceDescriptorSequence', true),
    sourceDescriptorHash: bytes(value.sourceDescriptorHash, 32, 'sourceDescriptorHash', true),
    targetCatalogEntryId: bytes(value.targetCatalogEntryId, 32, 'targetCatalogEntryId', true),
    targetRelayPublicKey: bytes(value.targetRelayPublicKey, 32, 'targetRelayPublicKey', true),
    targetDescriptorSequence: u64(value.targetDescriptorSequence, 'targetDescriptorSequence', true),
    targetDescriptorHash: bytes(value.targetDescriptorHash, 32, 'targetDescriptorHash', true),
    signedDescriptorHash: bytes(value.signedDescriptorHash, 32, 'signedDescriptorHash', true),
    signedHealthHash: bytes(value.signedHealthHash, 32, 'signedHealthHash', true),
    descriptorFresh: value.descriptorFresh,
    signedHealthFresh: value.signedHealthFresh,
    credentialFreeHttps: value.credentialFreeHttps,
    cookies: value.cookies,
    authorization: value.authorization,
    referrer: value.referrer,
    redirect: value.redirect,
    exactRequestBytes: value.exactRequestBytes,
    exactResultBytes: value.exactResultBytes,
    continuityBackend: value.continuityBackend
  }
  if (b4a.equals(output.sourceRelayPublicKey, output.targetRelayPublicKey)) fail('source and target relay public keys must differ')
  if (expectedWireV3AbiHash != null && !b4a.equals(output.wireV3AbiHash, bytes(expectedWireV3AbiHash, 32, 'expectedWireV3AbiHash', true))) {
    fail('verified endpoint WIRE v3 ABI hash does not match')
  }
  if (output.descriptorFresh !== true || output.signedHealthFresh !== true || output.credentialFreeHttps !== true ||
      output.cookies !== false || output.authorization !== false || output.referrer !== false || output.redirect !== false ||
      output.exactRequestBytes !== 65_536 || output.exactResultBytes !== 65_536 ||
      output.continuityBackend !== 'INDEXEDDB_PERSISTENT') {
    fail('verified endpoint trust, privacy, size, or continuity policy is incomplete')
  }
  return Object.freeze(output)
}

export function assertForwardHttpsSessionV3 (value, expectedWireV3AbiHash = null) {
  if (!value || typeof value !== 'object' || value.version !== 3) fail('session fixed header is invalid')
  exactObjectKeys(value, SESSION_FIELDS_V3, 'session')
  const verifiedEndpoint = assertForwardHttpsVerifiedEndpointV3(value.verifiedEndpoint, expectedWireV3AbiHash)
  const stableSessionId = bytes(value.stableSessionId, 32, 'stableSessionId', true)
  const capabilityPrefixHash = bytes(value.capabilityPrefixHash, 32, 'capabilityPrefixHash', true)
  const clientSessionNonce = bytes(value.clientSessionNonce, 32, 'clientSessionNonce', true)
  const nextSequence = u64(value.nextSequence, 'nextSequence')
  const previousTargetResultHash = bytes(value.previousTargetResultHash, 32, 'previousTargetResultHash')
  if ((nextSequence === 0n) !== isZero(previousTargetResultHash)) fail('previous target result hash must be zero iff nextSequence is zero')
  if (value.terminal !== 0 && value.terminal !== 1) fail('terminal must be 0 or 1')
  if (![0, 1, 2].includes(value.outstandingState)) fail('outstandingState is outside the closed registry')
  const commitment = bytes(value.outstandingOriginRequestCommitment, 32, 'outstandingOriginRequestCommitment')
  const request = variableBytes(value.outstandingOriginRequest, 'outstandingOriginRequest')
  if (value.outstandingState === FORWARD_HTTPS_OUTSTANDING_STATE_V3.NONE) {
    if (!isZero(commitment) || request.byteLength !== 0) fail('NONE outstanding state requires zero commitment and zero request bytes')
  } else if (isZero(commitment) || request.byteLength !== 65_536) {
    fail('persisted outstanding state requires a nonzero commitment and exact 65536-byte request')
  } else {
    const decoded = decodeCanonical(blindForwardHttpsOriginForwardTurnRequestV1, request, { copyBytes: true })
    if (decoded.requestRole !== FORWARD_HTTPS_REQUEST_ROLE_V1.ORIGIN_TEMPLATE ||
        decoded.sequence !== nextSequence ||
        !b4a.equals(decoded.stableSessionId, stableSessionId) ||
        !b4a.equals(decoded.clientSessionNonce, clientSessionNonce) ||
        !b4a.equals(decoded.previousTargetResultHash, previousTargetResultHash) ||
        !b4a.equals(forwardHttpsOriginRequestCommitmentV1(request), commitment) ||
        !b4a.equals(forwardHttpsCapabilityPrefixHashV1(request), capabilityPrefixHash) ||
        !b4a.equals(forwardHttpsStableSessionIdV1(decoded.parentCapability, clientSessionNonce), stableSessionId)) {
      fail('outstanding origin request does not bind the exact session state')
    }
    assertCapabilityMatchesEndpoint(decoded.parentCapability, verifiedEndpoint)
    if ((nextSequence === 0n) !== (decoded.requestKind === FORWARD_HTTPS_REQUEST_KIND_V1.OPEN)) {
      fail('only the sequence-zero request may be OPEN')
    }
  }
  const lastResult = variableBytes(value.lastDefinitiveTargetResult, 'lastDefinitiveTargetResult')
  if (nextSequence === 0n ? lastResult.byteLength !== 0 : lastResult.byteLength !== 65_536) {
    fail('last definitive target result size does not match nextSequence')
  }
  if (nextSequence !== 0n) {
    const decoded = decodeCanonical(blindForwardHttpsOriginForwardTurnResultV1, lastResult, { copyBytes: true })
    if (decoded.resultRole !== FORWARD_HTTPS_RESULT_ROLE_V1.TARGET_RESULT ||
        decoded.sequence !== nextSequence - 1n ||
        !b4a.equals(decoded.stableSessionId, stableSessionId) ||
        !b4a.equals(forwardHttpsStableSessionIdV1(decoded.finalizedParentCapability, clientSessionNonce), stableSessionId) ||
        !b4a.equals(forwardHttpsParentCapabilityPrefixHashV1(decoded.finalizedParentCapability), capabilityPrefixHash) ||
        !b4a.equals(forwardHttpsTargetResultChainHashV1(lastResult), previousTargetResultHash) ||
        !b4a.equals(decoded.signerPublicKey, verifiedEndpoint.targetRelayPublicKey) ||
        decoded.signerDescriptorSequence !== verifiedEndpoint.targetDescriptorSequence ||
        !b4a.equals(decoded.signerDescriptorHash, verifiedEndpoint.targetDescriptorHash) ||
        !verifyForwardHttpsParentCapabilitySignatureV1(decoded.finalizedParentCapability) ||
        !verifyForwardHttpsResultSignatureV1(lastResult)) {
      fail('last definitive target result does not bind the exact session chain and target authority')
    }
    assertCapabilityMatchesEndpoint(decoded.finalizedParentCapability, verifiedEndpoint)
    if (decoded.requestKind === FORWARD_HTTPS_REQUEST_KIND_V1.CLOSE && value.terminal !== 1) {
      fail('a definitive CLOSE request requires terminal state')
    }
  }
  if (value.terminal === 1 && value.outstandingState !== FORWARD_HTTPS_OUTSTANDING_STATE_V3.NONE) {
    fail('terminal session cannot retain a live outstanding request')
  }
  return Object.freeze({
    version: 3,
    verifiedEndpoint,
    stableSessionId,
    capabilityPrefixHash,
    clientSessionNonce,
    nextSequence,
    previousTargetResultHash,
    terminal: value.terminal,
    outstandingState: value.outstandingState,
    outstandingOriginRequestCommitment: commitment,
    outstandingOriginRequest: request,
    lastDefinitiveTargetResult: lastResult
  })
}

function list (encoding) {
  return {
    preencode (state, values) {
      c.uint.preencode(state, values.length)
      for (const value of values) encoding.preencode(state, value)
    },
    encode (state, values) {
      c.uint.encode(state, values.length)
      for (const value of values) encoding.encode(state, value)
    },
    decode (state) {
      const length = c.uint.decode(state)
      const values = []
      for (let i = 0; i < length; i++) values.push(encoding.decode(state))
      return values
    }
  }
}

const schemaEncoding = {
  preencode (state, value) {
    c.uint.preencode(state, value.schemaId)
    c.string.preencode(state, value.schemaName)
    c.buffer.preencode(state, value.canonicalDeclarationBytes)
  },
  encode (state, value) {
    c.uint.encode(state, value.schemaId)
    c.string.encode(state, value.schemaName)
    c.buffer.encode(state, value.canonicalDeclarationBytes)
  },
  decode (state) {
    return {
      schemaId: c.uint.decode(state),
      schemaName: c.string.decode(state),
      canonicalDeclarationBytes: b4a.from(c.buffer.decode(state))
    }
  }
}

const schemasEncoding = list(schemaEncoding)
const declarationBytes = schema => b4a.from(JSON.stringify({ name: schema.schemaName, fields: schema.fields }), 'utf8')

export const clientCompositionV3Encoding = {
  preencode (state, value) {
    c.string.preencode(state, value.magic)
    c.uint.preencode(state, value.authorityVersion)
    c.uint.preencode(state, value.formatMajor)
    c.uint.preencode(state, value.formatMinor)
    c.buffer.preencode(state, value.baseClientCompositionV2FormatHash)
    c.buffer.preencode(state, value.wireV3AbiHash)
    c.uint.preencode(state, value.baseSchemaCount)
    schemasEncoding.preencode(state, value.additionalSchemas)
    c.uint.preencode(state, value.forwardReadinessOperationBits)
  },
  encode (state, value) {
    c.string.encode(state, value.magic)
    c.uint.encode(state, value.authorityVersion)
    c.uint.encode(state, value.formatMajor)
    c.uint.encode(state, value.formatMinor)
    c.buffer.encode(state, value.baseClientCompositionV2FormatHash)
    c.buffer.encode(state, value.wireV3AbiHash)
    c.uint.encode(state, value.baseSchemaCount)
    schemasEncoding.encode(state, value.additionalSchemas)
    c.uint.encode(state, value.forwardReadinessOperationBits)
  },
  decode (state) {
    return {
      magic: c.string.decode(state),
      authorityVersion: c.uint.decode(state),
      formatMajor: c.uint.decode(state),
      formatMinor: c.uint.decode(state),
      baseClientCompositionV2FormatHash: b4a.from(c.buffer.decode(state)),
      wireV3AbiHash: b4a.from(c.buffer.decode(state)),
      baseSchemaCount: c.uint.decode(state),
      additionalSchemas: schemasEncoding.decode(state),
      forwardReadinessOperationBits: c.uint.decode(state)
    }
  }
}

export function createClientCompositionV3Value (baseClientCompositionV2FormatHash, wireV3AbiHash) {
  return {
    magic: 'hiverelay-blind-client-composition-v3',
    ...CLIENT_COMPOSITION_V3_PROTOCOL,
    baseClientCompositionV2FormatHash: bytes(baseClientCompositionV2FormatHash, 32, 'baseClientCompositionV2FormatHash', true),
    wireV3AbiHash: bytes(wireV3AbiHash, 32, 'wireV3AbiHash', true),
    baseSchemaCount: 8,
    additionalSchemas: CLIENT_COMPOSITION_V3_SCHEMA_DECLARATIONS.map(schema => ({
      schemaId: schema.schemaId,
      schemaName: schema.schemaName,
      canonicalDeclarationBytes: declarationBytes(schema)
    })),
    forwardReadinessOperationBits: 0
  }
}

export function encodeClientCompositionV3 (value) {
  const state = { start: 0, end: 0, buffer: null }
  clientCompositionV3Encoding.preencode(state, value)
  state.buffer = b4a.alloc(state.end)
  state.start = 0
  clientCompositionV3Encoding.encode(state, value)
  if (state.start !== state.end) fail('composition v3 encoder length mismatch')
  return state.buffer
}

export function decodeClientCompositionV3 (input) {
  input = b4a.from(input)
  const state = { start: 0, end: input.byteLength, buffer: input }
  const value = clientCompositionV3Encoding.decode(state)
  if (state.start !== state.end || !b4a.equals(encodeClientCompositionV3(value), input)) fail('composition v3 authority is not canonical')
  if (value.magic !== 'hiverelay-blind-client-composition-v3' || value.authorityVersion !== 3 ||
      value.formatMajor !== 3 || value.formatMinor !== 0 || value.baseSchemaCount !== 8 ||
      value.baseClientCompositionV2FormatHash.byteLength !== 32 || value.wireV3AbiHash.byteLength !== 32 ||
      value.additionalSchemas.length !== 2 || value.forwardReadinessOperationBits !== 0 ||
      value.additionalSchemas.some((schema, index) => schema.schemaId !== index + 9 ||
        !b4a.equals(schema.canonicalDeclarationBytes, declarationBytes(CLIENT_COMPOSITION_V3_SCHEMA_DECLARATIONS[index])))) {
    fail('composition v3 fixed allocation or declarations are invalid')
  }
  return value
}

export function encodeClientCompositionV3SchemaCatalog (schemas = CLIENT_COMPOSITION_V3_SCHEMA_DECLARATIONS) {
  const values = schemas.map(schema => ({
    schemaId: schema.schemaId,
    schemaName: schema.schemaName,
    canonicalDeclarationBytes: schema.canonicalDeclarationBytes == null
      ? declarationBytes(schema)
      : b4a.from(schema.canonicalDeclarationBytes)
  }))
  const state = { start: 0, end: 0, buffer: null }
  schemasEncoding.preencode(state, values)
  state.buffer = b4a.alloc(state.end)
  state.start = 0
  schemasEncoding.encode(state, values)
  return state.buffer
}

export function decodeClientCompositionV3SchemaCatalog (input) {
  input = b4a.from(input)
  const state = { start: 0, end: input.byteLength, buffer: input }
  const schemas = schemasEncoding.decode(state)
  if (state.start !== state.end || !b4a.equals(encodeClientCompositionV3SchemaCatalog(schemas), input) ||
      schemas.length !== 2 || schemas.some((schema, index) => schema.schemaId !== index + 9 ||
        !b4a.equals(schema.canonicalDeclarationBytes, declarationBytes(CLIENT_COMPOSITION_V3_SCHEMA_DECLARATIONS[index])))) {
    fail('composition v3 schema catalog is not canonical')
  }
  return schemas
}