OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / custody-share-bundle.test.js

556 lines · javascript ref:
// WS-B: P2P share-bundle delivery + relay-side verification.
//
// The blind-custody invariant is that a relay holds an OPAQUE encrypted PVSS
// share it can publicly verify but never open. The publisher writes the public
// share bundle ({ commitments[], encryptedShares[] }) to a sibling hypercore and
// names that core's key in the SIGNED v2 custody intent (shareBundleKey). A
// custodying relay replicates the bundle and — with no secret key — checks the
// specific encrypted share the dealer assigned it before anchoring a receipt.
//
// Two layers are pinned here:
//   1. verifyShareBundleForRelay (pvss.js) — the pure decision: is THIS relay
//      assigned a share, does the replicated bundle match the publisher-signed
//      commitmentRoot, and does the assigned encrypted share verify? Each
//      failure has a distinct, defensive `reason`.
//   2. AppLifecycle._recordCustodyReceipt — the SD2 policy: a failed OR
//      unavailable verification must NOT anchor and must emit
//      `custody:share-verify-failed`. A success assembles a v2 receipt whose
//      PVSS fields bind to the signed intent (proven here by running the REAL
//      createCustodyReceipt + validateCustodyTransition inside a fake registry).
//
// _readShareBundle (the network I/O) is stubbed: it is best-effort, non-throwing
// glue whose only contract is "returns the parsed bundle or null", and a null
// from it is exercised as the unavailable-bundle path.

import test from 'brittle'
import b4a from 'b4a'
import Corestore from 'corestore'
import sodium from 'sodium-universal'
import { EventEmitter } from 'events'
import { access, mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { keygen, split } from 'p2p-hiverelay-client/secret-sharing.js'
import {
  createCustodyIntent,
  createCustodyReceipt,
  validateCustodyTransition,
  hashHex
} from 'p2p-hiverelay/core/custody-signing.js'
import { verifyShareBundleForRelay, shareCommitmentAt } from 'p2p-hiverelay/core/pvss.js'
import { AppLifecycle } from 'p2p-hiverelay/core/relay-node/app-lifecycle.js'
import { measureStorageTreeBytes } from 'p2p-hiverelay/config/storage-cap.js'
import {
  STORAGE_DRIVE_AUXILIARY_ALLOWANCE_BYTES,
  STORAGE_SHARE_BUNDLE_MAX_BYTES
} from 'p2p-hiverelay/config/storage-admission-authority.js'

// Relays sign custody entries with sodium ed25519 keys — a SEPARATE identity
// from the secp256k1 shareholder keys the shares are encrypted to.
function keyPair () {
  const publicKey = b4a.alloc(sodium.crypto_sign_PUBLICKEYBYTES)
  const secretKey = b4a.alloc(sodium.crypto_sign_SECRETKEYBYTES)
  sodium.crypto_sign_keypair(publicKey, secretKey)
  return { publicKey, secretKey }
}

async function shareholders (n) {
  const keys = []
  for (let i = 0; i < n; i++) keys.push(await keygen())
  return keys
}

// relays[i] (an ed25519 signing identity) is assigned share i+1.
function assignmentsFor (relays) {
  return relays.map((r, i) => ({ relayPubkey: b4a.toString(r.publicKey, 'hex'), shareIndex: i + 1 }))
}

// Build a signed v2 PVSS intent + its public share bundle + n relay identities.
async function setup ({ n = 3, threshold = 2, now = Date.now() } = {}) {
  const holders = await shareholders(n)
  const res = await split({ threshold, shareholders: holders.map(k => k.publicKey) })
  const publisher = keyPair()
  const relays = Array.from({ length: n }, () => keyPair())
  const intent = createCustodyIntent({
    version: 2,
    blindContentId: hashHex('sb-blind'),
    ciphertextRoot: hashHex('sb-cipher'),
    contentVersion: 1,
    requiredReplicas: n,
    deadline: now + 60_000,
    retainUntil: now + 120_000,
    shareScheme: 'pvss-secp256k1-v1',
    shareThreshold: threshold,
    commitmentRoot: res.public.commitmentRoot,
    shareBundleKey: hashHex('sb-bundle-key'),
    shareAssignments: assignmentsFor(relays)
  }, publisher, { timestamp: now })
  return { res, intent, publisher, relays, now }
}

// A fake RelayNode just rich enough for AppLifecycle._recordCustodyReceipt.
// getCustodyIntent serves the signed intent; recordCustodyReceipt runs the REAL
// signer + transition check so the v2 fields the lifecycle assembled are proven
// to produce a receipt that actually binds to the intent (not just shaped right).
function makeNode ({ keyPair: kp, intent, now }) {
  const calls = []
  const node = {
    swarm: { keyPair: kp },
    store: {},
    config: { region: 'sb-region' },
    seedingRegistry: {
      getCustodyIntent (id) {
        return intent && id === intent.intentId ? intent : null
      },
      async recordCustodyReceipt (fields, relayKeyPair) {
        const receipt = createCustodyReceipt(fields, relayKeyPair, { timestamp: now + 1000 })
        const transition = validateCustodyTransition(receipt, { intent })
        calls.push({ fields, receipt, transition })
        if (!transition.valid) throw new Error('transition-invalid:' + transition.reason)
        return receipt
      }
    }
  }
  return { node, calls }
}

function captureEvents (emitter) {
  const events = []
  for (const name of ['custody-receipt', 'custody:share-verify-failed', 'custody-receipt-error']) {
    emitter.on(name, (payload) => events.push({ name, payload }))
  }
  return events
}

function optsFor (intent) {
  return {
    blind: true,
    custodyIntentId: intent.intentId,
    blindContentId: intent.blindContentId,
    ciphertextRoot: intent.ciphertextRoot,
    contentVersion: intent.contentVersion,
    retainUntil: intent.retainUntil,
    shardIds: [0]
  }
}

// ─── verifyShareBundleForRelay (pure decision) ──────────────────────

test('verifyShareBundleForRelay: happy path returns the receipt PVSS fields', async (t) => {
  const { res, intent, relays } = await setup()
  const relayPubkey = b4a.toString(relays[0].publicKey, 'hex')
  const out = verifyShareBundleForRelay(intent, res.public, relayPubkey)
  t.ok(out.ok, 'verifies the assigned encrypted share')
  t.is(out.shareScheme, 'pvss-secp256k1-v1')
  t.is(out.commitmentRoot, intent.commitmentRoot)
  t.is(out.shareIndex, 1, 'relays[0] is assigned shareIndex 1')
  t.is(out.shareCommitment, shareCommitmentAt(res.public.commitments, 1), 'shareCommitment is X_1')
})

test('verifyShareBundleForRelay: rejects a non-PVSS or null intent', async (t) => {
  const { res, relays } = await setup()
  const relayPubkey = b4a.toString(relays[0].publicKey, 'hex')
  t.is(verifyShareBundleForRelay(null, res.public, relayPubkey).reason, 'intent-not-pvss')
  t.is(verifyShareBundleForRelay({}, res.public, relayPubkey).reason, 'intent-not-pvss')
})

test('verifyShareBundleForRelay: rejects a PVSS intent with no assignments', async (t) => {
  const { res, intent, relays } = await setup()
  const relayPubkey = b4a.toString(relays[0].publicKey, 'hex')
  const bare = { shareScheme: intent.shareScheme, commitmentRoot: intent.commitmentRoot }
  t.is(verifyShareBundleForRelay(bare, res.public, relayPubkey).reason, 'intent-missing-assignments')
})

test('verifyShareBundleForRelay: rejects a relay absent from the assignment map', async (t) => {
  const { res, intent } = await setup()
  const stranger = b4a.toString(keyPair().publicKey, 'hex')
  t.is(verifyShareBundleForRelay(intent, res.public, stranger).reason, 'relay-not-assigned')
})

test('verifyShareBundleForRelay: rejects a malformed bundle', async (t) => {
  const { res, intent, relays } = await setup()
  const relayPubkey = b4a.toString(relays[0].publicKey, 'hex')
  t.is(verifyShareBundleForRelay(intent, null, relayPubkey).reason, 'bundle-malformed', 'null bundle')
  t.is(verifyShareBundleForRelay(intent, { encryptedShares: res.public.encryptedShares }, relayPubkey).reason, 'bundle-malformed', 'missing commitments')
  t.is(verifyShareBundleForRelay(intent, { commitments: res.public.commitments }, relayPubkey).reason, 'bundle-malformed', 'missing encryptedShares')
})

test('verifyShareBundleForRelay: rejects a bundle whose commitments do not match the signed root', async (t) => {
  const { intent, relays } = await setup()
  // An independent split has a different commitment vector → a different root,
  // so a swapped-but-internally-consistent bundle cannot pass.
  const holders = await shareholders(3)
  const other = await split({ threshold: 2, shareholders: holders.map(k => k.publicKey) })
  const relayPubkey = b4a.toString(relays[0].publicKey, 'hex')
  t.is(verifyShareBundleForRelay(intent, other.public, relayPubkey).reason, 'commitmentRoot-mismatch')
})

test('verifyShareBundleForRelay: rejects when the assigned share is absent', async (t) => {
  const { res, intent, relays } = await setup()
  const relayPubkey = b4a.toString(relays[0].publicKey, 'hex')
  const bundle = { commitments: res.public.commitments, encryptedShares: res.public.encryptedShares.filter(s => s.index !== 1) }
  t.is(verifyShareBundleForRelay(intent, bundle, relayPubkey).reason, 'share-missing')
})

test('verifyShareBundleForRelay: rejects a tampered encrypted share', async (t) => {
  const { res, intent, relays } = await setup()
  const relayPubkey = b4a.toString(relays[0].publicKey, 'hex')
  const bundle = JSON.parse(JSON.stringify(res.public))
  const s1 = bundle.encryptedShares.find(s => s.index === 1)
  const s2 = bundle.encryptedShares.find(s => s.index === 2)
  s1.encryptedShare = s2.encryptedShare // breaks the DLEQ binding for index 1
  t.is(verifyShareBundleForRelay(intent, bundle, relayPubkey).reason, 'share-verify-failed')
})

// ─── AppLifecycle._recordCustodyReceipt (SD2 policy) ────────────────

test('_recordCustodyReceipt: anchors a v2 receipt when the assigned share verifies', async (t) => {
  const { res, intent, relays, now } = await setup()
  const { node, calls } = makeNode({ keyPair: relays[0], intent, now })
  const lifecycle = new AppLifecycle(node)
  lifecycle._readShareBundle = async () => res.public
  const events = captureEvents(lifecycle)

  const receipt = await lifecycle._recordCustodyReceipt(hashHex('app'), optsFor(intent))

  t.ok(receipt, 'returns an anchored receipt')
  t.is(calls.length, 1, 'recorded exactly one receipt')
  const f = calls[0].fields
  t.is(f.version, 2, 'receipt is v2')
  t.is(f.shareScheme, 'pvss-secp256k1-v1')
  t.is(f.commitmentRoot, intent.commitmentRoot)
  t.is(f.shareIndex, 1, 'relays[0] custodies share 1')
  t.is(f.shareCommitment, shareCommitmentAt(res.public.commitments, 1))
  t.is(f.shareVerified, true)
  t.ok(calls[0].transition.valid, 'assembled receipt binds to the signed intent')
  t.ok(events.find(e => e.name === 'custody-receipt'), 'emitted custody-receipt')
  t.absent(events.find(e => e.name === 'custody:share-verify-failed'), 'no failure event on success')
})

test('_recordCustodyReceipt: does not anchor when the bundle is unavailable (SD2)', async (t) => {
  const { intent, relays, now } = await setup()
  const { node, calls } = makeNode({ keyPair: relays[0], intent, now })
  const lifecycle = new AppLifecycle(node)
  lifecycle._readShareBundle = async () => null // peer never supplied block 0
  const events = captureEvents(lifecycle)

  const receipt = await lifecycle._recordCustodyReceipt(hashHex('app'), optsFor(intent))

  t.is(receipt, null, 'no receipt')
  t.is(calls.length, 0, 'never reached recordCustodyReceipt')
  const fail = events.find(e => e.name === 'custody:share-verify-failed')
  t.ok(fail, 'emitted share-verify-failed')
  t.is(fail.payload.reason, 'bundle-malformed', 'a null bundle reads as malformed')
  t.is(fail.payload.shareBundleKey, intent.shareBundleKey, 'echoes the bundle key from the signed intent')
})

test('_recordCustodyReceipt: does not anchor a tampered share (SD2)', async (t) => {
  const { res, intent, relays, now } = await setup()
  const { node, calls } = makeNode({ keyPair: relays[0], intent, now })
  const lifecycle = new AppLifecycle(node)
  const bundle = JSON.parse(JSON.stringify(res.public))
  const s1 = bundle.encryptedShares.find(s => s.index === 1)
  const s2 = bundle.encryptedShares.find(s => s.index === 2)
  s1.encryptedShare = s2.encryptedShare
  lifecycle._readShareBundle = async () => bundle
  const events = captureEvents(lifecycle)

  const receipt = await lifecycle._recordCustodyReceipt(hashHex('app'), optsFor(intent))

  t.is(receipt, null)
  t.is(calls.length, 0)
  t.is(events.find(e => e.name === 'custody:share-verify-failed').payload.reason, 'share-verify-failed')
})

test('_recordCustodyReceipt: a relay not assigned a share does not anchor', async (t) => {
  const { res, intent, now } = await setup()
  const stranger = keyPair() // ed25519 identity absent from shareAssignments
  const { node, calls } = makeNode({ keyPair: stranger, intent, now })
  const lifecycle = new AppLifecycle(node)
  lifecycle._readShareBundle = async () => res.public
  const events = captureEvents(lifecycle)

  const receipt = await lifecycle._recordCustodyReceipt(hashHex('app'), optsFor(intent))

  t.is(receipt, null)
  t.is(calls.length, 0)
  t.is(events.find(e => e.name === 'custody:share-verify-failed').payload.reason, 'relay-not-assigned')
})

test('_recordCustodyReceipt: a PVSS-hinted seed with no loaded intent fails closed', async (t) => {
  const { now } = await setup()
  const stranger = keyPair()
  const { node, calls } = makeNode({ keyPair: stranger, intent: null, now })
  const lifecycle = new AppLifecycle(node)
  let read = false
  lifecycle._readShareBundle = async () => { read = true; return null }
  const events = captureEvents(lifecycle)

  // Publisher hint says PVSS, but the signed intent is not loaded yet: decline
  // rather than anchor a plain receipt the transition check would later reject.
  const opts = {
    blind: true,
    custodyIntentId: hashHex('absent-intent'),
    blindContentId: hashHex('h-blind'),
    ciphertextRoot: hashHex('h-cipher'),
    contentVersion: 1,
    retainUntil: now + 120_000,
    shareScheme: 'pvss-secp256k1-v1'
  }
  const receipt = await lifecycle._recordCustodyReceipt(hashHex('app'), opts)

  t.is(receipt, null)
  t.is(calls.length, 0)
  t.absent(read, 'never tries to read a bundle without an authentic intent')
  t.is(events.find(e => e.name === 'custody:share-verify-failed').payload.reason, 'intent-unavailable')
})

test('_recordCustodyReceipt: a plain (non-PVSS) intent anchors a v1 receipt unchanged', async (t) => {
  const now = Date.now()
  const publisher = keyPair()
  const intent = createCustodyIntent({
    blindContentId: hashHex('p-blind'),
    ciphertextRoot: hashHex('p-cipher'),
    contentVersion: 1,
    requiredReplicas: 3,
    deadline: now + 60_000,
    retainUntil: now + 120_000
  }, publisher, { timestamp: now })
  const relay = keyPair()
  const { node, calls } = makeNode({ keyPair: relay, intent, now })
  const lifecycle = new AppLifecycle(node)
  let read = false
  lifecycle._readShareBundle = async () => { read = true; return null }
  const events = captureEvents(lifecycle)

  const receipt = await lifecycle._recordCustodyReceipt(hashHex('app'), optsFor(intent))

  t.ok(receipt, 'anchors a plain receipt')
  t.is(calls.length, 1)
  t.absent(read, 'no share-bundle read for plain custody')
  t.absent(calls[0].fields.version, 'no v2 share fields assembled')
  t.absent(calls[0].fields.shareScheme)
  t.is(receipt.version, 1, 'receipt defaults to v1')
  t.ok(events.find(e => e.name === 'custody-receipt'))
})

test('_readShareBundle: oversized signed core proof is rejected before any body range materializes', async (t) => {
  let downloads = 0
  let gets = 0
  let leaves = 0
  const core = {
    key: b4a.alloc(32, 1),
    discoveryKey: b4a.alloc(32, 2),
    length: 1,
    byteLength: STORAGE_SHARE_BUNDLE_MAX_BYTES + 1,
    async ready () {},
    async update () { return true },
    download () { downloads++; throw new Error('must not download oversized bundle') },
    async get () { gets++; return null },
    async close () {}
  }
  const node = {
    swarm: {
      join: () => ({ async flushed () {}, async destroy () { leaves++ } }),
      async leave () {},
      on () {},
      removeListener () {}
    },
    storageAdmission: {
      mutationAdmission: () => ({ allowed: true }),
      canAcknowledge: () => true,
      runMutation: run => Promise.resolve().then(run)
    }
  }
  const lifecycle = new AppLifecycle(node)
  const result = await lifecycle._readShareBundle('f'.repeat(64), {
    appKey: 'a'.repeat(64),
    timeoutMs: 100,
    createAuxStore: async () => ({
      async ready () {},
      get: () => core,
      replicate () {},
      async close () {}
    })
  })
  t.is(result, null)
  t.is(downloads, 0)
  t.is(gets, 0)
  t.is(leaves, 1)
})

test('_readShareBundle: fork swap between proof and snapshot cannot authorize a body read', async (t) => {
  let downloads = 0
  let gets = 0
  const snapshotCore = {
    fork: 1,
    length: 1,
    byteLength: 16,
    async ready () {},
    download () { downloads++; return { async done () {}, destroy () {} } },
    async get () { gets++; return b4a.from('{}') },
    async close () {}
  }
  const core = {
    key: b4a.alloc(32, 3),
    discoveryKey: b4a.alloc(32, 4),
    fork: 0,
    length: 1,
    byteLength: 16,
    async ready () {},
    async update () { return true },
    snapshot () { return snapshotCore },
    async close () {}
  }
  const node = {
    swarm: {
      join: () => ({ async flushed () {} }),
      async leave () {},
      on () {},
      removeListener () {}
    },
    storageAdmission: {
      mutationAdmission: () => ({ allowed: true }),
      canAcknowledge: () => true
    }
  }
  const lifecycle = new AppLifecycle(node)
  const result = await lifecycle._readShareBundle('e'.repeat(64), {
    appKey: 'f'.repeat(64),
    timeoutMs: 100,
    createAuxStore: async () => ({
      async ready () {},
      get: () => core,
      replicate () {},
      async close () {}
    })
  })
  t.is(result, null)
  t.is(downloads, 0, 'mismatched fork is rejected before finite range creation')
  t.is(gets, 0, 'mismatched fork is never read')
})

test('share-bundle auxiliary allowance covers a worst-case one-block Corestore slot', async (t) => {
  const storage = await mkdtemp(join(tmpdir(), 'share-bundle-footprint-'))
  t.teardown(() => rm(storage, { recursive: true, force: true }))
  const store = new Corestore(storage)
  await store.ready()
  const core = store.get({ name: 'bundle' })
  await core.ready()
  await core.append(b4a.alloc(STORAGE_SHARE_BUNDLE_MAX_BYTES))
  await core.close()
  await store.close()
  const bytes = measureStorageTreeBytes(storage)
  t.ok(bytes <= STORAGE_DRIVE_AUXILIARY_ALLOWANCE_BYTES, `${bytes} bytes fit in the per-drive auxiliary allowance`)
})

test('_readShareBundle: retries and replaced keys leave no persistent auxiliary core slot', async (t) => {
  const storage = await mkdtemp(join(tmpdir(), 'share-bundle-ephemeral-'))
  t.teardown(() => rm(storage, { recursive: true, force: true }))
  const swarm = new EventEmitter()
  swarm.join = () => ({ async flushed () {} })
  swarm.leave = async () => {}
  const appKey = 'b'.repeat(64)
  const node = {
    config: { storage },
    connections: new Map(),
    swarm,
    storageAdmission: {
      mutationAdmission: () => ({ allowed: true }),
      canAcknowledge: () => true,
      runKeyMutation: (_key, run) => Promise.resolve().then(run)
    }
  }
  const lifecycle = new AppLifecycle(node)
  for (const bundleKey of ['c'.repeat(64), 'd'.repeat(64)]) {
    t.is(await lifecycle._readShareBundle(bundleKey, { appKey, timeoutMs: 25 }), null)
    await t.exception(
      access(join(storage, '.aux-share-bundles', appKey)),
      /ENOENT/,
      'isolated slot is removed after each attempt'
    )
  }
})

test('_readShareBundle: active auxiliary fetch drains before concurrent unseed releases drive debt', async (t) => {
  const appKey = '7'.repeat(64)
  const events = []
  const tails = new Map()
  let committed = true
  let resolveProof
  let proofStarted
  const proofGate = new Promise(resolve => { resolveProof = resolve })
  const started = new Promise(resolve => { proofStarted = resolve })
  const snapshot = {
    fork: 0,
    length: 1,
    byteLength: 2,
    async ready () {},
    download () { return { async done () {}, destroy () {} } },
    async get () { return b4a.from('{}') },
    async close () { events.push('aux-snapshot-close') }
  }
  const core = {
    key: b4a.alloc(32, 5),
    discoveryKey: b4a.alloc(32, 6),
    fork: 0,
    length: 1,
    byteLength: 2,
    async ready () {},
    async update () { events.push('aux-proof-start'); proofStarted(); await proofGate; return true },
    snapshot () { return snapshot },
    async close () { events.push('aux-core-close') }
  }
  const storageAdmission = {
    mutationAdmission: () => ({ allowed: true }),
    canAcknowledge: () => committed,
    runKeyMutation (key, run) {
      const operation = (tails.get(key) || Promise.resolve()).catch(() => {}).then(run)
      const tail = operation.catch(() => {})
      tails.set(key, tail)
      tail.finally(() => { if (tails.get(key) === tail) tails.delete(key) })
      return operation
    },
    release () { events.push('authority-release'); committed = false; return true }
  }
  const entry = {
    drive: { async close () { events.push('drive-close') } },
    discoveryKey: b4a.alloc(32),
    downloadRanges: [{ destroy () { events.push('range-destroy') } }],
    downloadSnapshotCores: [{ async close () {} }, { async close () {} }]
  }
  const apps = new Map([[appKey, entry]])
  const appRegistry = {
    get: key => apps.get(key),
    has: key => apps.has(key),
    delete (key) { events.push('registry-retire'); return apps.delete(key) },
    set: (key, value) => apps.set(key, value),
    async persistDelete () { events.push('registry-delete-durable') }
  }
  const swarm = new EventEmitter()
  swarm.join = () => ({ async flushed () {} })
  swarm.leave = async () => { events.push('swarm-leave') }
  const node = { storageAdmission, appRegistry, swarm, connections: new Map() }
  const lifecycle = new AppLifecycle(node)
  const fetch = lifecycle._readShareBundle('8'.repeat(64), {
    appKey,
    timeoutMs: 1000,
    createAuxStore: async () => ({
      async ready () {},
      get: () => core,
      replicate () {},
      async close () { events.push('aux-store-close') }
    })
  })
  await started
  const unseed = lifecycle.unseedApp(appKey)
  await new Promise(resolve => setImmediate(resolve))
  t.absent(events.includes('registry-retire'), 'unseed queues behind the active auxiliary proof')
  resolveProof()
  t.alike(await fetch, {})
  await unseed
  t.ok(events.indexOf('aux-store-close') < events.indexOf('registry-retire'))
  t.ok(events.indexOf('range-destroy') < events.indexOf('authority-release'))
  t.ok(events.indexOf('drive-close') < events.indexOf('authority-release'))
})