OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / blind-ipc / private-ipc-v3-contract.js

428 lines · javascript ref:
import b4a from 'b4a'
import c from 'compact-encoding'
import {
  decodeForwardHttpsParentCapabilityV1,
  encodeForwardHttpsParentCapabilityV1,
  forwardHttpsSessionIdV1,
  blindForwardHttpsTurnRequestV1,
  blindForwardHttpsTurnResultV1
} from '@hiverelay/blind-protocol'
import { decodeCanonical } from '@hiverelay/blind-protocol/codec'
import { privateBlake2b256 } from './private-hashes.js'

const MAX_U64 = (1n << 64n) - 1n
const FORMAT_DOMAIN = b4a.from('hiverelay.blind.private-ipc-format-hash.v3', 'ascii')
const VECTOR_DOMAIN = b4a.from('hiverelay.blind.private-ipc-vector-set-hash.v3', 'ascii')
const BINDING_MAGIC = b4a.from('LFE3', 'ascii')
const CAPABILITY_MAGIC = b4a.from('LFC3', 'ascii')
const TURN_MAGIC = b4a.from('LFT3', 'ascii')

export const PRIVATE_IPC_V3_MAGIC = 'hiverelay-blind-private-ipc-v3'
export const PRIVATE_IPC_V3_FORMAT_VERSION = 3

export const PRIVATE_IPC_V3_SCHEMA = Object.freeze({
  LocalForwardHttpsExporterBindingV3: 13,
  LocalForwardHttpsParentCapabilityV3: 14,
  LocalForwardHttpsTurnV3: 15
})

export const LOCAL_FORWARD_HTTPS_DIRECTION_V3 = Object.freeze({
  REQUEST: 1,
  RESULT: 2
})

export const PRIVATE_IPC_V3_LIMITS = Object.freeze({
  EXPORTER_BINDING_BYTES: 336,
  PARENT_CAPABILITY_BYTES: 427,
  TURN_HEADER_BYTES: 48,
  TURN_BODY_BYTES: 65_536,
  TURN_BYTES: 65_584
})

export const PRIVATE_IPC_V3_ADDITIONAL_SCHEMAS = Object.freeze([
  Object.freeze({
    schemaId: 13,
    schemaName: 'LocalForwardHttpsExporterBindingV3',
    fields: Object.freeze([
      'magic:fixed4=LFE3', 'version:u8=3', 'authorityKind:u8=TLS_EXPORTER_BINDING_HASH_BY_PEERCRED_EDGE',
      'releaseProfileId:u8=2', 'routeKind:u8=7',
      'edgeProcessNonce:fixed32[nonzero]', 'localChannelNonce:fixed32[nonzero]',
      'tlsExporterBindingHash:fixed32[nonzero;never-raw-exporter]',
      'sourceRelayPublicKey:fixed32[nonzero]', 'sourceDescriptorSequence:u64be', 'sourceDescriptorHash:fixed32[nonzero]',
      'targetCatalogEntryId:fixed32[nonzero]', 'targetRelayPublicKey:fixed32[nonzero;different-source]',
      'targetDescriptorSequence:u64be', 'targetDescriptorHash:fixed32[nonzero]',
      'routeId:fixed16[nonzero]', 'circuitNonce:fixed32[nonzero]',
      'issuedAtEpoch:u32be', 'expiresAtEpoch:u32be=issued+600',
      'no-url-host-ip-dial-fields', 'exactBytes:336'
    ])
  }),
  Object.freeze({
    schemaId: 14,
    schemaName: 'LocalForwardHttpsParentCapabilityV3',
    fields: Object.freeze([
      'magic:fixed4=LFC3', 'version:u8=3', 'sessionId:fixed32[nonzero]',
      'parentCapability:BlindForwardHttpsParentCapabilityV1[exact390]',
      'sessionId=BLAKE2b256(aux-domain||parentCapability)', 'exactBytes:427'
    ])
  }),
  Object.freeze({
    schemaId: 15,
    schemaName: 'LocalForwardHttpsTurnV3',
    fields: Object.freeze([
      'magic:fixed4=LFT3', 'version:u8=3', 'direction:u8[REQUEST=1|RESULT=2]', 'flags:u8=0', 'reserved:u8=0',
      'sessionId:fixed32[nonzero]', 'sequence:u64be',
      'body:closed(direction){REQUEST:BlindForwardHttpsTurnRequestV1|RESULT:BlindForwardHttpsTurnResultV1}[exact65536]',
      'sessionId+sequence=body', 'exactBytes:65584'
    ])
  })
])

function fail (message, code = 'BAD_PRIVATE_IPC_V3_CONTRACT') {
  const error = new Error(message)
  error.code = code
  throw error
}

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 value
}

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

function u32 (value, field) {
  if (!Number.isSafeInteger(value) || value < 0 || value > 0xffffffff) fail(`${field} is outside u32`)
  return value
}

function u64 (value, field) {
  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) fail(`${field} is outside u64`)
  return value
}

function put (output, value, offset) {
  b4a.copy(value, output, offset)
  return offset + value.byteLength
}

function take (input, offset, length) {
  return b4a.from(input.subarray(offset, offset + length))
}

function writeU32 (output, value, offset, field) {
  value = u32(value, field)
  output[offset] = value >>> 24
  output[offset + 1] = value >>> 16
  output[offset + 2] = value >>> 8
  output[offset + 3] = value
  return offset + 4
}

function readU32 (input, offset) {
  return input[offset] * 0x1000000 + input[offset + 1] * 0x10000 + input[offset + 2] * 0x100 + input[offset + 3]
}

function writeU64 (output, value, offset, field) {
  value = u64(value, field)
  for (let i = 7; i >= 0; i--) {
    output[offset + i] = Number(value & 0xffn)
    value >>= 8n
  }
  return offset + 8
}

function readU64 (input, offset) {
  let value = 0n
  for (let i = 0; i < 8; i++) value = (value << 8n) | BigInt(input[offset + i])
  return value
}

function validateExporterBinding (value) {
  if (!value || typeof value !== 'object') fail('exporter binding must be an object')
  for (const forbidden of ['tlsExporter', 'url', 'host', 'hostname', 'ip', 'ipAddress', 'dialAddress']) {
    if (forbidden in value) fail(`${forbidden} is forbidden in exporter binding IPC`)
  }
  if (value.version !== 3 || value.authorityKind !== 1 || value.releaseProfileId !== 2 || value.routeKind !== 7) {
    fail('exporter binding fixed header is invalid')
  }
  for (const field of [
    'edgeProcessNonce', 'localChannelNonce', 'tlsExporterBindingHash', 'sourceRelayPublicKey',
    'sourceDescriptorHash', 'targetCatalogEntryId', 'targetRelayPublicKey', 'targetDescriptorHash', 'circuitNonce'
  ]) bytes(value[field], 32, field, true)
  bytes(value.routeId, 16, 'routeId', true)
  u64(value.sourceDescriptorSequence, 'sourceDescriptorSequence')
  u64(value.targetDescriptorSequence, 'targetDescriptorSequence')
  if (b4a.equals(value.sourceRelayPublicKey, value.targetRelayPublicKey)) fail('source and target relay keys must differ')
  u32(value.issuedAtEpoch, 'issuedAtEpoch')
  u32(value.expiresAtEpoch, 'expiresAtEpoch')
  if (value.expiresAtEpoch !== value.issuedAtEpoch + 600) fail('exporter binding lifetime must be exactly 600 seconds')
}

export function encodeLocalForwardHttpsExporterBindingV3 (value) {
  validateExporterBinding(value)
  const output = b4a.alloc(PRIVATE_IPC_V3_LIMITS.EXPORTER_BINDING_BYTES)
  let offset = put(output, BINDING_MAGIC, 0)
  output[offset++] = 3
  output[offset++] = 1
  output[offset++] = 2
  output[offset++] = 7
  offset = put(output, value.edgeProcessNonce, offset)
  offset = put(output, value.localChannelNonce, offset)
  offset = put(output, value.tlsExporterBindingHash, offset)
  offset = put(output, value.sourceRelayPublicKey, offset)
  offset = writeU64(output, value.sourceDescriptorSequence, offset, 'sourceDescriptorSequence')
  offset = put(output, value.sourceDescriptorHash, offset)
  offset = put(output, value.targetCatalogEntryId, offset)
  offset = put(output, value.targetRelayPublicKey, offset)
  offset = writeU64(output, value.targetDescriptorSequence, offset, 'targetDescriptorSequence')
  offset = put(output, value.targetDescriptorHash, offset)
  offset = put(output, value.routeId, offset)
  offset = put(output, value.circuitNonce, offset)
  offset = writeU32(output, value.issuedAtEpoch, offset, 'issuedAtEpoch')
  offset = writeU32(output, value.expiresAtEpoch, offset, 'expiresAtEpoch')
  if (offset !== output.byteLength) fail('exporter binding encoder length mismatch')
  return output
}

export function decodeLocalForwardHttpsExporterBindingV3 (input) {
  input = bytes(input, PRIVATE_IPC_V3_LIMITS.EXPORTER_BINDING_BYTES, 'exporter binding')
  if (!b4a.equals(input.subarray(0, 4), BINDING_MAGIC)) fail('exporter binding magic is invalid')
  let offset = 4
  const value = {
    version: input[offset++],
    authorityKind: input[offset++],
    releaseProfileId: input[offset++],
    routeKind: input[offset++],
    edgeProcessNonce: take(input, offset, 32)
  }
  offset += 32
  value.localChannelNonce = take(input, offset, 32); offset += 32
  value.tlsExporterBindingHash = take(input, offset, 32); offset += 32
  value.sourceRelayPublicKey = take(input, offset, 32); offset += 32
  value.sourceDescriptorSequence = readU64(input, offset); offset += 8
  value.sourceDescriptorHash = take(input, offset, 32); offset += 32
  value.targetCatalogEntryId = take(input, offset, 32); offset += 32
  value.targetRelayPublicKey = take(input, offset, 32); offset += 32
  value.targetDescriptorSequence = readU64(input, offset); offset += 8
  value.targetDescriptorHash = take(input, offset, 32); offset += 32
  value.routeId = take(input, offset, 16); offset += 16
  value.circuitNonce = take(input, offset, 32); offset += 32
  value.issuedAtEpoch = readU32(input, offset); offset += 4
  value.expiresAtEpoch = readU32(input, offset); offset += 4
  if (offset !== input.byteLength) fail('exporter binding decoder length mismatch')
  validateExporterBinding(value)
  if (!b4a.equals(encodeLocalForwardHttpsExporterBindingV3(value), input)) fail('exporter binding is not canonical')
  return value
}

export function encodeLocalForwardHttpsParentCapabilityV3 (value) {
  if (!value || typeof value !== 'object' || value.version !== 3) fail('parent capability IPC fixed header is invalid')
  const capability = encodeForwardHttpsParentCapabilityV1(value.parentCapability)
  const sessionId = bytes(value.sessionId, 32, 'sessionId', true)
  if (!b4a.equals(sessionId, forwardHttpsSessionIdV1(value.parentCapability))) fail('sessionId does not bind parentCapability')
  const output = b4a.alloc(PRIVATE_IPC_V3_LIMITS.PARENT_CAPABILITY_BYTES)
  let offset = put(output, CAPABILITY_MAGIC, 0)
  output[offset++] = 3
  offset = put(output, sessionId, offset)
  offset = put(output, capability, offset)
  if (offset !== output.byteLength) fail('parent capability IPC encoder length mismatch')
  return output
}

export function decodeLocalForwardHttpsParentCapabilityV3 (input) {
  input = bytes(input, PRIVATE_IPC_V3_LIMITS.PARENT_CAPABILITY_BYTES, 'parent capability IPC')
  if (!b4a.equals(input.subarray(0, 4), CAPABILITY_MAGIC) || input[4] !== 3) fail('parent capability IPC fixed header is invalid')
  const value = {
    version: 3,
    sessionId: take(input, 5, 32),
    parentCapability: decodeForwardHttpsParentCapabilityV1(input.subarray(37))
  }
  if (!b4a.equals(encodeLocalForwardHttpsParentCapabilityV3(value), input)) fail('parent capability IPC is not canonical')
  return value
}

export function encodeLocalForwardHttpsTurnV3 (value) {
  if (!value || typeof value !== 'object' || value.version !== 3 || value.flags !== 0) fail('local turn fixed header is invalid')
  if (value.direction !== LOCAL_FORWARD_HTTPS_DIRECTION_V3.REQUEST && value.direction !== LOCAL_FORWARD_HTTPS_DIRECTION_V3.RESULT) {
    fail('local turn direction is invalid')
  }
  const body = bytes(value.body, PRIVATE_IPC_V3_LIMITS.TURN_BODY_BYTES, 'local turn body')
  const decoded = decodeCanonical(
    value.direction === LOCAL_FORWARD_HTTPS_DIRECTION_V3.REQUEST
      ? blindForwardHttpsTurnRequestV1
      : blindForwardHttpsTurnResultV1,
    body,
    { copyBytes: true }
  )
  const sessionId = bytes(value.sessionId, 32, 'sessionId', true)
  const sequence = u64(value.sequence, 'sequence')
  if (!b4a.equals(sessionId, decoded.sessionId) || sequence !== decoded.sequence) fail('local turn sessionId or sequence does not match body')
  const output = b4a.alloc(PRIVATE_IPC_V3_LIMITS.TURN_BYTES)
  let offset = put(output, TURN_MAGIC, 0)
  output[offset++] = 3
  output[offset++] = value.direction
  output[offset++] = 0
  output[offset++] = 0
  offset = put(output, sessionId, offset)
  offset = writeU64(output, sequence, offset, 'sequence')
  offset = put(output, body, offset)
  if (offset !== output.byteLength) fail('local turn encoder length mismatch')
  return output
}

export function decodeLocalForwardHttpsTurnV3 (input) {
  input = bytes(input, PRIVATE_IPC_V3_LIMITS.TURN_BYTES, 'local turn')
  if (!b4a.equals(input.subarray(0, 4), TURN_MAGIC) || input[4] !== 3 || input[6] !== 0 || input[7] !== 0) {
    fail('local turn fixed header is invalid')
  }
  const value = {
    version: 3,
    direction: input[5],
    flags: 0,
    sessionId: take(input, 8, 32),
    sequence: readU64(input, 40),
    body: take(input, 48, PRIVATE_IPC_V3_LIMITS.TURN_BODY_BYTES)
  }
  if (!b4a.equals(encodeLocalForwardHttpsTurnV3(value), input)) fail('local turn is not canonical')
  return value
}

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)

export const privateIpcV3RegistryEncoding = {
  preencode (state, value) {
    c.string.preencode(state, value.magic)
    c.uint.preencode(state, value.formatVersion)
    c.buffer.preencode(state, value.importedWireV2AbiHash)
    c.buffer.preencode(state, value.basePrivateIpcV2FormatHash)
    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.formatVersion)
    c.buffer.encode(state, value.importedWireV2AbiHash)
    c.buffer.encode(state, value.basePrivateIpcV2FormatHash)
    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),
      formatVersion: c.uint.decode(state),
      importedWireV2AbiHash: b4a.from(c.buffer.decode(state)),
      basePrivateIpcV2FormatHash: b4a.from(c.buffer.decode(state)),
      baseSchemaCount: c.uint.decode(state),
      additionalSchemas: schemasEncoding.decode(state),
      forwardReadinessOperationBits: c.uint.decode(state)
    }
  }
}

export function createPrivateIpcV3RegistryValue (importedWireV2AbiHash, basePrivateIpcV2FormatHash) {
  return {
    magic: PRIVATE_IPC_V3_MAGIC,
    formatVersion: PRIVATE_IPC_V3_FORMAT_VERSION,
    importedWireV2AbiHash: bytes(importedWireV2AbiHash, 32, 'importedWireV2AbiHash'),
    basePrivateIpcV2FormatHash: bytes(basePrivateIpcV2FormatHash, 32, 'basePrivateIpcV2FormatHash'),
    baseSchemaCount: 12,
    additionalSchemas: PRIVATE_IPC_V3_ADDITIONAL_SCHEMAS.map(schema => ({
      schemaId: schema.schemaId,
      schemaName: schema.schemaName,
      canonicalDeclarationBytes: b4a.from(JSON.stringify({ name: schema.schemaName, fields: schema.fields }), 'utf8')
    })),
    forwardReadinessOperationBits: 0
  }
}

export function encodePrivateIpcV3Registry (value) {
  const state = { start: 0, end: 0, buffer: null }
  privateIpcV3RegistryEncoding.preencode(state, value)
  state.buffer = b4a.alloc(state.end)
  state.start = 0
  privateIpcV3RegistryEncoding.encode(state, value)
  if (state.start !== state.end) fail('private IPC v3 registry encoder length mismatch')
  return state.buffer
}

export function decodePrivateIpcV3Registry (input) {
  const state = { start: 0, end: input.byteLength, buffer: input }
  const value = privateIpcV3RegistryEncoding.decode(state)
  if (state.start !== state.end) fail('trailing bytes after private IPC v3 registry')
  if (!b4a.equals(encodePrivateIpcV3Registry(value), input)) fail('private IPC v3 registry is not canonical')
  if (value.magic !== PRIVATE_IPC_V3_MAGIC || value.formatVersion !== 3 || value.baseSchemaCount !== 12 ||
      value.additionalSchemas.length !== 3 || value.additionalSchemas[0].schemaId !== 13 ||
      value.additionalSchemas[2].schemaId !== 15 || value.forwardReadinessOperationBits !== 0) {
    fail('private IPC v3 registry fixed allocation is invalid')
  }
  return value
}

function length64 (length) {
  const output = b4a.alloc(8)
  writeU64(output, BigInt(length), 0, 'hash input length')
  return output
}

function domainLengthHash (domain, input) {
  return privateBlake2b256(b4a.concat([domain, length64(input.byteLength), input]))
}

export const hashPrivateIpcV3Registry = input => domainLengthHash(FORMAT_DOMAIN, input)
export const hashPrivateIpcV3VectorManifest = input => domainLengthHash(VECTOR_DOMAIN, input)