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 || 5 * 60 * 1000 // 5 minutes default
// 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
// Clean up expired challenges every minute
this.cleanupInterval = setInterval(() => this.cleanup(), 60 * 1000)
}
/**
* 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) {
// Atomically check and mark as used to prevent race conditions
let active = this.cache.get(challengeId)
if (active && !active.used && Date.now() <= active.expiresAt) {
active.used = true
this.cache.set(challengeId, active)
} else {
// Fall back to storage lookup
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)
}
// Update in storage
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
}
/**
* 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 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) {
console.warn('[Challenge] Unknown challenge:', challengeId)
return false
}
// Check expiry
if (now > record.expiresAt) {
console.warn('[Challenge] Expired challenge:', challengeId)
await this.deleteChallenge(challengeId)
return false
}
// Check if already used (prevent replay)
if (record.used) {
console.warn('[Challenge] Challenge already used:', challengeId)
return false
}
// Verify challenge matches
if (record.challenge !== challenge) {
console.warn('[Challenge] Challenge mismatch:', challengeId)
return false
}
// Mark as used
record.used = true
this.cache.set(challengeId, record)
// Update in storage
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
}
/**
* 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)
}
}
if (cleaned > 0) {
console.log(`[Challenge] Cleaned up ${cleaned} expired challenges`)
}
}
/**
* 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
}
}
}