OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity / lib / auth / challenge-manager.js

132 lines · javascript ref:
import crypto from 'hypercore-crypto'
import b4a from 'b4a'

/**
 * ChallengeManager — Challenge-response authentication
 *
 * Generates time-limited challenges for signature-based auth.
 * Supports persistent storage and in-memory caching.
 */
export class ChallengeManager {
  constructor (opts = {}) {
    this.db = opts.db || null
    this.ttl = opts.ttl || 5 * 60 * 1000 // 5 minutes default
    this.cacheTimeout = opts.cacheTimeout || 30 * 1000 // 30 seconds
    this._cache = new Map()
    this._cleanupInterval = setInterval(() => this._cleanup(), this.ttl)
  }

  /**
   * Generate a new challenge
   * @param {Object} opts - { publicKey?, metadata? }
   * @returns {Promise<Object>} { challengeId, challenge, expiresAt }
   */
  async generate (opts = {}) {
    const challengeId = b4a.toString(crypto.randomBytes(16), 'hex')
    const challenge = b4a.toString(crypto.randomBytes(32), 'base64')
    const expiresAt = Date.now() + this.ttl

    const entry = {
      challengeId,
      challenge,
      expiresAt,
      publicKey: opts.publicKey || null,
      metadata: opts.metadata || null,
      consumed: false
    }

    // Store persistently
    if (this.db) {
      await this.db.put(`challenge:${challengeId}`, entry)
    }

    // Cache in memory
    this._cache.set(challengeId, entry)

    return { challengeId, challenge, expiresAt }
  }

  /**
   * Get a challenge by ID (without consuming it)
   * @param {string} challengeId
   * @returns {Promise<Object|null>} Challenge entry or null if expired/consumed
   */
  async get (challengeId) {
    let entry = this._cache.get(challengeId)

    if (!entry && this.db) {
      const stored = await this.db.get(`challenge:${challengeId}`)
      if (stored) entry = stored.value
    }

    if (!entry) return null
    if (entry.consumed) return null
    if (entry.expiresAt < Date.now()) return null

    return entry
  }

  /**
   * Consume a challenge (mark as used, single-use)
   * @param {string} challengeId
   */
  async consume (challengeId) {
    const entry = this._cache.get(challengeId)
    if (entry) {
      entry.consumed = true
      this._cache.set(challengeId, entry)
    }
    if (this.db) {
      const stored = await this.db.get(`challenge:${challengeId}`)
      if (stored) {
        stored.value.consumed = true
        await this.db.put(`challenge:${challengeId}`, stored.value)
      }
    }
  }

  /**
   * Verify and consume a challenge (combined operation)
   * @param {string} challengeId - Challenge ID
   * @param {string} challenge - Challenge value to verify
   * @returns {Promise<Object|null>} Challenge entry or null
   */
  async verify (challengeId, challenge) {
    const entry = await this.get(challengeId)
    if (!entry) return null
    if (entry.challenge !== challenge) return null

    await this.consume(challengeId)
    return entry
  }

  /**
   * Cleanup expired challenges
   */
  async _cleanup () {
    const now = Date.now()
    for (const [id, entry] of this._cache) {
      if (entry.expiresAt < now || entry.consumed) {
        this._cache.delete(id)
      }
    }
  }

  /**
   * Get stats
   */
  async stats () {
    return {
      cached: this._cache.size,
      active: [...this._cache.values()].filter(e => !e.consumed && e.expiresAt > Date.now()).length
    }
  }

  destroy () {
    clearInterval(this._cleanupInterval)
    this._cache.clear()
  }
}

export default ChallengeManager