OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / device-linker.test.js

165 lines · javascript ref:
import test from 'brittle'
import Hyperswarm from 'hyperswarm'
import createTestnet from '@hyperswarm/testnet'
import IdentityKey from 'keet-identity-key'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'

import { DeviceLinker } from '../lib/device-linker.js'
import { verifyEnvelope, signTestEnvelope } from '../lib/auth/envelope.js'
import { createKeetVerifier } from '../lib/auth/keet-verifier.js'
import { ReplayCache } from '../lib/auth/replay-cache.js'

// Phase 2c tests — blind-pairing device linking.
//
// These tests exercise the full end-to-end flow: source creates invite,
// target joins, target gets a device chain that verifies against the
// source's identity root.

test('device-linker: source creates invite, target joins and gets valid chain', async (t) => {
  // Setup: create a keet identity + device chain for the source daemon.
  const mnemonic = IdentityKey.generateMnemonic()
  const identityKey = await IdentityKey.from({ mnemonic })
  const sourceKp = crypto.keyPair()
  const sourceProof = await identityKey.bootstrap(sourceKp.publicKey)
  const rootHex = b4a.toString(identityKey.identityKeyPair.publicKey, 'hex')

  // Create a testnet so both swarm instances can discover each other.
  const testnet = await createTestnet(2)

  const sourceSwarm = new Hyperswarm(testnet[0])
  const targetSwarm = new Hyperswarm(testnet[1])

  t.teardown(async () => {
    await sourceSwarm.destroy()
    await targetSwarm.destroy()
    await testnet.destroy()
  })

  // Source side: create invite.
  const sourceLinker = new DeviceLinker({
    swarm: sourceSwarm,
    deviceChain: { keyPair: sourceKp, proof: sourceProof },
    rootPubkeyHex: rootHex
  })

  const { inviteHex, waitForPeer } = await sourceLinker.createInvite({
    label: 'test-device'
  })

  t.ok(inviteHex, 'inviteHex returned')
  t.ok(inviteHex.length > 20, 'inviteHex is non-trivial')

  // Target side: join with invite.
  const targetLinker = new DeviceLinker({ swarm: targetSwarm })

  const [peerResult, bundle] = await Promise.all([
    waitForPeer(),
    targetLinker.joinWithInvite(inviteHex)
  ])

  // Source sees the new device.
  t.ok(peerResult.devicePublicKeyHex, 'source got device pubkey')
  t.is(peerResult.devicePublicKeyHex.length, 64, 'pubkey is 64-char hex')

  // Target received a valid bundle.
  t.is(bundle.rootPubkey, rootHex, 'bundle root matches source identity')
  t.is(bundle.label, 'test-device', 'label preserved')
  t.is(bundle.devicePublicKey.length, 64, 'device pubkey is 64-char hex')
  t.is(bundle.deviceSecretKey.length, 128, 'device secret key is 128-char hex')
  t.ok(bundle.deviceProof, 'chain proof present')
  t.is(bundle.anKeypair.length, 132, 'anKeypair is 132-char canonical')

  // The received chain should verify via keet-identity-key.
  const proof = b4a.from(bundle.deviceProof, 'base64')
  const verification = IdentityKey.verify(proof)
  t.ok(verification, 'chain verifies via IdentityKey.verify')
  t.is(
    b4a.toString(verification.identityPublicKey, 'hex'),
    rootHex,
    'chain roots to the source identity'
  )

  // Target's device key in the chain matches the bundle.
  t.is(
    b4a.toString(verification.devicePublicKey, 'hex'),
    bundle.devicePublicKey,
    'chain device matches bundle device'
  )

  // Now verify the target can sign an envelope with chain and it resolves
  // to the source's root identity (end-to-end with Phase 2b).
  const { payload, sig } = await signTestEnvelope({
    keypair: bundle.anKeypair,
    method: 'whoami',
    params: {}
  })

  const verified = await verifyEnvelope(
    { payload, sig, chain: bundle.deviceProof },
    { replayCache: new ReplayCache(), keetVerifier: createKeetVerifier() }
  )

  t.is(verified.method, 'keet', 'envelope method is keet')
  t.is(verified.rootPubkey, rootHex, 'envelope resolves to source identity root')
  t.is(verified.devicePubkey, bundle.devicePublicKey, 'envelope device is the linked device')

  await sourceLinker.close()
  await targetLinker.close()
})

test('device-linker: target cannot use a different keypair than the chain vouches for', async (t) => {
  // Setup identity + source device.
  const mnemonic = IdentityKey.generateMnemonic()
  const identityKey = await IdentityKey.from({ mnemonic })
  const sourceKp = crypto.keyPair()
  const sourceProof = await identityKey.bootstrap(sourceKp.publicKey)
  const rootHex = b4a.toString(identityKey.identityKeyPair.publicKey, 'hex')

  const testnet = await createTestnet(2)
  const sw1 = new Hyperswarm(testnet[0])
  const sw2 = new Hyperswarm(testnet[1])

  t.teardown(async () => {
    await sw1.destroy()
    await sw2.destroy()
    await testnet.destroy()
  })

  const sourceLinker = new DeviceLinker({
    swarm: sw1,
    deviceChain: { keyPair: sourceKp, proof: sourceProof },
    rootPubkeyHex: rootHex
  })

  const { inviteHex, waitForPeer } = await sourceLinker.createInvite({ label: 'test' })
  const targetLinker = new DeviceLinker({ swarm: sw2 })

  const [, bundle] = await Promise.all([
    waitForPeer(),
    targetLinker.joinWithInvite(inviteHex)
  ])

  // Sign with a DIFFERENT keypair than the bundle's — chain verification
  // should reject because the chain vouches for a different device.
  const rogueKp = await (await import('anproto')).an.gen()
  const { payload, sig } = await signTestEnvelope({
    keypair: rogueKp,
    method: 'whoami',
    params: {}
  })

  try {
    await verifyEnvelope(
      { payload, sig, chain: bundle.deviceProof },
      { replayCache: new ReplayCache(), keetVerifier: createKeetVerifier() }
    )
    t.fail('should reject: chain/device mismatch')
  } catch (err) {
    t.ok(err.code === 'CHAIN_INVALID', 'chain mismatch caught: ' + err.message)
  }

  await sourceLinker.close()
  await targetLinker.close()
})