| … | … | @@ -67,6 +67,37 @@ if (sub === 'repair-pointers') { |
| 67 | 67 | // it by hand is four hex-string edits across two boxes with a shared secret in the middle, and it is what |
| 68 | 68 | // anyone adding a second machine needs. No root mnemonic involved: the invite is chain-attested by the |
| 69 | 69 | // 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. | |
| 75 | if (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 | } | |
| 70 | 101 | if (sub === 'coowner-invite' || sub === 'coowner-join') { |
| 71 | 102 | const { coownerCli } = await import('./lib/coowner.js') |
| 72 | 103 | try { |
| … | … | @@ -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. | |
| 30 | import { createPairInvite, joinPairInvite } from 'atproto-room/lib/pairing.js' | |
| 31 | import { 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. | |
| 40 | export 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. | |
| 90 | export 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 | } |
| … | … | @@ -35,8 +35,22 @@ import { configPath } from './config.js' |
| 35 | 35 | |
| 36 | 36 | const hex = (u8) => b4a.toString(b4a.from(u8), 'hex') |
| 37 | 37 | |
| 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". | |
| 38 | 47 | function 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 + ')') } | |
| 40 | 54 | } |
| 41 | 55 | |
| 42 | 56 | // 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 |
| 53 | 67 | // Add a device key to a DID entry's roster, idempotently. The roster is the set of device ids that MAY be |
| 54 | 68 | // admitted; admission itself is still proof-gated, so listing a key grants nothing on its own. |
| 55 | 69 | function 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()) | |
| 58 | 81 | // A roster that was ABSENT means "self only" (bootSwarmDevice defaults to [{key:self}]), so the first |
| 59 | 82 | // explicit roster must name the existing device too or it would silently drop it. |
| 60 | 83 | if (!norm.length) return { members: null, added: false, needsSelf: true } |
| … | … | @@ -96,7 +119,20 @@ export async function coownerInvite ({ did, devicePublicKey, configFile = null } |
| 96 | 119 | // |
| 97 | 120 | // Only if we are not ourselves a joiner: chains nest, so a joiner can legitimately vouch for the next |
| 98 | 121 | // 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 | |
| 100 | 136 | const backup = writeConfigFile(p, cfg) |
| 101 | 137 | |
| 102 | 138 | return { |
| … | … | @@ -120,20 +156,48 @@ export async function coownerInvite ({ did, devicePublicKey, configFile = null } |
| 120 | 156 | // mnemonic stays here and is never printed). With --bundle: write the full dids entry. |
| 121 | 157 | // Idempotent: an existing mnemonic is REUSED, never regenerated — regenerating would orphan an already |
| 122 | 158 | // attested key and leave the machine permanently unadmitted. |
| 123 | export 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). | |
| 171 | export async function coownerJoin ({ did = null, bundle = null, configFile = null } = {}) { | |
| 125 | 172 | const p = configFile || configPath() |
| 126 | 173 | const cfg = readConfigFile(p) |
| 127 | 174 | 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 | } | |
| 129 | 195 | |
| 130 | 196 | if (!entry) { |
| 131 | entry = { did, deviceMnemonic: IdentityKey.generateMnemonic() } | |
| 197 | entry = { did } | |
| 132 | 198 | cfg.dids.push(entry) |
| 133 | } else if (!entry.deviceMnemonic) { | |
| 134 | entry.deviceMnemonic = IdentityKey.generateMnemonic() | |
| 135 | 199 | } |
| 136 | const kp = await deviceKeetKeyPair(entry.deviceMnemonic) | |
| 200 | const kp = await deviceKeetKeyPair(mnemonic) | |
| 137 | 201 | const devicePublicKey = hex(kp.publicKey) |
| 138 | 202 | |
| 139 | 203 | if (bundle) { |
| … | … | @@ -566,7 +566,10 @@ export async function bootSwarmDevice ({ storage, deviceMnemonic, did, signingKe |
| 566 | 566 | // explicitly or the DID would never sync with anyone. Reported separately from _sideStore because |
| 567 | 567 | // StateBaseV3 owns this one and closes it itself (sb.close); double-closing a resync store is what |
| 568 | 568 | // _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 } | |
| 570 | 573 | } |
| 571 | 574 | |
| 572 | 575 | // ── RESYNC (fresh-corestore re-join) index ──────────────────────────────────────────────────────── |
| … | … | @@ -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 | |
| 23 | import '../lib/bare-globals.js' | |
| 24 | import test from 'brittle' | |
| 25 | import fs from 'bare-fs' | |
| 26 | import b4a from 'b4a' | |
| 27 | import { attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from 'atproto-room/lib/membership.js' | |
| 28 | import { coownerInvite } from '../lib/coowner.js' | |
| 29 | ||
| 30 | const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' | |
| 31 | const HOST_DEV = 'legal winner thank year wave sausage worth useful legal winner thank yellow' | |
| 32 | const DID = 'did:plc:coowner00000000000000000' | |
| 33 | const SIGNING = 'a'.repeat(64) | |
| 34 | const JOINER = 'd'.repeat(64) | |
| 35 | ||
| 36 | const tmpdir = () => { const d = '/tmp/hpp-noncreator-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d } | |
| 37 | const hex = (u8) => b4a.toString(b4a.from(u8), 'hex') | |
| 38 | const readJson = (p) => JSON.parse(fs.readFileSync(p, 'utf8')) | |
| 39 | ||
| 40 | async 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 | ||
| 61 | test('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 | ||
| 71 | test('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 | ||
| 80 | test('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 | }) |
| … | … | @@ -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 | |
| 18 | import '../lib/bare-globals.js' | |
| 19 | import test from 'brittle' | |
| 20 | import fs from 'bare-fs' | |
| 21 | import b4a from 'b4a' | |
| 22 | import IdentityKey from 'keet-identity-key' | |
| 23 | import { attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from 'atproto-room/lib/membership.js' | |
| 24 | import { coownerInvite, coownerJoin } from '../lib/coowner.js' | |
| 25 | ||
| 26 | const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' | |
| 27 | const HOST_DEV = 'legal winner thank year wave sausage worth useful legal winner thank yellow' | |
| 28 | const DID = 'did:plc:coowner00000000000000000' | |
| 29 | const SIGNING = 'a'.repeat(64) | |
| 30 | ||
| 31 | const tmpdir = () => { const d = '/tmp/hpp-didless-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d } | |
| 32 | const hex = (u8) => b4a.toString(b4a.from(u8), 'hex') | |
| 33 | const readJson = (p) => JSON.parse(fs.readFileSync(p, 'utf8')) | |
| 34 | ||
| 35 | async 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 | ||
| 55 | const 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 | ||
| 61 | test('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 | ||
| 68 | test('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 | ||
| 75 | test('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 | ||
| 94 | test('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 | ||
| 111 | test('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 | ||
| 123 | test('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 | }) |
| … | … | @@ -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 | |
| 19 | import '../lib/bare-globals.js' | |
| 20 | import test from 'brittle' | |
| 21 | import fs from 'bare-fs' | |
| 22 | import b4a from 'b4a' | |
| 23 | import { attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from 'atproto-room/lib/membership.js' | |
| 24 | import { coownerInvite } from '../lib/coowner.js' | |
| 25 | ||
| 26 | const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' | |
| 27 | const 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. | |
| 30 | const OTHER_DEV = 'letter advice cage absurd amount doctor acoustic avoid letter advice cage above' | |
| 31 | const DID = 'did:plc:coowner00000000000000000' | |
| 32 | const SIGNING = 'a'.repeat(64) | |
| 33 | const JOINER = 'f'.repeat(64) | |
| 34 | ||
| 35 | const tmpdir = () => { const d = '/tmp/hpp-members-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d } | |
| 36 | const hex = (u8) => b4a.toString(b4a.from(u8), 'hex') | |
| 37 | const 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. | |
| 40 | async 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 | ||
| 63 | const 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 | ||
| 67 | test('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 | ||
| 85 | test('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 | }) |
| … | … | @@ -65,7 +65,12 @@ test('the ceremony admits a joiner, and its proof passes the REAL admission gate |
| 65 | 65 | const step1 = await coownerJoin({ did: DID, configFile: joinerPath }) |
| 66 | 66 | t.ok(/^[0-9a-f]{64}$/.test(step1.devicePublicKey), 'joiner produced a device public key') |
| 67 | 67 | 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) | |
| 69 | 74 | t.ok(secret && secret.split(' ').length >= 12, 'its mnemonic was stored locally') |
| 70 | 75 | t.absent(JSON.stringify(step1).includes(secret), 'and the mnemonic is NOT in the printed output') |
| 71 | 76 | |
| … | … | @@ -80,8 +85,12 @@ test('the ceremony admits a joiner, and its proof passes the REAL admission gate |
| 80 | 85 | // 3. joiner: write the entry from the bundle |
| 81 | 86 | const step3 = await coownerJoin({ did: DID, bundle: inv.bundle, configFile: joinerPath }) |
| 82 | 87 | 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)') | |
| 85 | 94 | t.is(entry.signingKey, SIGNING, 'it holds the shared signing key') |
| 86 | 95 | t.is(entry.storeBackend, 'overlay', 'and inherits the backend') |
| 87 | 96 | 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 |
| 91 | 100 | |
| 92 | 101 | // 4. THE POINT: the minted proof passes the admission crypto StateBaseV3 actually uses. |
| 93 | 102 | const rootPub = await rootIdentityPublicKey(ROOT) |
| 94 | const kp = await deviceKeetKeyPair(entry.deviceMnemonic) | |
| 103 | const kp = await deviceKeetKeyPair(entryMnemonic) | |
| 95 | 104 | const signData = b4a.from('localKey||baseKey admission bytes') |
| 96 | 105 | const attested = await makeAttest(kp, b4a.from(entry.deviceProof, 'hex'))(signData) |
| 97 | 106 | const verified = await makeVerifyAttest(rootPub)(attested, signData, kp.publicKey) |