| … | … | @@ -18,7 +18,8 @@ const sock = process.env.FAVELA_SOCK || join(process.env.FAVELA_STORAGE || join( |
| 18 | 18 | const USAGE = `favela — a p2p feed of markdown records |
| 19 | 19 | |
| 20 | 20 | 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 | |
| 22 | 23 | favela devices admitted devices, and who owns this feed |
| 23 | 24 | favela unlink <deviceKey> revoke a device (and everything it attested) |
| 24 | 25 | favela pair offer THIS device's credential to another machine |
| … | … | @@ -127,7 +128,9 @@ async function main () { |
| 127 | 128 | // ⛔ Minted here, not in the daemon: the vault is opened by a command a human ran, never on a |
| 128 | 129 | // boot path. This is the FIRST device of an identity; a second machine should pair with this |
| 129 | 130 | // 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 }) | |
| 131 | 134 | const credential = { |
| 132 | 135 | publicKey: b4a.toString(cred.keyPair.publicKey, 'hex'), |
| 133 | 136 | secretKey: b4a.toString(cred.keyPair.secretKey, 'hex'), |
| … | … | @@ -25,6 +25,11 @@ import { join } from '#node:path' |
| 25 | 25 | import b4a from 'b4a' |
| 26 | 26 | import crypto from 'hypercore-crypto' |
| 27 | 27 | import 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. | |
| 32 | import { CryptoManager } from 'hyperident/lib/crypto.js' | |
| 28 | 33 | |
| 29 | 34 | const FILE = 'device.json' |
| 30 | 35 | const FEED = 'feed.key' |
| … | … | @@ -181,11 +186,18 @@ export function mintForDevice (cred) { |
| 181 | 186 | * cannot load under Bare: `lib/file-ops.js` uses `process.` without importing `bare-process` |
| 182 | 187 | * (`lib/debug-manager.js` too). One line each, in a repo we do not own here. |
| 183 | 188 | * |
| 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. | |
| 187 | 199 | */ |
| 188 | export async function mintFromVault (vaultPath) { | |
| 200 | export async function mintFromVault (vaultPath, { passphrase = null } = {}) { | |
| 189 | 201 | let raw |
| 190 | 202 | try { |
| 191 | 203 | raw = JSON.parse(fs.readFileSync(vaultPath, 'utf8')) |
| … | … | @@ -193,14 +205,42 @@ export async function mintFromVault (vaultPath) { |
| 193 | 205 | throw new Error('no identity vault at ' + vaultPath + ' (' + err.message + ')') |
| 194 | 206 | } |
| 195 | 207 | |
| 208 | if (!raw.mnemonic) throw new Error('the vault at ' + vaultPath + ' carries no mnemonic') | |
| 209 | ||
| 210 | let mnemonic = raw.mnemonic | |
| 196 | 211 | 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 | } | |
| 200 | 241 | } |
| 201 | if (!raw.mnemonic) throw new Error('the vault at ' + vaultPath + ' carries no mnemonic') | |
| 202 | 242 | |
| 203 | const idk = await IdentityKey.from({ mnemonic: raw.mnemonic }) | |
| 243 | const idk = await IdentityKey.from({ mnemonic }) | |
| 204 | 244 | // A FRESH device keypair, attested by the root. The vault's secret never leaves this function. |
| 205 | 245 | const keyPair = crypto.keyPair() |
| 206 | 246 | const proof = await IdentityKey.bootstrap({ identity: idk.identityKeyPair }, keyPair.publicKey) |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-peer", |
| 3 | "version": "0.0.51", | |
| 3 | "version": "0.0.52", | |
| 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": { |
| … | … | @@ -51,7 +51,8 @@ |
| 51 | 51 | "hyperswarm": "^4.17.0", |
| 52 | 52 | "keet-identity-key": "3.2.0", |
| 53 | 53 | "protomux": "^3.11.0", |
| 54 | "sodium-hmac": "^2.0.1" | |
| 54 | "sodium-hmac": "^2.0.1", | |
| 55 | "hyperident": "file:../repos/hyperident" | |
| 55 | 56 | }, |
| 56 | 57 | "devDependencies": { |
| 57 | 58 | "brittle": "^4.0.2" |
| … | … | @@ -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. | |
| 14 | import test from 'brittle' | |
| 15 | import fs from '#node:fs' | |
| 16 | import os from '#node:os' | |
| 17 | import { join } from '#node:path' | |
| 18 | import { CryptoManager } from 'hyperident/lib/crypto.js' | |
| 19 | import { mintFromVault } from '../lib/device.js' | |
| 20 | ||
| 21 | const 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. | |
| 24 | const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' | |
| 25 | ||
| 26 | const plaintextVault = (dir) => { | |
| 27 | const p = join(dir, 'identity') | |
| 28 | fs.writeFileSync(p, JSON.stringify({ mnemonic: MNEMONIC })) | |
| 29 | return p | |
| 30 | } | |
| 31 | ||
| 32 | const 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 | ||
| 48 | test('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 | ||
| 54 | test('★ 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 | ||
| 61 | test('⛔ 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 | ||
| 66 | test('⛔ 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. | |
| 74 | test('⛔ 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 | }) |