OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer commits / b89cd2f

0.0.52: an encrypted vault opens, using hyperident's crypto and not a fourth copy
`mintFromVault` refused every encrypted vault outright — "favela cannot decrypt it yet". On a desktop that was a nuisance with a workaround: link from an already-linked machine, which needs no mnemonic. On a handset it is a WALL — there is no second machine and no CLI — so an identity created with a passphrase could never be used by the device that created it. That is the gap behind "no option to create or paste a user on the phone". ⛔ THE CRYPTO IS HYPERIDENT'S. 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 (#47/#48). This monorepo already carries several copies of one signing format, three of which eat the first character of a hash; a fourth vault crypto is how that returns. ⚠ `CryptoManager` ONLY, never `IdentityManager` — the comment above the function already recorded why: hyperident's `file-ops.js` uses `process.` without importing `bare-process`, so its loader cannot load under Bare at all. The previous refusal argued "doing that badly is worse than not doing it". That was right while the alternative was writing the crypto again. It is not right when the fixed implementation can be imported. Three things the tests pin, each because getting them wrong is worse than failing: - A vault with no `kdf` is pre-#47 and XOR-"encrypted". It reports the secret as EXPOSED and re-mintable, NOT "wrong passphrase" — which would send someone to try harder at the wrong thing. - The kdf cost is read PER VAULT, not from today's defaults. A vault written on a phone at INTERACTIVE would otherwise become unopenable the day the default is raised, and would surface as a wrong passphrase. - A missing passphrase names itself, and says the already-linked path needs neither passphrase nor mnemonic. Written test-first: the plaintext case passed while the encrypted case failed, which is what proves the fixture and the mint path were sound and the failure was really about decryption. 356/356 tests, 850/850 asserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b89cd2fbe6ad56b06495120909d6fdaf8093d913
by plankatron <fleeky@gmail.com> · 2026-09-10T14:22:30.000Z

Changed files (4) +136 -13

bin/favela.js +5 -2
……@@ -18,7 +18,8 @@ const sock = process.env.FAVELA_SOCK || join(process.env.FAVELA_STORAGE || join(
1818const USAGE = `favela — a p2p feed of markdown records
1919
2020 favela whoami this node's feed key
21 favela link [--vault <path>] attest THIS device from your identity vault
21 favela link [--vault <path>] [--passphrase <p>]
22 attest THIS device from your identity vault
2223 favela devices admitted devices, and who owns this feed
2324 favela unlink <deviceKey> revoke a device (and everything it attested)
2425 favela pair offer THIS device's credential to another machine
……@@ -127,7 +128,9 @@ async function main () {
127128 // ⛔ Minted here, not in the daemon: the vault is opened by a command a human ran, never on a
128129 // boot path. This is the FIRST device of an identity; a second machine should pair with this
129130 // one instead, which needs no vault at all.
130 const cred = await mintFromVault(vault)
131 // ⛔ COLLECTED BY THE COMMAND, never by the daemon. `mintFromVault` takes the passphrase and
132 // never prompts: this is the one moment a human is present, and boot must never need one.
133 const cred = await mintFromVault(vault, { passphrase: str(argv, 'passphrase') || null })
131134 const credential = {
132135 publicKey: b4a.toString(cred.keyPair.publicKey, 'hex'),
133136 secretKey: b4a.toString(cred.keyPair.secretKey, 'hex'),
lib/device.js +49 -9
……@@ -25,6 +25,11 @@ import { join } from '#node:path'
2525import b4a from 'b4a'
2626import crypto from 'hypercore-crypto'
2727import IdentityKey from 'keet-identity-key'
28// ⛔ THEIRS, NOT OURS. argon2id + secretbox, and it is that only because the scheme it replaced was
29// repeating-key XOR against a mnemonic drawn from a PUBLIC 2048-word list — recoverable WITHOUT the
30// passphrase (hyper-social #47/#48). This monorepo already carries several copies of one signing
31// format; a fourth vault crypto is how that class of bug returns. Import the one that was fixed.
32import { CryptoManager } from 'hyperident/lib/crypto.js'
2833
2934const FILE = 'device.json'
3035const FEED = 'feed.key'
……@@ -181,11 +186,18 @@ export function mintForDevice (cred) {
181186 * cannot load under Bare: `lib/file-ops.js` uses `process.` without importing `bare-process`
182187 * (`lib/debug-manager.js` too). One line each, in a repo we do not own here.
183188 *
184 * ⛔ An ENCRYPTED vault is refused rather than half-handled. Decrypting it is argon2id + secretbox
185 * with parameters stored beside the ciphertext, and doing that badly is worse than not doing it —
186 * so this says so and stops. It is a link-time ceremony a human is present for, not a boot path.
189 * ⛔ An ENCRYPTED vault now opens, and the crypto is HYPERIDENT'S — `CryptoManager` only, never
190 * `IdentityManager`, for the Bare reason directly above. The previous version refused outright on the
191 * grounds that "doing that badly is worse than not doing it", which was right while the alternative
192 * was writing a fourth argon2id implementation in this monorepo. It is not right when the fixed one
193 * can simply be imported, and it was a wall rather than a nuisance on a handset: no second machine to
194 * link from, and no CLI to decrypt with.
195 *
196 * ⚠ Still a link-time ceremony a human is present for, not a boot path. The passphrase is passed in
197 * by the caller that collected it; nothing here reads a prompt, and the mnemonic never leaves this
198 * function.
187199 */
188export async function mintFromVault (vaultPath) {
200export async function mintFromVault (vaultPath, { passphrase = null } = {}) {
189201 let raw
190202 try {
191203 raw = JSON.parse(fs.readFileSync(vaultPath, 'utf8'))
……@@ -193,14 +205,42 @@ export async function mintFromVault (vaultPath) {
193205 throw new Error('no identity vault at ' + vaultPath + ' (' + err.message + ')')
194206 }
195207
208 if (!raw.mnemonic) throw new Error('the vault at ' + vaultPath + ' carries no mnemonic')
209
210 let mnemonic = raw.mnemonic
196211 if (raw.encrypted) {
197 throw new Error('the vault at ' + vaultPath + ' is encrypted, and favela cannot decrypt it ' +
198 'yet. Link this device from an ALREADY-LINKED one instead — that path never needs the ' +
199 'mnemonic — or decrypt the vault with hyperident first.')
212 // ⛔ NO `kdf` MEANS PRE-#47, AND THAT IS NOT A PASSPHRASE PROBLEM. Such a vault is XOR-"encrypted",
213 // which protects nothing: the plaintext is lowercase words from a public list, ~150-200 bytes
214 // against a 32-byte repeating key. Reporting "wrong passphrase" would send someone to try harder
215 // at the wrong thing; the secret should be treated as EXPOSED and the identity re-minted.
216 if (!raw.kdf) {
217 throw new Error('the vault at ' + vaultPath + ' was written with the pre-#47 XOR scheme, which ' +
218 'does not protect it. Recover the mnemonic with an older hyperident and re-create the vault. ' +
219 'Treat the secret as exposed if the file ever left this machine.')
220 }
221 if (!passphrase) {
222 throw new Error('the vault at ' + vaultPath + ' is encrypted — a passphrase is required to ' +
223 'unlock it. (Linking from an ALREADY-LINKED device needs no passphrase and no mnemonic.)')
224 }
225 // ⚠ HONOUR THE COST THE VAULT WAS WRITTEN WITH. Using today's defaults would derive a different
226 // key and surface as a wrong passphrase — a vault written on a phone at INTERACTIVE would become
227 // unopenable the day the default is raised. The parameters are recorded per-vault for this.
228 const params = {
229 ...CryptoManager.kdfParams(),
230 opslimit: raw.opslimit || CryptoManager.kdfParams().opslimit,
231 memlimit: raw.memlimit || CryptoManager.kdfParams().memlimit
232 }
233 try {
234 const box = Buffer.from(raw.mnemonic, 'hex')
235 mnemonic = CryptoManager.decryptSecret(box, passphrase, Buffer.from(raw.salt, 'hex'), params).toString('utf8')
236 } catch {
237 // Deliberately not the underlying crypto error: secretbox failing and a mistyped passphrase are
238 // the same event to the person at the keyboard, and the detail leaks nothing useful.
239 throw new Error('the vault at ' + vaultPath + ' did not open — wrong passphrase')
240 }
200241 }
201 if (!raw.mnemonic) throw new Error('the vault at ' + vaultPath + ' carries no mnemonic')
202242
203 const idk = await IdentityKey.from({ mnemonic: raw.mnemonic })
243 const idk = await IdentityKey.from({ mnemonic })
204244 // A FRESH device keypair, attested by the root. The vault's secret never leaves this function.
205245 const keyPair = crypto.keyPair()
206246 const proof = await IdentityKey.bootstrap({ identity: idk.identityKeyPair }, keyPair.publicKey)
package.json +3 -2
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-peer",
3 "version": "0.0.51",
3 "version": "0.0.52",
44 "description": "The favela daemon \u2014 feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.",
55 "type": "module",
66 "scripts": {
……@@ -51,7 +51,8 @@
5151 "hyperswarm": "^4.17.0",
5252 "keet-identity-key": "3.2.0",
5353 "protomux": "^3.11.0",
54 "sodium-hmac": "^2.0.1"
54 "sodium-hmac": "^2.0.1",
55 "hyperident": "file:../repos/hyperident"
5556 },
5657 "devDependencies": {
5758 "brittle": "^4.0.2"
test/vault.test.js +79 -0
……@@ -0,0 +1,79 @@
1// The identity vault, and the passphrase that unlocks it.
2//
3// ⛔ WHY THIS EXISTS. `mintFromVault` refused every ENCRYPTED vault outright — "favela cannot decrypt
4// it yet" — so a passphrase-protected identity could not be used to link a device at all. On a desktop
5// that was a nuisance with a workaround (link from an already-linked machine, which needs no
6// mnemonic). On a phone it is a wall: there is no second machine and no CLI, so an identity created
7// with a passphrase could never be used by the device that created it.
8//
9// ⛔ THE CRYPTO IS HYPERIDENT'S, NOT OURS. `hyperident/lib/crypto.js` is argon2id + secretbox, and it
10// is that only because the previous scheme was repeating-key XOR against a mnemonic drawn from a
11// PUBLIC 2048-word list — recoverable without the passphrase (hyper-social #47/#48). A fourth
12// implementation of vault crypto in this monorepo is exactly how that happens again, so this imports
13// the one that was fixed rather than mirroring it.
14import test from 'brittle'
15import fs from '#node:fs'
16import os from '#node:os'
17import { join } from '#node:path'
18import { CryptoManager } from 'hyperident/lib/crypto.js'
19import { mintFromVault } from '../lib/device.js'
20
21const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-vault-'))
22
23// A published BIP39 test vector — valid, and deliberately not a secret anyone could mistake for one.
24const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
25
26const plaintextVault = (dir) => {
27 const p = join(dir, 'identity')
28 fs.writeFileSync(p, JSON.stringify({ mnemonic: MNEMONIC }))
29 return p
30}
31
32const encryptedVault = (dir, passphrase) => {
33 const salt = CryptoManager.generateSalt()
34 const params = CryptoManager.kdfParams()
35 const box = CryptoManager.encryptSecret(Buffer.from(MNEMONIC, 'utf8'), passphrase, salt, params)
36 const p = join(dir, 'identity')
37 fs.writeFileSync(p, JSON.stringify({
38 encrypted: true,
39 kdf: params.kdf,
40 mnemonic: box.toString('hex'),
41 salt: salt.toString('hex'),
42 opslimit: params.opslimit,
43 memlimit: params.memlimit
44 }))
45 return p
46}
47
48test('a PLAINTEXT vault still mints — the path that already worked is unchanged', async (t) => {
49 const cred = await mintFromVault(plaintextVault(tmp()))
50 t.ok(cred.keyPair && cred.keyPair.publicKey, 'a device keypair')
51 t.ok(typeof cred.proof === 'string' && cred.proof.length > 0, 'attested by the root')
52})
53
54test('★ an ENCRYPTED vault mints when given its passphrase', async (t) => {
55 const p = encryptedVault(tmp(), 'correct horse battery staple')
56 const cred = await mintFromVault(p, { passphrase: 'correct horse battery staple' })
57 t.ok(cred.keyPair && cred.keyPair.publicKey, 'a device keypair')
58 t.ok(typeof cred.proof === 'string' && cred.proof.length > 0, 'attested by the root')
59})
60
61test('⛔ an encrypted vault WITHOUT a passphrase says what is needed, and does not throw a crypto error', async (t) => {
62 const p = encryptedVault(tmp(), 'pw')
63 await t.exception(() => mintFromVault(p), /passphrase/i, 'names the passphrase as the missing thing')
64})
65
66test('⛔ a WRONG passphrase is refused', async (t) => {
67 const p = encryptedVault(tmp(), 'right')
68 await t.exception(() => mintFromVault(p, { passphrase: 'wrong' }), /passphrase/i)
69})
70
71// ⛔ NOT "invalid passphrase". A vault with no `kdf` predates the argon2id fix and is XOR-"encrypted",
72// which is not protection: the secret should be treated as EXPOSED and re-minted, not re-encrypted.
73// Reporting a passphrase problem would send someone to try harder at the wrong thing.
74test('⛔ a pre-#47 XOR vault says the secret is exposed, not that the passphrase is wrong', async (t) => {
75 const dir = tmp()
76 const p = join(dir, 'identity')
77 fs.writeFileSync(p, JSON.stringify({ encrypted: true, mnemonic: 'deadbeef', salt: 'aa' }))
78 await t.exception(() => mintFromVault(p, { passphrase: 'anything' }), /exposed|XOR/i)
79})