OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer commits / 348b417

coowner: any device can invite the next, and joining takes only the invite
Three defects, each of which made the ceremony unusable from a device without a shell — which is every phone. THE ROSTER WAS TRUNCATED. addMember gated on Array.isArray(entry.members), but a roster is stored as an array of hex, an array of {key,role}, OR a comma-separated string (wj62 in production is the string form). config.js normalizeMembers accepts all three; this path reads raw JSON and did not, so a populated roster read as EMPTY and coownerInvite rewrote it as [self, joiner] — silently dropping every other device, performed by the command whose job is to grow the roster. INVITING CLAIMED THE GENESIS. forceCreator was pinned on whichever machine minted, gated only on !entry.joiner. Keet is the reference: addDevice verifies the attestation, addWriters the device, and gives the ADDER nothing — it has no creator election at all, because its room key IS the autobase key. Ours is a local construct, so the pin may only ever RE-STATE a role a node already holds. Two nodes were wrongly captured: one forced non-creator by resyncStore (a RUNTIME flag this file cannot see — tabertron's posture for wj62, where state-base.js checks _forceCreator BEFORE _forceNonCreator, so the pin would WIN and the DID would have two creators), and one carrying an explicit forceCreator:false. THE DID WAS DEMANDED BEFORE IT COULD BE KNOWN. A device keypair derives from a mnemonic and nothing else, yet coownerJoin minted it INSIDE the per-DID entry, so it needed a DID the bundle then hands back anyway (and cross-checks). The mnemonic is now device-scoped at config root, with an existing per-DID one still winning — that ordering is load-bearing: replacing an already-attested key orphans the attestation and leaves the machine permanently unadmitted. favela-web's browser join already worked this way; adopt now matches it. Also: an ABSENT config is where every joiner starts, not an error. ENOENT became "cannot read config", so the ceremony died on its first line for every fresh install. Missing now means new; MALFORMED still stops, deliberately — silently replacing a corrupt config would discard the one thing that cannot be regenerated. adopt.js moves here from the phone's tree, because any node holding the DID must be able to invite the next one, not just the phone. `coowner-invite <did> --pair` mints over the blind-pairing tunnel instead of a bundle file: same ceremony, same crypto, a courier that does not require two shells and a copied secret. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
348b417efeafa36cc5609a8f1e4cf33420f9697a
by plankatron <fleeky@gmail.com> · 2026-08-09T22:59:17.000Z

Changed files (8) +538 -16

index.js +31 -0
……@@ -67,6 +67,37 @@ if (sub === 'repair-pointers') {
6767// it by hand is four hex-string edits across two boxes with a shared secret in the middle, and it is what
6868// anyone adding a second machine needs. No root mnemonic involved: the invite is chain-attested by the
6969// inviting device itself (atproto-identity attestDeviceViaDevice). See lib/coowner.js for the ceremony.
70// `--pair` mints the invite over a BLIND-PAIRING TUNNEL instead of a bundle file. Same ceremony, same
71// crypto — only the hand-off changes. The file route asks an operator to move a secret between two boxes
72// by hand, which is fine between servers you have shells on and impossible for a phone. Any node holding
73// the DID can run this: keet's addDevice is available to every active member and confers nothing on the
74// adder, and coownerInvite no longer claims the genesis.
75if (sub === 'coowner-invite' && argv.includes('--pair')) {
76 const { mintAdoptInvite } = await import('./lib/adopt.js')
77 const did = argv.find((a) => a.startsWith('did:'))
78 if (!did) { console.error('usage: hyper-proto-peer coowner-invite <did> --pair [--config <file>]'); globalThis.Bare.exit(1) }
79 const ci = argv.indexOf('--config')
80 const configFile = ci >= 0 ? argv[ci + 1] : null
81 try {
82 // APPROVAL IS THE INVOCATION. coownerInvite MUTATES the config and the bundle carries the shared
83 // signing key, so mintAdoptInvite refuses to fire without an approve() — running this command, on a
84 // machine that holds the DID, IS that approval. Same posture as favela-web's `pair`.
85 const src = await mintAdoptInvite({ did, approve: () => true, configFile })
86 console.log('\n=== invite for ' + did + ' ===\n')
87 console.log(' ' + src.inviteHex + '\n')
88 console.error('Paste that ONE string into the joining device. It carries the shared signing key, so\ntreat it like a password: single-use, and anyone who reads it first can take the identity.\n')
89 console.error('Waiting for the device to join (Ctrl-C to cancel)…')
90 const r = await src.waitForPeer()
91 if (r) console.error('\n✓ joined — device ' + String(r.devicePublicKey).slice(0, 16) + '… roster now ' + (r.members ? r.members.length : '?') + ' device(s)')
92 else console.error('\n✗ refused — nothing was written')
93 try { await src.close() } catch {}
94 console.error('\nRestart this daemon and the joiner so both pick up the new roster.')
95 globalThis.Bare.exit(r ? 0 : 1)
96 } catch (e) {
97 console.error(String(e.message || e))
98 globalThis.Bare.exit(1)
99 }
100}
70101if (sub === 'coowner-invite' || sub === 'coowner-join') {
71102 const { coownerCli } = await import('./lib/coowner.js')
72103 try {
lib/adopt.js +106 -0
……@@ -0,0 +1,106 @@
1// adopt — admit ANOTHER DEVICE into an existing DID over a blind-pairing tunnel instead of a file.
2//
3// Lives in hpp (not in a client) because ANY node holding the DID must be able to mint an invite for the
4// next device — desktop or phone, symmetrically. That is keet's model: `addDevice`
5// (contract/members/index.js) is available to any active member and the adder gains nothing by
6// performing it. While this lived in the phone's tree, only the phone could mint.
7//
8// ⛔ THIS ADDS A TRANSPORT, NOT A CEREMONY. hyper-proto-peer's `coowner-invite` / `coowner-join` already
9// do the whole cryptographic job, and do it correctly:
10// - chained attestation root -> existing -> joiner, with NO ROOT MNEMONIC anywhere (a node deliberately
11// never stores one; only the root's PUBLIC key, which every node already carries)
12// - the inviter pins `forceCreator` on itself, so growing the roster cannot silently demote it
13// - the joiner is written with `joiner: true` and WITHOUT `indexer` — a plain writer
14// Those two lines are the loser-club fork and the N=2 quorum trap, already solved. Re-implementing any of
15// it here would be writing a second, worse copy. This module only replaces the hand-off.
16//
17// WHAT WAS ACTUALLY MISSING. The existing ceremony moves the bundle as a FILE between two machines:
18// joiner$ hyper-proto-peer coowner-join <did> -> prints its device PUBLIC key
19// existing$ hyper-proto-peer coowner-invite <did> --device <hex> -> writes a bundle file
20// joiner$ hyper-proto-peer coowner-join <did> --bundle <file>
21// That is fine between two servers you have shells on. It is useless for a phone: there is no shell, no
22// scp, and the bundle CARRIES THE SHARED SIGNING KEY — so "move this secret by hand" is the worst
23// possible instruction to give a user.
24//
25// The blind-pairing tunnel is strictly better than the file it replaces: one-time, owner-minted,
26// end-to-end encrypted over the DHT, and the secret never touches a filesystem either side.
27//
28// ⚠ `coownerInvite` MUTATES THE LOCAL CONFIG (adds the member, pins forceCreator) as well as returning
29// the bundle. So it must be called only AFTER the human approves — never speculatively on connection.
30import { createPairInvite, joinPairInvite } from 'atproto-room/lib/pairing.js'
31import { coownerInvite, coownerJoin } from './coowner.js'
32
33// ── EXISTING DEVICE (desktop, or any already-attested device — NOT necessarily the root) ─────────────
34//
35// approve({ devicePublicKey, did }) -> boolean. Called when a phone reaches the tunnel and BEFORE any
36// config is touched. Return false and the pairing is refused with the config untouched.
37//
38// Returns { inviteHex, waitForPeer, close } — same shape as createPairInvite, so a CLI or a UI can show
39// the invite as text or a QR and then await the result.
40export async function mintAdoptInvite ({ did, approve, configFile = null, swarm = null } = {}) {
41 if (!did) throw new Error('mintAdoptInvite requires { did }')
42 if (typeof approve !== 'function') throw new Error('mintAdoptInvite requires approve({ devicePublicKey, did }) -> boolean')
43
44 let result = null
45
46 const source = await createPairInvite({
47 swarm,
48 // The candidate's hello carries its device PUBLIC key. Same field the browser flow uses for a
49 // sub-key — the tunnel does not care what the bytes mean, only this handler does.
50 onSubkey: async (devicePublicKey) => {
51 const ok = await approve({ devicePublicKey, did })
52 if (!ok) {
53 // Refusing must leave NOTHING behind: no roster entry, no forceCreator, no backup file.
54 // Returning an error object rather than throwing keeps the tunnel's own teardown clean.
55 return { error: 'refused by the device owner' }
56 }
57 // Only now does anything mutate. coownerInvite adds the member, pins our creator role, writes the
58 // config (leaving a .bak), and hands back the bundle.
59 const r = await coownerInvite({ did, devicePublicKey, configFile })
60 result = { members: r.members, backup: r.backup, devicePublicKey }
61 return r.bundle
62 }
63 })
64
65 return {
66 inviteHex: source.inviteHex,
67 async waitForPeer () {
68 await source.waitForPeer()
69 return result // null iff refused
70 },
71 close: () => source.close()
72 }
73}
74
75// ── PHONE ────────────────────────────────────────────────────────────────────────────────────────────
76//
77// Two calls, because the device key must exist before there is anything to attest:
78// 1. coownerJoin({ did }) — mints a device mnemonic if absent, returns its PUBLIC key.
79// The mnemonic is written to config and never leaves the phone.
80// 2. joinPairInvite(...) — sends that public key up the tunnel, gets the bundle back.
81// 3. coownerJoin({ did, bundle }) — writes the entry, sets joiner:true, leaves indexer unset.
82//
83// Step 3 is what makes the phone a NODE rather than a client: the bundle carries the shared #atproto
84// signing key, so from here the phone materialises and signs its own commits.
85// `did` is OPTIONAL and normally omitted — the invite alone is the whole handoff. The device mints its
86// key knowing nothing (a keypair is derived from a mnemonic, not from an identity), sends the public
87// half up the tunnel, and learns the DID from the bundle that comes back — which coownerJoin then
88// cross-checks. Same shape as favela-web's browser join (bin/favela-web.js:962). Pass `did` only to
89// ASSERT which identity you expect; a mismatch then throws instead of joining the wrong one.
90export async function joinAdoptInvite ({ did = null, inviteHex, configFile = null, swarm = null, onStep = () => {} } = {}) {
91 if (!inviteHex) throw new Error('joinAdoptInvite requires { inviteHex }')
92
93 const pre = await coownerJoin({ did, configFile })
94 onStep('device key ready', { devicePublicKey: pre.devicePublicKey })
95
96 const bundle = await joinPairInvite(inviteHex, { subKey: pre.devicePublicKey, swarm })
97 if (!bundle || bundle.error) throw new Error('adopt refused: ' + ((bundle && bundle.error) || 'no bundle'))
98 onStep('bundle received', { did: bundle.did })
99
100 const joined = await coownerJoin({ did: did || bundle.did, bundle, configFile })
101 onStep('joined', { members: joined.entry.members })
102
103 // The caller restarts the node; boot picks the entry up. Deliberately not restarting here — a pairing
104 // function that also manages process lifecycle is two things.
105 return { devicePublicKey: pre.devicePublicKey, entry: joined.entry, backup: joined.backup }
106}
lib/coowner.js +75 -11
……@@ -35,8 +35,22 @@ import { configPath } from './config.js'
3535
3636const hex = (u8) => b4a.toString(b4a.from(u8), 'hex')
3737
38// ⛔ AN ABSENT CONFIG IS NOT AN ERROR — IT IS WHERE EVERY JOINER STARTS. A phone on first run has an
39// hpp directory and no config file, so turning ENOENT into a hard failure meant the pairing ceremony
40// died on its first line, before the tunnel even opened ("coowner: cannot read config at
41// /data/user/0/to.favela.android/files/hpp/config.json (ENOENT…)").
42//
43// A MALFORMED config is still a hard failure, and deliberately so: silently replacing a corrupt file
44// would discard a device's mnemonic and roster — the one thing that cannot be regenerated (a fresh key
45// orphans an attestation and leaves the machine permanently unadmitted). Missing means "new"; unreadable
46// means "stop".
3847function readConfigFile (p) {
39 try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch (e) { throw new Error('coowner: cannot read config at ' + p + ' (' + e.message + ')') }
48 let raw
49 try { raw = fs.readFileSync(p, 'utf8') } catch (e) {
50 if (e && (e.code === 'ENOENT' || /ENOENT/.test(e.message || ''))) return {}
51 throw new Error('coowner: cannot read config at ' + p + ' (' + e.message + ')')
52 }
53 try { return JSON.parse(raw) } catch (e) { throw new Error('coowner: cannot read config at ' + p + ' (' + e.message + ')') }
4054}
4155
4256// Never write a config without leaving the previous one on disk. A mistyped roster locks a machine out
……@@ -53,8 +67,17 @@ const findEntry = (cfg, did) => (cfg.dids || []).find((d) => d.did === did) || n
5367// Add a device key to a DID entry's roster, idempotently. The roster is the set of device ids that MAY be
5468// admitted; admission itself is still proof-gated, so listing a key grants nothing on its own.
5569function addMember (entry, deviceHex) {
56 const cur = Array.isArray(entry.members) ? entry.members.slice() : []
57 const norm = cur.map((m) => (typeof m === 'object' && m ? String(m.key || '') : String(m)).toLowerCase())
70 // ⛔ THE ROSTER IS NOT ALWAYS AN ARRAY. On disk it is an array of hex strings, an array of {key,role}
71 // objects, OR a comma-separated string — wj62 in production is the string form. `config.js
72 // normalizeMembers` accepts all three, but that is the BOOT path; this file reads the config with its
73 // own raw JSON.parse, so it must accept them too. An `Array.isArray` gate treated a populated string
74 // roster as EMPTY, which made `coownerInvite` rewrite it as [self, joiner] and silently DROP every
75 // other device — a truncation performed by the command whose job is to grow the roster.
76 // Control: test/coowner-members-string.test.mjs.
77 const raw = entry.members
78 const list = Array.isArray(raw) ? raw : (typeof raw === 'string' ? raw.split(',') : [])
79 const cur = list.map((m) => String((typeof m === 'object' && m ? m.key : m) || '').trim()).filter(Boolean)
80 const norm = cur.map((m) => m.toLowerCase())
5881 // A roster that was ABSENT means "self only" (bootSwarmDevice defaults to [{key:self}]), so the first
5982 // explicit roster must name the existing device too or it would silently drop it.
6083 if (!norm.length) return { members: null, added: false, needsSelf: true }
……@@ -96,7 +119,20 @@ export async function coownerInvite ({ did, devicePublicKey, configFile = null }
96119 //
97120 // Only if we are not ourselves a joiner: chains nest, so a joiner can legitimately vouch for the next
98121 // machine, but it must never claim the genesis — that stays with whoever bootstrapped the room.
99 if (!entry.joiner) entry.forceCreator = true
122 //
123 // ⛔ BUT INVITING IS NOT A CLAIM TO THE GENESIS. Keet is the reference here: `addDevice`
124 // (contract/members/index.js) verifies the attestation, addWriters the device, and gives the ADDER
125 // nothing — no role moves, because keet has no creator election at all (its room key IS the autobase
126 // key, so there is no roomSeed-derived genesis writer to claim or abandon). Our election is a local
127 // construct, so the pin must be too: it may only ever RE-STATE a role this node already holds.
128 //
129 // Two nodes are wrongly captured by a bare `!entry.joiner`:
130 // - one forced non-creator by `resyncStore` (device-node.js:178) — a RUNTIME flag, invisible here,
131 // which is tabertron's posture for wj62. state-base.js:406 checks _forceCreator BEFORE
132 // _forceNonCreator, so writing the pin would WIN and the DID would have two creators.
133 // - one carrying an explicit `forceCreator: false`, which is a decision on file.
134 // Both must be able to invite the next device without changing who holds the genesis.
135 if (!entry.joiner && entry.forceCreator !== false) entry.forceCreator = true
100136 const backup = writeConfigFile(p, cfg)
101137
102138 return {
……@@ -120,20 +156,48 @@ export async function coownerInvite ({ did, devicePublicKey, configFile = null }
120156// mnemonic stays here and is never printed). With --bundle: write the full dids entry.
121157// Idempotent: an existing mnemonic is REUSED, never regenerated — regenerating would orphan an already
122158// attested key and leave the machine permanently unadmitted.
123export async function coownerJoin ({ did, bundle = null, configFile = null } = {}) {
124 if (!did) throw new Error('coowner-join: <did> required')
159//
160// ⛔ THE DID IS OPTIONAL. A device keypair is derived from a mnemonic and nothing else
161// (`deviceKeetKeyPair(mnemonic)` takes no DID), so a device can mint its key BEFORE it knows which
162// identity it is joining — which is what lets a single opaque invite be the whole handoff. favela-web's
163// browser join already works this way (bin/favela-web.js:962): generate locally, send only the invite,
164// take the identity from what comes back. Without this, the phone had to be TOLD a DID that the bundle
165// then hands back anyway (and that `:140` below already cross-checks).
166//
167// The mnemonic therefore lives at CONFIG ROOT — it identifies the DEVICE, not one of its rooms — with a
168// per-DID mnemonic still winning when present. That ordering is load-bearing: an existing per-DID key
169// may already be attested, and replacing it would orphan the attestation and leave the machine
170// permanently unadmitted (the same hazard the "never regenerate" rule above guards).
171export async function coownerJoin ({ did = null, bundle = null, configFile = null } = {}) {
125172 const p = configFile || configPath()
126173 const cfg = readConfigFile(p)
127174 if (!Array.isArray(cfg.dids)) cfg.dids = []
128 let entry = findEntry(cfg, did)
175 // A bundle names its own DID; the caller need not.
176 if (!did && bundle && bundle.did) did = bundle.did
177 if (!did && bundle) throw new Error('coowner-join: bundle carries no did')
178 let entry = did ? findEntry(cfg, did) : null
179
180 // Key selection, most-specific first: an already-attested per-DID key, else this device's own.
181 if (entry && entry.deviceMnemonic) {
182 // keep it — never regenerate
183 } else if (!cfg.deviceMnemonic) {
184 cfg.deviceMnemonic = IdentityKey.generateMnemonic()
185 }
186 const mnemonic = (entry && entry.deviceMnemonic) || cfg.deviceMnemonic
187
188 // With no DID at all we are in step 1 of the ceremony: mint/reuse the key, report it, write nothing
189 // else. The entry cannot exist yet — the bundle that names the DID has not arrived.
190 if (!did) {
191 const kp0 = await deviceKeetKeyPair(mnemonic)
192 const backup0 = writeConfigFile(p, cfg)
193 return { devicePublicKey: hex(kp0.publicKey), joined: false, backup: backup0, entry: null }
194 }
129195
130196 if (!entry) {
131 entry = { did, deviceMnemonic: IdentityKey.generateMnemonic() }
197 entry = { did }
132198 cfg.dids.push(entry)
133 } else if (!entry.deviceMnemonic) {
134 entry.deviceMnemonic = IdentityKey.generateMnemonic()
135199 }
136 const kp = await deviceKeetKeyPair(entry.deviceMnemonic)
200 const kp = await deviceKeetKeyPair(mnemonic)
137201 const devicePublicKey = hex(kp.publicKey)
138202
139203 if (bundle) {
lib/device-node.js +4 -1
……@@ -566,7 +566,10 @@ export async function bootSwarmDevice ({ storage, deviceMnemonic, did, signingKe
566566 // explicitly or the DID would never sync with anyone. Reported separately from _sideStore because
567567 // StateBaseV3 owns this one and closes it itself (sb.close); double-closing a resync store is what
568568 // _sideStore's own close path is for.
569 return { device, sb, did, self, control: serving, membrane: membraneServing, controlSwarm, overlay: recordOverlay, repoRoot, reconcile, close, _sideStore: resyncStore, _statebaseStore: keepLocalStatebase ? sb.store : null }
569 // `blobs` is exposed because an IN-PROCESS consumer needs it: favela-android runs this daemon inside
570 // the app, so its UI reaches blobs through the object rather than the `upload-blob`/`get-blob` RPCs the
571 // co-located (favela-web over UDS) and remote (bridge over swarm) consumers use. Same store, no surface.
572 return { device, sb, did, self, blobs: blobStore, control: serving, membrane: membraneServing, controlSwarm, overlay: recordOverlay, repoRoot, reconcile, close, _sideStore: resyncStore, _statebaseStore: keepLocalStatebase ? sb.store : null }
570573}
571574
572575// ── RESYNC (fresh-corestore re-join) index ────────────────────────────────────────────────────────
test/coowner-invite-noncreator.test.mjs +85 -0
……@@ -0,0 +1,85 @@
1// CONTROL: inviting a device must not change WHO THE CREATOR IS.
2//
3// Any co-owner can vouch for the next device — the header of lib/coowner.js says so, and the chained
4// attestation (root -> us -> joiner) supports it. But `coownerInvite` sets `forceCreator = true` on
5// whichever machine mints, gated only on `!entry.joiner`. That is wrong for two real cases:
6//
7// 1. A node deliberately forced NON-creator. tabertron is exactly this for wj62: its non-creator
8// posture comes from `resyncStore` (device-node.js:178 `forceNonCreator: !!resyncStore || !!joiner`),
9// NOT from the `joiner` flag — so the `!entry.joiner` guard does not see it. Minting an invite
10// there would write `forceCreator: true`, and state-base.js:406 checks `_forceCreator` BEFORE
11// `_forceNonCreator`, so the pin WINS: wj62 ends up with two creators, which is the loser-club
12// fork the flag exists to prevent.
13//
14// 2. The phone. favela-android is a joiner that must be able to invite the NEXT device (keet's model:
15// any device holding the identity can pair another). It is `joiner: true` so it is covered today —
16// but only by accident of that one flag, not by intent.
17//
18// The pin itself is correct and must stay: growing the roster can silently demote the incumbent, since
19// the election is `lowest-keyed member == self`. What must change is that it only ever pins a node that
20// is ALREADY the creator, and never contradicts an explicit role.
21//
22// Run: bare test/coowner-invite-noncreator.test.mjs
23import '../lib/bare-globals.js'
24import test from 'brittle'
25import fs from 'bare-fs'
26import b4a from 'b4a'
27import { attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from 'atproto-room/lib/membership.js'
28import { coownerInvite } from '../lib/coowner.js'
29
30const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
31const HOST_DEV = 'legal winner thank year wave sausage worth useful legal winner thank yellow'
32const DID = 'did:plc:coowner00000000000000000'
33const SIGNING = 'a'.repeat(64)
34const JOINER = 'd'.repeat(64)
35
36const tmpdir = () => { const d = '/tmp/hpp-noncreator-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
37const hex = (u8) => b4a.toString(b4a.from(u8), 'hex')
38const readJson = (p) => JSON.parse(fs.readFileSync(p, 'utf8'))
39
40async function hostConfig (dir, extra = {}) {
41 const dev = await deviceKeetKeyPair(HOST_DEV)
42 const proof = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: dev.publicKey })
43 const p = dir + '/config.json'
44 fs.writeFileSync(p, JSON.stringify({
45 storage: dir + '/data',
46 swarm: true,
47 dids: [{
48 did: DID,
49 handle: 'coowner.test',
50 deviceMnemonic: HOST_DEV,
51 deviceProof: hex(proof),
52 signingKey: SIGNING,
53 rootIdentityKey: hex(await rootIdentityPublicKey(ROOT)),
54 members: [hex(dev.publicKey)],
55 ...extra
56 }]
57 }, null, 2))
58 return p
59}
60
61test('CONTROL — a node that is NOT the creator does not become one by inviting', async (t) => {
62 // `resyncStore` is a runtime flag, so the durable marker of "this node must not be creator" in a
63 // config is an explicit forceCreator:false. It must be honoured, not overwritten.
64 const p = await hostConfig(tmpdir(), { forceCreator: false })
65 await coownerInvite({ did: DID, devicePublicKey: JOINER, configFile: p })
66 const entry = readJson(p).dids[0]
67 t.is(entry.forceCreator, false, 'an explicit forceCreator:false SURVIVES the invite — minting is not a claim to the genesis')
68 t.ok((Array.isArray(entry.members) ? entry.members : []).includes(JOINER), 'and the device was still admitted')
69})
70
71test('a JOINER can invite the next device without claiming the genesis', async (t) => {
72 const p = await hostConfig(tmpdir(), { joiner: true })
73 await coownerInvite({ did: DID, devicePublicKey: JOINER, configFile: p })
74 const entry = readJson(p).dids[0]
75 t.absent(entry.forceCreator, 'a joiner never pins itself creator')
76 t.is(entry.joiner, true, 'and stays a joiner')
77 t.ok((Array.isArray(entry.members) ? entry.members : []).includes(JOINER), 'the invite still worked — this is keet\'s model, any device can pair the next')
78})
79
80test('an unflagged incumbent STILL pins itself — the demote guard must not regress', async (t) => {
81 const p = await hostConfig(tmpdir())
82 await coownerInvite({ did: DID, devicePublicKey: JOINER, configFile: p })
83 const entry = readJson(p).dids[0]
84 t.is(entry.forceCreator, true, 'growing the roster can silently demote the incumbent, so it pins itself')
85})
test/coowner-join-didless.test.mjs +130 -0
……@@ -0,0 +1,130 @@
1// CONTROL: a device must be able to mint its key BEFORE it knows which DID it is joining.
2//
3// This is the whole reason the pairing screen asks for a DID. The device keypair is derived from a
4// mnemonic and NOTHING else — `deviceKeetKeyPair(mnemonic)` takes no DID — so requiring one is pure
5// bookkeeping: `coownerJoin` stores the mnemonic INSIDE the per-DID entry, so it needs the entry, so it
6// needs the DID.
7//
8// favela-web's browser join already does it the right way (bin/favela-web.js:962): generate the sub-key
9// locally, send ONLY the invite, and take the identity from what comes back. The bundle already carries
10// `did` (coowner.js:106) and coownerJoin already cross-checks it (`:140`), so nothing about the ceremony
11// needs the caller to know it in advance.
12//
13// ⛔ AND AN EXISTING PER-DID MNEMONIC MUST STILL WIN. The header at coowner.js:121 is explicit:
14// regenerating orphans an already-attested key and leaves the machine permanently unadmitted. So the
15// device-scoped key is a FALLBACK for devices that have none, never a replacement.
16//
17// Run: bare test/coowner-join-didless.test.mjs
18import '../lib/bare-globals.js'
19import test from 'brittle'
20import fs from 'bare-fs'
21import b4a from 'b4a'
22import IdentityKey from 'keet-identity-key'
23import { attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from 'atproto-room/lib/membership.js'
24import { coownerInvite, coownerJoin } from '../lib/coowner.js'
25
26const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
27const HOST_DEV = 'legal winner thank year wave sausage worth useful legal winner thank yellow'
28const DID = 'did:plc:coowner00000000000000000'
29const SIGNING = 'a'.repeat(64)
30
31const tmpdir = () => { const d = '/tmp/hpp-didless-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
32const hex = (u8) => b4a.toString(b4a.from(u8), 'hex')
33const readJson = (p) => JSON.parse(fs.readFileSync(p, 'utf8'))
34
35async function hostConfig (dir) {
36 const dev = await deviceKeetKeyPair(HOST_DEV)
37 const proof = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: dev.publicKey })
38 const p = dir + '/config.json'
39 fs.writeFileSync(p, JSON.stringify({
40 storage: dir + '/data',
41 swarm: true,
42 dids: [{
43 did: DID,
44 handle: 'coowner.test',
45 deviceMnemonic: HOST_DEV,
46 deviceProof: hex(proof),
47 signingKey: SIGNING,
48 rootIdentityKey: hex(await rootIdentityPublicKey(ROOT)),
49 members: [hex(dev.publicKey)]
50 }]
51 }, null, 2))
52 return p
53}
54
55const joinerConfig = (dir) => {
56 const p = dir + '/config.json'
57 fs.writeFileSync(p, JSON.stringify({ storage: dir + '/data', swarm: true, dids: [] }, null, 2))
58 return p
59}
60
61test('CONTROL — a device mints its key with NO did', async (t) => {
62 const p = joinerConfig(tmpdir())
63 const r = await coownerJoin({ configFile: p })
64 t.ok(/^[0-9a-f]{64}$/.test(r.devicePublicKey || ''), 'returns a device public key without being told an identity')
65 t.ok(readJson(p).deviceMnemonic, 'and stores the mnemonic at CONFIG ROOT — it belongs to the device, not to a DID')
66})
67
68test('the same device key comes back — never regenerated', async (t) => {
69 const p = joinerConfig(tmpdir())
70 const a = await coownerJoin({ configFile: p })
71 const b = await coownerJoin({ configFile: p })
72 t.is(a.devicePublicKey, b.devicePublicKey, 'idempotent — a regenerated key would orphan an attestation already issued for the old one')
73})
74
75test('a bundle with no did argument writes the entry the BUNDLE names', async (t) => {
76 const hostP = await hostConfig(tmpdir())
77 const joinP = joinerConfig(tmpdir())
78
79 const pre = await coownerJoin({ configFile: joinP }) // no did — the phone does not know it yet
80 const { bundle } = await coownerInvite({ did: DID, devicePublicKey: pre.devicePublicKey, configFile: hostP })
81 const joined = await coownerJoin({ bundle, configFile: joinP }) // still no did
82
83 t.is(joined.entry.did, DID, 'the DID came from the bundle, never from the caller')
84 const entry = readJson(joinP).dids.find((d) => d.did === DID)
85 t.ok(entry, 'the entry was written')
86 t.is(entry.signingKey, SIGNING, 'and carries the shared signing key — this is what makes it a NODE')
87 t.is(entry.joiner, true, 'joiner posture survived')
88 t.absent(entry.forceCreator, 'and it did not claim the genesis')
89
90 const kp = await deviceKeetKeyPair(entry.deviceMnemonic || readJson(joinP).deviceMnemonic)
91 t.is(hex(kp.publicKey), pre.devicePublicKey, 'the entry uses the SAME device key that was attested')
92})
93
94test('an existing PER-DID mnemonic is reused, not replaced by the device-scoped one', async (t) => {
95 const dir = tmpdir()
96 const p = dir + '/config.json'
97 const legacy = IdentityKey.generateMnemonic()
98 const legacyKey = hex((await deviceKeetKeyPair(legacy)).publicKey)
99 fs.writeFileSync(p, JSON.stringify({
100 storage: dir + '/data',
101 swarm: true,
102 deviceMnemonic: IdentityKey.generateMnemonic(), // a device-scoped key exists too
103 dids: [{ did: DID, deviceMnemonic: legacy }]
104 }, null, 2))
105
106 const r = await coownerJoin({ did: DID, configFile: p })
107 t.is(r.devicePublicKey, legacyKey, 'the per-DID mnemonic WINS — an already-attested key must never be orphaned')
108 t.is(readJson(p).dids[0].deviceMnemonic, legacy, 'and is left untouched on disk')
109})
110
111test('CONTROL — a device with NO config file at all can still join', async (t) => {
112 // The state every fresh install is in. The phone creates its hpp DIRECTORY at boot but no config
113 // file, so the first thing the pairing ceremony did was read a file that cannot exist yet and turn
114 // ENOENT into "cannot read config" — failing before the tunnel even opened. A joiner starting with
115 // nothing is the normal case, not a fault; a MALFORMED config is still a fault.
116 const dir = tmpdir()
117 const missing = dir + '/does-not-exist.json'
118 const r = await coownerJoin({ configFile: missing })
119 t.ok(/^[0-9a-f]{64}$/.test(r.devicePublicKey || ''), 'it minted a device key against a config that did not exist')
120 t.ok(fs.existsSync(missing), 'and wrote the config it needed')
121})
122
123test('a MALFORMED config still fails loudly', async (t) => {
124 const dir = tmpdir()
125 const bad = dir + '/bad.json'
126 fs.writeFileSync(bad, '{ this is not json')
127 let threw = null
128 try { await coownerJoin({ configFile: bad }) } catch (e) { threw = e }
129 t.ok(threw && /cannot read config/.test(threw.message), 'a corrupt config is NOT silently replaced: ' + (threw && threw.message))
130})
test/coowner-members-string.test.mjs +94 -0
……@@ -0,0 +1,94 @@
1// CONTROL: a comma-string roster must survive `coowner-invite`.
2//
3// wj62 in production stores its roster as a comma-separated STRING of two device pubkeys, not an array:
4//
5// "members": "71555d85…3583,81e1cbbf…5715"
6//
7// The BOOT path copes — `config.js normalizeMembers` accepts "a comma-string of hex pubkeys or an array".
8// The INVITE path does not: `coowner.js` reads the file with its own raw `JSON.parse` (it imports only
9// `configPath` from config.js), and `addMember` gates on `Array.isArray(entry.members)`. A string fails
10// that test, falls through to `[]`, reports `needsSelf`, and `coownerInvite` then rewrites the roster as
11// `[self, joiner]` — silently DROPPING every other device already in it.
12//
13// That is a roster truncation on a live DID, performed by the command whose whole job is to grow the
14// roster, and it does not error. Test 1 is the CONTROL — it reproduces the truncation before any fix, so
15// the fix is measured against a real failure rather than an assumption. Test 2 pins the array case that
16// already works, so the fix cannot regress it.
17//
18// Run: bare test/coowner-members-string.test.mjs
19import '../lib/bare-globals.js'
20import test from 'brittle'
21import fs from 'bare-fs'
22import b4a from 'b4a'
23import { attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from 'atproto-room/lib/membership.js'
24import { coownerInvite } from '../lib/coowner.js'
25
26const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
27const HOST_DEV = 'legal winner thank year wave sausage worth useful legal winner thank yellow'
28// A THIRD device that is already in the roster and has nothing to do with this invite. It must still be
29// there afterwards — this is the device wj62 would have lost.
30const OTHER_DEV = 'letter advice cage absurd amount doctor acoustic avoid letter advice cage above'
31const DID = 'did:plc:coowner00000000000000000'
32const SIGNING = 'a'.repeat(64)
33const JOINER = 'f'.repeat(64)
34
35const tmpdir = () => { const d = '/tmp/hpp-members-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
36const hex = (u8) => b4a.toString(b4a.from(u8), 'hex')
37const readJson = (p) => JSON.parse(fs.readFileSync(p, 'utf8'))
38
39// `members` is written EXACTLY as the caller gives it — string or array — to mirror what is on disk.
40async function hostConfig (dir, members) {
41 const dev = await deviceKeetKeyPair(HOST_DEV)
42 const other = await deviceKeetKeyPair(OTHER_DEV)
43 const proof = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: dev.publicKey })
44 const p = dir + '/config.json'
45 fs.writeFileSync(p, JSON.stringify({
46 storage: dir + '/data',
47 swarm: true,
48 dids: [{
49 did: DID,
50 handle: 'coowner.test',
51 pdsUrl: 'https://pds.test',
52 storeBackend: 'overlay',
53 deviceMnemonic: HOST_DEV,
54 deviceProof: hex(proof),
55 signingKey: SIGNING,
56 rootIdentityKey: hex(await rootIdentityPublicKey(ROOT)),
57 members: members(hex(dev.publicKey), hex(other.publicKey))
58 }]
59 }, null, 2))
60 return { path: p, selfHex: hex(dev.publicKey), otherHex: hex(other.publicKey) }
61}
62
63const rosterOf = (entry) => (Array.isArray(entry.members) ? entry.members : String(entry.members || '').split(','))
64 .map((m) => String(typeof m === 'object' && m ? m.key : m).trim().toLowerCase())
65 .filter(Boolean)
66
67test('CONTROL — a comma-string roster is not truncated by the invite', async (t) => {
68 const dir = tmpdir()
69 const { path, selfHex, otherHex } = await hostConfig(dir, (self, other) => self + ',' + other)
70
71 const r = await coownerInvite({ did: DID, devicePublicKey: JOINER, configFile: path })
72
73 const written = rosterOf(readJson(path).dids[0])
74 t.ok(written.includes(otherHex), 'the third device SURVIVES the invite — roster was ' + written.length +
75 ' entries, expected 3 (self, other, joiner); dropping ' + otherHex.slice(0, 16) + '… is the truncation')
76 t.ok(written.includes(selfHex), 'the inviting device is still in its own roster')
77 t.ok(written.includes(JOINER), 'the joiner was added')
78 t.is(written.length, 3, 'exactly three devices')
79
80 // The bundle is what the JOINER writes into its own config, so a truncation here propagates.
81 const bundled = rosterOf({ members: r.bundle.members })
82 t.ok(bundled.includes(otherHex), 'the bundle handed to the joiner also keeps the third device')
83})
84
85test('an ARRAY roster still works — the fix must not regress the shape that already worked', async (t) => {
86 const dir = tmpdir()
87 const { path, selfHex, otherHex } = await hostConfig(dir, (self, other) => [self, other])
88
89 await coownerInvite({ did: DID, devicePublicKey: JOINER, configFile: path })
90
91 const written = rosterOf(readJson(path).dids[0])
92 t.is(written.length, 3, 'three devices')
93 t.ok(written.includes(otherHex) && written.includes(selfHex) && written.includes(JOINER), 'all three present')
94})
test/coowner.test.mjs +13 -4
……@@ -65,7 +65,12 @@ test('the ceremony admits a joiner, and its proof passes the REAL admission gate
6565 const step1 = await coownerJoin({ did: DID, configFile: joinerPath })
6666 t.ok(/^[0-9a-f]{64}$/.test(step1.devicePublicKey), 'joiner produced a device public key')
6767 t.absent(step1.joined, 'and is not yet joined')
68 const secret = readJson(joinerPath).dids[0].deviceMnemonic
68 // The mnemonic is DEVICE-scoped now (config root), with a per-DID one still honoured when a legacy
69 // config has one — see coownerJoin. The property under test is unchanged: it is stored LOCALLY and
70 // never printed. Only its location moved, so the assertion reads the location it may live in rather
71 // than pinning one.
72 const joinCfg = readJson(joinerPath)
73 const secret = joinCfg.deviceMnemonic || (joinCfg.dids[0] && joinCfg.dids[0].deviceMnemonic)
6974 t.ok(secret && secret.split(' ').length >= 12, 'its mnemonic was stored locally')
7075 t.absent(JSON.stringify(step1).includes(secret), 'and the mnemonic is NOT in the printed output')
7176
……@@ -80,8 +85,12 @@ test('the ceremony admits a joiner, and its proof passes the REAL admission gate
8085 // 3. joiner: write the entry from the bundle
8186 const step3 = await coownerJoin({ did: DID, bundle: inv.bundle, configFile: joinerPath })
8287 t.ok(step3.joined, 'joiner joined')
83 const entry = readJson(joinerPath).dids[0]
84 t.is(entry.deviceMnemonic, secret, 'its mnemonic was REUSED, not regenerated (regenerating would orphan the attested key)')
88 const joined = readJson(joinerPath)
89 const entry = joined.dids[0]
90 // Same property, new home: the key the entry USES must be the one that was attested. Read it the way
91 // coownerJoin resolves it (per-DID first, else device-scoped) rather than assuming a location.
92 const entryMnemonic = entry.deviceMnemonic || joined.deviceMnemonic
93 t.is(entryMnemonic, secret, 'its mnemonic was REUSED, not regenerated (regenerating would orphan the attested key)')
8594 t.is(entry.signingKey, SIGNING, 'it holds the shared signing key')
8695 t.is(entry.storeBackend, 'overlay', 'and inherits the backend')
8796 t.absent(entry.indexer, 'it is a PLAIN WRITER — never an indexer (the N=2 quorum trap)')
……@@ -91,7 +100,7 @@ test('the ceremony admits a joiner, and its proof passes the REAL admission gate
91100
92101 // 4. THE POINT: the minted proof passes the admission crypto StateBaseV3 actually uses.
93102 const rootPub = await rootIdentityPublicKey(ROOT)
94 const kp = await deviceKeetKeyPair(entry.deviceMnemonic)
103 const kp = await deviceKeetKeyPair(entryMnemonic)
95104 const signData = b4a.from('localKey||baseKey admission bytes')
96105 const attested = await makeAttest(kp, b4a.from(entry.deviceProof, 'hex'))(signData)
97106 const verified = await makeVerifyAttest(rootPub)(attested, signData, kp.publicKey)