OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android / test / mint.test.mjs

76 lines · javascript ref:
// Minting an identity ON THE PHONE, and the recovery vault that makes it survivable.
//
// hyper-proto-peer/test/mint.test.mjs already proves the ceremony itself — the dids[] entry, creator
// posture, and the custody rule that the root mnemonic never reaches the config. What this file has to
// show is the part the phone adds on top, which is the part that decides whether losing the handset is
// recoverable:
//
//   - the words the screen shows are the words that open the sealed vault
//   - the vault is bound to THIS identity, so one DID's vault cannot be served in place of another's
//   - a wrong passphrase fails, and fails the same way as everything else
//
// ⛔ THE VAULT IS THE ONLY REASON THIS IS NOT A ONE-SHOT SECRET. Create.jsx shows the mnemonic once and
// never again; the sealed blob is the second copy, and it is designed to be replicated onto disks we do
// not control. It is safe there only because it is useless without a passphrase that is never stored
// (hyper-social #54 §6). If the seal and the shown words could ever disagree, the backup would be a
// convincing forgery of one — hence the first assertion.
//
// Run: bare test/mint.test.mjs
import 'favela-web/lib/bare-globals.js'
import fs from 'bare-fs'
import os from 'bare-os'
import path from 'bare-path'
import { mintIdentity } from '../lib/mint.js'
import { sealVault, openVault } from 'atproto-identity/lib/vault.js'

let pass = 0
// Not process.exit — there is no `process` under bare (see first-run.test.mjs).
const ok = (c, m) => { if (!c) throw new Error('FAIL: ' + m); console.log('  ok -', m); pass++ }

const tmp = (n) => {
  const d = path.join(os.tmpdir(), 'favela-mint-' + n + '-' + Date.now())
  fs.mkdirSync(d, { recursive: true })
  return path.join(d, 'config.json')
}

const PASSPHRASE = 'correct horse battery staple'

// ── the re-export is real ────────────────────────────────────────────────────────────────────────
// lib/mint.js is a one-line re-export, and a broken one fails at RUNTIME on a handset, in the middle of
// a ceremony, with the words already generated. Cheap to assert here.
ok(typeof mintIdentity === 'function', 'lib/mint.js re-exports mintIdentity')

// ── mint, then seal exactly what the screen shows ────────────────────────────────────────────────
const r = await mintIdentity({ configFile: tmp('seal') })
ok(r.did.startsWith('did:plc:'), 'minted ' + r.did)
ok(r.mnemonic === undefined, 'the handler names it `rootMnemonic`, not `mnemonic` — the field the screen reads')
ok(typeof r.rootMnemonic === 'string' && r.rootMnemonic.split(' ').length >= 12, 'a real mnemonic came back')

const envelope = sealVault({ secret: r.rootMnemonic, passphrase: PASSPHRASE, did: r.did })
ok(openVault({ envelope, passphrase: PASSPHRASE, did: r.did }) === r.rootMnemonic,
  'the sealed vault opens to EXACTLY the words the screen showed')

// ── the three ways it must refuse ────────────────────────────────────────────────────────────────
const refuses = (fn, what) => {
  try { fn(); ok(false, what) } catch { ok(true, what) }
}
refuses(() => openVault({ envelope, passphrase: 'not the passphrase', did: r.did }), 'a wrong passphrase does not open it')

// Bound to the DID as AEAD additional data: another identity's vault cannot be substituted for this one.
const other = await mintIdentity({ configFile: tmp('other') })
ok(other.did !== r.did, 'a second mint is a different identity')
refuses(() => openVault({ envelope, passphrase: PASSPHRASE, did: other.did }), 'this vault does not open as another identity')

const tampered = { ...envelope, ct: envelope.ct.slice(0, -2) + (envelope.ct.endsWith('A') ? 'B' : 'A') }
refuses(() => openVault({ envelope: tampered, passphrase: PASSPHRASE, did: r.did }), 'a tampered blob does not open')

// ── the passphrase is not in the blob ────────────────────────────────────────────────────────────
// The envelope is replicated. If any part of the secret or the passphrase travelled with it, replication
// would be the leak.
const raw = JSON.stringify(envelope)
ok(!raw.includes(PASSPHRASE), 'the passphrase is not in the envelope')
ok(!raw.includes(r.rootMnemonic), 'nor the mnemonic')
for (const w of r.rootMnemonic.split(' ')) ok(!raw.includes('"' + w + '"'), 'nor any word verbatim (' + w + ')')

console.log('\n' + pass + ' assertions passed')