OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / lib / mint.js

108 lines · javascript ref:
// MINT — create a new favela identity on the device that will use it, or restore an existing one.
//
// The counterpart of `pair-accept`. Pairing copies an identity onto a SECOND device and needs the
// first one online; this creates the FIRST one, and needs nobody. Without it the origin of every
// favela identity sat outside the product — `hyperident` on a machine with a shell — and a handset
// has neither, which is why the phone had no way to become anyone.
//
// ⛔ ANY NODE, NOT JUST THE PHONE. favela-android's lib/mint.js records the same rule for the atproto
// stack: "a desktop favela on a fresh install has exactly the same problem", so this belongs in the
// daemon's library where every device gets it, never in one shell.
//
// ── ⛔ THE MNEMONIC IS RETURNED, NEVER KEPT ──────────────────────────────────────────────────────
// The mnemonic is the recovery root: it re-derives the identity and therefore outranks every device
// credential. Minting is the one moment it exists here. So it is RETURNED for the caller to show
// once, and what lands on disk is the VAULT — encrypted, when a passphrase is given. Nothing else
// persists it. hyper-proto-peer/lib/mint.js states the same contract and its test asserts the config
// does not contain it; test/mint.test.js asserts the same here.
//
// ── RESTORE IS THE SAME FUNCTION ─────────────────────────────────────────────────────────────────
// Passing a `mnemonic` re-creates the vault for an identity that already exists. That is the path
// that survives losing every device — pairing cannot, because it needs one of them awake. It is the
// same code because it is the same operation: a vault is a mnemonic plus a cost, and the identity
// root is a pure function of the words.
//
// ── WHAT THIS DOES NOT DO ────────────────────────────────────────────────────────────────────────
// It does not claim a handle and it does not join a namespace. Creating an identity and taking a name
// are different decisions, and requiring both at once is what hyper-proto-peer's mint records as
// having made minting undecidable. `claim-name` comes later, and can fail without costing anything.
import fs from '#node:fs'
import { dirname } from '#node:path'
import IdentityKey from 'keet-identity-key'
import { CryptoManager } from 'hyperident/lib/crypto.js'
import { mintFromVault } from './device.js'

/**
 * Create (or restore) an identity vault, and mint this device's credential from it.
 *
 * @param {object} opts
 * @param {string}  opts.vaultPath      where the vault is written.
 * @param {string}  [opts.passphrase]   encrypts the vault. Omitted, it is written in the clear.
 * @param {string}  [opts.mnemonic]     restore an EXISTING identity. Omitted, a fresh one is generated.
 * @returns {{ mnemonic, credential: { publicKey, secretKey, proof } }} the mnemonic is for showing ONCE.
 */
export async function mintIdentity ({ vaultPath, passphrase = null, mnemonic = null } = {}) {
  if (!vaultPath) throw new Error('mint: a vault path is required')

  // ⛔ REFUSE RATHER THAN OVERWRITE. A device that already has a vault is not asking for a second one
  // — it arrived here by a double-tap or a routing mistake, and the cost of being wrong is asymmetric:
  // minting again is cheap, and overwriting a vault destroys the only copy of a recovery root. The
  // same asymmetry hyper-proto-peer's mint refuses on, and #48 is what it costs when a vault is lost.
  if (fs.existsSync(vaultPath)) {
    throw new Error('mint: a vault already exists at ' + vaultPath + ' — refusing to overwrite it. ' +
      'Move it aside first if you really mean to replace this identity.')
  }

  // ⛔ keet-identity-key's generator, NOT bip39-mnemonic's — hyperident and hyper-proto-peer both mint
  // with `IdentityKey.generateMnemonic()`, so a favela identity is the same shape as every other one
  // in the fleet and `IdentityKey.from()` is guaranteed to accept it.
  //
  // ⚠ VALIDATED whether generated or supplied, and validated by the REAL CONSUMER. A typo in a
  // restored phrase would otherwise derive a DIFFERENT identity rather than failing — a working node
  // that is nobody, discovered when nothing you owned turns out to be yours.
  const words = (mnemonic ? String(mnemonic) : IdentityKey.generateMnemonic()).trim().replace(/\s+/g, ' ')
  try {
    await IdentityKey.from({ mnemonic: words })
  } catch {
    throw new Error('mint: that is not a valid recovery phrase — check the words and their order')
  }

  const vault = passphrase ? sealed(words, passphrase) : { mnemonic: words }

  fs.mkdirSync(dirname(vaultPath), { recursive: true })
  // ⚠ Written via a temp file and renamed: a vault half-written by a process that died at the wrong
  // moment is indistinguishable from a corrupt one, and it holds the only copy of a recovery root.
  const tmp = vaultPath + '.tmp'
  fs.writeFileSync(tmp, JSON.stringify(vault, null, 2))
  fs.renameSync(tmp, vaultPath)

  const cred = await mintFromVault(vaultPath, { passphrase })
  return {
    mnemonic: words,
    credential: {
      publicKey: cred.keyPair.publicKey.toString('hex'),
      secretKey: cred.keyPair.secretKey.toString('hex'),
      proof: cred.proof
    }
  }
}

// The on-disk shape `mintFromVault` reads. The cost is recorded PER VAULT so raising the default
// later cannot make an existing vault unopenable — on a phone especially, where the parameters were
// chosen (INTERACTIVE, 64 MB) precisely because a 256 MB allocation during unlock is a real risk.
function sealed (words, passphrase) {
  const salt = CryptoManager.generateSalt()
  const params = CryptoManager.kdfParams()
  const box = CryptoManager.encryptSecret(Buffer.from(words, 'utf8'), passphrase, salt, params)
  return {
    encrypted: true,
    kdf: params.kdf,
    mnemonic: box.toString('hex'),
    salt: salt.toString('hex'),
    opslimit: params.opslimit,
    memlimit: params.memlimit
  }
}

export default { mintIdentity }