OverviewFilesCommitsIssuesDocsReleasesPeers

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

131 lines · javascript ref:
// CONTROL: a device must be able to mint its key BEFORE it knows which DID it is joining.
//
// This is the whole reason the pairing screen asks for a DID. The device keypair is derived from a
// mnemonic and NOTHING else — `deviceKeetKeyPair(mnemonic)` takes no DID — so requiring one is pure
// bookkeeping: `coownerJoin` stores the mnemonic INSIDE the per-DID entry, so it needs the entry, so it
// needs the DID.
//
// favela-web's browser join already does it the right way (bin/favela-web.js:962): generate the sub-key
// locally, send ONLY the invite, and take the identity from what comes back. The bundle already carries
// `did` (coowner.js:106) and coownerJoin already cross-checks it (`:140`), so nothing about the ceremony
// needs the caller to know it in advance.
//
// ⛔ AND AN EXISTING PER-DID MNEMONIC MUST STILL WIN. The header at coowner.js:121 is explicit:
// regenerating orphans an already-attested key and leaves the machine permanently unadmitted. So the
// device-scoped key is a FALLBACK for devices that have none, never a replacement.
//
// Run: bare test/coowner-join-didless.test.mjs
import '../lib/bare-globals.js'
import test from 'brittle'
import fs from 'bare-fs'
import b4a from 'b4a'
import IdentityKey from 'keet-identity-key'
import { 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-didless-' + 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'))

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',
      deviceMnemonic: HOST_DEV,
      deviceProof: hex(proof),
      signingKey: SIGNING,
      rootIdentityKey: hex(await rootIdentityPublicKey(ROOT)),
      members: [hex(dev.publicKey)]
    }]
  }, null, 2))
  return p
}

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

test('CONTROL — a device mints its key with NO did', async (t) => {
  const p = joinerConfig(tmpdir())
  const r = await coownerJoin({ configFile: p })
  t.ok(/^[0-9a-f]{64}$/.test(r.devicePublicKey || ''), 'returns a device public key without being told an identity')
  t.ok(readJson(p).deviceMnemonic, 'and stores the mnemonic at CONFIG ROOT — it belongs to the device, not to a DID')
})

test('the same device key comes back — never regenerated', async (t) => {
  const p = joinerConfig(tmpdir())
  const a = await coownerJoin({ configFile: p })
  const b = await coownerJoin({ configFile: p })
  t.is(a.devicePublicKey, b.devicePublicKey, 'idempotent — a regenerated key would orphan an attestation already issued for the old one')
})

test('a bundle with no did argument writes the entry the BUNDLE names', async (t) => {
  const hostP = await hostConfig(tmpdir())
  const joinP = joinerConfig(tmpdir())

  const pre = await coownerJoin({ configFile: joinP }) // no did — the phone does not know it yet
  const { bundle } = await coownerInvite({ did: DID, devicePublicKey: pre.devicePublicKey, configFile: hostP })
  const joined = await coownerJoin({ bundle, configFile: joinP }) // still no did

  t.is(joined.entry.did, DID, 'the DID came from the bundle, never from the caller')
  const entry = readJson(joinP).dids.find((d) => d.did === DID)
  t.ok(entry, 'the entry was written')
  t.is(entry.signingKey, SIGNING, 'and carries the shared signing key — this is what makes it a NODE')
  t.is(entry.joiner, true, 'joiner posture survived')
  t.absent(entry.forceCreator, 'and it did not claim the genesis')

  const kp = await deviceKeetKeyPair(entry.deviceMnemonic || readJson(joinP).deviceMnemonic)
  t.is(hex(kp.publicKey), pre.devicePublicKey, 'the entry uses the SAME device key that was attested')
})

test('an existing PER-DID mnemonic is reused, not replaced by the device-scoped one', async (t) => {
  const dir = tmpdir()
  const p = dir + '/config.json'
  const legacy = IdentityKey.generateMnemonic()
  const legacyKey = hex((await deviceKeetKeyPair(legacy)).publicKey)
  fs.writeFileSync(p, JSON.stringify({
    storage: dir + '/data',
    swarm: true,
    deviceMnemonic: IdentityKey.generateMnemonic(), // a device-scoped key exists too
    dids: [{ did: DID, deviceMnemonic: legacy }]
  }, null, 2))

  const r = await coownerJoin({ did: DID, configFile: p })
  t.is(r.devicePublicKey, legacyKey, 'the per-DID mnemonic WINS — an already-attested key must never be orphaned')
  t.is(readJson(p).dids[0].deviceMnemonic, legacy, 'and is left untouched on disk')
})

test('CONTROL — a device with NO config file at all can still join', async (t) => {
  // The state every fresh install is in. The phone creates its hpp DIRECTORY at boot but no config
  // file, so the first thing the pairing ceremony did was read a file that cannot exist yet and turn
  // ENOENT into "cannot read config" — failing before the tunnel even opened. A joiner starting with
  // nothing is the normal case, not a fault; a MALFORMED config is still a fault.
  const dir = tmpdir()
  const missing = dir + '/does-not-exist.json'
  const r = await coownerJoin({ configFile: missing })
  t.ok(/^[0-9a-f]{64}$/.test(r.devicePublicKey || ''), 'it minted a device key against a config that did not exist')
  t.ok(fs.existsSync(missing), 'and wrote the config it needed')
})

test('a MALFORMED config still fails loudly', async (t) => {
  const dir = tmpdir()
  const bad = dir + '/bad.json'
  fs.writeFileSync(bad, '{ this is not json')
  let threw = null
  try { await coownerJoin({ configFile: bad }) } catch (e) { threw = e }
  t.ok(threw && /cannot read config/.test(threw.message), 'a corrupt config is NOT silently replaced: ' + (threw && threw.message))
})