| … | … | @@ -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. | |
| 29 | import fs from '#node:fs' | |
| 30 | import { dirname } from '#node:path' | |
| 31 | import IdentityKey from 'keet-identity-key' | |
| 32 | import { CryptoManager } from 'hyperident/lib/crypto.js' | |
| 33 | import { 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 | */ | |
| 44 | export 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. | |
| 93 | function 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 | ||
| 107 | export default { mintIdentity } |
| … | … | @@ -10,6 +10,7 @@ |
| 10 | 10 | import net from '#node:net' |
| 11 | 11 | import b4a from 'b4a' |
| 12 | 12 | import fs from '#node:fs' |
| 13 | import { join } from '#node:path' | |
| 13 | 14 | import { Peer, LIKE_VALUE } from './peer.js' |
| 14 | 15 | import { linkMentions } from './mentions.js' |
| 15 | 16 | import { graphOf } from './graph.js' |
| … | … | @@ -93,6 +94,13 @@ const MUTATIONS = new Set(['post', 'follow', 'unfollow', 'like', 'unlike', 'repl |
| 93 | 94 | * ⚠ "not in that room" and "that room does not exist" look identical from outside and are fixed by |
| 94 | 95 | * completely different things — one by a key, the other by nothing. |
| 95 | 96 | */ |
| 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. | |
| 100 | const vaultPathFor = (node) => join(node.storage, 'identity', 'vault') | |
| 101 | ||
| 102 | const b4aOf = (hex) => b4a.from(hex, 'hex') | |
| 103 | ||
| 96 | 104 | function requireRoom (node, key) { |
| 97 | 105 | const r = node.room(key) |
| 98 | 106 | 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) { |
| 121 | 129 | } |
| 122 | 130 | // Admit this device with a chain proof for its writer key. The CLI mints it; the daemon never |
| 123 | 131 | // 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 | ||
| 124 | 163 | case 'link': { |
| 125 | 164 | if (!a.credential || !a.credential.publicKey || !a.credential.secretKey || !a.credential.proof) { |
| 126 | 165 | throw new Error('link needs { credential: { publicKey, secretKey, proof } }') |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-peer", |
| 3 | "version": "0.0.52", | |
| 3 | "version": "0.0.53", | |
| 4 | 4 | "description": "The favela daemon \u2014 feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "scripts": { |
| … | … | @@ -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). | |
| 6 | import test from 'brittle' | |
| 7 | import fs from '#node:fs' | |
| 8 | import os from '#node:os' | |
| 9 | import { join } from '#node:path' | |
| 10 | import { mintIdentity } from '../lib/mint.js' | |
| 11 | import { mintFromVault } from '../lib/device.js' | |
| 12 | ||
| 13 | const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-mint-')) | |
| 14 | const vaultIn = (d) => join(d, '.hyperident', 'identity') | |
| 15 | ||
| 16 | test('★ 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 | ||
| 25 | test('⛔ 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 | ||
| 39 | test('★ 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 | ||
| 47 | test('★ 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 | ||
| 60 | test('⛔ 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 | ||
| 66 | test('⛔ 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 | }) |