OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / lib / device.js

347 lines · javascript ref:
// The device credential on disk, and the ceremonies that produce one.
//
// ── What a device IS ──────────────────────────────────────────────────────────────────────────
//
// A real Ed25519 keypair with a chain proof to the identity root: `{ keyPair, proof }`. It CAN
// sign, which is what makes everything else work — an already-linked device attests the next one
// from its own keypair, with no mnemonic. That is exactly what pear-git-v3's DeviceLinker ships
// over blind-pairing (`{ deviceSecretKey, deviceProof, rootPubkey }`, never the root secret).
//
// ⛔ A hypercore WRITER key is not a device. It is manifest-derived, nobody holds its secret half,
// and it can never sign — measured: attesting one and then trying to attest FROM it is refused. So
// a writer core is BOUND to a device as attested data (`bindWriter`), and the roster records which
// device stands behind each core.
//
// ⇒ The vault is opened for exactly two things: minting the FIRST device of an identity, and
// recovering onto a machine that has no linked device and no peer to pair with. Never at boot, and
// never to add a device when another one is reachable.
//
// ⚠ An earlier version of this file concluded the opposite — that every device needs the vault —
// from a measurement taken against a writer core key rather than a device keypair. The primitive
// was never the limitation; the modelling was.

import fs from '#node:fs'
import process from '#node:process'
import { join } from '#node:path'
import b4a from 'b4a'
import crypto from 'hypercore-crypto'
import IdentityKey from 'keet-identity-key'
// ⛔ THEIRS, NOT OURS. argon2id + secretbox, and it is that only because the scheme it replaced was
// repeating-key XOR against a mnemonic drawn from a PUBLIC 2048-word list — recoverable WITHOUT the
// passphrase (hyper-social #47/#48). This monorepo already carries several copies of one signing
// format; a fourth vault crypto is how that class of bug returns. Import the one that was fixed.
import { CryptoManager } from 'hyperident/lib/crypto.js'

const FILE = 'device.json'
const FEED = 'feed.key'
const NAMESPACE = 'namespace.key'
const NAMESPACE_JSON = 'namespace.json'
const NAMESPACES_JSON = 'namespaces.json'
const ROOMS = 'rooms.json'

export function proofPath (storage) { return join(storage, FILE) }
export function feedKeyPath (storage) { return join(storage, FEED) }

/**
 * The feed this node writes to, if it was told one.
 *
 * ⛔ A node that founds its own feed never writes this — the key is derived when the log is created.
 * It exists for a device that JOINED an identity: pairing hands over the feed key along with the
 * credential, because being someone's device and writing to their feed are two different facts and
 * only the first is in the chain.
 */
export function loadFeedKey (storage) {
  try {
    const s = fs.readFileSync(feedKeyPath(storage), 'utf8').trim()
    return /^[0-9a-f]{64}$/.test(s) ? s : null
  } catch {
    return null
  }
}

/**
 * The namespace this node resolves handles in.
 *
 * ⛔ A FILE, not a constant. Shipping a hardcoded namespace would make us its authority; NAMING.md
 * §8 #2 is explicit that a default which cannot be changed is a root. Edit this file to join a
 * different one; delete it to start your own.
 */
export function loadNamespaceKey (storage) {
  try {
    const s = fs.readFileSync(join(storage, NAMESPACE), 'utf8').trim()
    return /^[0-9a-f]{64}$/.test(s) ? s : null
  } catch {
    return null
  }
}

export function saveNamespaceKey (storage, hex) {
  fs.writeFileSync(join(storage, NAMESPACE), String(hex).trim() + '\n')
}

/**
 * The namespace this node resolves handles in, AND the Corestore slot its log lives under.
 *
 * ⛔ The slot has to be recorded, not derived. Two Autobees sharing one Corestore namespace derive
 * the same local writer and DEADLOCK rather than erroring (`node.js`, measured 2026-09-06), so each
 * namespace gets its own — but the one this node CREATED could not be named after its key, because
 * the key does not exist until the log is opened. So it lives in the legacy slot `names` and joined
 * ones get `names:<key>`. Guessing that from the key alone is impossible; the file says which.
 *
 * ⚠ Seeds from the legacy `namespace.key`, which every deployed host holds, and never deletes it.
 */
export function loadNamespace (storage) {
  try {
    const raw = JSON.parse(fs.readFileSync(join(storage, NAMESPACE_JSON), 'utf8'))
    if (raw && /^[0-9a-f]{64}$/.test(raw.key) && typeof raw.slot === 'string' && raw.slot) return raw
  } catch {}
  const legacy = loadNamespaceKey(storage)
  // The legacy file names a log that was opened under `names`, and that is where its claims are.
  if (legacy) return { key: legacy, slot: 'names' }

  // ⛔ A DEFAULT, AND IT IS CONFIGURATION. Without one a fresh node FOUNDS ITS OWN namespace and
  // resolves handles in a world containing only itself — so a new device could join the fleet, hold
  // the right feeds, and still find nobody by name. That is what a handset did, and it read as a
  // broken network rather than an empty registry.
  //
  // ⚠ `namespace.js` permits exactly this and states the limit: "Shipping a default namespace makes
  // us a convenient starting point, never an authoritative one… So the key is an option." NAMING.md
  // §8 #2 is the rule it must not break — "a default that cannot be changed is a root" — so
  // FAVELA_NAMESPACE overrides it, `namespace --join` moves off it, and nothing here is load-bearing
  // for anyone who has chosen otherwise.
  //
  // ⚠ TEMPORARY, by request. It hardcodes ONE fleet's registry into every install, which is a
  // starting point and not a design: the right end state is a default a person picks during
  // onboarding, or none at all for someone who wants their own world.
  // ⛔ `own` MUST REMAIN POSSIBLE. A default that cannot be declined does not merely inconvenience
  // someone who wants their own world — it removes the ability to be an ADMIN of anything, because
  // admin rights come from founding. `test/namespace-join.test.js` asserts exactly that ("a linked
  // node FOUNDS its namespace — so it has an admin from the first boot"), and it caught this.
  const configured = envNamespace()
  if (configured === OWN) return null
  const key = configured || DEFAULT_NAMESPACE
  return key ? { key, slot: namespaceSlot(key) } : null
}

// FAVELA_NAMESPACE=own — found a fresh one, the behaviour every node had before the default.
const OWN = 'own'

// tabertron's namespace — the one the fleet already resolves in, holding tabertron0, stortron0,
// ashatron1 and fleeky0. Measured from the running daemon rather than copied from a config file:
// `~/.favela/namespace.json` is a STALE storage root the daemon does not use, and reading it is how
// an earlier audit concluded the fleet was fragmented when it was not.
const DEFAULT_NAMESPACE = '2c9290ede07757087ba7983708508174768f92879ddd94858fac215ecbff9ca5'

function envNamespace () {
  try {
    const v = process.env.FAVELA_NAMESPACE
    if (typeof v !== 'string') return null
    const t = v.trim().toLowerCase()
    if (t === OWN) return OWN
    return /^[0-9a-f]{64}$/.test(t) ? t : null
  } catch { return null }
}

export function saveNamespace (storage, { key, slot }) {
  fs.writeFileSync(join(storage, NAMESPACE_JSON), JSON.stringify({ key, slot }, null, 2) + '\n')
}

/**
 * Every namespace this node has on disk — `[{ key, slot }]`, current one included.
 *
 * ⛔ AN INDEX, because the slots cannot be enumerated. A joined namespace lives under
 * `names:<key>` and the founded one under the legacy `names`, and nothing on disk lists them — so
 * `namespaceSettings` could truthfully promise "you can come back to it, handles and all" while
 * giving nobody a way to find out what "it" was. A 64-hex key you have to remember is not a door.
 *
 * ⚠ LOCAL state, exactly like `loadRooms`: which namespaces this machine holds is a fact about this
 * machine, not something derivable from anyone's log.
 */
export function loadNamespaces (storage) {
  let list = []
  try {
    const raw = JSON.parse(fs.readFileSync(join(storage, NAMESPACES_JSON), 'utf8'))
    if (Array.isArray(raw)) list = raw.filter((n) => n && /^[0-9a-f]{64}$/.test(n.key) && typeof n.slot === 'string' && n.slot)
  } catch {}
  // ⛔ The CURRENT one is always in the list, even if the index predates it or was never written.
  // An index that could omit the namespace you are standing in would offer you a switch to nowhere.
  const cur = loadNamespace(storage)
  if (cur && !list.some((n) => n.key === cur.key)) list = [{ key: cur.key, slot: cur.slot }, ...list]
  return list
}

/**
 * Which Corestore slot a namespace's log belongs in — pure, so the rule can be tested.
 *
 * ⛔ THE FOUNDED NAMESPACE IS THE WHOLE REASON THIS EXISTS. Its log lives in the legacy slot
 * `names`, which `namespaceSlot(key)` cannot produce because the key did not exist when the slot was
 * named. So leaving it and returning computed `names:<key>`, opened a SECOND log, and left every
 * handle claimed there resolving from a slot this node no longer used. Nothing was lost; it was
 * somewhere else, which is worse, because it presents as loss.
 *
 * Order matters: the namespace we are standing in knows its own slot, then the index, then derive.
 */
export function slotFor (key, { current = null, known = [] } = {}) {
  if (current && current.key === key && current.slot) return current.slot
  const recorded = (known || []).find((n) => n && n.key === key && n.slot)
  return recorded ? recorded.slot : namespaceSlot(key)
}

export function saveNamespaces (storage, list) {
  const seen = new Set()
  const clean = []
  for (const n of list || []) {
    if (!n || !/^[0-9a-f]{64}$/.test(n.key) || seen.has(n.key)) continue
    seen.add(n.key)
    clean.push({ key: n.key, slot: n.slot })
  }
  fs.writeFileSync(join(storage, NAMESPACES_JSON), JSON.stringify(clean, null, 2) + '\n')
}

/**
 * The rooms this node is in — `[{ key, slot }]`.
 *
 * ⚠ LOCAL state, not derived from anyone's log. A room key is a CAPABILITY: holding it is what lets
 * this node replicate the room at all, so the list of rooms it is in is a fact about this machine.
 */
export function loadRooms (storage) {
  try {
    const raw = JSON.parse(fs.readFileSync(join(storage, ROOMS), 'utf8'))
    return Array.isArray(raw) ? raw.filter((r) => r && /^[0-9a-f]{64}$/.test(r.key) && r.slot) : []
  } catch {
    return []
  }
}

export function saveRooms (storage, rooms) {
  fs.writeFileSync(join(storage, ROOMS), JSON.stringify(rooms, null, 2) + '\n')
}

/** The Corestore slot for a namespace we are JOINING rather than one we made. */
export const namespaceSlot = (keyHex) => 'names:' + String(keyHex).toLowerCase()

export function saveFeedKey (storage, hex) {
  fs.writeFileSync(feedKeyPath(storage), String(hex).trim() + '\n')
}

/** The stored proof, or null. Never throws — a missing credential is a state, not a fault. */
/** The stored credential `{ keyPair, proof }`, or null. Never throws. */
export function loadCredential (storage) {
  try {
    const raw = JSON.parse(fs.readFileSync(proofPath(storage), 'utf8'))
    if (!raw || !raw.proof || !raw.secretKey || !raw.publicKey) return null
    return {
      keyPair: { publicKey: b4a.from(raw.publicKey, 'hex'), secretKey: b4a.from(raw.secretKey, 'hex') },
      proof: raw.proof
    }
  } catch {
    return null
  }
}

// ⚠ This file holds a SECRET KEY. Mode 0600, and it is the one thing on disk whose loss means
// re-linking the device — not re-creating the identity, which lives only in the vault.
export function saveCredential (storage, cred) {
  const body = JSON.stringify({
    publicKey: b4a.toString(cred.keyPair.publicKey, 'hex'),
    secretKey: b4a.toString(cred.keyPair.secretKey, 'hex'),
    proof: cred.proof
  }, null, 2)
  fs.writeFileSync(proofPath(storage), body + '\n', { mode: 0o600 })
}

/**
 * Bind a hypercore writer key to this device — what an `add-device` op carries.
 *
 * The writer key travels as ATTESTED DATA inside the proof, so the result is good for that one
 * core and nothing else. Replaying it onto another core does not verify.
 */
export function bindWriter (cred, writerKeyHex) {
  const bound = IdentityKey.attestData(
    b4a.from(writerKeyHex, 'hex'), cred.keyPair, b4a.from(cred.proof, 'base64'))
  return b4a.toString(bound, 'base64')
}

/**
 * Attest a NEW device from this one. No mnemonic — the blind-pairing path.
 *
 * Returns a full credential for the target, whose SECRET KEY is generated here and must reach the
 * target over a channel that authenticated it (blind-pairing). Treat the return value as secret.
 */
export function mintForDevice (cred) {
  const keyPair = crypto.keyPair()
  const proof = IdentityKey.attestDevice(
    keyPair.publicKey, cred.keyPair, b4a.from(cred.proof, 'base64'))
  return { keyPair, proof: b4a.toString(proof, 'base64') }
}

/**
 * Mint a proof for `deviceKeyHex` from a hyperident vault file.
 *
 * ⚠ Reads the vault JSON directly rather than going through hyperident's IdentityManager, which
 * cannot load under Bare: `lib/file-ops.js` uses `process.` without importing `bare-process`
 * (`lib/debug-manager.js` too). One line each, in a repo we do not own here.
 *
 * ⛔ An ENCRYPTED vault now opens, and the crypto is HYPERIDENT'S — `CryptoManager` only, never
 * `IdentityManager`, for the Bare reason directly above. The previous version refused outright on the
 * grounds that "doing that badly is worse than not doing it", which was right while the alternative
 * was writing a fourth argon2id implementation in this monorepo. It is not right when the fixed one
 * can simply be imported, and it was a wall rather than a nuisance on a handset: no second machine to
 * link from, and no CLI to decrypt with.
 *
 * ⚠ Still a link-time ceremony a human is present for, not a boot path. The passphrase is passed in
 * by the caller that collected it; nothing here reads a prompt, and the mnemonic never leaves this
 * function.
 */
export async function mintFromVault (vaultPath, { passphrase = null } = {}) {
  let raw
  try {
    raw = JSON.parse(fs.readFileSync(vaultPath, 'utf8'))
  } catch (err) {
    throw new Error('no identity vault at ' + vaultPath + ' (' + err.message + ')')
  }

  if (!raw.mnemonic) throw new Error('the vault at ' + vaultPath + ' carries no mnemonic')

  let mnemonic = raw.mnemonic
  if (raw.encrypted) {
    // ⛔ NO `kdf` MEANS PRE-#47, AND THAT IS NOT A PASSPHRASE PROBLEM. Such a vault is XOR-"encrypted",
    // which protects nothing: the plaintext is lowercase words from a public list, ~150-200 bytes
    // against a 32-byte repeating key. Reporting "wrong passphrase" would send someone to try harder
    // at the wrong thing; the secret should be treated as EXPOSED and the identity re-minted.
    if (!raw.kdf) {
      throw new Error('the vault at ' + vaultPath + ' was written with the pre-#47 XOR scheme, which ' +
        'does not protect it. Recover the mnemonic with an older hyperident and re-create the vault. ' +
        'Treat the secret as exposed if the file ever left this machine.')
    }
    if (!passphrase) {
      throw new Error('the vault at ' + vaultPath + ' is encrypted — a passphrase is required to ' +
        'unlock it. (Linking from an ALREADY-LINKED device needs no passphrase and no mnemonic.)')
    }
    // ⚠ HONOUR THE COST THE VAULT WAS WRITTEN WITH. Using today's defaults would derive a different
    // key and surface as a wrong passphrase — a vault written on a phone at INTERACTIVE would become
    // unopenable the day the default is raised. The parameters are recorded per-vault for this.
    const params = {
      ...CryptoManager.kdfParams(),
      opslimit: raw.opslimit || CryptoManager.kdfParams().opslimit,
      memlimit: raw.memlimit || CryptoManager.kdfParams().memlimit
    }
    try {
      const box = Buffer.from(raw.mnemonic, 'hex')
      mnemonic = CryptoManager.decryptSecret(box, passphrase, Buffer.from(raw.salt, 'hex'), params).toString('utf8')
    } catch {
      // Deliberately not the underlying crypto error: secretbox failing and a mistyped passphrase are
      // the same event to the person at the keyboard, and the detail leaks nothing useful.
      throw new Error('the vault at ' + vaultPath + ' did not open — wrong passphrase')
    }
  }

  const idk = await IdentityKey.from({ mnemonic })
  // A FRESH device keypair, attested by the root. The vault's secret never leaves this function.
  const keyPair = crypto.keyPair()
  const proof = await IdentityKey.bootstrap({ identity: idk.identityKeyPair }, keyPair.publicKey)
  return { keyPair, proof: b4a.toString(proof, 'base64') }
}

export default { slotFor, loadNamespaces, saveNamespaces, loadRooms, saveRooms, proofPath, feedKeyPath, loadFeedKey, saveFeedKey, loadNamespaceKey, saveNamespaceKey, loadNamespace, saveNamespace, namespaceSlot, loadCredential, saveCredential, bindWriter, mintForDevice, mintFromVault }