1.9.0
Compatibility
Changes
[1.9.0] - 2026-05-15 โ Async Auth, Session Timeout & Security Hardening
๐ Bug Fixes
FIX [Critical] โ PBKDF2 verification was running on the main server thread
- Root cause:
handlePrivilegeCommand()calledPasswordHasher.verify()synchronously duringonCommand(), which executes on the main thread. With 120,000 PBKDF2 iterations, each failed attempt caused a measurable TPS drop (~50โ200 ms). Under coordinated brute-force from multiple clients this could stall the server tick loop. - Fix: Password verification is now dispatched via
Bukkit.getScheduler().runTaskAsynchronously(). The result is delivered back to the main thread viarunTask()for all Bukkit API side-effects. Zero blocking on the main thread. - New status field:
/opshield statusnow shows pending verification count (0/4).
FIX [Critical] โ No session timeout after successful authentication
- Root cause: After a correct password was accepted, no state was stored. Every subsequent
/opor/deoprequired re-entering the password. In practice admins worked around this by keeping a note of the password nearby, which is a security anti-pattern. - Fix: A
ConcurrentHashMap<UUID, Long>(authenticatedSessions) tracks expiry epoch-ms per player. After the first successful auth within a session window, password entry is skipped. - Config key:
security.session_timeout_minutes(default30; set0to disable). - Session lifecycle: granted on successful auth โ cleared on player quit (
PlayerQuitEvent) โ cleared on manual/opshield unlockโ purged by cleanup task.
FIX [High] โ No global rate limit on PBKDF2 operations
- Root cause: A coordinated attack with many accounts spamming
/op <name> <guess>queued unbounded async hashing tasks, saturating CPU cores. - Fix:
AtomicInteger globalAuthCountcaps server-wide concurrent PBKDF2 operations atMAX_CONCURRENT_AUTH = 4. Requests over the cap receiveauth_server_busyand are dropped without queuing. - Per-sender dedup:
Map<String, AtomicInteger> authPendingprevents the same player from queuing more than one verification at a time. Excess requests receiveauth_pending.
FIX [High] โ cleanupRuntimeState() ran on the main thread
- Root cause:
Bukkit.getScheduler().runTaskTimer()was used instead ofrunTaskTimerAsynchronously(). The cleanup method holds synchronized blocks onLockoutRecordobjects; under load this briefly stalled the tick. - Fix: Scheduler call changed to
runTaskTimerAsynchronously(). All collections involved areConcurrentHashMap/ConcurrentLinkedQueueโ safe for async access.
FIX [Medium] โ Shutdown race condition between async periodic flush and onDisable sync flush
- Root cause:
onDisable()calledflushPersistentDataSync()while the 200-tick async flush task could be mid-execution. AlthoughcompareAndSetprevented double saves, the two threads could both have a reference todataFileand compete on the sameYamlConfigurationserialisation. - Fix:
onDisable()now cancels both async task IDs (periodicFlushTaskId,cleanupTaskId) before callingflushPersistentDataSync(), ensuring only one flush can run at shutdown.
FIX [Medium] โ ShadowBanManager used HashMap for commandโmessage mapping
- Root cause:
HashMaphas undefined iteration order. Commands likedeop op,ban-ip, orgive opcould match the wrong message key depending on JVM run, leading to non-deterministic fake responses. - Fix: Replaced with
LinkedHashMappreserving explicit insertion order. More specific and longer tokens (e.g."inventory","teleport","deop") are declared before shorter substrings ("clear","tp","op") so prefix-matching is always predictable.
FIX [Low] โ config-version field was written but never read
- Root cause:
config-version: 2was introduced in v1.8.0 butreloadConfiguration()never checked it. There was no warning when admins ran an old config against a new build. - Fix:
reloadConfiguration()now comparesconfig-versionagainstEXPECTED_CONFIG_VERSION = 3. It logs a warning if the file is outdated (new options missing โ defaults used) or from a newer build (unknown keys may be ignored). The version is bumped in-place so the warning fires only once.
FIX [Low] โ PlayerQuitEvent not handled; stale session UUIDs accumulated
- Root cause:
authenticatedSessionswas new in this version, but without a quit handler a UUID could remain in the map until the cleanup task ran, creating a window where a reconnecting player could bypass auth. - Fix:
@EventHandler onPlayerQuit()immediately removes the UUID fromauthenticatedSessionson disconnect.
โจ New Features
Authenticated Session Tokens
- First successful
/opor/deopauthentication grants a timed session. - Subsequent privilege commands within the window skip password entry.
- Session duration:
security.session_timeout_minutes(default30;0= always require password). - Sessions are cleared on disconnect, manual unlock, or config reload.
- New language keys:
auth_session_granted,auth_pending,auth_server_busy.
Async PBKDF2 Authentication Pipeline
- All password verifications now run asynchronously.
- Bukkit API side-effects (setOp, messages, broadcasts) still execute on the main thread via callback.
- Extracted
completePrivilegeCommand()to consolidate post-auth logic (hash upgrade, session grant, lockout clear, setOp, broadcast).
Enhanced /opshield status Output
Two new status lines:
Active auth sessions: 2
Pending PBKDF2 verifications: 0/4
Session timeout: 30 min
๐ง Configuration Changes
| Key | Change | Default |
|---|---|---|
config-version |
Bumped 2 โ 3 |
3 |
security.session_timeout_minutes |
NEW โ auth session TTL in minutes; 0 = disabled |
30 |
No existing keys were removed or renamed. Old config-version: 2 files are read normally with a one-time console warning.
๐ Language Files
Three new message keys added to all language files (en, vn, ru):
| Key | Purpose |
|---|---|
auth_pending |
Sender already has a PBKDF2 verification in flight |
auth_server_busy |
Server-wide auth cap reached |
auth_session_granted |
Confirmation message shown after session is created |
๐ Code Quality Metrics
| Metric | v1.8.0 | v1.9.0 |
|---|---|---|
| PBKDF2 on main thread | โ yes | โ no (async) |
| Session timeout | โ | โ |
| Global auth rate limit | โ | โ (cap=4) |
| Per-sender auth dedup | โ | โ |
| Cleanup on main thread | โ yes | โ no (async) |
| Shutdown race condition | โ present | โ fixed |
| ShadowBan match order | non-deterministic | deterministic |
config-version enforced |
โ | โ |
PlayerQuitEvent handled |
โ | โ |
๐ Migration Notes from v1.8.0
- No breaking changes โ existing
config.ymlanddata.ymlare fully compatible. - New config key โ add
security.session_timeout_minutes: 30(or let the default apply on first reload). - config-version is auto-bumped from
2โ3on first reload; a one-time warning will appear in console. - Permission setup is unchanged from v1.8.0.
๐ฎ Planned for v1.10.0
- Extract
AutoPunishmentManagerโ move all ban/kick/firewall logic out ofOPShield.java - Extract
IpLimitManagerโ move IP tracking and flagging - Extract
CommandRestrictionManager - Unit tests for
LockoutManagerandShadowBanManager - Consider Argon2id as an optional stronger hashing algorithm
Projects on Modrinth are automatically available through a Maven repository for use with JVM build tools such as Gradle. To learn more about the Modrinth Maven API, click here.
Note: When available, you should use the creator's maven repo instead as it will have transitive dependency information that the Modrinth Maven API does not. You may also end up with duplicate dependencies if you use a mix of Modrinth and non-Modrinth Maven repositories for your dependencies, because the group identifier will be different when served through the Modrinth Maven API.
Maven coordinates:
Version ID:
build.gradle:
repositories {
exclusiveContent {
forRepository {
maven {
name = "Modrinth"
url = "https://api.modrinth.com/maven"
}
}
// forRepositories(fg.repository) // Uncomment when using ForgeGradle
filter {
includeGroup "maven.modrinth"
}
}
}
// Standard Gradle dependency
dependencies {
implementation "maven.modrinth:Bv67n40d:sLAIt2FX"
}
// Legacy Loom dependency
dependencies {
modImplementation "maven.modrinth:Bv67n40d:sLAIt2FX"
}

