Add Minecraft Education Edition support#6251
Add Minecraft Education Edition support#6251SendableMetatype wants to merge 6 commits intoGeyserMC:masterfrom
Conversation
- Add education config subsection with tenancy-mode field (OFF/OFFICIAL/HYBRID/STANDALONE, default OFF) - Add EducationTenancyMode enum for compile-time safety - Add education fields to BedrockClientData: EduTokenChain, EduJoinerToHostNonce, EduSessionToken, IsEduMode, AdRole - Add education fields to BedrockData for Floodgate handshake - Add config version 8 migration - Adjust ConfigLoaderTest for education section defaults
- Add EducationAuthManager: OAuth 2.0 device code flow, MESS server registration and hosting, periodic heartbeats, token refresh, tenant token pool with config-trust tracking, session persistence - Add EducationChainVerifier: extracts tenant ID from EduTokenChain JWT, decodes and dumps chain data for debug logging, padBase64 utility for base64url-to-base64 conversion - Generate edu_official.yml and edu_standalone.yml on startup with comments and paste slots; template methods prevent comment drift - Add nonce cache with on-demand MESS fetch_joiner_info verification (3-second timeout, 30-minute accumulating cache) - Add standalone token acquisition via /geyser edu token command: device code flow, auto-refresh, re-auth on failure - Support multiple tokens per tenant with newest-first routing - Warn on startup for tokens expiring within 3 days - Education init gated on tenancy-mode != OFF - Initialize and shut down auth manager from GeyserImpl lifecycle
- Add EducationCodecProcessor: wraps base codec with education-specific overrides, re-enabling education packets (chemistry, NPCs, photos, Code Builder) as IGNORED instead of ILLEGAL for edu sessions - Add EducationStartGameSerializer: appends 3 edu-specific string fields (eduSharedUri resource/buttonName/linkUri) to StartGamePacket - Mark education packets as ILLEGAL in base CodecProcessor so they disconnect non-education clients that send them
- Add education login path in LoginEncryptionUtils: tenant extraction, nonce verification (official/hybrid/standalone), education handshake JWT with signedToken claim, chain dump for debug logging - Reject education clients with clear message when tenancy mode is OFF - Add education session fields to GeyserSession: educationClient, educationTenantId, nonceVerified flags - Swap to education codec and send EducationSettingsPacket for edu clients during session initialization - Add education UUID generation in GeyserSessionAdapter using deterministic tenant+username hash with fixed MSB - Add duplicate education session check in SessionManager - Add edu-specific STOP_BREAK threshold in BlockBreakHandler
- Add EduCommand with status/players/token/reset subcommands - Status shows mode-aware display: tenant table with token health, Server ID/IP for official/hybrid only, device-code token count - Register edu command in CommandRegistry (permission: geyser.command.edu) - Add EducationAuthManagerTest: tenant extraction, config trust, nonce cache, join counters, formatExpiry, isActive (17 tests) - Add EducationChainVerifierTest: padBase64 edge cases (8 tests) - Add EducationUuidTest: determinism, collision resistance, MSB validation, input sensitivity (12 tests)
There was a problem hiding this comment.
Pull request overview
Adds first-class Minecraft Education Edition (MCEE) connectivity to Geyser by detecting Education clients during login, performing tenant-based authentication/verification, and applying an Education-specific Bedrock codec override (notably StartGame serialization) so Education clients remain connected and playable.
Changes:
- Add Education tenancy/auth flow (MESS registration + standalone tokens), tenant routing, and
/geyser edumanagement command. - Detect Education clients via client-data JWT, bypass Xbox chain signing checks for edu, inject
signedTokeninto the encryption handshake, and perform tenant/nonce verification depending on tenancy mode. - Use an Education-specific codec/StartGame serializer and adjust session/game rules and Floodgate data to support edu identities (tenant + role) and stable UUIDs.
Reviewed changes
Copilot reviewed 24 out of 24 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| core/src/test/java/org/geysermc/geyser/session/EducationUuidTest.java | Tests deterministic UUID generation for Education identities. |
| core/src/test/java/org/geysermc/geyser/network/EducationChainVerifierTest.java | Tests Base64URL padding helper used for JWT decoding. |
| core/src/test/java/org/geysermc/geyser/network/EducationAuthManagerTest.java | Tests tenant extraction, trust separation, expiry formatting, and counters in auth manager. |
| core/src/test/java/org/geysermc/geyser/configuration/ConfigLoaderTest.java | Adjusts migration comparison to ignore newly added education section for older configs. |
| core/src/main/java/org/geysermc/geyser/util/LoginEncryptionUtils.java | Education client detection, tenancy gating, nonce/config-trust verification, and edu handshake JWT with signedToken. |
| core/src/main/java/org/geysermc/geyser/session/cache/BlockBreakHandler.java | Adds Education-specific block-break action handling (CONTINUE_BREAK/STOP_BREAK). |
| core/src/main/java/org/geysermc/geyser/session/auth/BedrockClientData.java | Adds Education client-data fields (IsEduMode, tenant, AD role, nonce/token chain). |
| core/src/main/java/org/geysermc/geyser/session/SessionManager.java | Adds duplicate-session detection for Education players based on tenant+username (not XUID). |
| core/src/main/java/org/geysermc/geyser/session/GeyserSessionAdapter.java | Extends Floodgate payload with edu fields and generates stable UUIDs for edu Floodgate sessions. |
| core/src/main/java/org/geysermc/geyser/session/GeyserSession.java | Stores edu session state; sets edu StartGame fields, experiments, gamerules; swaps codec and sends EducationSettingsPacket. |
| core/src/main/java/org/geysermc/geyser/network/UpstreamPacketHandler.java | Uses tenant+username duplicate checks for Education clients; keeps XUID checks for others. |
| core/src/main/java/org/geysermc/geyser/network/EducationTenancyMode.java | Defines tenancy modes (OFF/OFFICIAL/HYBRID/STANDALONE). |
| core/src/main/java/org/geysermc/geyser/network/EducationStartGameSerializer.java | Appends/consumes the three Education-only LevelSettings strings to fix silent disconnect. |
| core/src/main/java/org/geysermc/geyser/network/EducationCodecProcessor.java | Builds edu codec wrapper: StartGame serializer override + ignores edu-only packets instead of disconnecting. |
| core/src/main/java/org/geysermc/geyser/network/EducationChainVerifier.java | Adds debug utilities to dump Education JWT chains for troubleshooting. |
| core/src/main/java/org/geysermc/geyser/network/EducationAuthManager.java | Implements MESS registration, token acquisition/refresh, tenant token pool, and nonce verification. |
| core/src/main/java/org/geysermc/geyser/network/CodecProcessor.java | Clarifies comment that edu packets are ILLEGAL in base codec and re-enabled for edu sessions only. |
| core/src/main/java/org/geysermc/geyser/configuration/GeyserConfig.java | Adds education config section and tenancy-mode config binding. |
| core/src/main/java/org/geysermc/geyser/configuration/ConfigMigrations.java | Bumps config version to introduce education section defaults. |
| core/src/main/java/org/geysermc/geyser/command/defaults/EduCommand.java | Adds /geyser edu command for status/players/token/reset workflows. |
| core/src/main/java/org/geysermc/geyser/command/CommandRegistry.java | Registers the new edu command. |
| core/src/main/java/org/geysermc/geyser/GeyserImpl.java | Initializes Education auth manager and loads/registers tokens based on tenancy mode. |
| core/src/main/java/org/geysermc/geyser/Constants.java | Bumps CONFIG_VERSION to 8. |
| common/src/main/java/org/geysermc/floodgate/util/BedrockData.java | Extends Floodgate BedrockData payload from 12→15 fields to carry edu flag, tenant, and AD role. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| private void parseServerTokenJwt(String jwtResponse) throws IOException { | ||
| this.serverTokenJwt = jwtResponse.trim(); | ||
| String[] parts = serverTokenJwt.split("\\."); | ||
| if (parts.length < 2) { | ||
| throw new IOException("Invalid JWT response (got " + parts.length + " parts, expected 3)"); | ||
| } | ||
|
|
||
| String payloadJson = new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8); | ||
|
|
||
| JsonObject payload = JsonParser.parseString(payloadJson).getAsJsonObject(); |
There was a problem hiding this comment.
JWT payload segments are Base64URL and commonly omit padding; decoding parts[1] without padding will throw IllegalArgumentException for many valid JWTs. Consider using the existing Base64URL padding helper (e.g., EducationChainVerifier.padBase64) before decoding, so MESS registration/fetch_token can't fail due to missing padding.
| } | ||
| return null; | ||
| } | ||
| String payloadJson = new String(Base64.getUrlDecoder().decode(EducationChainVerifier.padBase64(parts[1]))); |
There was a problem hiding this comment.
new String(byte[]) uses the platform default charset. Since these decoded JWT parts are UTF-8 JSON, this should specify StandardCharsets.UTF_8 to be deterministic across platforms.
| String payloadJson = new String(Base64.getUrlDecoder().decode(EducationChainVerifier.padBase64(parts[1]))); | |
| String payloadJson = new String( | |
| Base64.getUrlDecoder().decode(EducationChainVerifier.padBase64(parts[1])), | |
| StandardCharsets.UTF_8 | |
| ); |
| if (parts.length >= 2) { | ||
| String header = new String(Base64.getUrlDecoder().decode(padBase64(parts[0]))); | ||
| String payload = new String(Base64.getUrlDecoder().decode(padBase64(parts[1]))); | ||
| logger.debug("[EduChainDump] Header: %s", header); | ||
| logger.debug("[EduChainDump] Payload: %s", payload); | ||
| } else { | ||
| logger.debug("[EduChainDump] Raw: %s", jwtToken); | ||
| } | ||
| } | ||
| } else if (authPayload instanceof TokenPayload tokenPayload) { | ||
| String token = tokenPayload.getToken(); | ||
| logger.debug("[EduChainDump] Auth payload is TokenPayload (single token)."); | ||
| String[] parts = token.split("\\."); | ||
| if (parts.length >= 2) { | ||
| String header = new String(Base64.getUrlDecoder().decode(padBase64(parts[0]))); | ||
| String payload = new String(Base64.getUrlDecoder().decode(padBase64(parts[1]))); | ||
| logger.debug("[EduChainDump] Header: %s", header); | ||
| logger.debug("[EduChainDump] Payload: %s", payload); | ||
| } | ||
| } else { | ||
| logger.debug("[EduChainDump] Unknown auth payload type: %s", authPayload.getClass().getName()); | ||
| } | ||
|
|
||
| // Also dump the client data JWT | ||
| if (clientDataJwt != null) { | ||
| String[] parts = clientDataJwt.split("\\."); | ||
| logger.debug("[EduChainDump] --- Client Data JWT (parts: %s) ---", parts.length); | ||
| if (parts.length >= 2) { | ||
| String header = new String(Base64.getUrlDecoder().decode(padBase64(parts[0]))); | ||
| String payload = new String(Base64.getUrlDecoder().decode(padBase64(parts[1]))); | ||
| logger.debug("[EduChainDump] Header: %s", header); |
There was a problem hiding this comment.
new String(...) is using the platform default charset when decoding JWT headers/payloads. These are UTF-8 JSON, so the charset should be specified explicitly (e.g., StandardCharsets.UTF_8) to avoid garbled output on non-UTF-8 default platforms.
| * when a block finishes breaking. This threshold is a heuristic, currently unused | ||
| * since server-authoritative block breaking handles edu clients correctly without it. |
There was a problem hiding this comment.
The constant/Javadoc says this STOP_BREAK threshold heuristic is "currently unused", but it is now used in the STOP_BREAK handling path below. Please update the comment to reflect current behavior (or remove the constant if it truly shouldn't be active).
| * when a block finishes breaking. This threshold is a heuristic, currently unused | |
| * since server-authoritative block breaking handles edu clients correctly without it. | |
| * when a block finishes breaking. This threshold is a heuristic used in the STOP_BREAK | |
| * handling path to decide whether to treat the packet as a full block break. |
|
|
||
| /** | ||
| * Whether this client is Minecraft Education Edition. | ||
| * Detected from TitleId in client JWT, with fallback to education-token config. |
There was a problem hiding this comment.
This Javadoc says Education clients are detected from "TitleId in client JWT", but in this PR the flag is set from BedrockClientData#isEducationEdition() (via the IsEduMode claim). Please update the comment so future changes don’t rely on an incorrect detection mechanism.
| * Detected from TitleId in client JWT, with fallback to education-token config. | |
| * Determined from {@link BedrockClientData#isEducationEdition()}, which uses the IsEduMode claim in the client JWT. |
| this.educationAuthManager = new EducationAuthManager(); | ||
| this.educationAuthManager.setup(this); | ||
| if (tenancyMode != EducationTenancyMode.OFF) { |
There was a problem hiding this comment.
EducationAuthManager#setup() generates/writes edu_official.yml and edu_standalone.yml. Right now this runs unconditionally on startup, even when tenancy-mode is OFF, which introduces new files/IO side effects for servers that aren’t using Education support. Consider only constructing/setting up the auth manager (and generating these files) when tenancy-mode != OFF, or lazily when the feature/command is first used.
| this.educationAuthManager = new EducationAuthManager(); | |
| this.educationAuthManager.setup(this); | |
| if (tenancyMode != EducationTenancyMode.OFF) { | |
| if (tenancyMode != EducationTenancyMode.OFF) { | |
| this.educationAuthManager = new EducationAuthManager(); | |
| this.educationAuthManager.setup(this); |
| public static BedrockData fromString(String data) { | ||
| String[] split = data.split("\0"); | ||
| if (split.length != EXPECTED_LENGTH) { | ||
| return emptyData(split.length); | ||
| } | ||
|
|
||
| LinkedPlayer linkedPlayer = LinkedPlayer.fromString(split[8]); | ||
| // The format is the same as the order of the fields in this class | ||
| return new BedrockData( | ||
| split[0], split[1], split[2], Integer.parseInt(split[3]), split[4], | ||
| Integer.parseInt(split[5]), Integer.parseInt(split[6]), split[7], linkedPlayer, | ||
| "1".equals(split[9]), Integer.parseInt(split[10]), split[11], split.length | ||
| "1".equals(split[9]), Integer.parseInt(split[10]), split[11], | ||
| "1".equals(split[12]), split[13], Integer.parseInt(split[14]), split.length | ||
| ); |
There was a problem hiding this comment.
Bumping EXPECTED_LENGTH to 15 means any older Floodgate/Geyser counterpart still sending 12 fields will now parse as emptyData(...), which can cause confusing downstream failures. If backwards compatibility is desired, consider accepting both lengths (e.g., parse 12-field data and default the new education fields) and only treat truly malformed inputs as empty/invalid.
| public static void dumpEduChain(GeyserLogger logger, AuthPayload authPayload, String clientDataJwt) { | ||
| try { | ||
| logger.debug("[EduChainDump] ========== EDUCATION CLIENT JWT CHAIN DUMP =========="); | ||
|
|
||
| if (authPayload instanceof CertificateChainPayload certChain) { | ||
| List<String> chain = certChain.getChain(); | ||
| logger.debug("[EduChainDump] Chain length: %s", chain.size()); | ||
|
|
||
| for (int i = 0; i < chain.size(); i++) { | ||
| String jwtToken = chain.get(i); | ||
| String[] parts = jwtToken.split("\\."); | ||
| logger.debug("[EduChainDump] --- Chain JWT #%s (parts: %s) ---", i, parts.length); | ||
|
|
||
| if (parts.length >= 2) { | ||
| String header = new String(Base64.getUrlDecoder().decode(padBase64(parts[0]))); | ||
| String payload = new String(Base64.getUrlDecoder().decode(padBase64(parts[1]))); | ||
| logger.debug("[EduChainDump] Header: %s", header); | ||
| logger.debug("[EduChainDump] Payload: %s", payload); | ||
| } else { | ||
| logger.debug("[EduChainDump] Raw: %s", jwtToken); | ||
| } | ||
| } | ||
| } else if (authPayload instanceof TokenPayload tokenPayload) { | ||
| String token = tokenPayload.getToken(); | ||
| logger.debug("[EduChainDump] Auth payload is TokenPayload (single token)."); | ||
| String[] parts = token.split("\\."); | ||
| if (parts.length >= 2) { | ||
| String header = new String(Base64.getUrlDecoder().decode(padBase64(parts[0]))); | ||
| String payload = new String(Base64.getUrlDecoder().decode(padBase64(parts[1]))); | ||
| logger.debug("[EduChainDump] Header: %s", header); | ||
| logger.debug("[EduChainDump] Payload: %s", payload); | ||
| } | ||
| } else { | ||
| logger.debug("[EduChainDump] Unknown auth payload type: %s", authPayload.getClass().getName()); | ||
| } | ||
|
|
||
| // Also dump the client data JWT | ||
| if (clientDataJwt != null) { | ||
| String[] parts = clientDataJwt.split("\\."); | ||
| logger.debug("[EduChainDump] --- Client Data JWT (parts: %s) ---", parts.length); | ||
| if (parts.length >= 2) { | ||
| String header = new String(Base64.getUrlDecoder().decode(padBase64(parts[0]))); | ||
| String payload = new String(Base64.getUrlDecoder().decode(padBase64(parts[1]))); | ||
| logger.debug("[EduChainDump] Header: %s", header); | ||
| // Client data payloads can exceed 10KB (skin data); truncate to keep logs readable | ||
| if (payload.length() > 2000) { | ||
| logger.debug("[EduChainDump] Payload (truncated): %s...", payload.substring(0, 2000)); | ||
| } else { | ||
| logger.debug("[EduChainDump] Payload: %s", payload); | ||
| } | ||
| } |
There was a problem hiding this comment.
dumpEduChain logs fully decoded JWT headers/payloads. Even in debug mode, these payloads can contain sensitive identifiers (tenant IDs, user identity claims, potentially token-like values) and may end up in shared logs. Consider redacting sensitive fields (or only logging a minimal subset) to reduce accidental data exposure when debugMode is enabled.
- Specify UTF-8 charset in all new String(Base64...) calls in EducationChainVerifier (6 occurrences) and EducationAuthManager (1) - Fix GeyserSession.educationClient Javadoc to reflect actual detection via BedrockClientData.isEducationEdition()
|
Hello! Thank you for the contribution. However, after an internal discussion, we don't see us being able to merge this PR due to multiple reasons:
Thank you anyways! |
Demo: Education + Bedrock + Java cross-play
Edit: Updated some things to reflect the current state for anyone referencing this in the future
Background
There is no currently working tool to connect Education clients to Java servers. The last one, bundabrg's GeyserReversion, died in July 2022 when the extension interface it depended on was not accepted upstream. Since then, there have been attempts to revive it but none were successful.
Back in 2020, Education ran a significantly older protocol than Bedrock (v363 vs v408) and needed a full translation layer with separate codecs, custom serializers for StartGame, InventoryTransaction, CraftingData, Event, and PlayerList packets, and its own block/item mappings submodule. That's why bundabrg's PR #536 required 76 commits and a dependency injection system to support multiple editions.
This PR is the result of investigating what it would actually take with the current state of Education Edition.
Step 1: Does Education still need protocol translation?
No. This is the key discovery that makes everything else possible.
Education Edition 1.21.132 (the "Copper, Collaboration & Compete" update, released February 17, 2026) runs on Bedrock protocol 898. This is the same protocol as standard Bedrock 1.21.132 (the "Mounts of Mayhem" release, January 8, 2026). Both are built from the same source tree: Education's current branch is
edu_r21_u13.Verified by checking the client debug screen:
The build numbers (41.1M vs 41.7M) confirm they were compiled within a short window of each other. Protocol 898 maps to
Bedrock_v898.javain CloudburstMC's Protocol library, which Geyser already supports as part of its 1.21.130-26.10 range.Block and item palettes are also identical. Geyser's
runtime_item_states.1_21_130.jsonalready contains every Education entry:element_0throughelement_118,compound,medicine,bleach,balloon,sparkler,glow_stick,ice_bomb,chemistry_table,compound_creator,lab_table,material_reducer,element_constructor,allow,deny,border_block,camera,chalkboard,colored_torch_*,underwater_torch. Theblock_palette.1_21_130.nbtincludes the corresponding education block entries too. Education-specific content is gated behind theeducationFeaturesEnabledruntime flag, not by different palettes.This eliminates the entire translation layer bundabrg needed. No separate mappings, no version-specific codecs, no block ID conversion, no item remapping.
Step 2: What happens if you just point an Education client at Geyser?
Two things go wrong, and they're independent problems.
Problem 1: Auth rejection
Education doesn't use Xbox Live. It authenticates through Microsoft 365 / Entra ID (the identity system used by schools and organizations). The client sends a
CertificateChainPayload- the same payload type as standard Bedrock, so you can't distinguish them by auth type alone. But the login chain is not signed by Mojang's root key.result.signed()returnsfalse, and Geyser rejects the connection.Problem 2: Silent disconnect
Even after disabling login validation (
validate-bedrock-login: false), the client connects, receives StartGamePacket, and silently disconnects 1-1.5 seconds later. No error message on the client. No disconnect reason in the logs beyond a generic connection close. The client briefly shows the loading screen and then returns to the menu.Step 3: Solving the auth
Two fixes for two problems.
Fix for auth rejection: Education clients are detected via the
IsEduModefield in the client data JWT before the Xbox validation check runs. When detected, theresult.signed()check is bypassed. Instead, education chains are validated through their own tenant-based path instead.Fix for the handshake: Education clients expect a
signedTokenclaim in theServerToClientHandshakePacketJWT. This is a tenant-scoped authorization token in the format:Example:
03b5e7a1-cb09-4417-9e1a-c686c440b2c5|e2a79ff3-29ba-4cc2-99ad-4c355fe81bfa|2026-03-19T14:18:13.486Z|41863f21cdbeacbd1...Without it, the client shows "Invalid Tenant ID." The token is obtained either directly via the discovery
/hostendpoint (discovery.minecrafteduservices.com/host) by exchanging an OAuth2 bearer token, or through Microsoft's dedicated server API (dedicatedserver.minecrafteduservices.com) which handles full server registration, hosting, and token management through Minecraft Education's server infrastructure (MESS - Minecraft Education Server Services). Tokens expire after approximately 10 days based on the embedded timestamp, and can be refreshed automatically.The fix in
LoginEncryptionUtils.startEncryptionHandshake()constructs a custom JWS when an Education client is detected:The algorithm (ES384), key format, and salt claim are identical to the standard Bedrock handshake, only the
signedTokenclaim is added.Multiple tenants are supported. Each connecting student's tenant ID is matched against the configured tokens, and clients are verified via MESS nonce verification. This allows a single server to accept Education players from different schools.
Floodgate change
This PR extends
BedrockDatafrom 12 to 15 fields to carry education identity (education flag, tenant ID, AD role). A corresponding Floodgate PR handles the receiving side: stable UUID generation from tenant-scoped identity (education players don't have XUIDs) and username formatting. This is the simplest approach since Geyser has no way to negotiate field count with Floodgate. It could also be done defensively by only appending the extra fields for education sessions, but that adds branching for no practical benefit.Authentication methods
Tokens can be obtained three ways: full MESS registration (recommended for schools, requires Global Admin, fully automated token refresh, server appears in the built-in server list), device code flow via the
/hostendpoint (any teacher, automatic refresh, works unless the tenant blocks device code flow), or the token tool (MSAL browser login, fallback for tenants with strict conditional access, manual refresh every ~10 days).Step 4: Finding the disconnect - the investigation
This was the hard part and took the majority of the development time. The client disconnects silently with disconnect code 0x29 (generic "Disconnected"), giving no indication of what went wrong.
Systematic elimination
I worked through every plausible theory:
And 13 other theories including experiments, entity packets, movement mode, skin validation, safety service phone-home, vanillaVersion, gamerules, currentTick, and token format. All ruled out.
Nuclear suppression test
The breakthrough diagnostic: I suppressed ALL packets after StartGamePacket - every single one. No entity spawns, no chunks, no commands, nothing. The client still disconnected at the exact same timing (1-1.5 seconds).
This definitively proved:
DLL injection and binary analysis
To understand what the client was doing internally, I injected a custom DLL into the Education Edition process (a UWP/Centennial app, requiring special injection techniques).
closesocket hook: Hooked
closesocketinws2_32.dllto intercept the moment the client decides to disconnect. Results:[this->field_0xD0->field_0x40]was ALREADY set on the very first tick check - meaning it was set during connection initialization, not during gameplaypersona_right_legstring appeared nearby in memory, which was a red herring (coincidental memory layout from the persona skin system)Key RVA offsets identified (base varies per launch due to ASLR):
0x28AEFA00x29E3BA00x29E3C11cmp byte ptr [rax], bpl; je skip0x02935E400xADF9C0The DLL analysis was consistent with the actual root cause: a deserialization failure in StartGamePacket processing sets a disconnect flag immediately during connection initialization, before any gameplay packets are processed. The flag being set on the first tick check rather than at any specific game event pointed squarely at the packet parsing stage.
Finding the 3 missing fields
Running
stringson the Education dedicated server binary (bedrock_server_edu.exe) revealed three field names not present in the standard Bedrock binary or in any public protocol documentation:educationReferrerIdeducationCreatorWorldIdeducationCreatorIdCross-referencing with CloudburstMC's
StartGameSerializer_v898.javaand walking up through its parent class hierarchy (StartGameSerializer_v291.java→writeLevelSettings()), I confirmed these fields are written as strings at the end ofLevelSettingsin the serialization order. The standard Bedrock serializer doesn't include them. The Education client expects them and reads past the end of the buffer when they're absent, causing a deserialization failure.No public documentation exists for these fields - not in Mojang's bedrock-protocol-docs, not in CloudburstMC's Protocol source, not in PrismarineJS, not on the Bedrock Wiki. This is the piece that blocked KrystilizeNevaDies/GeyserEE and would block anyone else attempting this without access to the Education binary.
Step 5: The fix
A custom
EducationStartGameSerializerextendsStartGameSerializer_v898and overrideswriteLevelSettings()to callsuper.writeLevelSettings()then append three empty strings:This serializer is registered in the codec for Education sessions only. Standard Bedrock sessions continue using the unmodified serializer.
The client connects, stays connected, and gameplay works normally.
Complete summary of changes
Education support is disabled by default (
tenancy-mode: off). Existing Geyser servers are completely unaffected. When enabled, all changes are gated behindtenancy-mode != offat startup andsession.isEducationClient()at runtime, so the normal Bedrock pipeline is untouched. MESS registration settings and standalone tokens are stored in separate files (edu_official.ymlandedu_standalone.yml) rather than inconfig.yml:Core changes (required for connection):
EducationStartGameSerializer(new file) - extendsStartGameSerializer_v898, appends 3 empty strings aftersuper.writeLevelSettings(). This is the root cause fix.LoginEncryptionUtils.java- injectssignedTokenclaim into the handshake JWT whenIsEduModeis detected in the client data JWT. Rejects education clients when tenancy mode isoff. Uses the same ES384 algorithm and key format as standard Bedrock.GeyserSession.java- adds per-sessioneducationClientboolean flag. When true, sets Education-specific StartGamePacket field gamerule: "codebuilder:false". Prevents client from sending packets with ILLEGAL serializers.BedrockClientData.java- adds@SerializedName("IsEduMode") boolean isEduModefield with accessor, enabling per-session Education client detection from the client data JWT.Authentication and server management:
EducationAuthManager.java(new file) - handles MESS registration, OAuth device code flow, token routing by tenant ID, nonce verification, token refresh, session persistence. Well-sectioned into 18 labeled regions.EducationChainVerifier.java(new file) - extracts and verifies tenant identity from the Education login chain JWT.EducationTenancyMode.java(new file) - enum withOFF(default),OFFICIAL,HYBRID,STANDALONE.Supporting changes:
GeyserImpl.java- initializes education auth manager gated behindtenancy-mode != off. Loads manual and device-code tokens based on mode.GeyserConfig.java- addsEducationConfiginterface withtenancyMode()defaulting toOFF.EduCommand.java(new file) -/geyser educommand with status (tenant table, mode-appropriate info), players, token, and reset subcommands.BedrockData.java(Floodgate common) - extends from 12 to 15 fields to carry education flag, tenant ID, and AD role.GeyserSessionAdapter.java,UpstreamPacketHandler.java,SessionManager.java- education-specific handling for session lifecycle and XUID tracking.ConfigMigrations.java,Constants.java,CommandRegistry.java- config version bump to 8, edu command registration.Tests -
EducationAuthManagerTest.java,EducationChainVerifierTest.java,EducationUuidTest.java(479 lines total).What is NOT changed:
Why this can't be an extension
I tried the extension approach first and documented exactly where it fails. The required changes operate below what the Extension API (v2.9.5) exposes:
Codec/serializer swap: The 3 extra strings must be written by the serializer in the Netty pipeline. The API operates above the codec layer - even if you intercept and modify the StartGamePacket object, the standard serializer still writes it without those fields. The Education client reads a binary stream, not object fields, and the bytes must be present at the right position.
Handshake JWT: The
signedTokenmust be injected duringstartEncryptionHandshake(), before the session is fully established. No extension hook fires at this point in the connection lifecycle.Login validation: Education chains aren't signed by Mojang's root key. The validation logic in the login path needs to accept Education clients through a separate path rather than globally disabling validation for everyone.
Per-session codec swapping: Extensions cannot swap codecs based on client type detected during login. The codec must be set before StartGamePacket is serialized but after the client type is known.
I also analyzed PR #5685 (Redned's upcoming Networking API). It can intercept and modify packets including during login, which is more capable than I initially expected. However, every send path in
GeyserNetwork.send()round-trips through the session codec serializer. Even if you construct a modified packet, the standard serializer re-encodes it without the Education fields. The codec swap remains a hard blocker. Auth hooks also fire afterLoginEncryptionUtilshas already run.Maintenance burden
Education never leads Bedrock. Every Education release catches up to a Bedrock protocol version that Geyser already supports, typically by 5-6 weeks:
This means that by the time an Education update ships, Geyser has already been handling that protocol for weeks. Maintenance cost for education support is effectively zero unless the 3 LevelSettings strings change, which they haven't since they were first added.
The per-session codec architecture already supports version divergence - if Education falls behind in the future, Education sessions use their codec while Bedrock sessions use the current one.
Bedrock 26.x vs Education 1.21.132: the version gap currently looks bigger than it is. 26.x is mostly just 1.21 with the new year-based versioning system. For practical purposes, Education and Bedrock players have identical survival experiences.
Prior art
What has changed since 2021:
This PR is the first working Education-to-Java bridge since GeyserReversion died nearly four years ago.
How Education clients connect
There are three ways Education clients can connect to a Geyser server:
button.menu_serversaction. The server list screen and its code paths are still present in the Education client - Microsoft only removed the UI element that opens it.minecraftedu://connect/?serverUrl=ip:port- URI scheme, works on all platforms (Windows, macOS, iPad, Chromebook, Android). A simple HTML page with a link is the easiest distribution method.Education Edition removed the direct IP connect UI in the 1.18.32 update (August 2022), replacing "Join by IP Address" with "Join by Connection Id" when they moved to WebRTC-based multiplayer through Microsoft's relay infrastructure. This wasn't a deliberate restriction but a UX change because there was simply nothing to type into an IP field anymore since all connections went through Microsoft's signalling servers back then. The underlying direct connect code remains fully functional in the Bedrock engine.
Testing
Tested with Education Edition 1.21.132 and Preview (which is confusingly still on 1.21.131.2) on Windows, iOS, Android. Confirmed working with both the token tool and device code authentication methods. Multiple independent users have successfully connected on different hosting setups (Paper standalone, Velocity networks).
I have a commercial Education license and can provide test accounts to anyone who wants to verify without dealing with Microsoft licensing.
Documentation
Full technical documentation (3,300+ lines) is available in the repository, covering the complete protocol analysis, authentication system, debugging history, connection methods, and deployment guide. The MESS API is publicly documented by Microsoft with a sample tooling notebook and Microsoft actively encouraging third-party implementations. The 3 Education-specific StartGamePacket fields (
educationReferrerId,educationCreatorWorldId,educationCreatorId) are not documented in any public protocol specification and were identified through analysis of the Education dedicated server binary.Legal
This PR does not violate any Microsoft terms. The Minecraft EULA explicitly excludes Education Edition from its scope. The Education EULA governs the client software only and does not address servers, mods, external connections, or interoperability. No client software is modified. The MESS API is used exactly as Microsoft designed and documented it. EduGeyser operates in the same legal space as Geyser itself: server-side protocol translation.
The protocol analysis work (identifying the 3 undocumented StartGamePacket fields from the Education dedicated server binary) is protected under Article 21 of the Swiss Federal Act on Copyright, which permits decompilation for the purpose of interoperability with independently created software. The Education EULA's own reverse engineering clause includes the standard exception: "except and only to the extent that applicable law expressly permits."
A detailed legal analysis covering all relevant Microsoft documents is available here.