OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / identity / identity-manager.js

262 lines · javascript ref:
/**
 * Identity Manager — Mnemonic generation, proof chains, vault coordination
 *
 * Orchestrates keet-identity-key and SecureStorage to provide:
 * - Mnemonic generation + encrypted vault storage
 * - Proof chain creation (identity → device attestation)
 * - Proof verification
 * - Mnemonic export (password-protected)
 */

import fs from 'bare-fs'
import path from 'bare-path'
import IdentityKey from 'keet-identity-key'
import sodium from 'sodium-native'
import { SecureStorage } from './secure-storage.js'

export class IdentityManager {
  constructor ({ storagePath, identityFile }) {
    this.storagePath = storagePath
    // Allow override — client uses device-identity.json, server uses server-identity.json
    this.identityFile = identityFile || path.join(storagePath, 'server-identity.json')
    // Vault: prefer existing server-mnemonic.enc (backward compat), else device-mnemonic.enc
    const oldVault = path.join(storagePath, 'server-mnemonic.enc')
    const newVault = path.join(storagePath, 'device-mnemonic.enc')
    let vaultFile = newVault
    try {
      fs.statSync(oldVault)
      vaultFile = oldVault
    } catch {}
    this.vaultFile = vaultFile
    this.vault = new SecureStorage(this.vaultFile)
  }

  /**
   * Ensure a device keypair exists at this.identityFile.
   * If server-identity.json already exists (server machine), this is a no-op.
   * Otherwise, generates a new Ed25519 keypair.
   * @returns {boolean} true if newly generated, false if already existed
   */
  async ensureKeypair () {
    try {
      await fs.promises.readFile(this.identityFile, 'utf-8')
      return false // already exists
    } catch (err) {
      if (err.code !== 'ENOENT') throw err
    }
    // Generate new Ed25519 keypair
    const crypto = (await import('hypercore-crypto')).default
    const keypair = crypto.keyPair()
    const data = {
      publicKey: keypair.publicKey.toString('hex'),
      secretKey: keypair.secretKey.toString('hex'),
      createdAt: Date.now()
    }
    await fs.promises.mkdir(path.dirname(this.identityFile), { recursive: true })
    await fs.promises.writeFile(this.identityFile, JSON.stringify(data, null, 2), {
      encoding: 'utf-8', mode: 0o600
    })
    return true // newly generated
  }

  /**
   * Get current identity status
   * @returns {{ hasKeypair: boolean, hasVault: boolean, hasProof: boolean, identityPublicKey: string|null }}
   */
  status () {
    let hasKeypair = false
    let hasProof = false
    let identityPublicKey = null
    let devicePublicKey = null

    try {
      const data = JSON.parse(fs.readFileSync(this.identityFile, 'utf-8'))
      hasKeypair = !!(data.publicKey && data.secretKey)
      hasProof = !!data.proof
      identityPublicKey = data.identityPublicKey || null
      devicePublicKey = data.publicKey || null
    } catch {}

    return {
      hasKeypair,
      hasVault: this.vault.exists(),
      hasProof,
      identityPublicKey,
      devicePublicKey
    }
  }

  /**
   * Generate mnemonic, create proof chain, encrypt vault
   * @param {Buffer} password - Encryption password
   * @returns {{ mnemonic: string, identityPublicKey: string }}
   */
  async setup (password) {
    // Read existing server identity (must exist)
    const identityData = JSON.parse(await fs.promises.readFile(this.identityFile, 'utf-8'))
    const devicePublicKey = Buffer.from(identityData.publicKey, 'hex')

    // Generate mnemonic
    const mnemonic = IdentityKey.generateMnemonic()

    // Create identity from mnemonic
    const identity = await IdentityKey.from({ mnemonic })

    // Bootstrap proof: identity → device
    const proof = await identity.bootstrap(devicePublicKey)

    // Sanity check: verify the proof
    const verified = IdentityKey.verify(proof, null, {
      expectedDevice: devicePublicKey
    })
    if (!verified) {
      identity.clear()
      throw new Error('Proof verification failed after bootstrap')
    }

    // Encrypt mnemonic to vault
    const mnemonicBuf = Buffer.from(mnemonic, 'utf-8')
    await this.vault.encrypt(mnemonicBuf, password)
    sodium.sodium_memzero(mnemonicBuf)

    // Store proof + identity public key in server-identity.json
    identityData.identityPublicKey = identity.identityPublicKey.toString('hex')
    identityData.proof = proof.toString('hex')
    identityData.proofCreatedAt = Date.now()

    await fs.promises.writeFile(this.identityFile, JSON.stringify(identityData, null, 2), {
      encoding: 'utf-8',
      mode: 0o600
    })

    const identityPublicKeyHex = identity.identityPublicKey.toString('hex')

    // Zero secrets
    identity.clear()

    return { mnemonic, identityPublicKey: identityPublicKeyHex }
  }

  /**
   * Setup from an existing mnemonic (used by device linking)
   * Creates a new proof chain for THIS device's key
   * @param {string} mnemonic - BIP39 mnemonic
   * @param {Buffer} password - Encryption password
   * @returns {{ identityPublicKey: string }}
   */
  async setupFromMnemonic (mnemonic, password) {
    // Read existing server identity
    const identityData = JSON.parse(await fs.promises.readFile(this.identityFile, 'utf-8'))
    const devicePublicKey = Buffer.from(identityData.publicKey, 'hex')

    // Create identity from mnemonic
    const identity = await IdentityKey.from({ mnemonic })

    // Bootstrap proof for this device
    const proof = await identity.bootstrap(devicePublicKey)

    // Verify
    const verified = IdentityKey.verify(proof, null, {
      expectedDevice: devicePublicKey
    })
    if (!verified) {
      identity.clear()
      throw new Error('Proof verification failed')
    }

    // Encrypt mnemonic to local vault
    const mnemonicBuf = Buffer.from(mnemonic, 'utf-8')
    await this.vault.encrypt(mnemonicBuf, password)
    sodium.sodium_memzero(mnemonicBuf)

    // Store proof + identity public key
    identityData.identityPublicKey = identity.identityPublicKey.toString('hex')
    identityData.proof = proof.toString('hex')
    identityData.proofCreatedAt = Date.now()

    await fs.promises.writeFile(this.identityFile, JSON.stringify(identityData, null, 2), {
      encoding: 'utf-8',
      mode: 0o600
    })

    const identityPublicKeyHex = identity.identityPublicKey.toString('hex')
    identity.clear()

    return { identityPublicKey: identityPublicKeyHex }
  }

  /**
   * Verify proof chain stored in server-identity.json
   * @returns {{ valid: boolean, identityPublicKey: string|null, devicePublicKey: string|null }}
   */
  verifyProof () {
    try {
      const data = JSON.parse(fs.readFileSync(this.identityFile, 'utf-8'))

      if (!data.proof) {
        return { valid: false, identityPublicKey: null, devicePublicKey: null }
      }

      const proof = Buffer.from(data.proof, 'hex')
      const devicePublicKey = Buffer.from(data.publicKey, 'hex')

      const result = IdentityKey.verify(proof, null, {
        expectedDevice: devicePublicKey
      })

      if (!result) {
        return { valid: false, identityPublicKey: null, devicePublicKey: null }
      }

      return {
        valid: true,
        identityPublicKey: result.identityPublicKey.toString('hex'),
        devicePublicKey: result.devicePublicKey.toString('hex')
      }
    } catch {
      return { valid: false, identityPublicKey: null, devicePublicKey: null }
    }
  }

  /**
   * Export mnemonic from vault (password required)
   * @param {Buffer} password
   * @returns {string} mnemonic
   */
  async exportMnemonic (password) {
    const plaintext = await this.vault.decrypt(password)
    const mnemonic = plaintext.toString('utf-8')
    sodium.sodium_memzero(plaintext)
    return mnemonic
  }

  /**
   * Change vault password
   * @param {Buffer} oldPassword
   * @param {Buffer} newPassword
   */
  async changePassword (oldPassword, newPassword) {
    await this.vault.changePassword(oldPassword, newPassword)
  }

  /**
   * Attest another device's public key using this identity
   * @param {Buffer} devicePublicKey - Public key of device to attest
   * @param {Buffer} password - Vault password (to recover mnemonic)
   * @returns {Buffer} Proof buffer for the new device
   */
  async attestDevice (devicePublicKey, password) {
    const plaintext = await this.vault.decrypt(password)
    const mnemonic = plaintext.toString('utf-8')
    sodium.sodium_memzero(plaintext)

    const identity = await IdentityKey.from({ mnemonic })
    const proof = await identity.bootstrap(devicePublicKey)
    identity.clear()

    return proof
  }
}

export default IdentityManager