OverviewFilesCommitsIssuesDocsReleasesPeers

anproto / lib / challenges.js

94 lines · javascript ref:
/**
 * Challenges — in-memory replay protection helper for ANProto login flows
 *
 * Not part of canonical ANProto. A small helper for HTTP login flows where
 * the server issues a challenge string, the client signs hash(challenge),
 * and the server consumes the challenge to prevent replay.
 *
 * Usage:
 *   const challenges = new Challenges({ ttl: 5 * 60 * 1000 })
 *   const challenge = challenges.generate()      // server issues
 *   // ... browser signs hash(challenge), POSTs sig ...
 *   if (!challenges.consume(challenge)) reject() // server consumes (single-use)
 */

import crypto from 'hypercore-crypto'
import b4a from 'b4a'

const DEFAULT_TTL = 5 * 60 * 1000 // 5 minutes
const DEFAULT_CLEANUP_INTERVAL = 60 * 1000 // 1 minute

export class Challenges {
  constructor (opts = {}) {
    this.ttl = opts.ttl || DEFAULT_TTL
    this._active = new Map() // challenge string → expiresAt
    this._cleanupTimer = setInterval(() => this._cleanup(), opts.cleanupInterval || DEFAULT_CLEANUP_INTERVAL)
    if (this._cleanupTimer.unref) this._cleanupTimer.unref()
  }

  /**
   * Issue a new challenge string. The caller sends this to the client; the
   * client signs hash(challenge) and returns the signature.
   * @returns {string} 44-char base64 random challenge
   */
  generate () {
    const challenge = b4a.toString(crypto.randomBytes(32), 'base64')
    this._active.set(challenge, Date.now() + this.ttl)
    return challenge
  }

  /**
   * Consume a challenge. Returns true if the challenge was active and not
   * yet consumed; false otherwise. Single-use — calling consume() removes
   * the challenge from the active set.
   * @param {string} challenge
   * @returns {boolean}
   */
  consume (challenge) {
    const expiresAt = this._active.get(challenge)
    if (expiresAt === undefined) return false
    if (expiresAt < Date.now()) {
      this._active.delete(challenge)
      return false
    }
    this._active.delete(challenge)
    return true
  }

  /**
   * Check if a challenge is active without consuming it.
   * @param {string} challenge
   * @returns {boolean}
   */
  has (challenge) {
    const expiresAt = this._active.get(challenge)
    if (expiresAt === undefined) return false
    return expiresAt >= Date.now()
  }

  /**
   * Number of active (unexpired) challenges.
   */
  get size () {
    return this._active.size
  }

  _cleanup () {
    const now = Date.now()
    for (const [challenge, expiresAt] of this._active) {
      if (expiresAt < now) this._active.delete(challenge)
    }
  }

  /**
   * Stop the cleanup timer. Call when shutting down.
   */
  destroy () {
    if (this._cleanupTimer) {
      clearInterval(this._cleanupTimer)
      this._cleanupTimer = null
    }
    this._active.clear()
  }
}