OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / proof-of-storage.test.js

311 lines · javascript ref:
/**
 * Proof-of-Storage (Tier 2) — adversarial suite.
 *
 * A false trustless proof is worse than none, so this exercises the REAL
 * Hypercore primitives (temp-dir cores, genuine Merkle proofs) and asserts that
 * every forgery is rejected: fake content (even with a self-consistent relay
 * signature), wrong index, wrong core, wrong/forged relay signature, replayed
 * nonce, and a relay that simply doesn't hold the block.
 */
import test from 'brittle'
import Hypercore from 'hypercore'
import b4a from 'b4a'
import os from 'os'
import path from 'path'
import { readFile } from 'fs/promises'
import c from 'compact-encoding'
import sodium from 'sodium-universal'
import { wire } from 'hypercore/lib/messages.js'
import {
  buildStorageProof,
  PROOF_KIND_RETRIEVABILITY,
  RETRIEVABILITY_PROOF_DOMAIN,
  RETRIEVABILITY_PROOF_LIMITATION,
  RETRIEVABILITY_PROOF_SIGNATURE_PROFILE,
  retrievabilityProofSignable,
  verifyStorageProof,
  storageProofSignable
} from 'p2p-hiverelay/core/protocol/proof-of-storage.js'

const RETRIEVABILITY_VECTOR_URL = new URL(
  '../fixtures/relaykernel-profile/retrievability-proof-signable-v1.json',
  import.meta.url
)

let _n = 0
function tmp () { return path.join(os.tmpdir(), 'hr-pos-' + process.pid + '-' + (_n++)) }
function relayKeyPair () {
  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 freshNonce () { const n = b4a.alloc(32); sodium.randombytes_buf(n); return n }

async function seededCore (blocks) {
  const core = new Hypercore(tmp())
  await core.ready()
  await core.append(blocks)
  return core
}
async function verifierFor (key) {
  const v = new Hypercore(tmp(), key)
  await v.ready()
  return v
}
async function loadRetrievabilityVector () {
  return JSON.parse(await readFile(RETRIEVABILITY_VECTOR_URL, 'utf8'))
}
function keyPairFromSeed (seedHex) {
  const publicKey = b4a.alloc(sodium.crypto_sign_PUBLICKEYBYTES)
  const secretKey = b4a.alloc(sodium.crypto_sign_SECRETKEYBYTES)
  sodium.crypto_sign_seed_keypair(publicKey, secretKey, b4a.from(seedHex, 'hex'))
  return { publicKey, secretKey }
}

test('RelayKernel profile: retrievability signature vector is domain-separated', async (t) => {
  const vector = await loadRetrievabilityVector()
  const keyPair = keyPairFromSeed(vector.seedHex)
  const coreKey = b4a.from(vector.coreKey, 'hex')
  const nonce = b4a.from(vector.nonce, 'hex')
  const blockValue = b4a.from(vector.blockValueHex, 'hex')
  const { signable, blockHash } = retrievabilityProofSignable(coreKey, vector.index, nonce, blockValue)
  const legacy = storageProofSignable(coreKey, vector.index, nonce, blockValue)

  t.is(vector.domain, RETRIEVABILITY_PROOF_DOMAIN)
  t.is(b4a.toString(keyPair.publicKey, 'hex'), vector.relayPubkey, 'seed derives fixture relay key')
  t.is(b4a.toString(signable, 'hex'), vector.signableHex, 'domain-separated bytes are stable')
  t.is(b4a.toString(blockHash, 'hex'), vector.blockHash, 'block hash is stable')
  t.unlike(b4a.toString(legacy.signable, 'hex'), vector.signableHex, 'legacy bytes remain distinct')
  t.ok(
    sodium.crypto_sign_verify_detached(
      b4a.from(vector.signature, 'hex'),
      signable,
      keyPair.publicKey
    ),
    'fixture signature verifies against domain-separated bytes'
  )
  t.absent(
    sodium.crypto_sign_verify_detached(
      b4a.from(vector.signature, 'hex'),
      legacy.signable,
      keyPair.publicKey
    ),
    'fixture signature cannot be replayed as legacy proof bytes'
  )
})

test('VALID proof: genuine block + relay signature over the challenge => valid', async (t) => {
  const core = await seededCore([b4a.from('a'), b4a.from('bb'), b4a.from('ccc'), b4a.from('dddd')])
  const relay = relayKeyPair()
  const nonce = freshNonce()
  const response = await buildStorageProof({ core, index: 2, nonce, keyPair: relay })
  t.is(response.proofKind, PROOF_KIND_RETRIEVABILITY)
  t.is(response.proofLimit, RETRIEVABILITY_PROOF_LIMITATION)
  const verifierCore = await verifierFor(core.key)
  const v = await verifyStorageProof({
    verifierCore,
    response,
    expect: { driveKey: core.key, index: 2, nonce, relayPubkey: relay.publicKey }
  })
  t.ok(v.valid, 'valid'); t.ok(v.contentValid, 'content verified vs drive key'); t.ok(v.sigValid, 'relay sig ok')
  t.is(v.proofKind, PROOF_KIND_RETRIEVABILITY)
  t.is(v.proofLimit, RETRIEVABILITY_PROOF_LIMITATION)
  await core.close(); await verifierCore.close()
})

test('RelayKernel profile: opt-in retrievability signature verifies without changing legacy default', async (t) => {
  const core = await seededCore([b4a.from('aa'), b4a.from('bb'), b4a.from('cc')])
  const relay = relayKeyPair()
  const nonce = freshNonce()
  const response = await buildStorageProof({
    core,
    index: 1,
    nonce,
    keyPair: relay,
    signatureProfile: RETRIEVABILITY_PROOF_SIGNATURE_PROFILE
  })
  t.is(response.signatureProfile, RETRIEVABILITY_PROOF_SIGNATURE_PROFILE, 'profile is explicit when opted in')

  const expect = { driveKey: core.key, index: 1, nonce, relayPubkey: relay.publicKey }
  const verifierCore = await verifierFor(core.key)
  const ok = await verifyStorageProof({ verifierCore, response, expect })
  t.ok(ok.valid, 'domain-separated profile verifies')

  const downgraded = { ...response }
  delete downgraded.signatureProfile
  const legacy = await verifyStorageProof({
    verifierCore: await verifierFor(core.key),
    response: downgraded,
    expect
  })
  t.absent(legacy.valid, 'profile signature does not verify as legacy bytes')
  t.is(legacy.reason, 'SIG_INVALID')

  const unsupported = await verifyStorageProof({
    verifierCore: await verifierFor(core.key),
    response: { ...response, signatureProfile: 'unknown-profile' },
    expect
  })
  t.absent(unsupported.valid, 'unsupported profile rejected')
  t.is(unsupported.reason, 'SIGNATURE_PROFILE_UNSUPPORTED')

  await core.close(); await verifierCore.close()
})

test('proof kind metadata is additive but rejects explicit wrong claims', async (t) => {
  const core = await seededCore([b4a.from('aa'), b4a.from('bb')])
  const relay = relayKeyPair()
  const nonce = freshNonce()
  const response = await buildStorageProof({ core, index: 1, nonce, keyPair: relay })
  const expect = { driveKey: core.key, index: 1, nonce, relayPubkey: relay.publicKey }

  const legacyShape = { ...response }
  delete legacyShape.proofKind
  delete legacyShape.proofLimit
  const legacyVerifier = await verifierFor(core.key)
  const legacyOk = await verifyStorageProof({
    verifierCore: legacyVerifier,
    response: legacyShape,
    expect
  })
  t.ok(legacyOk.valid, 'legacy response without proofKind stays compatible')

  const wrongKindVerifier = await verifierFor(core.key)
  const wrongKind = await verifyStorageProof({
    verifierCore: wrongKindVerifier,
    response: { ...response, proofKind: 'proof-of-replication' },
    expect
  })
  t.absent(wrongKind.valid, 'explicit overclaim is rejected')
  t.is(wrongKind.reason, 'PROOF_KIND_UNSUPPORTED')

  await legacyVerifier.close(); await wrongKindVerifier.close(); await core.close()
})

test('one verifier core validates MULTIPLE random samples', async (t) => {
  const core = await seededCore(['0', '1', '2', '3', '4', '5'].map((s) => b4a.from('blk-' + s)))
  const relay = relayKeyPair()
  const verifierCore = await verifierFor(core.key)
  for (const i of [5, 0, 3]) {
    const nonce = freshNonce()
    const response = await buildStorageProof({ core, index: i, nonce, keyPair: relay })
    const v = await verifyStorageProof({ verifierCore, response, expect: { driveKey: core.key, index: i, nonce, relayPubkey: relay.publicKey } })
    t.ok(v.valid, 'sample ' + i + ' valid')
  }
  await core.close(); await verifierCore.close()
})

test('FORGED content: relay claims a block it does not have (fake value + self-consistent sig) => rejected', async (t) => {
  const core = await seededCore([b4a.from('real-0'), b4a.from('real-1'), b4a.from('real-2')])
  const relay = relayKeyPair()
  const nonce = freshNonce()
  const honest = await buildStorageProof({ core, index: 1, nonce, keyPair: relay })

  // Attacker rewrites the proven block to fake content and RE-SIGNS honestly over
  // the fake content — so the signature is internally consistent. Only the
  // Hypercore Merkle check can catch this, and it must.
  const decoded = c.decode(wire.data, b4a.from(honest.proof, 'hex'))
  decoded.block.value = b4a.from('FORGERY')
  const proofBytes = c.encode(wire.data, decoded)
  const { signable, blockHash } = storageProofSignable(core.key, 1, nonce, decoded.block.value)
  const sig = b4a.alloc(sodium.crypto_sign_BYTES)
  sodium.crypto_sign_detached(sig, signable, relay.secretKey)
  const forged = { ...honest, proof: b4a.toString(proofBytes, 'hex'), blockHash: b4a.toString(blockHash, 'hex'), signature: b4a.toString(sig, 'hex') }

  const verifierCore = await verifierFor(core.key)
  const v = await verifyStorageProof({ verifierCore, response: forged, expect: { driveKey: core.key, index: 1, nonce, relayPubkey: relay.publicKey } })
  t.absent(v.valid, 'forged content rejected'); t.absent(v.contentValid)
  t.ok(String(v.reason).startsWith('CONTENT_INVALID'), 'rejected by Hypercore Merkle check: ' + v.reason)
  await core.close(); await verifierCore.close()
})

test('WRONG index: response for a different block than challenged => rejected', async (t) => {
  const core = await seededCore([b4a.from('a'), b4a.from('b'), b4a.from('c')])
  const relay = relayKeyPair(); const nonce = freshNonce()
  const response = await buildStorageProof({ core, index: 0, nonce, keyPair: relay })
  const verifierCore = await verifierFor(core.key)
  const v = await verifyStorageProof({ verifierCore, response, expect: { driveKey: core.key, index: 2, nonce, relayPubkey: relay.publicKey } })
  t.absent(v.valid); t.is(v.reason, 'INDEX_MISMATCH')
  await core.close(); await verifierCore.close()
})

test('WRONG core: proof for a different drive than expected => rejected', async (t) => {
  const core = await seededCore([b4a.from('a'), b4a.from('b')])
  const other = await seededCore([b4a.from('x'), b4a.from('y')])
  const relay = relayKeyPair(); const nonce = freshNonce()
  const response = await buildStorageProof({ core, index: 1, nonce, keyPair: relay })
  const verifierCore = await verifierFor(other.key)
  const v = await verifyStorageProof({ verifierCore, response, expect: { driveKey: other.key, index: 1, nonce, relayPubkey: relay.publicKey } })
  t.absent(v.valid); t.is(v.reason, 'CORE_MISMATCH')
  await core.close(); await other.close(); await verifierCore.close()
})

test('FORGED signature: signed by an attacker key but labelled as the honest relay => rejected', async (t) => {
  const core = await seededCore([b4a.from('a'), b4a.from('b'), b4a.from('c')])
  const honestRelay = relayKeyPair(); const attacker = relayKeyPair(); const nonce = freshNonce()
  // Attacker produces a genuine content proof but signs with its own key, then
  // labels the response with the honest relay's pubkey to impersonate it.
  const resp = await buildStorageProof({ core, index: 2, nonce, keyPair: attacker })
  resp.relayPubkey = b4a.toString(honestRelay.publicKey, 'hex')
  const verifierCore = await verifierFor(core.key)
  const v = await verifyStorageProof({ verifierCore, response: resp, expect: { driveKey: core.key, index: 2, nonce, relayPubkey: honestRelay.publicKey } })
  t.absent(v.valid); t.ok(v.contentValid, 'content is genuine'); t.absent(v.sigValid); t.is(v.reason, 'SIG_INVALID')
  await core.close(); await verifierCore.close()
})

test('REPLAY: a proof signed for an old nonce, re-labelled with the new nonce => rejected', async (t) => {
  const core = await seededCore([b4a.from('a'), b4a.from('b'), b4a.from('c')])
  const relay = relayKeyPair()
  const oldNonce = freshNonce(); const newNonce = freshNonce()
  const captured = await buildStorageProof({ core, index: 1, nonce: oldNonce, keyPair: relay })
  // Attacker relabels the captured proof's nonce to the fresh challenge but
  // cannot re-sign (no relay key) — sig still covers oldNonce.
  const replay = { ...captured, nonce: b4a.toString(newNonce, 'hex') }
  const verifierCore = await verifierFor(core.key)
  const v = await verifyStorageProof({ verifierCore, response: replay, expect: { driveKey: core.key, index: 1, nonce: newNonce, relayPubkey: relay.publicKey } })
  t.absent(v.valid, 'replay rejected'); t.is(v.reason, 'SIG_INVALID', 'sig was bound to the old nonce')
  // And the un-relabelled capture is rejected on the nonce check itself.
  const v2 = await verifyStorageProof({ verifierCore: await verifierFor(core.key), response: captured, expect: { driveKey: core.key, index: 1, nonce: newNonce, relayPubkey: relay.publicKey } })
  t.is(v2.reason, 'NONCE_MISMATCH')
  await core.close(); await verifierCore.close()
})

test('BLOCKHASH mismatch: relay lies about the content hash it signed => rejected', async (t) => {
  const core = await seededCore([b4a.from('a'), b4a.from('b'), b4a.from('c')])
  const relay = relayKeyPair(); const nonce = freshNonce()
  const resp = await buildStorageProof({ core, index: 0, nonce, keyPair: relay })
  resp.blockHash = b4a.toString(b4a.alloc(32, 0xff), 'hex') // wrong hash
  const verifierCore = await verifierFor(core.key)
  const v = await verifyStorageProof({ verifierCore, response: resp, expect: { driveKey: core.key, index: 0, nonce, relayPubkey: relay.publicKey } })
  t.absent(v.valid); t.is(v.reason, 'BLOCKHASH_MISMATCH')
  await core.close(); await verifierCore.close()
})

test('LENGTH PIN: a proof against an old/short signed version is rejected when a min length is required', async (t) => {
  const core = await seededCore([b4a.from('a'), b4a.from('b'), b4a.from('c')]) // length 3
  const relay = relayKeyPair(); const nonce = freshNonce()
  const response = await buildStorageProof({ core, index: 1, nonce, keyPair: relay })
  const verifierCore = await verifierFor(core.key)
  const exp = { driveKey: core.key, index: 1, nonce, relayPubkey: relay.publicKey }

  // Client knows the current head is 5 blocks — an old length-3 proof is stale.
  const stale = await verifyStorageProof({ verifierCore, response, expect: { ...exp, minLength: 5 } })
  t.absent(stale.valid, 'stale (short) proof rejected'); t.is(stale.reason, 'LENGTH_TOO_SHORT')
  t.is(stale.provenLength, 3, 'reports the author-signed length it proved')

  // Same proof passes when the pin matches what it actually proved.
  const ok = await verifyStorageProof({ verifierCore: await verifierFor(core.key), response, expect: { ...exp, minLength: 3 } })
  t.ok(ok.valid, 'accepted when minLength is satisfied'); t.ok(ok.lengthValid)
  await core.close(); await verifierCore.close()
})

test('relay does NOT hold the block: buildStorageProof fails honestly (no fetch-on-demand)', async (t) => {
  // A key-only core that has never downloaded block 0.
  const author = await seededCore([b4a.from('a'), b4a.from('b')])
  const empty = await verifierFor(author.key) // knows the key, holds nothing
  const relay = relayKeyPair(); const nonce = freshNonce()
  await t.exception(buildStorageProof({ core: empty, index: 0, nonce, keyPair: relay }), /BLOCK_NOT_LOCAL|BLOCK_OUT_OF_RANGE/)
  await author.close(); await empty.close()
})