OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity / lib / identity-manager.js

258 lines · javascript ref:
import process from '#node:process'
import crypto from 'hypercore-crypto'
import sodium from 'sodium-native'
import b4a from 'b4a'
import { join } from '#node:path'
import { SecureStorage } from './secure-storage.js'

/**
 * IdentityManager — Persistent identity keypairs + mnemonic proof chains
 *
 * SECURITY: Private keys and mnemonics are stored encrypted on the filesystem
 * using Argon2id + XSalsa20-Poly1305 (via SecureStorage). They are NEVER
 * written to Hyperbee or any replicable storage.
 *
 * Storage layout:
 *   {storagePath}/keypair.enc    — encrypted Ed25519 keypair (Argon2id vault)
 *   {storagePath}/mnemonic.enc   — encrypted BIP39 mnemonic (Argon2id vault)
 *
 * Passphrase sources (in priority order):
 *   1. opts.passphrase (Buffer or string)
 *   2. process.env.HYPER_IDENTITY_PASSPHRASE
 *   3. null → falls back to legacy Hyperbee storage (migration path)
 */
export class IdentityManager {
  constructor (store, opts = {}) {
    this._store = store // Hyperbee (for legacy migration reads only)
    this._storagePath = opts.storagePath || null
    this._passphrase = opts.passphrase
      ? (typeof opts.passphrase === 'string' ? Buffer.from(opts.passphrase) : opts.passphrase)
      : (process.env.HYPER_IDENTITY_PASSPHRASE
        ? Buffer.from(process.env.HYPER_IDENTITY_PASSPHRASE)
        : null)
    this._keypair = null
    this._mnemonic = null
    this._identityKey = null
    this._IdentityKey = opts.IdentityKey || null
    this._encrypted = false // Whether using encrypted storage
  }

  /**
   * Initialize — load or generate persistent keypair
   */
  async ready () {
    // Try encrypted storage first (if storagePath and passphrase are available)
    if (this._storagePath && this._passphrase) {
      await this._loadEncrypted()
      return
    }

    // Fall back to legacy Hyperbee storage (migration path)
    await this._loadLegacy()
  }

  /**
   * Load keys from encrypted vaults
   */
  async _loadEncrypted () {
    this._encrypted = true

    const keypairVault = new SecureStorage(join(this._storagePath, 'keypair.enc'))
    const mnemonicVault = new SecureStorage(join(this._storagePath, 'mnemonic.enc'))

    // Load or generate keypair
    if (keypairVault.exists()) {
      const plain = await keypairVault.decrypt(this._passphrase)
      const data = JSON.parse(plain.toString())
      this._keypair = {
        publicKey: b4a.from(data.publicKey, 'base64'),
        secretKey: b4a.from(data.secretKey, 'base64')
      }
      sodium.sodium_memzero(plain)
    } else {
      this._keypair = crypto.keyPair()
      const plain = Buffer.from(JSON.stringify({
        publicKey: b4a.toString(this._keypair.publicKey, 'base64'),
        secretKey: b4a.toString(this._keypair.secretKey, 'base64')
      }))
      await keypairVault.encrypt(plain, this._passphrase)
      sodium.sodium_memzero(plain)
    }

    // Load mnemonic if vault exists
    if (mnemonicVault.exists()) {
      const plain = await mnemonicVault.decrypt(this._passphrase)
      this._mnemonic = plain.toString()
      sodium.sodium_memzero(plain)
      await this._loadIdentityKey()
    }

    // Migrate from legacy Hyperbee if vaults didn't exist but Hyperbee has keys
    if (!keypairVault.exists()) {
      const existing = await this._store.get('instance-keypair')
      if (existing) {
        // Migrate: encrypt the Hyperbee keypair into vault, then delete from Hyperbee
        console.log('[IdentityManager] Migrating keypair from Hyperbee to encrypted vault')
        const val = existing.value || existing
        const encoding = (val.publicKey && val.publicKey.length === 44) ? 'base64' : 'hex'
        this._keypair = {
          publicKey: b4a.from(val.publicKey, encoding),
          secretKey: b4a.from(val.secretKey, encoding)
        }
        const plain = Buffer.from(JSON.stringify({
          publicKey: b4a.toString(this._keypair.publicKey, 'base64'),
          secretKey: b4a.toString(this._keypair.secretKey, 'base64')
        }))
        await keypairVault.encrypt(plain, this._passphrase)
        sodium.sodium_memzero(plain)
        // Delete from Hyperbee (no longer needed)
        await this._store.del('instance-keypair')
      }
    }
  }

  /**
   * Load keys from legacy Hyperbee storage (unencrypted)
   */
  async _loadLegacy () {
    this._encrypted = false

    const existing = await this._store.get('instance-keypair')
    if (existing) {
      const val = existing.value || existing
      const encoding = (val.publicKey && val.publicKey.length === 44) ? 'base64' : 'hex'
      this._keypair = {
        publicKey: b4a.from(val.publicKey, encoding),
        secretKey: b4a.from(val.secretKey, encoding)
      }
    } else {
      this._keypair = crypto.keyPair()
      await this._store.put('instance-keypair', {
        publicKey: b4a.toString(this._keypair.publicKey, 'base64'),
        secretKey: b4a.toString(this._keypair.secretKey, 'base64')
      })
    }

    const mnemonicEntry = await this._store.get('identity-mnemonic')
    if (mnemonicEntry) {
      this._mnemonic = mnemonicEntry.value
      await this._loadIdentityKey()
    }
  }

  async _loadIdentityKey () {
    if (!this._IdentityKey || !this._mnemonic) return
    try {
      this._identityKey = this._IdentityKey.from({ mnemonic: this._mnemonic })
    } catch {
      this._identityKey = null
    }
  }

  getPublicKeyHex () {
    if (!this._keypair) return null
    return b4a.toString(this._keypair.publicKey, 'hex')
  }

  getPublicKey () {
    return this._keypair ? this._keypair.publicKey : null
  }

  getKeypair () {
    return this._keypair
  }

  /**
   * Set mnemonic and derive identity key
   * @param {string} mnemonic - BIP39 mnemonic
   */
  async setMnemonic (mnemonic) {
    this._mnemonic = mnemonic

    if (this._encrypted && this._storagePath && this._passphrase) {
      // Store encrypted
      const vault = new SecureStorage(join(this._storagePath, 'mnemonic.enc'))
      const plain = Buffer.from(mnemonic)
      await vault.encrypt(plain, this._passphrase)
      sodium.sodium_memzero(plain)
    } else {
      // Legacy: store in Hyperbee (unencrypted)
      await this._store.put('identity-mnemonic', mnemonic)
    }

    await this._loadIdentityKey()
  }

  getMnemonic () {
    return this._mnemonic
  }

  getIdentityKey () {
    return this._identityKey
  }

  async bootstrap (devicePublicKey) {
    if (!this._identityKey) throw new Error('No identity key loaded')
    return this._identityKey.bootstrap(devicePublicKey)
  }

  sign (data) {
    if (!this._keypair) throw new Error('Not initialized')
    return crypto.sign(data, this._keypair.secretKey)
  }

  verify (data, signature) {
    if (!this._keypair) throw new Error('Not initialized')
    return crypto.verify(data, signature, this._keypair.publicKey)
  }

  getPublicKeyBase64 () {
    if (!this._keypair) return null
    return b4a.toString(this._keypair.publicKey, 'base64')
  }

  getKeypairString () {
    if (!this._keypair) return null
    return b4a.toString(this._keypair.publicKey, 'base64') +
           b4a.toString(this._keypair.secretKey, 'base64')
  }

  getIdentityPublicKey () {
    if (!this._identityKey) return null
    return this._identityKey.publicKey
  }

  getIdentityPublicKeyHex () {
    const pk = this.getIdentityPublicKey()
    return pk ? b4a.toString(pk, 'hex') : null
  }

  /**
   * Whether using encrypted storage
   */
  get isEncrypted () {
    return this._encrypted
  }

  /**
   * Zero sensitive key material from memory
   */
  destroy () {
    if (this._keypair && this._keypair.secretKey) {
      sodium.sodium_memzero(this._keypair.secretKey)
    }
    if (this._identityKey && typeof this._identityKey.clear === 'function') {
      this._identityKey.clear()
    }
    if (this._passphrase) {
      sodium.sodium_memzero(this._passphrase)
    }
    this._keypair = null
    this._identityKey = null
    this._mnemonic = null
    this._passphrase = null
  }
}

export default IdentityManager