OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / blind-client / results.js

512 lines · javascript ref:
import b4a from 'b4a'
import {
  AUXILIARY_SIGNATURE_DOMAIN_ID,
  CELL_SIZE_CLASS,
  CELL_RECEIPT_RESULT,
  CORE_ACK_RESULT,
  FAMILY,
  INBOX_APPEND_RESULT,
  INBOX_RECEIPT_RESULT,
  OPERATION,
  RESULT_SIGNATURE_DOMAIN_ID
} from '@hiverelay/blind-protocol/wire-runtime-authority'
import {
  batchGetResultV1,
  blindCoreAckV1,
  blindReceiptV1,
  coreServeResultV1,
  getCellResultV1,
  inboxAppendAckV1,
  inboxReadResultV1,
  inboxReceiptV1,
  proveCellResultV1
} from '@hiverelay/blind-protocol/schemas'
import { encodeCanonical } from '@hiverelay/blind-protocol/codec'
import {
  allocationCommitment,
  blake2b256,
  cellBatchGetRequestCommitment,
  cellGetRequestCommitment,
  cellManageRequestCommitment,
  cellProveRequestCommitment,
  cellPutRequestCommitment,
  coreMirrorRequestCommitment,
  coreServeRequestCommitment,
  inboxAppendRequestCommitment,
  inboxCreateCommitment,
  inboxCreateRequestCommitment,
  inboxManageRequestCommitment,
  inboxReadRequestCommitment,
  inboxWatchRequestCommitment,
  persistentResultCommitment
} from '@hiverelay/blind-protocol/hashes'
import { blindExternalCommitWitnessV1 } from '@hiverelay/blind-protocol/result-binding'
import { asBytes } from './bytes.js'
import { openCell } from './cells.js'
import { verifiedEndpointContext } from './verified-endpoint.js'
import { fail } from './errors.js'
import {
  decodeCanonicalCopy,
  sameBytes,
  verifyAuxiliarySignedValue,
  verifyResultSignedValue
} from './signed.js'

const VERIFIED_RESULT = Symbol('VerifiedOperationResult')
const resultInternals = new WeakMap()

function key (familyId, operationId) {
  return `${familyId}:${operationId}`
}

function requireSame (actual, expected, field) {
  if (!sameBytes(actual, expected)) fail('RELAY_PROTOCOL_VIOLATION', `${field} does not match the attempted request`)
}

function requestNonce (request) {
  return asBytes(request.clientNonce, 'request clientNonce', 32)
}

const OPTIONAL_EXTERNAL_WITNESS = new Set([
  key(FAMILY.CELL, OPERATION.CELL.PROVE),
  key(FAMILY.CELL, OPERATION.CELL.BATCH_GET),
  key(FAMILY.INBOX, OPERATION.INBOX.READ),
  key(FAMILY.CORE, OPERATION.CORE.PROVE)
])

function requiresExternalWitness (context, request) {
  if (context.durabilityProfileId !== 2) return false
  if (OPTIONAL_EXTERNAL_WITNESS.has(key(context.familyId, context.operationId))) {
    return request != null && request.admission != null
  }
  return true
}

function verifyBinding (binding, context, options, resultCommitment) {
  const scalar = [
    ['descriptorSequence', context.descriptorSequence],
    ['durabilityProfileId', context.durabilityProfileId],
    ['restoreEvidenceHeadSequence', context.restoreEvidenceHeadSequence]
  ]
  for (const [field, expected] of scalar) {
    if (BigInt(binding[field]) !== BigInt(expected)) {
      fail('RELAY_PROTOCOL_VIOLATION', `relay binding ${field} does not match the qualified descriptor`)
    }
  }
  for (const field of [
    'relayPublicKey',
    'storeId',
    'descriptorHash',
    'durabilityContinuityHash',
    'durabilityProfileHash',
    'restoreEvidenceHeadHash'
  ]) {
    if (!sameBytes(binding[field], context[field])) {
      fail('RELAY_PROTOCOL_VIOLATION', `relay binding ${field} does not match the qualified descriptor`)
    }
  }
  const witness = binding.externalCommitWitness
  const required = requiresExternalWitness(context, options.request)
  if (witness == null) {
    if (required) {
      fail('RELAY_PROTOCOL_VIOLATION', 'profile-2 result omitted its external commit witness')
    }
    return
  }
  if (!required) fail('RELAY_PROTOCOL_VIOLATION', 'uncharged result carried an external commit witness')
  if (context.durabilityProfileId !== 2 ||
      !sameBytes(witness.witnessPublicKey, context.externalWitnessPublicKey)) {
    fail('RELAY_PROTOCOL_VIOLATION', 'external commit witness is not bound to the descriptor witness key')
  }
  if (witness.familyId !== context.familyId || witness.operationId !== context.operationId ||
      !sameBytes(witness.requestCommitment, options.requestCommitment) ||
      !sameBytes(witness.externalJournalId, context.externalJournalId) ||
      !sameBytes(witness.resultCommitment, resultCommitment)) {
    fail('RELAY_PROTOCOL_VIOLATION', 'external commit witness does not bind the attempted operation')
  }
  verifyAuxiliarySignedValue(blindExternalCommitWitnessV1, witness,
    AUXILIARY_SIGNATURE_DOMAIN_ID.EXTERNAL_COMMIT_WITNESS,
    context.externalWitnessPublicKey, 'external commit witness')
  if (typeof options.externalWitnessVerifier !== 'function' ||
      options.externalWitnessVerifier(Object.freeze({ witness, context, resultCommitment: b4a.from(resultCommitment) })) !== true) {
    fail('RELAY_PROTOCOL_VIOLATION', 'external commit witness policy verification failed')
  }
}

function signed (encoding, value, domainId, context, label, binding, options) {
  verifyResultSignedValue(encoding, value, domainId, context.relayPublicKey, label)
  if (binding != null) {
    const withoutWitness = encodeCanonical(encoding, {
      ...value,
      relayBinding: { ...binding, externalCommitWitness: null }
    })
    const unsigned = withoutWitness.subarray(0, withoutWitness.byteLength - 64)
    verifyBinding(binding, context, options,
      persistentResultCommitment(context.familyId, context.operationId, unsigned))
  }
}

function commonCorrelation (value, request, requestCommitment) {
  requireSame(value.requestNonce, requestNonce(request), 'result requestNonce')
  requireSame(value.requestCommitment, requestCommitment, 'result requestCommitment')
}

function putAllocationCommitment (context, request) {
  return allocationCommitment({
    relayPublicKey: context.relayPublicKey,
    storageSlot: request.storageSlot,
    allocationEpoch: request.allocationEpoch,
    sizeClass: request.sizeClass,
    leaseClass: request.leaseClass,
    declaredCellBlobHash: request.declaredBlobHash,
    createPublicKey: request.createPublicKey,
    renewPublicKey: request.renewPublicKey,
    dropPublicKey: request.dropPublicKey
  })
}

function recomputeRequestCommitment (context, request, getStorageSlot) {
  const relayPublicKey = context.relayPublicKey
  switch (key(context.familyId, context.operationId)) {
    case key(FAMILY.CELL, OPERATION.CELL.PUT): {
      const allocation = putAllocationCommitment(context, request)
      return cellPutRequestCommitment({ allocationCommitment: allocation, clientNonce: request.clientNonce })
    }
    case key(FAMILY.CELL, OPERATION.CELL.GET):
      return cellGetRequestCommitment({ relayPublicKey, storageSlot: getStorageSlot, clientNonce: request.clientNonce })
    case key(FAMILY.CELL, OPERATION.CELL.RENEW):
      return cellManageRequestCommitment({
        operation: 'cell-renew',
        relayPublicKey,
        storageSlot: request.storageSlot,
        expectedRevision: request.expectedRevision,
        expectedLeaseEpoch: request.expectedLeaseEpoch,
        requestedLeaseClass: request.leaseClass,
        clientNonce: request.clientNonce
      })
    case key(FAMILY.CELL, OPERATION.CELL.DROP):
      return cellManageRequestCommitment({
        operation: 'cell-drop',
        relayPublicKey,
        storageSlot: request.storageSlot,
        expectedRevision: request.expectedRevision,
        expectedLeaseEpoch: request.expectedLeaseEpoch,
        requestedLeaseClass: 0,
        clientNonce: request.clientNonce
      })
    case key(FAMILY.CELL, OPERATION.CELL.PROVE):
      return cellProveRequestCommitment({ relayPublicKey, storageSlot: request.storageSlot, clientNonce: request.clientNonce })
    case key(FAMILY.CELL, OPERATION.CELL.BATCH_GET):
      return cellBatchGetRequestCommitment({ relayPublicKey, slots: request.slots, clientNonce: request.clientNonce })
    case key(FAMILY.INBOX, OPERATION.INBOX.CREATE): {
      const create = inboxCreateCommitment({ ...request, relayPublicKey })
      return inboxCreateRequestCommitment({ inboxCreateCommitment: create, clientNonce: request.clientNonce })
    }
    case key(FAMILY.INBOX, OPERATION.INBOX.RENEW):
    case key(FAMILY.INBOX, OPERATION.INBOX.CLOSE):
      return inboxManageRequestCommitment({
        operation: context.operationId === OPERATION.INBOX.RENEW ? 'inbox-renew' : 'inbox-close',
        relayPublicKey,
        physicalTopic: request.physicalTopic,
        expectedRevision: request.expectedRevision,
        expectedLeaseEpoch: request.expectedLeaseEpoch,
        requestedLeaseClass: request.leaseClass,
        clientNonce: request.clientNonce
      })
    case key(FAMILY.INBOX, OPERATION.INBOX.APPEND):
      return inboxAppendRequestCommitment({
        relayPublicKey,
        physicalTopic: request.physicalTopic,
        frameClass: request.frameClass,
        frameHash: request.frameHash,
        clientNonce: request.clientNonce
      })
    case key(FAMILY.INBOX, OPERATION.INBOX.READ):
      return inboxReadRequestCommitment({
        relayPublicKey,
        physicalTopic: request.physicalTopic,
        cursor: request.cursor,
        limit: request.limit,
        clientNonce: request.clientNonce
      })
    case key(FAMILY.INBOX, OPERATION.INBOX.WATCH):
      return inboxWatchRequestCommitment({
        relayPublicKey,
        physicalTopic: request.physicalTopic,
        afterRevision: request.afterRevision,
        limit: request.limit,
        maxWaitMillis: request.maxWaitMillis,
        clientNonce: request.clientNonce
      })
    case key(FAMILY.CORE, OPERATION.CORE.MIRROR):
      return coreMirrorRequestCommitment({ ...request, relayPublicKey })
    case key(FAMILY.CORE, OPERATION.CORE.PROVE):
      return coreServeRequestCommitment({ ...request, relayPublicKey })
    default:
      fail('BAD_CLIENT_INPUT', 'operation has no closed request commitment verifier')
  }
}

const RESULT_TABLE = new Map([
  [key(FAMILY.CELL, OPERATION.CELL.PUT), {
    encoding: blindReceiptV1,
    verify (value, request, commitment, context, options) {
      signed(blindReceiptV1, value, RESULT_SIGNATURE_DOMAIN_ID.CELL_RECEIPT,
        context, 'cell PUT receipt', value.relayBinding, options)
      commonCorrelation(value, request, commitment)
      requireSame(value.slotCommitment, blake2b256(request.storageSlot), 'cell receipt slotCommitment')
      requireSame(value.cellBlobHash, request.declaredBlobHash, 'cell receipt cellBlobHash')
      requireSame(value.allocationCommitment, putAllocationCommitment(context, request),
        'cell receipt allocationCommitment')
      if (value.sizeClass !== request.sizeClass || value.allocationEpoch !== request.allocationEpoch ||
          value.leaseClass !== request.leaseClass || BigInt(value.stateRevision) !== 0n ||
          value.result !== CELL_RECEIPT_RESULT.STORED) {
        fail('RELAY_PROTOCOL_VIOLATION', 'cell PUT receipt shape does not match the request')
      }
    }
  }],
  [key(FAMILY.CELL, OPERATION.CELL.GET), {
    encoding: getCellResultV1,
    verify () {}
  }],
  [key(FAMILY.CELL, OPERATION.CELL.RENEW), {
    encoding: blindReceiptV1,
    verify (value, request, commitment, context, options) {
      signed(blindReceiptV1, value, RESULT_SIGNATURE_DOMAIN_ID.CELL_RECEIPT,
        context, 'cell RENEW receipt', value.relayBinding, options)
      commonCorrelation(value, request, commitment)
      requireSame(value.slotCommitment, blake2b256(request.storageSlot), 'cell receipt slotCommitment')
      if (value.result !== CELL_RECEIPT_RESULT.RENEWED || value.leaseClass !== request.leaseClass ||
          BigInt(value.stateRevision) !== BigInt(request.expectedRevision) + 1n ||
          value.leaseEpoch <= request.expectedLeaseEpoch) {
        fail('RELAY_PROTOCOL_VIOLATION', 'cell RENEW receipt shape does not match the request')
      }
    }
  }],
  [key(FAMILY.CELL, OPERATION.CELL.DROP), {
    encoding: blindReceiptV1,
    verify (value, request, commitment, context, options) {
      signed(blindReceiptV1, value, RESULT_SIGNATURE_DOMAIN_ID.CELL_RECEIPT,
        context, 'cell DROP receipt', value.relayBinding, options)
      commonCorrelation(value, request, commitment)
      requireSame(value.slotCommitment, blake2b256(request.storageSlot), 'cell receipt slotCommitment')
      if (value.result !== CELL_RECEIPT_RESULT.DROPPED || value.leaseClass !== 0 ||
          BigInt(value.stateRevision) !== BigInt(request.expectedRevision) + 1n) {
        fail('RELAY_PROTOCOL_VIOLATION', 'cell DROP receipt shape does not match the request')
      }
    }
  }],
  [key(FAMILY.CELL, OPERATION.CELL.PROVE), {
    encoding: proveCellResultV1,
    verify (value, request, commitment, context, options) {
      signed(blindReceiptV1, value.receipt, RESULT_SIGNATURE_DOMAIN_ID.CELL_RECEIPT,
        context, 'cell PROVE receipt', value.receipt.relayBinding, options)
      commonCorrelation(value.receipt, request, commitment)
      requireSame(value.receipt.slotCommitment, blake2b256(request.storageSlot), 'cell proof slotCommitment')
    }
  }],
  [key(FAMILY.CELL, OPERATION.CELL.BATCH_GET), {
    encoding: batchGetResultV1,
    verify (value, request, commitment, context, options) {
      signed(batchGetResultV1, value, RESULT_SIGNATURE_DOMAIN_ID.BATCH_GET_RESULT,
        context, 'batch GET result', value.relayBinding, options)
      commonCorrelation(value, request, commitment)
      if (value.entries.length !== request.slots.length) {
        fail('RELAY_PROTOCOL_VIOLATION', 'batch GET result count does not match request slots')
      }
    }
  }],
  [key(FAMILY.INBOX, OPERATION.INBOX.CREATE), inboxReceiptVerifier(INBOX_RECEIPT_RESULT.CREATED)],
  [key(FAMILY.INBOX, OPERATION.INBOX.RENEW), inboxReceiptVerifier(INBOX_RECEIPT_RESULT.RENEWED)],
  [key(FAMILY.INBOX, OPERATION.INBOX.CLOSE), inboxReceiptVerifier(INBOX_RECEIPT_RESULT.CLOSED)],
  [key(FAMILY.INBOX, OPERATION.INBOX.APPEND), {
    encoding: inboxAppendAckV1,
    verify (value, request, commitment, context, options) {
      signed(inboxAppendAckV1, value, RESULT_SIGNATURE_DOMAIN_ID.INBOX_APPEND_ACK,
        context, 'inbox APPEND acknowledgement', value.relayBinding, options)
      commonCorrelation(value, request, commitment)
      requireSame(value.topicCommitment, blake2b256(request.physicalTopic), 'inbox topicCommitment')
      requireSame(value.frameHash, request.frameHash, 'inbox frameHash')
      if (value.result !== INBOX_APPEND_RESULT.STORED) fail('RELAY_PROTOCOL_VIOLATION', 'inbox append was not stored')
    }
  }],
  [key(FAMILY.INBOX, OPERATION.INBOX.READ), inboxReadVerifier()],
  [key(FAMILY.INBOX, OPERATION.INBOX.WATCH), inboxReadVerifier()],
  [key(FAMILY.CORE, OPERATION.CORE.MIRROR), coreAckVerifier(CORE_ACK_RESULT.MIRROR_ACCEPTED)],
  [key(FAMILY.CORE, OPERATION.CORE.PROVE), {
    encoding: coreServeResultV1,
    verify (value, request, commitment, context, options) {
      const ack = value.acknowledgement
      signed(blindCoreAckV1, ack, RESULT_SIGNATURE_DOMAIN_ID.CORE_ACK,
        context, 'core PROVE acknowledgement', ack.relayBinding, options)
      commonCorrelation(ack, request, commitment)
      coreHeadCorrelation(ack, request)
      if (ack.result !== CORE_ACK_RESULT.RECENTLY_SERVED || typeof options.coreProofVerifier !== 'function' ||
          options.coreProofVerifier(Object.freeze({ request, acknowledgement: ack, proofsAndBlocks: value.proofsAndBlocks })) !== true) {
        fail('RELAY_PROTOCOL_VIOLATION', 'core proof verification failed')
      }
    }
  }]
])

function inboxReceiptVerifier (expectedResult) {
  return {
    encoding: inboxReceiptV1,
    verify (value, request, commitment, context, options) {
      signed(inboxReceiptV1, value, RESULT_SIGNATURE_DOMAIN_ID.INBOX_RECEIPT,
        context, 'inbox management receipt', value.relayBinding, options)
      commonCorrelation(value, request, commitment)
      requireSame(value.topicCommitment, blake2b256(request.physicalTopic), 'inbox topicCommitment')
      const expectedRevision = expectedResult === INBOX_RECEIPT_RESULT.CREATED
        ? 0n
        : BigInt(request.expectedRevision) + 1n
      if (value.result !== expectedResult || BigInt(value.stateRevision) !== expectedRevision ||
          value.leaseClass !== request.leaseClass) {
        fail('RELAY_PROTOCOL_VIOLATION', 'inbox receipt result does not match operation')
      }
    }
  }
}

function inboxReadVerifier () {
  return {
    encoding: inboxReadResultV1,
    verify (value, request, commitment, context, options) {
      signed(inboxReadResultV1, value, RESULT_SIGNATURE_DOMAIN_ID.INBOX_READ_RESULT,
        context, 'inbox read result', value.relayBinding, options)
      commonCorrelation(value, request, commitment)
    }
  }
}

function coreHeadCorrelation (value, request) {
  for (const field of ['corePublicKey', 'signedHeadHash']) requireSame(value[field], request[field], `core result ${field}`)
  if (BigInt(value.fork) !== BigInt(request.fork) || BigInt(value.length) !== BigInt(request.length)) {
    fail('RELAY_PROTOCOL_VIOLATION', 'core result head does not match request')
  }
}

function coreAckVerifier (expectedResult) {
  return {
    encoding: blindCoreAckV1,
    verify (value, request, commitment, context, options) {
      signed(blindCoreAckV1, value, RESULT_SIGNATURE_DOMAIN_ID.CORE_ACK,
        context, 'core acknowledgement', value.relayBinding, options)
      commonCorrelation(value, request, commitment)
      coreHeadCorrelation(value, request)
      if (value.result !== expectedResult) fail('RELAY_PROTOCOL_VIOLATION', 'core acknowledgement result does not match operation')
    }
  }
}

export class VerifiedOperationResult {
  constructor (token, fields) {
    if (token !== VERIFIED_RESULT) throw new TypeError('VerifiedOperationResult is not directly constructible')
    resultInternals.set(this, fields)
    Object.freeze(this)
  }

  get familyId () { return resultInternals.get(this).context.familyId }
  get operationId () { return resultInternals.get(this).context.operationId }
  snapshotBytes () { return b4a.from(resultInternals.get(this).bytes) }
}

export async function openVerifiedCellGetResult (options) {
  if (!options || typeof options !== 'object') {
    fail('BAD_CLIENT_INPUT', 'verified CELL.GET result options are required')
  }
  const internal = resultInternals.get(options.verifiedResult)
  if (!internal) {
    fail('BAD_CLIENT_INPUT', 'a package-owned VerifiedOperationResult is required')
  }
  if (internal.context.familyId !== FAMILY.CELL || internal.context.operationId !== OPERATION.CELL.GET) {
    fail('BAD_CLIENT_INPUT', 'verifiedResult is not a CELL.GET result')
  }
  if (!options.readCap || typeof options.readCap !== 'object') {
    fail('BAD_CLIENT_INPUT', 'readCap is required')
  }
  if (options.readCap.version !== 1) {
    fail('BAD_CLIENT_INPUT', 'readCap version must be 1')
  }

  const relayPublicKey = b4a.from(asBytes(options.readCap.relayPublicKey, 'readCap relayPublicKey', 32))
  const storageSlot = b4a.from(asBytes(options.readCap.storageSlot, 'readCap storageSlot', 32))
  const cellKey = b4a.from(asBytes(options.readCap.cellKey, 'readCap cellKey', 32))
  try {
    const sizeClass = options.readCap.sizeClass
    if (!Number.isInteger(sizeClass) || CELL_SIZE_CLASS[sizeClass] == null) {
      fail('BAD_CLIENT_INPUT', 'readCap sizeClass is outside the frozen cell classes')
    }
    const expectedCellBlobBytes = CELL_SIZE_CLASS[sizeClass]
    const expectedCellBlobHash = b4a.from(asBytes(
      options.readCap.expectedCellBlobHash,
      'readCap expectedCellBlobHash',
      32
    ))

    if (!sameBytes(relayPublicKey, internal.context.relayPublicKey)) {
      fail('BAD_CLIENT_INPUT', 'readCap relayPublicKey does not match the verified CELL.GET endpoint')
    }
    if (!sameBytes(storageSlot, internal.getStorageSlot)) {
      fail('BAD_CLIENT_INPUT', 'readCap storageSlot does not match the verified CELL.GET request')
    }
    if (internal.value.sizeClass !== sizeClass) {
      fail('RELAY_PROTOCOL_VIOLATION', 'CELL.GET result sizeClass does not match the read capability')
    }
    const cellBlob = b4a.from(asBytes(internal.value.cellBlob, 'CELL.GET result cellBlob', expectedCellBlobBytes))
    return await openCell({
      runtime: options.runtime,
      storageSlot,
      cellKey,
      sizeClass,
      expectedCellBlobHash,
      cellBlob
    })
  } finally {
    // The caller retains its encrypted capability record. Erase only this
    // short-lived copy after the asynchronous authenticated open completes.
    cellKey.fill(0)
  }
}

export function verifyOperationResult (options) {
  if (!options || typeof options !== 'object') fail('BAD_CLIENT_INPUT', 'operation result verification options are required')
  const context = verifiedEndpointContext(options.endpoint)
  const entry = RESULT_TABLE.get(key(context.familyId, context.operationId))
  if (!entry) fail('BAD_CLIENT_INPUT', 'qualified endpoint has no closed result verifier')
  const requestCommitment = b4a.from(asBytes(options.requestCommitment, 'requestCommitment', 32))
  const isCellGet = context.familyId === FAMILY.CELL && context.operationId === OPERATION.CELL.GET
  let getStorageSlot = null
  let recomputed
  try {
    if (isCellGet) {
      getStorageSlot = b4a.from(asBytes(options.request.storageSlot, 'CELL.GET request storageSlot', 32))
    }
    recomputed = recomputeRequestCommitment(context, options.request, getStorageSlot)
  } catch (error) {
    if (error && error.code === 'BAD_CLIENT_INPUT') throw error
    fail('BAD_CLIENT_INPUT', 'request cannot reproduce its frozen commitment', { cause: error })
  }
  if (!sameBytes(requestCommitment, recomputed)) {
    fail('BAD_CLIENT_INPUT', 'requestCommitment does not match the canonical attempted request')
  }
  const decoded = decodeCanonicalCopy(entry.encoding, options.resultBytes, 'operation result')
  entry.verify(decoded.value, options.request, requestCommitment, context, {
    ...options,
    requestCommitment
  })
  return new VerifiedOperationResult(VERIFIED_RESULT, {
    bytes: decoded.bytes,
    value: decoded.value,
    context,
    getStorageSlot
  })
}

export const RESULT_VERIFIER_STATUS = Object.freeze({
  expectedOperationCount: 14,
  implementedOperationCount: RESULT_TABLE.size,
  reservedOperationCount: 5,
  completeForAdvertisedOrdinary: RESULT_TABLE.size === 14
})