OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity / test / hyperident-vault.test.js

266 lines · javascript ref:
/**
 * 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')
})