/**
* 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()
}
}