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