OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer / lib / adopt.js

107 lines · javascript ref:
// adopt — admit ANOTHER DEVICE into an existing DID over a blind-pairing tunnel instead of a file.
//
// Lives in hpp (not in a client) because ANY node holding the DID must be able to mint an invite for the
// next device — desktop or phone, symmetrically. That is keet's model: `addDevice`
// (contract/members/index.js) is available to any active member and the adder gains nothing by
// performing it. While this lived in the phone's tree, only the phone could mint.
//
// ⛔ THIS ADDS A TRANSPORT, NOT A CEREMONY. hyper-proto-peer's `coowner-invite` / `coowner-join` already
// do the whole cryptographic job, and do it correctly:
//   - chained attestation root -> existing -> joiner, with NO ROOT MNEMONIC anywhere (a node deliberately
//     never stores one; only the root's PUBLIC key, which every node already carries)
//   - the inviter pins `forceCreator` on itself, so growing the roster cannot silently demote it
//   - the joiner is written with `joiner: true` and WITHOUT `indexer` — a plain writer
// Those two lines are the loser-club fork and the N=2 quorum trap, already solved. Re-implementing any of
// it here would be writing a second, worse copy. This module only replaces the hand-off.
//
// WHAT WAS ACTUALLY MISSING. The existing ceremony moves the bundle as a FILE between two machines:
//     joiner$   hyper-proto-peer coowner-join <did>                  -> prints its device PUBLIC key
//     existing$ hyper-proto-peer coowner-invite <did> --device <hex> -> writes a bundle file
//     joiner$   hyper-proto-peer coowner-join <did> --bundle <file>
// That is fine between two servers you have shells on. It is useless for a phone: there is no shell, no
// scp, and the bundle CARRIES THE SHARED SIGNING KEY — so "move this secret by hand" is the worst
// possible instruction to give a user.
//
// The blind-pairing tunnel is strictly better than the file it replaces: one-time, owner-minted,
// end-to-end encrypted over the DHT, and the secret never touches a filesystem either side.
//
// ⚠ `coownerInvite` MUTATES THE LOCAL CONFIG (adds the member, pins forceCreator) as well as returning
// the bundle. So it must be called only AFTER the human approves — never speculatively on connection.
import { createPairInvite, joinPairInvite } from 'atproto-room/lib/pairing.js'
import { coownerInvite, coownerJoin } from './coowner.js'

// ── EXISTING DEVICE (desktop, or any already-attested device — NOT necessarily the root) ─────────────
//
// approve({ devicePublicKey, did }) -> boolean. Called when a phone reaches the tunnel and BEFORE any
// config is touched. Return false and the pairing is refused with the config untouched.
//
// Returns { inviteHex, waitForPeer, close } — same shape as createPairInvite, so a CLI or a UI can show
// the invite as text or a QR and then await the result.
export async function mintAdoptInvite ({ did, approve, configFile = null, swarm = null } = {}) {
  if (!did) throw new Error('mintAdoptInvite requires { did }')
  if (typeof approve !== 'function') throw new Error('mintAdoptInvite requires approve({ devicePublicKey, did }) -> boolean')

  let result = null

  const source = await createPairInvite({
    swarm,
    // The candidate's hello carries its device PUBLIC key. Same field the browser flow uses for a
    // sub-key — the tunnel does not care what the bytes mean, only this handler does.
    onSubkey: async (devicePublicKey) => {
      const ok = await approve({ devicePublicKey, did })
      if (!ok) {
        // Refusing must leave NOTHING behind: no roster entry, no forceCreator, no backup file.
        // Returning an error object rather than throwing keeps the tunnel's own teardown clean.
        return { error: 'refused by the device owner' }
      }
      // Only now does anything mutate. coownerInvite adds the member, pins our creator role, writes the
      // config (leaving a .bak), and hands back the bundle.
      const r = await coownerInvite({ did, devicePublicKey, configFile })
      result = { members: r.members, backup: r.backup, devicePublicKey }
      return r.bundle
    }
  })

  return {
    inviteHex: source.inviteHex,
    async waitForPeer () {
      await source.waitForPeer()
      return result // null iff refused
    },
    close: () => source.close()
  }
}

// ── PHONE ────────────────────────────────────────────────────────────────────────────────────────────
//
// Two calls, because the device key must exist before there is anything to attest:
//   1. coownerJoin({ did })          — mints a device mnemonic if absent, returns its PUBLIC key.
//                                      The mnemonic is written to config and never leaves the phone.
//   2. joinPairInvite(...)           — sends that public key up the tunnel, gets the bundle back.
//   3. coownerJoin({ did, bundle })  — writes the entry, sets joiner:true, leaves indexer unset.
//
// Step 3 is what makes the phone a NODE rather than a client: the bundle carries the shared #atproto
// signing key, so from here the phone materialises and signs its own commits.
// `did` is OPTIONAL and normally omitted — the invite alone is the whole handoff. The device mints its
// key knowing nothing (a keypair is derived from a mnemonic, not from an identity), sends the public
// half up the tunnel, and learns the DID from the bundle that comes back — which coownerJoin then
// cross-checks. Same shape as favela-web's browser join (bin/favela-web.js:962). Pass `did` only to
// ASSERT which identity you expect; a mismatch then throws instead of joining the wrong one.
export async function joinAdoptInvite ({ did = null, inviteHex, configFile = null, swarm = null, onStep = () => {} } = {}) {
  if (!inviteHex) throw new Error('joinAdoptInvite requires { inviteHex }')

  const pre = await coownerJoin({ did, configFile })
  onStep('device key ready', { devicePublicKey: pre.devicePublicKey })

  const bundle = await joinPairInvite(inviteHex, { subKey: pre.devicePublicKey, swarm })
  if (!bundle || bundle.error) throw new Error('adopt refused: ' + ((bundle && bundle.error) || 'no bundle'))
  onStep('bundle received', { did: bundle.did })

  const joined = await coownerJoin({ did: did || bundle.did, bundle, configFile })
  onStep('joined', { members: joined.entry.members })

  // The caller restarts the node; boot picks the entry up. Deliberately not restarting here — a pairing
  // function that also manages process lifecycle is two things.
  return { devicePublicKey: pre.devicePublicKey, entry: joined.entry, backup: joined.backup }
}