OverviewFilesCommitsIssuesDocsReleasesPeers

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

393 lines · javascript ref:
import b4a from 'b4a'
import { isAdvertisedOperation } from '@hiverelay/blind-protocol/wire-runtime-authority'
import { asBytes, randomBytes, wipe } from './bytes.js'
import { fail } from './errors.js'

const MAGIC = b4a.from('HRINT1', 'ascii')
const VERSION = 1
const HEADER_BYTES = 264
const MAX_OPERATION_BYTES = 4 * 1024 * 1024
const MAX_RESULT_BYTES = 4 * 1024 * 1024
const MAX_U64 = (1n << 64n) - 1n
const INTENT_AAD_DOMAIN = b4a.from('hiverelay.blind.client-intent-aead.v1', 'ascii')

export const INTENT_STATE = Object.freeze({
  JOURNALED: 1,
  TARGET_PREPARED: 2,
  SENT: 3,
  ACKNOWLEDGED: 4,
  PENDING_UNKNOWN: 5,
  RESULT_VERIFIED: 6,
  RETRYABLE: 7,
  TERMINAL: 8
})

const ALLOWED_TRANSITIONS = new Map([
  [INTENT_STATE.JOURNALED, new Set([INTENT_STATE.TARGET_PREPARED, INTENT_STATE.TERMINAL])],
  [INTENT_STATE.TARGET_PREPARED, new Set([INTENT_STATE.SENT, INTENT_STATE.RETRYABLE, INTENT_STATE.TERMINAL])],
  [INTENT_STATE.SENT, new Set([INTENT_STATE.ACKNOWLEDGED, INTENT_STATE.PENDING_UNKNOWN,
    INTENT_STATE.RESULT_VERIFIED, INTENT_STATE.RETRYABLE, INTENT_STATE.TERMINAL])],
  [INTENT_STATE.ACKNOWLEDGED, new Set([INTENT_STATE.RESULT_VERIFIED, INTENT_STATE.PENDING_UNKNOWN])],
  [INTENT_STATE.PENDING_UNKNOWN, new Set([INTENT_STATE.TARGET_PREPARED, INTENT_STATE.RESULT_VERIFIED,
    INTENT_STATE.TERMINAL])],
  [INTENT_STATE.RESULT_VERIFIED, new Set([])],
  [INTENT_STATE.RETRYABLE, new Set([INTENT_STATE.TARGET_PREPARED, INTENT_STATE.TERMINAL])],
  [INTENT_STATE.TERMINAL, new Set([])]
])

function u64 (value, field) {
  if (typeof value === 'number') {
    if (!Number.isSafeInteger(value) || value < 0) fail('BAD_CLIENT_INPUT', `${field} is outside u64`)
    value = BigInt(value)
  }
  if (typeof value !== 'bigint' || value < 0n || value > MAX_U64) fail('BAD_CLIENT_INPUT', `${field} is outside u64`)
  return value
}

function writeU64 (output, offset, value) {
  value = u64(value, 'u64 value')
  for (let index = 7; index >= 0; index--) {
    output[offset + index] = Number(value & 0xffn)
    value >>= 8n
  }
}

function readU64 (input, offset) {
  let value = 0n
  for (let index = 0; index < 8; index++) value = (value << 8n) | BigInt(input[offset + index])
  return value
}

function writeU16 (output, offset, value) {
  output[offset] = (value >>> 8) & 0xff
  output[offset + 1] = value & 0xff
}

function readU16 (input, offset) {
  return input[offset] * 0x100 + input[offset + 1]
}

function nonzeroBytes (value, field, length = 32) {
  value = b4a.from(asBytes(value, field, length))
  if (value.every(byte => byte === 0)) fail('BAD_CLIENT_INPUT', `${field} must be nonzero`)
  return value
}

function boundedBytes (value, field, maximum, allowEmpty = false) {
  value = b4a.from(asBytes(value, field))
  if ((!allowEmpty && value.byteLength === 0) || value.byteLength > maximum) {
    fail('BAD_CLIENT_INPUT', `${field} is outside its byte bound`)
  }
  return value
}

function integer (value, minimum, maximum, field) {
  if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
    fail('BAD_CLIENT_INPUT', `${field} must be within ${minimum}..${maximum}`)
  }
  return value
}

function normalizedIntent (value) {
  if (!value || typeof value !== 'object') fail('BAD_CLIENT_INPUT', 'client intent must be an object')
  const state = integer(value.state, INTENT_STATE.JOURNALED, INTENT_STATE.TERMINAL, 'intent state')
  const operationBytes = boundedBytes(value.operationBytes, 'operationBytes', MAX_OPERATION_BYTES)
  const resultBytes = value.resultBytes == null
    ? null
    : boundedBytes(value.resultBytes, 'resultBytes', MAX_RESULT_BYTES, true)
  const mayHaveCommitted = value.mayHaveCommitted === true
  if (state === INTENT_STATE.JOURNALED && mayHaveCommitted) {
    fail('BAD_CLIENT_INPUT', 'newly journaled intent cannot be marked mayHaveCommitted')
  }
  if (state === INTENT_STATE.RESULT_VERIFIED && resultBytes == null) {
    fail('BAD_CLIENT_INPUT', 'verified intent requires exact result bytes')
  }
  if (state !== INTENT_STATE.RESULT_VERIFIED && resultBytes != null) {
    fail('BAD_CLIENT_INPUT', 'result bytes are persisted only after complete verification')
  }
  const transportSupportBit = integer(value.transportSupportBit, 1, 0xffff, 'transportSupportBit')
  const privacyProfileBit = integer(value.privacyProfileBit, 1, 0xffff, 'privacyProfileBit')
  if ((transportSupportBit & (transportSupportBit - 1)) !== 0 ||
      (privacyProfileBit & (privacyProfileBit - 1)) !== 0) {
    fail('BAD_CLIENT_INPUT', 'transport and privacy profiles must each contain one bit')
  }
  const familyId = integer(value.familyId, 1, 5, 'familyId')
  const operationId = integer(value.operationId, 1, 255, 'operationId')
  if (!isAdvertisedOperation(familyId, operationId)) {
    fail('BAD_CLIENT_INPUT', 'client intent operation is unknown or reserved by the active release profile')
  }
  return {
    version: VERSION,
    state,
    mayHaveCommitted,
    intentId: nonzeroBytes(value.intentId, 'intentId'),
    logicalId: nonzeroBytes(value.logicalId, 'logicalId'),
    continuityRoot: nonzeroBytes(value.continuityRoot, 'continuityRoot'),
    storeId: nonzeroBytes(value.storeId, 'storeId'),
    descriptorHash: nonzeroBytes(value.descriptorHash, 'descriptorHash'),
    descriptorSequence: u64(value.descriptorSequence, 'descriptorSequence'),
    endpointId: integer(value.endpointId, 1, 255, 'endpointId'),
    transportId: integer(value.transportId, 1, 255, 'transportId'),
    transportSupportBit,
    privacyProfileBit,
    familyId,
    operationId,
    requestCommitment: nonzeroBytes(value.requestCommitment, 'requestCommitment'),
    clientNonce: b4a.from(asBytes(value.clientNonce, 'clientNonce', 32)),
    operationBytes,
    resultBytes,
    attemptCount: integer(value.attemptCount == null ? 0 : value.attemptCount, 0, 0xffffffff, 'attemptCount'),
    lastErrorCode: integer(value.lastErrorCode == null ? 0 : value.lastErrorCode, 0, 255, 'lastErrorCode')
  }
}

function copyIntent (value) {
  return normalizedIntent(value)
}

export function encodeClientIntent (value) {
  value = normalizedIntent(value)
  const resultLength = value.resultBytes == null ? 0xffffffff : value.resultBytes.byteLength
  const output = b4a.alloc(HEADER_BYTES + value.operationBytes.byteLength + (value.resultBytes == null ? 0 : resultLength))
  b4a.copy(MAGIC, output, 0)
  output[6] = VERSION
  output[7] = value.state
  output[8] = value.mayHaveCommitted ? 1 : 0
  output[9] = value.familyId
  output[10] = value.operationId
  output[11] = value.endpointId
  output[12] = value.transportId
  writeU16(output, 13, value.transportSupportBit)
  writeU16(output, 15, value.privacyProfileBit)
  b4a.writeUInt32BE(output, value.attemptCount, 17)
  output[21] = value.lastErrorCode
  b4a.writeUInt32BE(output, value.operationBytes.byteLength, 22)
  b4a.writeUInt32BE(output, resultLength, 26)
  writeU64(output, 30, value.descriptorSequence)
  b4a.copy(value.intentId, output, 38)
  b4a.copy(value.logicalId, output, 70)
  b4a.copy(value.continuityRoot, output, 102)
  b4a.copy(value.storeId, output, 134)
  b4a.copy(value.descriptorHash, output, 166)
  b4a.copy(value.requestCommitment, output, 198)
  b4a.copy(value.clientNonce, output, 230)
  b4a.copy(value.operationBytes, output, HEADER_BYTES)
  if (value.resultBytes != null) b4a.copy(value.resultBytes, output, HEADER_BYTES + value.operationBytes.byteLength)
  return output
}

export function decodeClientIntent (input) {
  input = b4a.from(asBytes(input, 'encoded client intent'))
  if (input.byteLength < HEADER_BYTES || !b4a.equals(input.subarray(0, 6), MAGIC) || input[6] !== VERSION) {
    fail('INTENT_CORRUPT', 'client intent header is invalid')
  }
  if (input[8] > 1 || input[262] !== 0 || input[263] !== 0) {
    fail('INTENT_CORRUPT', 'client intent flags or reserved bytes are invalid')
  }
  const operationLength = b4a.readUInt32BE(input, 22)
  const encodedResultLength = b4a.readUInt32BE(input, 26)
  const resultLength = encodedResultLength === 0xffffffff ? 0 : encodedResultLength
  if (operationLength === 0 || operationLength > MAX_OPERATION_BYTES || resultLength > MAX_RESULT_BYTES ||
      input.byteLength !== HEADER_BYTES + operationLength + resultLength) {
    fail('INTENT_CORRUPT', 'client intent lengths are invalid')
  }
  return normalizedIntent({
    state: input[7],
    mayHaveCommitted: input[8] === 1,
    familyId: input[9],
    operationId: input[10],
    endpointId: input[11],
    transportId: input[12],
    transportSupportBit: readU16(input, 13),
    privacyProfileBit: readU16(input, 15),
    attemptCount: b4a.readUInt32BE(input, 17),
    lastErrorCode: input[21],
    descriptorSequence: readU64(input, 30),
    intentId: input.subarray(38, 70),
    logicalId: input.subarray(70, 102),
    continuityRoot: input.subarray(102, 134),
    storeId: input.subarray(134, 166),
    descriptorHash: input.subarray(166, 198),
    requestCommitment: input.subarray(198, 230),
    clientNonce: input.subarray(230, 262),
    operationBytes: input.subarray(HEADER_BYTES, HEADER_BYTES + operationLength),
    resultBytes: encodedResultLength === 0xffffffff ? null : input.subarray(HEADER_BYTES + operationLength)
  })
}

function assertTransition (before, after) {
  if (!sameIdentity(before, after)) fail('INTENT_TRANSITION_INVALID', 'immutable intent identity changed during CAS')
  if (before.state !== after.state && !ALLOWED_TRANSITIONS.get(before.state).has(after.state)) {
    fail('INTENT_TRANSITION_INVALID', 'intent state transition is not allowed')
  }
  if (after.attemptCount < before.attemptCount || after.attemptCount > before.attemptCount + 1) {
    fail('INTENT_TRANSITION_INVALID', 'intent attempt count must be monotonic and increment by at most one')
  }
  if (before.mayHaveCommitted && !after.mayHaveCommitted) {
    fail('INTENT_TRANSITION_INVALID', 'mayHaveCommitted cannot be cleared')
  }
}

function sameIdentity (left, right) {
  for (const field of [
    'intentId', 'logicalId', 'continuityRoot', 'storeId', 'descriptorHash', 'requestCommitment', 'clientNonce',
    'operationBytes'
  ]) if (!b4a.equals(left[field], right[field])) return false
  for (const field of [
    'descriptorSequence', 'endpointId', 'transportId', 'transportSupportBit', 'privacyProfileBit', 'familyId', 'operationId'
  ]) if (BigInt(left[field]) !== BigInt(right[field])) return false
  return true
}

function intentKey (intentId) {
  return `intent:${b4a.toString(asBytes(intentId, 'intentId', 32), 'hex')}`
}

function aad (intentId, revision) {
  const revisionBytes = b4a.alloc(8)
  writeU64(revisionBytes, 0, revision)
  return b4a.concat([INTENT_AAD_DOMAIN, asBytes(intentId, 'intentId', 32), revisionBytes])
}

export function createAesGcmIntentSealer (runtime, key) {
  key = b4a.from(asBytes(key, 'intent encryption key', 32))
  if (!runtime || typeof runtime.aes256GcmEncrypt !== 'function' || typeof runtime.aes256GcmDecrypt !== 'function') {
    fail('CRYPTO_UNAVAILABLE', 'intent sealer requires AES-256-GCM runtime methods')
  }
  return Object.freeze({
    async seal ({ intentId, revision, plaintext }) {
      const nonce = randomBytes(runtime, 12, 'intent encryption nonce')
      const sealed = await runtime.aes256GcmEncrypt({ key, nonce, aad: aad(intentId, revision), plaintext })
      return b4a.concat([nonce, b4a.from(sealed)])
    },
    async open ({ intentId, revision, ciphertext }) {
      ciphertext = asBytes(ciphertext, 'intent ciphertext')
      if (ciphertext.byteLength < 28) fail('INTENT_CORRUPT', 'intent ciphertext is truncated')
      try {
        return b4a.from(await runtime.aes256GcmDecrypt({
          key,
          nonce: ciphertext.subarray(0, 12),
          aad: aad(intentId, revision),
          sealed: ciphertext.subarray(12)
        }))
      } catch (error) {
        fail('INTENT_CORRUPT', 'intent ciphertext authentication failed', { cause: error })
      }
    }
  })
}

export class MemoryIntentBackend {
  constructor () {
    this.records = new Map()
  }

  async read (key) {
    const value = this.records.get(key)
    return value == null
      ? { revision: 0n, ciphertext: null }
      : { revision: value.revision, ciphertext: b4a.from(value.ciphertext) }
  }

  async compareAndSwap (key, expectedRevision, ciphertext) {
    const current = this.records.get(key)
    const revision = current == null ? 0n : current.revision
    if (revision !== BigInt(expectedRevision)) return false
    this.records.set(key, { revision: revision + 1n, ciphertext: b4a.from(ciphertext) })
    return true
  }

  async keys () {
    return [...this.records.keys()].sort()
  }
}

export class EncryptedIntentStore {
  constructor (options) {
    if (!options || !options.backend || !options.sealer ||
        typeof options.backend.read !== 'function' || typeof options.backend.compareAndSwap !== 'function' ||
        typeof options.sealer.seal !== 'function' || typeof options.sealer.open !== 'function') {
      fail('BAD_CLIENT_INPUT', 'encrypted intent store requires a CAS backend and authenticated sealer')
    }
    this.backend = options.backend
    this.sealer = options.sealer
  }

  async create (value) {
    const intent = normalizedIntent({
      ...value,
      state: INTENT_STATE.JOURNALED,
      mayHaveCommitted: false,
      attemptCount: 0,
      lastErrorCode: 0,
      resultBytes: null
    })
    const key = intentKey(intent.intentId)
    const plaintext = encodeClientIntent(intent)
    try {
      const ciphertext = await this.sealer.seal({ intentId: intent.intentId, revision: 1n, plaintext })
      if (!await this.backend.compareAndSwap(key, 0n, ciphertext)) fail('INTENT_CONFLICT', 'intent ID already exists')
      return copyIntent(intent)
    } finally {
      wipe(plaintext)
    }
  }

  async read (intentId) {
    const record = await this.backend.read(intentKey(intentId))
    if (record.ciphertext == null) return null
    const plaintext = await this.sealer.open({ intentId, revision: record.revision, ciphertext: record.ciphertext })
    try {
      const value = decodeClientIntent(plaintext)
      if (!b4a.equals(value.intentId, asBytes(intentId, 'intentId', 32))) fail('INTENT_CORRUPT', 'intent key/body mismatch')
      return { revision: record.revision, value }
    } finally {
      wipe(plaintext)
    }
  }

  async update (intentId, mutate) {
    if (typeof mutate !== 'function') fail('BAD_CLIENT_INPUT', 'intent CAS mutate callback is required')
    for (let attempt = 0; attempt < 8; attempt++) {
      const current = await this.read(intentId)
      if (current == null) fail('INTENT_NOT_FOUND', 'intent does not exist')
      const candidate = normalizedIntent(await mutate(copyIntent(current.value)))
      assertTransition(current.value, candidate)
      const plaintext = encodeClientIntent(candidate)
      try {
        const nextRevision = current.revision + 1n
        const ciphertext = await this.sealer.seal({ intentId, revision: nextRevision, plaintext })
        if (await this.backend.compareAndSwap(intentKey(intentId), current.revision, ciphertext)) {
          return { revision: nextRevision, value: copyIntent(candidate) }
        }
      } finally {
        wipe(plaintext)
      }
    }
    fail('INTENT_BUSY', 'intent CAS did not converge')
  }
}

export function createClientIntent (options) {
  return normalizedIntent({
    ...options,
    intentId: options.intentId == null ? randomBytes(options.runtime, 32, 'intentId') : options.intentId,
    state: INTENT_STATE.JOURNALED,
    mayHaveCommitted: false,
    attemptCount: 0,
    lastErrorCode: 0,
    resultBytes: null
  })
}

export async function journalSignedIntent (store, intent, options = {}) {
  if (!(store instanceof EncryptedIntentStore)) fail('BAD_CLIENT_INPUT', 'EncryptedIntentStore is required')
  const ephemeralSecrets = options.ephemeralSecrets || []
  if (!Array.isArray(ephemeralSecrets)) fail('BAD_CLIENT_INPUT', 'ephemeralSecrets must be an array')
  try {
    return await store.create(intent)
  } finally {
    for (const secret of ephemeralSecrets) wipe(secret)
  }
}