OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / seed-request-builder.test.js

460 lines · javascript ref:
/**
 * seed-request-builder tests.
 *
 * The builder is the shared validation + opts-assembly path used by both
 * the HTTP /api/v1/seed route and the Protomux hiverelay-publish channel's
 * 'seed' submit kind. Tests pin its contract so both transports stay in
 * lockstep.
 *
 * Signature verification uses a real Ed25519 keypair so we exercise the
 * actual verifySeedRequestSignature path, not a mock. Catches regressions
 * in the canonical-payload layout that would silently break v0.8.x
 * publishers signing against the v2 shape.
 */

import test from 'brittle'
import b4a from 'b4a'
import sodium from 'sodium-universal'
import {
  serializeSeedRequestForReplaySigning,
  serializeSeedRequestForSigning
} from 'p2p-hiverelay/core/protocol/seed-request.js'
import {
  buildPublisherSignedSeedOpts,
  consumePublisherSeedReplayNonce,
  extractCustodySeedOpts
} from 'p2p-hiverelay/core/seed-request-builder.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 }
}

// Build a publisher-signed body exactly as a real publisher would —
// canonical-serialize, Ed25519-sign, then convert to the wire JSON shape.
function signedBody (kp, overrides = {}) {
  const fields = {
    appKey: b4a.alloc(32, 0x42),
    discoveryKeys: [],
    replicationFactor: 3,
    maxStorageBytes: 500 * 1024 * 1024,
    ttlSeconds: 30 * 24 * 3600,
    bountyRate: 0,
    revocable: true,
    unseedFreezeMs: 0,
    durability: 0,
    publisherPubkey: kp.publicKey,
    issuedAt: overrides.issuedAt,
    requestNonce: overrides.requestNonce ? b4a.from(overrides.requestNonce, 'hex') : undefined,
    ...overrides
  }
  const payload = overrides.issuedAt !== undefined || overrides.requestNonce !== undefined
    ? serializeSeedRequestForReplaySigning(fields)
    : serializeSeedRequestForSigning(fields)
  const sig = b4a.alloc(sodium.crypto_sign_BYTES)
  sodium.crypto_sign_detached(sig, payload, kp.secretKey)
  const body = {
    appKey: b4a.toString(fields.appKey, 'hex'),
    discoveryKeys: fields.discoveryKeys.map(dk => b4a.toString(dk, 'hex')),
    replicationFactor: fields.replicationFactor,
    maxStorageBytes: fields.maxStorageBytes,
    ttlSeconds: fields.ttlSeconds,
    bountyRate: fields.bountyRate,
    revocable: fields.revocable,
    unseedFreezeMs: fields.unseedFreezeMs,
    durability: fields.durability,
    publisherPubkey: b4a.toString(fields.publisherPubkey, 'hex'),
    publisherSignature: b4a.toString(sig, 'hex')
  }
  if (fields.issuedAt !== undefined) body.issuedAt = fields.issuedAt
  if (fields.requestNonce !== undefined) {
    body.requestNonce = b4a.isBuffer(fields.requestNonce)
      ? b4a.toString(fields.requestNonce, 'hex')
      : fields.requestNonce
  }
  return body
}

// ─── Happy path ─────────────────────────────────────────────────────

test('builder: signed body with defaults returns ok + canonical opts', (t) => {
  const kp = keyPair()
  const body = signedBody(kp)
  const r = buildPublisherSignedSeedOpts(body)
  t.is(r.ok, true)
  t.is(r.appKey, body.appKey)
  t.is(r.opts.replicas, 3)
  t.is(r.opts.maxStorage, 500 * 1024 * 1024)
  t.is(r.opts.ttlDays, 30)
  t.is(r.opts.bountyRate, 0)
  t.is(r.opts.revocable, true)
  t.is(r.opts.unseedFreezeMs, 0)
  t.is(r.opts.durability, 0)
  t.is(r.opts.publisherPubkey, body.publisherPubkey)
  t.is(r.opts.publisherSignature, body.publisherSignature)
})

test('builder: replay-v1 signed body returns replay metadata and cache rejects duplicates', (t) => {
  const kp = keyPair()
  const now = 1782753600000
  const body = signedBody(kp, {
    issuedAt: now - 1000,
    requestNonce: '01'.repeat(16)
  })
  const r = buildPublisherSignedSeedOpts(body, { now })
  t.is(r.ok, true)
  t.is(r.opts.seedSignatureProfile, 'replay-v1')
  t.is(r.opts.issuedAt, body.issuedAt)
  t.is(r.opts.requestNonce, body.requestNonce)
  t.is(r.replay.requestNonce, body.requestNonce)

  const cache = new Map()
  t.is(consumePublisherSeedReplayNonce(r.replay, cache, { now }).ok, true)
  const duplicate = consumePublisherSeedReplayNonce(r.replay, cache, { now })
  t.is(duplicate.ok, false)
  t.is(duplicate.status, 409)
  t.ok(duplicate.error.includes('SEED_REQUEST_REPLAY'))
})

test('builder: replay fields require replay-v1 signature and fresh issuedAt', (t) => {
  const kp = keyPair()
  const now = 1782753600000
  const legacyWithReplayFields = signedBody(kp)
  legacyWithReplayFields.issuedAt = now
  legacyWithReplayFields.requestNonce = '02'.repeat(16)
  const legacy = buildPublisherSignedSeedOpts(legacyWithReplayFields, { now })
  t.is(legacy.ok, false)
  t.is(legacy.status, 403)
  t.ok(legacy.error.includes('INVALID_SIGNATURE'))

  const stale = signedBody(kp, {
    issuedAt: now - 60 * 60 * 1000 - 1,
    requestNonce: '03'.repeat(16)
  })
  const staleResult = buildPublisherSignedSeedOpts(stale, { now })
  t.is(staleResult.ok, false)
  t.is(staleResult.error, 'issuedAt is outside the replay window')

  const future = signedBody(kp, {
    issuedAt: now + 60 * 1000 + 1,
    requestNonce: '04'.repeat(16)
  })
  const futureResult = buildPublisherSignedSeedOpts(future, { now })
  t.is(futureResult.ok, false)
  t.is(futureResult.error, 'issuedAt is too far in the future')
})

test('builder: signed body with all optional metadata returns ok', (t) => {
  const kp = keyPair()
  const body = signedBody(kp)
  body.type = 'drive'
  body.storageClass = 'temporary'
  body.availabilityClass = 'atomic-handoff'
  body.privacyTier = 'p2p-only'
  body.blind = true
  body.custodyIntentId = '0'.repeat(63) + '1'
  body.blindContentId = '0'.repeat(63) + '2'
  body.ciphertextRoot = '0'.repeat(63) + '3'
  body.contentVersion = 7
  body.retainUntil = Date.now() + 60_000
  body.shardIds = [0, 1, 2]

  const r = buildPublisherSignedSeedOpts(body)
  t.is(r.ok, true)
  t.is(r.opts.type, 'drive')
  t.is(r.opts.storageClass, 'temporary')
  t.is(r.opts.availabilityClass, 'atomic-handoff')
  t.is(r.opts.privacyTier, 'p2p-only')
  t.is(r.opts.blind, true)
  t.is(r.opts.custodyIntentId, body.custodyIntentId)
  t.is(r.opts.contentVersion, 7)
  t.alike(r.opts.shardIds, [0, 1, 2])
})

// ─── Presence + format failures ─────────────────────────────────────

test('builder: missing appKey → 400', (t) => {
  const r = buildPublisherSignedSeedOpts({ publisherPubkey: 'a', publisherSignature: 'b' })
  t.is(r.ok, false)
  t.is(r.status, 400)
  t.is(r.error, 'appKey required')
})

test('builder: malformed appKey → 400', (t) => {
  const r = buildPublisherSignedSeedOpts({ appKey: 'not-hex', publisherPubkey: 'a', publisherSignature: 'b' })
  t.is(r.ok, false)
  t.is(r.status, 400)
  t.ok(r.error.includes('appKey must be 64 hex'))
})

test('builder: missing publisherPubkey → 400', (t) => {
  const r = buildPublisherSignedSeedOpts({ appKey: 'a'.repeat(64) })
  t.is(r.ok, false)
  t.is(r.error, 'publisherPubkey required')
})

test('builder: missing publisherSignature → 400', (t) => {
  const r = buildPublisherSignedSeedOpts({ appKey: 'a'.repeat(64), publisherPubkey: 'b'.repeat(64) })
  t.is(r.ok, false)
  t.is(r.error, 'publisherSignature required')
})

test('builder: signature with wrong publisher pubkey → 403 INVALID_SIGNATURE', (t) => {
  const kp1 = keyPair()
  const kp2 = keyPair()
  const body = signedBody(kp1)
  // Swap to a different pubkey while keeping kp1's signature
  body.publisherPubkey = b4a.toString(kp2.publicKey, 'hex')
  const r = buildPublisherSignedSeedOpts(body)
  t.is(r.ok, false)
  t.is(r.status, 403)
  t.ok(r.error.startsWith('INVALID_SIGNATURE'))
})

test('builder: signature over tampered field rejected', (t) => {
  const kp = keyPair()
  const body = signedBody(kp)
  body.replicationFactor = 7 // tamper after signing
  const r = buildPublisherSignedSeedOpts(body)
  t.is(r.ok, false)
  t.is(r.status, 403)
})

// ─── Numeric bounds ─────────────────────────────────────────────────

test('builder: replicationFactor out of range → 400', (t) => {
  // We don't need a valid signature here; bounds check runs before verify.
  const r = buildPublisherSignedSeedOpts({
    appKey: 'a'.repeat(64),
    publisherPubkey: 'b'.repeat(64),
    publisherSignature: 'c'.repeat(128),
    replicationFactor: 300
  })
  t.is(r.ok, false)
  t.ok(r.error.includes('[1,255]'))
})

test('builder: negative maxStorageBytes → 400', (t) => {
  const r = buildPublisherSignedSeedOpts({
    appKey: 'a'.repeat(64),
    publisherPubkey: 'b'.repeat(64),
    publisherSignature: 'c'.repeat(128),
    maxStorageBytes: -1
  })
  t.is(r.ok, false)
  t.is(r.error, 'maxStorageBytes must be a positive safe integer')
})

test('builder: missing, zero, or unsafe maxStorageBytes → 400', (t) => {
  const base = {
    appKey: 'a'.repeat(64),
    publisherPubkey: 'b'.repeat(64),
    publisherSignature: 'c'.repeat(128)
  }
  for (const maxStorageBytes of [undefined, 0, Number.MAX_SAFE_INTEGER + 1]) {
    const body = { ...base }
    if (maxStorageBytes !== undefined) body.maxStorageBytes = maxStorageBytes
    const r = buildPublisherSignedSeedOpts(body)
    t.is(r.ok, false)
    t.is(r.status, 400)
    t.is(r.error, 'maxStorageBytes must be a positive safe integer')
  }
})

// ─── discoveryKeys ──────────────────────────────────────────────────

test('builder: discoveryKeys not an array → 400', (t) => {
  const r = buildPublisherSignedSeedOpts({
    appKey: 'a'.repeat(64),
    publisherPubkey: 'b'.repeat(64),
    publisherSignature: 'c'.repeat(128),
    discoveryKeys: 'not-array'
  })
  t.is(r.ok, false)
  t.ok(r.error.includes('discoveryKeys must be an array'))
})

test('builder: too many discoveryKeys → 400', (t) => {
  const r = buildPublisherSignedSeedOpts({
    appKey: 'a'.repeat(64),
    publisherPubkey: 'b'.repeat(64),
    publisherSignature: 'c'.repeat(128),
    discoveryKeys: new Array(101).fill('d'.repeat(64))
  })
  t.is(r.ok, false)
  t.ok(r.error.includes('exceeds maximum'))
})

// ─── Optional metadata validators ───────────────────────────────────

test('builder: invalid type → 400', (t) => {
  const kp = keyPair()
  const body = signedBody(kp)
  body.type = 'not-a-real-type'
  const r = buildPublisherSignedSeedOpts(body)
  t.is(r.ok, false)
  t.ok(r.error.startsWith('type must be one of'))
})

test('builder: shardIds with negative integer → 400', (t) => {
  const kp = keyPair()
  const body = signedBody(kp)
  body.shardIds = [0, -1, 2]
  const r = buildPublisherSignedSeedOpts(body)
  t.is(r.ok, false)
  t.ok(r.error.includes('non-negative integers'))
})

test('builder: publisher-signed seed rejects unknown top-level fields', (t) => {
  const kp = keyPair()
  const body = signedBody(kp)
  body.caption = 'private docs'

  const r = buildPublisherSignedSeedOpts(body)
  t.is(r.ok, false)
  t.is(r.status, 400)
  t.is(r.error, 'unknown publisher seed field: caption')
})

test('builder: publisher-signed seed allowlists lease payment envelope', (t) => {
  const kp = keyPair()
  const body = signedBody(kp)
  body.leaseDays = 7
  body.paymentProof = { quoteId: 'quote-1' }

  const r = buildPublisherSignedSeedOpts(body)
  t.is(r.ok, true)

  const extraProof = signedBody(kp)
  extraProof.paymentProof = { quoteId: 'quote-1', memo: 'secret' }
  const extraProofResult = buildPublisherSignedSeedOpts(extraProof)
  t.is(extraProofResult.ok, false)
  t.is(extraProofResult.error, 'unknown paymentProof field: memo')

  const extraToken = signedBody(kp)
  extraToken.paymentProof = { blindToken: { secret: 's', C: 'c', note: 'secret' } }
  const extraTokenResult = buildPublisherSignedSeedOpts(extraToken)
  t.is(extraTokenResult.ok, false)
  t.is(extraTokenResult.error, 'unknown paymentProof.blindToken field: note')
})

// ─── Custody publisher mismatch ─────────────────────────────────────

test('builder: custody intent publisher mismatch → 403 CUSTODY_PUBLISHER_MISMATCH', (t) => {
  const kp = keyPair()
  const body = signedBody(kp)
  body.custodyIntentId = '0'.repeat(63) + '1'
  // Mock registry returns an intent signed by a DIFFERENT publisher
  const seedingRegistry = {
    getCustodyIntent: () => ({ publisherPubkey: 'f'.repeat(64) })
  }
  const r = buildPublisherSignedSeedOpts(body, { seedingRegistry })
  t.is(r.ok, false)
  t.is(r.status, 403)
  t.ok(r.error.startsWith('CUSTODY_PUBLISHER_MISMATCH'))
})

test('builder: custody intent publisher match → ok', (t) => {
  const kp = keyPair()
  const body = signedBody(kp)
  body.custodyIntentId = '0'.repeat(63) + '1'
  const seedingRegistry = {
    getCustodyIntent: () => ({ publisherPubkey: body.publisherPubkey })
  }
  const r = buildPublisherSignedSeedOpts(body, { seedingRegistry })
  t.is(r.ok, true)
  t.is(r.opts.custodyIntentId, body.custodyIntentId)
})

test('builder: custody intent registry error is best-effort (does not block)', (t) => {
  const kp = keyPair()
  const body = signedBody(kp)
  body.custodyIntentId = '0'.repeat(63) + '1'
  const seedingRegistry = {
    getCustodyIntent: () => { throw new Error('registry down') }
  }
  const r = buildPublisherSignedSeedOpts(body, { seedingRegistry })
  t.is(r.ok, true)
})

// ─── Body shape guards ──────────────────────────────────────────────

test('builder: null body → 400', (t) => {
  const r = buildPublisherSignedSeedOpts(null)
  t.is(r.ok, false)
  t.is(r.error, 'body required')
})

test('builder: non-object body → 400', (t) => {
  const r = buildPublisherSignedSeedOpts('garbage')
  t.is(r.ok, false)
  t.is(r.error, 'body required')
})

// ─── extractCustodySeedOpts (legacy Protomux seed path) ─────────────
//
// The binary seedRequestEncoding carries no custody fields and signs
// none, so the legacy `_onSeedRequest` handler historically dropped the
// custody binding — a custody seed accepted there landed with no
// custodyIntentId/retainUntil, so the relay could never attest a
// non-serving-proof for it. extractCustodySeedOpts pulls any well-formed
// custody linkage off an enriched message into a seedApp()-ready fragment.
// It is deliberately read-only and permissive: it does NOT re-verify the
// publisher signature (the publish channel remains the authenticated path).

test('extractCustodySeedOpts: well-formed custody fields are carried through', (t) => {
  const msg = {
    custodyIntentId: '0'.repeat(63) + '1',
    blindContentId: '0'.repeat(63) + '2',
    ciphertextRoot: '0'.repeat(63) + '3',
    contentVersion: 4,
    retainUntil: 1_700_000_000_000
  }
  const out = extractCustodySeedOpts(msg)
  t.is(out.custodyIntentId, msg.custodyIntentId)
  t.is(out.blindContentId, msg.blindContentId)
  t.is(out.ciphertextRoot, msg.ciphertextRoot)
  t.is(out.contentVersion, 4)
  t.is(out.retainUntil, 1_700_000_000_000)
})

test('extractCustodySeedOpts: hex fields are lowercased', (t) => {
  const out = extractCustodySeedOpts({ custodyIntentId: 'A'.repeat(64) })
  t.is(out.custodyIntentId, 'a'.repeat(64))
})

test('extractCustodySeedOpts: malformed / absent fields are omitted', (t) => {
  // Non-hex, wrong length, and wrong types are all dropped silently so the
  // fragment spreads to nothing — identical to the pre-fix behavior.
  const out = extractCustodySeedOpts({
    custodyIntentId: 'not-hex',
    blindContentId: 'abc', // too short
    ciphertextRoot: 123, // wrong type
    contentVersion: -1, // negative → dropped
    retainUntil: -5 // negative → dropped
  })
  t.alike(out, {})
})

test('extractCustodySeedOpts: empty object for non-object / nullish input', (t) => {
  t.alike(extractCustodySeedOpts(null), {})
  t.alike(extractCustodySeedOpts(undefined), {})
  t.alike(extractCustodySeedOpts('garbage'), {})
  t.alike(extractCustodySeedOpts({}), {})
})

test('extractCustodySeedOpts: contentVersion/retainUntil floored to integers', (t) => {
  const out = extractCustodySeedOpts({ contentVersion: 4.9, retainUntil: 100.7 })
  t.is(out.contentVersion, 4)
  t.is(out.retainUntil, 100)
})

test('extractCustodySeedOpts: contentVersion 0 and retainUntil 0 are kept', (t) => {
  // 0 is a valid floor value (>= 0 guard), not absence.
  const out = extractCustodySeedOpts({ contentVersion: 0, retainUntil: 0 })
  t.is(out.contentVersion, 0)
  t.is(out.retainUntil, 0)
})