OverviewFilesCommitsIssuesDocsReleasesPeers

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

188 lines · javascript ref:
/**
 * Phase 2d — device management handlers end-to-end.
 *
 * Exercises /api/rpc when chain-bearing envelopes arrive from linked
 * devices. Verifies:
 *   1. TOFU auto-registers each device as a secondary identity under
 *      the same user (identified by the chain's root pubkey).
 *   2. list-my-devices returns root + all linked devices.
 *   3. label-device persists a human label.
 *   4. revoke-device on a different device succeeds.
 *   5. self-revoke (trying to revoke the envelope's own signing device)
 *      is rejected.
 *   6. A subsequent envelope from the revoked device is rejected with
 *      EREVOKED by the envelope verifier.
 */

import test from 'brittle'
import { mkdtempSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import http from '#node:http'
import b4a from 'b4a'
import crypto from 'hypercore-crypto'
import IdentityKey from 'keet-identity-key'

import { startPearGit } from '../index.js'
import { signTestEnvelope } from '../lib/auth/envelope.js'

function tmpStorage () { return mkdtempSync(join(tmpdir(), 'pgv3-2d-')) }
function tmpSockPath () { return join(mkdtempSync(join(tmpdir(), 'pgv3-2d-sock-')), 'svc.sock') }

async function makeNode (t) {
  const node = await startPearGit({
    storage: tmpStorage(),
    uds: tmpSockPath(),
    http: { port: 0 },
    swarm: false,
    autoImportSSH: false
  })
  t.teardown(async () => { try { await node.close() } catch {} })
  return node
}

function postJson (url, body) {
  return new Promise((resolve, reject) => {
    const json = JSON.stringify(body || {})
    const u = new URL(url)
    const req = http.request({
      method: 'POST',
      hostname: u.hostname,
      port: u.port,
      path: u.pathname,
      headers: {
        'Content-Type': 'application/json',
        'Content-Length': b4a.byteLength(json),
        Accept: 'application/json'
      }
    }, (res) => {
      const parts = []
      res.on('data', (c) => parts.push(c))
      res.on('end', () => {
        const text = b4a.concat(parts).toString('utf8')
        let parsed
        try { parsed = JSON.parse(text) } catch { parsed = text }
        resolve({ status: res.statusCode, body: parsed })
      })
      res.on('error', reject)
    })
    req.on('error', reject)
    req.write(json)
    req.end()
  })
}

/**
 * Build a fresh identity with TWO attested devices, both directly
 * chained to the same root. Each device gets its own anproto keypair
 * (base64(pub)+base64(sec)) and its own chain buffer.
 */
async function makeIdentityWithTwoDevices () {
  const mnemonic = IdentityKey.generateMnemonic()
  const identityKey = await IdentityKey.from({ mnemonic })

  function makeDevice () {
    const kp = crypto.keyPair()
    return {
      kp,
      anKeypair: b4a.toString(kp.publicKey, 'base64') + b4a.toString(kp.secretKey, 'base64'),
      pubHex: b4a.toString(kp.publicKey, 'hex')
    }
  }

  const deviceA = makeDevice()
  const deviceB = makeDevice()

  // Each device gets its own bootstrapped chain (root → device). Two
  // siblings under the same root — simpler than nested attestation for
  // the test, and matches what pgh device-linker produces on first
  // attest.
  const proofA = await identityKey.bootstrap(deviceA.kp.publicKey)
  const proofB = await identityKey.bootstrap(deviceB.kp.publicKey)

  return {
    rootHex: b4a.toString(identityKey.identityKeyPair.publicKey, 'hex'),
    deviceA: { ...deviceA, chainB64: b4a.toString(proofA, 'base64') },
    deviceB: { ...deviceB, chainB64: b4a.toString(proofB, 'base64') }
  }
}

async function rpcWithChain (port, device, method, params = {}) {
  const { payload, sig } = await signTestEnvelope({
    keypair: device.anKeypair,
    method,
    params
  })
  return postJson('http://127.0.0.1:' + port + '/api/rpc', {
    payload, sig, chain: device.chainB64
  })
}

test('Phase 2d: chain envelope auto-registers device; list/label/revoke work', async (t) => {
  const node = await makeNode(t)
  const port = node.svc.http.port
  const ident = await makeIdentityWithTwoDevices()

  // 1) Envelope from device A → TOFU registers root + device A
  const whoamiA = await rpcWithChain(port, ident.deviceA, 'whoami', {})
  t.is(whoamiA.status, 200, 'whoami via deviceA accepted')
  t.is(whoamiA.body.identity.publicKeyHex, ident.rootHex,
    'identity publicKey is the chain root, not the device')

  // 2) Envelope from device B → TOFU registers device B under same user
  const whoamiB = await rpcWithChain(port, ident.deviceB, 'whoami', {})
  t.is(whoamiB.status, 200, 'whoami via deviceB accepted')
  t.is(whoamiB.body.identity.publicKeyHex, ident.rootHex,
    'deviceB resolves to the same root identity')

  // 3) list-my-devices — should include root + A + B
  const list = await rpcWithChain(port, ident.deviceA, 'list-my-devices', {})
  t.is(list.status, 200, 'list-my-devices accepted')
  const pubkeys = list.body.result.devices.map(d => d.publicKey).sort()
  t.ok(pubkeys.includes(ident.rootHex), 'list includes root')
  t.ok(pubkeys.includes(ident.deviceA.pubHex), 'list includes device A')
  t.ok(pubkeys.includes(ident.deviceB.pubHex), 'list includes device B')

  const bEntry = list.body.result.devices.find(d => d.publicKey === ident.deviceB.pubHex)
  t.is(bEntry.method, 'keet')
  t.is(bEntry.label, null, 'label starts null')
  t.ok(bEntry.firstLinkedAt, 'firstLinkedAt recorded')
  t.absent(bEntry.revoked, 'not revoked')

  // 4) label-device on B, signed by A
  const label = await rpcWithChain(port, ident.deviceA, 'label-device', {
    devicePubkey: ident.deviceB.pubHex,
    label: 'laptop-pgh'
  })
  t.is(label.status, 200, 'label-device accepted')
  t.ok(label.body.ok)

  const list2 = await rpcWithChain(port, ident.deviceA, 'list-my-devices', {})
  const bEntry2 = list2.body.result.devices.find(d => d.publicKey === ident.deviceB.pubHex)
  t.is(bEntry2.label, 'laptop-pgh', 'label persisted')

  // 5) Self-revoke (A trying to revoke A) must be blocked
  const selfRev = await rpcWithChain(port, ident.deviceA, 'revoke-device', {
    devicePubkey: ident.deviceA.pubHex
  })
  t.not(selfRev.status, 200, 'self-revoke rejected (non-200)')
  t.ok(/own current device/i.test(selfRev.body.error || ''),
    'self-revoke error message matches')

  // 6) Cross-revoke: A revokes B
  const revB = await rpcWithChain(port, ident.deviceA, 'revoke-device', {
    devicePubkey: ident.deviceB.pubHex
  })
  t.is(revB.status, 200, 'cross-revoke accepted')
  t.ok(revB.body.ok)

  // 7) A can still authenticate
  const stillA = await rpcWithChain(port, ident.deviceA, 'whoami', {})
  t.is(stillA.status, 200, 'device A still works')

  // 8) B can no longer authenticate — EREVOKED
  const brokenB = await rpcWithChain(port, ident.deviceB, 'whoami', {})
  t.is(brokenB.status, 401, 'revoked deviceB is rejected')
  t.is(brokenB.body && brokenB.body.code, 'EREVOKED', 'rejection code is EREVOKED')
})