OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / blind-daemon / manifest-store.js

598 lines · javascript ref:
import fs from 'node:fs/promises'
import { constants as FS_CONSTANTS } from 'node:fs'
import path from 'node:path'
import { randomBytes } from 'node:crypto'
import b4a from 'b4a'
import sodium from 'sodium-universal'
import {
  blindStoreManifestV1,
  decodeCanonical,
  encodeCanonical
} from '@hiverelay/blind-protocol'

const SLOT_NAMES = Object.freeze({ a: 'manifest-a.v1', b: 'manifest-b.v1' })
const MAX_MANIFEST_BYTES = 4096
const MAX_TEMP_FILES = 16
const ZERO32 = b4a.alloc(32)
const MAC_DOMAIN = b4a.from('hiverelay.blind.store-manifest-mac.v1', 'ascii')
const HASH_DOMAIN = b4a.from('hiverelay.blind.store-manifest-hash.v1', 'ascii')
const MANIFEST_SNAPSHOT_STATE = new WeakMap()
const MANIFEST_STORE_STATE = new WeakMap()
const ACTIVE_MANIFEST_DIRECTORIES = new Set()
const REQUIRED_BINDINGS = Object.freeze([
  'storeId',
  'relayPublicKey',
  'durabilityProfileId',
  'durabilityContinuityHash',
  'durabilityProfileHash',
  'formatMajor',
  'formatMinor',
  'storeFormatHash',
  'specHash',
  'abiHash',
  'mapGeneration',
  'bucketMapHash',
  'writerFenceTokenHash'
])

export class BlindManifestIntegrityError extends Error {
  constructor (message) {
    super(message)
    this.name = 'BlindManifestIntegrityError'
    this.code = 'RECOVERY_GAP_READ_ONLY'
  }
}

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 && b4a.equals(value, ZERO32)) throw new TypeError(`${field} must be nonzero`)
  return value
}

function u64bytes (value) {
  if (typeof value === 'number') {
    if (!Number.isSafeInteger(value) || value < 0) throw new TypeError('u64 value is invalid')
    value = BigInt(value)
  }
  if (typeof value !== 'bigint' || value < 0n || value > ((1n << 64n) - 1n)) {
    throw new TypeError('u64 value is invalid')
  }
  const output = b4a.alloc(8)
  for (let index = 7; index >= 0; index--) {
    output[index] = Number(value & 0xffn)
    value >>= 8n
  }
  return output
}

function hashParts (domain, bytes, key = null) {
  const input = b4a.concat([domain, u64bytes(bytes.byteLength), bytes])
  const output = b4a.alloc(32)
  if (key == null) sodium.crypto_generichash(output, input)
  else sodium.crypto_generichash(output, input, key)
  return output
}

function manifestWithoutMac (value) {
  const placeholder = b4a.alloc(32, 1)
  const complete = encodeCanonical(blindStoreManifestV1, { ...value, mac: placeholder })
  return complete.subarray(0, complete.byteLength - 32)
}

export function sealBlindStoreManifest (value, manifestKey) {
  manifestKey = asBytes(manifestKey, 32, 'manifestKey', true)
  const unsigned = manifestWithoutMac(value)
  const mac = hashParts(MAC_DOMAIN, unsigned, manifestKey)
  return encodeCanonical(blindStoreManifestV1, { ...value, mac })
}

export function blindStoreManifestHash (canonicalBytes) {
  canonicalBytes = asBytes(canonicalBytes, null, 'canonical manifest bytes')
  return hashParts(HASH_DOMAIN, canonicalBytes)
}

function verifyManifest (canonicalBytes, manifestKey) {
  canonicalBytes = asBytes(canonicalBytes, null, 'canonical manifest bytes')
  if (canonicalBytes.byteLength < 64 || canonicalBytes.byteLength > MAX_MANIFEST_BYTES) {
    throw new BlindManifestIntegrityError('manifest size is outside its closed bound')
  }
  let value
  try {
    value = decodeCanonical(blindStoreManifestV1, canonicalBytes, { copyBytes: true })
    if (!b4a.equals(encodeCanonical(blindStoreManifestV1, value), canonicalBytes)) {
      throw new Error('non-canonical')
    }
  } catch (error) {
    throw new BlindManifestIntegrityError(`manifest is not canonical: ${error.message}`)
  }
  const expectedMac = hashParts(MAC_DOMAIN, manifestWithoutMac(value), manifestKey)
  if (!b4a.equals(expectedMac, value.mac)) {
    throw new BlindManifestIntegrityError('manifest MAC verification failed')
  }
  return {
    bytes: b4a.from(canonicalBytes),
    hash: blindStoreManifestHash(canonicalBytes),
    value
  }
}

function sameInode (left, right) {
  return left.dev === right.dev && left.ino === right.ino
}

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

async function syncDirectory (directory) {
  const handle = await fs.open(directory, FS_CONSTANTS.O_RDONLY)
  try {
    await handle.sync()
  } finally {
    await handle.close()
  }
}

async function readAll (handle, size) {
  const output = b4a.alloc(size)
  let offset = 0
  while (offset < size) {
    const { bytesRead } = await handle.read(output, offset, size - offset, offset)
    if (bytesRead === 0) throw new BlindManifestIntegrityError('manifest file ended before its declared size')
    offset += bytesRead
  }
  return output
}

function bindingCopy (value, field) {
  if (value && typeof value.byteLength === 'number') return b4a.from(asBytes(value, null, field))
  if (typeof value === 'bigint' || typeof value === 'number') return value
  throw new TypeError(`expectedBindings.${field} has an unsupported type`)
}

function sameBinding (left, right) {
  if (left && typeof left.byteLength === 'number') {
    return right && typeof right.byteLength === 'number' && b4a.equals(left, right)
  }
  return left === right
}

function manifestPrivate (store) {
  const state = MANIFEST_STORE_STATE.get(store)
  if (!state) throw new BlindManifestIntegrityError('manifest store identity is invalid')
  return state
}

function publicSnapshot (store, record) {
  const privateState = manifestPrivate(store)
  const value = decodeCanonical(blindStoreManifestV1, record.bytes, { copyBytes: true })
  const snapshot = Object.freeze({
    slot: record.slot,
    needsRepair: record.needsRepair,
    revision: value.manifestRevision,
    bytes: b4a.from(record.bytes),
    hash: b4a.from(record.hash),
    manifest: Object.freeze(value)
  })
  MANIFEST_SNAPSHOT_STATE.set(snapshot, Object.freeze({
    store,
    generation: privateState.generation,
    controlDirectory: store.controlDirectory,
    bytes: b4a.from(record.bytes),
    hash: b4a.from(record.hash)
  }))
  return snapshot
}

export function verifyBlindManifestSnapshot (snapshot, expectedControlDirectory) {
  if (!snapshot || typeof snapshot !== 'object') {
    throw new BlindManifestIntegrityError('manifest snapshot is missing or forged')
  }
  const state = MANIFEST_SNAPSHOT_STATE.get(snapshot)
  const storeState = state && MANIFEST_STORE_STATE.get(state.store)
  if (!state || state.controlDirectory !== expectedControlDirectory ||
      !storeState || !storeState.opened || storeState.closing ||
      state.store.controlDirectory !== expectedControlDirectory ||
      storeState.generation !== state.generation || storeState.currentSelectedHash == null ||
      !b4a.equals(storeState.currentSelectedHash, state.hash) ||
      !b4a.equals(state.bytes, snapshot.bytes) || !b4a.equals(state.hash, snapshot.hash)) {
    throw new BlindManifestIntegrityError('manifest snapshot is forged, stale-lifetime, or belongs to another store')
  }
  return true
}

export function advanceBlindManifestSnapshot (snapshot, expectedControlDirectory, expectedHash, updates) {
  verifyBlindManifestSnapshot(snapshot, expectedControlDirectory)
  const state = MANIFEST_SNAPSHOT_STATE.get(snapshot)
  expectedHash = asBytes(expectedHash, 32, 'expected manifest hash')
  if (!b4a.equals(state.hash, expectedHash)) {
    throw new BlindManifestIntegrityError('branded manifest CAS expected hash differs from its snapshot')
  }
  return state.store.advance(expectedHash, updates)
}

export class TwoSlotManifestStore {
  constructor (options = {}) {
    if (typeof options.controlDirectory !== 'string' || !path.isAbsolute(options.controlDirectory) ||
        path.normalize(options.controlDirectory) !== options.controlDirectory || options.controlDirectory.includes('\0')) {
      throw new TypeError('controlDirectory must be a canonical absolute path')
    }
    if (!options.expectedBindings || typeof options.expectedBindings !== 'object' ||
        Array.isArray(options.expectedBindings) || Object.keys(options.expectedBindings).length === 0) {
      throw new TypeError('expectedBindings must be a non-empty object')
    }
    for (const field of REQUIRED_BINDINGS) {
      if (!Object.prototype.hasOwnProperty.call(options.expectedBindings, field)) {
        throw new TypeError(`expectedBindings.${field} is required`)
      }
    }
    this.controlDirectory = options.controlDirectory
    this.manifestKey = b4a.from(asBytes(options.manifestKey, 32, 'manifestKey', true))
    this.expectedBindings = Object.freeze(Object.fromEntries(Object.entries(options.expectedBindings)
      .map(([field, value]) => [field, bindingCopy(value, field)])))
    this.faultInjector = options.faultInjector == null ? null : options.faultInjector
    if (this.faultInjector != null && typeof this.faultInjector !== 'function') {
      throw new TypeError('faultInjector must be a function')
    }
    this.opened = false
    this.validationOnly = false
    this.closing = false
    this.closePromise = null
    this.serial = Promise.resolve()
    this.generation = 0
    this.currentSelectedHash = null
    MANIFEST_STORE_STATE.set(this, {
      generation: 0,
      currentSelectedHash: null,
      opened: false,
      closing: false
    })
  }

  async open (options = {}) {
    if (this.opened) throw new Error('manifest store is already open')
    if (ACTIVE_MANIFEST_DIRECTORIES.has(this.controlDirectory)) {
      throw new BlindManifestIntegrityError('manifest control directory already has an active in-process owner')
    }
    if (!options || typeof options !== 'object' || Array.isArray(options) ||
        Object.keys(options).some(key => key !== 'validationOnly') ||
        (options.validationOnly != null && typeof options.validationOnly !== 'boolean')) {
      throw new TypeError('manifest open options may contain only validationOnly')
    }
    const validationOnly = options.validationOnly === true
    ACTIVE_MANIFEST_DIRECTORIES.add(this.controlDirectory)
    let opened = false
    try {
      const stat = await fs.lstat(this.controlDirectory)
      if (!stat.isDirectory() || stat.isSymbolicLink()) {
        throw new BlindManifestIntegrityError('manifest control root is not a directory')
      }
      if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
        throw new BlindManifestIntegrityError('manifest control root is not owned by the daemon UID')
      }
      if ((stat.mode & 0o700) !== 0o700 || (stat.mode & 0o077) !== 0) {
        throw new BlindManifestIntegrityError('manifest control root permissions are not private')
      }
      if (await fs.realpath(this.controlDirectory) !== this.controlDirectory) {
        throw new BlindManifestIntegrityError('manifest control root is not its canonical realpath')
      }
      const temporaryFiles = await this._inspectTemps()
      if (!validationOnly) await this._cleanupTemps(temporaryFiles)
      this.generation++
      this.currentSelectedHash = null
      this.opened = true
      this.validationOnly = validationOnly
      this.closing = false
      this.closePromise = null
      const privateState = manifestPrivate(this)
      privateState.generation++
      privateState.currentSelectedHash = null
      privateState.opened = true
      privateState.closing = false
      opened = true
      return this
    } finally {
      if (!opened) ACTIVE_MANIFEST_DIRECTORIES.delete(this.controlDirectory)
    }
  }

  _serialized (operation) {
    const result = this.serial.then(operation)
    this.serial = result.catch(() => {})
    return result
  }

  _assertOpen () {
    if (!this.opened) throw new Error('manifest store is not open')
  }

  _assertAccepting () {
    this._assertOpen()
    if (this.closing) throw new Error('manifest store is closing')
  }

  _assertMutable () {
    this._assertAccepting()
    if (this.validationOnly) {
      const error = new Error('manifest store was opened in validation-only mode')
      error.code = 'BLIND_MANIFEST_VALIDATION_ONLY'
      throw error
    }
  }

  _assertBindings (manifest) {
    for (const [field, expected] of Object.entries(this.expectedBindings)) {
      if (!(field in manifest) || !sameBinding(expected, manifest[field])) {
        throw new BlindManifestIntegrityError(`manifest ${field} does not match its launch binding`)
      }
    }
  }

  async _fault (point, context) {
    if (this.faultInjector) await this.faultInjector(point, context)
  }

  async _inspectTemps () {
    const names = (await fs.readdir(this.controlDirectory))
      .filter(name => /^\.manifest-[ab]\.v1\.[0-9a-f]{32}\.tmp$/.test(name))
    if (names.length > MAX_TEMP_FILES) throw new BlindManifestIntegrityError('manifest temporary-file bound exceeded')
    for (const name of names) {
      const target = path.join(this.controlDirectory, name)
      const stat = await fs.lstat(target)
      assertPrivateFile(stat, 'manifest temporary file')
    }
    return names
  }

  async _cleanupTemps (names) {
    for (const name of names) {
      const target = path.join(this.controlDirectory, name)
      await fs.unlink(target)
    }
    if (names.length > 0) await syncDirectory(this.controlDirectory)
  }

  async _readSlot (slot) {
    const target = path.join(this.controlDirectory, SLOT_NAMES[slot])
    let pathStat
    try {
      pathStat = await fs.lstat(target)
    } catch (error) {
      if (error && error.code === 'ENOENT') return { slot, exists: false, valid: false, error: null }
      return { slot, exists: true, valid: false, error }
    }
    let handle
    try {
      if (pathStat.isSymbolicLink()) throw new BlindManifestIntegrityError(`manifest slot ${slot} is a symlink`)
      assertPrivateFile(pathStat, `manifest slot ${slot}`)
      handle = await fs.open(target, FS_CONSTANTS.O_RDONLY | FS_CONSTANTS.O_NOFOLLOW)
      const opened = await handle.stat()
      if (!sameInode(pathStat, opened)) throw new BlindManifestIntegrityError(`manifest slot ${slot} changed inode`)
      assertPrivateFile(opened, `manifest slot ${slot}`)
      if (opened.size < 64 || opened.size > MAX_MANIFEST_BYTES) {
        throw new BlindManifestIntegrityError(`manifest slot ${slot} size is invalid`)
      }
      const verified = verifyManifest(await readAll(handle, opened.size), this.manifestKey)
      this._assertBindings(verified.value)
      return { slot, exists: true, valid: true, error: null, ...verified }
    } catch (error) {
      return { slot, exists: true, valid: false, error }
    } finally {
      if (handle) await handle.close().catch(() => {})
    }
  }

  async _states () {
    return Promise.all([this._readSlot('a'), this._readSlot('b')])
  }

  _select (states, allowAbsent = false) {
    const valid = states.filter(state => state.valid)
    if (valid.length === 0) {
      if (allowAbsent && states.every(state => !state.exists)) return null
      const details = states.map(state => state.error && state.error.message).filter(Boolean).join('; ')
      throw new BlindManifestIntegrityError(`no valid manifest slot${details ? `: ${details}` : ''}`)
    }
    if (valid.length === 1) return { ...valid[0], needsRepair: true }
    const [left, right] = valid
    const leftRevision = left.value.manifestRevision
    const rightRevision = right.value.manifestRevision
    if (leftRevision === rightRevision) {
      if (!b4a.equals(left.hash, right.hash) || !b4a.equals(left.bytes, right.bytes)) {
        throw new BlindManifestIntegrityError('equal-revision manifest fork detected')
      }
      return { ...left, slot: 'both', needsRepair: false }
    }
    const high = leftRevision > rightRevision ? left : right
    const low = high === left ? right : left
    if (high.value.manifestRevision !== low.value.manifestRevision + 1n ||
        high.value.previousManifestHash == null ||
        !b4a.equals(high.value.previousManifestHash, low.hash)) {
      throw new BlindManifestIntegrityError('manifest slots do not form one adjacent predecessor chain')
    }
    return { ...high, needsRepair: true }
  }

  async _loadInternal (allowAbsent = false) {
    return this._select(await this._states(), allowAbsent)
  }

  load () {
    this._assertAccepting()
    return this._serialized(async () => {
      this._assertOpen()
      const snapshot = publicSnapshot(this, await this._loadInternal())
      this.currentSelectedHash = b4a.from(snapshot.hash)
      manifestPrivate(this).currentSelectedHash = b4a.from(snapshot.hash)
      return snapshot
    })
  }

  initialize (value) {
    this._assertMutable()
    return this._serialized(async () => {
      this._assertOpen()
      const states = await this._states()
      if (BigInt(value.manifestRevision) !== 0n || value.previousManifestHash != null) {
        throw new TypeError('manifest genesis must be revision zero without a predecessor')
      }
      this._assertBindings(value)
      const bytes = sealBlindStoreManifest(value, this.manifestKey)
      for (const state of states) {
        if (!state.exists) continue
        if (!state.valid || !b4a.equals(state.bytes, bytes)) {
          throw new BlindManifestIntegrityError(
            'manifest initialization refuses any existing slot unless it is the exact authenticated genesis'
          )
        }
      }
      await this._install('a', bytes)
      await this._fault('manifest:after-first-install', { slot: 'a', revision: 0n })
      await this._install('b', bytes)
      await this._fault('manifest:after-second-install', { slot: 'b', revision: 0n })
      const snapshot = publicSnapshot(this, await this._loadInternal())
      this.currentSelectedHash = b4a.from(snapshot.hash)
      manifestPrivate(this).currentSelectedHash = b4a.from(snapshot.hash)
      return snapshot
    })
  }

  advance (expectedHash, updates = {}) {
    this._assertMutable()
    return this._serialized(async () => {
      this._assertOpen()
      expectedHash = asBytes(expectedHash, 32, 'expected manifest hash')
      for (const forbidden of ['manifestRevision', 'previousManifestHash', 'mac']) {
        if (Object.prototype.hasOwnProperty.call(updates, forbidden)) {
          throw new TypeError(`manifest advance owns ${forbidden}`)
        }
      }
      const current = await this._loadInternal()
      if (!b4a.equals(current.hash, expectedHash)) {
        throw new BlindManifestIntegrityError('manifest advance expected hash is stale')
      }
      const next = {
        ...current.value,
        ...updates,
        previousManifestHash: b4a.from(current.hash),
        manifestRevision: current.value.manifestRevision + 1n
      }
      this._assertBindings(next)
      const bytes = sealBlindStoreManifest(next, this.manifestKey)
      const first = current.slot === 'a' ? 'b' : 'a'
      const second = first === 'a' ? 'b' : 'a'
      await this._install(first, bytes)
      await this._fault('manifest:after-first-install', { slot: first, revision: next.manifestRevision })
      await this._install(second, bytes)
      await this._fault('manifest:after-second-install', { slot: second, revision: next.manifestRevision })
      const snapshot = publicSnapshot(this, await this._loadInternal())
      this.currentSelectedHash = b4a.from(snapshot.hash)
      manifestPrivate(this).currentSelectedHash = b4a.from(snapshot.hash)
      return snapshot
    })
  }

  repair (expectedHash) {
    this._assertMutable()
    return this._serialized(async () => {
      this._assertOpen()
      expectedHash = asBytes(expectedHash, 32, 'expected manifest hash')
      const current = await this._loadInternal()
      if (!b4a.equals(current.hash, expectedHash)) {
        throw new BlindManifestIntegrityError('manifest repair expected hash is stale')
      }
      await this._install('a', current.bytes)
      await this._install('b', current.bytes)
      const snapshot = publicSnapshot(this, await this._loadInternal())
      this.currentSelectedHash = b4a.from(snapshot.hash)
      manifestPrivate(this).currentSelectedHash = b4a.from(snapshot.hash)
      return snapshot
    })
  }

  async _install (slot, bytes) {
    const target = path.join(this.controlDirectory, SLOT_NAMES[slot])
    const temporary = path.join(this.controlDirectory,
      `.${SLOT_NAMES[slot]}.${randomBytes(16).toString('hex')}.tmp`)
    let handle
    try {
      handle = await fs.open(temporary,
        FS_CONSTANTS.O_WRONLY | FS_CONSTANTS.O_CREAT | FS_CONSTANTS.O_EXCL | FS_CONSTANTS.O_NOFOLLOW,
        0o600)
      let offset = 0
      while (offset < bytes.byteLength) {
        const { bytesWritten } = await handle.write(bytes, offset, bytes.byteLength - offset, offset)
        if (bytesWritten === 0) throw new BlindManifestIntegrityError('manifest temporary write made no progress')
        offset += bytesWritten
      }
      await this._fault('manifest:after-temp-write', { slot })
      await handle.sync()
      await this._fault('manifest:after-temp-sync', { slot })
      await handle.close()
      handle = null
      await fs.rename(temporary, target)
      await this._fault('manifest:after-rename', { slot })
      await syncDirectory(this.controlDirectory)
      await this._fault('manifest:after-directory-sync', { slot })
      const installed = await this._readSlot(slot)
      if (!installed.valid || !b4a.equals(installed.bytes, bytes)) {
        const reason = installed.error && installed.error.message
        throw new BlindManifestIntegrityError(`manifest slot ${slot} failed post-install verification${reason ? `: ${reason}` : ''}`)
      }
    } finally {
      if (handle) await handle.close().catch(() => {})
      await fs.unlink(temporary).catch(error => {
        if (!error || error.code !== 'ENOENT') throw error
      })
    }
  }

  close () {
    if (this.closePromise) return this.closePromise
    if (!this.opened) {
      this.validationOnly = false
      this.currentSelectedHash = null
      const privateState = manifestPrivate(this)
      privateState.currentSelectedHash = null
      privateState.opened = false
      privateState.closing = false
      ACTIVE_MANIFEST_DIRECTORIES.delete(this.controlDirectory)
      this.manifestKey.fill(0)
      return Promise.resolve()
    }
    this.closing = true
    manifestPrivate(this).closing = true
    this.closePromise = this._serialized(async () => {
      this.opened = false
      this.validationOnly = false
      this.currentSelectedHash = null
      const privateState = manifestPrivate(this)
      privateState.currentSelectedHash = null
      privateState.opened = false
      privateState.closing = false
      ACTIVE_MANIFEST_DIRECTORIES.delete(this.controlDirectory)
      this.manifestKey.fill(0)
    })
    return this.closePromise
  }
}

export const BLIND_MANIFEST_STORE_LIMITS = Object.freeze({
  maxManifestBytes: MAX_MANIFEST_BYTES,
  maxTemporaryFiles: MAX_TEMP_FILES,
  requiredBindings: REQUIRED_BINDINGS,
  slotNames: SLOT_NAMES
})