OverviewFilesCommitsIssuesDocsReleasesPeers

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

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

/**
 * ChallengeManager
 *
 * Manages server-side authentication challenges with persistent storage:
 * - Generates random challenges
 * - Tracks challenge expiry
 * - Validates challenge freshness
 * - Prevents replay attacks
 * - Persistent state in Hyperbee
 */
export class ChallengeManager {
  constructor(options = {}) {
    this.ttl = options.ttl || 60 * 1000  // 60 seconds default (short to limit replay window)

    // Persistent storage (optional)
    this.db = options.db || null

    // In-memory cache for performance
    this.cache = new Map()
    this.cacheTimeout = options.cacheTimeout || 30 * 1000  // 30 seconds

    // Per-challenge locks to prevent TOCTOU race conditions
    this._locks = new Map()

    // Clean up expired challenges every minute
    this.cleanupInterval = setInterval(() => this.cleanup(), 60 * 1000)
  }

  /**
   * Acquire a per-challenge lock to prevent concurrent consume/verify.
   * Returns a release function.
   */
  async _acquireLock (challengeId) {
    while (this._locks.has(challengeId)) {
      await this._locks.get(challengeId)
    }
    let release
    const promise = new Promise(resolve => { release = resolve })
    this._locks.set(challengeId, promise)
    return () => {
      this._locks.delete(challengeId)
      release()
    }
  }

  /**
   * Generate new challenge
   * @param {Object} options - { context } for logging
   * @returns {Promise<Object>} { challengeId, challenge, expiresAt }
   */
  async generate(options = {}) {
    const challengeId = crypto.randomBytes(16).toString('hex')
    const challenge = crypto.randomBytes(32).toString('base64')
    const createdAt = Date.now()
    const expiresAt = createdAt + this.ttl

    const record = {
      challenge,
      createdAt,
      expiresAt,
      used: false,
      context: options.context,
      cachedAt: Date.now()
    }

    // Save to cache
    this.cache.set(challengeId, record)

    // Save to storage
    if (this.db) {
      try {
        const { cachedAt, ...data } = record
        await this.db.put(`challenge:${challengeId}`, data)
      } catch (err) {
        console.warn('[Challenge] Failed to save challenge:', err.message)
      }
    }

    return {
      challengeId,
      challenge,
      expiresAt
    }
  }

  /**
   * Get challenge record (without consuming)
   * @param {string} challengeId - Challenge ID
   * @returns {Promise<Object|null>} Challenge record or null
   */
  async get(challengeId) {
    const now = Date.now()

    // Try cache first
    let record = this.cache.get(challengeId)

    // Load from storage if not in cache or cache expired
    if (!record || (record.cachedAt && now - record.cachedAt > this.cacheTimeout)) {
      record = await this.loadChallenge(challengeId)
      if (record) {
        record.cachedAt = now
        this.cache.set(challengeId, record)
      }
    }

    if (!record) {
      return null
    }

    // Check expiry
    if (now > record.expiresAt) {
      await this.deleteChallenge(challengeId)
      return null
    }

    // Check if already used
    if (record.used) {
      return null
    }

    return record
  }

  /**
   * Consume challenge (mark as used)
   * @param {string} challengeId - Challenge ID
   * @returns {Promise<boolean>} True if consumed successfully
   */
  async consume(challengeId) {
    const release = await this._acquireLock(challengeId)
    try {
      let active = this.cache.get(challengeId)
      if (active && !active.used && Date.now() <= active.expiresAt) {
        active.used = true
        this.cache.set(challengeId, active)
      } else {
        active = await this.loadChallenge(challengeId)
        if (!active || active.used || Date.now() > active.expiresAt) return false
        active.used = true
        active.cachedAt = Date.now()
        this.cache.set(challengeId, active)
      }

      if (this.db) {
        try {
          const { cachedAt, ...data } = active
          await this.db.put(`challenge:${challengeId}`, data)
        } catch (err) {
          console.warn('[Challenge] Failed to mark challenge as used:', err.message)
        }
      }

      return true
    } finally {
      release()
    }
  }

  /**
   * Verify challenge is valid and not expired
   * @param {string} challengeId - Challenge ID
   * @param {string} challenge - Challenge string to verify
   * @returns {Promise<boolean>} True if valid and not expired
   */
  async verify(challengeId, challenge) {
    const release = await this._acquireLock(challengeId)
    try {
      const now = Date.now()

      let record = this.cache.get(challengeId)

      if (!record || (record.cachedAt && now - record.cachedAt > this.cacheTimeout)) {
        record = await this.loadChallenge(challengeId)
        if (record) {
          record.cachedAt = now
          this.cache.set(challengeId, record)
        }
      }

      if (!record) return false

      if (now > record.expiresAt) {
        await this.deleteChallenge(challengeId)
        return false
      }

      if (record.used) return false

      if (record.challenge !== challenge) return false

      // Mark as used atomically (under lock)
      record.used = true
      this.cache.set(challengeId, record)

      if (this.db) {
        try {
          const { cachedAt, ...data } = record
          await this.db.put(`challenge:${challengeId}`, data)
        } catch (err) {
          console.warn('[Challenge] Failed to mark challenge as used:', err.message)
        }
      }

      return true
    } finally {
      release()
    }
  }

  /**
   * Load challenge from storage
   * @param {string} challengeId - Challenge ID
   * @returns {Promise<Object|null>} Challenge record or null
   */
  async loadChallenge(challengeId) {
    if (!this.db) return null

    try {
      const entry = await this.db.get(`challenge:${challengeId}`)
      return entry?.value || null
    } catch (err) {
      console.warn('[Challenge] Failed to load challenge:', err.message)
      return null
    }
  }

  /**
   * Delete challenge from storage
   * @param {string} challengeId - Challenge ID
   * @returns {Promise<void>}
   */
  async deleteChallenge(challengeId) {
    // Remove from cache
    this.cache.delete(challengeId)

    // Remove from storage
    if (this.db) {
      try {
        await this.db.del(`challenge:${challengeId}`)
      } catch (err) {
        console.warn('[Challenge] Failed to delete challenge:', err.message)
      }
    }
  }

  /**
   * Cleanup expired challenges
   */
  async cleanup() {
    const now = Date.now()
    let cleaned = 0

    // Clean cache
    for (const [challengeId, record] of this.cache.entries()) {
      if (now > record.expiresAt) {
        this.cache.delete(challengeId)
        cleaned++
      }
    }

    // Clean storage
    if (this.db) {
      try {
        const toDelete = []

        for await (const entry of this.db.createReadStream({
          gte: 'challenge:',
          lt: 'challenge:\xff'
        })) {
          const record = entry.value
          if (record && now > record.expiresAt) {
            toDelete.push(entry.key)
          }
        }

        for (const key of toDelete) {
          await this.db.del(key)
          cleaned++
        }
      } catch (err) {
        console.warn('[Challenge] Cleanup error:', err.message)
      }
    }

    // Cleanup runs silently
  }

  /**
   * Destroy manager and stop cleanup
   */
  destroy() {
    clearInterval(this.cleanupInterval)
    this.cache.clear()
  }

  /**
   * Get stats
   */
  async stats() {
    let total = 0
    let active = 0
    let used = 0

    // Count from cache
    for (const record of this.cache.values()) {
      total++
      if (record.used) {
        used++
      } else {
        active++
      }
    }

    // Count from storage (if available)
    if (this.db) {
      try {
        for await (const entry of this.db.createReadStream({
          gte: 'challenge:',
          lt: 'challenge:\xff'
        })) {
          const challengeId = entry.key.replace('challenge:', '')

          // Skip if already counted in cache
          if (this.cache.has(challengeId)) continue

          total++
          if (entry.value.used) {
            used++
          } else {
            active++
          }
        }
      } catch (err) {
        console.warn('[Challenge] Stats error:', err.message)
      }
    }

    return {
      total,
      cached: this.cache.size,
      active,
      used
    }
  }
}