OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / integration / delegated-publish.test.js

451 lines · javascript ref:
/**
 * Integration tests for delegated publish via a device-attestation cert.
 *
 * The relay's seed acceptance path (`_scanRegistry`) optionally accepts
 * registry entries that carry a `delegationCert`. When the cert verifies
 * (and the seed-request signature was made by the device named in the cert),
 * the relay accepts the request but attributes it to the *primary* identity
 * named on the cert. Otherwise it emits `'delegation-rejected'` and skips.
 */

import test from 'brittle'
import createTestnet from '@hyperswarm/testnet'
import { RelayNode } from 'p2p-hiverelay/core/relay-node/index.js'
import { verifyDelegationCert } from 'p2p-hiverelay/core/delegation.js'
import b4a from 'b4a'
import sodium from 'sodium-universal'
import path from 'path'
import { tmpdir } from 'os'
import { randomBytes } from 'crypto'
import { mkdirSync } from 'fs'
import Hyperdrive from 'hyperdrive'

const REQUEST_BOUND = 16 * 1024 * 1024

function tmpStorage () {
  const storage = path.join(tmpdir(), 'hiverelay-deleg-' + randomBytes(8).toString('hex'))
  mkdirSync(storage, { recursive: true })
  return storage
}

function createNode (testnet, overrides = {}) {
  return new RelayNode({
    storage: tmpStorage(),
    bootstrapNodes: testnet.bootstrap,
    enableAPI: false,
    enableMetrics: false,
    enableServices: false,
    acceptMode: 'open',
    ...overrides
  })
}

async function keyedReopenVersion (node, key, timeoutMs = 5000) {
  const drive = new Hyperdrive(node.store.session(), key)
  let timer = null
  try {
    await Promise.race([
      drive.ready(),
      new Promise((_resolve, reject) => {
        timer = setTimeout(() => reject(new Error('fixture keyed reopen timed out')), timeoutMs)
      })
    ])
    return drive.version
  } finally {
    if (timer) clearTimeout(timer)
    await drive.close().catch(() => {})
  }
}

// Generate an Ed25519 keypair (libsodium).
function keygen () {
  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 }
}

// Create a delegation cert exactly as `HiveRelayClient.createDeviceAttestation`
// does. This must match the format the verifier expects.
function createDelegationCert (primary, devicePubkey, opts = {}) {
  const ttl = opts.ttlMs || 24 * 60 * 60 * 1000
  const expiresAt = typeof opts.expiresAt === 'number'
    ? opts.expiresAt
    : Date.now() + ttl
  const label = opts.label || ''

  const expBuf = b4a.alloc(8)
  new DataView(expBuf.buffer, expBuf.byteOffset).setBigUint64(0, BigInt(expiresAt), false)
  const labelBuf = b4a.from(label, 'utf8')
  const payload = b4a.concat([primary.publicKey, devicePubkey, expBuf, labelBuf])

  const signature = b4a.alloc(64)
  sodium.crypto_sign_detached(signature, payload, primary.secretKey)

  return {
    version: 1,
    primaryPubkey: b4a.toString(primary.publicKey, 'hex'),
    devicePubkey: b4a.toString(devicePubkey, 'hex'),
    expiresAt,
    label,
    signature: b4a.toString(signature, 'hex')
  }
}

// Produce a `publisherSignature` over the seed-request payload using the
// SeedProtocol's serialization rules (appKey || hash(discoveryKeys) || meta).
function signSeedRequest (entry, secretKey) {
  const appKeyBuf = b4a.from(entry.appKey, 'hex')
  const dkHash = b4a.alloc(32)
  if (entry.discoveryKeys && entry.discoveryKeys.length > 0) {
    const dkBufs = entry.discoveryKeys.map(dk => b4a.from(dk, 'hex'))
    sodium.crypto_generichash(dkHash, b4a.concat(dkBufs))
  }
  const meta = b4a.alloc(28)
  const view = new DataView(meta.buffer, meta.byteOffset)
  view.setUint8(0, entry.replicationFactor || 0)
  view.setBigUint64(8, BigInt(entry.maxStorageBytes || 0))
  view.setBigUint64(16, BigInt(entry.ttlSeconds || 0))
  view.setUint32(24, entry.bountyRate || 0)

  const payload = b4a.concat([appKeyBuf, dkHash, meta])
  const sig = b4a.alloc(64)
  sodium.crypto_sign_detached(sig, payload, secretKey)
  return b4a.toString(sig, 'hex')
}

// Build a synthetic registry seed-request entry. Caller supplies the device
// secret key for signing; pass `signWith` to override (e.g. to produce an
// invalid signature signed by the wrong key).
function makeRegistryEntry ({ device, primary, cert, appKey, discoveryKey, signWith }) {
  const entry = {
    type: 'seed-request',
    timestamp: Date.now(),
    appKey: b4a.toString(appKey, 'hex'),
    discoveryKeys: [b4a.toString(discoveryKey, 'hex')],
    contentType: 'app',
    parentKey: null,
    mountPath: null,
    replicationFactor: 1,
    geoPreference: [],
    maxStorageBytes: REQUEST_BOUND,
    bountyRate: 0,
    ttlSeconds: 3600,
    privacyTier: 'public',
    publisherPubkey: b4a.toString(device.publicKey, 'hex')
  }
  entry.publisherSignature = signSeedRequest(entry, signWith || device.secretKey)
  if (cert !== undefined) entry.delegationCert = cert
  return entry
}

// Inject a synthetic registry entry into the relay's `seedingRegistry`
// in-memory index, then drive `_scanRegistry()` once. Returns the array of
// emitted events of interest.
async function injectAndScan (node, entry, eventNames = ['registry-seed-accepted', 'delegation-rejected', 'registry-rejected', 'registry-skipped-policy', 'registry-pending']) {
  const events = []
  const handlers = new Map()
  for (const name of eventNames) {
    const h = (payload) => events.push({ name, payload })
    handlers.set(name, h)
    node.on(name, h)
  }
  node.seedingRegistry._requests.set(entry.appKey, entry)
  let timer = null
  try {
    await Promise.race([
      node._scanRegistry(),
      new Promise((_resolve, reject) => {
        timer = setTimeout(() => {
          const err = new Error('delegated publish registry scan timed out')
          err.code = 'REGISTRY_SCAN_TIMEOUT'
          reject(err)
        }, 15_000)
      })
    ])
  } finally {
    if (timer) clearTimeout(timer)
    for (const [name, h] of handlers) node.removeListener(name, h)
  }
  return events
}

// ─── verifier sanity ────────────────────────────────────────────────

test('verifyDelegationCert agrees with the createDeviceAttestation format', async (t) => {
  const primary = keygen()
  const device = keygen()
  const cert = createDelegationCert(primary, device.publicKey, { label: 'iPhone' })
  const result = verifyDelegationCert(cert)
  t.is(result.valid, true, 'cert valid')
  t.is(result.primaryPubkey, b4a.toString(primary.publicKey, 'hex'), 'returns primary pubkey')

  // Tamper with signature
  const bad = { ...cert, signature: 'ff'.repeat(64) }
  t.is(verifyDelegationCert(bad).valid, false, 'tampered signature rejected')

  // Expired
  const expired = createDelegationCert(primary, device.publicKey, { expiresAt: Date.now() - 1000 })
  t.is(verifyDelegationCert(expired).valid, false, 'expired rejected')
})

// ─── happy path ──────────────────────────────────────────────────────

test('relay accepts seed request with valid delegation cert; primary attributed', async (t) => {
  const testnet = await createTestnet(3)
  const node = createNode(testnet)
  let sourceDrive = null
  t.teardown(async () => {
    if (sourceDrive) await sourceDrive.close().catch(() => {})
    await node.stop()
    await testnet.destroy()
  })
  await node.start()

  const primary = keygen()
  const device = keygen()
  const cert = createDelegationCert(primary, device.publicKey, { label: 'laptop' })

  sourceDrive = new Hyperdrive(node.store.session())
  await sourceDrive.ready()
  await sourceDrive.put('/index.html', b4a.from('delegated publish fixture'))
  const appKey = b4a.from(sourceDrive.key)
  const discoveryKey = b4a.from(sourceDrive.discoveryKey)
  await sourceDrive.close()
  sourceDrive = null
  t.ok(await keyedReopenVersion(node, appKey) > 0, 'published drive reopens through the production keyed session')
  const entry = makeRegistryEntry({ device, primary, cert, appKey, discoveryKey })

  const events = await injectAndScan(node, entry)

  const accepted = events.find(e => e.name === 'registry-seed-accepted')
  t.ok(accepted, 'registry-seed-accepted emitted')
  const rejected = events.find(e => e.name === 'delegation-rejected')
  t.absent(rejected, 'no delegation-rejected event')

  // Attribution: the seeded app should record the *primary* pubkey, not the
  // device pubkey.
  const seeded = node.seededApps.get(entry.appKey)
  t.ok(seeded, 'app is seeded')
  t.is(seeded.publisherPubkey, b4a.toString(primary.publicKey, 'hex'),
    'stored publisherPubkey is the primary identity')
  t.is(seeded.maxStorage, REQUEST_BOUND, 'the exact finite request bound reaches AppLifecycle')
})

// ─── negative: tampered cert signature ───────────────────────────────

test('rejects when delegation cert signature is tampered', async (t) => {
  const testnet = await createTestnet(3)
  const node = createNode(testnet)
  t.teardown(async () => {
    await node.stop()
    await testnet.destroy()
  })
  await node.start()

  const primary = keygen()
  const device = keygen()
  const cert = createDelegationCert(primary, device.publicKey)
  cert.signature = 'aa'.repeat(64) // tamper

  const appKey = randomBytes(32)
  const discoveryKey = randomBytes(32)
  const entry = makeRegistryEntry({ device, primary, cert, appKey, discoveryKey })

  const events = await injectAndScan(node, entry)

  const rejected = events.find(e => e.name === 'delegation-rejected')
  t.ok(rejected, 'delegation-rejected emitted')
  t.is(rejected.payload.reason, 'bad signature', 'reason is bad signature')
  const accepted = events.find(e => e.name === 'registry-seed-accepted')
  t.absent(accepted, 'not accepted')
  t.is(node.seededApps.has(entry.appKey), false, 'app not seeded')
})

// ─── negative: expired cert ──────────────────────────────────────────

test('rejects when delegation cert is expired', async (t) => {
  const testnet = await createTestnet(3)
  const node = createNode(testnet)
  t.teardown(async () => {
    await node.stop()
    await testnet.destroy()
  })
  await node.start()

  const primary = keygen()
  const device = keygen()
  const cert = createDelegationCert(primary, device.publicKey, { expiresAt: Date.now() - 60_000 })

  const appKey = randomBytes(32)
  const discoveryKey = randomBytes(32)
  const entry = makeRegistryEntry({ device, primary, cert, appKey, discoveryKey })

  const events = await injectAndScan(node, entry)

  const rejected = events.find(e => e.name === 'delegation-rejected')
  t.ok(rejected, 'delegation-rejected emitted')
  t.is(rejected.payload.reason, 'expired', 'reason is expired')
  t.is(node.seededApps.has(entry.appKey), false, 'app not seeded')
})

// ─── negative: cert.devicePubkey ≠ publisherPubkey ───────────────────

test('rejects when cert.devicePubkey does not match request publisherPubkey', async (t) => {
  const testnet = await createTestnet(3)
  const node = createNode(testnet)
  t.teardown(async () => {
    await node.stop()
    await testnet.destroy()
  })
  await node.start()

  const primary = keygen()
  const device = keygen()
  const otherDevice = keygen()
  // Cert authorises `device`, but the request is signed by `otherDevice`
  const cert = createDelegationCert(primary, device.publicKey)

  const appKey = randomBytes(32)
  const discoveryKey = randomBytes(32)
  const entry = makeRegistryEntry({
    device: otherDevice, // request publisher = otherDevice
    primary,
    cert,
    appKey,
    discoveryKey
  })

  const events = await injectAndScan(node, entry)

  const rejected = events.find(e => e.name === 'delegation-rejected')
  t.ok(rejected, 'delegation-rejected emitted')
  t.is(rejected.payload.reason, 'cert.devicePubkey mismatch', 'reason is mismatch')
  t.is(node.seededApps.has(entry.appKey), false, 'app not seeded')
})

// ─── negative: missing fields ────────────────────────────────────────

test('rejects when delegation cert is missing required fields', async (t) => {
  const testnet = await createTestnet(3)
  const node = createNode(testnet)
  t.teardown(async () => {
    await node.stop()
    await testnet.destroy()
  })
  await node.start()

  const primary = keygen()
  const device = keygen()

  // Cert with no signature
  const cert = createDelegationCert(primary, device.publicKey)
  delete cert.signature

  const appKey = randomBytes(32)
  const discoveryKey = randomBytes(32)
  const entry = makeRegistryEntry({ device, primary, cert, appKey, discoveryKey })

  const events = await injectAndScan(node, entry)

  const rejected = events.find(e => e.name === 'delegation-rejected')
  t.ok(rejected, 'delegation-rejected emitted')
  t.is(rejected.payload.reason, 'missing signature', 'reason is missing signature')
  t.is(node.seededApps.has(entry.appKey), false, 'app not seeded')

  // Also test: cert with wrong version. Clear the registry first so the
  // previous (missing-signature) entry doesn't re-fire its own event.
  node.seedingRegistry._requests.clear()
  const cert2 = createDelegationCert(primary, device.publicKey)
  cert2.version = 99
  const appKey2 = randomBytes(32)
  const entry2 = makeRegistryEntry({ device, primary, cert: cert2, appKey: appKey2, discoveryKey })
  const events2 = await injectAndScan(node, entry2)
  const rejected2 = events2.find(e => e.name === 'delegation-rejected' && e.payload.appKey === entry2.appKey)
  t.ok(rejected2, 'unsupported version rejected')
  t.is(rejected2.payload.reason, 'unsupported version', 'reason is unsupported version')
})

// ─── negative: valid cert but request signed by wrong key ────────────

test('rejects when request signature is not from the device named in cert', async (t) => {
  const testnet = await createTestnet(3)
  const node = createNode(testnet)
  t.teardown(async () => {
    await node.stop()
    await testnet.destroy()
  })
  await node.start()

  const primary = keygen()
  const device = keygen()
  const wrong = keygen()

  const cert = createDelegationCert(primary, device.publicKey)
  const appKey = randomBytes(32)
  const discoveryKey = randomBytes(32)
  // publisherPubkey says `device`, but the signature was made by `wrong`
  const entry = makeRegistryEntry({
    device,
    primary,
    cert,
    appKey,
    discoveryKey,
    signWith: wrong.secretKey
  })

  const events = await injectAndScan(node, entry)

  const rejected = events.find(e => e.name === 'delegation-rejected')
  t.ok(rejected, 'delegation-rejected emitted')
  t.is(rejected.payload.reason, 'request signature mismatch', 'reason is request signature mismatch')
  t.is(node.seededApps.has(entry.appKey), false, 'app not seeded')
})

// ─── backward compatibility ──────────────────────────────────────────

test('seed request without delegation cert still works (backward compatible)', async (t) => {
  const testnet = await createTestnet(3)
  const node = createNode(testnet)
  let sourceDrive = null
  t.teardown(async () => {
    if (sourceDrive) await sourceDrive.close().catch(() => {})
    await node.stop()
    await testnet.destroy()
  })
  await node.start()

  const publisher = keygen()
  sourceDrive = new Hyperdrive(node.store.session())
  await sourceDrive.ready()
  await sourceDrive.put('/index.html', b4a.from('legacy publish fixture'))
  const appKey = b4a.from(sourceDrive.key)
  const discoveryKey = b4a.from(sourceDrive.discoveryKey)
  await sourceDrive.close()
  sourceDrive = null
  t.ok(await keyedReopenVersion(node, appKey) > 0, 'legacy published drive reopens through the production keyed session')
  // No cert; entry has the standard publisher pubkey only.
  const entry = makeRegistryEntry({
    device: publisher,
    primary: publisher,
    cert: undefined,
    appKey,
    discoveryKey
  })
  delete entry.delegationCert
  // also drop publisherSignature — the legacy registry path doesn't rely on it
  delete entry.publisherSignature

  const events = await injectAndScan(node, entry)
  const accepted = events.find(e => e.name === 'registry-seed-accepted')
  t.ok(accepted, 'accepted with no cert')
  const rejected = events.find(e => e.name === 'delegation-rejected')
  t.absent(rejected, 'no delegation-rejected event')

  const seeded = node.seededApps.get(entry.appKey)
  t.ok(seeded, 'app seeded')
  t.is(seeded.publisherPubkey, b4a.toString(publisher.publicKey, 'hex'),
    'stored publisherPubkey is the original publisher')
  t.is(seeded.maxStorage, REQUEST_BOUND, 'legacy path preserves the exact finite request bound')
})