OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android / test / hosting.test.mjs

78 lines · javascript ref:
// The phone's half of the invite ceremony — unlocking the vault mint sealed.
//
// The PLC half is proven in atproto-identity (hosting-attach.test.js) and hyper-proto-peer
// (hosting-cli.test.mjs) and is not re-tested here. What this file covers is the part that only exists
// on a handset: finding the vault mint wrote, opening it with a passphrase, and getting back the key
// that can actually sign a PLC update.
//
// ⛔ THE ROUND TRIP IS THE POINT. mint seals; this opens. If those two ever disagree about where the
// vault lives or what it contains, the failure appears months later when someone is trying to recover an
// identity — the worst possible time to discover a filename mismatch.
//
// Run: bare test/hosting.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 { vaultPathFor, resolveSigner } from '../lib/hosting.js'
import { sealVault } from 'atproto-identity/lib/vault.js'
import { deriveAtprotoKey, ATPROTO_RECOVERY_PATH } from 'atproto-identity'

let pass = 0
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-hosting-' + n + '-' + Date.now())
  fs.mkdirSync(d, { recursive: true })
  return d
}

// ── the path agrees with what mint writes ────────────────────────────────────────────────────────
{
  const p = vaultPathFor('/data/hpp', 'did:plc:zl5loetx46p6xusgc47h3mia')
  ok(p === '/data/hpp/recovery/zl5loetx46p6xusgc47h3mia.vault.json', 'vault path matches mint\'s layout: ' + p)
  // Per-DID, not a shared vault.json — a device may hold several identities and the last mint must not
  // silently overwrite an earlier one's recovery secret.
  ok(vaultPathFor('/d', 'did:plc:aaa') !== vaultPathFor('/d', 'did:plc:bbb'), 'two identities get two vaults')
  let threw = null
  try { vaultPathFor('/d', '') } catch (e) { threw = e }
  ok(threw, 'a missing did is refused rather than producing a wrong path')
}

// ── mint seals; hosting opens; they must agree ───────────────────────────────────────────────────
{
  const hpp = tmp('roundtrip')
  const r = await mintIdentity({ configFile: path.join(hpp, 'config.json') })
  const PASS = 'correct horse battery staple'

  // Exactly what lib/node.js's mint handler does.
  const dir = path.join(hpp, 'recovery')
  fs.mkdirSync(dir, { recursive: true })
  fs.writeFileSync(vaultPathFor(hpp, r.did), JSON.stringify(sealVault({ secret: r.rootMnemonic, passphrase: PASS, did: r.did })) + '\n')

  // And exactly what the hosting handler does.
  const envelope = JSON.parse(fs.readFileSync(vaultPathFor(hpp, r.did), 'utf8'))
  const signer = resolveSigner({ vault: envelope, passphrase: PASS, did: r.did })

  const expected = deriveAtprotoKey(r.rootMnemonic, ATPROTO_RECOVERY_PATH)
  ok(signer.didKey === expected.didKey, 'the unlocked key is the RECOVERY key — the one a PLC update needs')

  // ⛔ AND IT IS A ROTATION KEY OF THIS IDENTITY. Deriving *a* key proves nothing; the directory only
  // accepts an update signed by a key already in the head op's rotationKeys.
  ok(r.signedOp.rotationKeys.includes(signer.didKey), 'and it is in the identity\'s own rotationKeys, so the directory would accept it')
}

// ── the refusals ─────────────────────────────────────────────────────────────────────────────────
{
  const hpp = tmp('refuse')
  const r = await mintIdentity({ configFile: path.join(hpp, 'config.json') })
  const envelope = sealVault({ secret: r.rootMnemonic, passphrase: 'right', did: r.did })
  const bad = (fn, m) => { try { fn(); ok(false, m) } catch { ok(true, m) } }
  bad(() => resolveSigner({ vault: envelope, passphrase: 'wrong', did: r.did }), 'a wrong passphrase does not unlock it')
  bad(() => resolveSigner({ vault: envelope, passphrase: 'right', did: 'did:plc:someoneelse' }), 'nor does the wrong identity')
  bad(() => resolveSigner({ vault: envelope, did: r.did }), 'nor no passphrase at all')
}

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