OverviewFilesCommitsIssuesDocsReleasesPeers

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

144 lines · javascript ref:
// rotation-guard — can this node still CHANGE the identities it hosts?
//
// WHY THIS EXISTS (hyper-social #47). wj62's PLC rotation key was derived once, on 2026-07-02, from
// the mnemonic in ~/.hyperident/identity. Nothing in the running stack ever read that file again —
// pgh and pear-git-v3 resolve identity from ~/.pgh/linked-device.json instead — so when the vault
// went missing before 2026-07-28, NOTHING NOTICED. The DID kept serving perfectly, because serving
// needs only the SIGNING key, which we still hold. What was lost was the ability to ever change the
// identity: no handle change, no PDS move, no key rotation after a leak, no tombstone. Permanently.
//
// Every other field in `diag` answers "is this node advancing?". None of them can answer "could we
// still fix this DID if we had to?" — and that question has exactly one wrong answer, discovered six
// weeks late. This is the only check in the stack that would have fired while it was still fixable.
//
// READ-ONLY, and never handles a secret it does not have to: it derives PUBLIC did:keys from local
// secrets and compares strings. Nothing is printed, logged, written or transmitted.
import fs from 'bare-fs'
import os from 'bare-os'
import path from 'bare-path'
import { deriveAtprotoKey, keyFromPrivate, ATPROTO_SIGNING_PATH, ATPROTO_RECOVERY_PATH } from 'atproto-identity'

const HOME = os.homedir()
const PATHS = [['signing-path', ATPROTO_SIGNING_PATH], ['recovery-path', ATPROTO_RECOVERY_PATH]]

function readJson (p) {
  try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return null }
}

// Every PUBLIC did:key we can still produce from a secret on this box, mapped to where it came from.
//
// Deliberately broad. The point is to prove SOME local material still controls the DID, not to assert
// which file ought to. A key found somewhere unexpected is still a key we hold — and finding it
// somewhere unexpected is itself worth reporting.
export function localRotationCandidates (cfg = {}) {
  const owned = new Map() // didKey -> "source [how]"
  const add = (k, label) => { if (k && k.didKey && !owned.has(k.didKey)) owned.set(k.didKey, label) }
  const fromMnemonic = (mn, label) => {
    for (const [how, p] of PATHS) {
      try { add(deriveAtprotoKey(mn, p), `${label} [${how}]`) } catch { /* an unusable candidate proves nothing */ }
    }
  }

  const vault = readJson(path.join(HOME, '.hyperident', 'identity'))
  if (vault && vault.mnemonic && !vault.encrypted) fromMnemonic(vault.mnemonic, '~/.hyperident/identity')
  // An ENCRYPTED vault is not "absent" — we simply cannot answer without the passphrase, and saying
  // "no key" there would be the same lie the 5A gate shipped (a check that could not run is not a NO).
  const vaultLocked = !!(vault && vault.encrypted)

  const lc = readJson(path.join(HOME, '.config', 'loser-club', 'secrets.json'))
  if (lc && lc.rootMnemonic) fromMnemonic(lc.rootMnemonic, '~/.config/loser-club/secrets.json')

  for (const d of (cfg.dids || [])) {
    const tag = (d.did || '').replace('did:plc:', '').slice(0, 8)
    if (d.deviceMnemonic) fromMnemonic(d.deviceMnemonic, `hpp config ${tag}.deviceMnemonic`)
    if (d.signingKey && /^[0-9a-f]{64}$/i.test(d.signingKey)) {
      try { add(keyFromPrivate(b(d.signingKey)), `hpp config ${tag}.signingKey [raw]`) } catch {}
    }
  }
  return { owned, vaultLocked }
}

function b (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
}

// The DID's CURRENT rotationKeys, from plc.directory under a HARD deadline, with the persisted
// genesis as an offline fallback.
//
// The deadline uses AbortController, NOT a `timeout` option: bare-fetch destructures only
// { body, method, headers, signal, agent } and silently DROPS `timeout`, so a timeout option here
// would be a phantom deadline that reads as handled in review and bounds nothing.
export async function rotationKeysFor (did, { storage = null, timeoutMs = 5000, plcUrl = 'https://plc.directory', fetchImpl = null } = {}) {
  // LIVE FIRST. This used to prefer the persisted genesis so the check worked at boot with no network —
  // but a genesis op is a snapshot of the DID at MINT time, and rotationKeys are exactly the field that
  // changes afterwards. So after a successful `rotation add` the check kept reporting the original single
  // key and told the operator their DID was still one secret from frozen. A diagnostic that contradicts
  // the live document right after you fix something is worse than no diagnostic (hyper-social #47).
  //
  // The cached genesis remains the OFFLINE fallback, but it is labelled so a stale answer can never be
  // mistaken for an authoritative one.
  let f = fetchImpl
  if (!f && typeof globalThis.fetch !== 'function') {
    try { await import('bare-fetch/global') } catch { /* fall through to the local fallback */ }
  }
  if (!f) f = typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : null

  if (f) {
    const ac = new AbortController()
    const timer = setTimeout(() => ac.abort(), timeoutMs)
    try {
      const res = await f(`${plcUrl}/${did}/data`, { signal: ac.signal })
      if (res.ok) {
        const doc = await res.json()
        if (Array.isArray(doc.rotationKeys)) return { keys: doc.rotationKeys, source: 'plc.directory (live)' }
      }
    } catch { /* offline — fall through */ } finally { clearTimeout(timer) }
  }

  for (const p of [
    path.join(HOME, '.config', 'atproto-pds', 'pending-genesis.json'),
    storage ? path.join(storage, 'peer-identity.json') : null
  ].filter(Boolean)) {
    const j = readJson(p)
    if (j && j.did === did) {
      const rk = (j.signedOp && j.signedOp.rotationKeys) || j.rotationKeys
      if (Array.isArray(rk) && rk.length) {
        return { keys: rk, source: p.replace(HOME, '~') + ' ⚠ GENESIS SNAPSHOT — may be stale, plc.directory was unreachable', stale: true }
      }
    }
  }
  return { keys: null, source: 'plc.directory unreachable and no local genesis found' }
}

// Per-DID verdict. `held` is deliberately TRI-STATE:
//   true   a local secret derives one of this DID's rotation keys — we can still change it
//   false  none does — this DID is frozen forever, and no later action can undo that
//   null   we could not determine it (no network, locked vault). NOT the same as false, and
//          reporting it as false would be a fabricated verdict.
export async function collectRotationHeld ({ cfg = {}, dids = null, storage = null, timeoutMs = 5000, fetchImpl = null } = {}) {
  const { owned, vaultLocked } = localRotationCandidates(cfg)
  const list = dids || (cfg.dids || []).map((d) => d.did)
  const out = new Map()
  for (const did of list) {
    const { keys, source, stale } = await rotationKeysFor(did, { storage, timeoutMs, fetchImpl })
    if (!keys) {
      out.set(did, { held: null, reason: source, rotationKeyCount: null, singleKeyRisk: null, heldBy: null })
      continue
    }
    const hit = keys.map((k) => ({ k, by: owned.get(k) })).find((x) => x.by)
    out.set(did, {
      held: hit ? true : (vaultLocked ? null : false),
      reason: hit ? null : (vaultLocked ? 'hyperident vault is passphrase-encrypted — cannot check' : 'no local secret derives any rotation key'),
      rotationKeyCount: keys.length,
      // Holding the ONLY rotation key is still a single point of failure. A DID can be `held: true`
      // and one disk failure from wj62's outcome, so the two facts are reported separately.
      singleKeyRisk: keys.length === 1,
      stale: !!stale,
      heldBy: hit ? hit.by : null
    })
  }
  return out
}