/**
* 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')
})