// The identity vault, and the passphrase that unlocks it.
//
// ⛔ WHY THIS EXISTS. `mintFromVault` refused every ENCRYPTED vault outright — "favela cannot decrypt
// it yet" — so a passphrase-protected identity could not be used to link a device at all. On a desktop
// that was a nuisance with a workaround (link from an already-linked machine, which needs no
// mnemonic). On a phone 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.
//
// ⛔ THE CRYPTO IS HYPERIDENT'S, NOT OURS. `hyperident/lib/crypto.js` is argon2id + secretbox, and it
// is that only because the previous scheme was repeating-key XOR against a mnemonic drawn from a
// PUBLIC 2048-word list — recoverable without the passphrase (hyper-social #47/#48). A fourth
// implementation of vault crypto in this monorepo is exactly how that happens again, so this imports
// the one that was fixed rather than mirroring it.
import test from 'brittle'
import fs from '#node:fs'
import os from '#node:os'
import { join } from '#node:path'
import { CryptoManager } from 'hyperident/lib/crypto.js'
import { mintFromVault } from '../lib/device.js'
const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-vault-'))
// A published BIP39 test vector — valid, and deliberately not a secret anyone could mistake for one.
const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
const plaintextVault = (dir) => {
const p = join(dir, 'identity')
fs.writeFileSync(p, JSON.stringify({ mnemonic: MNEMONIC }))
return p
}
const encryptedVault = (dir, passphrase) => {
const salt = CryptoManager.generateSalt()
const params = CryptoManager.kdfParams()
const box = CryptoManager.encryptSecret(Buffer.from(MNEMONIC, 'utf8'), passphrase, salt, params)
const p = join(dir, 'identity')
fs.writeFileSync(p, JSON.stringify({
encrypted: true,
kdf: params.kdf,
mnemonic: box.toString('hex'),
salt: salt.toString('hex'),
opslimit: params.opslimit,
memlimit: params.memlimit
}))
return p
}
test('a PLAINTEXT vault still mints — the path that already worked is unchanged', async (t) => {
const cred = await mintFromVault(plaintextVault(tmp()))
t.ok(cred.keyPair && cred.keyPair.publicKey, 'a device keypair')
t.ok(typeof cred.proof === 'string' && cred.proof.length > 0, 'attested by the root')
})
test('★ an ENCRYPTED vault mints when given its passphrase', async (t) => {
const p = encryptedVault(tmp(), 'correct horse battery staple')
const cred = await mintFromVault(p, { passphrase: 'correct horse battery staple' })
t.ok(cred.keyPair && cred.keyPair.publicKey, 'a device keypair')
t.ok(typeof cred.proof === 'string' && cred.proof.length > 0, 'attested by the root')
})
test('⛔ an encrypted vault WITHOUT a passphrase says what is needed, and does not throw a crypto error', async (t) => {
const p = encryptedVault(tmp(), 'pw')
await t.exception(() => mintFromVault(p), /passphrase/i, 'names the passphrase as the missing thing')
})
test('⛔ a WRONG passphrase is refused', async (t) => {
const p = encryptedVault(tmp(), 'right')
await t.exception(() => mintFromVault(p, { passphrase: 'wrong' }), /passphrase/i)
})
// ⛔ NOT "invalid passphrase". A vault with no `kdf` predates the argon2id fix and is XOR-"encrypted",
// which is not protection: the secret should be treated as EXPOSED and re-minted, not re-encrypted.
// Reporting a passphrase problem would send someone to try harder at the wrong thing.
test('⛔ a pre-#47 XOR vault says the secret is exposed, not that the passphrase is wrong', async (t) => {
const dir = tmp()
const p = join(dir, 'identity')
fs.writeFileSync(p, JSON.stringify({ encrypted: true, mnemonic: 'deadbeef', salt: 'aa' }))
await t.exception(() => mintFromVault(p, { passphrase: 'anything' }), /exposed|XOR/i)
})