OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer / test / coowner.test.mjs

143 lines · javascript ref:
// The co-owner ceremony end to end, on real config files, ending at the REAL admission gate.
//
// Admitting a second machine to a DID needs four things to line up across two boxes: the joiner has its
// own device keypair, an existing co-owner has attested it to the DID's ROOT identity, both rosters name
// both keys, and the joiner holds the shared #atproto signing key. By hand that is four hex-string edits
// with a secret in the middle — and a typo does not error, it silently leaves the room refusing
// admission. Hence a command; hence this test.
//
// The load-bearing assertion is the last one: the proof the ceremony mints actually passes
// makeAttest/makeVerifyAttest — the exact admission crypto StateBaseV3 injects — anchored at the ROOT
// identity, with no root mnemonic anywhere in the ceremony.
//
// Run: bare test/coowner.test.mjs
import '../lib/bare-globals.js'
import test from 'brittle'
import fs from 'bare-fs'
import b4a from 'b4a'
import { makeAttest, makeVerifyAttest, attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from 'atproto-room/lib/membership.js'
import { coownerInvite, coownerJoin } from '../lib/coowner.js'

const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
const HOST_DEV = 'legal winner thank year wave sausage worth useful legal winner thank yellow'
const DID = 'did:plc:coowner00000000000000000'
const SIGNING = 'a'.repeat(64)

const tmpdir = () => { const d = '/tmp/hpp-coown-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
const hex = (u8) => b4a.toString(b4a.from(u8), 'hex')
const readJson = (p) => JSON.parse(fs.readFileSync(p, 'utf8'))

// An EXISTING co-owner's config: it holds its own device mnemonic + the root's attestation of it, the
// shared signing key, and the root's PUBLIC key. No root mnemonic — that is the whole point.
async function hostConfig (dir) {
  const dev = await deviceKeetKeyPair(HOST_DEV)
  const proof = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: dev.publicKey })
  const p = dir + '/config.json'
  fs.writeFileSync(p, JSON.stringify({
    storage: dir + '/data',
    swarm: true,
    dids: [{
      did: DID,
      handle: 'coowner.test',
      pdsUrl: 'https://pds.test',
      storeBackend: 'overlay',
      deviceMnemonic: HOST_DEV,
      deviceProof: hex(proof),
      signingKey: SIGNING,
      rootIdentityKey: hex(await rootIdentityPublicKey(ROOT))
    }]
  }, null, 2))
  return { path: p, deviceHex: hex(dev.publicKey) }
}

const joinerConfig = (dir) => {
  const p = dir + '/config.json'
  fs.writeFileSync(p, JSON.stringify({ storage: dir + '/data', swarm: true, dids: [] }, null, 2))
  return p
}

test('the ceremony admits a joiner, and its proof passes the REAL admission gate', async (t) => {
  const hostDir = tmpdir(); const joinDir = tmpdir()
  const host = await hostConfig(hostDir)
  const joinerPath = joinerConfig(joinDir)

  // 1. joiner: mint its device keypair, print only the PUBLIC key
  const step1 = await coownerJoin({ did: DID, configFile: joinerPath })
  t.ok(/^[0-9a-f]{64}$/.test(step1.devicePublicKey), 'joiner produced a device public key')
  t.absent(step1.joined, 'and is not yet joined')
  // The mnemonic is DEVICE-scoped now (config root), with a per-DID one still honoured when a legacy
  // config has one — see coownerJoin. The property under test is unchanged: it is stored LOCALLY and
  // never printed. Only its location moved, so the assertion reads the location it may live in rather
  // than pinning one.
  const joinCfg = readJson(joinerPath)
  const secret = joinCfg.deviceMnemonic || (joinCfg.dids[0] && joinCfg.dids[0].deviceMnemonic)
  t.ok(secret && secret.split(' ').length >= 12, 'its mnemonic was stored locally')
  t.absent(JSON.stringify(step1).includes(secret), 'and the mnemonic is NOT in the printed output')

  // 2. existing co-owner: attest it (chained, no root mnemonic) + add to the roster
  const inv = await coownerInvite({ did: DID, devicePublicKey: step1.devicePublicKey, configFile: host.path })
  t.ok(inv.bundle.deviceProof, 'the invite carries a chained proof')
  t.is(inv.bundle.signingKey, SIGNING, 'and the shared signing key the co-owner must hold')
  t.alike(inv.members.slice().sort(), [host.deviceHex, step1.devicePublicKey].sort(), 'the roster now names BOTH devices')
  t.ok(fs.existsSync(inv.backup), 'the previous config was backed up before writing')
  t.is(readJson(host.path).dids[0].forceCreator, true, 'the inviter PINNED its creator role — a growing roster must not demote it')

  // 3. joiner: write the entry from the bundle
  const step3 = await coownerJoin({ did: DID, bundle: inv.bundle, configFile: joinerPath })
  t.ok(step3.joined, 'joiner joined')
  const joined = readJson(joinerPath)
  const entry = joined.dids[0]
  // Same property, new home: the key the entry USES must be the one that was attested. Read it the way
  // coownerJoin resolves it (per-DID first, else device-scoped) rather than assuming a location.
  const entryMnemonic = entry.deviceMnemonic || joined.deviceMnemonic
  t.is(entryMnemonic, secret, 'its mnemonic was REUSED, not regenerated (regenerating would orphan the attested key)')
  t.is(entry.signingKey, SIGNING, 'it holds the shared signing key')
  t.is(entry.storeBackend, 'overlay', 'and inherits the backend')
  t.absent(entry.indexer, 'it is a PLAIN WRITER — never an indexer (the N=2 quorum trap)')
  t.is(entry.joiner, true, 'and carries the JOINER posture — forceNonCreator + converge-only boot')
  t.absent(entry.forceCreator, 'never also pinned as creator — exactly one node holds the genesis')
  t.alike(entry.members.slice().sort(), [host.deviceHex, step1.devicePublicKey].sort(), 'and carries the full roster')

  // 4. THE POINT: the minted proof passes the admission crypto StateBaseV3 actually uses.
  const rootPub = await rootIdentityPublicKey(ROOT)
  const kp = await deviceKeetKeyPair(entryMnemonic)
  const signData = b4a.from('localKey||baseKey admission bytes')
  const attested = await makeAttest(kp, b4a.from(entry.deviceProof, 'hex'))(signData)
  const verified = await makeVerifyAttest(rootPub)(attested, signData, kp.publicKey)
  t.ok(verified, 'the joiner is ADMITTED — the chained proof resolves to the DID root')
  t.alike(b4a.from(verified.identityPublicKey), b4a.from(rootPub), 'anchored at the same root identity')

  // The inviter must still be admissible after vouching (keet mutates a decoded chain in place).
  const hostKp = await deviceKeetKeyPair(HOST_DEV)
  const hostEntry = readJson(host.path).dids[0]
  const hostAttested = await makeAttest(hostKp, b4a.from(hostEntry.deviceProof, 'hex'))(signData)
  t.ok(await makeVerifyAttest(rootPub)(hostAttested, signData, hostKp.publicKey), 'the inviter still verifies afterwards')
})

test('re-running the join is idempotent and never regenerates the key', async (t) => {
  const dir = tmpdir()
  const p = joinerConfig(dir)
  const a = await coownerJoin({ did: DID, configFile: p })
  const b = await coownerJoin({ did: DID, configFile: p })
  t.is(a.devicePublicKey, b.devicePublicKey, 'the same device key across runs — an attested key is never orphaned')
  t.is(readJson(p).dids.length, 1, 'and no duplicate entry is appended')
})

test('refuses to vouch when this machine does not host the DID, or lacks the material', async (t) => {
  const dir = tmpdir()
  const p = joinerConfig(dir)
  await t.exception(() => coownerInvite({ did: DID, devicePublicKey: 'b'.repeat(64), configFile: p }), /does not host/, 'a non-host cannot invite')

  const half = dir + '/half.json'
  fs.writeFileSync(half, JSON.stringify({ dids: [{ did: DID, deviceMnemonic: HOST_DEV }] }))
  await t.exception(() => coownerInvite({ did: DID, devicePublicKey: 'b'.repeat(64), configFile: half }), /deviceProof|signingKey/, 'a host without a proof cannot vouch')
})

test('rejects a malformed device key and a mismatched bundle', async (t) => {
  const dir = tmpdir()
  const host = await hostConfig(dir)
  await t.exception(() => coownerInvite({ did: DID, devicePublicKey: 'nope', configFile: host.path }), /64-hex/, 'a bad device key is refused')
  const p = joinerConfig(dir)
  await t.exception(() => coownerJoin({ did: DID, bundle: { did: 'did:plc:other', deviceProof: 'x', signingKey: 'y', rootIdentityKey: 'z' }, configFile: p }), /bundle is for/, 'a bundle for another DID is refused')
})