OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / integration / journey-namespace-blind.test.js

298 lines · javascript ref:
/**
 * GIGA DoD cross-feature journey — namespace × blind
 * (docs/GIGA-RELEASE-ARCHITECTURE.md §7 "Cross-feature journeys":
 * "two apps sharing one relay's outboxlog under distinct blind namespaces,
 * with takedown"; docs/NAMESPACE.md).
 *
 * What this proves (composition, not internals), all over the relay's real
 * HTTP bridge into a RelayNode-loaded outboxlog service:
 *   1. Sealed round-trip: two app writers share one relay under distinct
 *      blind namespaces; a client-sealed record appends and reads back
 *      with the relay holding ciphertext only (stored body is a blind
 *      sealed body; the plaintext never appears in the served record).
 *   2. Plaintext-shaped records are hard-rejected (400) — both a record
 *      whose body is not a sealed body and a sealed record carrying a
 *      BLIND_FORBIDDEN_FIELDS marker.
 *   3. Operator opaque-id takedown: DO-NOT-SERVE via the admin surface
 *      (admin key, never the browser token) suppresses the record from
 *      reads without content exposure — the row stays in storage and the
 *      audit surface lists only opaque ids; restore reverses it.
 *   4. Cross-namespace replay is rejected: the namespace is inside the
 *      ed25519 signature domain, so a record signed for appalpha fails
 *      verification when replayed as appbeta — and an appalpha record
 *      replayed into an appbeta-bound outbox is refused as well.
 *
 * Realization: real RelayNode + its real ServiceRegistry + a real
 * OutboxLogApp (in-memory persistence — the durability backend is
 * orthogonal to this journey) on a hyperswarm testnet; clients drive the
 * real /api/sync and /api/admin HTTP surface. The RelayNode-boots-
 * outboxlog-with-hypercore-journal path on a fresh store is separately
 * broken upstream of these journeys — see the marked GAP test below.
 */

import test from 'brittle'
import createTestnet from '@hyperswarm/testnet'
import sodium from 'sodium-universal'
import b4a from 'b4a'
import fs from 'fs'
import os from 'os'
import path from 'path'
import { RelayNode } from 'p2p-hiverelay/core/relay-node/index.js'
import {
  OutboxLogApp,
  canonicalOutboxRecord,
  createOutboxBlindSealAAD,
  createOutboxBlindSealKey,
  isOutboxBlindRecord,
  openOutboxBlindPayload,
  sealOutboxBlindPayload,
  verifyOutboxRecordSignature
} from 'p2p-hiveservices/builtin/outboxlog/index.js'

const ADMIN_KEY = 'journey-operator-admin-key'
const NS_ALPHA = 'appalpha'
const NS_BETA = 'appbeta'

const NAMESPACES = {
  [NS_ALPHA]: { blind: true, caps: { maxOutboxes: 4, maxEntriesPerOutbox: 8, maxValueBytes: 16 * 1024 } },
  [NS_BETA]: { blind: true, caps: { maxOutboxes: 4, maxEntriesPerOutbox: 8, maxValueBytes: 16 * 1024 } }
}

function tmpdir (t) {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hiverelay-journey-namespace-blind-'))
  t.teardown(() => { try { fs.rmSync(dir, { recursive: true, force: true }) } catch {} })
  return dir
}

function pickPort () {
  return 49000 + Math.floor(Math.random() * 10000)
}

function writerKeyPair (seedByte) {
  const publicKey = b4a.alloc(32)
  const secretKey = b4a.alloc(64)
  sodium.crypto_sign_seed_keypair(publicKey, secretKey, b4a.alloc(32, seedByte))
  return { publicKey, secretKey, publicKeyHex: b4a.toString(publicKey, 'hex') }
}

// Same signing shape the app clients use (test/unit/outboxlog-blind-seal.test.js):
// the namespace is part of the signed payload — `pear.app.<driveKey>:<ns>:<record>`.
function signRecord (writer, fields = {}, type = 'message') {
  const namespace = fields._ns || NS_ALPHA
  const data = {
    id: fields.id || 'm1',
    author: writer.publicKeyHex,
    ...fields,
    _k: writer.publicKeyHex,
    _dk: fields._dk || 'd'.repeat(64),
    _ns: namespace,
    _alg: 'ed25519'
  }
  const signed = `pear.app.${data._dk}:${data._ns}:${canonicalOutboxRecord(type, data)}`
  const signature = b4a.alloc(64)
  sodium.crypto_sign_detached(signature, b4a.from(signed, 'utf8'), writer.secretKey)
  return { ...data, _sig: b4a.toString(signature, 'hex') }
}

async function postJson (port, requestPath, body, headers = {}) {
  const res = await fetch(`http://127.0.0.1:${port}${requestPath}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', ...headers },
    body: JSON.stringify(body)
  })
  return { status: res.status, body: await res.json().catch(() => null) }
}

async function getJson (port, requestPath, headers = {}) {
  const res = await fetch(`http://127.0.0.1:${port}${requestPath}`, { headers })
  return { status: res.status, body: await res.json().catch(() => null) }
}

test('journey namespace × blind: two apps share one relay under blind namespaces — sealed I/O, rejection, takedown, replay', async (t) => {
  const dir = tmpdir(t)
  const relayStorage = path.join(dir, 'relay')
  fs.mkdirSync(relayStorage, { recursive: true })

  const testnet = await createTestnet(3)
  const apiPort = pickPort()

  const relay = new RelayNode({
    storage: relayStorage,
    bootstrapNodes: testnet.bootstrap,
    enableAPI: true,
    apiPort,
    apiHost: '127.0.0.1',
    enableRelay: false,
    enableSeeding: true,
    enableServices: true,
    plugins: [],
    enableNetworkDiscovery: false,
    enableHolesail: false,
    outboxlog: { adminKey: ADMIN_KEY }
  })
  t.teardown(async () => {
    try { await relay.stop() } catch {}
    try { await testnet.destroy() } catch {}
  })
  await relay.start()

  // Real OutboxLogApp registered into the running relay's real
  // ServiceRegistry — same provider object the PluginLoader would build,
  // with in-memory persistence (the hypercore journal's RelayNode boot path
  // is broken on fresh stores — GAP test below). Everything this journey
  // asserts (namespace registry, blind-seal enforcement, signature domain,
  // takedown surface, HTTP bridge) is untouched by the persistence backend.
  const outboxApp = new OutboxLogApp({ persistence: false, namespaces: NAMESPACES })
  relay.serviceRegistry.register(outboxApp)
  await relay.serviceRegistry.startAll(relay._buildServiceContext())
  t.is(relay.serviceRegistry.services.get('outboxlog')?.status, 'running', 'outboxlog service running inside the relay')

  const token = (await postJson(apiPort, '/api/token', {})).body.token
  t.ok(token, 'browser bridge token issued')
  const auth = { 'X-Pear-Token': token }
  const bridge = await getJson(apiPort, '/api/bridge/status', auth)
  t.alike(bridge.body, { ready: true, service: 'outboxlog' }, 'outboxlog HTTP bridge live on the relay')

  // Two app writers, one relay, two blind namespaces.
  const alpha = writerKeyPair(41)
  const beta = writerKeyPair(42)
  t.is((await postJson(apiPort, '/api/sync/create', { appId: alpha.publicKeyHex, namespace: NS_ALPHA }, auth)).status, 200)
  t.is((await postJson(apiPort, '/api/sync/create', { appId: beta.publicKeyHex, namespace: NS_BETA }, auth)).status, 200)

  // ─── 1. Sealed round-trip — ciphertext only at the relay ──────────────
  const sealKey = createOutboxBlindSealKey()
  const keyId = 'alpha-room:main@1'
  const aad = createOutboxBlindSealAAD({ namespace: NS_ALPHA, appId: alpha.publicKeyHex, type: 'message', id: 'm1', keyId })
  const sealedBody = sealOutboxBlindPayload({ text: 'alpha room secret' }, { key: sealKey, aad, keyId })
  const record = signRecord(alpha, { id: 'm1', _ns: NS_ALPHA, body: sealedBody }, 'message')

  const appended = await postJson(apiPort, '/api/sync/append', { appId: alpha.publicKeyHex, op: { type: 'message', data: record } }, auth)
  t.is(appended.status, 200, 'sealed record accepted: ' + JSON.stringify(appended.body))
  t.alike(appended.body, { ok: true, key: 'message!m1' })

  const served = await getJson(apiPort, `/api/sync/get?appId=${alpha.publicKeyHex}&key=${encodeURIComponent('message!m1')}`, auth)
  t.is(served.status, 200)
  t.ok(isOutboxBlindRecord(served.body), 'relay stores/serves a blind sealed body')
  t.absent(JSON.stringify(served.body).includes('alpha room secret'), 'no plaintext anywhere in the served record')
  t.alike(
    openOutboxBlindPayload(served.body.body, { key: sealKey, aad }),
    { text: 'alpha room secret' },
    'client re-opens the round-tripped ciphertext with the room key + aad'
  )

  // ─── 2. Plaintext-shaped records are hard-rejected ────────────────────
  const plaintextShaped = signRecord(alpha, { id: 'm2', _ns: NS_ALPHA, body: { text: 'relay must never store this' } }, 'message')
  const rejectedPlain = await postJson(apiPort, '/api/sync/append', { appId: alpha.publicKeyHex, op: { type: 'message', data: plaintextShaped } }, auth)
  t.is(rejectedPlain.status, 400, 'non-sealed body hard-rejected on a blind namespace')
  t.ok(/namespace policy/.test(rejectedPlain.body?.error || ''), 'rejected by the blind namespace policy: ' + rejectedPlain.body?.error)

  const forbiddenField = signRecord(alpha, { id: 'm3', _ns: NS_ALPHA, body: sealedBody, dataKey: 'deadbeef' }, 'message')
  const rejectedField = await postJson(apiPort, '/api/sync/append', { appId: alpha.publicKeyHex, op: { type: 'message', data: forbiddenField } }, auth)
  t.is(rejectedField.status, 400, 'BLIND_FORBIDDEN_FIELDS marker hard-rejected even with a sealed body')

  const ghosted = await getJson(apiPort, `/api/sync/get?appId=${alpha.publicKeyHex}&key=${encodeURIComponent('message!m2')}`, auth)
  t.is(ghosted.body, null, 'rejected record never entered the outbox')

  // ─── 3. Operator opaque-id takedown — tombstone without exposure ──────
  const wrongAuth = await postJson(apiPort, '/api/admin/takedown', { appId: alpha.publicKeyHex, key: 'message!m1' }, auth)
  t.is(wrongAuth.status, 401, 'browser token never reaches the admin surface')

  const dropped = await postJson(apiPort, '/api/admin/takedown', { appId: alpha.publicKeyHex, key: 'message!m1' }, { 'X-Pear-Admin-Token': ADMIN_KEY })
  t.is(dropped.status, 200)
  t.alike(dropped.body, { appId: alpha.publicKeyHex, key: 'message!m1', suppressed: true }, 'opaque-id takedown applied')

  const tombstoned = await getJson(apiPort, `/api/sync/get?appId=${alpha.publicKeyHex}&key=${encodeURIComponent('message!m1')}`, auth)
  t.is(tombstoned.body, null, 'subsequent reads serve the tombstone (null), not the content')

  const audit = await getJson(apiPort, '/api/admin/takedowns', { 'X-Pear-Admin-Token': ADMIN_KEY })
  t.alike(audit.body, { takedowns: [{ appId: alpha.publicKeyHex, key: 'message!m1' }], count: 1 },
    'audit surface lists opaque ids only')
  t.absent(JSON.stringify(audit.body).includes('alpha room secret'), 'audit surface exposes no content')

  // The row itself is untouched in storage — suppression is serve-time only.
  const engine = relay.serviceRegistry.services.get('outboxlog').provider.engine
  t.ok(engine.isSuppressed(alpha.publicKeyHex, 'message!m1'), 'engine marks the opaque id suppressed')
  const storedRows = engine.snapshot().groups.find(([id]) => id === alpha.publicKeyHex)[1].rows
  t.ok(storedRows.some(([key]) => key === 'message!m1'), 'record remains in storage after takedown')

  const restored = await postJson(apiPort, '/api/admin/restore', { appId: alpha.publicKeyHex, key: 'message!m1' }, { 'X-Pear-Admin-Token': ADMIN_KEY })
  t.alike(restored.body, { appId: alpha.publicKeyHex, key: 'message!m1', suppressed: false })
  const servedAgain = await getJson(apiPort, `/api/sync/get?appId=${alpha.publicKeyHex}&key=${encodeURIComponent('message!m1')}`, auth)
  t.ok(isOutboxBlindRecord(servedAgain.body), 'restore reverses the tombstone; sealed body serves again')

  // ─── 4. Cross-namespace replay is rejected (namespace ∈ signature domain)
  // Replay the exact signed alpha record as appbeta: the signature no longer
  // verifies — the namespace is inside the signed payload.
  const replayed = { ...record, _ns: NS_BETA }
  const replay = await postJson(apiPort, '/api/sync/append', { appId: alpha.publicKeyHex, op: { type: 'message', data: replayed } }, auth)
  t.is(replay.status, 400, 'cross-namespace replay rejected over the wire')
  t.ok(/bad signature/.test(replay.body?.error || ''), 'rejected at the signature check: ' + replay.body?.error)
  t.absent(
    verifyOutboxRecordSignature({ appId: alpha.publicKeyHex, type: 'message', data: replayed }, { namespaces: NAMESPACES }),
    'direct evidence: the alpha signature does not verify under appbeta'
  )

  // And the intact alpha record replayed into the beta-bound outbox is refused too.
  const crossOutbox = await postJson(apiPort, '/api/sync/append', { appId: beta.publicKeyHex, op: { type: 'message', data: record } }, auth)
  t.is(crossOutbox.status, 400, 'alpha record cannot land in the beta-bound outbox')
  const betaOutbox = await getJson(apiPort, `/api/sync/get?appId=${beta.publicKeyHex}&key=${encodeURIComponent('message!m1')}`, auth)
  t.is(betaOutbox.body, null, 'beta outbox never gained the replayed record')
})

// ─── MARKED GAP (GIGA DoD §7 "Cross-feature journeys" — namespace rows) ──
//
// RelayNode + PluginLoader + outboxlog with the operator-required bounded
// persistence backend (`outboxlog.journal: 'hypercore'`) does NOT boot on a
// fresh disk store with the vendored hypercore 11.34.1 / corestore 7.11.1:
//
//   1. createHypercoreOutboxJournal probes the named journal core via
//      `store.get({ name, createIfMissing: false })`. On a fresh store that
//      open fails STORAGE_EMPTY — and the failed core is never evicted from
//      corestore's tracker, so the follow-up `store.get({ name })` creation
//      resumes the same poisoned core and fails STORAGE_EMPTY again
//      (reproduced standalone: probe → create on an empty Corestore).
//   2. Even with the journal core pre-created, JournalStorageController
//      .measureExact() throws "OutboxLog exact core storage info
//      unavailable" because `core.info({ storage: true }).storage` is null
//      with the vendored hypercore.
//
// Consequence: an operator enabling `plugins: ['outboxlog']` with the
// bounded-persistence journal (the ONLY persistence shape RelayNode's
// storage-admission gate accepts) gets SERVICE_START_FAILED on first boot.
// The journeys above therefore register the real OutboxLogApp with
// in-memory persistence (its documented constructor option); the
// namespace/blind/takedown semantics under test are independent of the
// durability backend.
//
// This skipped test encodes the expected composition once the journal's
// probe/measure path is repaired — unskip it as the fix verification.
test.skip('gap: RelayNode boots outboxlog with hypercore journal on a fresh store (GIGA DoD §7)', async (t) => {
  const dir = tmpdir(t)
  const relayStorage = path.join(dir, 'relay')
  fs.mkdirSync(relayStorage, { recursive: true })
  const testnet = await createTestnet(3)
  const relay = new RelayNode({
    storage: relayStorage,
    bootstrapNodes: testnet.bootstrap,
    enableAPI: false,
    enableRelay: false,
    enableSeeding: true,
    enableServices: true,
    plugins: ['outboxlog'],
    enableNetworkDiscovery: false,
    enableHolesail: false,
    outboxlog: {
      journal: 'hypercore',
      maxJournalStorageBytes: 16 * 1024 * 1024,
      namespaces: NAMESPACES
    }
  })
  t.teardown(async () => {
    try { await relay.stop() } catch {}
    try { await testnet.destroy() } catch {}
  })
  await relay.start()
  t.is(relay.serviceRegistry.services.get('outboxlog')?.status, 'running',
    'outboxlog boots with the bounded hypercore journal on a fresh store')
})