OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / blind-client / browser-artifact.js

456 lines · javascript ref:
import b4a from 'b4a'
import sodium from './crypto.js'

const MAGIC = b4a.from('HIVERELAY-BLIND-CLIENT-BROWSER-V1', 'ascii')
const ARTIFACT_HASH_DOMAIN = b4a.from('hiverelay.blind.client-browser-artifact-hash.v1', 'ascii')
const MANIFEST_HASH_DOMAIN = b4a.from('hiverelay.blind.client-browser-artifact-manifest-hash.v1', 'ascii')
const CLOSURE_HASH_DOMAIN = b4a.from('hiverelay.blind.client-browser-source-closure-hash.v1', 'ascii')
const MAX_TEXT_BYTES = 1024
const MAX_ARTIFACT_BYTES = 320 * 1024
const MAX_ARTIFACT_GZIP_BYTES = 90 * 1024

export const BLIND_CLIENT_BROWSER_ARTIFACT_STATUS = Object.freeze({
  artifactPath: 'browser-artifacts/blind-client-control-v1.mjs',
  manifestPath: 'browser-artifacts/blind-client-control-v1.manifest.cenc',
  chromiumEvidencePath: 'browser-artifacts/blind-client-control-v1.chromium-evidence.json',
  crossHostEvidencePath: 'browser-artifacts/blind-client-control-v1.cross-host-evidence.json',
  tupleBound: true,
  sourceClosureBound: true,
  deterministicGeneratorReady: true,
  sameHostByteEqualityProven: true,
  crossHostByteEqualityProven: false,
  realBrowserImportProven: false,
  packageSubpathsExported: true,
  maxArtifactBytes: MAX_ARTIFACT_BYTES,
  maxArtifactGzipBytes: MAX_ARTIFACT_GZIP_BYTES,
  finalTupleBound: true,
  clientCompositionTupleBound: true,
  releaseReady: false,
  releaseBlockers: Object.freeze([
    'REAL_CHROMIUM_HASH_BOUND_EVIDENCE_NOT_VERIFIED',
    'CROSS_HOST_HASH_BOUND_EVIDENCE_NOT_VERIFIED'
  ])
})

const CHROMIUM_EVIDENCE_CHECKS = Object.freeze([
  'STANDALONE_ESM_IMPORT',
  'REQUIRED_CONTROL_EXPORTS',
  'CLOSED_EXTERNAL_PROFILE_DECODER',
  'WEBCRYPTO_AES_256_GCM_ROUNDTRIP',
  'SIGNED_CAPABILITY_CELL_COMPOSITION',
  'PLAINTEXT_SENTINEL_ABSENT_FROM_REQUEST'
])
const CROSS_HOST_EVIDENCE_CHECKS = Object.freeze([
  'CLEAN_LINUX_DEPENDENCY_INSTALL',
  'FROZEN_GENERATOR_CHECK',
  'ARTIFACT_BYTE_EQUALITY',
  'MANIFEST_BYTE_EQUALITY'
])

function fail (code, message) {
  const error = new Error(message)
  error.code = code
  throw error
}

function bytes (value, field) {
  if (b4a.isBuffer(value)) return value
  if (ArrayBuffer.isView(value)) return b4a.from(value.buffer, value.byteOffset, value.byteLength)
  if (value instanceof ArrayBuffer) return b4a.from(value)
  fail('BAD_BLIND_CLIENT_BROWSER_ARTIFACT', `${field} must be bytes`)
}

function fixed (value, length, field) {
  value = bytes(value, field)
  if (value.byteLength !== length) {
    fail('BAD_BLIND_CLIENT_BROWSER_ARTIFACT', `${field} must be exactly ${length} bytes`)
  }
  return b4a.from(value)
}

function exactObject (value, fields, field) {
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
    fail('BAD_BLIND_CLIENT_BROWSER_ARTIFACT', `${field} must be an object`)
  }
  const prototype = Object.getPrototypeOf(value)
  if (prototype !== Object.prototype && prototype !== null) {
    fail('BAD_BLIND_CLIENT_BROWSER_ARTIFACT', `${field} has an unexpected prototype`)
  }
  const keys = Reflect.ownKeys(value)
  if (keys.length !== fields.length || keys.some(key => typeof key !== 'string') ||
      fields.some(name => !Object.prototype.hasOwnProperty.call(value, name))) {
    fail('BAD_BLIND_CLIENT_BROWSER_ARTIFACT', `${field} fields are missing or unexpected`)
  }
  const descriptors = Object.getOwnPropertyDescriptors(value)
  if (fields.some(name => !descriptors[name].enumerable ||
      !Object.prototype.hasOwnProperty.call(descriptors[name], 'value'))) {
    fail('BAD_BLIND_CLIENT_BROWSER_ARTIFACT', `${field} fields must be enumerable data properties`)
  }
  return descriptors
}

function u16 (value, field) {
  if (!Number.isSafeInteger(value) || value < 0 || value > 0xffff) {
    fail('BAD_BLIND_CLIENT_BROWSER_ARTIFACT', `${field} is outside u16`)
  }
  return b4a.from([(value >>> 8) & 0xff, value & 0xff])
}

function u64 (value, field) {
  if (typeof value === 'number') {
    if (!Number.isSafeInteger(value) || value < 0) {
      fail('BAD_BLIND_CLIENT_BROWSER_ARTIFACT', `${field} must be an unsigned safe integer or bigint`)
    }
    value = BigInt(value)
  }
  if (typeof value !== 'bigint' || value < 0n || value > ((1n << 64n) - 1n)) {
    fail('BAD_BLIND_CLIENT_BROWSER_ARTIFACT', `${field} is outside u64`)
  }
  const output = b4a.alloc(8)
  for (let index = 7; index >= 0; index--) {
    output[index] = Number(value & 0xffn)
    value >>= 8n
  }
  return output
}

function textBytes (value, field) {
  if (typeof value !== 'string' || value.length === 0 || value !== value.normalize('NFC')) {
    fail('BAD_BLIND_CLIENT_BROWSER_ARTIFACT', `${field} must be non-empty NFC text`)
  }
  const output = b4a.from(value, 'utf8')
  if (output.byteLength > MAX_TEXT_BYTES || b4a.toString(output, 'utf8') !== value) {
    fail('BAD_BLIND_CLIENT_BROWSER_ARTIFACT', `${field} is not bounded canonical UTF-8`)
  }
  return output
}

function hash (domain, value) {
  value = bytes(value, 'hash input')
  const output = b4a.alloc(32)
  sodium.crypto_generichash(output, b4a.concat([domain, u64(value.byteLength, 'hash length'), value]))
  return output
}

export function hashBlindClientBrowserArtifact (value) {
  return hash(ARTIFACT_HASH_DOMAIN, value)
}

export function hashBlindClientBrowserSourceClosure (value) {
  return hash(CLOSURE_HASH_DOMAIN, value)
}

function exactManifestInput (value) {
  const fields = [
    'version', 'draft', 'specHash', 'abiHash', 'vectorSetHash',
    'clientCompositionFormatHash', 'clientCompositionVectorSetHash',
    'toolchain', 'buildProfile', 'sourceClosureHash', 'artifactPath',
    'artifactLength', 'artifactHash'
  ]
  exactObject(value, fields, 'browser artifact manifest')
  if (value.version !== 1 || value.draft !== false) {
    fail('BAD_BLIND_CLIENT_BROWSER_ARTIFACT', 'browser artifact authority requires final version 1')
  }
  let artifactLength
  if (typeof value.artifactLength === 'bigint') artifactLength = value.artifactLength
  else if (Number.isSafeInteger(value.artifactLength)) artifactLength = BigInt(value.artifactLength)
  else fail('BAD_BLIND_CLIENT_BROWSER_ARTIFACT', 'browser artifact length is not an exact integer')
  if (artifactLength < 1n || artifactLength > BigInt(MAX_ARTIFACT_BYTES)) {
    fail('BAD_BLIND_CLIENT_BROWSER_ARTIFACT', 'browser artifact length exceeds its fixed budget')
  }
  const artifactPath = b4a.toString(textBytes(value.artifactPath, 'artifactPath'), 'utf8')
  if (artifactPath !== BLIND_CLIENT_BROWSER_ARTIFACT_STATUS.artifactPath) {
    fail('BAD_BLIND_CLIENT_BROWSER_ARTIFACT', 'browser artifact path is not the frozen v1 package path')
  }
  return {
    version: 1,
    draft: false,
    specHash: fixed(value.specHash, 32, 'specHash'),
    abiHash: fixed(value.abiHash, 32, 'abiHash'),
    vectorSetHash: fixed(value.vectorSetHash, 32, 'vectorSetHash'),
    clientCompositionFormatHash: fixed(
      value.clientCompositionFormatHash, 32, 'clientCompositionFormatHash'),
    clientCompositionVectorSetHash: fixed(
      value.clientCompositionVectorSetHash, 32, 'clientCompositionVectorSetHash'),
    toolchain: b4a.toString(textBytes(value.toolchain, 'toolchain'), 'utf8'),
    buildProfile: b4a.toString(textBytes(value.buildProfile, 'buildProfile'), 'utf8'),
    sourceClosureHash: fixed(value.sourceClosureHash, 32, 'sourceClosureHash'),
    artifactPath,
    artifactLength,
    artifactHash: fixed(value.artifactHash, 32, 'artifactHash')
  }
}

export function encodeBlindClientBrowserArtifactManifestV1 (input) {
  const value = exactManifestInput(input)
  const toolchain = textBytes(value.toolchain, 'toolchain')
  const buildProfile = textBytes(value.buildProfile, 'buildProfile')
  const artifactPath = textBytes(value.artifactPath, 'artifactPath')
  return b4a.concat([
    MAGIC,
    b4a.from([1, 0]),
    value.specHash,
    value.abiHash,
    value.vectorSetHash,
    value.clientCompositionFormatHash,
    value.clientCompositionVectorSetHash,
    u16(toolchain.byteLength, 'toolchain length'), toolchain,
    u16(buildProfile.byteLength, 'buildProfile length'), buildProfile,
    value.sourceClosureHash,
    u16(artifactPath.byteLength, 'artifactPath length'), artifactPath,
    u64(value.artifactLength, 'artifactLength'),
    value.artifactHash
  ])
}

class Reader {
  constructor (value) {
    this.value = bytes(value, 'browser artifact manifest')
    this.offset = 0
  }

  take (length, field) {
    if (!Number.isSafeInteger(length) || length < 0 || this.offset + length > this.value.byteLength) {
      fail('BAD_BLIND_CLIENT_BROWSER_ARTIFACT', `truncated ${field}`)
    }
    const output = this.value.subarray(this.offset, this.offset + length)
    this.offset += length
    return output
  }

  byte (field) { return this.take(1, field)[0] }

  text (field) {
    const lengthBytes = this.take(2, `${field} length`)
    const length = (lengthBytes[0] << 8) | lengthBytes[1]
    const raw = this.take(length, field)
    const value = b4a.toString(raw, 'utf8')
    if (!b4a.equals(textBytes(value, field), raw)) {
      fail('BAD_BLIND_CLIENT_BROWSER_ARTIFACT', `${field} is not canonical UTF-8`)
    }
    return value
  }

  uint64 (field) {
    let output = 0n
    for (const byte of this.take(8, field)) output = (output << 8n) | BigInt(byte)
    return output
  }

  end () {
    if (this.offset !== this.value.byteLength) {
      fail('BAD_BLIND_CLIENT_BROWSER_ARTIFACT', 'browser artifact manifest has trailing bytes')
    }
  }
}

export function decodeBlindClientBrowserArtifactManifestV1 (input) {
  const reader = new Reader(input)
  if (!b4a.equals(reader.take(MAGIC.byteLength, 'magic'), MAGIC)) {
    fail('BAD_BLIND_CLIENT_BROWSER_ARTIFACT', 'browser artifact manifest magic is invalid')
  }
  const value = {
    version: reader.byte('version'),
    draft: reader.byte('draft') === 1,
    specHash: b4a.from(reader.take(32, 'specHash')),
    abiHash: b4a.from(reader.take(32, 'abiHash')),
    vectorSetHash: b4a.from(reader.take(32, 'vectorSetHash')),
    clientCompositionFormatHash: b4a.from(reader.take(32, 'clientCompositionFormatHash')),
    clientCompositionVectorSetHash: b4a.from(reader.take(32, 'clientCompositionVectorSetHash')),
    toolchain: reader.text('toolchain'),
    buildProfile: reader.text('buildProfile'),
    sourceClosureHash: b4a.from(reader.take(32, 'sourceClosureHash')),
    artifactPath: reader.text('artifactPath'),
    artifactLength: reader.uint64('artifactLength'),
    artifactHash: b4a.from(reader.take(32, 'artifactHash'))
  }
  reader.end()
  const exact = exactManifestInput(value)
  const canonical = encodeBlindClientBrowserArtifactManifestV1(exact)
  if (!b4a.equals(canonical, bytes(input, 'browser artifact manifest'))) {
    fail('BAD_BLIND_CLIENT_BROWSER_ARTIFACT', 'browser artifact manifest is noncanonical')
  }
  return Object.freeze(exact)
}

export function hashBlindClientBrowserArtifactManifest (value) {
  return hash(MANIFEST_HASH_DOMAIN, value)
}

export function verifyBlindClientBrowserArtifactV1 (input = {}) {
  exactObject(input, [
    'manifestBytes', 'artifactBytes', 'expectedManifestHash', 'expectedTuple'
  ], 'browser artifact verification input')
  const manifestBytes = b4a.from(bytes(input.manifestBytes, 'browser artifact manifest'))
  const expectedManifestHash = fixed(
    input.expectedManifestHash, 32, 'expected browser artifact manifest hash')
  if (!b4a.equals(hashBlindClientBrowserArtifactManifest(manifestBytes), expectedManifestHash)) {
    fail('BLIND_CLIENT_BROWSER_MANIFEST_DRIFT',
      'browser artifact manifest does not match its authenticated expected hash')
  }
  const manifest = decodeBlindClientBrowserArtifactManifestV1(manifestBytes)
  const artifactBytes = b4a.from(bytes(input.artifactBytes, 'browser artifact bytes'))
  if (BigInt(artifactBytes.byteLength) !== manifest.artifactLength ||
      !b4a.equals(hashBlindClientBrowserArtifact(artifactBytes), manifest.artifactHash)) {
    fail('BLIND_CLIENT_BROWSER_ARTIFACT_DRIFT', 'browser artifact bytes do not match the manifest')
  }
  const expectedTuple = input.expectedTuple
  exactObject(expectedTuple, [
    'specHash', 'abiHash', 'vectorSetHash',
    'clientCompositionFormatHash', 'clientCompositionVectorSetHash'
  ], 'expected browser artifact tuple')
  const expectedSpecHash = fixed(expectedTuple.specHash, 32, 'expected specHash')
  const expectedAbiHash = fixed(expectedTuple.abiHash, 32, 'expected abiHash')
  const expectedVectorSetHash = fixed(expectedTuple.vectorSetHash, 32, 'expected vectorSetHash')
  const expectedClientCompositionFormatHash = fixed(
    expectedTuple.clientCompositionFormatHash, 32, 'expected clientCompositionFormatHash')
  const expectedClientCompositionVectorSetHash = fixed(
    expectedTuple.clientCompositionVectorSetHash, 32, 'expected clientCompositionVectorSetHash')
  if (!b4a.equals(expectedSpecHash, manifest.specHash) ||
      !b4a.equals(expectedAbiHash, manifest.abiHash) ||
      !b4a.equals(expectedVectorSetHash, manifest.vectorSetHash) ||
      !b4a.equals(expectedClientCompositionFormatHash, manifest.clientCompositionFormatHash) ||
      !b4a.equals(expectedClientCompositionVectorSetHash, manifest.clientCompositionVectorSetHash)) {
    fail('BLIND_CLIENT_BROWSER_TUPLE_MISMATCH', 'browser artifact does not match the exact expected HiveRelay tuple')
  }
  const manifestSnapshot = Object.freeze({
    ...manifest,
    specHash: b4a.from(manifest.specHash),
    abiHash: b4a.from(manifest.abiHash),
    vectorSetHash: b4a.from(manifest.vectorSetHash),
    clientCompositionFormatHash: b4a.from(manifest.clientCompositionFormatHash),
    clientCompositionVectorSetHash: b4a.from(manifest.clientCompositionVectorSetHash),
    sourceClosureHash: b4a.from(manifest.sourceClosureHash),
    artifactHash: b4a.from(manifest.artifactHash)
  })
  return Object.freeze({
    get manifest () {
      return Object.freeze({
        ...manifestSnapshot,
        specHash: b4a.from(manifestSnapshot.specHash),
        abiHash: b4a.from(manifestSnapshot.abiHash),
        vectorSetHash: b4a.from(manifestSnapshot.vectorSetHash),
        clientCompositionFormatHash: b4a.from(manifestSnapshot.clientCompositionFormatHash),
        clientCompositionVectorSetHash: b4a.from(manifestSnapshot.clientCompositionVectorSetHash),
        sourceClosureHash: b4a.from(manifestSnapshot.sourceClosureHash),
        artifactHash: b4a.from(manifestSnapshot.artifactHash)
      })
    },
    get artifactBytes () { return b4a.from(artifactBytes) }
  })
}

function canonicalEvidenceJson (value, field) {
  value = bytes(value, field)
  if (value.byteLength < 1 || value.byteLength > 16 * 1024) {
    fail('BAD_BLIND_CLIENT_BROWSER_RELEASE_EVIDENCE', `${field} is outside its byte limit`)
  }
  const source = b4a.toString(value, 'utf8')
  if (!b4a.equals(b4a.from(source, 'utf8'), value)) {
    fail('BAD_BLIND_CLIENT_BROWSER_RELEASE_EVIDENCE', `${field} is not canonical UTF-8`)
  }
  let parsed
  try {
    parsed = JSON.parse(source)
  } catch {
    fail('BAD_BLIND_CLIENT_BROWSER_RELEASE_EVIDENCE', `${field} is not JSON`)
  }
  if (JSON.stringify(parsed, null, 2) + '\n' !== source) {
    fail('BAD_BLIND_CLIENT_BROWSER_RELEASE_EVIDENCE', `${field} is not canonical JSON`)
  }
  return parsed
}

function evidenceText (value, field, maximum = 256) {
  if (typeof value !== 'string' || value.length < 1 || value.length > maximum ||
      /[^\x20-\x7e]/.test(value)) {
    fail('BAD_BLIND_CLIENT_BROWSER_RELEASE_EVIDENCE', `${field} is not bounded printable ASCII`)
  }
  return value
}

function exactChecks (actual, expected, field) {
  if (!Array.isArray(actual) || actual.length !== expected.length ||
      actual.some((value, index) => value !== expected[index])) {
    fail('BAD_BLIND_CLIENT_BROWSER_RELEASE_EVIDENCE', `${field} is not the exact checked set`)
  }
}

function commonEvidence (value, fields, expected, field) {
  exactObject(value, fields, field)
  if (value.version !== 1 || value.passed !== true ||
      value.artifactPath !== BLIND_CLIENT_BROWSER_ARTIFACT_STATUS.artifactPath ||
      value.artifactLength !== expected.artifactLength ||
      value.artifactHash !== expected.artifactHash ||
      value.manifestHash !== expected.manifestHash ||
      value.sourceClosureHash !== expected.sourceClosureHash) {
    fail('BLIND_CLIENT_BROWSER_RELEASE_EVIDENCE_MISMATCH',
      `${field} does not bind the exact checked browser artifact`)
  }
}

export function verifyBlindClientBrowserArtifactReleaseEvidenceV1 (input = {}) {
  exactObject(input, [
    'manifestBytes', 'artifactBytes', 'expectedManifestHash', 'expectedTuple',
    'chromiumEvidenceBytes', 'crossHostEvidenceBytes'
  ], 'browser artifact release evidence verification input')
  const verified = verifyBlindClientBrowserArtifactV1({
    manifestBytes: input.manifestBytes,
    artifactBytes: input.artifactBytes,
    expectedManifestHash: input.expectedManifestHash,
    expectedTuple: input.expectedTuple
  })
  const manifest = verified.manifest
  const expected = Object.freeze({
    artifactLength: Number(manifest.artifactLength),
    artifactHash: b4a.toString(manifest.artifactHash, 'hex'),
    manifestHash: b4a.toString(fixed(input.expectedManifestHash, 32, 'expected manifest hash'), 'hex'),
    sourceClosureHash: b4a.toString(manifest.sourceClosureHash, 'hex')
  })
  const chromium = canonicalEvidenceJson(input.chromiumEvidenceBytes, 'Chromium evidence')
  const chromiumFields = [
    'schema', 'version', 'evidenceClass', 'artifactPath', 'artifactLength',
    'artifactHash', 'manifestHash', 'sourceClosureHash', 'chromium', 'checks', 'passed'
  ]
  commonEvidence(chromium, chromiumFields, expected, 'Chromium evidence')
  if (chromium.schema !== 'HiveRelayBlindClientBrowserArtifactChromiumEvidenceV1' ||
      chromium.evidenceClass !== 'real-chromium') {
    fail('BAD_BLIND_CLIENT_BROWSER_RELEASE_EVIDENCE', 'Chromium evidence has the wrong authority class')
  }
  evidenceText(chromium.chromium, 'Chromium version')
  exactChecks(chromium.checks, CHROMIUM_EVIDENCE_CHECKS, 'Chromium evidence checks')

  const crossHost = canonicalEvidenceJson(input.crossHostEvidenceBytes, 'cross-host evidence')
  const crossHostFields = [
    'schema', 'version', 'evidenceClass', 'artifactPath', 'artifactLength',
    'artifactHash', 'manifestHash', 'sourceClosureHash', 'platform', 'architecture',
    'containerImageId', 'node', 'toolchain', 'checks', 'passed'
  ]
  commonEvidence(crossHost, crossHostFields, expected, 'cross-host evidence')
  if (crossHost.schema !== 'HiveRelayBlindClientBrowserArtifactCrossHostEvidenceV1' ||
      crossHost.evidenceClass !== 'clean-linux-container' || crossHost.platform !== 'linux' ||
      crossHost.toolchain !== manifest.toolchain || !/^sha256:[0-9a-f]{64}$/.test(crossHost.containerImageId)) {
    fail('BAD_BLIND_CLIENT_BROWSER_RELEASE_EVIDENCE', 'cross-host evidence has the wrong authority class')
  }
  evidenceText(crossHost.architecture, 'cross-host architecture', 32)
  evidenceText(crossHost.node, 'cross-host Node version', 64)
  exactChecks(crossHost.checks, CROSS_HOST_EVIDENCE_CHECKS, 'cross-host evidence checks')

  return Object.freeze({
    ...BLIND_CLIENT_BROWSER_ARTIFACT_STATUS,
    crossHostByteEqualityProven: true,
    realBrowserImportProven: true,
    releaseReady: true,
    releaseBlockers: Object.freeze([]),
    artifactHash: expected.artifactHash,
    manifestHash: expected.manifestHash,
    chromium: Object.freeze({ version: chromium.chromium }),
    crossHost: Object.freeze({
      platform: crossHost.platform,
      architecture: crossHost.architecture,
      containerImageId: crossHost.containerImageId,
      node: crossHost.node
    })
  })
}