OverviewFilesCommitsIssuesDocsReleasesPeers

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

162 lines · javascript ref:
import test from 'brittle'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import crypto from 'hypercore-crypto'
import BlindPairing from 'blind-pairing'
import { CandidateRequest, MemberRequest, createInvite } from 'blind-pairing-core'
import { IdentityManager } from '../../lib/identity/identity-manager.js'
import { DeviceLinker } from '../../lib/identity/device-linker.js'

const tmpDir = path.join(os.tmpdir(), 'pear-git-test-linker-' + Date.now())

function createStorage (name) {
  const dir = path.join(tmpDir, name)
  fs.mkdirSync(dir, { recursive: true })

  const keypair = crypto.keyPair()
  const identity = {
    publicKey: keypair.publicKey.toString('hex'),
    secretKey: keypair.secretKey.toString('hex'),
    createdAt: Date.now()
  }
  fs.writeFileSync(path.join(dir, 'server-identity.json'), JSON.stringify(identity, null, 2), {
    encoding: 'utf-8',
    mode: 0o600
  })

  return { dir, keypair }
}

test('setup: create temp dir', async (t) => {
  await fs.promises.mkdir(tmpDir, { recursive: true })
  t.pass('temp dir created')
})

test('device-linker: blind-pairing mnemonic transfer (core primitives)', async (t) => {
  // Test the mnemonic transfer flow using blind-pairing-core directly
  // This verifies the cryptographic flow without needing network

  const pairingKey = crypto.keyPair()
  const { invite, publicKey: invitePublicKey, seed: inviteSeed } = createInvite(pairingKey.publicKey)

  // Derive invite's full keypair from seed (needed for signing additional data)
  const inviteKeyPair = crypto.keyPair(inviteSeed)

  // Simulate candidate sending device public key
  const deviceKey = crypto.keyPair()
  const candidateReq = new CandidateRequest(invite, deviceKey.publicKey)

  // Simulate member receiving request
  const memberReq = MemberRequest.from(candidateReq.encode())
  const userData = memberReq.open(invitePublicKey)

  t.alike(userData, deviceKey.publicKey, 'member receives device public key')

  // Member confirms with mnemonic as additional data
  // Signature MUST use invite's seed-derived keypair (candidate verifies against it)
  const mnemonic = Buffer.from('abandon ability able about above absent absorb abstract absurd abuse access accident')
  memberReq.confirm({
    key: pairingKey.publicKey,
    encryptionKey: null,
    additional: {
      data: mnemonic,
      signature: crypto.sign(mnemonic, inviteKeyPair.secretKey)
    }
  })

  // Candidate processes response
  const auth = candidateReq.handleResponse(memberReq.response)

  t.ok(auth, 'candidate received auth')
  t.alike(auth.key, pairingKey.publicKey, 'auth key matches')
  t.alike(auth.data, mnemonic, 'mnemonic transferred correctly')
})

test('device-linker: full identity setup via mnemonic transfer', async (t) => {
  // Test the end-to-end flow: source setup → mnemonic transfer → target setup
  // Uses IdentityManager directly (no network)

  const { dir: srcDir } = createStorage('transfer-src')
  const { dir: dstDir, keypair: dstKeypair } = createStorage('transfer-dst')
  const password = Buffer.from('test-password')

  // Source device: setup identity
  const srcMgr = new IdentityManager({ storagePath: srcDir })
  const { mnemonic, identityPublicKey } = await srcMgr.setup(password)

  // Simulate mnemonic transfer (what blind-pairing does over the network)
  // Target device: setup from received mnemonic
  const dstMgr = new IdentityManager({ storagePath: dstDir })
  const result = await dstMgr.setupFromMnemonic(mnemonic, password)

  // Both devices share the same identity key
  t.is(result.identityPublicKey, identityPublicKey, 'same identity key')

  // Both proofs are valid
  const srcVerify = srcMgr.verifyProof()
  const dstVerify = dstMgr.verifyProof()

  t.ok(srcVerify.valid, 'source proof valid')
  t.ok(dstVerify.valid, 'target proof valid')
  t.is(srcVerify.identityPublicKey, dstVerify.identityPublicKey, 'same identity in proofs')
  t.not(srcVerify.devicePublicKey, dstVerify.devicePublicKey, 'different device keys')

  // Both vaults decrypt correctly
  const srcExported = await srcMgr.exportMnemonic(password)
  const dstExported = await dstMgr.exportMnemonic(password)
  t.is(srcExported, dstExported, 'both vaults contain same mnemonic')
})

test('device-linker: invite creation returns invite hex', async (t) => {
  t.timeout(30000)
  const { dir } = createStorage('invite-create')
  const mgr = new IdentityManager({ storagePath: dir })
  const password = Buffer.from('test-password')

  await mgr.setup(password)

  const linker = new DeviceLinker({ storagePath: dir })

  try {
    const result = await linker.createInvite(password)
    t.ok(result.invite, 'invite buffer returned')
    t.ok(result.inviteHex, 'invite hex returned')
    t.ok(result.inviteHex.length > 0, 'invite hex is non-empty')
    t.ok(typeof result.waitForPeer === 'function', 'waitForPeer function returned')
  } finally {
    await linker.close()
  }
})

test('device-linker: joinWithInvite fails without server identity', async (t) => {
  const noKeyDir = path.join(tmpDir, 'no-key')
  fs.mkdirSync(noKeyDir, { recursive: true })

  const linker = new DeviceLinker({ storagePath: noKeyDir })

  try {
    await linker.joinWithInvite('aabbccdd', Buffer.from('pw'))
    t.fail('should have thrown')
  } catch (err) {
    t.ok(err.message.includes('No server identity'), 'throws without server identity')
  } finally {
    await linker.close()
  }
})

test('device-linker: cleanup closes swarm', async (t) => {
  const { dir } = createStorage('cleanup')
  const linker = new DeviceLinker({ storagePath: dir })

  // close without any operations
  await linker.close()
  t.is(linker._swarm, null, 'swarm is null after close')
  t.is(linker._pairing, null, 'pairing is null after close')
})

test('cleanup: remove temp dir', async (t) => {
  await fs.promises.rm(tmpDir, { recursive: true, force: true })
  t.pass('cleaned up')
})