// coowner — admit another machine as a co-owner of a DID, as a COMMAND rather than a hand-edit.
//
// Co-owning a DID needs four things to line up on two machines: the joiner has its own device keypair,
// an existing co-owner has ATTESTED that keypair to the DID's root identity, both configs list both
// device keys in the roster, and the joiner holds the shared #atproto signing key. Done by hand that is
// four edits across two boxes with a secret in the middle — every one a chance to typo a hex string into
// a room that then silently refuses admission. It is also the exact operation anyone adding a second
// machine will need, so it belongs in the tool.
//
// The ceremony (mirrors pgv3's `pgh device invite` / `pgh device join` shape):
//
// joiner$ hyper-proto-peer coowner-join <did> -> prints its device PUBLIC key
// existing$ hyper-proto-peer coowner-invite <did> --device <hex> -> prints a bundle, adds the key locally
// joiner$ hyper-proto-peer coowner-join <did> --bundle <file> -> writes its dids entry
// (restart both daemons)
//
// NO ROOT MNEMONIC IS INVOLVED. The invite is signed by the existing co-owner's own device key via
// keet's chained attestation (atproto-identity attestDeviceViaDevice): root -> existing -> joiner. The
// root mnemonic is deliberately never stored on a node, and this ceremony does not need it — only the
// root's PUBLIC key, which every node already carries as rootIdentityKey.
//
// THE BUNDLE CARRIES THE SHARED SIGNING KEY. That is inherent: a co-owner materializes the DID's signed
// commits, so it must hold the shared #atproto key (SwarmDevice's privateKey). Treat a bundle file as a
// secret — write it to a private path, move it over a trusted channel, delete it after joining.
//
// The joiner is admitted as a PLAIN WRITER, never an indexer (StateBaseV3's default). Two indexers is the
// N=2 quorum trap: the majority becomes 2, one loss freezes indexedLength, and the room forks. One
// indexer plus plain writers is the shape a 2-peer room wants.
import fs from 'bare-fs'
import b4a from 'b4a'
import IdentityKey from 'keet-identity-key'
import { deviceKeetKeyPair, attestDeviceViaDevice } from 'atproto-identity/lib/device-chain.js'
import { configPath } from './config.js'
const hex = (u8) => b4a.toString(b4a.from(u8), 'hex')
// ⛔ AN ABSENT CONFIG IS NOT AN ERROR — IT IS WHERE EVERY JOINER STARTS. A phone on first run has an
// hpp directory and no config file, so turning ENOENT into a hard failure meant the pairing ceremony
// died on its first line, before the tunnel even opened ("coowner: cannot read config at
// /data/user/0/to.favela.android/files/hpp/config.json (ENOENT…)").
//
// A MALFORMED config is still a hard failure, and deliberately so: silently replacing a corrupt file
// would discard a device's mnemonic and roster — the one thing that cannot be regenerated (a fresh key
// orphans an attestation and leaves the machine permanently unadmitted). Missing means "new"; unreadable
// means "stop".
// Exported for lib/mint.js, which writes the same `dids[]` file for a device that CREATES an identity
// rather than joining one. Shared rather than reimplemented on purpose: the ENOENT-is-not-an-error rule
// above and the backup-before-write rule below are both load-bearing, and a second copy would drift.
export function readConfigFile (p) {
let raw
try { raw = fs.readFileSync(p, 'utf8') } catch (e) {
if (e && (e.code === 'ENOENT' || /ENOENT/.test(e.message || ''))) return {}
throw new Error('coowner: cannot read config at ' + p + ' (' + e.message + ')')
}
try { return JSON.parse(raw) } catch (e) { throw new Error('coowner: cannot read config at ' + p + ' (' + e.message + ')') }
}
// Never write a config without leaving the previous one on disk. A mistyped roster locks a machine out
// of a room, and the old file is the fastest way back.
export function writeConfigFile (p, cfg) {
const backup = p + '.bak.' + Math.floor(Date.now() / 1000)
try { fs.copyFileSync(p, backup) } catch {}
fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + '\n')
return backup
}
const findEntry = (cfg, did) => (cfg.dids || []).find((d) => d.did === did) || null
// Add a device key to a DID entry's roster, idempotently. The roster is the set of device ids that MAY be
// admitted; admission itself is still proof-gated, so listing a key grants nothing on its own.
function addMember (entry, deviceHex) {
// ⛔ THE ROSTER IS NOT ALWAYS AN ARRAY. On disk it is an array of hex strings, an array of {key,role}
// objects, OR a comma-separated string — wj62 in production is the string form. `config.js
// normalizeMembers` accepts all three, but that is the BOOT path; this file reads the config with its
// own raw JSON.parse, so it must accept them too. An `Array.isArray` gate treated a populated string
// roster as EMPTY, which made `coownerInvite` rewrite it as [self, joiner] and silently DROP every
// other device — a truncation performed by the command whose job is to grow the roster.
// Control: test/coowner-members-string.test.mjs.
const raw = entry.members
const list = Array.isArray(raw) ? raw : (typeof raw === 'string' ? raw.split(',') : [])
const cur = list.map((m) => String((typeof m === 'object' && m ? m.key : m) || '').trim()).filter(Boolean)
const norm = cur.map((m) => m.toLowerCase())
// A roster that was ABSENT means "self only" (bootSwarmDevice defaults to [{key:self}]), so the first
// explicit roster must name the existing device too or it would silently drop it.
if (!norm.length) return { members: null, added: false, needsSelf: true }
if (norm.includes(String(deviceHex).toLowerCase())) return { members: cur, added: false }
cur.push(String(deviceHex).toLowerCase())
return { members: cur, added: true }
}
// ── coowner-invite (run on an EXISTING co-owner) ────────────────────────────────────────────────────
// Attest the joiner's device key against this DID's root (chained through OUR device), add it to our
// roster, and emit the bundle the joiner needs. Returns { bundle, backup, members }.
export async function coownerInvite ({ did, devicePublicKey, configFile = null } = {}) {
if (!did) throw new Error('coowner-invite: <did> required')
if (!/^[0-9a-fA-F]{64}$/.test(String(devicePublicKey || ''))) throw new Error('coowner-invite: --device <64-hex device public key> required')
const p = configFile || configPath()
const cfg = readConfigFile(p)
const entry = findEntry(cfg, did)
if (!entry) throw new Error('coowner-invite: this machine does not host ' + did + ' (no dids entry)')
if (!entry.deviceMnemonic || !entry.deviceProof) throw new Error('coowner-invite: our entry lacks deviceMnemonic/deviceProof — cannot vouch for another device')
if (!entry.signingKey || !entry.rootIdentityKey) throw new Error('coowner-invite: our entry lacks signingKey/rootIdentityKey — incomplete bundle')
const parent = await deviceKeetKeyPair(entry.deviceMnemonic)
const selfHex = hex(parent.publicKey)
const childProof = attestDeviceViaDevice({
parentKeyPair: parent,
parentProof: b4a.from(entry.deviceProof, 'hex'),
devicePublicKey: b4a.from(String(devicePublicKey), 'hex')
})
// Roster: both devices. An absent roster meant self-only, so seed it with US before adding THEM.
const r = addMember(entry, devicePublicKey)
const members = r.needsSelf ? [selfHex, String(devicePublicKey).toLowerCase()] : r.members
entry.members = members
// PIN OUR CREATOR ROLE. The creator election is `lowest-keyed member == self`, so growing the roster can
// silently demote us: if the joiner's key sorts below ours we would stop opening the roomSeed-derived
// genesis writer and ABANDON it, while the joiner claims it. That is the loser-club fork, reproduced in
// test/coowner-join-role.test.js. A room's creator is a property of the room, not of key ordering.
//
// Only if we are not ourselves a joiner: chains nest, so a joiner can legitimately vouch for the next
// machine, but it must never claim the genesis — that stays with whoever bootstrapped the room.
//
// ⛔ BUT INVITING IS NOT A CLAIM TO THE GENESIS. Keet is the reference here: `addDevice`
// (contract/members/index.js) verifies the attestation, addWriters the device, and gives the ADDER
// nothing — no role moves, because keet has no creator election at all (its room key IS the autobase
// key, so there is no roomSeed-derived genesis writer to claim or abandon). Our election is a local
// construct, so the pin must be too: it may only ever RE-STATE a role this node already holds.
//
// Two nodes are wrongly captured by a bare `!entry.joiner`:
// - one forced non-creator by `resyncStore` (device-node.js:178) — a RUNTIME flag, invisible here,
// which is tabertron's posture for wj62. state-base.js:406 checks _forceCreator BEFORE
// _forceNonCreator, so writing the pin would WIN and the DID would have two creators.
// - one carrying an explicit `forceCreator: false`, which is a decision on file.
// Both must be able to invite the next device without changing who holds the genesis.
if (!entry.joiner && entry.forceCreator !== false) entry.forceCreator = true
const backup = writeConfigFile(p, cfg)
return {
backup,
members,
bundle: {
did,
handle: entry.handle || null,
pdsUrl: entry.pdsUrl || null,
signingKey: entry.signingKey, // SECRET — the shared #atproto key a co-owner signs with
rootIdentityKey: entry.rootIdentityKey,
deviceProof: hex(childProof), // root -> us -> joiner
storeBackend: entry.storeBackend || null,
members
}
}
}
// ── coowner-join (run on the JOINER) ───────────────────────────────────────────────────────────────
// Without --bundle: ensure this machine has a device mnemonic for the DID and print its PUBLIC key (the
// mnemonic stays here and is never printed). With --bundle: write the full dids entry.
// Idempotent: an existing mnemonic is REUSED, never regenerated — regenerating would orphan an already
// attested key and leave the machine permanently unadmitted.
//
// ⛔ THE DID IS OPTIONAL. A device keypair is derived from a mnemonic and nothing else
// (`deviceKeetKeyPair(mnemonic)` takes no DID), so a device can mint its key BEFORE it knows which
// identity it is joining — which is what lets a single opaque invite be the whole handoff. favela-web's
// browser join already works this way (bin/favela-web.js:962): generate locally, send only the invite,
// take the identity from what comes back. Without this, the phone had to be TOLD a DID that the bundle
// then hands back anyway (and that `:140` below already cross-checks).
//
// The mnemonic therefore lives at CONFIG ROOT — it identifies the DEVICE, not one of its rooms — with a
// per-DID mnemonic still winning when present. That ordering is load-bearing: an existing per-DID key
// may already be attested, and replacing it would orphan the attestation and leave the machine
// permanently unadmitted (the same hazard the "never regenerate" rule above guards).
export async function coownerJoin ({ did = null, bundle = null, configFile = null } = {}) {
const p = configFile || configPath()
const cfg = readConfigFile(p)
if (!Array.isArray(cfg.dids)) cfg.dids = []
// A bundle names its own DID; the caller need not.
if (!did && bundle && bundle.did) did = bundle.did
if (!did && bundle) throw new Error('coowner-join: bundle carries no did')
let entry = did ? findEntry(cfg, did) : null
// Key selection, most-specific first: an already-attested per-DID key, else this device's own.
if (entry && entry.deviceMnemonic) {
// keep it — never regenerate
} else if (!cfg.deviceMnemonic) {
cfg.deviceMnemonic = IdentityKey.generateMnemonic()
}
const mnemonic = (entry && entry.deviceMnemonic) || cfg.deviceMnemonic
// With no DID at all we are in step 1 of the ceremony: mint/reuse the key, report it, write nothing
// else. The entry cannot exist yet — the bundle that names the DID has not arrived.
if (!did) {
const kp0 = await deviceKeetKeyPair(mnemonic)
const backup0 = writeConfigFile(p, cfg)
return { devicePublicKey: hex(kp0.publicKey), joined: false, backup: backup0, entry: null }
}
if (!entry) {
entry = { did }
cfg.dids.push(entry)
}
const kp = await deviceKeetKeyPair(mnemonic)
const devicePublicKey = hex(kp.publicKey)
if (bundle) {
if (bundle.did && bundle.did !== did) throw new Error('coowner-join: bundle is for ' + bundle.did + ', not ' + did)
if (!bundle.deviceProof || !bundle.signingKey || !bundle.rootIdentityKey) throw new Error('coowner-join: bundle must carry deviceProof, signingKey and rootIdentityKey')
entry.deviceProof = bundle.deviceProof
entry.signingKey = bundle.signingKey
entry.rootIdentityKey = bundle.rootIdentityKey
if (bundle.handle) entry.handle = bundle.handle
if (bundle.pdsUrl) entry.pdsUrl = bundle.pdsUrl
if (bundle.storeBackend) entry.storeBackend = bundle.storeBackend
if (Array.isArray(bundle.members) && bundle.members.length) {
const set = bundle.members.map((m) => String(typeof m === 'object' && m ? m.key : m).toLowerCase())
if (!set.includes(devicePublicKey)) set.push(devicePublicKey)
entry.members = set
}
// JOINER POSTURE (load-bearing). Without this the joiner elects itself creator whenever its key sorts
// below the incumbent's, opens the roomSeed-derived genesis writer empty-and-writable in its fresh
// store, and the boot-write block authors a rival genesis — the loser-club fork. `joiner` makes it
// forceNonCreator AND converge-only at boot (no migratePointers/restore/adopt/fresh-account), so it
// takes the existing line by replication and the forward-only sign() picks up at the tip.
// Proven two-node in test/coowner-join-role.test.js, control included.
entry.joiner = true
// A joiner must never also be pinned as creator — exactly one node holds the genesis.
delete entry.forceCreator
// Deliberately NOT set: `indexer`. The joiner is a plain writer — see the header (N=2 quorum trap).
}
const backup = writeConfigFile(p, cfg)
return { devicePublicKey, joined: !!bundle, backup, entry: { did, handle: entry.handle || null, storeBackend: entry.storeBackend || null, members: entry.members || null } }
}
// CLI arg helpers shared by index.js's subcommand dispatch.
export function coownerCli (sub, args) {
const did = args.find((a) => a.startsWith('did:'))
const flag = (name) => { const i = args.indexOf(name); return i < 0 ? null : args[i + 1] }
if (sub === 'coowner-invite') return coownerInvite({ did, devicePublicKey: flag('--device'), configFile: flag('--config') })
const bundleFile = flag('--bundle')
let bundle = null
if (bundleFile) bundle = JSON.parse(fs.readFileSync(bundleFile, 'utf8'))
return coownerJoin({ did, bundle, configFile: flag('--config') })
}