OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / blind-protocol-wire-v2.test.js

202 lines · javascript ref:
import test from 'brittle'
import b4a from 'b4a'
import {
  decodeCanonical,
  encodeCanonical
} from '../../packages/blind-protocol/codec.js'
import {
  createWireAbiV2Value,
  decodeWireAbiV2,
  encodeWireAbiV2
} from '../../packages/blind-protocol/abi-registry-v2.js'
import {
  DIRECT_HTTPS_ROUTE_KIND_V2,
  FORWARD_HTTPS_DOMAIN_V2,
  FORWARD_HTTPS_LIMITS_V1,
  FORWARD_HTTPS_RESULT_OUTCOME_V1,
  FORWARD_HTTPS_TRANSPORT_VARIANTS_V2,
  FORWARD_HTTPS_TURN_KIND_V1,
  ForwardHttpsTurnReplayWindowV1,
  RELEASE_PROFILE_V2,
  WIRE_V2_SCHEMA,
  WIRE_V2_SCHEMA_DECLARATIONS,
  assertForwardHttpsCatalogTargetV1,
  blindForwardHttpsTurnRequestV1,
  blindForwardHttpsTurnResultV1,
  forwardHttpsSessionIdV1,
  forwardHttpsTurnRequestCommitmentV1,
  forwardHttpsTurnResultSignaturePayloadV1,
  normalizeReleaseProfileInputV2
} from '../../packages/blind-protocol/wire-v2.js'

const fill = (length, value) => b4a.alloc(length, value)

function capability () {
  return {
    version: 1,
    routeKind: 7,
    releaseProfileId: 2,
    sourceRelayPublicKey: fill(32, 1),
    sourceDescriptorSequence: 11n,
    sourceDescriptorHash: fill(32, 2),
    targetRelayPublicKey: fill(32, 3),
    targetDescriptorSequence: 22n,
    targetDescriptorHash: fill(32, 4),
    targetCatalogEntryId: fill(32, 5),
    routeId: fill(16, 6),
    routePrefixRelayPublicKey: fill(32, 1),
    maxRelayCount: 2,
    remainingTransitions: 1,
    circuitClass: 1,
    maxCircuitBytes: 16n * 1024n * 1024n,
    initialWindowBytes: 65_536,
    idleMillis: 30_000,
    lifetimeMillis: 600_000,
    issuedAtEpoch: 1_800_000_000,
    expiresAtEpoch: 1_800_000_600,
    circuitNonce: fill(32, 7),
    tlsExporterBindingHash: fill(32, 8),
    signature: fill(64, 9)
  }
}

function request (parent = capability()) {
  return {
    version: 1,
    routeKind: 7,
    releaseProfileId: 2,
    turnKind: FORWARD_HTTPS_TURN_KIND_V1.DATA,
    flags: 0,
    sessionId: forwardHttpsSessionIdV1(parent),
    sequence: 0n,
    requestNonce: fill(32, 10),
    parentCapability: parent,
    inner: {
      version: 1,
      circuitNonce: parent.circuitNonce,
      offset: 0n,
      bytes: fill(64, 11)
    },
    padding: fill(FORWARD_HTTPS_LIMITS_V1.MAX_REQUEST_INNER_BYTES - 106, 12)
  }
}

function successResult (requestBytes) {
  return {
    version: 1,
    routeKind: 7,
    releaseProfileId: 2,
    turnKind: FORWARD_HTTPS_TURN_KIND_V1.DATA,
    outcome: FORWARD_HTTPS_RESULT_OUTCOME_V1.SUCCESS,
    sessionId: forwardHttpsSessionIdV1(capability()),
    sequence: 0n,
    requestCommitment: forwardHttpsTurnRequestCommitmentV1(requestBytes),
    relayPublicKey: fill(32, 3),
    descriptorSequence: 22n,
    descriptorHash: fill(32, 4),
    signature: fill(64, 13),
    inner: {
      version: 1,
      circuitNonce: fill(32, 7),
      offset: 0n,
      bytes: fill(32, 14)
    }
  }
}

test('WIRE v2 is an additive protocol 1.1 ABI with exact frozen allocations', t => {
  t.alike(WIRE_V2_SCHEMA, {
    BlindForwardHttpsTurnRequestV1: 74,
    BlindForwardHttpsTurnResultV1: 75
  })
  t.is(RELEASE_PROFILE_V2.LIMITED_PUBLIC_TEST_V1.operationBits, 0x0001ffff)
  t.is(RELEASE_PROFILE_V2.LIMITED_PUBLIC_TEST_FORWARD_ONE_HOP_V1.operationBits, 0x003dffff)
  t.is(RELEASE_PROFILE_V2.LIMITED_PUBLIC_TEST_FORWARD_ONE_HOP_V1.operationBits & (1 << 17), 0)
  t.is(normalizeReleaseProfileInputV2('BASELINE_17'), 'LIMITED_PUBLIC_TEST_V1')
  t.is(DIRECT_HTTPS_ROUTE_KIND_V2.DIRECT_HTTPS_FORWARD_ONE_HOP, 7)
  t.is(FORWARD_HTTPS_TRANSPORT_VARIANTS_V2.length, 4)
  t.alike(FORWARD_HTTPS_TRANSPORT_VARIANTS_V2.map(row => row.requestSchemaId), [74, 74, 74, 74])
  t.alike(Object.values(FORWARD_HTTPS_DOMAIN_V2).map(row => row.domainId), [17, 112, 214])

  const abi = createWireAbiV2Value(fill(32, 15))
  const encoded = encodeWireAbiV2(abi)
  const decoded = decodeWireAbiV2(encoded)
  t.is(decoded.protocolMajor, 1)
  t.is(decoded.protocolMinor, 1)
  t.is(decoded.additionalSchemas.length, 2)
  t.is(decoded.forwardReadinessOperationBits, 0)
  t.absent(b4a.toString(encoded, 'utf8').includes('BASELINE_17'), 'input-only alias is not serialised')

  const resultFields = WIRE_V2_SCHEMA_DECLARATIONS[1].fields.join('|')
  t.ok(resultFields.includes('ERROR:BlindErrorV1'), 'the only error branch reuses BlindErrorV1')
  t.ok(resultFields.includes('SUCCESS.OPEN:BlindForwardOpenResultV1'), 'OPEN success is bidirectional')
  t.ok(resultFields.includes('SUCCESS.DATA:BlindForwardDataV1'), 'DATA success is bidirectional')
  t.ok(resultFields.includes('SUCCESS.WINDOW:BlindForwardWindowV1'), 'WINDOW success is bidirectional')
  t.ok(resultFields.includes('SUCCESS.CLOSE:BlindForwardCloseV1'), 'CLOSE success is bidirectional')
  t.ok(resultFields.includes('no-ACK-schema'), 'the result union has no ACK branch')
})

test('WIRE v2 generic request/result and canonical error are each exactly 65536 bytes', t => {
  const requestValue = request()
  const requestBytes = encodeCanonical(blindForwardHttpsTurnRequestV1, requestValue)
  t.is(requestBytes.byteLength, 65_536)
  t.alike(decodeCanonical(blindForwardHttpsTurnRequestV1, requestBytes), requestValue)

  const success = successResult(requestBytes)
  const successBytes = encodeCanonical(blindForwardHttpsTurnResultV1, success)
  t.is(successBytes.byteLength, 65_536)
  t.alike(decodeCanonical(blindForwardHttpsTurnResultV1, successBytes), {
    ...success,
    padding: fill(65_536 - 221 - 74, 0)
  })

  const error = {
    ...success,
    outcome: FORWARD_HTTPS_RESULT_OUTCOME_V1.ERROR,
    inner: { version: 1, code: 1, retryable: 0, retryAfterEpoch: null }
  }
  const errorBytes = encodeCanonical(blindForwardHttpsTurnResultV1, error)
  t.is(errorBytes.byteLength, 65_536)
  t.alike(decodeCanonical(blindForwardHttpsTurnResultV1, errorBytes).inner, error.inner)

  const payloadA = forwardHttpsTurnResultSignaturePayloadV1(success)
  const changedPadding = { ...success, padding: fill(65_536 - 221 - 74, 99) }
  const payloadB = forwardHttpsTurnResultSignaturePayloadV1(changedPadding)
  t.is(payloadA.byteLength, FORWARD_HTTPS_DOMAIN_V2.RESULT.exactAsciiBytes.length + 8 + 65_472)
  t.unlike(payloadA, payloadB, 'result signature binds every padding byte')
})

test('WIRE v2 exact catalog target and single outstanding replay fail closed', t => {
  const parent = capability()
  t.ok(assertForwardHttpsCatalogTargetV1(parent, {
    catalogEntryId: parent.targetCatalogEntryId,
    relayPublicKey: parent.targetRelayPublicKey,
    descriptorSequence: parent.targetDescriptorSequence,
    descriptorHash: parent.targetDescriptorHash
  }))
  t.exception(() => assertForwardHttpsCatalogTargetV1(parent, {
    catalogEntryId: parent.targetCatalogEntryId,
    relayPublicKey: parent.targetRelayPublicKey,
    descriptorSequence: parent.targetDescriptorSequence,
    descriptorHash: parent.targetDescriptorHash,
    host: 'arbitrary.invalid'
  }), /must not contain caller dial fields/)
  t.exception(() => encodeCanonical(blindForwardHttpsTurnRequestV1, request({
    ...parent,
    targetRelayPublicKey: parent.sourceRelayPublicKey
  })), /source and target relay keys must differ/)
  t.exception(() => encodeCanonical(blindForwardHttpsTurnRequestV1, request({
    ...parent,
    url: 'https://arbitrary.invalid'
  })), /url is forbidden/)

  const requestBytes = encodeCanonical(blindForwardHttpsTurnRequestV1, request(parent))
  const replay = new ForwardHttpsTurnReplayWindowV1(forwardHttpsSessionIdV1(parent))
  t.is(replay.accept(requestBytes).disposition, 'ACCEPTED')
  t.is(replay.accept(requestBytes).disposition, 'IDEMPOTENT_RETRY')
  const changed = b4a.from(requestBytes)
  changed[changed.byteLength - 1] ^= 1
  t.exception(() => replay.accept(changed), /changed bytes reused/)
  t.ok(replay.terminal)
})