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