OverviewFilesCommitsIssuesDocsReleasesPeers

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

541 lines · javascript ref:
import test from 'brittle'
import b4a from 'b4a'
import sodium from 'sodium-universal'
import {
  computeReceiptRoot,
  createCustodyCommit,
  createCustodyExpiryWitness,
  createCustodyIntent,
  createCustodyNonServingProof,
  createCustodyProof,
  createCustodyReceipt,
  createSourceRetired,
  hashHex,
  validateCustodyTransition,
  verifyCustodyEntry
} from 'p2p-hiverelay/core/custody-signing.js'

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 }
}

test('custody signing: intent/receipt/commit/retirement/proof verify', (t) => {
  const publisher = keyPair()
  const relay = keyPair()
  const observer = keyPair()
  const now = Date.now()
  const blindContentId = hashHex('blind-content')
  const ciphertextRoot = hashHex('ciphertext-root')
  const addressKey = hashHex('address-key')

  const intent = createCustodyIntent({
    addressKey,
    blindContentId,
    ciphertextRoot,
    contentVersion: 7,
    requiredReplicas: 1,
    deadline: now + 60_000,
    retainUntil: now + 120_000
  }, publisher, { timestamp: now })

  t.ok(verifyCustodyEntry(intent, { now }).valid, 'intent verifies')

  const receipt = createCustodyReceipt({
    intentId: intent.intentId,
    blindContentId,
    ciphertextRoot,
    contentVersion: 7,
    retainUntil: now + 120_000,
    relayRegion: 'test',
    shardIds: [0]
  }, relay, { timestamp: now + 1000 })

  t.ok(verifyCustodyEntry(receipt, { now: now + 1000 }).valid, 'receipt verifies')

  const commit = createCustodyCommit({
    intentId: intent.intentId,
    blindContentId,
    ciphertextRoot,
    contentVersion: 7,
    relayQuorum: [b4a.toString(relay.publicKey, 'hex')],
    receiptRoot: computeReceiptRoot([receipt])
  }, publisher, { timestamp: now + 2000 })

  t.ok(verifyCustodyEntry(commit, { now: now + 2000 }).valid, 'commit verifies')

  const retired = createSourceRetired({
    intentId: intent.intentId,
    blindContentId,
    retiredAtVersion: 7
  }, publisher, { timestamp: now + 3000 })

  t.ok(verifyCustodyEntry(retired, { now: now + 3000 }).valid, 'source retirement verifies')

  const proof = createCustodyProof({
    intentId: intent.intentId,
    blindContentId,
    relayPubkey: b4a.toString(relay.publicKey, 'hex'),
    challengeNonce: hashHex('challenge'),
    shardIds: [0],
    blockIndices: [1, 2],
    passed: true,
    latencyMs: 42
  }, observer, { timestamp: now + 4000 })

  t.ok(verifyCustodyEntry(proof, { now: now + 4000 }).valid, 'custody proof verifies')

  const nonServingProof = createCustodyNonServingProof({
    intentId: intent.intentId,
    addressKey: intent.addressKey,
    blindContentId,
    challengeNonce: hashHex('post-expiry-challenge'),
    retainUntil: now + 120_000,
    notServing: true,
    notServingReason: 'expired-unseeded',
    catalogPresent: false,
    activeSwarmServing: false
  }, relay, { timestamp: now + 130_000 })

  t.ok(verifyCustodyEntry(nonServingProof, { now: now + 130_000 }).valid, 'non-serving proof verifies')
})

test('custody signing: tampering and forbidden plaintext metadata are rejected', (t) => {
  const publisher = keyPair()
  const now = Date.now()
  const intent = createCustodyIntent({
    blindContentId: hashHex('blind-content'),
    ciphertextRoot: hashHex('ciphertext-root'),
    contentVersion: 1,
    requiredReplicas: 1,
    deadline: now + 60_000,
    retainUntil: now + 120_000
  }, publisher, { timestamp: now })

  const tampered = { ...intent, ciphertextRoot: hashHex('other-root') }
  t.is(verifyCustodyEntry(tampered, { now }).valid, false, 'tampered intent signature rejected')

  t.exception(() => createCustodyIntent({
    blindContentId: hashHex('blind-content'),
    ciphertextRoot: hashHex('ciphertext-root'),
    contentVersion: 1,
    requiredReplicas: 1,
    deadline: now + 60_000,
    retainUntil: now + 120_000,
    dataKey: 'never-send-this'
  }, publisher, { timestamp: now }), /unknown custody field: dataKey/, 'dataKey is rejected by the custody allowlist')

  t.exception(() => createCustodyIntent({
    blindContentId: hashHex('blind-content'),
    ciphertextRoot: hashHex('ciphertext-root'),
    contentVersion: 1,
    requiredReplicas: 1,
    deadline: now + 60_000,
    retainUntil: now + 120_000,
    name: 'private docs'
  }, publisher, { timestamp: now }), /unknown custody field: name/, 'plaintext metadata is rejected by the custody allowlist')

  t.exception(() => createCustodyIntent({
    blindContentId: hashHex('blind-content'),
    ciphertextRoot: hashHex('ciphertext-root'),
    contentVersion: 1,
    requiredReplicas: 1,
    deadline: now + 60_000,
    retainUntil: now + 120_000,
    surpriseField: true
  }, publisher, { timestamp: now }), /unknown custody field/, 'unknown fields are rejected')
})

test('custody signing: every forbidden field name is rejected by the per-type allowlist', (t) => {
  // Plaintext/key field names should be rejected by the positive allowlist
  // before signing. The recursive forbidden scan remains for nested content
  // inside fields that are otherwise allowed.
  const publisher = keyPair()
  const now = Date.now()
  const baseIntent = {
    blindContentId: hashHex('a'),
    ciphertextRoot: hashHex('b'),
    contentVersion: 1,
    requiredReplicas: 1,
    deadline: now + 60_000,
    retainUntil: now + 120_000
  }
  const forbidden = ['dataKey', 'decryptionKey', 'plaintext', 'fileName', 'filename',
    'path', 'name', 'description', 'author', 'categories']
  for (const field of forbidden) {
    t.exception(
      () => createCustodyIntent({ ...baseIntent, [field]: 'value' }, publisher, { timestamp: now }),
      new RegExp('unknown custody field: ' + field),
      `field "${field}" rejected`
    )
  }

  t.exception(
    () => createCustodyIntent({
      ...baseIntent,
      candidateRelays: [{ dataKey: 'nested-secret' }]
    }, publisher, { timestamp: now }),
    /forbidden/,
    'nested secret field inside an allowed container is still rejected'
  )
})

test('custody signing: future-skew tolerance — entry from clock-skewed peer is rejected', (t) => {
  // A publisher signs an entry with their current timestamp. A verifier
  // whose clock is far behind sees the entry as "from the future" and
  // rejects it (>10min skew tolerance).
  const publisher = keyPair()
  const now = Date.now()
  const intent = createCustodyIntent({
    blindContentId: hashHex('a'),
    ciphertextRoot: hashHex('b'),
    contentVersion: 1,
    requiredReplicas: 1,
    deadline: now + 60_000,
    retainUntil: now + 120_000
  }, publisher, { timestamp: now })

  // Verify with `now` set to 30 min before the entry was signed — the
  // entry's timestamp will appear too far in the future.
  const skewedNow = now - 30 * 60 * 1000
  const result = verifyCustodyEntry(intent, { now: skewedNow })
  t.is(result.valid, false, 'rejected when verifier clock is far behind')
})

test('custody signing: receipt with mismatched relay pubkey is rejected', (t) => {
  const publisher = keyPair()
  const relayA = keyPair()
  const relayB = keyPair()
  const now = Date.now()
  const intent = createCustodyIntent({
    blindContentId: hashHex('a'),
    ciphertextRoot: hashHex('b'),
    contentVersion: 1,
    requiredReplicas: 1,
    deadline: now + 60_000,
    retainUntil: now + 120_000
  }, publisher, { timestamp: now })

  // relayA signs but relayB's pubkey is in the receipt
  const receipt = createCustodyReceipt({
    intentId: intent.intentId,
    blindContentId: intent.blindContentId,
    ciphertextRoot: intent.ciphertextRoot,
    contentVersion: 1,
    retainUntil: intent.retainUntil
  }, relayA, { timestamp: now })

  // Tamper: swap signer pubkey to relayB while keeping relayA's signature
  const tampered = { ...receipt, relayPubkey: b4a.toString(relayB.publicKey, 'hex') }
  t.is(verifyCustodyEntry(tampered, { now }).valid, false, 'mismatched signer rejected')
})

test('custody signing: computeReceiptRoot is deterministic and order-invariant', (t) => {
  // The commit's receiptRoot is a hash over the relay quorum's receipts.
  // Order of receipts in the input must not change the root, otherwise
  // different observers compute different roots and quorum can't agree.
  const r1 = { type: 'custody-receipt', intentId: 'x', relayPubkey: 'a', anchored: true, timestamp: 1, signature: 'sig1' }
  const r2 = { type: 'custody-receipt', intentId: 'x', relayPubkey: 'b', anchored: true, timestamp: 2, signature: 'sig2' }
  const r3 = { type: 'custody-receipt', intentId: 'x', relayPubkey: 'c', anchored: true, timestamp: 3, signature: 'sig3' }

  const root123 = computeReceiptRoot([r1, r2, r3])
  const root321 = computeReceiptRoot([r3, r2, r1])
  const root213 = computeReceiptRoot([r2, r1, r3])

  t.is(root123, root321, 'order does not matter (123 vs 321)')
  t.is(root123, root213, 'order does not matter (123 vs 213)')
  t.is(typeof root123, 'string')
  t.is(root123.length, 64, 'root is 64 hex chars (sha-256)')
})

test('custody signing: witness tombstone create + verify', (t) => {
  // A witness signs an attestation that a relay has stopped serving content
  // after retainUntil. The witness does NOT store content; it just observed.
  const witness = keyPair()
  const relay = keyPair()
  const now = Date.now()
  const intentId = hashHex('intent')
  const blindContentId = hashHex('blind')
  const nonServingProofHash = hashHex('non-serving-proof-A')

  const tombstone = createCustodyExpiryWitness({
    intentId,
    blindContentId,
    relayPubkey: b4a.toString(relay.publicKey, 'hex'),
    nonServingProofHash,
    catalogPresent: false,
    gatewayServing: false,
    activeSwarmObserved: false
  }, witness, { timestamp: now })

  t.is(tombstone.type, 'custody-expiry-witness')
  t.is(tombstone.witnessPubkey, b4a.toString(witness.publicKey, 'hex'))
  t.is(tombstone.relayPubkey, b4a.toString(relay.publicKey, 'hex'))
  t.is(tombstone.catalogPresent, false)
  t.is(tombstone.gatewayServing, false)
  t.ok(tombstone.signature, 'tombstone is signed')

  const verified = verifyCustodyEntry(tombstone, { now })
  t.is(verified.valid, true, 'witness signature verifies')
})

test('custody signing: tampered witness tombstone is rejected', (t) => {
  const witness = keyPair()
  const relay = keyPair()
  const now = Date.now()
  const tombstone = createCustodyExpiryWitness({
    intentId: hashHex('intent'),
    blindContentId: hashHex('blind'),
    relayPubkey: b4a.toString(relay.publicKey, 'hex'),
    nonServingProofHash: hashHex('proof'),
    catalogPresent: false,
    gatewayServing: false,
    activeSwarmObserved: false
  }, witness, { timestamp: now })

  // Tamper: flip catalogPresent to claim relay IS still serving (collusion)
  const tampered = { ...tombstone, catalogPresent: true }
  const verified = verifyCustodyEntry(tampered, { now })
  t.is(verified.valid, false, 'tampered witness rejected')
})

test('custody signing: witness tombstone transition requires post-expiry non-serving proof', (t) => {
  const publisher = keyPair()
  const relay = keyPair()
  const witness = keyPair()
  const now = Date.now()
  const blindContentId = hashHex('witness-transition-blind')
  const addressKey = hashHex('witness-transition-address')
  const intent = createCustodyIntent({
    addressKey,
    blindContentId,
    ciphertextRoot: hashHex('witness-transition-ciphertext'),
    contentVersion: 1,
    requiredReplicas: 1,
    deadline: now + 60_000,
    retainUntil: now + 120_000
  }, publisher, { timestamp: now })
  const nonServingProof = createCustodyNonServingProof({
    intentId: intent.intentId,
    addressKey,
    blindContentId,
    retainUntil: intent.retainUntil,
    notServing: true,
    catalogPresent: false,
    activeSwarmServing: false
  }, relay, { timestamp: now + 130_000 })
  const validWitness = createCustodyExpiryWitness({
    intentId: intent.intentId,
    blindContentId,
    relayPubkey: b4a.toString(relay.publicKey, 'hex'),
    nonServingProofHash: hashHex(nonServingProof),
    catalogPresent: false,
    gatewayServing: false,
    activeSwarmObserved: false
  }, witness, { timestamp: now + 131_000 })

  const valid = validateCustodyTransition(validWitness, { intent, nonServingProofs: [nonServingProof] })
  t.is(valid.valid, true, 'valid witness with matching non-serving proof is accepted')

  const beforeExpiry = { ...validWitness, timestamp: now + 60_000 }
  const early = validateCustodyTransition(beforeExpiry, { intent, nonServingProofs: [nonServingProof] })
  t.is(early.valid, false, 'witness before retainUntil is rejected')

  const serving = { ...validWitness, gatewayServing: true }
  const observedServing = validateCustodyTransition(serving, { intent, nonServingProofs: [nonServingProof] })
  t.is(observedServing.valid, false, 'witness that observed serving is rejected')

  const wrongHash = { ...validWitness, nonServingProofHash: hashHex('wrong-proof') }
  const mismatch = validateCustodyTransition(wrongHash, { intent, nonServingProofs: [nonServingProof] })
  t.is(mismatch.valid, false, 'witness must bind to a known non-serving proof')
})

test('custody signing: receiptRoot is unique per quorum (different signatures → different roots)', (t) => {
  // Different signatures across the quorum must produce different roots —
  // commits over different relay sets are distinguishable.
  const r1 = { type: 'custody-receipt', intentId: 'x', relayPubkey: 'a', anchored: true, timestamp: 1, signature: 'sig1' }
  const r2 = { type: 'custody-receipt', intentId: 'x', relayPubkey: 'b', anchored: true, timestamp: 2, signature: 'sig2' }
  const r3 = { type: 'custody-receipt', intentId: 'x', relayPubkey: 'c', anchored: true, timestamp: 3, signature: 'sig3' }

  const rootAB = computeReceiptRoot([r1, r2])
  const rootAC = computeReceiptRoot([r1, r3])
  t.not(rootAB, rootAC, 'different relay quorums produce different roots')
})

// ─── Partial-quorum support ────────────────────────────────────────────
//
// validateCustodyTransition for 'custody-commit' previously rejected any
// commit whose set of receipts didn't match the relay's full set of
// anchored receipts. That made T-of-N quorums (T < N) inherently racy:
// any receipts arriving after the publisher's threshold-collect but
// before the relay's commit-process tipped the receiptRoot or
// relayQuorum into a mismatch. drop-pear's v3.0.7 partial-quorum design
// hit this whenever real-world gossip arrived faster than the publisher
// submitted the commit (which was every time against ≥4-relay sets).
//
// The fix: when commit carries an explicit `relayQuorum`, validate only
// against the receipts from those specific pubkeys. Publisher signature
// on the commit binds the chosen set, so a malicious publisher can't
// swap in alternative receipts after the relays have signed.
//
// These tests pin the new behaviour.

test('custody signing: commit with explicit relayQuorum is validated against the named subset, not all visible receipts (partial-quorum support)', (t) => {
  const publisher = keyPair()
  const relayA = keyPair()
  const relayB = keyPair()
  const relayC = keyPair()
  const now = Date.now()
  const blindContentId = hashHex('partial-quorum-blind')
  const ciphertextRoot = hashHex('partial-quorum-ciphertext')

  // n=3 relays, threshold=2. Intent declares the 2-of-3 floor.
  const intent = createCustodyIntent({
    addressKey: hashHex('partial-quorum-address'),
    blindContentId,
    ciphertextRoot,
    contentVersion: 1,
    requiredReplicas: 2, // = threshold = the commit-quorum floor
    deadline: now + 60_000,
    retainUntil: now + 120_000
  }, publisher, { timestamp: now })

  const mkReceipt = (relay, ts) => createCustodyReceipt({
    intentId: intent.intentId,
    blindContentId,
    ciphertextRoot,
    contentVersion: 1,
    retainUntil: now + 120_000,
    relayRegion: 'test',
    shardIds: []
  }, relay, { timestamp: ts })

  const receiptA = mkReceipt(relayA, now + 1000)
  const receiptB = mkReceipt(relayB, now + 1500)
  // receiptC arrives later via gossip — the publisher collected only A+B,
  // then went to commit. Previously this caused receiptRoot mismatch on
  // any relay that had seen receiptC via gossip.
  const receiptC = mkReceipt(relayC, now + 2000)

  // Publisher commits referencing only A+B.
  const ab = [receiptA, receiptB]
  const commit = createCustodyCommit({
    intentId: intent.intentId,
    blindContentId,
    ciphertextRoot,
    contentVersion: 1,
    relayQuorum: ab.map(r => r.relayPubkey).sort(),
    receiptRoot: computeReceiptRoot(ab)
  }, publisher, { timestamp: now + 3000 })

  // Relay's view includes C via gossip (the bug condition).
  const status = { intent, receipts: [receiptA, receiptB, receiptC] }
  const result = validateCustodyTransition(commit, status)
  t.is(result.valid, true, `commit must validate against named subset (got: ${result.reason || 'OK'})`)
})

test('custody signing: commit naming a pubkey with no visible receipt is rejected', (t) => {
  // Publisher can't reference receipts the relay can't see — that would
  // let a malicious publisher forge a commit citing a relay that never
  // signed. The new check requires every pubkey in relayQuorum to have
  // a corresponding anchored receipt visible to the relay.
  const publisher = keyPair()
  const relayA = keyPair()
  const relayB = keyPair()
  const relayPhantom = keyPair() // never signed anything
  const now = Date.now()
  const blindContentId = hashHex('phantom-blind')
  const ciphertextRoot = hashHex('phantom-ciphertext')

  const intent = createCustodyIntent({
    addressKey: hashHex('phantom-address'),
    blindContentId,
    ciphertextRoot,
    contentVersion: 1,
    requiredReplicas: 2,
    deadline: now + 60_000,
    retainUntil: now + 120_000
  }, publisher, { timestamp: now })

  const mkReceipt = (relay) => createCustodyReceipt({
    intentId: intent.intentId,
    blindContentId,
    ciphertextRoot,
    contentVersion: 1,
    retainUntil: now + 120_000,
    relayRegion: 'test',
    shardIds: []
  }, relay, { timestamp: now + 1000 })

  const receiptA = mkReceipt(relayA)
  const receiptB = mkReceipt(relayB)

  // Commit claims phantom is in the quorum (it never anchored).
  const fakeQuorum = [
    receiptA.relayPubkey,
    b4a.toString(relayPhantom.publicKey, 'hex')
  ].sort()
  const commit = createCustodyCommit({
    intentId: intent.intentId,
    blindContentId,
    ciphertextRoot,
    contentVersion: 1,
    relayQuorum: fakeQuorum,
    // Even with the receiptRoot computed correctly, the named pubkey
    // doesn't correspond to a visible receipt → reject.
    receiptRoot: hashHex('fake-root')
  }, publisher, { timestamp: now + 2000 })

  const status = { intent, receipts: [receiptA, receiptB] }
  const result = validateCustodyTransition(commit, status)
  t.is(result.valid, false, 'phantom quorum must be rejected')
  t.ok(/not yet visible/.test(result.reason), `expected "not yet visible" reason, got: ${result.reason}`)
})

test('custody signing: legacy commit without relayQuorum still validates against all visible receipts (backwards compat)', (t) => {
  // Pre-this-change publishers always committed with relayQuorum = all
  // visible receipts. Those still need to verify cleanly. Backwards-compat
  // path: empty or missing relayQuorum → use all anchored receipts.
  const publisher = keyPair()
  const relay = keyPair()
  const now = Date.now()
  const blindContentId = hashHex('legacy-blind')
  const ciphertextRoot = hashHex('legacy-ciphertext')

  const intent = createCustodyIntent({
    addressKey: hashHex('legacy-address'),
    blindContentId,
    ciphertextRoot,
    contentVersion: 1,
    requiredReplicas: 1,
    deadline: now + 60_000,
    retainUntil: now + 120_000
  }, publisher, { timestamp: now })

  const receipt = createCustodyReceipt({
    intentId: intent.intentId,
    blindContentId,
    ciphertextRoot,
    contentVersion: 1,
    retainUntil: now + 120_000,
    relayRegion: 'test',
    shardIds: []
  }, relay, { timestamp: now + 1000 })

  // Legacy commit — relayQuorum = single relay (matches all visible).
  const commit = createCustodyCommit({
    intentId: intent.intentId,
    blindContentId,
    ciphertextRoot,
    contentVersion: 1,
    relayQuorum: [receipt.relayPubkey],
    receiptRoot: computeReceiptRoot([receipt])
  }, publisher, { timestamp: now + 2000 })

  const status = { intent, receipts: [receipt] }
  const result = validateCustodyTransition(commit, status)
  t.is(result.valid, true, 'legacy single-relay commit still validates')
})