/**
* Tests for hyperident-vault — hyper-identity's bridge to the hyperident
* master vault at ~/.hyperident/.
*
* Uses FileManager.setBasePath() to redirect hyperident's vault to a tmp
* dir per test so we never touch the user's real ~/.hyperident/.
*/
import test from 'brittle'
import fs from '#node:fs'
import path from '#node:path'
import os from '#node:os'
import b4a from 'b4a'
import IdentityKey from 'keet-identity-key'
import crypto from 'hypercore-crypto'
import {
loadHyperidentMaster,
attestLocalDevice,
verifyChainAgainstLocalMaster,
exportMasterBundle,
importMasterBundle,
shareMasterVault,
joinSharedDevice
} from '../lib/hyperident-vault.js'
function mktemp (prefix = 'hyperident-vault') {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix + '-'))
}
async function withVault (t, fn) {
const vault = mktemp()
const { FileManager } = await import('hyperident/lib/file-ops.js')
const prev = FileManager._customPath || null
FileManager.setBasePath(vault)
t.teardown(() => {
if (prev) FileManager.setBasePath(prev)
else FileManager._customPath = null
fs.rmSync(vault, { recursive: true, force: true })
})
return fn(vault)
}
async function initVault () {
const { IdentityManager } = await import('hyperident/lib/identity-manager.js')
return IdentityManager.generateIdentity(null, 'test-device')
}
test('loadHyperidentMaster returns the master keypair from ~/.hyperident/', async (t) => {
await withVault(t, async () => {
const generated = await initVault()
const master = await loadHyperidentMaster()
t.is(typeof master.mnemonic, 'string')
t.is(master.publicKeyHex.length, 64, 'master pubkey is 64 hex chars')
t.is(master.publicKeyHex, generated.publicKey,
'matches IdentityManager.generateIdentity output')
t.ok(master.identityKeyPair.publicKey, 'identityKeyPair.publicKey present')
t.is(b4a.toString(master.identityKeyPair.publicKey, 'hex'), master.publicKeyHex)
})
})
test('attestLocalDevice produces a chain rooted at the master', async (t) => {
await withVault(t, async () => {
await initVault()
const master = await loadHyperidentMaster()
const attested = await attestLocalDevice({ label: 'writer' })
t.is(attested.rootPubkeyHex, master.publicKeyHex,
'attested.rootPubkeyHex equals master root')
t.is(attested.devicePublicKeyHex.length, 64)
t.is(attested.deviceSecretKeyHex.length, 128)
t.is(typeof attested.chainB64, 'string')
t.is(attested.label, 'writer')
// Cryptographic verification — the actual contract.
const verified = IdentityKey.verify(attested.chainBuffer)
t.ok(verified, 'IdentityKey.verify accepts the produced chain')
t.is(b4a.toString(verified.identityPublicKey, 'hex'), master.publicKeyHex,
'chain identityPublicKey === master root')
t.is(b4a.toString(verified.devicePublicKey, 'hex'), attested.devicePublicKeyHex,
'chain devicePublicKey === fresh writer device pubkey')
})
})
test('attestLocalDevice generates a fresh keypair per call', async (t) => {
await withVault(t, async () => {
await initVault()
const a = await attestLocalDevice()
const b = await attestLocalDevice()
t.not(a.devicePublicKeyHex, b.devicePublicKeyHex)
t.is(a.rootPubkeyHex, b.rootPubkeyHex)
})
})
test('verifyChainAgainstLocalMaster — chain rooted at local master accepted', async (t) => {
await withVault(t, async () => {
await initVault()
const attested = await attestLocalDevice()
const result = await verifyChainAgainstLocalMaster({ chainB64: attested.chainB64 })
t.ok(result.ok)
t.is(result.rootPubkeyHex, attested.rootPubkeyHex)
t.is(result.devicePublicKeyHex, attested.devicePublicKeyHex)
})
})
test('verifyChainAgainstLocalMaster — foreign-rooted chain rejected (yesterday\'s bug shape)', async (t) => {
await withVault(t, async () => {
await initVault()
const localMaster = await loadHyperidentMaster()
// Build a totally separate "foreign" master and attest a device under it.
const foreignKey = await IdentityKey.from({ mnemonic: IdentityKey.generateMnemonic() })
const foreignDeviceKp = crypto.keyPair()
const foreignProof = await IdentityKey.bootstrap(
{ identity: foreignKey.identityKeyPair },
foreignDeviceKp.publicKey
)
const foreignChainB64 = b4a.toString(foreignProof, 'base64')
const result = await verifyChainAgainstLocalMaster({ chainB64: foreignChainB64 })
t.absent(result.ok)
t.is(result.reason, 'chain-root-mismatch')
t.is(result.expectedRoot, localMaster.publicKeyHex)
t.not(result.actualRoot, localMaster.publicKeyHex)
t.is(result.actualRoot, b4a.toString(foreignKey.identityKeyPair.publicKey, 'hex'),
'actualRoot identifies the foreign master')
})
})
test('verifyChainAgainstLocalMaster — empty/invalid chainB64 reports chain-invalid', async (t) => {
await withVault(t, async () => {
await initVault()
const r1 = await verifyChainAgainstLocalMaster({ chainB64: '' })
t.absent(r1.ok)
t.is(r1.reason, 'chain-invalid')
const r2 = await verifyChainAgainstLocalMaster({ chainB64: 'not-base64-not-a-chain' })
t.absent(r2.ok)
t.is(r2.reason, 'chain-invalid')
})
})
test('exportMasterBundle returns a bundle containing the mnemonic + master pubkey', async (t) => {
await withVault(t, async () => {
await initVault()
const master = await loadHyperidentMaster()
const bundle = await exportMasterBundle()
t.ok(bundle, 'bundle returned')
t.ok(bundle.identity, 'bundle has identity field')
t.is(typeof bundle.identity.mnemonic, 'string', 'mnemonic is a string')
t.is(bundle.identity.publicKey, master.publicKeyHex,
'bundle.identity.publicKey === local master root')
t.ok(bundle.deviceChain, 'bundle has deviceChain field')
t.ok(bundle.deviceChain.keyPair, 'deviceChain has keyPair')
t.ok(bundle.deviceChain.proof, 'deviceChain has proof')
})
})
test('exportMasterBundle → importMasterBundle reproduces the same master root on a fresh vault', async (t) => {
// Two completely independent vaults. Vault A initializes, exports its
// bundle, vault B imports it. After import, both vaults must hold the
// same master root.
const vaultA = mktemp('hyperident-vault-A')
const vaultB = mktemp('hyperident-vault-B')
const { FileManager } = await import('hyperident/lib/file-ops.js')
const prev = FileManager._customPath || null
t.teardown(() => {
if (prev) FileManager.setBasePath(prev)
else FileManager._customPath = null
fs.rmSync(vaultA, { recursive: true, force: true })
fs.rmSync(vaultB, { recursive: true, force: true })
})
// Init A and capture its master + bundle
FileManager.setBasePath(vaultA)
await initVault()
const masterA = await loadHyperidentMaster()
const bundle = await exportMasterBundle()
// Switch to B (no init — empty vault) and import the bundle
FileManager.setBasePath(vaultB)
await importMasterBundle({ bundle, deviceName: 'machine-B' })
const masterB = await loadHyperidentMaster()
t.is(masterB.publicKeyHex, masterA.publicKeyHex,
'after import, vault B holds the same master root as vault A')
t.is(masterB.mnemonic, masterA.mnemonic,
'after import, vault B holds the same mnemonic as vault A')
// The receiver should also be able to attest a fresh device under the
// shared master — confirming the import wired up everything keet-id-key
// needs to chain new devices.
const attested = await attestLocalDevice({ label: 'machine-B-writer' })
t.is(attested.rootPubkeyHex, masterA.publicKeyHex,
'B can attest a fresh device under the shared master')
// And the chain it produces must verify against the shared master
const verified = await verifyChainAgainstLocalMaster({ chainB64: attested.chainB64 })
t.ok(verified.ok, 'B-attested chain verifies against B\'s (now shared) local master')
})
test('joinSharedDevice rejects malformed input', async (t) => {
await t.exception(
() => joinSharedDevice({ identityPublicKey: '', deviceName: 'x' }),
/requires identityPublicKey/
)
await t.exception(
() => joinSharedDevice({ identityPublicKey: 'aa'.repeat(32), deviceName: '' }),
/requires deviceName/
)
})
test('shareMasterVault returns the local master pubkey + a stop() callback', async (t) => {
await withVault(t, async () => {
const generated = await initVault()
const session = await shareMasterVault({ timeoutMinutes: 1 })
t.is(session.identityPublicKey, generated.publicKey,
'identityPublicKey === local master root')
t.is(typeof session.stop, 'function', 'stop is callable')
// Tear down the swarm immediately — we don't want a real connection
await session.stop()
})
})
test('importMasterBundle rejects malformed input', async (t) => {
await withVault(t, async () => {
await initVault()
await t.exception(
() => importMasterBundle({ bundle: null, deviceName: 'x' }),
/requires a bundle object/
)
await t.exception(
() => importMasterBundle({ bundle: { identity: {} }, deviceName: '' }),
/requires a deviceName/
)
})
})
test('verifyChainAgainstLocalMaster — no master vault reports no-master', async (t) => {
// Don't init the vault — verify without any master present.
const vault = mktemp('hyperident-vault-empty')
const { FileManager } = await import('hyperident/lib/file-ops.js')
const prev = FileManager._customPath || null
FileManager.setBasePath(vault)
t.teardown(() => {
if (prev) FileManager.setBasePath(prev)
else FileManager._customPath = null
fs.rmSync(vault, { recursive: true, force: true })
})
// Build a synthetic chain just to have something to pass in
const k = await IdentityKey.from({ mnemonic: IdentityKey.generateMnemonic() })
const dev = crypto.keyPair()
const proof = await IdentityKey.bootstrap({ identity: k.identityKeyPair }, dev.publicKey)
const chainB64 = b4a.toString(proof, 'base64')
const result = await verifyChainAgainstLocalMaster({ chainB64 })
t.absent(result.ok)
t.is(result.reason, 'no-master')
})