OverviewFilesCommitsIssuesDocsReleasesPeers

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

255 lines · javascript ref:
/**
 * Blind-shard dispersal + recovery (CLIENT-SIDE).
 *
 * The connective layer between PVSS secret-sharing (secret-sharing.js) and the
 * content-addressed blind shard store (/api/v1/shard). It turns a secret into
 * `n` self-verifying shares, encodes each as a canonical OPAQUE shard blob, and
 * disperses them so NO SINGLE RELAY holds >= threshold shares — then lets any
 * reader collect >= threshold shards and reconstruct the secret AT THE EDGE.
 * A relay only ever sees opaque, content-addressed bytes; below the threshold a
 * held share is COMPUTATIONALLY hidden (secp256k1 DL/DDH — each shard exposes
 * S_i = p(i)*G, so this is the standard PVSS-in-the-exponent tradeoff, not the
 * information-theoretic hiding of plain Shamir).
 *
 * INTEGRITY CONTRACT: recoverSecret binds each fetched share to the point the
 * manifest commits to (shareCommitment), so a relay cannot substitute a
 * forged-but-self-consistent share (a valid DLEQ proof alone does NOT prove a
 * share matches the dealer's polynomial). That binding is only as trustworthy
 * as the manifest: callers MUST obtain the shareManifest from a publisher-signed
 * custody intent (verify the intent + that its shareCommitments derive from the
 * signed commitmentRoot) before trusting a reconstruction. An unauthenticated
 * manifest can commit to anything.
 *
 * This is the "public plaintext, blind custody" path: encrypt content with a
 * random key, store the ciphertext, and disperse the KEY as blind shards here —
 * no single operator can produce the plaintext, a reader can.
 *
 * Transport-agnostic: the caller injects put()/fetch() (HTTP /api/v1/shard, the
 * P2P service RPC, or in-process for tests).
 */
import b4a from 'b4a'
import sodium from 'sodium-universal'
import { keygen, split, decryptShare, reconstruct } from './secret-sharing.js'

export const SHARE_SHARD_VERSION = 1
export const SHARE_SHARD_SCHEME = 'pvss-secp256k1-v1'

const HEX_32 = /^[0-9a-f]{64}$/
const POINT = /^[0-9a-f]{66}$/

// blake2b-256(bytes) as 'shard:<lowercase-hex>' — byte-identical to the relay
// shard store's shardHash (shard-engine.js), so an address computed locally
// matches what a relay returns on PUT and stores under.
export function shardAddressOf (bytes) {
  const buf = b4a.isBuffer(bytes) ? bytes : b4a.from(bytes)
  const out = b4a.alloc(32)
  sodium.crypto_generichash(out, buf)
  return 'shard:' + b4a.toString(out, 'hex')
}

function normalizeAddress (addr) {
  const raw = typeof addr === 'string' && addr.startsWith('shard:') ? addr.slice('shard:'.length) : String(addr)
  return 'shard:' + raw.toLowerCase()
}

function assertShare (s, where) {
  if (!s || typeof s !== 'object') throw new Error(where + ': share object required')
  if (!Number.isInteger(s.index) || s.index < 1) throw new Error(where + ': bad share index')
  if (!POINT.test(s.share)) throw new Error(where + ': bad share point')
  if (!POINT.test(s.shareholder)) throw new Error(where + ': bad shareholder point')
  if (!POINT.test(s.encryptedShare)) throw new Error(where + ': bad encryptedShare point')
  if (!s.proof || !HEX_32.test(s.proof.e) || !HEX_32.test(s.proof.s)) throw new Error(where + ': bad DLEQ proof')
}

/**
 * Canonical, deterministic bytes for a decrypted PVSS share so that
 * blake2b(encodeShareShard(s)) is a STABLE content address across runs and
 * hosts. Built as a fixed-order JSON string (not JSON.stringify) so the
 * encoding never depends on engine key-ordering. All values are validated
 * hex/points + an integer index, so no escaping is required.
 */
export function encodeShareShard (share) {
  assertShare(share, 'encodeShareShard')
  const canonical =
    '{"v":' + SHARE_SHARD_VERSION +
    ',"scheme":"' + SHARE_SHARD_SCHEME + '"' +
    ',"index":' + share.index +
    ',"share":"' + share.share + '"' +
    ',"shareholder":"' + share.shareholder + '"' +
    ',"encryptedShare":"' + share.encryptedShare + '"' +
    ',"proof":{"e":"' + share.proof.e + '","s":"' + share.proof.s + '"}}'
  return b4a.from(canonical, 'utf8')
}

/** Inverse of encodeShareShard. Validates version/scheme/shape. */
export function decodeShareShard (bytes) {
  let obj
  try {
    obj = JSON.parse(b4a.toString(b4a.isBuffer(bytes) ? bytes : b4a.from(bytes), 'utf8'))
  } catch {
    throw new Error('decodeShareShard: not JSON')
  }
  if (obj.v !== SHARE_SHARD_VERSION) throw new Error('decodeShareShard: unsupported version')
  if (obj.scheme !== SHARE_SHARD_SCHEME) throw new Error('decodeShareShard: unsupported scheme')
  const share = {
    index: obj.index,
    share: obj.share,
    shareholder: obj.shareholder,
    encryptedShare: obj.encryptedShare,
    proof: obj.proof && { e: obj.proof.e, s: obj.proof.s }
  }
  assertShare(share, 'decodeShareShard')
  return share
}

/**
 * DEALER: split a secret into `count` self-verifying shares (threshold `k`),
 * encode each as an opaque shard, and PUT it via the injected put().
 *
 * Shareholders are EPHEMERAL keys generated here — disjoint from the custodying
 * relays (as split() requires) and held only long enough to produce plain,
 * DLEQ-self-verifying shares that any reader can later reconstruct without a
 * secret key.
 *
 * @param {object} p
 * @param {number} p.count      n — total shares / relays (>= threshold)
 * @param {number} p.threshold  k — shares required to reconstruct
 * @param {string} [p.secret]   64-hex scalar; random if omitted
 * @param {(shardBytes:Uint8Array, meta:{shareIndex:number}) => Promise<string>} p.put
 *        stores one shard and returns its 'shard:<hash>' address
 * @returns {Promise<{ key, secretPoint, threshold, count, commitmentRoot,
 *   shareManifest:Array<{shareIndex:number, shard:string, shareCommitment:string}> }>}
 *   key/secretPoint are DEALER-PRIVATE — exactly what a reader reconstructs.
 */
export async function disperseSecret ({ count, threshold, secret, put } = {}) {
  if (typeof put !== 'function') throw new Error('disperseSecret: put(shardBytes, meta) required')
  const plan = await planDispersal({ count, threshold, secret })

  const shareManifest = []
  for (const s of plan.shares) {
    const shard = await put(s.bytes, { shareIndex: s.shareIndex })
    if (normalizeAddress(shard) !== s.shard) {
      throw new Error('disperseSecret: relay stored a different hash for share ' + s.shareIndex)
    }
    shareManifest.push({ shareIndex: s.shareIndex, shard: s.shard, shareCommitment: s.shareCommitment })
  }

  return {
    key: plan.key,
    secretPoint: plan.secretPoint,
    threshold: plan.threshold,
    count: plan.count,
    commitmentRoot: plan.commitmentRoot,
    shareManifest
  }
}

/**
 * DEALER, phase 1 — split + encode every share WITHOUT storing anything, so a
 * caller can learn each share's content address up front (to bind them into a
 * signed custody intent and PUBLISH that intent to the relays) BEFORE the shards
 * are PUT and authorized against it. disperseSecret() is this plus the PUT loop.
 *
 * @param {object} p
 * @param {number} p.count      n — total shares (>= threshold)
 * @param {number} p.threshold  k
 * @param {string} [p.secret]   64-hex scalar; random if omitted
 * @returns {Promise<{ key, secretPoint, threshold, count, commitmentRoot,
 *   shares:Array<{shareIndex:number, bytes:Uint8Array, shard:string, shareCommitment:string}> }>}
 *   shares are ordered by shareIndex; `shard` is the content address the relay
 *   will store the bytes under, `shareCommitment` = S_i = p(i)*G.
 */
export async function planDispersal ({ count, threshold, secret } = {}) {
  if (!Number.isInteger(count) || count < 1) throw new Error('planDispersal: count >= 1 required')
  if (!Number.isInteger(threshold) || threshold < 1 || threshold > count) {
    throw new Error('planDispersal: 1 <= threshold <= count required')
  }

  const holders = []
  for (let i = 0; i < count; i++) holders.push(await keygen())
  const dealt = await split({ threshold, shareholders: holders.map(h => h.publicKey), secret })

  const shares = []
  for (const enc of dealt.public.encryptedShares) {
    const holder = holders[enc.index - 1]
    const share = await decryptShare({ encryptedShare: enc, secretKey: holder.secretKey })
    const bytes = encodeShareShard(share)
    // shareCommitment for index i IS S_i = p(i)*G — the same Feldman-verifiable
    // point a reader checks and a relay's shareVerified gate binds its blob to
    // (BLIND-SHARD-STORE-SPEC.md §9.3).
    shares.push({ shareIndex: enc.index, bytes, shard: shardAddressOf(bytes), shareCommitment: share.share })
  }

  return {
    key: dealt.key,
    secretPoint: dealt.secretPoint,
    threshold,
    count,
    commitmentRoot: dealt.public.commitmentRoot,
    shares
  }
}

/**
 * READER: collect >= threshold shards named by the manifest via the injected
 * fetch(), verify each by content address, decode, and reconstruct the secret —
 * entirely at the reader's edge. No relay ever holds the whole secret.
 *
 * Each manifest entry MUST carry the committed share point
 * (shareCommitment = S_i = p(i)*G); a fetched share whose point does not equal
 * that commitment is rejected. See the INTEGRITY CONTRACT above.
 *
 * @param {object} p
 * @param {Array<{shareIndex:number, shard:string, shareCommitment:string}>} p.shareManifest
 * @param {number} p.threshold  k
 * @param {(shardAddress:string) => Promise<Uint8Array|null>} p.fetch
 *        returns the opaque shard bytes for an address, or null if unavailable
 * @returns {Promise<{ ok:boolean, key?:string, secretPoint?:string,
 *   used?:number, collected:number, need:number, reason?:string }>}
 */
export async function recoverSecret ({ shareManifest, threshold, fetch } = {}) {
  if (!Array.isArray(shareManifest)) throw new Error('recoverSecret: shareManifest array required')
  if (!Number.isInteger(threshold) || threshold < 1) throw new Error('recoverSecret: threshold >= 1 required')
  if (typeof fetch !== 'function') throw new Error('recoverSecret: fetch(shard) required')

  const shares = []
  const seenAddr = new Set()
  const seenIndex = new Set()
  for (const entry of shareManifest) {
    if (shares.length >= threshold) break
    if (!entry || typeof entry.shard !== 'string') continue
    // The manifest MUST commit to this share's point. Without a well-formed
    // commitment the share cannot be validated, so it is unusable (fail closed).
    const commitment = typeof entry.shareCommitment === 'string' ? entry.shareCommitment.toLowerCase() : null
    if (!commitment || !POINT.test(commitment)) continue
    const address = normalizeAddress(entry.shard)
    if (seenAddr.has(address)) continue
    seenAddr.add(address)
    let bytes = null
    try { bytes = await fetch(address) } catch { bytes = null }
    if (!bytes || !bytes.length) continue
    // Content-address integrity: a relay returning wrong/garbage bytes for the
    // hash is caught here — no relay is trusted for the bytes.
    if (shardAddressOf(bytes) !== address) continue
    let share
    try { share = decodeShareShard(bytes) } catch { continue }
    // Commitment binding: the decoded share point MUST equal the point the
    // (authenticated) manifest commits to. A valid DLEQ proof alone is forgeable
    // — it only proves S_i is the honest decryption of an attacker-suppliable
    // encryptedShare — so this equality is what actually stops a substituted
    // share from silently reconstructing a WRONG secret.
    if (share.share.toLowerCase() !== commitment) continue
    if (seenIndex.has(share.index)) continue // duplicate share index is unusable
    seenIndex.add(share.index)
    shares.push(share)
  }

  if (shares.length < threshold) {
    return { ok: false, collected: shares.length, need: threshold, reason: 'INSUFFICIENT_SHARDS' }
  }
  // reconstruct() re-verifies every share's DLEQ proof and Lagrange-interpolates
  // in the exponent — a substituted/forged share throws rather than corrupts.
  const out = await reconstruct({ shares, threshold })
  return { ok: true, key: out.key, secretPoint: out.secretPoint, used: shares.length, collected: shares.length, need: threshold }
}