OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / scripts / blind-dispersal-live.mjs

130 lines · javascript ref:
#!/usr/bin/env node
/**
 * Live blind-shard dispersal across a REAL relay set.
 *
 * Splits a secret k-of-n, publishes a signed custody intent to each relay, PUTs
 * one opaque shard to each over /api/v1/shard, then reconstructs from any k — end
 * to end over the network. This is the fleet run of test/integration/
 * blind-custody-intent-e2e.test.js (same code path, real URLs).
 *
 *   node scripts/blind-dispersal-live.mjs <config.json>
 *
 * The config is YOURS and is never embedded here (keep it out of git):
 *   {
 *     "threshold": 3,                     // k
 *     "secret": "<optional 64-hex>",      // random if omitted
 *     "publisherSeed": "<optional 64-hex>",
 *     "relays": [                         // n custodians, in share-index order
 *       { "baseUrl": "https://relay-a:9100", "pubkey": "<relay node pubkey hex>", "apiKey": "<relay admin key>" },
 *       ...
 *     ]
 *   }
 *
 * PREREQS per target relay: running a build with the /api/v1/shard mount
 * (>= the v0.24.0 line), the `shard-store` service ENABLED, reachable over HTTPS.
 *
 * HONEST NOTE: dispersing across boxes ONE ENTITY controls proves the MECHANISM
 * live; it does NOT yet provide the security property (no single operator can
 * reconstruct) — that needs independent operators. This tool makes the mechanism
 * demonstrable; independence makes the guarantee real.
 */
import fs from 'node:fs'
import b4a from 'b4a'
import sodium from 'sodium-universal'
import { planDispersal, recoverSecret, shardAddressOf } from '../packages/client/blind-shards.js'
import { createHttpShardPut, createHttpShardFetch } from '../packages/client/shard-transport.js'
import { signShardPin, normalizeShardAddress } from '../packages/services/builtin/shard-store/index.js'
import { createCustodyIntent } from '../packages/core/core/custody-signing.js'

const cfgPath = process.argv[2]
if (!cfgPath) { console.error('usage: node scripts/blind-dispersal-live.mjs <config.json>'); process.exit(2) }
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'))
const relays = Array.isArray(cfg.relays) ? cfg.relays : []
const k = cfg.threshold
if (relays.length < 2 || !Number.isInteger(k) || k < 1 || k > relays.length) {
  console.error('config needs >=2 relays and 1 <= threshold <= relays.length'); process.exit(2)
}
for (const r of relays) {
  if (!r.baseUrl || !/^[0-9a-f]{64}$/i.test(r.pubkey || '')) {
    console.error('each relay needs { baseUrl, pubkey(64-hex), apiKey }'); process.exit(2)
  }
}

function keyPairFromSeed (seed) {
  const publicKey = b4a.alloc(32); const secretKey = b4a.alloc(64)
  sodium.crypto_sign_seed_keypair(publicKey, secretKey, seed)
  return { publicKey, secretKey, hex: b4a.toString(publicKey, 'hex') }
}
const seed = cfg.publisherSeed ? b4a.from(cfg.publisherSeed, 'hex') : (() => { const s = b4a.alloc(32); sodium.randombytes_buf(s); return s })()
const publisher = keyPairFromSeed(seed)
const H = (c) => c.repeat(64)
const nonce = () => { const b = b4a.alloc(16); sodium.randombytes_buf(b); return b4a.toString(b, 'hex') }
const RETAIN = Date.now() + 30 * 24 * 60 * 60 * 1000

async function publishIntent (baseUrl, apiKey, intent) {
  const res = await fetch(baseUrl.replace(/\/+$/, '') + '/api/custody/intent', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', ...(apiKey ? { Authorization: 'Bearer ' + apiKey } : {}) },
    body: JSON.stringify(intent)
  })
  if (!res.ok) throw new Error('publish intent -> ' + baseUrl + ' failed: ' + res.status + ' ' + (await res.text()).slice(0, 120))
}

async function main () {
  const n = relays.length
  console.log(`\n▶ dispersing a secret ${k}-of-${n} across ${n} relays\n`)

  // 1. plan (no storage yet) — learn every shard address up front
  const plan = await planDispersal({ count: n, threshold: k, secret: cfg.secret })
  const shareAssignments = plan.shares.map((s) => ({ relayPubkey: relays[s.shareIndex - 1].pubkey.toLowerCase(), shareIndex: s.shareIndex }))
  const shareManifest = plan.shares.map((s) => ({ shareIndex: s.shareIndex, shard: s.shard, shareCommitment: s.shareCommitment }))

  // 2. one signed custody intent binding relay -> shareIndex -> shard
  const intent = createCustodyIntent({
    version: 2,
    blindContentId: H('b'),
    ciphertextRoot: plan.commitmentRoot,
    contentVersion: 1,
    requiredReplicas: n,
    shareScheme: 'pvss-secp256k1-v1',
    shareThreshold: k,
    commitmentRoot: plan.commitmentRoot,
    shareBundleKey: shardAddressOf(b4a.from(plan.commitmentRoot, 'hex')).slice('shard:'.length),
    shareAssignments,
    shareManifest,
    retainUntil: RETAIN
  }, publisher)
  console.log(`  custody intent ${intent.intentId}`)

  // 3. publish to every relay so its PUT authorizes
  for (const r of relays) { await publishIntent(r.baseUrl, r.apiKey, intent); console.log(`  ✓ intent published → ${r.baseUrl}`) }

  // 4. PUT each shard to its assigned relay
  for (const s of plan.shares) {
    const r = relays[s.shareIndex - 1]
    const put = createHttpShardPut({
      baseUrl: r.baseUrl,
      signPin: ({ hash }) => signShardPin({ reason: 'custody', hash: normalizeShardAddress(hash), custodyIntentId: intent.intentId, shareIndex: s.shareIndex, retainUntil: RETAIN, nonce: nonce() }, publisher)
    })
    const addr = await put(s.bytes, { shareIndex: s.shareIndex })
    console.log(`  ✓ share ${s.shareIndex} → ${r.baseUrl}  ${addr}`)
  }

  // 5. reconstruct from the FIRST k relays only (prove any-k works)
  const readSet = relays.slice(0, k)
  const rec = await recoverSecret({ shareManifest, threshold: k, fetch: createHttpShardFetch({ baseUrls: readSet.map((r) => r.baseUrl) }) })
  console.log(`\n  reader used ${readSet.length} relays → ${rec.ok ? 'reconstructed' : 'FAILED: ' + rec.reason}`)
  const ok = rec.ok && rec.key === plan.key
  console.log(`  key matches the dealer's secret: ${ok ? 'YES' : 'NO'}`)

  // 6. sanity: k-1 relays must NOT reconstruct
  const under = await recoverSecret({ shareManifest, threshold: k, fetch: createHttpShardFetch({ baseUrls: relays.slice(0, k - 1).map((r) => r.baseUrl) }) })
  console.log(`  ${k - 1} relays (below threshold) reconstruct: ${under.ok ? 'YES ✗ (BUG)' : 'no ✓'}`)

  console.log(`\n${ok && !under.ok ? '✅ LIVE DISPERSAL OK' : '❌ CHECK FAILED'} — mechanism proven across ${n} relays.`)
  console.log('   (Reminder: same-operator relays prove the mechanism, not the security property — that needs independent operators.)\n')
  process.exit(ok && !under.ok ? 0 : 1)
}

main().catch((e) => { console.error('\n✗ ' + (e && e.message)); process.exit(1) })