OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / custody-pvss.test.js

536 lines · javascript ref:
// PVSS custody — Phase 2. Two concerns in one file:
//
//   1. CROSS-IMPLEMENTATION AGREEMENT. The relay's verify-only path lives in
//      core (packages/core/core/pvss.js); the dealer/shareholder prover lives
//      in the client SDK (packages/client/secret-sharing.js). They are
//      independently versioned deployables that meet over the wire, so a
//      shared import would be false comfort. These tests pin that the LIVE
//      core verifier accepts exactly what the client prover produces, and
//      rejects tampering — byte-for-byte transcript agreement is the real
//      interop contract.
//
//   2. CUSTODY-LOG WIRE FORMAT (version 2). The signed custody log gains
//      optional PVSS fields (intent: shareScheme/shareThreshold/commitmentRoot;
//      receipt: shareScheme/commitmentRoot/shareIndex/shareCommitment/
//      shareVerified). Version-gated so v1 entries stay byte-identical. Plus
//      FORBIDDEN_KEYS hardening against cleartext share material.

import test from 'brittle'
import b4a from 'b4a'
import sodium from 'sodium-universal'
import { keygen, split, decryptShare } from 'p2p-hiverelay-client/secret-sharing.js'
import {
  commitmentRootOf,
  shareCommitmentAt,
  verifyEncryptedShare,
  verifyDecryptedShare
} from 'p2p-hiverelay/core/pvss.js'
import {
  createCustodyIntent,
  createCustodyReceipt,
  createCustodyCommit,
  computeReceiptRoot,
  validateCustodyTransition,
  summarizeCustodyStatus,
  verifyCustodyEntry,
  hashHex
} from 'p2p-hiverelay/core/custody-signing.js'

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

// Custody entries are signed with sodium ed25519 keys (distinct from the
// secp256k1 PVSS shareholder keys keygen() produces).
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 }
}

// Map relay pubkeys to share indices 1..n for an intent's shareAssignments
// (relays[i] custodies share i+1) — mirrors the dealer's split, where index
// i+1's encrypted share is delivered to the relay this names.
function assignmentsFor (relays) {
  return relays.map((r, i) => ({ relayPubkey: b4a.toString(r.publicKey, 'hex'), shareIndex: i + 1 }))
}

// Build a realistic PVSS custody scenario: a real client split plus a v2
// custody-intent pinning the resulting commitmentRoot/threshold, the share
// bundle key, and the relay→index assignment map. Returns the n custodying
// relay keypairs (relays[i] is assigned shareIndex i+1).
async function pvssScenario ({ n, threshold, now }) {
  const keys = await shareholders(n)
  const res = await split({ threshold, shareholders: keys.map(k => k.publicKey) })
  const publisher = keyPair()
  const relays = Array.from({ length: n }, () => keyPair())
  const blindContentId = hashHex('pvss-blind-' + n + '-' + threshold + '-' + now)
  const ciphertextRoot = hashHex('pvss-cipher-' + n + '-' + threshold + '-' + now)
  const shareBundleKey = hashHex('pvss-bundle-' + n + '-' + threshold + '-' + now)
  const intent = createCustodyIntent({
    version: 2,
    blindContentId,
    ciphertextRoot,
    contentVersion: 1,
    requiredReplicas: n,
    deadline: now + 60_000,
    retainUntil: now + 120_000,
    shareScheme: 'pvss-secp256k1-v1',
    shareThreshold: threshold,
    commitmentRoot: res.public.commitmentRoot,
    shareBundleKey,
    shareAssignments: assignmentsFor(relays)
  }, publisher, { timestamp: now })
  return { keys, res, publisher, relays, blindContentId, ciphertextRoot, shareBundleKey, intent }
}

// A v2 custody-receipt for `shareIndex`, echoing the intent's PVSS binding and
// pinning the per-index commitment point. `overrides` tampers individual
// fields for the negative cases.
function pvssReceipt (intent, relay, shareIndex, commitments, now, overrides = {}) {
  return createCustodyReceipt({
    version: 2,
    intentId: intent.intentId,
    blindContentId: intent.blindContentId,
    ciphertextRoot: intent.ciphertextRoot,
    contentVersion: intent.contentVersion,
    retainUntil: intent.retainUntil,
    relayRegion: 'test',
    shardIds: [Math.max(0, shareIndex - 1)],
    shareScheme: 'pvss-secp256k1-v1',
    commitmentRoot: intent.commitmentRoot,
    shareIndex,
    shareCommitment: shareCommitmentAt(commitments, shareIndex),
    shareVerified: true,
    ...overrides
  }, relay, { timestamp: now + 1000 })
}

// ─── 1. Cross-implementation agreement (core verifier ↔ service prover) ───

test('cross-impl: core commitmentRootOf reproduces the service-published root', async (t) => {
  const keys = await shareholders(4)
  const res = await split({ threshold: 2, shareholders: keys.map(k => k.publicKey) })
  t.is(
    commitmentRootOf(res.public.commitments),
    res.public.commitmentRoot,
    'independent core hash matches the dealer-published commitmentRoot'
  )
})

test('cross-impl: core verifyEncryptedShare accepts every honest share', async (t) => {
  const keys = await shareholders(5)
  const res = await split({ threshold: 3, shareholders: keys.map(k => k.publicKey) })
  for (const share of res.public.encryptedShares) {
    t.ok(
      verifyEncryptedShare(res.public.commitments, share),
      `core verifies honest share #${share.index} (no secret key)`
    )
  }
})

test('cross-impl: core shareCommitmentAt is a stable per-index point a relay can pin', async (t) => {
  const keys = await shareholders(3)
  const res = await split({ threshold: 2, shareholders: keys.map(k => k.publicKey) })
  for (const share of res.public.encryptedShares) {
    const sc = shareCommitmentAt(res.public.commitments, share.index)
    t.ok(/^0[23][0-9a-f]{64}$/.test(sc), `index ${share.index} → compressed point`)
    t.is(shareCommitmentAt(res.public.commitments, share.index), sc, 'deterministic')
  }
})

test('cross-impl: a tampered encrypted share or proof is rejected by the core verifier', async (t) => {
  const keys = await shareholders(3)
  const res = await split({ threshold: 2, shareholders: keys.map(k => k.publicKey) })

  const swapped = { ...res.public.encryptedShares[0], encryptedShare: (await keygen()).publicKey }
  t.is(verifyEncryptedShare(res.public.commitments, swapped), false, 'swapped share value rejected')

  const badProof = { ...res.public.encryptedShares[1], proof: { ...res.public.encryptedShares[1].proof, s: 'f'.repeat(64) } }
  t.is(verifyEncryptedShare(res.public.commitments, badProof), false, 'tampered DLEQ response rejected')

  const wrongIndex = { ...res.public.encryptedShares[2], index: 99 }
  t.is(verifyEncryptedShare(res.public.commitments, wrongIndex), false, 'wrong index breaks the commitment recompute')
})

test('cross-impl: core verifyDecryptedShare agrees with the service on honest + forged shares', async (t) => {
  const keys = await shareholders(3)
  const res = await split({ threshold: 2, shareholders: keys.map(k => k.publicKey) })
  const enc = res.public.encryptedShares.find(s => s.index === 1)
  const dec = await decryptShare({ encryptedShare: enc, secretKey: keys[0].secretKey })

  t.is(verifyDecryptedShare(dec), true, 'honest decrypted share verifies in core')

  const forged = { ...dec, share: (await keygen()).publicKey }
  t.is(verifyDecryptedShare(forged), false, 'forged decrypted share rejected in core')
})

// ─── 2. Custody-log wire format (version 2) ───

test('custody v2: a PVSS custody intent + receipt sign, verify, and bind end-to-end', async (t) => {
  const now = Date.now()
  const n = 5
  const threshold = 3
  const keys = await shareholders(n)
  const res = await split({ threshold, shareholders: keys.map(k => k.publicKey) })
  const publisher = keyPair()
  const relays = Array.from({ length: n }, () => keyPair())
  const relay = relays[0] // assigned shareIndex 1 by assignmentsFor below
  const blindContentId = hashHex('pvss-blind-content')
  const ciphertextRoot = hashHex('pvss-ciphertext-root')

  const intent = createCustodyIntent({
    version: 2,
    blindContentId,
    ciphertextRoot,
    contentVersion: 1,
    requiredReplicas: n,
    deadline: now + 60_000,
    retainUntil: now + 120_000,
    shareScheme: 'pvss-secp256k1-v1',
    shareThreshold: threshold,
    commitmentRoot: res.public.commitmentRoot,
    shareBundleKey: hashHex('pvss-bundle-content'),
    shareAssignments: assignmentsFor(relays)
  }, publisher, { timestamp: now })

  t.ok(verifyCustodyEntry(intent, { now }).valid, 'v2 intent signature verifies')
  t.is(intent.version, 2, 'intent carries version 2')
  t.is(intent.commitmentRoot, res.public.commitmentRoot, 'intent pins the published commitmentRoot')

  // The relay publicly verifies the encrypted share it will custody (#1)
  // BEFORE attesting shareVerified — exactly the relay-side flow, no secret.
  const share = res.public.encryptedShares.find(s => s.index === 1)
  t.ok(verifyEncryptedShare(res.public.commitments, share), 'relay verifies encrypted share #1 with no secret key')

  const receipt = createCustodyReceipt({
    version: 2,
    intentId: intent.intentId,
    blindContentId,
    ciphertextRoot,
    contentVersion: 1,
    retainUntil: now + 120_000,
    relayRegion: 'test',
    shardIds: [0],
    shareScheme: 'pvss-secp256k1-v1',
    commitmentRoot: res.public.commitmentRoot,
    shareIndex: 1,
    shareCommitment: shareCommitmentAt(res.public.commitments, 1),
    shareVerified: true
  }, relay, { timestamp: now + 1000 })

  t.ok(verifyCustodyEntry(receipt, { now: now + 1000 }).valid, 'v2 receipt signature verifies')
  t.is(receipt.shareCommitment, shareCommitmentAt(res.public.commitments, 1), 'receipt pins the per-index commitment point')
  t.ok(validateCustodyTransition(receipt, { intent }).valid, 'receipt binds to its PVSS intent')
})

test('custody v2: version-gating keeps the v1 surface closed to PVSS fields', (t) => {
  const now = Date.now()
  const publisher = keyPair()
  const base = {
    blindContentId: hashHex('gate-blind'),
    ciphertextRoot: hashHex('gate-cipher'),
    contentVersion: 1,
    requiredReplicas: 3,
    deadline: now + 60_000,
    retainUntil: now + 120_000
  }

  // v1 (default) intent with no PVSS fields verifies — unchanged behavior.
  const v1 = createCustodyIntent({ ...base }, publisher, { timestamp: now })
  t.ok(verifyCustodyEntry(v1, { now }).valid, 'plain v1 intent verifies')
  t.is(v1.version, 1, 'defaults to version 1')
  t.is(v1.shareScheme, undefined, 'no PVSS fields leak into v1')

  // A v1 intent carrying a PVSS field is rejected as an unknown field.
  t.exception(
    () => createCustodyIntent({ ...base, shareScheme: 'pvss-secp256k1-v1' }, publisher, { timestamp: now }),
    /unknown custody field: shareScheme/,
    'v1 intent rejects shareScheme'
  )

  // version 2 is only defined for intent + receipt; other types reject it.
  t.exception(
    () => createCustodyCommit({
      version: 2,
      intentId: v1.intentId,
      blindContentId: base.blindContentId,
      ciphertextRoot: base.ciphertextRoot,
      contentVersion: 1,
      relayQuorum: [],
      receiptRoot: hashHex('rr')
    }, publisher, { timestamp: now }),
    /version 2 unsupported for custody-commit/,
    'version 2 rejected for commit type'
  )

  // An unsupported version number is rejected outright.
  t.exception(
    () => createCustodyIntent({ ...base, version: 3 }, publisher, { timestamp: now }),
    /unsupported custody version/,
    'version 3 rejected'
  )
})

test('custody v2: cleartext PVSS share material is forbidden in any custody entry', (t) => {
  const now = Date.now()
  const publisher = keyPair()
  const base = {
    version: 2,
    blindContentId: hashHex('forbid-blind'),
    ciphertextRoot: hashHex('forbid-cipher'),
    contentVersion: 1,
    requiredReplicas: 3,
    deadline: now + 60_000,
    retainUntil: now + 120_000,
    shareScheme: 'pvss-secp256k1-v1',
    shareThreshold: 2,
    commitmentRoot: hashHex('forbid-root')
  }
  // A top-level PVSS secret field is rejected by the positive allowlist (which
  // names the offending field) before signing — the same primary gate the base
  // custody fields use (see custody-signing.test.js and custody-signing.js:
  // rejectUnknownFields runs before the recursive forbidden scan). These field
  // names are never in any allowlist, so they can never reach a signed entry.
  for (const secretField of ['share', 'shareScalar', 'decryptedShare', 'secret', 'secretPoint', 'coefficient', 'coefficients', 'polynomial']) {
    t.exception(
      () => createCustodyIntent({ ...base, [secretField]: 'deadbeef' }, publisher, { timestamp: now }),
      new RegExp('unknown custody field: ' + secretField),
      `top-level ${secretField} rejected by the allowlist`
    )
  }
  // Nested PVSS secret material riding inside an otherwise-allowed container is
  // caught by the recursive forbidden-secret scan (the backstop for allowlisted
  // containers).
  t.exception(
    () => createCustodyIntent({ ...base, candidateRelays: [{ share: 'nested-secret' }] }, publisher, { timestamp: now }),
    /forbidden/,
    'nested PVSS secret is rejected by the recursive scan'
  )
})

test('custody v2: receipt↔intent PVSS binding is enforced by validateCustodyTransition', async (t) => {
  const now = Date.now()
  const { res, intent, relays } = await pvssScenario({ n: 3, threshold: 2, now })
  const relay = relays[0] // assigned shareIndex 1 by the scenario
  const commitments = res.public.commitments

  t.ok(validateCustodyTransition(pvssReceipt(intent, relay, 1, commitments, now), { intent }).valid, 'matching receipt valid')

  t.is(
    validateCustodyTransition(pvssReceipt(intent, relay, 1, commitments, now, { commitmentRoot: hashHex('other-root') }), { intent }).reason,
    'commitmentRoot mismatch',
    'wrong commitmentRoot rejected'
  )

  // shareScheme is constrained to one known value at normalize time, so a
  // mismatching scheme can only arrive on an adversarial (un-normalized) entry
  // over the wire — the transition check must still catch it.
  const good = pvssReceipt(intent, relay, 1, commitments, now)
  t.is(
    validateCustodyTransition({ ...good, shareScheme: 'pvss-secp256k1-v2' }, { intent }).reason,
    'shareScheme mismatch',
    'mismatched shareScheme rejected'
  )

  t.is(
    validateCustodyTransition(pvssReceipt(intent, relay, 99, commitments, now), { intent }).reason,
    'shareIndex out of range',
    'shareIndex beyond requiredReplicas rejected'
  )

  t.is(
    validateCustodyTransition({ ...good, shareVerified: false }, { intent }).reason,
    'shareVerified must be true',
    'unverified share rejected'
  )
})

test('custody v2: intent shareBundleKey + shareAssignments are validated at signing time', async (t) => {
  const now = Date.now()
  const keys = await shareholders(3)
  const res = await split({ threshold: 2, shareholders: keys.map(k => k.publicKey) })
  const publisher = keyPair()
  const relays = Array.from({ length: 3 }, () => keyPair())
  const hex = (r) => b4a.toString(r.publicKey, 'hex')
  const base = {
    version: 2,
    blindContentId: hashHex('assign-blind'),
    ciphertextRoot: hashHex('assign-cipher'),
    contentVersion: 1,
    requiredReplicas: 3,
    deadline: now + 60_000,
    retainUntil: now + 120_000,
    shareScheme: 'pvss-secp256k1-v1',
    shareThreshold: 2,
    commitmentRoot: res.public.commitmentRoot,
    shareBundleKey: hashHex('assign-bundle')
  }
  const mk = (overrides) => createCustodyIntent({ ...base, ...overrides }, publisher, { timestamp: now })

  // Happy path signs + verifies, and the map is sorted ascending by shareIndex
  // (fed in reverse to prove the sort actually runs).
  const ok = mk({ shareAssignments: assignmentsFor(relays).slice().reverse() })
  t.ok(verifyCustodyEntry(ok, { now }).valid, 'valid v2 intent signs + verifies')
  t.alike(ok.shareAssignments.map(a => a.shareIndex), [1, 2, 3], 'assignments sorted ascending by shareIndex')

  // shareBundleKey is required for a v2 intent.
  const noBundle = { ...base, shareAssignments: assignmentsFor(relays) }
  delete noBundle.shareBundleKey
  t.exception(() => createCustodyIntent(noBundle, publisher, { timestamp: now }), /shareBundleKey must be 64 hex/, 'shareBundleKey required')

  t.exception(() => mk({ shareAssignments: [] }), /shareAssignments must be a non-empty array/, 'empty assignments rejected')
  t.exception(
    () => mk({ shareAssignments: [{ relayPubkey: hex(relays[0]), shareIndex: 4 }, { relayPubkey: hex(relays[1]), shareIndex: 2 }] }),
    /shareIndex exceeds requiredReplicas/,
    'index beyond requiredReplicas rejected'
  )
  t.exception(
    () => mk({ shareAssignments: [{ relayPubkey: hex(relays[0]), shareIndex: 1 }, { relayPubkey: hex(relays[1]), shareIndex: 1 }] }),
    /duplicate shareIndex/,
    'duplicate index rejected'
  )
  t.exception(
    () => mk({ shareAssignments: [{ relayPubkey: hex(relays[0]), shareIndex: 1 }, { relayPubkey: hex(relays[0]), shareIndex: 2 }] }),
    /duplicate relayPubkey/,
    'duplicate relay rejected'
  )
  t.exception(
    () => mk({ shareAssignments: [{ relayPubkey: hex(relays[0]), shareIndex: 1 }] }),
    /shareAssignments fewer than shareThreshold/,
    'fewer assignments than threshold rejected'
  )
})

test('custody v2: a receipt from an unassigned relay (or wrong index) is rejected', async (t) => {
  const now = Date.now()
  const { res, intent, relays } = await pvssScenario({ n: 3, threshold: 2, now })
  const commitments = res.public.commitments

  // relays[1] is assigned shareIndex 2; a receipt for that index binds.
  t.ok(validateCustodyTransition(pvssReceipt(intent, relays[1], 2, commitments, now), { intent }).valid, 'assigned relay at its index binds')

  // relays[1] claiming index 3 (assigned to relays[2]) is rejected.
  t.is(
    validateCustodyTransition(pvssReceipt(intent, relays[1], 3, commitments, now), { intent }).reason,
    'shareIndex does not match assignment',
    'assigned relay cannot claim a different index'
  )

  // A relay absent from the assignment map is rejected outright.
  const stranger = keyPair()
  t.is(
    validateCustodyTransition(pvssReceipt(intent, stranger, 1, commitments, now), { intent }).reason,
    'relay not in shareAssignments',
    'unassigned relay rejected'
  )
})

test('custody v2: a PVSS receipt cannot bind to a non-PVSS (v1) intent', async (t) => {
  const now = Date.now()
  const { res } = await pvssScenario({ n: 3, threshold: 2, now })
  const publisher = keyPair()
  const blindContentId = hashHex('v1-intent-blind')
  const ciphertextRoot = hashHex('v1-intent-cipher')
  const v1Intent = createCustodyIntent({
    blindContentId,
    ciphertextRoot,
    contentVersion: 1,
    requiredReplicas: 3,
    deadline: now + 60_000,
    retainUntil: now + 120_000
  }, publisher, { timestamp: now })
  t.is(v1Intent.version, 1, 'control intent is v1 (no PVSS)')

  const relay = keyPair()
  const receipt = createCustodyReceipt({
    version: 2,
    intentId: v1Intent.intentId,
    blindContentId,
    ciphertextRoot,
    contentVersion: 1,
    retainUntil: now + 120_000,
    relayRegion: 'test',
    shardIds: [0],
    shareScheme: 'pvss-secp256k1-v1',
    commitmentRoot: res.public.commitmentRoot,
    shareIndex: 1,
    shareCommitment: shareCommitmentAt(res.public.commitments, 1),
    shareVerified: true
  }, relay, { timestamp: now + 1000 })

  t.is(
    validateCustodyTransition(receipt, { intent: v1Intent }).reason,
    'share custody declared for non-PVSS intent',
    'PVSS receipt against a non-PVSS intent rejected'
  )
})

test('custody v2: custody-commit requires distinct PVSS share indices across the quorum', async (t) => {
  const now = Date.now()
  const { res, publisher, intent } = await pvssScenario({ n: 2, threshold: 2, now })
  const relayA = keyPair()
  const relayB = keyPair()
  const commitments = res.public.commitments

  const buildCommit = (receipts) => createCustodyCommit({
    intentId: intent.intentId,
    blindContentId: intent.blindContentId,
    ciphertextRoot: intent.ciphertextRoot,
    contentVersion: intent.contentVersion,
    relayQuorum: receipts.map(r => r.relayPubkey).sort(),
    receiptRoot: computeReceiptRoot(receipts)
  }, publisher, { timestamp: now + 2000 })

  // Two receipts colliding on shareIndex = 1 cannot reconstruct the secret.
  const dup = [
    pvssReceipt(intent, relayA, 1, commitments, now),
    pvssReceipt(intent, relayB, 1, commitments, now)
  ]
  t.is(
    validateCustodyTransition(buildCommit(dup), { intent, receipts: dup }).reason,
    'duplicate shareIndex in quorum',
    'colliding share indices rejected'
  )

  // Distinct indices 1 and 2 reconstruct → commit valid.
  const ok = [
    pvssReceipt(intent, relayA, 1, commitments, now),
    pvssReceipt(intent, relayB, 2, commitments, now)
  ]
  t.ok(validateCustodyTransition(buildCommit(ok), { intent, receipts: ok }).valid, 'distinct indices commit valid')
})

test('custody v2: summarizeCustodyStatus surfaces a PVSS block only for PVSS intents', async (t) => {
  const now = Date.now()
  const { res, intent } = await pvssScenario({ n: 3, threshold: 2, now })
  const relayA = keyPair()
  const relayB = keyPair()
  const commitments = res.public.commitments
  const receipts = [
    pvssReceipt(intent, relayA, 2, commitments, now),
    pvssReceipt(intent, relayB, 1, commitments, now)
  ]
  const summary = summarizeCustodyStatus(intent, receipts)
  t.ok(summary.pvss, 'pvss block present')
  t.is(summary.pvss.shareScheme, 'pvss-secp256k1-v1', 'scheme surfaced')
  t.is(summary.pvss.shareThreshold, 2, 'threshold surfaced')
  t.is(summary.pvss.commitmentRoot, res.public.commitmentRoot, 'root surfaced')
  t.alike(summary.pvss.shareIndices, [1, 2], 'share indices deduped + sorted')

  // Non-PVSS intent → no pvss block (byte-identical to pre-change summaries).
  const publisher = keyPair()
  const v1Intent = createCustodyIntent({
    blindContentId: hashHex('sum-v1-blind'),
    ciphertextRoot: hashHex('sum-v1-cipher'),
    contentVersion: 1,
    requiredReplicas: 1,
    deadline: now + 60_000,
    retainUntil: now + 120_000
  }, publisher, { timestamp: now })
  t.is(summarizeCustodyStatus(v1Intent, []).pvss, undefined, 'no pvss block for v1 intent')
})