OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer / lib / coowner.js

246 lines · javascript ref:
// 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') })
}