OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / custody-claim-path-witness.test.js

256 lines · javascript ref:
// custody-claim-path-witness: v0.8.27 regression tests for the claim-path
// erasure witness.
//
// Background. The custody expiry sweep historically only attested the
// UNCLAIMED-expiry path: content that sat untouched until its retainUntil
// window lapsed. validateCustodyTransition enforced that floor by rejecting
// any non-serving-proof / expiry-witness timestamped before retainUntil.
//
// That left a gap: a *claimed* drop — where the publisher has signed a
// source-retired entry ("I deleted the source; the handoff is complete",
// which itself requires a custody commit, so the recipient/quorum already
// holds the content) — produced no third-party-provable destruction until
// the (often weeks-long) retain window elapsed. That is the exact piece
// dmc flagged as missing for "provable to a third party."
//
// v0.8.27 relaxes the retainUntil floor on the claim path: once a validated
// source-retirement exists, a relay may attest non-serving (and peers may
// witness) immediately. These tests pin both the pure-function relaxation
// and the registry-append integration.

import test from 'brittle'
import b4a from 'b4a'
import sodium from 'sodium-universal'
import { SeedingRegistry } from 'p2p-hiverelay/core/registry/index.js'
import {
  createCustodyNonServingProof,
  createCustodyExpiryWitness,
  createCustodyIntent,
  validateCustodyTransition,
  hashHex
} 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 }
}

function registryFixture () {
  const registry = new SeedingRegistry(null, null)
  const blocks = []
  registry.localLog = {
    async append (block) {
      blocks.push(JSON.parse(b4a.toString(block)))
    }
  }
  return { registry, blocks }
}

test('claim-path: non-serving-proof before retainUntil is rejected without retirement, accepted with it', (t) => {
  const publisher = keyPair()
  const relay = keyPair()
  const now = Date.now()
  const blindContentId = hashHex('claim-nsp-blind')
  const addressKey = hashHex('claim-nsp-address')

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

  // Proof timestamped well BEFORE retainUntil — the claim-path scenario.
  const nonServing = createCustodyNonServingProof({
    intentId: intent.intentId,
    addressKey,
    blindContentId,
    retainUntil: intent.retainUntil,
    notServing: true,
    notServingReason: 'source-retired',
    catalogPresent: false,
    activeSwarmServing: false
  }, relay, { timestamp: now + 2_000 })

  const withoutRetirement = validateCustodyTransition(nonServing, { intent })
  t.is(withoutRetirement.valid, false, 'rejected before retainUntil with no retirement')
  t.ok(/before retainUntil/.test(withoutRetirement.reason), 'reason cites retainUntil floor')

  // A truthy sourceRetired in the status context discharges the floor.
  const withRetirement = validateCustodyTransition(nonServing, {
    intent,
    sourceRetired: { type: 'source-retired', intentId: intent.intentId }
  })
  t.is(withRetirement.valid, true, 'accepted before retainUntil once source is retired')

  // The getCustodyStatus() field name (`sourceRetirement`) is honored too.
  const withRetirementAlias = validateCustodyTransition(nonServing, {
    intent,
    sourceRetirement: { type: 'source-retired', intentId: intent.intentId }
  })
  t.is(withRetirementAlias.valid, true, 'sourceRetirement alias also discharges the floor')
})

test('claim-path: expiry-witness before retainUntil follows the same retirement rule', (t) => {
  const publisher = keyPair()
  const relay = keyPair()
  const witness = keyPair()
  const now = Date.now()
  const blindContentId = hashHex('claim-witness-blind')
  const addressKey = hashHex('claim-witness-address')

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

  const nonServing = createCustodyNonServingProof({
    intentId: intent.intentId,
    addressKey,
    blindContentId,
    retainUntil: intent.retainUntil,
    notServing: true,
    notServingReason: 'source-retired',
    catalogPresent: false,
    activeSwarmServing: false
  }, relay, { timestamp: now + 2_000 })

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

  const ctx = { intent, nonServingProofs: [nonServing] }
  const early = validateCustodyTransition(tombstone, ctx)
  t.is(early.valid, false, 'witness before retainUntil rejected with no retirement')
  t.ok(/before retainUntil/.test(early.reason), 'reason cites retainUntil floor')

  const retired = validateCustodyTransition(tombstone, { ...ctx, sourceRetired: { type: 'source-retired' } })
  t.is(retired.valid, true, 'witness before retainUntil accepted once source is retired')
})

test('claim-path integration: registry rejects a pre-retainUntil non-serving-proof until the source is retired', async (t) => {
  const { registry } = registryFixture()
  const publisher = keyPair()
  const relayA = keyPair()
  const relayB = keyPair()
  const now = Date.now()
  const blindContentId = hashHex('claim-int-blind')
  const ciphertextRoot = hashHex('claim-int-ciphertext')
  const addressKey = hashHex('claim-int-address')

  const intent = await registry.publishCustodyIntent({
    addressKey,
    blindContentId,
    ciphertextRoot,
    contentVersion: 1,
    requiredReplicas: 2,
    deadline: now + 60_000,
    retainUntil: now + 120_000
  }, publisher)

  await registry.recordCustodyReceipt({
    intentId: intent.intentId, blindContentId, ciphertextRoot, contentVersion: 1, retainUntil: intent.retainUntil
  }, relayA)
  await registry.recordCustodyReceipt({
    intentId: intent.intentId, blindContentId, ciphertextRoot, contentVersion: 1, retainUntil: intent.retainUntil
  }, relayB)
  await registry.publishCustodyCommit({ intentId: intent.intentId }, publisher)

  const mkProof = (ts) => createCustodyNonServingProof({
    intentId: intent.intentId,
    addressKey,
    blindContentId,
    retainUntil: intent.retainUntil,
    notServing: true,
    notServingReason: 'source-retired',
    catalogPresent: false,
    activeSwarmServing: false
  }, relayA, { timestamp: ts })

  // Committed but NOT yet source-retired → the retainUntil floor still holds.
  await t.exception(
    registry.recordCustodyNonServingProof(mkProof(now + 2_000)),
    /INVALID_CUSTODY_TRANSITION/,
    'pre-retainUntil proof rejected while source is not retired'
  )

  // Publisher retires the source — the claim is complete.
  await registry.publishSourceRetired({ intentId: intent.intentId }, publisher)
  t.is(registry.getCustodyStatus(intent.intentId).sourceRetired, true, 'source retired')

  // Now the same early-timestamped proof is accepted.
  await registry.recordCustodyNonServingProof(mkProof(now + 3_000))
  const status = registry.getCustodyStatus(intent.intentId)
  t.is(status.nonServingProofCount, 1, 'claim-path non-serving-proof indexed before retainUntil')
  t.alike(status.nonServingRelays, [b4a.toString(relayA.publicKey, 'hex')], 'attesting relay reported')
})

test('claim-path integration: a peer witness counts as valid before retainUntil once retired', async (t) => {
  const { registry } = registryFixture()
  const publisher = keyPair()
  const relay = keyPair()
  const witness = keyPair()
  const now = Date.now()
  const blindContentId = hashHex('claim-wint-blind')
  const ciphertextRoot = hashHex('claim-wint-ciphertext')
  const addressKey = hashHex('claim-wint-address')

  const intent = await registry.publishCustodyIntent({
    addressKey,
    blindContentId,
    ciphertextRoot,
    contentVersion: 1,
    requiredReplicas: 1,
    deadline: now + 60_000,
    retainUntil: now + 120_000
  }, publisher)
  await registry.recordCustodyReceipt({
    intentId: intent.intentId, blindContentId, ciphertextRoot, contentVersion: 1, retainUntil: intent.retainUntil
  }, relay)
  await registry.publishCustodyCommit({ intentId: intent.intentId }, publisher)
  await registry.publishSourceRetired({ intentId: intent.intentId }, publisher)

  const nonServing = await registry.recordCustodyNonServingProof(createCustodyNonServingProof({
    intentId: intent.intentId,
    addressKey,
    blindContentId,
    retainUntil: intent.retainUntil,
    notServing: true,
    notServingReason: 'source-retired',
    catalogPresent: false,
    activeSwarmServing: false
  }, relay, { timestamp: now + 2_000 }))

  // Witness timestamped before retainUntil — valid because the source is retired.
  const tombstone = await registry.recordCustodyExpiryWitness({
    intentId: intent.intentId,
    timestamp: now + 3_000,
    relayPubkey: b4a.toString(relay.publicKey, 'hex'),
    nonServingProofHash: hashHex(nonServing),
    catalogPresent: false,
    gatewayServing: false,
    activeSwarmObserved: false
  }, witness)

  const status = registry.getCustodyStatus(intent.intentId)
  t.ok(tombstone.signature, 'claim-path witness signed')
  t.is(status.expiryWitnessCount, 1, 'raw witness indexed')
  t.is(status.validExpiryWitnessCount, 1, 'claim-path witness counts as valid before retainUntil')
})