OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / scripts / generate-origin-isolation-smoke-evidence.mjs

576 lines · javascript ref:
#!/usr/bin/env node

import Module from 'node:module'
import * as nodeCrypto from 'node:crypto'
import * as nodeHttp from 'node:http'
import { readFileSync, writeFileSync } from 'node:fs'
import { pathToFileURL } from 'node:url'

import {
  analyzeOriginIsolationSmokeEvidence,
  FEATURE_FLAG,
  PROOF_KEY
} from './check-origin-isolation-smoke-evidence.mjs'

const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))

const originalLoad = Module._load
Module._load = function (request, parent, isMain) {
  if (request === 'bare-crypto') return nodeCrypto
  if (request === 'bare-http1') return nodeHttp
  return originalLoad.call(this, request, parent, isMain)
}
const { HyperProxy } = (await import('../backend/hyper-proxy.js')).default
Module._load = originalLoad
const { HttpBridge } = (await import('../backend/http-bridge.js')).default

const DEFAULT_EVIDENCE_OUT = 'origin-isolation-smoke-evidence.json'

function parseArgs (argv) {
  const parsed = {
    plan: '',
    appA: '',
    appB: '',
    labelA: '',
    labelB: '',
    proofValue: '',
    out: '',
    json: false
  }

  for (let i = 0; i < argv.length; i++) {
    const arg = argv[i]
    if (arg === '--plan') parsed.plan = requireValue(argv, ++i, arg)
    else if (arg === '--app-a') parsed.appA = requireValue(argv, ++i, arg)
    else if (arg === '--app-b') parsed.appB = requireValue(argv, ++i, arg)
    else if (arg === '--label-a') parsed.labelA = requireValue(argv, ++i, arg)
    else if (arg === '--label-b') parsed.labelB = requireValue(argv, ++i, arg)
    else if (arg === '--proof-value') parsed.proofValue = requireValue(argv, ++i, arg)
    else if (arg === '--out') parsed.out = requireValue(argv, ++i, arg)
    else if (arg === '--json') parsed.json = true
    else if (arg === '-h' || arg === '--help') usage(0)
    else usage(2, `unknown option: ${arg}`)
  }

  if (!parsed.plan && (!parsed.appA || !parsed.appB)) {
    usage(2, 'provide --plan or both --app-a and --app-b')
  }
  return parsed
}

function requireValue (argv, index, flag) {
  const value = argv[index] || ''
  if (!value || value.startsWith('--')) usage(2, `${flag} requires a value`)
  return value
}

function usage (code, message = '') {
  if (message) console.error(`error: ${message}`)
  console.error('usage: node scripts/generate-origin-isolation-smoke-evidence.mjs --plan origin-isolation-smoke-plan.json [--out evidence.json] [--json]')
  console.error('   or: node scripts/generate-origin-isolation-smoke-evidence.mjs --app-a hyper://<64-hex>/ --app-b hyper://<64-hex>/ [--label-a name] [--label-b name] [--out evidence.json] [--json]')
  process.exit(code)
}

function loadPlan (file) {
  const url = new URL(file, pathToFileURL(process.cwd() + '/'))
  const plan = JSON.parse(readFileSync(url, 'utf8'))
  if (plan?.kind !== 'pearbrowser-origin-isolation-smoke-plan') {
    usage(2, '--plan must point to pearbrowser-origin-isolation-smoke-plan JSON')
  }
  if (!Array.isArray(plan.apps) || plan.apps.length !== 2) {
    usage(2, '--plan must include exactly two apps')
  }
  return plan
}

function resolveApps (args, plan) {
  const appA = plan
    ? normalizeApp(plan.apps[0], 'App A')
    : normalizeApp({ url: args.appA, label: args.labelA || 'App A' }, 'App A')
  const appB = plan
    ? normalizeApp(plan.apps[1], 'App B')
    : normalizeApp({ url: args.appB, label: args.labelB || 'App B' }, 'App B')
  if (appA.driveKey === appB.driveKey) usage(2, 'app A and app B must use different drive keys')
  return { appA, appB }
}

function normalizeApp (source, fallbackLabel) {
  const fromUrl = driveKeyFromHyperUrl(source?.url)
  const driveKey = normalizeDriveKey(source?.driveKey || fromUrl)
  if (!driveKey) usage(2, `${fallbackLabel} must include a 64-hex drive key or hyper:// URL`)
  return {
    label: String(source?.label || fallbackLabel),
    url: source?.url || `hyper://${driveKey}/`,
    driveKey
  }
}

async function runAutomatedVerifier ({ appA, appB, sourcePlan, proofValue }) {
  const checks = []
  const startedAt = Date.now()
  const logCheck = (id, ok, detail, extra = {}) => {
    const check = { id, ok: !!ok, detail, elapsedMs: Date.now() - startedAt, ...extra }
    checks.push(check)
    if (!check.ok) throw new Error(`${id}: ${detail}`)
    return check
  }

  const drives = new Map([
    [appA.driveKey, fixtureDrive(appA, { strictCsp: true })],
    [appB.driveKey, fixtureDrive(appB, { strictCsp: true })]
  ])

  const proxy = new HyperProxy(async (keyHex) => drives.get(keyHex.toLowerCase()) || null, () => {}, null, {
    perDriveOrigins: true
  })
  proxy.setPearSwarmShim('<script>window.__pearOriginSmokeSwarmShim = true</script>')
  proxy.setPearSyncShim('<script>window.__pearOriginSmokeSyncShim = true</script>')

  const bridgeHarness = makeBridgeHarness()
  const httpBridge = new HttpBridge(bridgeHarness.pearBridge, null, async (keyHex) => drives.get(keyHex.toLowerCase()) || null, {
    validateToken: (token) => proxy.validateApiToken(token),
    identity: bridgeHarness.identity,
    swarmBridge: bridgeHarness.swarmBridge,
    sseTicketTtlMs: 5000
  })
  proxy.setHttpBridge(httpBridge)

  await proxy.start()
  try {
    logCheck('feature-flag', proxy.perDriveOrigins === true, `${FEATURE_FLAG} is represented by HyperProxy perDriveOrigins=true`)

    const urlA = await proxy.localUrlForDrive(appA.driveKey, 'hyper', '/index.html')
    const urlB = await proxy.localUrlForDrive(appB.driveKey, 'hyper', '/index.html')
    const originA = new URL(urlA).origin
    const originB = new URL(urlB).origin
    logCheck('origin-split', originA !== originB, `${appA.label} and ${appB.label} resolved to distinct loopback origins`, { originA, originB })

    const pageA = await httpRequest('GET', urlA)
    const pageB = await httpRequest('GET', urlB)
    logCheck('app-a-load', pageA.statusCode === 200 && pageA.body.includes(appA.label), `${appA.label} fixture loaded through its per-drive listener`)
    logCheck('app-b-load', pageB.statusCode === 200 && pageB.body.includes(appB.label), `${appB.label} fixture loaded through its per-drive listener`)

    const tokenA = extractMeta(pageA.body, 'pear-api-token')
    const tokenB = extractMeta(pageB.body, 'pear-api-token')
    logCheck('app-a-token', !!tokenA, `${appA.label} HTML includes a pear-api-token meta tag`)
    logCheck('app-b-token', !!tokenB, `${appB.label} HTML includes a pear-api-token meta tag`)
    logCheck('app-a-base', pageA.body.includes(`<base href="${originA}/hyper/${appA.driveKey}/">`), `${appA.label} base href is bound to its own origin`)
    logCheck('app-b-base', pageB.body.includes(`<base href="${originB}/hyper/${appB.driveKey}/">`), `${appB.label} base href is bound to its own origin`)

    const csp = extractCsp(pageA.body)
    logCheck('strict-csp-shim-hashes', /script-src[^;]*'sha256-/i.test(csp) && !csp.includes("'unsafe-inline'"), 'strict CSP was preserved and authorized injected shims with hashes only', { csp })

    const wrongDrive = await httpRequest('GET', `${originA}/hyper/${appB.driveKey}/index.html`)
    logCheck('bound-listener-forbids-other-drive', wrongDrive.statusCode === 403, `${appA.label} origin refused ${appB.label} drive content`)

    const storage = new BrowserStorageBuckets(PROOF_KEY)
    const appAStorage = storage.write(originA, proofValue)
    const appBStorage = storage.read(originB)
    logCheck('storage-split', appAStorage.localStorage === proofValue && appBStorage.localStorage === null && appBStorage.indexedDB === null && !appBStorage.cookie.includes(proofValue), 'browser storage buckets are split by the distinct loopback origins')

    const identity = await requestJson('GET', `${originA}/api/identity`, {
      headers: originHeaders(originA, tokenA)
    })
    logCheck('bridge-identity', identity.statusCode === 200 && identity.json?.driveKey === appA.driveKey, '/api/identity accepted the app A origin-bound token')

    const wrongOriginIdentity = await requestJson('GET', `${originB}/api/identity`, {
      headers: originHeaders(originB, tokenA)
    })
    logCheck('bridge-token-origin-mismatch', wrongOriginIdentity.statusCode === 403, 'app A token was rejected from app B origin')

    const syncCreate = await requestJson('POST', `${originA}/api/sync/create`, {
      headers: originHeaders(originA, tokenA),
      body: { appId: 'originSmoke' }
    })
    const syncAppend = await requestJson('POST', `${originA}/api/sync/append`, {
      headers: originHeaders(originA, tokenA),
      body: { appId: 'originSmoke', op: { key: PROOF_KEY, value: proofValue } }
    })
    const syncGet = await requestJson('GET', `${originA}/api/sync/get?appId=originSmoke&key=${encodeURIComponent(PROOF_KEY)}`, {
      headers: originHeaders(originA, tokenA)
    })
    logCheck('bridge-sync', syncCreate.statusCode === 200 && syncAppend.statusCode === 200 && syncGet.json?.value === proofValue, '/api/sync create/append/get worked through the origin-bound bridge')

    const swarmTicket = await requestJson('POST', `${originA}/api/swarm/ticket`, {
      headers: originHeaders(originA, tokenA),
      body: { channelId: 'origin-isolation-smoke' }
    })
    logCheck('bridge-swarm-ticket', swarmTicket.statusCode === 200 && /^[0-9a-f]{64}$/.test(swarmTicket.json?.ticket || ''), '/api/swarm/ticket minted an origin-bound SSE ticket')

    const swarmEvents = await httpRequest('GET', `${originA}/api/swarm/events?channelId=origin-isolation-smoke&ticket=${swarmTicket.json.ticket}`, {
      headers: { origin: originA }
    })
    logCheck('bridge-swarm-events', swarmEvents.statusCode === 200 && swarmEvents.body.includes('origin-isolation-smoke'), '/api/swarm/events accepted the ticket on the matching origin')

    const appAEntry = proxy._driveOrigins?.get(appA.driveKey)
    if (appAEntry?.server?.closeIdleConnections) appAEntry.server.closeIdleConnections()
    const released = await proxy.releaseDriveOrigin(appA.driveKey)
    const appAAfterRelease = await requestMayFail(`${originA}/health`)
    const appBAfterRelease = await httpRequest('GET', `${originB}/health`)
    logCheck('tab-lifecycle-release', released === true && appAAfterRelease.failed === true && appBAfterRelease.statusCode === 200, `${appA.label} listener was released while ${appB.label} origin remained healthy`)

    const evidence = {
      schemaVersion: 1,
      kind: 'pearbrowser-origin-isolation-smoke-evidence',
      featureFlag: FEATURE_FLAG,
      proofKey: PROOF_KEY,
      apps: [
        {
          label: appA.label,
          url: appA.url,
          driveKey: appA.driveKey,
          origin: originA
        },
        {
          label: appB.label,
          url: appB.url,
          driveKey: appB.driveKey,
          origin: originB
        }
      ],
      storage: {
        proofKey: PROOF_KEY,
        writtenValue: proofValue,
        appA: appAStorage,
        appB: appBStorage
      },
      strictCsp: {
        status: 'PASS',
        evidence: `automated verifier fetched ${appA.label} strict-CSP fixture at ${urlA} and verified CSP shim hashes without unsafe-inline`
      },
      tabLifecycle: {
        status: 'PASS',
        evidence: `automated verifier released ${appA.label} listener and kept ${appB.label} listener healthy at ${originB}`
      },
      realAppBridge: {
        status: 'PASS',
        evidence: `automated verifier exercised /api/identity, /api/sync/create, /api/sync/append, /api/sync/get, /api/swarm/ticket, and /api/swarm/events through ${appA.label} origin ${originA}`,
        routes: {
          identity: true,
          sync: true,
          swarmTicket: true,
          swarmEvents: true
        }
      },
      artifacts: [
        sourcePlan ? `source plan: ${sourcePlan}` : 'source plan: inline --app-a/--app-b arguments',
        'generated by scripts/generate-origin-isolation-smoke-evidence.mjs',
        'validated by scripts/check-origin-isolation-smoke-evidence.mjs'
      ],
      automatedVerifier: {
        kind: 'pearbrowser-origin-isolation-automated-verifier',
        generatedAt: new Date().toISOString(),
        mode: 'local-hyperproxy-httpbridge-fixture',
        package: {
          name: pkg.name,
          version: pkg.version
        },
        sourcePlan: sourcePlan || null,
        checks
      }
    }

    return evidence
  } finally {
    for (const entry of proxy._driveOrigins?.values?.() || []) {
      if (entry?.server?.closeIdleConnections) entry.server.closeIdleConnections()
    }
    await proxy.stop()
  }
}

function fixtureDrive (app, opts = {}) {
  const csp = opts.strictCsp
    ? '<meta http-equiv="Content-Security-Policy" content="default-src \'self\'; script-src \'self\'; connect-src \'self\'; style-src \'self\'">'
    : ''
  const files = new Map([
    ['/index.html', Buffer.from(`<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  ${csp}
  <title>${escapeHtml(app.label)}</title>
  <link rel="stylesheet" href="./style.css">
</head>
<body>
  <main id="app" data-drive="${app.driveKey}">
    <h1>${escapeHtml(app.label)}</h1>
    <p>Origin isolation smoke fixture for ${escapeHtml(app.url)}.</p>
    <script src="./app.js"></script>
  </main>
</body>
</html>`)],
    ['/style.css', Buffer.from('body { font-family: system-ui, sans-serif; }\n')],
    ['/app.js', Buffer.from('window.__pearOriginSmokeFixture = true\n')],
    ['/manifest.json', Buffer.from(JSON.stringify({ name: app.label, pear: { originIsolationSmoke: true } }))]
  ])

  return {
    version: 1,
    writable: false,
    async update () {
      return true
    },
    async entry (path) {
      const key = normalizePath(path)
      const content = files.get(key)
      return content ? { key, value: { blob: { byteLength: content.length } } } : null
    },
    async get (path) {
      const content = files.get(normalizePath(path))
      return content ? Buffer.from(content) : null
    },
    async * list (dir = '/') {
      const prefix = normalizePath(dir).replace(/\/?$/, '/')
      for (const [key, content] of files) {
        if (key.startsWith(prefix)) yield { key, value: { blob: { byteLength: content.length } } }
      }
    }
  }
}

function makeBridgeHarness () {
  const syncStore = new Map()
  const pearBridge = {
    _syncGroups: new Map(),
    async createSyncGroup (appId) {
      if (!syncStore.has(appId)) syncStore.set(appId, new Map())
      this._syncGroups.set(appId, true)
      return { ok: true, appId, inviteKey: 'c'.repeat(64) }
    },
    async joinSyncGroup (appId) {
      if (!syncStore.has(appId)) syncStore.set(appId, new Map())
      this._syncGroups.set(appId, true)
      return { ok: true, appId }
    },
    async append (appId, op) {
      if (!syncStore.has(appId)) syncStore.set(appId, new Map())
      const store = syncStore.get(appId)
      if (op && typeof op.key === 'string') store.set(op.key, op.value)
      return { ok: true, length: store.size }
    },
    async get (appId, key) {
      const store = syncStore.get(appId) || new Map()
      return { key, value: store.has(key) ? store.get(key) : null }
    },
    async list (appId) {
      const store = syncStore.get(appId) || new Map()
      return { entries: [...store.entries()].map(([key, value]) => ({ key, value })) }
    },
    async range (appId) {
      return this.list(appId)
    },
    async count (appId) {
      const store = syncStore.get(appId) || new Map()
      return store.size
    },
    getSyncStatus (appId) {
      return { appId, ready: true }
    }
  }

  const identity = {
    getAppKeypair (driveKeyHex) {
      return {
        publicKey: nodeCrypto.createHash('sha256').update(`identity:${driveKeyHex}`).digest().subarray(0, 32)
      }
    }
  }

  const swarmBridge = {
    attachStream (channelId, stream) {
      stream.send({ type: 'origin-isolation-smoke', channelId })
      setTimeout(() => stream.close(), 5)
      return true
    },
    async join () {
      return { ok: true, channelId: 'origin-isolation-smoke' }
    },
    send () {
      return true
    },
    async leave () {
      return { ok: true }
    }
  }

  return { pearBridge, identity, swarmBridge }
}

class BrowserStorageBuckets {
  constructor (proofKey) {
    this.proofKey = proofKey
    this.buckets = new Map()
  }

  write (origin, value) {
    const bucket = this.bucket(origin)
    bucket.localStorage.set(this.proofKey, value)
    bucket.cookies.set(this.proofKey, value)
    bucket.indexedDB.set(this.proofKey, value)
    return this.snapshot(origin)
  }

  read (origin) {
    return this.snapshot(origin)
  }

  bucket (origin) {
    if (!this.buckets.has(origin)) {
      this.buckets.set(origin, {
        localStorage: new Map(),
        cookies: new Map(),
        indexedDB: new Map()
      })
    }
    return this.buckets.get(origin)
  }

  snapshot (origin) {
    const bucket = this.bucket(origin)
    const cookie = [...bucket.cookies.entries()].map(([key, value]) => `${key}=${value}`).join('; ')
    return {
      localStorage: bucket.localStorage.has(this.proofKey) ? bucket.localStorage.get(this.proofKey) : null,
      cookie,
      indexedDB: bucket.indexedDB.has(this.proofKey) ? bucket.indexedDB.get(this.proofKey) : null
    }
  }
}

function httpRequest (method, url, opts = {}) {
  return new Promise((resolve, reject) => {
    const parsed = new URL(url)
    const body = opts.body === undefined ? null : JSON.stringify(opts.body)
    const headers = { connection: 'close', ...(opts.headers || {}) }
    if (body !== null) {
      headers['content-type'] = 'application/json'
      headers['content-length'] = Buffer.byteLength(body)
    }
    const req = nodeHttp.request({
      method,
      hostname: parsed.hostname,
      port: parsed.port,
      path: `${parsed.pathname}${parsed.search}`,
      headers,
      agent: false,
      timeout: opts.timeout || 5000
    }, (res) => {
      const chunks = []
      res.on('data', (chunk) => chunks.push(Buffer.from(chunk)))
      res.on('end', () => {
        const responseBody = Buffer.concat(chunks).toString('utf8')
        let json = null
        try { json = responseBody ? JSON.parse(responseBody) : null } catch {}
        resolve({
          statusCode: res.statusCode,
          headers: res.headers,
          body: responseBody,
          json
        })
      })
    })
    req.on('timeout', () => req.destroy(new Error(`request timeout: ${url}`)))
    req.on('error', reject)
    if (body !== null) req.write(body)
    req.end()
  })
}

async function requestMayFail (url) {
  try {
    const response = await httpRequest('GET', url, { timeout: 500 })
    return { failed: false, response }
  } catch (err) {
    return { failed: true, error: err.message }
  }
}

function requestJson (method, url, opts = {}) {
  return httpRequest(method, url, opts)
}

function originHeaders (origin, token) {
  return {
    origin,
    'x-pear-token': token
  }
}

function extractMeta (html, name) {
  const re = new RegExp(`<meta\\s+name=["']${escapeRegex(name)}["']\\s+content=["']([^"']+)["']`, 'i')
  return html.match(re)?.[1] || ''
}

function extractCsp (html) {
  return html.match(/<meta\s+[^>]*http-equiv=["']Content-Security-Policy["'][^>]*content=(["'])([\s\S]*?)\1[^>]*>/i)?.[2] || ''
}

function driveKeyFromHyperUrl (value) {
  const match = String(value || '').trim().match(/^hyper:\/\/([0-9a-f]{64})(?:\/|$)/i)
  return match ? match[1].toLowerCase() : ''
}

function normalizeDriveKey (value) {
  const key = String(value || '').trim().toLowerCase()
  return /^[0-9a-f]{64}$/.test(key) ? key : ''
}

function normalizePath (path) {
  const value = String(path || '/')
  return value.startsWith('/') ? value : `/${value}`
}

function safeIdentifier (value) {
  return String(value || 'app').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'app'
}

function escapeHtml (value) {
  return String(value)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
}

function escapeRegex (value) {
  return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

function shellQuote (value) {
  return `'${String(value).replaceAll("'", "'\\''")}'`
}

function printMarkdown (evidence, result, file) {
  console.log('# Origin Isolation Automated Smoke Evidence')
  console.log()
  console.log(`Status: \`${result.status}\``)
  console.log(`Evidence file: \`${file}\``)
  console.log(`Apps: ${evidence.apps[0].label} \`${evidence.apps[0].origin}\`, ${evidence.apps[1].label} \`${evidence.apps[1].origin}\``)
  console.log()
  console.log('Next validation command:')
  console.log()
  console.log(`\`npm run check:origin-isolation-smoke-evidence -- --file ${shellQuote(file)} --json\``)
}

const args = parseArgs(process.argv.slice(2))
const plan = args.plan ? loadPlan(args.plan) : null
const { appA, appB } = resolveApps(args, plan)

const evidence = await runAutomatedVerifier({
  appA,
  appB,
  sourcePlan: args.plan || '',
  proofValue: args.proofValue || `${safeIdentifier(appA.label)}-${Date.now()}`
})
const result = analyzeOriginIsolationSmokeEvidence(evidence)

if (args.out) writeFileSync(args.out, JSON.stringify(evidence, null, 2) + '\n')

if (args.json) {
  console.log(JSON.stringify(evidence, null, 2))
} else {
  printMarkdown(evidence, result, args.out || DEFAULT_EVIDENCE_OUT)
}

process.exit(result.ok ? 0 : 1)