OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface-android / test / did-mint.test.js

99 lines · javascript ref:
// did-mint has to write where the node's own state lives, and it has to seal what it hands back.
//
// lib/node.js's header states the condition: "There is no shell, no $HOME and no env: Android hands
// the app-private directory in as argv". But `hyper-proto-peer`'s `mintIdentity` defaults its config
// to `os.homedir() + '/.config/hyper-proto-peer/config.json'`, and `writeConfigFile` is a bare
// `writeFileSync` with no `mkdirSync` in front of it. So on a handset the mint derived every key,
// built the genesis, and threw ENOENT on its last line — having produced a recovery root that was
// then lost, because nothing persisted it.
//
// Same failure class as the OTA homedir chain (hyper-social #69 §1), where one message named the
// PATH and not the caller and it cost four sideload cycles. favela-peer already fixed its own half
// and wrote down the rule: "THE VAULT LIVES UNDER THE NODE'S STORAGE, not ~/.hyperident. A phone has
// no home directory... the daemon already knows where its own state goes, so it says."
//
// ⚠ $HOME is redirected below rather than trusted. A test that took the desktop default would read —
// and could write — the developer's REAL hyper-proto-peer config, which on this machine holds a live
// identity. bare-os honours a runtime change to process.env.HOME (measured), so the redirect is
// effective for the lazy `import('hyper-proto-peer/lib/mint.js')` inside the handler.

import test from 'brittle'
import fs from 'bare-fs'
import os from 'bare-os'
import process from 'bare-process'
import { join } from 'bare-path'
import { inProcessClient } from '../lib/node.js'

const PASS = 'a passphrase long enough'

function scratch (name) {
  const dir = join(os.tmpdir(), 'favela-did-mint-' + name + '-' + Date.now() + '-' + Math.random().toString(16).slice(2))
  fs.mkdirSync(dir, { recursive: true })
  return dir
}

// A node stub with a redirected $HOME. Minting touches nothing else about a FavelaNode —
// `provisionDevice` is pure key derivation, with no store and no network.
function fixture (t, name) {
  const root = scratch(name)
  const home = join(root, 'home')
  const storage = join(root, 'storage')
  fs.mkdirSync(home, { recursive: true })
  fs.mkdirSync(storage, { recursive: true })
  const realHome = process.env.HOME
  process.env.HOME = home
  t.teardown(() => { process.env.HOME = realHome })
  return { home, storage, client: inProcessClient({ storage }) }
}

test('did-mint writes the identity under the node storage, never under $HOME', async (t) => {
  const { home, storage, client } = fixture(t, 'paths')

  const r = await client.call('did-mint', { passphrase: PASS })

  t.ok(String(r.did).startsWith('did:plc:'), 'a DID came back')
  t.absent(
    fs.existsSync(join(home, '.config', 'hyper-proto-peer', 'config.json')),
    'nothing was written under $HOME — a handset has none, so anything that lands there is lost'
  )
  t.ok(fs.existsSync(join(storage, 'atproto', 'config.json')), 'the config is under the node storage')
})

// ⛔ The passphrase is the ONLY thing protecting the vault. favela-android's mint says why: the blob
// is designed to be replicated, so it will land on disks we do not control, and it is safe there only
// because it is useless without something in a person's head.
test('did-mint refuses without a passphrase, rather than minting an unprotectable root', async (t) => {
  const { storage, client } = fixture(t, 'nopass')

  await t.exception(() => client.call('did-mint', {}), /passphrase/)
  await t.exception(() => client.call('did-mint', { passphrase: 'short' }), /passphrase/)

  t.absent(fs.existsSync(join(storage, 'atproto', 'config.json')),
    'and it refused BEFORE minting — an identity that cannot be protected must not exist')
})

test('did-mint seals the recovery root into a vault the passphrase opens', async (t) => {
  const { storage, client } = fixture(t, 'vault')

  const r = await client.call('did-mint', { passphrase: PASS })
  t.ok(r.sealed, 'the mint reports that it sealed')

  // Named by the DID's own suffix, because one device may later hold several identities and a single
  // vault.json would silently mean "the last one minted".
  const vaultPath = join(storage, 'atproto', 'recovery', String(r.did).split(':').pop() + '.vault.json')
  t.ok(fs.existsSync(vaultPath), 'the sealed vault is on disk beside the config')

  const { openVault } = await import('atproto-identity/lib/vault.js')
  const envelope = JSON.parse(fs.readFileSync(vaultPath, 'utf8'))
  t.is(openVault({ envelope, passphrase: PASS, did: r.did }), r.mnemonic,
    'and it opens to exactly the phrase the caller was told to write down')

  // The AEAD binds the blob to its identity, so a vault cannot be served in place of another's.
  t.exception(() => openVault({ envelope, passphrase: 'the wrong one', did: r.did }))

  // hpp's own test asserts this of the config; assert it here too, because this is the composition
  // that decides where both files go.
  t.absent(fs.readFileSync(join(storage, 'atproto', 'config.json'), 'utf8').includes(r.mnemonic),
    'the recovery phrase is not in the config in the clear')
})