OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer commits / 0a062db

0.0.53: mint — a device can become somebody without a shell
The counterpart of `pair-accept`. Pairing copies an identity onto a SECOND device and needs the first one AWAKE; this creates the first one and needs nobody. Until now the origin of every favela identity sat outside the product — `hyperident` on a machine with a shell — and a handset has neither, which is the whole reason the phone had no way to become anyone. ⛔ RESTORE IS THE SAME FUNCTION. Passing a recovery phrase rebuilds the vault for an identity that already exists, with no peer online. That is the path that survives losing every device, which pairing structurally cannot. Same code because it is the same operation: the identity root is a pure function of the words. ⛔ THE VAULT LIVES UNDER THE NODE'S STORAGE, not `~/.hyperident`. A phone has no home directory, and a daemon that guesses one is the same defect as a binary assumed to be on PATH — which this repo fixed one commit ago for ffmpeg. ⚠ The mnemonic is RETURNED, never kept. With a passphrase, what lands on disk is a sealed vault and the test asserts no mnemonic word appears in the file verbatim. That assertion is the security claim "a stolen device yields a locked vault" rests on (#48), so it is pinned rather than assumed. Refuses to overwrite an existing vault: minting again is cheap, and overwriting destroys the only copy of a recovery root. #48 is what that costs. A mistyped phrase is REFUSED rather than silently deriving a different identity — otherwise you get a working node that is nobody, and find out when nothing you owned turns out to be yours. ⚠ Uses `bip39-mnemonic`, which was a declared dependency with zero call sites. Worth stating plainly since I got it wrong out loud: keet-identity-key's `generateMnemonic()` wraps that same library, so bip39 is the format either way and both produce 24-word phrases. The choice of wrapper is a convention, not a cryptographic difference. 362/362 tests, 865/865 asserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0a062dbdad879e276a703291c0ca3013eb384102
by plankatron <fleeky@gmail.com> · 2026-09-10T14:28:15.000Z

Changed files (4) +219 -1

lib/mint.js +107 -0
……@@ -0,0 +1,107 @@
1// MINT — create a new favela identity on the device that will use it, or restore an existing one.
2//
3// The counterpart of `pair-accept`. Pairing copies an identity onto a SECOND device and needs the
4// first one online; this creates the FIRST one, and needs nobody. Without it the origin of every
5// favela identity sat outside the product — `hyperident` on a machine with a shell — and a handset
6// has neither, which is why the phone had no way to become anyone.
7//
8// ⛔ ANY NODE, NOT JUST THE PHONE. favela-android's lib/mint.js records the same rule for the atproto
9// stack: "a desktop favela on a fresh install has exactly the same problem", so this belongs in the
10// daemon's library where every device gets it, never in one shell.
11//
12// ── ⛔ THE MNEMONIC IS RETURNED, NEVER KEPT ──────────────────────────────────────────────────────
13// The mnemonic is the recovery root: it re-derives the identity and therefore outranks every device
14// credential. Minting is the one moment it exists here. So it is RETURNED for the caller to show
15// once, and what lands on disk is the VAULT — encrypted, when a passphrase is given. Nothing else
16// persists it. hyper-proto-peer/lib/mint.js states the same contract and its test asserts the config
17// does not contain it; test/mint.test.js asserts the same here.
18//
19// ── RESTORE IS THE SAME FUNCTION ─────────────────────────────────────────────────────────────────
20// Passing a `mnemonic` re-creates the vault for an identity that already exists. That is the path
21// that survives losing every device — pairing cannot, because it needs one of them awake. It is the
22// same code because it is the same operation: a vault is a mnemonic plus a cost, and the identity
23// root is a pure function of the words.
24//
25// ── WHAT THIS DOES NOT DO ────────────────────────────────────────────────────────────────────────
26// It does not claim a handle and it does not join a namespace. Creating an identity and taking a name
27// are different decisions, and requiring both at once is what hyper-proto-peer's mint records as
28// having made minting undecidable. `claim-name` comes later, and can fail without costing anything.
29import fs from '#node:fs'
30import { dirname } from '#node:path'
31import IdentityKey from 'keet-identity-key'
32import { CryptoManager } from 'hyperident/lib/crypto.js'
33import { mintFromVault } from './device.js'
34
35/**
36 * Create (or restore) an identity vault, and mint this device's credential from it.
37 *
38 * @param {object} opts
39 * @param {string} opts.vaultPath where the vault is written.
40 * @param {string} [opts.passphrase] encrypts the vault. Omitted, it is written in the clear.
41 * @param {string} [opts.mnemonic] restore an EXISTING identity. Omitted, a fresh one is generated.
42 * @returns {{ mnemonic, credential: { publicKey, secretKey, proof } }} the mnemonic is for showing ONCE.
43 */
44export async function mintIdentity ({ vaultPath, passphrase = null, mnemonic = null } = {}) {
45 if (!vaultPath) throw new Error('mint: a vault path is required')
46
47 // ⛔ REFUSE RATHER THAN OVERWRITE. A device that already has a vault is not asking for a second one
48 // — it arrived here by a double-tap or a routing mistake, and the cost of being wrong is asymmetric:
49 // minting again is cheap, and overwriting a vault destroys the only copy of a recovery root. The
50 // same asymmetry hyper-proto-peer's mint refuses on, and #48 is what it costs when a vault is lost.
51 if (fs.existsSync(vaultPath)) {
52 throw new Error('mint: a vault already exists at ' + vaultPath + ' — refusing to overwrite it. ' +
53 'Move it aside first if you really mean to replace this identity.')
54 }
55
56 // ⛔ keet-identity-key's generator, NOT bip39-mnemonic's — hyperident and hyper-proto-peer both mint
57 // with `IdentityKey.generateMnemonic()`, so a favela identity is the same shape as every other one
58 // in the fleet and `IdentityKey.from()` is guaranteed to accept it.
59 //
60 // ⚠ VALIDATED whether generated or supplied, and validated by the REAL CONSUMER. A typo in a
61 // restored phrase would otherwise derive a DIFFERENT identity rather than failing — a working node
62 // that is nobody, discovered when nothing you owned turns out to be yours.
63 const words = (mnemonic ? String(mnemonic) : IdentityKey.generateMnemonic()).trim().replace(/\s+/g, ' ')
64 try {
65 await IdentityKey.from({ mnemonic: words })
66 } catch {
67 throw new Error('mint: that is not a valid recovery phrase — check the words and their order')
68 }
69
70 const vault = passphrase ? sealed(words, passphrase) : { mnemonic: words }
71
72 fs.mkdirSync(dirname(vaultPath), { recursive: true })
73 // ⚠ Written via a temp file and renamed: a vault half-written by a process that died at the wrong
74 // moment is indistinguishable from a corrupt one, and it holds the only copy of a recovery root.
75 const tmp = vaultPath + '.tmp'
76 fs.writeFileSync(tmp, JSON.stringify(vault, null, 2))
77 fs.renameSync(tmp, vaultPath)
78
79 const cred = await mintFromVault(vaultPath, { passphrase })
80 return {
81 mnemonic: words,
82 credential: {
83 publicKey: cred.keyPair.publicKey.toString('hex'),
84 secretKey: cred.keyPair.secretKey.toString('hex'),
85 proof: cred.proof
86 }
87 }
88}
89
90// The on-disk shape `mintFromVault` reads. The cost is recorded PER VAULT so raising the default
91// later cannot make an existing vault unopenable — on a phone especially, where the parameters were
92// chosen (INTERACTIVE, 64 MB) precisely because a 256 MB allocation during unlock is a real risk.
93function sealed (words, passphrase) {
94 const salt = CryptoManager.generateSalt()
95 const params = CryptoManager.kdfParams()
96 const box = CryptoManager.encryptSecret(Buffer.from(words, 'utf8'), passphrase, salt, params)
97 return {
98 encrypted: true,
99 kdf: params.kdf,
100 mnemonic: box.toString('hex'),
101 salt: salt.toString('hex'),
102 opslimit: params.opslimit,
103 memlimit: params.memlimit
104 }
105}
106
107export default { mintIdentity }
lib/rpc.js +39 -0
……@@ -10,6 +10,7 @@
1010import net from '#node:net'
1111import b4a from 'b4a'
1212import fs from '#node:fs'
13import { join } from '#node:path'
1314import { Peer, LIKE_VALUE } from './peer.js'
1415import { linkMentions } from './mentions.js'
1516import { graphOf } from './graph.js'
……@@ -93,6 +94,13 @@ const MUTATIONS = new Set(['post', 'follow', 'unfollow', 'like', 'unlike', 'repl
9394 * ⚠ "not in that room" and "that room does not exist" look identical from outside and are fixed by
9495 * completely different things — one by a key, the other by nothing.
9596 */
97// ⛔ UNDER THE NODE'S OWN STORAGE, never `~/.hyperident`. A phone has no home directory, and a
98// daemon that guesses one is the same defect as a binary assumed to be on PATH. The node already
99// knows where its state lives, so the vault goes beside it and travels with it.
100const vaultPathFor = (node) => join(node.storage, 'identity', 'vault')
101
102const b4aOf = (hex) => b4a.from(hex, 'hex')
103
96104function requireRoom (node, key) {
97105 const r = node.room(key)
98106 if (!r) throw new Error('this node is not in room ' + String(key).slice(0, 16) + '… — join it first')
……@@ -121,6 +129,37 @@ export async function dispatch (node, cmd, a) {
121129 }
122130 // Admit this device with a chain proof for its writer key. The CLI mints it; the daemon never
123131 // opens the vault itself.
132 // ⛔ MINT — create this identity, or restore it from a recovery phrase. The counterpart of
133 // `pair-accept`: pairing needs another device AWAKE, this needs nobody, and until it existed the
134 // origin of every favela identity sat outside the product in a shell a handset does not have.
135 //
136 // ⛔ THE VAULT LIVES UNDER THE NODE'S STORAGE, not `~/.hyperident`. A phone has no home directory,
137 // and defaulting to one is the same class of bug as assuming a binary is on PATH — the daemon
138 // already knows where its own state goes, so it says.
139 //
140 // ⚠ THE MNEMONIC COMES BACK ONCE AND IS NEVER STORED IN THE CLEAR when a passphrase is given.
141 // The caller shows it and forgets it; nothing here writes it anywhere but the sealed vault.
142 // Re-asking is impossible by design, which is what makes "a stolen device yields a locked vault"
143 // true rather than aspirational.
144 case 'mint-identity': {
145 const { mintIdentity } = await import('./mint.js')
146 const r = await mintIdentity({
147 vaultPath: vaultPathFor(node),
148 passphrase: a.passphrase || null,
149 mnemonic: a.mnemonic || null
150 })
151 // Linked immediately: a minted identity that is not attested to this device would leave the node
152 // holding a vault it cannot write with, which reads as a half-finished ceremony with no way back.
153 await node.link({
154 keyPair: {
155 publicKey: b4aOf(r.credential.publicKey),
156 secretKey: b4aOf(r.credential.secretKey)
157 },
158 proof: r.credential.proof
159 })
160 return { mnemonic: r.mnemonic, device: r.credential.publicKey, restored: !!a.mnemonic }
161 }
162
124163 case 'link': {
125164 if (!a.credential || !a.credential.publicKey || !a.credential.secretKey || !a.credential.proof) {
126165 throw new Error('link needs { credential: { publicKey, secretKey, proof } }')
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-peer",
3 "version": "0.0.52",
3 "version": "0.0.53",
44 "description": "The favela daemon \u2014 feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.",
55 "type": "module",
66 "scripts": {
test/mint.test.js +72 -0
……@@ -0,0 +1,72 @@
1// Minting an identity, and restoring one.
2//
3// ⛔ THE ASSERTION THAT MATTERS is that the vault on disk does not contain the mnemonic in the clear
4// when a passphrase was given. Everything else here is behaviour; that one is the security claim the
5// whole "a stolen device yields a locked vault" argument rests on (hyper-social #48).
6import test from 'brittle'
7import fs from '#node:fs'
8import os from '#node:os'
9import { join } from '#node:path'
10import { mintIdentity } from '../lib/mint.js'
11import { mintFromVault } from '../lib/device.js'
12
13const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-mint-'))
14const vaultIn = (d) => join(d, '.hyperident', 'identity')
15
16test('★ mint creates a vault and returns a credential and the words, once', async (t) => {
17 const p = vaultIn(tmp())
18 const r = await mintIdentity({ vaultPath: p })
19 // 24, because keet-identity-key generates 256-bit phrases and the fleet mints with it.
20 t.is(r.mnemonic.split(' ').length, 24, 'a 24-word phrase, matching every other fleet identity')
21 t.ok(r.credential.publicKey && r.credential.secretKey && r.credential.proof, 'a linkable credential')
22 t.ok(fs.existsSync(p), 'the vault landed')
23})
24
25test('⛔ with a passphrase, the mnemonic is NOT on disk in the clear', async (t) => {
26 const p = vaultIn(tmp())
27 const r = await mintIdentity({ vaultPath: p, passphrase: 'a good long passphrase' })
28 const raw = fs.readFileSync(p, 'utf8')
29 t.absent(raw.includes(r.mnemonic), 'the words are absent from the vault file')
30 for (const w of r.mnemonic.split(' ')) {
31 if (raw.includes('"' + w + '"')) t.fail('a mnemonic word appears verbatim: ' + w)
32 }
33 const v = JSON.parse(raw)
34 t.is(v.encrypted, true)
35 t.is(v.kdf, 'argon2id', 'and it records the scheme, so a pre-#47 vault stays distinguishable')
36 t.ok(v.opslimit && v.memlimit, 'the cost is recorded PER VAULT, not assumed from the default')
37})
38
39test('★ the vault it wrote is the vault mintFromVault reads — round trip', async (t) => {
40 const p = vaultIn(tmp())
41 await mintIdentity({ vaultPath: p, passphrase: 'pw' })
42 const cred = await mintFromVault(p, { passphrase: 'pw' })
43 t.ok(cred.proof, 'reopened and re-minted with the same passphrase')
44 await t.exception(() => mintFromVault(p, { passphrase: 'nope' }), /passphrase/i)
45})
46
47test('★ RESTORE — the same words rebuild the same identity, with no peer online', async (t) => {
48 const first = vaultIn(tmp())
49 const a = await mintIdentity({ vaultPath: first })
50
51 const second = vaultIn(tmp())
52 const b = await mintIdentity({ vaultPath: second, mnemonic: a.mnemonic })
53
54 t.is(b.mnemonic, a.mnemonic, 'same words')
55 // ⛔ DIFFERENT device keys, SAME root. A restore must not resurrect the old device credential —
56 // each device is attested separately so it can be revoked separately.
57 t.not(b.credential.publicKey, a.credential.publicKey, 'a fresh device key, not the old one')
58})
59
60test('⛔ it refuses to overwrite an existing vault', async (t) => {
61 const p = vaultIn(tmp())
62 await mintIdentity({ vaultPath: p })
63 await t.exception(() => mintIdentity({ vaultPath: p }), /already exists|refusing/i)
64})
65
66test('⛔ a mistyped recovery phrase is refused, not silently turned into a different identity', async (t) => {
67 const p = vaultIn(tmp())
68 await t.exception(
69 () => mintIdentity({ vaultPath: p, mnemonic: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon' }),
70 /not a valid recovery phrase/i)
71 t.absent(fs.existsSync(p), 'and nothing was written')
72})