OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / p2p / identity / server-identity.js

264 lines · javascript ref:
/**
 * Server Identity Manager for Pear-Git P2P
 *
 * Manages the server's persistent keypair used for:
 * - Deriving P2P topic hashes (topic = hash(serverKey + repoName))
 * - Identifying the server in pear:// URLs
 * - Signing P2P protocol messages (future)
 *
 * The server key is stored persistently so that pear:// URLs remain stable.
 */

import fs from 'bare-fs'
import path from 'bare-path'
import crypto from 'hypercore-crypto'
import bip39 from 'bip39-mnemonic'

const { entropyToMnemonic, mnemonicToEntropy, validateMnemonic } = bip39

export class ServerIdentity {
  constructor(options = {}) {
    this.storagePath = options.storagePath
    this.keyFile = path.join(this.storagePath, 'server-identity.json')
    this.publicKey = null
    this.secretKey = null
    this._explicitStorage = options.explicitStorage || false
  }

  /**
   * Initialize server identity
   * Loads existing keypair or generates new one
   */
  async init() {
    // Ensure storage directory exists
    try {
      await fs.promises.mkdir(this.storagePath, { recursive: true })
    } catch (err) {
      if (err.code !== 'EEXIST') throw err
    }

    // Try to load existing identity
    try {
      const data = await fs.promises.readFile(this.keyFile, 'utf-8')
      const identity = JSON.parse(data)

      this.publicKey = Buffer.from(identity.publicKey, 'hex')
      this.secretKey = Buffer.from(identity.secretKey, 'hex')

      // Verify file permissions (should be 0600, not world-readable)
      try {
        const stat = await fs.promises.stat(this.keyFile)
        const mode = stat.mode & 0o777
        if (mode & 0o077) {
          console.warn(`[Identity] WARNING: ${this.keyFile} has mode ${mode.toString(8)}, fixing to 0600`)
          await fs.promises.chmod(this.keyFile, 0o600)
        }
      } catch {}

      console.log(`[Identity] Loaded server key: ${this.getPublicKeyHex().slice(0, 8)}...`)
    } catch (err) {
      if (err.code !== 'ENOENT') throw err

      // Generate new identity
      await this._generateIdentity()
    }
  }

  /**
   * Get public key as hex string (for URLs)
   */
  getPublicKeyHex() {
    return this.publicKey.toString('hex')
  }

  /**
   * Get public key as buffer (for crypto operations)
   */
  getPublicKeyBuffer() {
    return this.publicKey
  }

  /**
   * Derive a topic hash for a repository
   * topic = hash(serverPublicKey + repoName)
   *
   * @param {string} repoName - Repository name
   * @returns {Buffer} - 32-byte topic hash
   */
  deriveTopic(repoName) {
    return crypto.hash(Buffer.concat([
      this.publicKey,
      Buffer.from(repoName, 'utf-8')
    ])).subarray(0, 32)
  }

  /**
   * Derive topic from any key (for clients)
   * @param {string|Buffer} serverKey - Server public key
   * @param {string} repoName - Repository name
   * @returns {Buffer} - 32-byte topic hash
   */
  static deriveTopicFromKey(serverKey, repoName) {
    const keyBuffer = typeof serverKey === 'string'
      ? Buffer.from(serverKey, 'hex')
      : serverKey

    return crypto.hash(Buffer.concat([
      keyBuffer,
      Buffer.from(repoName, 'utf-8')
    ])).subarray(0, 32)
  }

  /**
   * Sign data with server secret key
   * @param {Buffer|string} data - Data to sign
   * @returns {Buffer} - Signature
   */
  sign(data) {
    const dataBuffer = typeof data === 'string' ? Buffer.from(data) : data
    return crypto.sign(dataBuffer, this.secretKey)
  }

  /**
   * Verify a signature from this server
   * @param {Buffer|string} data - Original data
   * @param {Buffer|string} signature - Signature to verify
   * @returns {boolean}
   */
  verify(data, signature) {
    const dataBuffer = typeof data === 'string' ? Buffer.from(data) : data
    const sigBuffer = typeof signature === 'string' ? Buffer.from(signature, 'hex') : signature
    return crypto.verify(dataBuffer, sigBuffer, this.publicKey)
  }

  /**
   * Export identity as 24-word BIP39 mnemonic
   * Encodes the first 32 bytes (seed) of the secret key
   * @returns {string} - 24-word mnemonic
   */
  toMnemonic () {
    if (!this.secretKey) throw new Error('No server identity loaded')
    const seed = this.secretKey.subarray(0, 32)
    return entropyToMnemonic(seed)
  }

  /**
   * Restore identity from a BIP39 mnemonic
   * Validates mnemonic, recovers seed, generates keypair, saves to disk
   * @param {string} mnemonic - 24-word BIP39 mnemonic
   */
  async initFromMnemonic (mnemonic) {
    if (!validateMnemonic(mnemonic)) {
      throw new Error('Invalid BIP39 mnemonic')
    }

    const seed = Buffer.from(mnemonicToEntropy(mnemonic))
    const keypair = crypto.keyPair(seed)

    this.publicKey = keypair.publicKey
    this.secretKey = keypair.secretKey

    await fs.promises.mkdir(this.storagePath, { recursive: true })
    const identity = {
      publicKey: this.publicKey.toString('hex'),
      secretKey: this.secretKey.toString('hex'),
      createdAt: Date.now(),
      restoredFromMnemonic: true
    }
    await fs.promises.writeFile(
      this.keyFile,
      JSON.stringify(identity, null, 2),
      { encoding: 'utf-8', mode: 0o600 }
    )

    console.log('[Identity] Restored from mnemonic: ' + this.getPublicKeyHex().slice(0, 8) + '...')
  }

  /**
   * Verify a signature from any server
   * @param {Buffer|string} data - Original data
   * @param {Buffer|string} signature - Signature
   * @param {Buffer|string} publicKey - Server public key
   * @returns {boolean}
   */
  static verifySignature(data, signature, publicKey) {
    const dataBuffer = typeof data === 'string' ? Buffer.from(data) : data
    const sigBuffer = typeof signature === 'string' ? Buffer.from(signature, 'hex') : signature
    const keyBuffer = typeof publicKey === 'string' ? Buffer.from(publicKey, 'hex') : publicKey
    return crypto.verify(dataBuffer, sigBuffer, keyBuffer)
  }

  /**
   * Export identity info (for display, NOT including secret key)
   */
  toJSON() {
    return {
      publicKey: this.getPublicKeyHex(),
      shortKey: this.getPublicKeyHex().slice(0, 8) + '...'
    }
  }

  /**
   * Get Keet identity proof status
   * @returns {{ hasProof: boolean, identityPublicKey: string|null }}
   */
  getProofStatus () {
    try {
      const data = JSON.parse(fs.readFileSync(this.keyFile, 'utf-8'))
      return {
        hasProof: !!data.proof,
        identityPublicKey: data.identityPublicKey || null
      }
    } catch {
      return { hasProof: false, identityPublicKey: null }
    }
  }

  // === Private Methods ===

  async _generateIdentity() {
    // Safety: check if an identity already exists at the locator path.
    // If the locator points to a DIFFERENT path with a valid identity,
    // abort to prevent creating a duplicate identity (Issue #29).
    // Skip this check when storage was explicitly provided (--storage flag
    // or programmatic option) — the caller intentionally wants a separate instance.
    if (!this._explicitStorage) {
      const { StorageLocator } = await import('../../storage-locator.js')
      const locatorPath = StorageLocator.readRaw()
      const thisDir = path.resolve(path.dirname(this.keyFile))

      if (locatorPath && path.resolve(locatorPath) !== thisDir) {
        try {
          const locatorIdentity = path.join(locatorPath, 'server-identity.json')
          fs.statSync(locatorIdentity)
          throw new Error(
            `[Identity] ABORT: Existing identity found at ${locatorPath}\n` +
            `  Current storage: ${this.storagePath}\n` +
            `  Use --storage=${locatorPath} to use the existing identity`
          )
        } catch (err) {
          if (err.message.startsWith('[Identity] ABORT')) throw err
          // Old locator path has no identity — safe to generate
        }
      }
    }

    // Only generate if truly first run
    const keypair = crypto.keyPair()
    this.publicKey = keypair.publicKey
    this.secretKey = keypair.secretKey

    // Save to disk
    const identity = {
      publicKey: this.publicKey.toString('hex'),
      secretKey: this.secretKey.toString('hex'),
      createdAt: Date.now()
    }

    await fs.promises.writeFile(this.keyFile, JSON.stringify(identity, null, 2), { encoding: 'utf-8', mode: 0o600 })
    console.log(`[Identity] Generated new server key: ${this.getPublicKeyHex().slice(0, 8)}...`)
  }
}

export default ServerIdentity