OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / client / blind-custody.js

121 lines · javascript ref:
/**
 * Blind-custody dispersal — the app-facing one-call orchestration over a relay
 * set. It ties together the pieces proven separately:
 *
 *   planDispersal (blind-shards)  — split a secret into n opaque shards
 *   createHttpShardPut/Fetch (shard-transport) — move them over /api/v1/shard
 *   the custody intent            — the signed binding a relay authorizes against
 *
 * `disperse()` plans the shards, assigns share i to relay i (so no operator holds
 * >= threshold), lets the app PUBLISH the signed custody intent (relays authorize
 * PUTs against it) and SIGN each custody pin, then PUTs every shard to its relay.
 * `recover()` gathers >= threshold shards from the relay set and reconstructs the
 * secret at the reader's edge.
 *
 * SIGNING + PUBLISHING ARE INJECTED, not done here: the publisher key and each
 * relay's write credential belong to the app, and the custody-pin signature must
 * stay byte-identical to the relay's verifier. This module owns the ORCHESTRATION
 * (planning, relay assignment, routing, reconstruction) — never the app's keys.
 *
 * The returned `shareAssignments` + `shareManifest` + `commitmentRoot` are exactly
 * the fields a v2 custody intent binds, so the app can build/sign the intent with
 * custody.js `createCustodyIntent` and hand it to `publishIntent`.
 */
import { planDispersal, recoverSecret } from './blind-shards.js'
import { createHttpShardPut, createHttpShardFetch } from './shard-transport.js'

function assertRelays (relays, threshold, where) {
  if (!Array.isArray(relays) || relays.length < 1) throw new Error(where + ': relays[] required')
  if (!Number.isInteger(threshold) || threshold < 1 || threshold > relays.length) {
    throw new Error(where + ': 1 <= threshold <= relays.length required')
  }
  for (const r of relays) {
    if (!r || typeof r.baseUrl !== 'string' || !r.baseUrl) throw new Error(where + ': each relay needs a baseUrl')
  }
}

/**
 * DEALER: disperse `secret` across `relays` (share i → relays[i]), k-of-n.
 *
 * @param {string|undefined} secret  64-hex scalar; random if omitted
 * @param {object} o
 * @param {Array<{baseUrl:string, pubkey?:string}>} o.relays  the n custodians, in
 *        share-index order (share i is assigned to relays[i-1])
 * @param {number} o.threshold  k — shards needed to reconstruct
 * @param {(ctx:{hash:string, address:string, shareIndex:number, relay:object}) => Promise<object>|object} o.signPin
 *        signs the custody pin for one shard PUT
 * @param {(relay:object, intent:{shareManifest, shareAssignments, commitmentRoot, threshold, count}) => Promise<any>} [o.publishIntent]
 *        publishes the signed custody intent to a relay so its PUT authorizes;
 *        called once per relay before any PUT. Omit only against relays whose
 *        authorization is arranged out of band (e.g. tests with a stub resolver).
 * @param {typeof fetch} [o.fetch]
 * @returns {Promise<{ key, secretPoint, threshold, count, commitmentRoot,
 *   shareManifest, shareAssignments, refs:Array<object> }>}
 *   key/secretPoint are DEALER-PRIVATE (what a reader reconstructs).
 */
export async function disperse (secret, { relays, threshold, signPin, publishIntent, fetch } = {}) {
  assertRelays(relays, threshold, 'disperse')
  if (typeof signPin !== 'function') throw new Error('disperse: signPin(ctx) required')

  const plan = await planDispersal({ count: relays.length, threshold, secret })

  const shareAssignments = plan.shares.map((s) => ({
    relayPubkey: relays[s.shareIndex - 1].pubkey,
    shareIndex: s.shareIndex
  }))
  const shareManifest = plan.shares.map((s) => ({
    shareIndex: s.shareIndex,
    shard: s.shard,
    shareCommitment: s.shareCommitment
  }))
  const intent = { shareManifest, shareAssignments, commitmentRoot: plan.commitmentRoot, threshold, count: relays.length }

  // Publish the (app-signed) intent to every relay FIRST, so each PUT authorizes
  // against a custody intent the relay has already indexed.
  if (typeof publishIntent === 'function') {
    for (const relay of relays) await publishIntent(relay, intent)
  }

  // Route share i → relays[i] and PUT over /api/v1/shard.
  const refs = []
  for (const s of plan.shares) {
    const relay = relays[s.shareIndex - 1]
    const put = createHttpShardPut({
      baseUrl: relay.baseUrl,
      signPin: (ctx) => signPin({ ...ctx, shareIndex: s.shareIndex, relay }),
      fetch
    })
    refs.push({ shareIndex: s.shareIndex, shard: await put(s.bytes, { shareIndex: s.shareIndex }) })
  }

  return {
    key: plan.key,
    secretPoint: plan.secretPoint,
    threshold,
    count: relays.length,
    commitmentRoot: plan.commitmentRoot,
    shareManifest,
    shareAssignments,
    refs
  }
}

/**
 * READER: gather >= threshold shards from `relays` and reconstruct the secret.
 * Integrity rests on an AUTHENTIC `shareManifest` (from a publisher-signed custody
 * intent) — see the contract in blind-shards.js.
 *
 * @param {object} o
 * @param {Array<{baseUrl:string}>} o.relays
 * @param {Array<{shareIndex:number, shard:string, shareCommitment:string}>} o.shareManifest
 * @param {number} o.threshold
 * @param {typeof fetch} [o.fetch]
 * @returns {Promise<{ ok, key?, secretPoint?, used?, collected, need, reason? }>}
 */
export async function recover ({ relays, shareManifest, threshold, fetch } = {}) {
  if (!Array.isArray(relays) || !relays.length) throw new Error('recover: relays[] required')
  const baseUrls = relays.map((r) => r && r.baseUrl).filter(Boolean)
  const fetchShard = createHttpShardFetch({ baseUrls, fetch })
  return recoverSecret({ shareManifest, threshold, fetch: fetchShard })
}