OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / blind-protocol / generate-wire-v3.mjs

394 lines · javascript ref:
import fs from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { createHash } from 'node:crypto'
import b4a from 'b4a'
import sodium from './crypto.js'
import { createWireAbiV3Value, encodeWireAbiV3 } from './abi-registry-v3.js'
import { decodeCanonical, encodeCanonical } from './codec.js'
import { encodeVectorManifest, hashAbi, hashSpec, hashVectorSet } from './hashes.js'
import { blindForwardOpenResultV1, blindForwardOpenV1 } from './schemas.js'
import {
  FORWARD_HTTPS_DOMAIN_V3,
  FORWARD_HTTPS_REQUEST_ROLE_V1,
  FORWARD_HTTPS_RESULT_ROLE_V1,
  FORWARD_HTTPS_SUCCESSOR_TRANSPORT_VARIANTS_V3,
  FORWARD_HTTPS_TLS_EXPORTER_LABEL_V1,
  FORWARD_HTTPS_V3_LIMITS,
  FORWARD_HTTPS_V3_RESULT_MATRIX,
  WIRE_V3_HASH_DOMAIN_PURPOSE,
  WIRE_V3_HASH_RECIPES,
  WIRE_V3_PROTOCOL,
  WIRE_V3_SCHEMA_DECLARATIONS,
  blindForwardHttpsOriginForwardTurnRequestV1,
  blindForwardHttpsOriginForwardTurnResultV1,
  createForwardHttpsForwardedRequestV1,
  forwardHttpsForwardedRequestCommitmentV1,
  forwardHttpsOriginRequestCommitmentV1,
  forwardHttpsResultSignaturePayloadV1,
  forwardHttpsStableSessionIdV1,
  forwardHttpsTargetResultChainHashV1,
  forwardHttpsTlsExporterBindingHashV1,
  forwardHttpsTlsExporterContextV1
} from './wire-v3.js'
import { forwardHttpsParentCapabilitySignaturePayloadV1, RELEASE_PROFILE_V2 } from './wire-v2.js'

const check = process.argv.includes('--check')
const root = path.dirname(new URL(import.meta.url).pathname)
const repoRoot = path.resolve(root, '../..')
const vectorRoot = path.join(root, 'vectors-v3/wire')
const fixed = (length, value) => b4a.alloc(length, value)
const hex = value => b4a.toString(value, 'hex')
const json = value => b4a.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8')

const compatibilityFloor = Object.freeze({
  'hiverelay-blind-abi-v1.cenc': '8fcc75ed7f32af8f118a521fe230d77ec1e4b2b209296adda2e73e87b74ff5b6',
  'hiverelay-blind-abi-v2.cenc': 'ff86d47c480a39156e2ab0268e3da786dbac3ff8528f98f758671ed1bf2a297c',
  'vector-manifest-v1.cenc': 'e23137dc90f52a1c9c3c8ac1e6ecb98eb32b653260fe1049f078ff8cebabc522',
  'vector-manifest-v2.cenc': '803717545ff5f51f396bf06646dda7ca4b12596c61bffc640a244360d287aa7f',
  'hiverelay-blind-wire-authority-v1.json': 'd6b757334bbec7b85d949085ce4b896a5fe960bc4c86c7f9001f81be78d0cefc',
  'hiverelay-blind-wire-authority-v2.json': '165316d7d551afabfe6943981d80cc8ba9cbe0b2db06336a3b95eaeb17783e86',
  'wire-v2.js': '678b417f8ca3adacb50442f46c2e8ae0500334f81d039e3552c1d8b89acd19d4',
  'abi-registry-v2.js': '8aabef205df158f49a08c5bd7a2c7bb6318355c16b3cb1b6dea38eddd361a126',
  'registry.js': '782075a117d9946b53fae90a90cd7cf957ba469cce522151dcc8f8b570b8f61f'
})

const sha256 = bytes => createHash('sha256').update(bytes).digest('hex')

async function assertCompatibilityFloor () {
  for (const [relative, expected] of Object.entries(compatibilityFloor)) {
    const actual = sha256(await fs.readFile(path.join(root, relative)))
    if (actual !== expected) throw new Error(`frozen WIRE artifact changed: ${relative} ${actual}`)
  }
}

async function writeOrCheck (file, bytes) {
  if (!b4a.isBuffer(bytes)) bytes = b4a.from(bytes)
  if (check) {
    let current
    try {
      current = await fs.readFile(file)
    } catch {
      throw new Error(`missing generated WIRE v3 artifact: ${path.relative(repoRoot, file)}`)
    }
    if (!b4a.equals(current, bytes)) throw new Error(`stale generated WIRE v3 artifact: ${path.relative(repoRoot, file)}`)
    return
  }
  await fs.mkdir(path.dirname(file), { recursive: true })
  await fs.writeFile(file, bytes)
}

function keypair (seedByte) {
  const publicKey = b4a.alloc(32)
  const secretKey = b4a.alloc(64)
  sodium.crypto_sign_seed_keypair(publicKey, secretKey, fixed(32, seedByte))
  return { publicKey, secretKey }
}

const source = keypair(41)
const target = keypair(42)
const clientSessionNonce = fixed(32, 43)

function originCapability (open) {
  return {
    version: 1,
    routeKind: 7,
    releaseProfileId: 2,
    sourceRelayPublicKey: source.publicKey,
    sourceDescriptorSequence: 11n,
    sourceDescriptorHash: fixed(32, 44),
    targetRelayPublicKey: target.publicKey,
    targetDescriptorSequence: open.nextDescriptorSequence,
    targetDescriptorHash: open.nextDescriptorHash,
    targetCatalogEntryId: fixed(32, 45),
    routeId: open.routeId,
    routePrefixRelayPublicKey: source.publicKey,
    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: open.circuitNonce,
    tlsExporterBindingHash: b4a.alloc(32),
    signature: b4a.alloc(64)
  }
}

function originRequest (requestKind, inner, sequence, previousTargetResultHash, capability) {
  return {
    version: 1,
    routeKind: 7,
    releaseProfileId: 2,
    requestRole: FORWARD_HTTPS_REQUEST_ROLE_V1.ORIGIN_TEMPLATE,
    requestKind,
    flags: 0,
    stableSessionId: forwardHttpsStableSessionIdV1(capability, clientSessionNonce),
    sequence,
    clientSessionNonce,
    requestNonce: fixed(32, 50 + requestKind),
    previousTargetResultHash,
    parentCapability: capability,
    turnTlsExporterBindingHash: b4a.alloc(32),
    originRequestCommitment: b4a.alloc(32),
    sourceTransformSignature: b4a.alloc(64),
    inner
  }
}

function finalizeCapability (origin, binding) {
  const capability = {
    ...origin,
    tlsExporterBindingHash: binding,
    signature: b4a.alloc(64)
  }
  sodium.crypto_sign_detached(capability.signature, forwardHttpsParentCapabilitySignaturePayloadV1(capability), source.secretKey)
  return capability
}

function transform (originBytes, capability, secretByte) {
  const origin = decodeCanonical(blindForwardHttpsOriginForwardTurnRequestV1, originBytes, { copyBytes: true })
  const context = forwardHttpsTlsExporterContextV1(origin.stableSessionId, origin.sequence, forwardHttpsOriginRequestCommitmentV1(originBytes))
  const binding = forwardHttpsTlsExporterBindingHashV1(fixed(32, secretByte), context)
  return createForwardHttpsForwardedRequestV1(originBytes, finalizeCapability(capability, binding), binding, source.secretKey)
}

function signedResult (forwardedBytes, resultRole, responseKind, inner) {
  const forwarded = decodeCanonical(blindForwardHttpsOriginForwardTurnRequestV1, forwardedBytes, { copyBytes: true })
  const sourceRole = resultRole !== FORWARD_HTTPS_RESULT_ROLE_V1.TARGET_RESULT
  const signer = sourceRole ? source : target
  const capability = forwarded.parentCapability
  const value = {
    version: 1,
    routeKind: 7,
    releaseProfileId: 2,
    resultRole,
    requestKind: forwarded.requestKind,
    responseKind,
    flags: 0,
    stableSessionId: forwarded.stableSessionId,
    sequence: forwarded.sequence,
    previousTargetResultHash: forwarded.previousTargetResultHash,
    originRequestCommitment: forwarded.originRequestCommitment,
    forwardedRequestCommitment: forwardHttpsForwardedRequestCommitmentV1(forwardedBytes),
    finalizedParentCapability: capability,
    turnTlsExporterBindingHash: forwarded.turnTlsExporterBindingHash,
    sourceTransformSignature: forwarded.sourceTransformSignature,
    signerPublicKey: signer.publicKey,
    signerDescriptorSequence: sourceRole ? capability.sourceDescriptorSequence : capability.targetDescriptorSequence,
    signerDescriptorHash: sourceRole ? capability.sourceDescriptorHash : capability.targetDescriptorHash,
    resultSignature: b4a.alloc(64),
    inner
  }
  const unsigned = encodeCanonical(blindForwardHttpsOriginForwardTurnResultV1, value)
  sodium.crypto_sign_detached(value.resultSignature, forwardHttpsResultSignaturePayloadV1(unsigned), signer.secretKey)
  return encodeCanonical(blindForwardHttpsOriginForwardTurnResultV1, value)
}

function mutate (bytes, offset) {
  const output = b4a.from(bytes)
  output[offset] ^= 1
  return output
}

function runtimeAuthoritySource (authority) {
  return '/* eslint-disable */\n// Generated by generate-wire-v3.mjs. Do not edit.\n' +
    'export * from \'./wire-runtime-authority-v2.js\'\n' +
    `export const WIRE_V3_PROTOCOL = Object.freeze(${JSON.stringify(authority.protocol)})\n` +
    `export const WIRE_V3_BASE_ABI_HASH = '${authority.baseWireV2AbiHash}'\n` +
    `export const WIRE_V3_ABI_HASH = '${authority.abiHash}'\n` +
    `export const WIRE_V3_COMPATIBILITY_ONLY_SCHEMA_IDS = Object.freeze(${JSON.stringify(authority.compatibilityOnlySchemaIds)})\n` +
    `export const WIRE_V3_SUCCESSOR_TRANSPORT_VARIANTS = Object.freeze(${JSON.stringify(authority.successorTransportVariants, null, 2)})\n` +
    `export const WIRE_V3_ADDITIONAL_DOMAINS = Object.freeze(${JSON.stringify(authority.additionalDomains, null, 2)})\n` +
    `export const WIRE_V3_HASH_RECIPES = Object.freeze(${JSON.stringify(authority.hashRecipes, null, 2)})\n` +
    'export const WIRE_V3_FORWARD_DESCRIPTOR_OPERATION_BITS = 0\n' +
    'export const WIRE_V3_FORWARD_ADVERTISED_OPERATION_BITS = 0\n' +
    'export const WIRE_V3_FORWARD_READINESS_OPERATION_BITS = 0\n'
}

await assertCompatibilityFloor()

const v2AbiBytes = await fs.readFile(path.join(root, 'hiverelay-blind-abi-v2.cenc'))
const baseWireV2AbiHash = hashAbi(v2AbiBytes)
if (hex(baseWireV2AbiHash) !== 'cc1abb0e24bd4c75e0cb99b824e114cf50ad91270362f39d8594a826e29d5053') {
  throw new Error('frozen WIRE v2 ABI hash mismatch')
}
const abiBytes = encodeWireAbiV3(createWireAbiV3Value(baseWireV2AbiHash))
const specBytes = await fs.readFile(path.join(repoRoot, 'docs/protocol/HIVERELAY-BLIND-WIRE-V3.md'))
const open = decodeCanonical(blindForwardOpenV1, await fs.readFile(path.join(root, 'vectors/forward/open.bin')), { copyBytes: true })
const openResult = decodeCanonical(blindForwardOpenResultV1, await fs.readFile(path.join(root, 'vectors/forward/open-result.bin')), { copyBytes: true })
const capability = originCapability(open)

const openOrigin = encodeCanonical(blindForwardHttpsOriginForwardTurnRequestV1, originRequest(1, open, 0n, b4a.alloc(32), capability))
const openForwarded = transform(openOrigin, capability, 60)
const openTargetResult = signedResult(openForwarded, 1, 1, openResult)
const previousHash = forwardHttpsTargetResultChainHashV1(openTargetResult)

const dataInner = { version: 1, circuitNonce: capability.circuitNonce, offset: 0n, bytes: fixed(64_000, 61) }
const dataOrigin = encodeCanonical(blindForwardHttpsOriginForwardTurnRequestV1, originRequest(2, dataInner, 1n, previousHash, capability))
const dataForwarded = transform(dataOrigin, capability, 62)
const dataResults = new Map([
  ['ack', signedResult(dataForwarded, 1, 2, null)],
  ['data', signedResult(dataForwarded, 1, 4, { ...dataInner, bytes: fixed(32, 63) })],
  ['window', signedResult(dataForwarded, 1, 5, { version: 1, circuitNonce: capability.circuitNonce, consumedThrough: 64_000n, creditIncrement: 4096 })],
  ['close', signedResult(dataForwarded, 1, 6, { version: 1, circuitNonce: capability.circuitNonce, closeKind: 1, finalSendOffset: 64_000n, reasonCode: 0 })],
  ['error', signedResult(dataForwarded, 1, 7, { version: 1, code: 1, retryable: 0, retryAfterEpoch: null })],
  ['source-error', signedResult(dataForwarded, 2, 7, { version: 1, code: 1, retryable: 0, retryAfterEpoch: null })],
  ['source-ambiguous', signedResult(dataForwarded, 3, 8, null)]
])
const pollOrigin = encodeCanonical(blindForwardHttpsOriginForwardTurnRequestV1, originRequest(5, null, 1n, previousHash, capability))
const pollForwarded = transform(pollOrigin, capability, 64)

const windowInner = { version: 1, circuitNonce: capability.circuitNonce, consumedThrough: 64_000n, creditIncrement: 4096 }
const windowOrigin = encodeCanonical(blindForwardHttpsOriginForwardTurnRequestV1, originRequest(3, windowInner, 1n, previousHash, capability))
const windowForwarded = transform(windowOrigin, capability, 65)
const closeInner = { version: 1, circuitNonce: capability.circuitNonce, closeKind: 1, finalSendOffset: 64_000n, reasonCode: 0 }
const closeOrigin = encodeCanonical(blindForwardHttpsOriginForwardTurnRequestV1, originRequest(4, closeInner, 1n, previousHash, capability))
const closeForwarded = transform(closeOrigin, capability, 66)

const requestFixtures = new Map([
  ['open', { kind: 1, origin: openOrigin, forwarded: openForwarded }],
  ['data', { kind: 2, origin: dataOrigin, forwarded: dataForwarded }],
  ['window', { kind: 3, origin: windowOrigin, forwarded: windowForwarded }],
  ['close', { kind: 4, origin: closeOrigin, forwarded: closeForwarded }],
  ['poll', { kind: 5, origin: pollOrigin, forwarded: pollForwarded }]
])
const responseNames = new Map([
  [1, 'open-accept'], [2, 'ack'], [3, 'noop'], [4, 'data'], [5, 'window'], [6, 'close'], [7, 'error']
])
const responseInner = new Map([
  [1, openResult],
  [2, null],
  [3, null],
  [4, { ...dataInner, bytes: fixed(32, 63) }],
  [5, windowInner],
  [6, closeInner],
  [7, { version: 1, code: 1, retryable: 0, retryAfterEpoch: null }]
])
const completeRoleAndMatrixVectors = []
for (const [requestName, fixture] of requestFixtures) {
  completeRoleAndMatrixVectors.push(
    { path: `positive/${requestName}-origin-role.bin`, bytes: fixture.origin },
    { path: `positive/${requestName}-forwarded-role.bin`, bytes: fixture.forwarded }
  )
  for (const responseKind of FORWARD_HTTPS_V3_RESULT_MATRIX[fixture.kind]) {
    completeRoleAndMatrixVectors.push({
      path: `positive/${requestName}-target-${responseNames.get(responseKind)}.bin`,
      bytes: signedResult(fixture.forwarded, 1, responseKind, responseInner.get(responseKind))
    })
  }
  completeRoleAndMatrixVectors.push(
    {
      path: `positive/${requestName}-source-pre-forward-error.bin`,
      bytes: signedResult(fixture.forwarded, 2, 7, responseInner.get(7))
    },
    {
      path: `positive/${requestName}-source-post-forward-ambiguous.bin`,
      bytes: signedResult(fixture.forwarded, 3, 8, null)
    }
  )
}

const profiles = Object.entries(RELEASE_PROFILE_V2).map(([canonicalName, profile]) => ({
  profileId: profile.id,
  canonicalName,
  operationBits: profile.operationBits,
  isDefault: profile.isDefault
}))
const domains = Object.values(FORWARD_HTTPS_DOMAIN_V3).map(value => ({ ...value }))
const matrix = Object.fromEntries(Object.entries(FORWARD_HTTPS_V3_RESULT_MATRIX).map(([kind, responses]) => [kind, responses]))
const vectorFiles = [
  { path: 'registry/wire-abi-v3.cenc', bytes: abiBytes },
  { path: 'registry/compatibility-floor.json', bytes: json(compatibilityFloor) },
  { path: 'registry/release-profiles-v3.json', bytes: json(profiles) },
  { path: 'registry/successor-transport-variants-v3.json', bytes: json(FORWARD_HTTPS_SUCCESSOR_TRANSPORT_VARIANTS_V3) },
  { path: 'registry/domains-v3.json', bytes: json(domains) },
  { path: 'registry/hash-recipes-v3.json', bytes: json(WIRE_V3_HASH_RECIPES) },
  { path: 'registry/result-matrix-v3.json', bytes: json(matrix) },
  { path: 'positive/open-origin.bin', bytes: openOrigin },
  { path: 'positive/open-forwarded.bin', bytes: openForwarded },
  { path: 'positive/open-target-result.bin', bytes: openTargetResult },
  { path: 'positive/data-origin-max.bin', bytes: dataOrigin },
  { path: 'positive/data-forwarded-max.bin', bytes: dataForwarded },
  { path: 'positive/poll-origin.bin', bytes: pollOrigin },
  { path: 'positive/poll-forwarded.bin', bytes: pollForwarded },
  { path: 'positive/window-origin.bin', bytes: windowOrigin },
  { path: 'positive/window-forwarded.bin', bytes: windowForwarded },
  { path: 'positive/close-origin.bin', bytes: closeOrigin },
  { path: 'positive/close-forwarded.bin', bytes: closeForwarded },
  { path: 'positive/data-target-source-error.bin', bytes: dataResults.get('source-error') },
  { path: 'positive/data-target-source-ambiguous.bin', bytes: dataResults.get('source-ambiguous') },
  ...completeRoleAndMatrixVectors,
  { path: 'negative/origin-bad-magic.bin', bytes: mutate(openOrigin, 0) },
  { path: 'negative/origin-nonzero-padding.bin', bytes: mutate(openOrigin, 65_535) },
  { path: 'negative/forwarded-bad-transform-signature.bin', bytes: mutate(dataForwarded, 600) },
  { path: 'negative/result-bad-signature.bin', bytes: mutate(dataResults.get('ack'), 705) },
  { path: 'negative/result-role-confusion.bin', bytes: mutate(dataResults.get('source-ambiguous'), 7) },
  {
    path: 'negative/expectations.json',
    bytes: json({
      'origin-bad-magic.bin': 'reject-magic',
      'origin-nonzero-padding.bin': 'reject-zero-padding',
      'forwarded-bad-transform-signature.bin': 'reject-source-transform-signature',
      'result-bad-signature.bin': 'reject-role-domain-signature',
      'result-role-confusion.bin': 'reject-role-conditioned-result-matrix',
      'wire-v2-schema-74-or-75': 'compatibility-only-unselectable'
    })
  }
]
const manifestBytes = encodeVectorManifest(vectorFiles)
const authority = {
  magic: 'hiverelay-blind-wire-authority-v3',
  formatVersion: 3,
  protocol: WIRE_V3_PROTOCOL,
  baseSchemaCount: 75,
  compatibilityOnlySchemaIds: [74, 75],
  baseWireV2AbiHash: hex(baseWireV2AbiHash),
  abiHash: hex(hashAbi(abiBytes)),
  specHash: hex(hashSpec(specBytes)),
  vectorSetHash: hex(hashVectorSet(manifestBytes)),
  additionalSchemas: WIRE_V3_SCHEMA_DECLARATIONS,
  releaseProfiles: profiles,
  successorTransportVariants: FORWARD_HTTPS_SUCCESSOR_TRANSPORT_VARIANTS_V3,
  additionalDomains: domains,
  hashDomainPurpose: WIRE_V3_HASH_DOMAIN_PURPOSE,
  hashRecipes: WIRE_V3_HASH_RECIPES,
  tlsExporterLabel: FORWARD_HTTPS_TLS_EXPORTER_LABEL_V1,
  exactRequestBytes: FORWARD_HTTPS_V3_LIMITS.EXACT_REQUEST_BYTES,
  exactResultBytes: FORWARD_HTTPS_V3_LIMITS.EXACT_RESULT_BYTES,
  maxDataBytes: FORWARD_HTTPS_V3_LIMITS.MAX_DATA_BYTES,
  forwardDescriptorOperationBits: 0,
  forwardAdvertisedOperationBits: 0,
  forwardReadinessOperationBits: 0,
  runtimeReady: false,
  authorizesRelease: false,
  compatibilityFloor
}
const outputs = [
  [path.join(root, 'hiverelay-blind-abi-v3.cenc'), abiBytes],
  [path.join(root, 'vector-manifest-v3.cenc'), manifestBytes],
  [path.join(root, 'hiverelay-blind-wire-authority-v3.json'), json(authority)],
  [path.join(root, 'wire-runtime-authority-v3.js'), b4a.from(runtimeAuthoritySource(authority))]
]
for (const vector of vectorFiles) outputs.push([path.join(vectorRoot, vector.path), vector.bytes])
for (const [file, bytes] of outputs) await writeOrCheck(file, bytes)

if (check) {
  const expected = new Set(vectorFiles.map(vector => vector.path))
  async function walk (directory, prefix = '') {
    const found = []
    for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
      const relative = prefix ? `${prefix}/${entry.name}` : entry.name
      if (entry.isDirectory()) found.push(...await walk(path.join(directory, entry.name), relative))
      else found.push(relative)
    }
    return found
  }
  for (const found of await walk(vectorRoot)) if (!expected.has(found)) throw new Error(`unexpected WIRE v3 vector: ${found}`)
}

console.log(check ? 'WIRE v3 authority verified' : 'WIRE v3 authority generated')