// vault-setup-keeps-identity.test.js — vault.setup BACKS UP the identity's
// mnemonic; it must never REPLACE the identity (hyper-games #89 §5).
//
// keet-core's confirmRecoveryPhrase() (persist defaults to false) sets
// @local/identity.entropy = null, so getRecoveryPhrase() returns null after it
// (keet-core 4.22.28 index.js:3141-3149, 3207-3219). _vaultSetup used to call
// confirm FIRST, then treat the null phrase as "core returned empty" and
// generate a NEW mnemonic + restoreIdentity — swapping the player's identity
// for a fresh one on every vault.setup.
//
// Steps, on fresh tmp storage (identity-0 is auto-created, unconfirmed):
// 1. identity → publicKey A
// 2. vault.setup → publicKey still A; vault.export's phrase derives A
// 3. vault.setup again, new password (the launcher's "vault already
// exists" path): refused without / with a wrong oldPassword; with the
// right one → still A; vault.export(new password) is the same phrase
// 4. a second identity, confirmed (phrase dropped by keet-core), no vault:
// vault.setup refuses and asks for the phrase; identity unchanged
// 5. vault.setup {phrase}: another identity's phrase is refused, its own
// is backed up; the identity never changes
//
// Run: cd hyper-fleeky/packages/keet-daemon && bare test/vault-setup-keeps-identity.test.js
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import Pipe from 'bare-pipe'
import FramedStream from 'framed-stream'
import IdentityKey from 'keet-identity-key'
import z32 from 'z32'
import { generateMnemonic } from 'bip39-mnemonic'
import { startDaemon } from '../index.js'
let exitCode = 0
const failures = []
function ok (cond, msg) {
if (cond) console.log(' ok ' + msg)
else { console.log(' FAIL ' + msg); exitCode = 1; failures.push(msg) }
}
function rmrf (dir) { try { fs.rmSync(dir, { recursive: true, force: true }) } catch {} }
function mktemp (prefix) {
const dir = path.join(os.tmpdir(), 'keet-daemon-test-' + prefix + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8))
fs.mkdirSync(dir, { recursive: true })
return dir
}
class Client {
constructor (socketPath) {
this._socketPath = socketPath
this._pipe = null
this._framed = null
this._nextId = 1
this._pending = new Map()
}
connect () {
return new Promise((resolve, reject) => {
const pipe = new Pipe(this._socketPath)
const onErr = (err) => reject(err)
pipe.once('error', onErr)
pipe.once('connect', () => {
pipe.removeListener('error', onErr)
this._pipe = pipe
const FS = FramedStream.default || FramedStream
this._framed = new FS(pipe)
this._framed.on('data', (buf) => {
let msg
try { msg = JSON.parse(buf.toString('utf-8')) } catch { return }
const p = this._pending.get(msg.id)
if (!p) return
this._pending.delete(msg.id)
if (msg.ok === false || msg.error) p.reject(new Error(msg.error || 'RPC failed'))
else p.resolve(msg.data !== undefined ? msg.data : msg)
})
resolve()
})
})
}
call (method, params = {}, timeoutMs = 30000) {
const id = this._nextId++
const payload = JSON.stringify({ id, method, params })
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this._pending.delete(id)
reject(new Error('RPC timeout: ' + method))
}, timeoutMs)
this._pending.set(id, {
resolve: (v) => { clearTimeout(timer); resolve(v) },
reject: (e) => { clearTimeout(timer); reject(e) }
})
this._framed.write(Buffer.from(payload, 'utf-8'))
})
}
close () {
try { this._framed && this._framed.destroy() } catch {}
try { this._pipe && this._pipe.destroy() } catch {}
}
}
async function publicKeyOfPhrase (phrase) {
const key = await IdentityKey.from({ mnemonic: phrase })
return z32.encode(key.identityPublicKey) // identity's publicKey is the z32 memberId (router.js _identity)
}
async function main () {
const baseDir = mktemp('vault-keeps-identity')
const socketPath = path.join(baseDir, 'daemon.sock')
console.log('[test] baseDir = ' + baseDir)
let daemon
try {
daemon = await startDaemon({ storage: baseDir, socketPath })
ok(true, 'startDaemon resolved')
} catch (err) {
ok(false, 'startDaemon: ' + err.message); rmrf(baseDir); Bare.exit(1); return
}
const client = new Client(socketPath)
try { await client.connect(); ok(true, 'UDS connected') }
catch (err) { ok(false, 'UDS connect: ' + err.message); rmrf(baseDir); Bare.exit(1); return }
// 1. The auto-created identity.
let pkA = null
try {
const id = await client.call('identity', {}, 15000)
pkA = id && id.publicKey
ok(!!pkA, 'identity.publicKey before vault.setup: ' + pkA)
} catch (err) { ok(false, 'identity: ' + err.message) }
// 2. First vault.setup.
const pw1 = 'test-pw-' + Math.random().toString(36).slice(2, 10)
try {
const r = await client.call('vault.setup', { password: pw1 }, 120000)
ok(r && r.ok === true, 'vault.setup #1 ok')
} catch (err) { ok(false, 'vault.setup #1: ' + err.message) }
let phrase1 = null
try {
const id = await client.call('identity', {}, 15000)
ok(id && id.publicKey === pkA, 'identity unchanged after vault.setup #1 (before ' + pkA + ', after ' + (id && id.publicKey) + ')')
const ex = await client.call('vault.export', { password: pw1 }, 60000)
phrase1 = ex && ex.phrase
const derived = phrase1 ? await publicKeyOfPhrase(phrase1) : null
ok(derived === pkA, 'the vault holds THIS identity\'s phrase (derives ' + derived + ')')
} catch (err) { ok(false, 'after setup #1: ' + err.message) }
// 3. vault.setup again. keet-core dropped the phrase at #1's confirm, so a
// new password needs the old one (the phrase now lives only in the vault).
const pw2 = 'test-pw-' + Math.random().toString(36).slice(2, 10)
try {
await client.call('vault.setup', { password: pw2 }, 120000)
ok(false, 'vault.setup #2 without oldPassword must be refused')
} catch (err) { ok(/oldPassword/.test(err.message), 'vault.setup #2 without oldPassword refused (' + err.message + ')') }
try {
await client.call('vault.setup', { password: pw2, oldPassword: 'wrong-' + pw1 }, 120000)
ok(false, 'vault.setup #2 with a wrong oldPassword must be refused')
} catch (err) { ok(true, 'vault.setup #2 with a wrong oldPassword refused (' + err.message + ')') }
try {
const r = await client.call('vault.setup', { password: pw2, oldPassword: pw1 }, 120000)
ok(r && r.ok === true, 'vault.setup #2 with oldPassword ok')
} catch (err) { ok(false, 'vault.setup #2: ' + err.message) }
try {
const id = await client.call('identity', {}, 15000)
ok(id && id.publicKey === pkA, 'identity unchanged after vault.setup #2 (after ' + (id && id.publicKey) + ')')
const ex = await client.call('vault.export', { password: pw2 }, 60000)
ok(ex && ex.phrase === phrase1 && phrase1 !== null, 'vault #2 holds the same phrase under the new password')
} catch (err) { ok(false, 'after setup #2: ' + err.message) }
// 4. A second identity whose phrase keet-core already dropped (confirmed, the
// way Keet desktop leaves it) and that has no vault: setup must refuse and
// point at vault.import — never mint a replacement identity.
try {
const created = await client.call('identity-manager.create', { name: 'vault-noph-' + Date.now().toString(36) }, 15000)
const newId = created.id || (created.entry && created.entry.id)
await client.call('identity-manager.switch', { id: newId }, 30000)
const before = await client.call('identity', {}, 15000)
const rec = await client.call('identity.recovery', {}, 15000)
const ownPhrase = rec && rec.phrase
ok(typeof ownPhrase === 'string' && ownPhrase.split(' ').length >= 12, 'read identity-1\'s phrase before confirming it away')
await client.call('identity.confirm', {}, 15000)
try {
await client.call('vault.setup', { password: pw1 }, 120000)
ok(false, 'vault.setup with no phrase and no vault must be refused')
} catch (err) { ok(/phrase/.test(err.message), 'no phrase + no vault refused, asks for the phrase (' + err.message + ')') }
let after = await client.call('identity', {}, 15000)
ok(before && after && before.publicKey === after.publicKey, 'identity unchanged by the refused setup (' + (before && before.publicKey) + ' / ' + (after && after.publicKey) + ')')
let st = await client.call('vault.status', {}, 15000)
ok(st && st.exists === false, 'no vault file was written for it')
// 5. The player types the words in. Someone else's valid phrase is refused
// and changes nothing; their own is backed up — never restored.
const otherPhrase = generateMnemonic()
try {
await client.call('vault.setup', { password: pw1, phrase: otherPhrase }, 120000)
ok(false, 'vault.setup with another identity\'s phrase must be refused')
} catch (err) { ok(/Wrong recovery phrase/.test(err.message), 'another identity\'s phrase refused as wrong (' + err.message + ')') }
after = await client.call('identity', {}, 15000)
ok(after && after.publicKey === before.publicKey, 'identity unchanged by the wrong phrase')
st = await client.call('vault.status', {}, 15000)
ok(st && st.exists === false, 'no vault written for the wrong phrase')
const r = await client.call('vault.setup', { password: pw1, phrase: ownPhrase }, 120000)
ok(r && r.ok === true, 'vault.setup with the identity\'s own phrase ok')
after = await client.call('identity', {}, 15000)
ok(after && after.publicKey === before.publicKey, 'identity unchanged by the phrase backup')
const ex = await client.call('vault.export', { password: pw1 }, 60000)
ok(ex && ex.phrase === ownPhrase, 'vault holds the typed phrase')
} catch (err) { ok(false, 'no-phrase case: ' + err.message) }
console.log('[test] cleanup')
client.close()
try { await daemon.router.core.close() } catch {}
try { await daemon.core.close() } catch {}
try { await daemon.uds.stop() } catch {}
rmrf(baseDir)
console.log('')
if (exitCode === 0) console.log('=== RESULT: ALL PASSED ===')
else {
console.log('=== RESULT: ' + failures.length + ' FAILED ===')
for (const f of failures) console.log(' - ' + f)
}
Bare.exit(exitCode)
}
main().catch(err => {
console.error('[test] uncaught:', err && err.message)
if (err && err.stack) console.error(err.stack)
Bare.exit(1)
})