OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer / lib / rotation-cli.js

296 lines · javascript ref:
// `hyper-proto-peer rotation` — the operator surface for PLC rotation authority.
//
// WHY A SUBCOMMAND AND NOT A SCRIPT. These first shipped as scripts/rotation-*.mjs, which meant
// "ssh to the box, find the install tree, pick a node binary that is new enough, run a loose file" —
// the exact friction `diag` exists to have removed. A capability that is awkward to invoke is one
// nobody invokes, and this one has a six-week-late failure behind it (hyper-social #47).
//
//   hyper-proto-peer rotation                          can we still change each hosted DID?
//   hyper-proto-peer rotation gen                      mint an offline recovery key (prints ONCE)
//   hyper-proto-peer rotation add --did D --key K      register K as an additional rotation key
//                                                      (DRY RUN unless --publish)
//
// `rotation` (the check) is READ-ONLY and offline-first. `add --publish` is the only writing path in
// this file, and it appends PERMANENTLY to a global public log.
import fs from 'bare-fs'
import os from 'bare-os'
import IdentityKey from 'keet-identity-key'
import {
  deriveAtprotoKey, keyFromPrivate, buildUpdateOp, signOp, ATPROTO_RECOVERY_PATH, ATPROTO_SIGNING_PATH
} from 'atproto-identity'
import { PlcDirectory, checkUpdateOp } from 'atproto-identity/lib/plc-directory.js'
import { loadConfig } from './config.js'
import { collectRotationHeld, localRotationCandidates } from './rotation-guard.js'

const flag = (argv, n, d = null) => { const i = argv.indexOf('--' + n); return i !== -1 && i + 1 < argv.length ? argv[i + 1] : d }
const has = (argv, n) => argv.includes('--' + n)

async function ensureFetch () {
  if (typeof globalThis.fetch !== 'function') await import('bare-fetch/global')
}

// ── the check ────────────────────────────────────────────────────────────────────────────────────
async function cmdStatus (argv) {
  const cfg = loadConfig()
  const json = has(argv, 'json')
  const held = await collectRotationHeld({ cfg, storage: cfg.storage })
  const rows = []
  for (const d of (cfg.dids || [])) rows.push({ did: d.did, handle: d.handle, ...(held.get(d.did) || {}) })

  if (json) { console.log(JSON.stringify({ checkedAt: new Date().toISOString(), dids: rows }, null, 2)); }
  else {
    console.log('rotation — can we still CHANGE each hosted DID?\n')
    for (const r of rows) {
      const mark = r.held === true ? '✅' : (r.held === null ? '❔' : '🚨')
      console.log(`${mark} ${r.handle || r.did}\n   did: ${r.did}`)
      if (r.held === true) {
        console.log(`   held by: ${r.heldBy}`)
        if (r.singleKeyRisk) {
          console.log('   ⚠  only ONE rotation key exists. Lose this secret and the DID is frozen forever.')
          console.log('      hyper-proto-peer rotation gen   →  then  rotation add --did … --key …')
        }
      } else if (r.held === null) {
        console.log(`   could not determine — ${r.reason}`)
      } else {
        console.log(`   🚨 no local secret derives ANY of this DID's ${r.rotationKeyCount} rotation key(s).`)
        console.log('      This DID can never be changed again: no handle change, no PDS move, no key')
        console.log('      rotation after a leak, no tombstone. Serving still works, which is why it hides.')
        console.log('      If a copy of the root exists ANYWHERE (another machine, removable media,')
        console.log('      paper) recover it NOW — this is not repairable later.')
      }
      console.log('')
    }
    const bad = rows.filter((r) => r.held === false).length
    const unk = rows.filter((r) => r.held === null).length
    console.log(`${rows.length} hosted DID(s): ${rows.length - bad - unk} OK, ${bad} unrecoverable, ${unk} unknown`)
  }
  return rows.some((r) => r.held === false) ? 1 : (rows.some((r) => r.held === null) ? 2 : 0)
}

// ── mint an offline recovery key ─────────────────────────────────────────────────────────────────
// The phrase leaves this process ONLY via your terminal. Nothing is written to disk, because a backup
// this daemon could write is a backup on the same disk as the thing it is backing up — which is how
// wj62's only copy came to be its only copy.
function cmdGen () {
  const mnemonic = IdentityKey.generateMnemonic()
  const key = deriveAtprotoKey(mnemonic, ATPROTO_RECOVERY_PATH)
  const w = mnemonic.split(' ')
  console.log('')
  console.log('  NEW ROTATION KEY — write these words down NOW, on paper, off this machine.')
  console.log('  Shown once. Nothing is saved.\n')
  console.log('  ' + w.slice(0, 12).join(' '))
  console.log('  ' + w.slice(12).join(' '))
  console.log('\n  public did:key (safe to share — this is what you register):')
  console.log('  ' + key.didKey)
  console.log('\n  derivation: BIP39 → ' + ATPROTO_RECOVERY_PATH + '  (secp256k1)')
  console.log('\n  VERIFY BEFORE REGISTERING: re-enter the phrase in a fresh shell and confirm it')
  console.log('  reproduces the same did:key. A key you cannot reproduce is not a backup, and')
  console.log('  registering one only moves the single point of failure.\n')
  return 0
}

// ── register an additional rotation key ──────────────────────────────────────────────────────────
async function cmdAdd (argv) {
  const did = flag(argv, 'did')
  const addKey = flag(argv, 'key')
  const dropKey = flag(argv, 'replace')
  const publish = has(argv, 'publish')
  if (!did || !addKey) {
    console.error('usage: hyper-proto-peer rotation add --did <did:plc:…> --key <did:key:…> [--replace <did:key:…>] [--publish]')
    return 2
  }
  if (dropKey && !/^did:key:z/.test(dropKey)) {
    console.error('refusing: --replace must be a PUBLIC did:key')
    return 2
  }
  if (!/^did:key:z/.test(addKey)) {
    console.error('refusing: --key must be a PUBLIC did:key (never a mnemonic or private key)')
    return 2
  }
  await ensureFetch()
  const cfg = loadConfig()
  const plc = new PlcDirectory({})
  const head = await plc.getHead(did)
  const current = head.rotationKeys || []

  console.log(`did:              ${did}`)
  console.log(`current rotation: ${current.length} key(s)`)
  current.forEach((k, i) => console.log(`  [${i}] ${k}`))
  if (current.includes(addKey)) { console.log(`\n${addKey}\nis ALREADY a rotation key on this DID — nothing to do.`); return 0 }

  const { owned } = localRotationCandidates(cfg)
  const signer = current.map((k, i) => ({ k, i, by: owned.get(k) })).find((x) => x.by)
  if (!signer) {
    console.error('\n🚨 no local secret derives ANY current rotation key for this DID.')
    console.error('   Nothing can add a key here — this DID is already frozen. (This is wj62.)')
    return 1
  }
  // Re-derive the PRIVATE half of the signer we just identified by its public key.
  const priv = privateFor(cfg, signer.k)
  if (!priv) { console.error('\ninternal: located a signer public key but could not re-derive its private half'); return 1 }
  console.log(`\nsigning with:     [${signer.i}] ${signer.by}`)

  // APPEND, never prepend. Lower index = higher authority (did-plc §4: a recovery fork must be signed by
  // a STRICTLY LOWER index than the op it overrides). Ranking a fresh offline key first would make it
  // supreme — nothing could fork against it, so a photograph of that paper would end the identity with no
  // recourse. Keeping the incumbent at [0] preserves its power to fork back if the backup is stolen,
  // while the backup at [1] still fixes single-key LOSS. Both failure modes covered, neither traded.
  let rotationDidKeys = [...current, addKey]
  if (dropKey) {
    // REPLACE. A PLC append cannot be undone, so a key that turns out to be exposed can only be
    // superseded — publish a new key set without it. That is possible only while a DIFFERENT current
    // key can sign the op, which is exactly what appending rather than prepending preserved.
    if (!current.includes(dropKey)) {
      console.error(`\nrefusing: ${dropKey}\nis not currently a rotation key on this DID — nothing to replace.`)
      return 2
    }
    if (dropKey === signer.k) {
      // Valid per the protocol, but it signs away the authority being used in the same breath. Require
      // signing with a key that survives the op, so a mistake leaves something to recover with.
      console.error(`\nrefusing: that is the key this node would SIGN with ([${signer.i}]).`)
      console.error('   Removing it in the same op signs away the authority you are using. Run this from')
      console.error('   a machine holding a DIFFERENT rotation key.')
      return 2
    }
    rotationDidKeys = [...current.filter((k) => k !== dropKey), addKey]
    if (!rotationDidKeys.length) { console.error('\nrefusing: that would leave the DID with NO rotation keys — permanently frozen.'); return 2 }
    console.log(`removing:         ${dropKey}`)
  }
  const unsigned = buildUpdateOp({
    prevSignedOp: head,
    rotationDidKeys,
    verificationMethods: head.verificationMethods,
    alsoKnownAs: head.alsoKnownAs,
    services: head.services
  })
  const signedOp = { ...unsigned, sig: signOp(unsigned, priv) }
  const chk = checkUpdateOp(signedOp, head)
  console.log(`\nlocal validation: ${chk.ok ? 'PASS' : 'FAIL — ' + chk.error}`)
  console.log('\nresulting rotationKeys:')
  rotationDidKeys.forEach((k, i) => console.log(`  [${i}] ${k}${k === addKey ? '   ← NEW' : ''}`))
  if (dropKey) console.log(`  (removed: ${dropKey})`)
  if (!chk.ok) return 1

  if (!publish) {
    console.log('\nDRY RUN — nothing submitted. Re-run with --publish to append this to the global')
    console.log('PLC op-log. That append is PERMANENT: no edit, no delete, no undo. Confirm the new')
    console.log('key is written down and reproducible FIRST.')
    return 0
  }
  console.log('\nsubmitting to plc.directory (irreversible) …')
  const after = await plc.submitUpdate({ did, signedOp })
  console.log('submitted. live rotationKeys now:')
  ;(after.rotationKeys || []).forEach((k, i) => console.log(`  [${i}] ${k}`))
  return 0
}

// ── verify a written-down phrase reproduces a key ────────────────────────────────────────────────
//
// The step that matters most had no verb: `gen` prints a phrase and a did:key, and the operator is told
// to confirm the phrase reproduces it — with no tool to do so. A one-liner was the interface, and it
// broke on pasted input.
//
// Reads a LINE, not raw characters. A 24-word phrase is pasted, so it arrives as a chunk; the
// char-by-char raw-mode pattern used elsewhere in this stack tests `char === '\r'` and never matches a
// chunk that merely ENDS with one. That is the broken-line-endings bug.
//
// Normalised the way keet normalises (lowercase + whitespace-collapse), so a paste that wrapped, used
// CRLF, or picked up double spaces still verifies. The phrase is never echoed back, never logged, never
// written, and never passed as an argv (which would land in shell history).
function readPhrase (prompt) {
  process.stdout.write(prompt)
  return new Promise((resolve) => {
    let buf = ''
    const onData = (d) => {
      buf += d.toString()
      if (!/[\r\n]/.test(buf)) return // keep reading until the line actually terminates
      try { process.stdin.removeListener('data', onData) } catch {}
      try { process.stdin.pause() } catch {}
      resolve(buf)
    }
    process.stdin.on('data', onData)
    try { process.stdin.resume() } catch {}
  })
}

const normalizePhrase = (s) => String(s).replace(/\s+/g, ' ').trim().toLowerCase()

async function cmdVerify (argv) {
  const want = flag(argv, 'key')
  if (!want || !/^did:key:z/.test(want)) {
    console.error('usage: hyper-proto-peer rotation verify --key <did:key:...>')
    console.error('  then paste the recovery phrase when prompted.')
    return 2
  }
  const phrase = normalizePhrase(await readPhrase('paste the recovery phrase (not echoed, not saved): '))
  const words = phrase.split(' ').filter(Boolean)
  console.log('')
  if (words.length !== 12 && words.length !== 24) {
    console.log(`  ⚠  read ${words.length} words — a BIP39 phrase is 12 or 24. Check the paste.`)
  } else {
    console.log(`  read ${words.length} words`)
  }

  // Try BOTH derivation paths: `gen` mints at the recovery path, but a root minted by the older
  // ceremony lives at the signing path (that is how wj62's key was found). Reporting WHICH path
  // matched is the difference between "this is your key" and a confusing false negative.
  let matched = null
  for (const [name, path] of [['recovery-path', ATPROTO_RECOVERY_PATH], ['signing-path', ATPROTO_SIGNING_PATH]]) {
    try { if (deriveAtprotoKey(phrase, path).didKey === want) matched = name } catch { /* unusable phrase */ }
  }

  if (matched) {
    console.log(`  ✅ MATCH — this phrase reproduces ${want}`)
    console.log(`     via ${matched}`)
    console.log('     Safe to register. Store the paper somewhere the disk is not.')
    return 0
  }
  console.log(`  🚨 NO MATCH — this phrase does NOT reproduce ${want}`)
  console.log('     Do NOT register that key. A key you cannot reproduce is not a backup — registering')
  console.log('     it only moves the single point of failure. Re-run `rotation gen` and start over.')
  return 1
}

// Find the private key whose public did:key is `want`, from the same local material the check uses.
function privateFor (cfg, want) {
  const tryKey = (k) => (k && k.didKey === want ? k.privateKey : null)
  const fromMnemonic = (mn) => {
    for (const p of [ATPROTO_SIGNING_PATH, ATPROTO_RECOVERY_PATH]) {
      try { const hit = tryKey(deriveAtprotoKey(mn, p)); if (hit) return hit } catch {}
    }
    return null
  }
  const home = os.homedir()
  const read = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return null } }
  const vault = read(home + '/.hyperident/identity')
  if (vault && vault.mnemonic && !vault.encrypted) { const h = fromMnemonic(vault.mnemonic); if (h) return h }
  const lc = read(home + '/.config/loser-club/secrets.json')
  if (lc && lc.rootMnemonic) { const h = fromMnemonic(lc.rootMnemonic); if (h) return h }
  for (const d of (cfg.dids || [])) {
    if (d.deviceMnemonic) { const h = fromMnemonic(d.deviceMnemonic); if (h) return h }
    if (d.signingKey && /^[0-9a-f]{64}$/i.test(d.signingKey)) {
      try { const hit = tryKey(keyFromPrivate(hexBytes(d.signingKey))); if (hit) return hit } catch {}
    }
  }
  return null
}

function hexBytes (hex) {
  const out = new Uint8Array(hex.length / 2)
  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16)
  return out
}

export async function rotationCommand (argv = []) {
  const mode = argv[0] && !argv[0].startsWith('-') ? argv[0] : null
  if (mode === 'gen') return cmdGen()
  if (mode === 'verify') return cmdVerify(argv.slice(1))
  if (mode === 'add') return cmdAdd(argv.slice(1))
  if (mode && mode !== 'status') {
    console.error(`unknown: rotation ${mode}`)
    console.error('usage: hyper-proto-peer rotation [--json] | rotation gen | rotation verify --key <did:key> | rotation add --did <did> --key <did:key> [--replace <did:key>] [--publish]')
    return 2
  }
  return cmdStatus(argv)
}