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