OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / blind-daemon / control-snapshot-stream.js

386 lines · javascript ref:
import fs from 'node:fs/promises'
import { constants as FS_CONSTANTS } from 'node:fs'
import path from 'node:path'
import b4a from 'b4a'
import sodium from 'sodium-universal'
import { HASH_DOMAIN } from '@hiverelay/blind-protocol'

const FIXED_HEADER_BYTES = 137
const MINIMUM_ENTRY_BYTES = 4
const MAX_SCHEMA_ENTRIES = 0x1000000
const MAX_U64 = (1n << 64n) - 1n
const DEFAULT_READ_CHUNK_BYTES = 64 * 1024

export class BlindControlSnapshotIntegrityError extends Error {
  constructor (message, code = 'BLIND_CONTROL_SNAPSHOT_INVALID') {
    super(message)
    this.name = 'BlindControlSnapshotIntegrityError'
    this.code = code
  }
}

function canonicalAbsolutePath (value, field) {
  if (typeof value !== 'string' || value.includes('\0') || !path.isAbsolute(value) || path.normalize(value) !== value) {
    throw new TypeError(`${field} must be a canonical absolute path`)
  }
  return value
}

function boundedInteger (value, minimum, maximum, field) {
  if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
    throw new TypeError(`${field} is outside ${minimum}..${maximum}`)
  }
  return value
}

function asU64 (value, field) {
  if (typeof value === 'number') {
    if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`${field} must be an unsigned safe integer or bigint`)
    value = BigInt(value)
  }
  if (typeof value !== 'bigint' || value < 0n || value > MAX_U64) throw new TypeError(`${field} is outside u64`)
  return value
}

function asBytes (value, length, field, nonzero = false) {
  if (!value || typeof value.byteLength !== 'number') throw new TypeError(`${field} must be bytes`)
  value = b4a.isBuffer(value)
    ? value
    : ArrayBuffer.isView(value)
      ? b4a.from(value.buffer, value.byteOffset, value.byteLength)
      : b4a.from(value)
  if (length != null && value.byteLength !== length) throw new TypeError(`${field} must be exactly ${length} bytes`)
  if (nonzero && isZero(value)) throw new TypeError(`${field} must be nonzero`)
  return value
}

function isZero (value) {
  for (const byte of value) if (byte !== 0) return false
  return true
}

function sameBytes (left, right) {
  return b4a.equals(asBytes(left, null, 'left bytes'), asBytes(right, null, 'right bytes'))
}

function writeU64be (value) {
  value = asU64(value, 'snapshot byte length')
  const output = b4a.alloc(8)
  for (let index = 7; index >= 0; index--) {
    output[index] = Number(value & 0xffn)
    value >>= 8n
  }
  return output
}

function readU64be (value) {
  let output = 0n
  for (const byte of value) output = (output << 8n) | BigInt(byte)
  return output
}

function readU16le (value) {
  return value[0] + value[1] * 0x100
}

function readU32le (value) {
  return value[0] + value[1] * 0x100 + value[2] * 0x10000 + value[3] * 0x1000000
}

function readU64le (value) {
  let output = 0n
  for (let index = 7; index >= 0; index--) output = (output << 8n) | BigInt(value[index])
  return output
}

function assertPrivateFile (stat, field) {
  if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) {
    throw new BlindControlSnapshotIntegrityError(`${field} is not a single-link regular file`)
  }
  if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
    throw new BlindControlSnapshotIntegrityError(`${field} is not owned by the daemon uid`)
  }
  if ((stat.mode & 0o600) !== 0o600 || (stat.mode & 0o077) !== 0) {
    throw new BlindControlSnapshotIntegrityError(`${field} permissions are not private`)
  }
}

function sameFileState (left, right) {
  return left.dev === right.dev && left.ino === right.ino && left.size === right.size &&
    left.nlink === right.nlink && left.mode === right.mode && left.uid === right.uid &&
    left.gid === right.gid && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs
}

function createHashState (byteLength) {
  const state = b4a.alloc(sodium.crypto_generichash_STATEBYTES)
  sodium.crypto_generichash_init(state, null, 32)
  sodium.crypto_generichash_update(state, b4a.from(HASH_DOMAIN.CONTROL_SNAPSHOT, 'ascii'))
  sodium.crypto_generichash_update(state, writeU64be(BigInt(byteLength)))
  return {
    update (value) { sodium.crypto_generichash_update(state, value) },
    digest () {
      const output = b4a.alloc(32)
      sodium.crypto_generichash_final(state, output)
      return output
    }
  }
}

class BufferedFileReader {
  constructor (handle, byteLength, hashState, readChunkBytes) {
    this.handle = handle
    this.byteLength = byteLength
    this.hashState = hashState
    this.buffer = b4a.alloc(readChunkBytes)
    this.bufferStart = 0
    this.bufferEnd = 0
    this.fileOffset = 0
    this.consumed = 0
  }

  async _fill () {
    if (this.fileOffset >= this.byteLength) {
      throw new BlindControlSnapshotIntegrityError('control snapshot is truncated')
    }
    const wanted = Math.min(this.buffer.byteLength, this.byteLength - this.fileOffset)
    const { bytesRead } = await this.handle.read(this.buffer, 0, wanted, this.fileOffset)
    if (bytesRead <= 0) throw new BlindControlSnapshotIntegrityError('control snapshot read made no progress')
    const chunk = this.buffer.subarray(0, bytesRead)
    this.hashState.update(chunk)
    this.fileOffset += bytesRead
    this.bufferStart = 0
    this.bufferEnd = bytesRead
  }

  async read (length, field) {
    boundedInteger(length, 0, 0x10000, `${field} length`)
    if (this.consumed + length > this.byteLength) {
      throw new BlindControlSnapshotIntegrityError(`control snapshot has truncated ${field}`)
    }
    const output = b4a.alloc(length)
    let offset = 0
    while (offset < length) {
      if (this.bufferStart === this.bufferEnd) await this._fill()
      const available = Math.min(length - offset, this.bufferEnd - this.bufferStart)
      b4a.copy(this.buffer, output, offset, this.bufferStart, this.bufferStart + available)
      this.bufferStart += available
      this.consumed += available
      offset += available
    }
    return output
  }

  async byte (field) {
    return (await this.read(1, field))[0]
  }
}

async function readCompactUint (reader, field) {
  const marker = await reader.byte(`${field} prefix`)
  if (marker <= 0xfc) return marker
  if (marker === 0xfd) {
    const value = readU16le(await reader.read(2, field))
    if (value <= 0xfc) throw new BlindControlSnapshotIntegrityError(`${field} uses a non-canonical compact integer`)
    return value
  }
  if (marker === 0xfe) {
    const value = readU32le(await reader.read(4, field))
    if (value <= 0xffff) throw new BlindControlSnapshotIntegrityError(`${field} uses a non-canonical compact integer`)
    return value
  }
  const value = readU64le(await reader.read(8, field))
  if (value <= 0xffffffffn || value > BigInt(Number.MAX_SAFE_INTEGER)) {
    throw new BlindControlSnapshotIntegrityError(`${field} uses a non-canonical or unsupported compact integer`)
  }
  return Number(value)
}

function compareEntryKey (leftKind, leftKey, rightKind, rightKey) {
  return leftKind - rightKind || b4a.compare(leftKey, rightKey)
}

function expectedTuple (value = {}) {
  const output = {}
  if (value.relayPublicKey != null) output.relayPublicKey = asBytes(value.relayPublicKey, 32, 'expected relayPublicKey', true)
  if (value.storeId != null) output.storeId = asBytes(value.storeId, 32, 'expected storeId', true)
  if (value.durabilityContinuityHash != null) {
    output.durabilityContinuityHash = asBytes(value.durabilityContinuityHash, 32, 'expected durabilityContinuityHash', true)
  }
  if (value.walSequence != null) output.walSequence = asU64(value.walSequence, 'expected walSequence')
  if (value.walHash != null) output.walHash = asBytes(value.walHash, 32, 'expected walHash', true)
  return output
}

function assertTuple (actual, expected, label) {
  for (const field of ['relayPublicKey', 'storeId', 'durabilityContinuityHash', 'walHash']) {
    if (expected[field] != null && !sameBytes(actual[field], expected[field])) {
      throw new BlindControlSnapshotIntegrityError(`${label} ${field} does not match`)
    }
  }
  if (expected.walSequence != null && actual.walSequence !== expected.walSequence) {
    throw new BlindControlSnapshotIntegrityError(`${label} walSequence does not match`)
  }
}

function immutableHeader (header) {
  return Object.freeze({
    relayPublicKey: b4a.from(header.relayPublicKey),
    storeId: b4a.from(header.storeId),
    durabilityContinuityHash: b4a.from(header.durabilityContinuityHash),
    walSequence: header.walSequence,
    walHash: b4a.from(header.walHash)
  })
}

// The semantic verifier must consume the supplied async iterable completely and
// reconstruct the storage-engine control state. Its returned tuple is checked
// against the canonical file header so a tuple-only parser cannot authorize a
// checkpoint by itself.
export async function verifyBlindControlStateSnapshotFile (options = {}) {
  const filePath = canonicalAbsolutePath(options.filePath, 'control snapshot path')
  const maximumSnapshotBytes = boundedInteger(options.maximumSnapshotBytes, FIXED_HEADER_BYTES + 1, 0x7fffffff, 'maximumSnapshotBytes')
  const maximumEntries = boundedInteger(
    options.maximumEntries == null ? MAX_SCHEMA_ENTRIES : options.maximumEntries,
    0,
    MAX_SCHEMA_ENTRIES,
    'maximumEntries'
  )
  const readChunkBytes = boundedInteger(
    options.readChunkBytes == null ? DEFAULT_READ_CHUNK_BYTES : options.readChunkBytes,
    1024,
    1024 * 1024,
    'readChunkBytes'
  )
  const expectedByteLength = options.expectedByteLength == null
    ? null
    : asU64(options.expectedByteLength, 'expectedByteLength')
  const expectedHash = options.expectedHash == null
    ? null
    : asBytes(options.expectedHash, 32, 'expectedHash', true)
  const expected = expectedTuple(options.expected)
  if (typeof options.semanticVerifier !== 'function') {
    throw new TypeError('semanticVerifier must be a function that consumes the snapshot entry stream')
  }
  if (!FS_CONSTANTS.O_NOFOLLOW) {
    throw new BlindControlSnapshotIntegrityError('O_NOFOLLOW is required for snapshot validation')
  }

  const linkedBefore = await fs.lstat(filePath)
  assertPrivateFile(linkedBefore, 'control snapshot')
  let handle
  try {
    handle = await fs.open(filePath, FS_CONSTANTS.O_RDONLY | FS_CONSTANTS.O_NOFOLLOW)
    const openedBefore = await handle.stat()
    assertPrivateFile(openedBefore, 'opened control snapshot')
    if (openedBefore.dev !== linkedBefore.dev || openedBefore.ino !== linkedBefore.ino) {
      throw new BlindControlSnapshotIntegrityError('control snapshot path and opened inode disagree')
    }
    if (!Number.isSafeInteger(openedBefore.size) || openedBefore.size < FIXED_HEADER_BYTES + 1 || openedBefore.size > maximumSnapshotBytes) {
      throw new BlindControlSnapshotIntegrityError('control snapshot byte length is outside its configured bound')
    }
    if (expectedByteLength != null && BigInt(openedBefore.size) !== expectedByteLength) {
      throw new BlindControlSnapshotIntegrityError('control snapshot byte length does not match its checkpoint')
    }

    const hashState = createHashState(openedBefore.size)
    const reader = new BufferedFileReader(handle, openedBefore.size, hashState, readChunkBytes)
    const version = await reader.byte('version')
    if (version !== 1) throw new BlindControlSnapshotIntegrityError('control snapshot version is not 1')
    const header = immutableHeader({
      relayPublicKey: await reader.read(32, 'relayPublicKey'),
      storeId: await reader.read(32, 'storeId'),
      durabilityContinuityHash: await reader.read(32, 'durabilityContinuityHash'),
      walSequence: readU64be(await reader.read(8, 'walSequence')),
      walHash: await reader.read(32, 'walHash')
    })
    for (const field of ['relayPublicKey', 'storeId', 'durabilityContinuityHash', 'walHash']) {
      if (isZero(header[field])) throw new BlindControlSnapshotIntegrityError(`control snapshot ${field} must be nonzero`)
    }
    if (header.walSequence === 0n) throw new BlindControlSnapshotIntegrityError('local control snapshot WAL sequence must be nonzero')
    assertTuple(header, expected, 'control snapshot')

    const declaredEntryCount = await readCompactUint(reader, 'entry count')
    if (declaredEntryCount > maximumEntries) {
      throw new BlindControlSnapshotIntegrityError('control snapshot entry count exceeds its configured bound')
    }
    const remainingBytes = openedBefore.size - reader.consumed
    if (declaredEntryCount > Math.floor(remainingBytes / MINIMUM_ENTRY_BYTES)) {
      throw new BlindControlSnapshotIntegrityError('control snapshot entry count cannot fit in the declared file length')
    }

    let previousKind = 0
    let previousKey = null
    let parsedEntryCount = 0
    let completed = false
    const entries = (async function * () {
      for (let index = 0; index < declaredEntryCount; index++) {
        const entryKind = await reader.byte('entry kind')
        if (entryKind < 1 || entryKind > 8) throw new BlindControlSnapshotIntegrityError('control snapshot entry kind is outside 1..8')
        const keyLength = await readCompactUint(reader, 'entry key length')
        if (keyLength < 1 || keyLength > 256) throw new BlindControlSnapshotIntegrityError('control snapshot entry key length is outside 1..256')
        const key = await reader.read(keyLength, 'entry key')
        const valueLength = await readCompactUint(reader, 'entry value length')
        if (valueLength > 0xffff) throw new BlindControlSnapshotIntegrityError('control snapshot entry value length exceeds 65535')
        const value = await reader.read(valueLength, 'entry value')
        if (previousKey != null && compareEntryKey(previousKind, previousKey, entryKind, key) >= 0) {
          throw new BlindControlSnapshotIntegrityError('control snapshot entries are not strictly sorted and duplicate-free')
        }
        previousKind = entryKind
        previousKey = b4a.from(key)
        parsedEntryCount++
        yield Object.freeze({ index, entryKind, key, value })
      }
      if (reader.consumed !== openedBefore.size) {
        throw new BlindControlSnapshotIntegrityError('control snapshot contains trailing bytes')
      }
      completed = true
    })()

    const verifierHeader = immutableHeader(header)
    const echo = await options.semanticVerifier(Object.freeze({
      header: verifierHeader,
      declaredEntryCount,
      entries
    }))
    if (!completed || parsedEntryCount !== declaredEntryCount) {
      if (typeof entries.return === 'function') await entries.return().catch(() => {})
      throw new BlindControlSnapshotIntegrityError('semantic verifier did not consume the complete snapshot entry stream')
    }
    if (!echo || typeof echo !== 'object') {
      throw new BlindControlSnapshotIntegrityError('semantic verifier did not return a reconstruction echo')
    }
    assertTuple(echo, header, 'semantic reconstruction')
    if (echo.entryCount !== declaredEntryCount) {
      throw new BlindControlSnapshotIntegrityError('semantic reconstruction entry count does not match')
    }

    const snapshotHash = hashState.digest()
    if (expectedHash != null && !sameBytes(snapshotHash, expectedHash)) {
      throw new BlindControlSnapshotIntegrityError('control snapshot hash does not match its checkpoint')
    }
    const [openedAfter, linkedAfter] = await Promise.all([handle.stat(), fs.lstat(filePath)])
    assertPrivateFile(openedAfter, 'opened control snapshot')
    assertPrivateFile(linkedAfter, 'control snapshot')
    if (!sameFileState(openedBefore, openedAfter) || !sameFileState(openedAfter, linkedAfter)) {
      throw new BlindControlSnapshotIntegrityError('control snapshot changed while it was verified')
    }
    return Object.freeze({
      filePath,
      byteLength: BigInt(openedBefore.size),
      snapshotHash,
      header: immutableHeader(header),
      entryCount: declaredEntryCount,
      semanticEcho: echo
    })
  } finally {
    if (handle) await handle.close().catch(() => {})
  }
}

export const BLIND_CONTROL_SNAPSHOT_STREAM_LIMITS = Object.freeze({
  fixedHeaderBytes: FIXED_HEADER_BYTES,
  minimumEntryBytes: MINIMUM_ENTRY_BYTES,
  maximumSchemaEntries: MAX_SCHEMA_ENTRIES,
  defaultReadChunkBytes: DEFAULT_READ_CHUNK_BYTES
})