OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer / test / device-registry.test.js

66 lines · javascript ref:
// device-registry.test.js — the persistent, revocable browser-sub-key registry (Option 3): delegated
// sub-keys recorded as RESERVED StateBase entries (never in the public repo), listable + revocable via
// a soft tombstone. The atproto-keyless analog of hyper-identity's IdentityRegistry device records +
// `revoked:` tombstone, riding the StateBase we already run (no second autobase).

import '../lib/bare-globals.js'
import test from 'brittle'
import crypto from 'hypercore-crypto'
import fs from 'bare-fs'
import { StateBaseV3 } from 'atproto-room/lib/state-base.js'
import { SwarmDevice } from '../lib/swarm-device.js'

const PRIV = new Uint8Array(Buffer.from('9085d2bef69286a6cbb51623c8fa258629945cd55ca705cc4e66700396894e0c', 'hex'))
const DID = 'did:plc:test22222222222222222222'
function tmp () { const d = '/tmp/hpp-devreg-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
const subKeyFrom = (b) => '02' + b.repeat(32) // valid 33-byte compressed pubkey shape (66 hex)

test('device registry: add → list → revoke → restore (reserved, never public)', async (t) => {
  const sb = new StateBaseV3(tmp(), crypto.randomBytes(32), { roomSeed: crypto.randomBytes(32), network: false })
  await sb.ready()
  t.teardown(() => sb.close())
  const dev = new SwarmDevice(sb, { did: DID, privateKey: PRIV, self: 'deviceA' })

  const sk1 = subKeyFrom('aa')
  const sk2 = subKeyFrom('bb')

  await dev.addDevice(sk1, { label: 'laptop' })
  await dev.addDevice(sk2)
  let list = await dev.listDevices()
  t.is(list.length, 2, 'both delegated devices listed')
  const d1 = list.find((d) => d.subKey === sk1)
  t.is(d1.label, 'laptop', 'label preserved')
  t.ok(d1.addedAt > 0, 'addedAt recorded')
  t.absent(d1.revoked, 'not revoked initially')
  t.absent(await dev.isDeviceRevoked(sk1), 'isDeviceRevoked false initially')

  // re-adding preserves the original addedAt (idempotent re-delegate)
  const firstAddedAt = d1.addedAt
  await dev.addDevice(sk1, { label: 'laptop-renamed' })
  const d1b = (await dev.listDevices()).find((d) => d.subKey === sk1)
  t.is(d1b.addedAt, firstAddedAt, 're-delegate keeps original addedAt')
  t.is(d1b.label, 'laptop-renamed', 're-delegate can relabel')

  // revoke sk1
  await dev.revokeDevice(sk1, { reason: 'lost laptop' })
  t.ok(await dev.isDeviceRevoked(sk1), 'sk1 now revoked')
  t.absent(await dev.isDeviceRevoked(sk2), 'sk2 still valid')
  list = await dev.listDevices()
  t.ok(list.find((d) => d.subKey === sk1).revoked, 'sk1 shows revoked in list')
  t.ok(list.find((d) => d.subKey === sk1).revokedAt > 0, 'revokedAt recorded')

  // restore sk1 (soft, reversible tombstone — never a destructive delete of the record)
  await dev.restoreDevice(sk1)
  t.absent(await dev.isDeviceRevoked(sk1), 'sk1 restored')
  t.ok((await dev.listDevices()).find((d) => d.subKey === sk1), 'sk1 still listed after restore')

  // the registry entries are RESERVED — they never surface as public repo records
  const records = await dev.repo.listRecords()
  const leaked = records.filter((r) => String(r.collection || '').startsWith('__device'))
  t.is(leaked.length, 0, 'device registry entries never appear as public repo records')

  // input validation
  await t.exception(async () => { await dev.addDevice('nothex') }, /subKey/, 'bad subKey rejected')
  await t.exception(async () => { await dev.revokeDevice('nothex') }, /subKey/, 'revoke bad subKey rejected')
})