OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / core / gateway / hive-host.js

141 lines · javascript ref:
import b4a from 'b4a'

export const HIVE_Z32_ALPHABET = 'ybndrfg8ejkmcpqxot1uwisza345h769'
const HIVE_Z32_KEY_LENGTH = 52
const DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/
const Z32_VALUES = new Map(Array.from(HIVE_Z32_ALPHABET, (char, value) => [char, value]))

export function encodeHiveAppKey (key) {
  const bytes = b4a.from(key)
  if (bytes.byteLength !== 32) throw new Error('Hive app key must be 32 bytes')

  let output = ''
  let accumulator = 0
  let bits = 0

  for (const byte of bytes) {
    accumulator = (accumulator << 8) | byte
    bits += 8

    while (bits >= 5) {
      bits -= 5
      output += HIVE_Z32_ALPHABET[(accumulator >>> bits) & 31]
      accumulator &= bits === 0 ? 0 : (1 << bits) - 1
    }
  }

  if (bits > 0) output += HIVE_Z32_ALPHABET[(accumulator << (5 - bits)) & 31]
  return output
}

export function decodeHiveAppKey (label) {
  if (typeof label !== 'string' || label.length !== HIVE_Z32_KEY_LENGTH) return null
  if (label !== label.toLowerCase()) return null

  const output = []
  let accumulator = 0
  let bits = 0

  for (const char of label) {
    const value = Z32_VALUES.get(char)
    if (value === undefined) return null

    accumulator = (accumulator << 5) | value
    bits += 5

    while (bits >= 8) {
      bits -= 8
      output.push((accumulator >>> bits) & 255)
      accumulator &= bits === 0 ? 0 : (1 << bits) - 1
    }
  }

  // A 32-byte key occupies 260 encoded bits. The final four bits are padding
  // and must be zero, otherwise multiple DNS labels could decode to one key.
  if (output.length !== 32 || bits !== 4 || accumulator !== 0) return null

  const bytes = b4a.from(output)
  if (encodeHiveAppKey(bytes) !== label) return null
  return bytes
}

export function normalizeHiveAppHostSuffix (value) {
  if (typeof value !== 'string') return null
  let suffix = value.trim().toLowerCase()
  if (suffix.endsWith('.')) suffix = suffix.slice(0, -1)
  // Leave room for the 52-character app-key label and separating dot.
  if (!suffix || suffix.length > 200 || suffix.includes('..')) return null

  const labels = suffix.split('.')
  if (labels.length < 2 || labels.some(label => !DNS_LABEL.test(label))) return null
  return suffix
}

/**
 * Resolve `<z32-app-key>.<suffix>` without consulting forwarded headers.
 *
 * Returns one of:
 *   { kind: 'none' }                       ordinary compatibility host
 *   { kind: 'invalid', reason }            malformed/intended app host
 *   { kind: 'app', appKey, label, host }   canonical app origin
 */
export function resolveHiveAppHost (hostHeader, configuredSuffix) {
  const suffix = normalizeHiveAppHostSuffix(configuredSuffix)
  if (!suffix) return { kind: 'none' }
  if (typeof hostHeader !== 'string' || !hostHeader) {
    return { kind: 'invalid', reason: 'missing Host header' }
  }

  if (hostHeader !== hostHeader.trim() || hostHeader.includes(',')) {
    return { kind: 'invalid', reason: 'invalid Host header' }
  }

  // Raw IPv6 gateway access is a compatibility host, never an app subdomain.
  if (hostHeader.startsWith('[')) {
    const ipv6 = /^\[[0-9a-f:.]+\](?::([0-9]{1,5}))?$/i.exec(hostHeader)
    if (!ipv6) {
      return { kind: 'invalid', reason: 'invalid Host header' }
    }
    if (ipv6[1]) {
      const port = Number(ipv6[1])
      if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
        return { kind: 'invalid', reason: 'invalid Host port' }
      }
    }
    return { kind: 'none' }
  }

  const match = /^([a-z0-9.-]+)(?::([0-9]{1,5}))?$/i.exec(hostHeader)
  if (!match) return { kind: 'invalid', reason: 'invalid Host header' }

  if (match[2]) {
    const port = Number(match[2])
    if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
      return { kind: 'invalid', reason: 'invalid Host port' }
    }
  }

  let hostname = match[1].toLowerCase()
  if (hostname.endsWith('.')) hostname = hostname.slice(0, -1)

  if (hostname === suffix) {
    return { kind: 'invalid', reason: 'missing Hive app key label' }
  }
  if (!hostname.endsWith('.' + suffix)) return { kind: 'none' }

  const label = hostname.slice(0, -(suffix.length + 1))
  if (!label || label.includes('.')) {
    return { kind: 'invalid', reason: 'invalid Hive app hostname' }
  }

  const key = decodeHiveAppKey(label)
  if (!key) return { kind: 'invalid', reason: 'invalid Hive app key label' }

  return {
    kind: 'app',
    appKey: b4a.toString(key, 'hex'),
    label,
    host: `${label}.${suffix}`
  }
}