OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / delegation-revocation.test.js

276 lines · javascript ref:
/**
 * Revocation primitives tests — createRevocation / verifyRevocation pair,
 * plus the RelayNode.submitRevocation store integration and the
 * _checkDelegation fast-path rejection for a revoked cert signature.
 */

import test from 'brittle'
import sodium from 'sodium-universal'
import b4a from 'b4a'
import { EventEmitter } from 'events'
import { createRevocation, verifyRevocation, verifyDelegationCert } from 'p2p-hiverelay/core/delegation.js'
import { HiveRelayClient } from 'p2p-hiverelay-client'
import { RelayNode } from 'p2p-hiverelay/core/relay-node/index.js'

// Swarm mock matching the shape used across the other client tests.
function mockSwarm (keyPair) {
  const swarm = new EventEmitter()
  swarm.keyPair = keyPair || { publicKey: Buffer.alloc(32, 0xaa), secretKey: null }
  swarm.connections = new Set()
  swarm.join = () => ({ destroy: () => {} })
  swarm.leave = async () => {}
  swarm.flush = async () => {}
  swarm.destroy = async () => {}
  return swarm
}

function genKeypair () {
  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 makeCert (primaryKp, deviceKp, ttlMs = 60_000) {
  const devicePub = b4a.toString(deviceKp.publicKey, 'hex')
  const expiresAt = Date.now() + ttlMs
  const label = 'test-device'
  const primaryPub = b4a.toString(primaryKp.publicKey, 'hex')
  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([primaryKp.publicKey, deviceKp.publicKey, expBuf, labelBuf])
  const sig = b4a.alloc(sodium.crypto_sign_BYTES)
  sodium.crypto_sign_detached(sig, payload, primaryKp.secretKey)
  return {
    version: 1,
    primaryPubkey: primaryPub,
    devicePubkey: devicePub,
    expiresAt,
    label,
    signature: b4a.toString(sig, 'hex')
  }
}

function makeNode () {
  return new RelayNode({ storage: '/tmp/hr-rev-test-' + Date.now() + '-' + Math.random() })
}

// ─── Primitive tests ──────────────────────────────────────────────

test('revocation: round-trip verifies', (t) => {
  const primaryKp = genKeypair()
  const deviceKp = genKeypair()
  const cert = makeCert(primaryKp, deviceKp)

  const rev = createRevocation(cert, primaryKp.secretKey, { reason: 'device lost' })
  t.is(rev.version, 1)
  t.is(rev.primaryPubkey, cert.primaryPubkey)
  t.is(rev.revokedCertSignature, cert.signature)
  t.is(rev.reason, 'device lost')
  t.ok(rev.revokedAt <= Date.now())

  const result = verifyRevocation(rev)
  t.is(result.valid, true)
  t.is(result.revokedCertSignature, cert.signature)
})

test('revocation: rejects forged signature', (t) => {
  const primaryKp = genKeypair()
  const attackerKp = genKeypair()
  const deviceKp = genKeypair()
  const cert = makeCert(primaryKp, deviceKp)

  // Attacker tries to revoke a cert they didn't issue.
  const rev = createRevocation(cert, attackerKp.secretKey)
  // createRevocation produces a valid-looking rev signed by attacker,
  // but verifyRevocation uses cert.primaryPubkey as the expected signer,
  // so the signature check fails.
  const result = verifyRevocation(rev)
  t.is(result.valid, false)
  t.is(result.reason, 'bad signature')
})

test('revocation: rejects malformed inputs without throwing', (t) => {
  const cases = [null, {}, { version: 99 }, { version: 1, primaryPubkey: 'x', revokedCertSignature: 'y', signature: 'z' }]
  for (const rev of cases) {
    const result = verifyRevocation(rev)
    t.is(result.valid, false)
  }
})

test('revocation: reason ≤ 256 bytes accepted; > 256 rejected', (t) => {
  const primaryKp = genKeypair()
  const deviceKp = genKeypair()
  const cert = makeCert(primaryKp, deviceKp)

  // 256 bytes exactly — fine
  const ok = createRevocation(cert, primaryKp.secretKey, { reason: 'a'.repeat(256) })
  t.is(verifyRevocation(ok).valid, true)

  // 257 bytes — rejected at create time
  try {
    createRevocation(cert, primaryKp.secretKey, { reason: 'a'.repeat(257) })
    t.fail('should throw')
  } catch (err) {
    t.ok(err.message.includes('256'))
  }
})

test('revocation: createRevocation validates the secret key length', (t) => {
  const primaryKp = genKeypair()
  const deviceKp = genKeypair()
  const cert = makeCert(primaryKp, deviceKp)
  try {
    createRevocation(cert, b4a.alloc(32))
    t.fail('should throw')
  } catch (err) {
    t.ok(err.message.includes('64-byte'))
  }
})

// ─── RelayNode integration ─────────────────────────────────────────

test('RelayNode: submitRevocation stores + listRevocations returns entries', (t) => {
  const primaryKp = genKeypair()
  const deviceKp = genKeypair()
  const cert = makeCert(primaryKp, deviceKp)
  const rev = createRevocation(cert, primaryKp.secretKey, { reason: 'rotation' })

  const node = makeNode()
  const result = node.submitRevocation(rev, { certExpiresAt: cert.expiresAt })
  t.is(result.ok, true)
  t.is(result.revokedCertSignature, cert.signature)

  const list = node.listRevocations()
  t.is(list.length, 1)
  t.is(list[0].revokedCertSignature, cert.signature)
  t.is(list[0].reason, 'rotation')
  t.is(list[0].expiresAt, cert.expiresAt)
})

test('RelayNode: submitRevocation rejects forged revocations', (t) => {
  const primaryKp = genKeypair()
  const attackerKp = genKeypair()
  const deviceKp = genKeypair()
  const cert = makeCert(primaryKp, deviceKp)
  // Attacker signs a would-be revocation
  const forgedRev = createRevocation(cert, attackerKp.secretKey)
  const node = makeNode()
  const result = node.submitRevocation(forgedRev)
  t.is(result.ok, false)
  t.is(result.reason, 'bad signature')
  t.is(node.listRevocations().length, 0, 'store unchanged after forgery rejection')
})

test('RelayNode._checkDelegation: rejects a valid cert once revoked', (t) => {
  const primaryKp = genKeypair()
  const deviceKp = genKeypair()
  const cert = makeCert(primaryKp, deviceKp)

  const node = makeNode()

  // Synthesize a signed seed request from the device. The request-signature
  // path is already well-tested elsewhere; here we only care that the
  // revocation check fires ahead of the fine-grained checks.
  const appKey = b4a.alloc(32, 0x11)
  const discoveryKeys = [b4a.alloc(32, 0x22)]
  const dkHash = b4a.alloc(32)
  sodium.crypto_generichash(dkHash, b4a.concat(discoveryKeys))
  const meta = b4a.alloc(28)
  const view = new DataView(meta.buffer, meta.byteOffset)
  view.setUint8(0, 3)
  view.setBigUint64(8, BigInt(500 * 1024 * 1024))
  view.setBigUint64(16, BigInt(30 * 24 * 3600))
  view.setUint32(24, 0)
  const payload = b4a.concat([appKey, dkHash, meta])
  const reqSig = b4a.alloc(64)
  sodium.crypto_sign_detached(reqSig, payload, deviceKp.secretKey)

  const req = {
    appKey,
    discoveryKeys,
    replicationFactor: 3,
    maxStorageBytes: 500 * 1024 * 1024,
    ttlSeconds: 30 * 24 * 3600,
    bountyRate: 0,
    publisherPubkey: deviceKp.publicKey,
    publisherSignature: reqSig,
    delegationCert: cert
  }

  // Sanity: the chain is fine before revocation.
  const beforeCertCheck = verifyDelegationCert(cert)
  t.is(beforeCertCheck.valid, true, 'cert itself is well-formed + unexpired')

  const before = node._checkDelegation(req)
  t.is(before.ok, true, 'delegation accepted pre-revocation')

  // Submit the revocation.
  const rev = createRevocation(cert, primaryKp.secretKey, { reason: 'compromised' })
  node.submitRevocation(rev, { certExpiresAt: cert.expiresAt })

  // Now the same request must be rejected with reason 'revoked'.
  const after = node._checkDelegation(req)
  t.is(after.ok, false)
  t.is(after.reason, 'revoked')
})

test('RelayNode: _sweepRevocations drops entries past certExpiresAt', (t) => {
  const primaryKp = genKeypair()
  const deviceKp = genKeypair()
  const cert = makeCert(primaryKp, deviceKp)
  const rev = createRevocation(cert, primaryKp.secretKey)

  const node = makeNode()
  // Expiry in the past → sweep should drop immediately.
  node.submitRevocation(rev, { certExpiresAt: Date.now() - 1000 })
  t.is(node.listRevocations().length, 1)
  let sweptCount = 0
  node.on('revocations-swept', ({ dropped }) => { sweptCount = dropped })
  node._sweepRevocations()
  t.is(sweptCount, 1)
  t.is(node.listRevocations().length, 0)
})

// ─── Client SDK method ────────────────────────────────────────────

test('HiveRelayClient.createCertRevocation: signs + verifies', async (t) => {
  const primaryKp = genKeypair()
  const deviceKp = genKeypair()
  const cert = makeCert(primaryKp, deviceKp)

  const client = new HiveRelayClient({ swarm: mockSwarm(primaryKp), keyPair: primaryKp })
  await client.start()

  const rev = client.createCertRevocation(cert, { reason: 'lost device' })
  t.is(rev.primaryPubkey, b4a.toString(primaryKp.publicKey, 'hex'))
  t.is(rev.revokedCertSignature, cert.signature)
  t.is(rev.reason, 'lost device')

  // And it verifies cleanly.
  const result = verifyRevocation(rev)
  t.is(result.valid, true)

  await client.destroy()
})

test('HiveRelayClient.createCertRevocation: rejects certs signed by a different primary', async (t) => {
  const myKp = genKeypair()
  const otherKp = genKeypair()
  const deviceKp = genKeypair()
  const otherCert = makeCert(otherKp, deviceKp)

  const client = new HiveRelayClient({ swarm: mockSwarm(myKp), keyPair: myKp })
  await client.start()

  try {
    client.createCertRevocation(otherCert)
    t.fail('should throw')
  } catch (err) {
    t.ok(err.message.includes('did not issue'), 'refuses to sign revocation for a cert from another identity')
  }

  await client.destroy()
})