diff --git a/docs/agents/performance.md b/docs/agents/performance.md index 1688e8b..1341b92 100644 --- a/docs/agents/performance.md +++ b/docs/agents/performance.md @@ -21,8 +21,8 @@ ## Timing Attack Protection -- `tokensMatch()` enforces a **100ms floor** on token comparison to mask cache hit vs miss timing -- Each auth request holds a servlet thread for at least 100ms during token validation +- `tokensMatch()` applies **randomized jitter (80–120ms)** on token comparison to mask cache hit vs miss timing +- Each auth request holds a servlet thread for the jitter duration during token validation - **Thread pool sizing**: with a 200-thread pool, max theoretical auth throughput is ~2,000 req/s - Size `server.tomcat.threads.max` accordingly for auth-heavy workloads diff --git a/docs/agents/security.md b/docs/agents/security.md index ae57634..09329d8 100644 --- a/docs/agents/security.md +++ b/docs/agents/security.md @@ -25,6 +25,12 @@ - Tokens **BCrypt-hashed** before storage (never plaintext) - Token comparison results **cached** (Caffeine, 1hr) to reduce BCrypt overhead - Token prefix (8-char) enables **O(1) DB lookups** vs O(n) BCrypt scans +- **DUMMY_HASH** must be a valid 60-char BCrypt hash (not a short placeholder) so `BCryptPasswordEncoder.matches()` performs a full computation on the "user not found" path, preventing user enumeration via timing + +### Sub-token Validation + +- Fallback linear scan in `validateSubToken()` is bounded to `maxClients` entries to limit worst-case BCrypt comparisons +- Primary path (bearer with client ID) uses direct O(1) lookup via `getByUserIdAndClient()` ### Authentication Flow diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenService.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenService.kt index 85db53e..90b1a04 100644 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenService.kt +++ b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenService.kt @@ -33,6 +33,7 @@ import jakarta.servlet.http.HttpServletResponse import java.time.Instant import java.time.temporal.ChronoUnit import java.util.Base64 +import java.util.concurrent.ThreadLocalRandom import java.util.concurrent.TimeUnit import org.slf4j.LoggerFactory import org.springframework.beans.factory.ObjectProvider @@ -144,11 +145,11 @@ open class OgiriTokenService( val result = tokenEqualityCache.get(key) { passwordEncoder.matches(token, tokenHash) } - // Ensure minimum 100ms to mask cache hit vs miss timing + // Randomized jitter to mask cache hit vs miss timing without a fixed floor + val jitter = ThreadLocalRandom.current().nextLong(80_000_000L, 120_000_000L) val elapsed = System.nanoTime() - startTime - val minDelayNanos = 100_000_000L // 100ms - if (elapsed < minDelayNanos) { - Thread.sleep((minDelayNanos - elapsed) / 1_000_000) + if (elapsed < jitter) { + Thread.sleep((jitter - elapsed) / 1_000_000) } return result @@ -723,8 +724,11 @@ open class OgiriTokenService( companion object { private val logger = LoggerFactory.getLogger(OgiriTokenService::class.java) - // Pre-computed BCrypt hash for timing normalization - private const val DUMMY_HASH = "\$2a\$10\$dummyhashvalueforconstanttimecheck" + // Pre-computed valid BCrypt hash for constant-time comparison when user is not found. + // Must be a properly formatted 60-char BCrypt hash so that BCryptPasswordEncoder.matches() + // performs a full BCrypt computation rather than short-circuiting on pattern validation. + private const val DUMMY_HASH = + "\$2a\$10\$II9GOMND5IObe5IA9/7EXuflfP5U77aqrWyXCEXEFpXolIDmBbLmS" } @Transactional @@ -847,6 +851,7 @@ open class OgiriTokenService( repository .findByUserIdAndTokenSubtypeOrderByUpdatedAtDesc(user.getOgiriUserId(), subTokenName) .filter { OgiriTokenType.of(it.tokenType) == OgiriTokenType.SUB } + .take(maxClients.toInt()) return all.any { tokenMatches(it, tokenField) && registration.validate(tokenField) } }