OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / seed-protocol-encoding.test.js

482 lines · javascript ref:
import test from 'brittle'
import b4a from 'b4a'
import c from 'compact-encoding'
import { EventEmitter } from 'events'
import {
  MAX_SEED_DENY_DETAIL_BYTES,
  MAX_SEED_DENY_REASON_BYTES,
  MAX_SEED_DISCOVERY_KEYS,
  MAX_SEED_GEO_PREFERENCE_BYTES,
  MAX_SEED_REGION_BYTES,
  seedAcceptEncoding,
  seedDenyEncoding,
  seedRequestEncoding,
  unseedRequestEncoding
} from 'p2p-hiverelay/core/protocol/messages.js'
import {
  MAX_PROTOCOL_HANDSHAKE_BYTES,
  SEED_PROTOCOL_HANDSHAKE_MAX_BYTES,
  SEED_PROTOCOL_VERSION,
  SeedProtocol,
  evaluateSeedProtocolHandshake,
  parseProtocolHandshake,
  parseSeedProtocolHandshake
} from 'p2p-hiverelay/core/protocol/seed-request.js'
import { HiveRelayClient } from 'p2p-hiverelay-client'

function encodeFrame (encoding, msg) {
  const state = { start: 0, end: 0, buffer: null }
  encoding.preencode(state, msg)
  state.buffer = b4a.alloc(state.end)
  state.start = 0
  encoding.encode(state, msg)
  return state.buffer
}

function declaredSeedDiscoveryCountFrame (count) {
  const appKey = b4a.alloc(32, 0x11)
  const state = { start: 0, end: 0, buffer: null }
  c.fixed32.preencode(state, appKey)
  c.uint.preencode(state, count)
  state.buffer = b4a.alloc(state.end)
  c.fixed32.encode(state, appKey)
  c.uint.encode(state, count)
  return state.buffer
}

function declaredSeedGeoFrame (len, payload = null) {
  const appKey = b4a.alloc(32, 0x11)
  const state = { start: 0, end: 0, buffer: null }
  c.fixed32.preencode(state, appKey)
  c.uint.preencode(state, 0)
  c.uint.preencode(state, 1)
  c.uint.preencode(state, len)
  state.end += payload ? payload.byteLength : 0
  state.buffer = b4a.alloc(state.end)
  c.fixed32.encode(state, appKey)
  c.uint.encode(state, 0)
  c.uint.encode(state, 1)
  c.uint.encode(state, len)
  if (payload) payload.copy(state.buffer, state.start)
  return state.buffer
}

function declaredAcceptRegionFrame (len, payload = null) {
  const appKey = b4a.alloc(32, 0x11)
  const relayPubkey = b4a.alloc(32, 0x22)
  const state = { start: 0, end: 0, buffer: null }
  c.fixed32.preencode(state, appKey)
  c.fixed32.preencode(state, relayPubkey)
  c.uint.preencode(state, len)
  state.end += payload ? payload.byteLength : 0
  state.buffer = b4a.alloc(state.end)
  c.fixed32.encode(state, appKey)
  c.fixed32.encode(state, relayPubkey)
  c.uint.encode(state, len)
  if (payload) payload.copy(state.buffer, state.start)
  return state.buffer
}

function declaredDenyFrame ({ reasonLen, reasonPayload = null, detailLen, detailPayload = null }) {
  const appKey = b4a.alloc(32, 0x11)
  const relayPubkey = b4a.alloc(32, 0x22)
  const state = { start: 0, end: 0, buffer: null }
  c.fixed32.preencode(state, appKey)
  c.fixed32.preencode(state, relayPubkey)
  c.uint.preencode(state, reasonLen)
  state.end += reasonPayload ? reasonPayload.byteLength : 0
  c.uint.preencode(state, detailLen)
  state.end += detailPayload ? detailPayload.byteLength : 0
  state.buffer = b4a.alloc(state.end)
  c.fixed32.encode(state, appKey)
  c.fixed32.encode(state, relayPubkey)
  c.uint.encode(state, reasonLen)
  if (reasonPayload) {
    reasonPayload.copy(state.buffer, state.start)
    state.start += reasonPayload.byteLength
  }
  c.uint.encode(state, detailLen)
  if (detailPayload) {
    detailPayload.copy(state.buffer, state.start)
    state.start += detailPayload.byteLength
  }
  return state.buffer
}

function unseedTimestampOnlyFrame () {
  const appKey = b4a.alloc(32, 0x11)
  const state = { start: 0, end: 0, buffer: null }
  c.fixed32.preencode(state, appKey)
  c.uint.preencode(state, Date.now())
  state.buffer = b4a.alloc(state.end)
  c.fixed32.encode(state, appKey)
  c.uint.encode(state, Date.now())
  return state.buffer
}

function baseSeedRequest () {
  return {
    appKey: b4a.alloc(32, 0x01),
    discoveryKeys: [b4a.alloc(32, 0x02)],
    replicationFactor: 2,
    geoPreference: ['NA'],
    maxStorageBytes: 1024,
    bountyRate: 0,
    ttlSeconds: 3600,
    publisherPubkey: b4a.alloc(32, 0x03),
    publisherSignature: b4a.alloc(64, 0x04),
    revocable: false,
    unseedFreezeMs: 1000,
    durability: 1
  }
}

function baseSeedAccept () {
  return {
    appKey: b4a.alloc(32, 0x01),
    relayPubkey: b4a.alloc(32, 0x02),
    region: 'NA',
    availableStorageBytes: 2048,
    relaySignature: b4a.alloc(64, 0x03)
  }
}

function baseSeedDeny () {
  return {
    appKey: b4a.alloc(32, 0x01),
    relayPubkey: b4a.alloc(32, 0x02),
    reasonCode: 'queued-for-review',
    detail: 'pending',
    relaySignature: b4a.alloc(64, 0x03)
  }
}

function baseUnseed () {
  return {
    appKey: b4a.alloc(32, 0x01),
    timestamp: Date.now(),
    publisherPubkey: b4a.alloc(32, 0x02),
    publisherSignature: b4a.alloc(64, 0x03)
  }
}

function fakeChannel () {
  return { stream: { remotePublicKey: b4a.alloc(32, 0x55) } }
}

function fakeOpenChannel (handshake) {
  const sent = []
  return {
    handshake,
    closed: false,
    close () {
      this.closed = true
    },
    _hiverelay: {
      seedRequestMsg: {
        send: (msg) => sent.push(msg)
      }
    },
    sent
  }
}

function clearSeedProtocol (proto) {
  if (proto._pendingCleanup) clearInterval(proto._pendingCleanup)
  if (proto._unseedNonceCleanup) clearInterval(proto._unseedNonceCleanup)
  if (proto.rateLimiter && typeof proto.rateLimiter.destroy === 'function') proto.rateLimiter.destroy()
}

function mockSwarm () {
  const swarm = new EventEmitter()
  swarm.keyPair = { publicKey: Buffer.alloc(32, 0xaa), secretKey: null }
  swarm.connections = new Set()
  swarm.join = () => ({ destroy: () => {} })
  swarm.leave = async () => {}
  swarm.flush = async () => {}
  swarm.destroy = async () => {}
  return swarm
}

function mockStore () {
  return {
    close: async () => {},
    get: () => ({ key: Buffer.alloc(32), ready: async () => {} })
  }
}

test('seed protocol encodings: round-trip valid frames', (t) => {
  const seed = baseSeedRequest()
  const seedFrame = encodeFrame(seedRequestEncoding, seed)
  const seedOut = seedRequestEncoding.decode({ buffer: seedFrame, start: 0, end: seedFrame.length })
  t.alike(seedOut.appKey, seed.appKey)
  t.alike(seedOut.discoveryKeys, seed.discoveryKeys)
  t.alike(seedOut.geoPreference, seed.geoPreference)
  t.is(seedOut.revocable, false)
  t.is(seedOut.unseedFreezeMs, 1000)
  t.is(seedOut.durability, 1)

  const accept = baseSeedAccept()
  const acceptFrame = encodeFrame(seedAcceptEncoding, accept)
  t.alike(seedAcceptEncoding.decode({ buffer: acceptFrame, start: 0, end: acceptFrame.length }), accept)

  const deny = baseSeedDeny()
  const denyFrame = encodeFrame(seedDenyEncoding, deny)
  t.alike(seedDenyEncoding.decode({ buffer: denyFrame, start: 0, end: denyFrame.length }), deny)

  const unseed = baseUnseed()
  const unseedFrame = encodeFrame(unseedRequestEncoding, unseed)
  t.alike(unseedRequestEncoding.decode({ buffer: unseedFrame, start: 0, end: unseedFrame.length }), unseed)
})

test('seed protocol encodings: reject bad outbound frames before allocation growth', (t) => {
  const badAppKey = { start: 0, end: 0, buffer: null }
  t.exception(() => {
    seedRequestEncoding.preencode(badAppKey, { ...baseSeedRequest(), appKey: b4a.alloc(31) })
  }, /appKey/, 'bad appKey rejected')
  t.is(badAppKey.end, 0, 'bad appKey does not grow state.end')

  const tooManyKeys = { start: 0, end: 0, buffer: null }
  t.exception(() => {
    seedRequestEncoding.preencode(tooManyKeys, {
      ...baseSeedRequest(),
      discoveryKeys: Array.from({ length: MAX_SEED_DISCOVERY_KEYS + 1 }, () => b4a.alloc(32))
    })
  }, /too many discovery keys/, 'too many discovery keys rejected')
  t.is(tooManyKeys.end, 0, 'too many discovery keys does not grow state.end')

  const hugeGeo = { start: 0, end: 0, buffer: null }
  t.exception(() => {
    seedRequestEncoding.preencode(hugeGeo, { ...baseSeedRequest(), geoPreference: ['x'.repeat(MAX_SEED_GEO_PREFERENCE_BYTES + 1)] })
  }, /geoPreference too large/, 'huge geoPreference rejected')
  t.is(hugeGeo.end, 0, 'huge geoPreference does not grow state.end')

  const hugeRegion = { start: 0, end: 0, buffer: null }
  t.exception(() => {
    seedAcceptEncoding.preencode(hugeRegion, { ...baseSeedAccept(), region: 'x'.repeat(MAX_SEED_REGION_BYTES + 1) })
  }, /region too large/, 'huge region rejected')
  t.is(hugeRegion.end, 0, 'huge region does not grow state.end')

  const hugeDetail = { start: 0, end: 0, buffer: null }
  t.exception(() => {
    seedDenyEncoding.preencode(hugeDetail, { ...baseSeedDeny(), detail: 'x'.repeat(MAX_SEED_DENY_DETAIL_BYTES + 1) })
  }, /detail too large/, 'huge deny detail rejected')
  t.is(hugeDetail.end, 0, 'huge deny detail does not grow state.end')

  const badTimestamp = { start: 0, end: 0, buffer: null }
  t.exception(() => {
    unseedRequestEncoding.preencode(badTimestamp, { ...baseUnseed(), timestamp: -1 })
  }, /timestamp/, 'bad timestamp rejected')
  t.is(badTimestamp.end, 0, 'bad timestamp does not grow state.end')
})

test('seed protocol encodings: reject oversized declared inbound frames before materializing them', (t) => {
  const tooManyKeys = declaredSeedDiscoveryCountFrame(MAX_SEED_DISCOVERY_KEYS + 1)
  const keyOut = seedRequestEncoding.decode({ buffer: tooManyKeys, start: 0, end: tooManyKeys.length })
  t.is(keyOut.error, 'too many discovery keys')

  const hugeGeo = declaredSeedGeoFrame(MAX_SEED_GEO_PREFERENCE_BYTES + 1)
  const geoOut = seedRequestEncoding.decode({ buffer: hugeGeo, start: 0, end: hugeGeo.length })
  t.is(geoOut.error, 'geoPreference too large')

  const hugeRegion = declaredAcceptRegionFrame(MAX_SEED_REGION_BYTES + 1)
  const regionOut = seedAcceptEncoding.decode({ buffer: hugeRegion, start: 0, end: hugeRegion.length })
  t.is(regionOut.error, 'region too large')

  const hugeReason = declaredDenyFrame({ reasonLen: MAX_SEED_DENY_REASON_BYTES + 1, detailLen: 0 })
  const reasonOut = seedDenyEncoding.decode({ buffer: hugeReason, start: 0, end: hugeReason.length })
  t.is(reasonOut.error, 'reasonCode too large')

  const hugeDetail = declaredDenyFrame({
    reasonLen: 3,
    reasonPayload: b4a.from('bad'),
    detailLen: MAX_SEED_DENY_DETAIL_BYTES + 1
  })
  const detailOut = seedDenyEncoding.decode({ buffer: hugeDetail, start: 0, end: hugeDetail.length })
  t.is(detailOut.error, 'detail too large')
})

test('seed protocol encodings: reject malformed and truncated frames without throwing', (t) => {
  let seedOut = null
  t.execution(() => {
    seedOut = seedRequestEncoding.decode({ buffer: b4a.alloc(31), start: 0, end: 31 })
  }, 'truncated seed request does not throw')
  t.is(seedOut.error, 'malformed seed request')

  let invalidJson = null
  t.execution(() => {
    const payload = b4a.from('not-json')
    const frame = declaredSeedGeoFrame(payload.byteLength, payload)
    invalidJson = seedRequestEncoding.decode({ buffer: frame, start: 0, end: frame.length })
  }, 'invalid geoPreference JSON does not throw')
  t.is(invalidJson.error, 'malformed seed request')

  let acceptOut = null
  t.execution(() => {
    acceptOut = seedAcceptEncoding.decode({ buffer: b4a.alloc(63), start: 0, end: 63 })
  }, 'truncated seed accept does not throw')
  t.is(acceptOut.error, 'malformed seed accept')

  let unseedOut = null
  t.execution(() => {
    const frame = unseedTimestampOnlyFrame()
    unseedOut = unseedRequestEncoding.decode({ buffer: frame, start: 0, end: frame.length })
  }, 'truncated unseed does not throw')
  t.is(unseedOut.error, 'malformed unseed request')
})

test('seed protocol encodings: reject invalid decode state without throwing', (t) => {
  const frame = encodeFrame(seedRequestEncoding, baseSeedRequest())
  const cases = [
    { name: 'missing state', state: null },
    { name: 'missing buffer', state: { start: 0, end: 0 } },
    { name: 'negative start', state: { buffer: frame, start: -1, end: frame.length } },
    { name: 'end before start', state: { buffer: frame, start: 4, end: 2 } },
    { name: 'end past buffer', state: { buffer: frame, start: 0, end: frame.length + 1 } },
    { name: 'unsafe start', state: { buffer: frame, start: Number.MAX_SAFE_INTEGER + 1, end: frame.length } }
  ]

  for (const { name, state } of cases) {
    let result = null
    t.execution(() => {
      result = seedRequestEncoding.decode(state)
    }, `${name} does not throw`)
    t.is(result.appKey, null, `${name} appKey is null`)
    t.is(result.error, 'malformed seed request', `${name} error`)
  }
})

test('SeedProtocol handlers ignore decoded seed protocol errors without throwing', (t) => {
  const proto = new SeedProtocol(null, {})
  t.teardown(() => clearSeedProtocol(proto))
  const events = { request: [], accept: [], unseed: [], deny: [] }
  proto.on('invalid-request', (event) => events.request.push(event))
  proto.on('invalid-accept', (event) => events.accept.push(event))
  proto.on('invalid-unseed', (event) => events.unseed.push(event))
  proto.on('invalid-deny', (event) => events.deny.push(event))

  t.execution(() => proto._onSeedRequest(fakeChannel(), { error: 'too many discovery keys', appKey: null }), 'seed request error handled')
  t.execution(() => proto._onSeedAccept(fakeChannel(), { error: 'region too large', appKey: null }), 'seed accept error handled')
  t.execution(() => proto._onUnseedRequest(fakeChannel(), { error: 'malformed unseed request', appKey: null }), 'unseed error handled')
  t.execution(() => proto._onSeedDeny(fakeChannel(), { error: 'detail too large', appKey: null }), 'seed deny error handled')

  t.is(events.request[0].reason, 'too many discovery keys')
  t.is(events.accept[0].reason, 'region too large')
  t.is(events.unseed[0].reason, 'malformed unseed request')
  t.is(events.deny[0].reason, 'detail too large')
})

test('SeedProtocol handshake evaluator pins major-version negotiation policy', (t) => {
  t.alike(SEED_PROTOCOL_VERSION, { major: 1, minor: 0 })
  t.is(MAX_PROTOCOL_HANDSHAKE_BYTES, 256)
  t.is(SEED_PROTOCOL_HANDSHAKE_MAX_BYTES, 256)
  t.is(SEED_PROTOCOL_HANDSHAKE_MAX_BYTES, MAX_PROTOCOL_HANDSHAKE_BYTES)
  t.alike(
    parseProtocolHandshake(b4a.from(JSON.stringify({ major: 1, minor: 0 }))),
    parseSeedProtocolHandshake(b4a.from(JSON.stringify({ major: 1, minor: 0 }))),
    'legacy parser export remains an alias for the canonical parser'
  )

  t.alike(
    evaluateSeedProtocolHandshake(b4a.from(JSON.stringify({ major: 1, minor: 0 }))),
    {
      valid: true,
      action: 'accept',
      local: { major: 1, minor: 0 },
      remote: { major: 1, minor: 0 },
      reason: null
    }
  )
  t.alike(
    evaluateSeedProtocolHandshake(b4a.from(JSON.stringify({ major: 1, minor: 99 }))),
    {
      valid: true,
      action: 'accept',
      local: { major: 1, minor: 0 },
      remote: { major: 1, minor: 99 },
      reason: null
    },
    'future minor version within the same major is compatible'
  )
  t.alike(
    evaluateSeedProtocolHandshake(b4a.from(JSON.stringify({ major: 2, minor: 0 }))),
    {
      valid: false,
      action: 'close',
      local: { major: 1, minor: 0 },
      remote: { major: 2, minor: 0 },
      reason: 'major version mismatch'
    },
    'major version mismatch closes before replay'
  )
  t.is(evaluateSeedProtocolHandshake(b4a.from('[]')).reason, 'malformed handshake')
  t.is(evaluateSeedProtocolHandshake(b4a.alloc(257, 0x20)).reason, 'handshake too large')
})

test('SeedProtocol handshake accepts current version before pending replay', (t) => {
  const proto = new SeedProtocol(null, {})
  t.teardown(() => clearSeedProtocol(proto))
  const request = baseSeedRequest()
  const channel = fakeOpenChannel(b4a.from(JSON.stringify({ major: 1, minor: 0 })))
  let opened = false

  proto.pendingRequests.set('aa'.repeat(32), request)
  proto.on('channel-open', () => { opened = true })

  proto._onOpen(channel)

  t.is(opened, true)
  t.is(channel.closed, false)
  t.is(channel.sent.length, 1)
  t.alike(channel.sent[0], request)
})

test('SeedProtocol handshake rejects malformed and oversized frames before pending replay', (t) => {
  const proto = new SeedProtocol(null, {})
  t.teardown(() => clearSeedProtocol(proto))
  const malformed = fakeOpenChannel(b4a.from('[]'))
  const oversized = fakeOpenChannel(b4a.alloc(257, 0x20))
  const invalid = []

  proto.pendingRequests.set('aa'.repeat(32), baseSeedRequest())
  proto.on('invalid-handshake', event => invalid.push(event.reason))

  proto._onOpen(malformed)
  proto._onOpen(oversized)

  t.alike(invalid, ['malformed handshake', 'handshake too large'])
  t.is(malformed.closed, true)
  t.is(oversized.closed, true)
  t.is(malformed.sent.length, 0)
  t.is(oversized.sent.length, 0)
})

test('SeedProtocol handshake rejects mismatched major versions before pending replay', (t) => {
  const proto = new SeedProtocol(null, {})
  t.teardown(() => clearSeedProtocol(proto))
  const channel = fakeOpenChannel(b4a.from(JSON.stringify({ major: 2, minor: 0 })))
  let mismatch = null

  proto.pendingRequests.set('aa'.repeat(32), baseSeedRequest())
  proto.on('version-mismatch', event => { mismatch = event })

  proto._onOpen(channel)

  t.is(channel.closed, true)
  t.is(channel.sent.length, 0)
  t.alike(mismatch.remote, { major: 2, minor: 0 })
})

test('HiveRelayClient seed handlers ignore decoded seed protocol errors without throwing', (t) => {
  const client = new HiveRelayClient({ swarm: mockSwarm(), store: mockStore() })
  const events = { accept: [], deny: [] }
  client.on('invalid-accept', (event) => events.accept.push(event))
  client.on('invalid-deny', (event) => events.deny.push(event))

  t.execution(() => client._onSeedAccept('aa'.repeat(32), { error: 'malformed seed accept', appKey: null }), 'client seed accept error handled')
  t.execution(() => client._onSeedDeny('aa'.repeat(32), { error: 'malformed seed deny', appKey: null }), 'client seed deny error handled')

  t.is(events.accept[0].reason, 'malformed seed accept')
  t.is(events.deny[0].reason, 'malformed seed deny')
})