// `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)
}