OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / test / vault.test.js

80 lines · javascript ref:
// 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)
})