OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity / lib / hyperident-vault.js

266 lines · javascript ref:
/**
 * hyperident-vault — bridge between hyper-identity and the hyperident
 * master vault at ~/.hyperident/.
 *
 * This is the single point in the application identity layer that talks
 * to hyperident. Callers (pgh, pear-git-v3 daemon, etc.) use these
 * functions to:
 *
 *   - load the local master keypair (mnemonic-derived) for attestation
 *   - generate + chain-attest a fresh writer device under that master
 *   - verify a device-chain proof resolves to the local master
 *
 * pgh still owns orchestration (CLI commands, persistence to
 * ~/.pgh/linked-device.json, backup, etc.). This module only provides
 * the identity primitives — no disk writes outside the hyperident vault
 * itself, no daemon-specific knowledge.
 *
 * Why this lives in hyper-identity (not pgh): pear-git-v3 already
 * consumes hyper-identity for its IdentityRegistry + auth providers.
 * Putting the hyperident bridge here means BOTH consumers (pgh and
 * pear-git-v3) call into one place when they need master-rooted
 * attestation, instead of each duplicating the wiring.
 */

import IdentityKey from 'keet-identity-key'
import { IdentityManager as HyperidentVault } from 'hyperident/lib/identity-manager.js'
import { DeviceLinkingManager } from 'hyperident/lib/device-linking-manager.js'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'

/**
 * Load the local hyperident master identity from ~/.hyperident/.
 *
 * @param {object} [opts]
 * @param {string|null} [opts.passphrase] - if the vault is passphrase-protected
 * @returns {Promise<{
 *   mnemonic: string,
 *   publicKeyHex: string,
 *   identityKey: object,
 *   identityKeyPair: { publicKey: Buffer, secretKey: Buffer }
 * }>}
 * @throws if no hyperident vault exists or passphrase is wrong
 */
export async function loadHyperidentMaster ({ passphrase = null } = {}) {
  const data = await HyperidentVault.loadIdentity(passphrase)
  const identityKey = await IdentityKey.from({ mnemonic: data.mnemonic })
  return {
    mnemonic: data.mnemonic,
    publicKeyHex: data.publicKey,
    identityKey,
    identityKeyPair: identityKey.identityKeyPair
  }
}

/**
 * Generate a fresh Ed25519 device keypair and attest it under the local
 * hyperident master. Returns the keypair + a chain proof binding the
 * device to the master root.
 *
 * Pure: does not write anything to disk. Caller decides where the new
 * device-chain lives (e.g. pgh writes to ~/.pgh/linked-device.json).
 *
 * @param {object} [opts]
 * @param {string|null} [opts.passphrase]
 * @param {string|null} [opts.label] - human-readable label for the new device
 * @returns {Promise<{
 *   rootPubkeyHex: string,
 *   devicePublicKey: Buffer,
 *   deviceSecretKey: Buffer,
 *   devicePublicKeyHex: string,
 *   deviceSecretKeyHex: string,
 *   chainBuffer: Buffer,
 *   chainB64: string,
 *   label: string|null,
 *   createdAt: number
 * }>}
 */
export async function attestLocalDevice ({ passphrase = null, label = null } = {}) {
  const master = await loadHyperidentMaster({ passphrase })
  const newDeviceKp = crypto.keyPair()

  // bootstrap = chain length 1 (just the new device under master).
  // Equivalent to attestDevice with empty proof; produces an encoded
  // ProofEncoding Buffer that IdentityKey.verify can consume.
  const chainBuffer = await IdentityKey.bootstrap(
    { identity: master.identityKeyPair },
    newDeviceKp.publicKey
  )

  return {
    rootPubkeyHex: master.publicKeyHex,
    devicePublicKey: newDeviceKp.publicKey,
    deviceSecretKey: newDeviceKp.secretKey,
    devicePublicKeyHex: b4a.toString(newDeviceKp.publicKey, 'hex'),
    deviceSecretKeyHex: b4a.toString(newDeviceKp.secretKey, 'hex'),
    chainBuffer,
    chainB64: b4a.toString(chainBuffer, 'base64'),
    label,
    createdAt: Date.now()
  }
}

/**
 * Export the local hyperident master vault as a bundle suitable for
 * transfer to another machine. The bundle contains the mnemonic — treat
 * the returned object as sensitive material; callers should write it to
 * a secured file path and wipe afterwards (or pass through a
 * passphrase-encrypted form).
 *
 * Thin wrapper over hyperident's IdentityManager.exportDeviceBundle so
 * callers (pgh) don't import hyperident directly.
 *
 * @param {object} [opts]
 * @param {string|null} [opts.passphrase] - if the local vault is encrypted
 * @returns {Promise<object>} the bundle (mnemonic + deviceChain + profiles)
 */
export async function exportMasterBundle ({ passphrase = null } = {}) {
  return HyperidentVault.exportDeviceBundle(passphrase)
}

/**
 * Import a master-vault bundle into the local ~/.hyperident/. After
 * import, the local hyperident vault holds the bundle's mnemonic — same
 * master root as the exporter — and a fresh device keypair attested
 * under it.
 *
 * Destructive on the receiving machine: overwrites
 * ~/.hyperident/identity. Caller is responsible for backing up the
 * existing vault before invoking.
 *
 * Thin wrapper over hyperident's IdentityManager.importDeviceBundle.
 *
 * @param {object} opts
 * @param {object} opts.bundle      - the bundle returned by exportMasterBundle
 * @param {string} opts.deviceName  - human-readable name for the receiver's
 *                                     fresh device (e.g. 'stortron')
 * @param {string|null} [opts.passphrase] - if the receiver wants to
 *                                          encrypt the imported vault
 * @returns {Promise<void>}
 */
export async function importMasterBundle ({ bundle, deviceName, passphrase = null }) {
  if (!bundle || typeof bundle !== 'object') {
    throw new Error('importMasterBundle requires a bundle object')
  }
  if (!deviceName || typeof deviceName !== 'string') {
    throw new Error('importMasterBundle requires a deviceName')
  }
  return HyperidentVault.importDeviceBundle(bundle, deviceName, passphrase)
}

/**
 * Start sharing the local master vault over the network so another
 * machine can join via `joinSharedDevice` below.
 *
 * Returns the identity public key (hex) the joining device needs.
 * Uses hyperident's DeviceLinkingManager — network transport over
 * Hyperswarm, same trust model as exportMasterBundle (the bundle is
 * sent over the wire; the joining peer ends up with the mnemonic).
 *
 * Caller is responsible for stopping the share via the returned
 * `stop()` callback (or letting the timeout expire).
 *
 * @param {object} [opts]
 * @param {number} [opts.timeoutMinutes=5]
 * @returns {Promise<{
 *   identityPublicKey: string,
 *   stop: () => Promise<void>
 * }>}
 */
export async function shareMasterVault ({ timeoutMinutes = 5 } = {}) {
  const linker = new DeviceLinkingManager()
  const identityPublicKey = await linker.startSharing(timeoutMinutes)
  return {
    identityPublicKey,
    stop: async () => { await linker.stopSharing() }
  }
}

/**
 * Join a master vault that another machine is currently sharing.
 *
 * Connects to the sharing peer over Hyperswarm, receives the bundle,
 * imports it into the local ~/.hyperident/. Same trust model as
 * importMasterBundle — receiver ends up with the mnemonic.
 *
 * Refuses to run if a local vault already exists. Caller (pgh) is
 * responsible for backing up + clearing the existing vault before
 * invoking if --force is set.
 *
 * @param {object} opts
 * @param {string} opts.identityPublicKey - hex pubkey returned by
 *                                          shareMasterVault on the source
 * @param {string} opts.deviceName        - name for the local device
 * @param {string|null} [opts.passphrase] - if the local vault should be
 *                                          encrypted after import
 * @returns {Promise<boolean>} success
 */
export async function joinSharedDevice ({ identityPublicKey, deviceName, passphrase = null }) {
  if (!identityPublicKey) throw new Error('joinSharedDevice requires identityPublicKey')
  if (!deviceName) throw new Error('joinSharedDevice requires deviceName')
  const linker = new DeviceLinkingManager()
  return linker.joinDevice(identityPublicKey, deviceName, passphrase)
}

/**
 * Verify a chain proof resolves to the local hyperident master root.
 *
 * Returns a discriminated result:
 *   { ok: true, rootPubkeyHex, devicePublicKeyHex }
 *   { ok: false, reason: 'no-master', detail }
 *   { ok: false, reason: 'chain-invalid' }
 *   { ok: false, reason: 'chain-root-mismatch', expectedRoot, actualRoot }
 *
 * @param {object} opts
 * @param {string} opts.chainB64 - base64-encoded chain proof to verify
 * @param {string|null} [opts.passphrase]
 */
export async function verifyChainAgainstLocalMaster ({ chainB64, passphrase = null }) {
  if (typeof chainB64 !== 'string' || chainB64.length === 0) {
    return { ok: false, reason: 'chain-invalid' }
  }

  let master
  try {
    master = await loadHyperidentMaster({ passphrase })
  } catch (err) {
    return { ok: false, reason: 'no-master', detail: err.message }
  }

  let proof
  try {
    proof = b4a.from(chainB64, 'base64')
  } catch {
    return { ok: false, reason: 'chain-invalid' }
  }
  if (!proof || proof.length === 0) {
    return { ok: false, reason: 'chain-invalid' }
  }

  // IdentityKey.verify throws (not just returns null) on malformed
  // buffers — b4a.from is lenient with bad base64 and produces garbage.
  let verified
  try {
    verified = IdentityKey.verify(proof)
  } catch {
    return { ok: false, reason: 'chain-invalid' }
  }
  if (!verified) return { ok: false, reason: 'chain-invalid' }

  const actualRoot = b4a.toString(verified.identityPublicKey, 'hex')
  if (actualRoot !== master.publicKeyHex) {
    return {
      ok: false,
      reason: 'chain-root-mismatch',
      expectedRoot: master.publicKeyHex,
      actualRoot
    }
  }

  return {
    ok: true,
    rootPubkeyHex: actualRoot,
    devicePublicKeyHex: b4a.toString(verified.devicePublicKey, 'hex')
  }
}