// 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')
})