From 906434b921014e4c93378455ab5587e6898250c8 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 6 Jul 2026 21:23:26 +0200 Subject: [PATCH 1/4] docs: plan Bedrock identity enforcement --- ...2026-07-06-bedrock-identity-enforcement.md | 105 ++++++++++++++++++ ...-06-bedrock-identity-enforcement-design.md | 59 ++++++++++ 2 files changed, 164 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-06-bedrock-identity-enforcement.md create mode 100644 docs/superpowers/specs/2026-07-06-bedrock-identity-enforcement-design.md diff --git a/docs/superpowers/plans/2026-07-06-bedrock-identity-enforcement.md b/docs/superpowers/plans/2026-07-06-bedrock-identity-enforcement.md new file mode 100644 index 00000000..3e2c500b --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-bedrock-identity-enforcement.md @@ -0,0 +1,105 @@ +# Bedrock Identity Enforcement Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add opt-in Connect Java enforcement for Moxy-signed Bedrock identity envelopes. + +**Architecture:** Add config for static-key enforcement, a focused core enforcer around `BedrockIdentityVerifier`, and platform hook calls at existing login boundaries. Default behavior remains disabled so current servers are unaffected until operators configure enforcement. + +**Tech Stack:** Java 17, Gradle, JUnit 5, Gson/configutils, Connect Java core/proxy/spigot modules. + +--- + +## File Structure + +- Modify `core/src/main/java/com/minekube/connect/config/ConnectConfig.java` to expose `BedrockIdentityConfig`. +- Modify `core/src/main/resources/config.yml` and `core/src/main/resources/proxy-config.yml` to document defaults. +- Modify `api/src/main/java/com/minekube/connect/api/player/bedrock/BedrockIdentityVerifier.java` so endpoint ID and org ID checks are optional expected scope fields. +- Extend `core/src/test/java/com/minekube/connect/api/player/bedrock/BedrockIdentityVerifierTest.java` for partial-scope verification. +- Create `core/src/main/java/com/minekube/connect/bedrock/BedrockIdentityEnforcer.java` for enforcement decisions. +- Create `core/src/test/java/com/minekube/connect/bedrock/BedrockIdentityEnforcerTest.java` for core behavior. +- Modify `velocity/src/main/java/com/minekube/connect/listener/VelocityListener.java` to reject/warn in `PreLoginEvent`. +- Modify `bungee/src/main/java/com/minekube/connect/listener/BungeeListener.java` to reject/warn in `PreLoginEvent`. +- Modify `spigot/src/main/java/com/minekube/connect/addon/data/SpigotDataHandler.java` to reject/warn before login profile mutation. +- Extend existing tests where seams exist, especially `spigot/src/test/java/com/minekube/connect/addon/data/SpigotDataHandlerTest.java`. + +## Task 1: Config Parsing + +- [ ] Add a failing test in `ConfigLoaderTest` that loads: + +```yaml +bedrock-identity: + enforcement: require + public-key: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + expected-policy: trusted_bedrock_xuid +``` + +and asserts `config.getBedrockIdentity().getEnforcement()` is `require`. + +- [ ] Run `./gradlew :core:test --tests com.minekube.connect.config.ConfigLoaderTest --no-daemon` and verify the test fails because config accessors do not exist. +- [ ] Add `ConnectConfig.BedrockIdentityConfig` with fields `enforcement`, `publicKey`, and `expectedPolicy`; default enforcement is `disabled`, expected policy is `trusted_bedrock_xuid`. +- [ ] Add the YAML block to `config.yml` and `proxy-config.yml`. +- [ ] Re-run the focused config test and commit: + +```bash +git add core/src/main/java/com/minekube/connect/config/ConnectConfig.java core/src/main/resources/config.yml core/src/main/resources/proxy-config.yml core/src/test/java/com/minekube/connect/config/ConfigLoaderTest.java +git commit -m "feat: configure Bedrock identity enforcement" +``` + +## Task 2: Core Enforcer + +- [ ] Add a failing verifier test showing a signed envelope verifies when endpoint name, session ID, protocol, and policy match but endpoint ID/org ID are not supplied to the builder. +- [ ] Run `./gradlew :core:test --tests com.minekube.connect.api.player.bedrock.BedrockIdentityVerifierTest --no-daemon` and verify it fails because endpoint ID/org ID are currently required. +- [ ] Update `BedrockIdentityVerifier` so endpoint ID/org ID are optional expected scope fields. The envelope must still contain both fields; the verifier only skips equality checks for expected values that are not configured. +- [ ] Re-run the focused verifier test. +- [ ] Add failing tests in `BedrockIdentityEnforcerTest` for disabled no-op, require success, require missing property, warn missing property, invalid key fail-open in warn, invalid key fail-closed in require, and replay rejection. +- [ ] Run `./gradlew :core:test --tests com.minekube.connect.bedrock.BedrockIdentityEnforcerTest --no-daemon` and verify failures are for missing enforcer code. +- [ ] Implement `BedrockIdentityEnforcer` with: + +```java +public Decision verify(LocalSession.Context context) +``` + +where `Decision` exposes `allowed()`, `message()`, and `verifiedClaims()`. + +- [ ] The implementation must not log raw `minekube:bedrock_identity` values or public keys. +- [ ] Re-run the focused enforcer test and commit: + +```bash +git add api/src/main/java/com/minekube/connect/api/player/bedrock/BedrockIdentityVerifier.java core/src/test/java/com/minekube/connect/api/player/bedrock/BedrockIdentityVerifierTest.java core/src/main/java/com/minekube/connect/bedrock/BedrockIdentityEnforcer.java core/src/test/java/com/minekube/connect/bedrock/BedrockIdentityEnforcerTest.java +git commit -m "feat: enforce Bedrock identity envelopes" +``` + +## Task 3: Platform Hooks + +- [ ] Add the enforcer injection to Velocity, Bungee, and Spigot login paths. +- [ ] Velocity: call the enforcer inside `LocalSession.context(channel, ctx -> ...)` before `forceOfflineMode()` and cache insertion; reject through `event.setResult(PreLoginEvent.PreLoginComponentResult.denied(Component.text(message)))` on deny. +- [ ] Bungee: call the enforcer before setting online mode/UUID/name; reject through `event.setCancelReason(message)` and `event.setCancelled(true)` on deny. +- [ ] Spigot: call the enforcer before spoofed UUID/profile mutation; close the channel and reject the proposal on deny. +- [ ] Add or extend tests for the available seams, then run focused platform tests: + +```bash +./gradlew :spigot:test --tests com.minekube.connect.addon.data.SpigotDataHandlerTest --no-daemon +``` + +- [ ] Commit: + +```bash +git add velocity/src/main/java/com/minekube/connect/listener/VelocityListener.java bungee/src/main/java/com/minekube/connect/listener/BungeeListener.java spigot/src/main/java/com/minekube/connect/addon/data/SpigotDataHandler.java spigot/src/test/java/com/minekube/connect/addon/data/SpigotDataHandlerTest.java +git commit -m "feat: reject invalid Bedrock identity sessions" +``` + +## Task 4: Verification and Tracking + +- [ ] Run: + +```bash +./gradlew :core:test --tests com.minekube.connect.config.ConfigLoaderTest --no-daemon +./gradlew :core:test --tests com.minekube.connect.bedrock.BedrockIdentityEnforcerTest --no-daemon +./gradlew build --no-daemon +git diff --check +``` + +- [ ] Open a PR to `minekube/connect-java` linking `minekube/moxy#403` and `minekube/moxy#396`. +- [ ] Ensure CI passes, merge if clean, and verify release automation. +- [ ] Comment on moxy issues with PR, commit, checks, and remaining key-discovery/docs follow-ups. diff --git a/docs/superpowers/specs/2026-07-06-bedrock-identity-enforcement-design.md b/docs/superpowers/specs/2026-07-06-bedrock-identity-enforcement-design.md new file mode 100644 index 00000000..d76fdceb --- /dev/null +++ b/docs/superpowers/specs/2026-07-06-bedrock-identity-enforcement-design.md @@ -0,0 +1,59 @@ +# Bedrock Identity Enforcement Design + +## Goal + +Add opt-in Connect Java enforcement for Moxy-signed `minekube:bedrock_identity` profile properties so backend servers can reject Bedrock sessions whose trusted edge identity cannot be verified. + +## Scope + +This slice adds static-key enforcement only. It does not add network key discovery, key rotation metadata, or public docs. Those are follow-up rollout tasks because they change operational trust and production delivery independently from local enforcement behavior. + +## Config + +Add a `bedrock-identity` block to `config.yml` and `proxy-config.yml`: + +```yaml +bedrock-identity: + enforcement: disabled + public-key: "" + expected-policy: trusted_bedrock_xuid +``` + +`enforcement` accepts: + +- `disabled`: default; no verification or rejection. +- `warn`: verify when a public key is configured and log failures, but allow the session. +- `require`: verify with the configured public key and reject when the envelope is missing, forged, expired, replayed, scope-mismatched, or policy-mismatched. + +`public-key` is a base64-encoded Ed25519 public key. The verifier accepts raw 32-byte keys and X.509 encoded keys after decoding. `expected-policy` defaults to `trusted_bedrock_xuid`. + +## Architecture + +Add a small core enforcement service around the released `BedrockIdentityVerifier`. It receives `ConnectConfig` and `ConnectLogger`, builds verifiers per `LocalSession.Context`, and returns an allow/reject decision. It owns replay cache state so platform hooks do not duplicate verification details. + +The service checks endpoint name from `ConnectConfig#getEndpoint`, session ID from the `ConnectPlayer`, protocol `bedrock`, and expected policy from config. Connect Java does not currently receive endpoint ID or org ID in the legacy WatchService session proposal, and libp2p registration does not attach those values to the local login context. This slice therefore extends the verifier API so endpoint ID and org ID are optional expected scope fields: if configured, they are enforced; if omitted, the signed envelope must still contain them but local enforcement does not claim to know their expected values. + +The reject reason must be support-safe and must never include the raw signed envelope or key material. + +## Platform Hooks + +Run enforcement at the earliest existing platform login boundary where `LocalSession.Context` is present: + +- Velocity: `PreLoginEvent`, before `forceOfflineMode()` and player cache insertion. +- Bungee: `PreLoginEvent`, before UUID/name rewrite. +- Spigot: `LOGIN_START_PACKET`, before spoofed UUID, profile rewrite, and server login event calls. + +In `require` mode, reject the local login and reject the session proposal if the tunnel has not opened. In `warn` mode, log a warning and continue. In `disabled` mode, do nothing. + +## Testing + +Core tests cover disabled, warn, require success, require missing property, invalid public key, policy mismatch, replay, and support-safe logging. Platform tests cover hook behavior where the repo already has viable test seams, with Spigot covered through `SpigotDataHandlerTest` and core enforcement covered independently for proxy hooks. + +## Follow-Ups + +Create or update GitHub tracking for: + +- public-key discovery and rotation metadata +- carrying endpoint ID/org ID into the local login context so Connect Java can enforce full endpoint/org scope +- production signing-key/public-key rollout +- operator docs explaining official Bedrock/Xbox auth, Moxy identity envelopes, and Connect Java enforcement From 664db945f0bc54f72ab686c59d14106d7b63c460 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 6 Jul 2026 21:24:47 +0200 Subject: [PATCH 2/4] feat: configure Bedrock identity enforcement --- .../connect/config/ConnectConfig.java | 21 ++++++++++++++ core/src/main/resources/config.yml | 12 ++++++++ core/src/main/resources/proxy-config.yml | 12 ++++++++ .../connect/config/ConfigLoaderTest.java | 28 +++++++++++++++++++ 4 files changed, 73 insertions(+) diff --git a/core/src/main/java/com/minekube/connect/config/ConnectConfig.java b/core/src/main/java/com/minekube/connect/config/ConnectConfig.java index 61d4ef14..023007b5 100644 --- a/core/src/main/java/com/minekube/connect/config/ConnectConfig.java +++ b/core/src/main/java/com/minekube/connect/config/ConnectConfig.java @@ -54,6 +54,11 @@ public class ConnectConfig { */ private Boolean allowOfflineModePlayers; + /** + * Verification settings for Moxy-signed Bedrock identity envelopes. + */ + private BedrockIdentityConfig bedrockIdentity = new BedrockIdentityConfig(); + /** * Super endpoints are authorized to control this endpoint via Connect API. * e.g. disconnect players from this endpoint, send messages to players, etc. @@ -84,4 +89,20 @@ public static class MetricsConfig { */ private String uuid; } + + @Getter + public static class BedrockIdentityConfig { + /** + * disabled, warn, or require. + */ + private String enforcement = "disabled"; + /** + * Base64-encoded Ed25519 public key used to verify identity envelopes. + */ + private String publicKey = ""; + /** + * Expected Connect Edge Bedrock authentication policy. + */ + private String expectedPolicy = "trusted_bedrock_xuid"; + } } diff --git a/core/src/main/resources/config.yml b/core/src/main/resources/config.yml index 049d18ce..5ac177b5 100644 --- a/core/src/main/resources/config.yml +++ b/core/src/main/resources/config.yml @@ -15,6 +15,18 @@ allow-offline-mode-players: false # - pvp-endpoint # - someones-hub +# Moxy can attach a signed Bedrock/Xbox identity envelope to Bedrock sessions. +# Keep enforcement disabled unless Minekube provided the current public key. +bedrock-identity: + # disabled: do not verify or reject + # warn: verify when public-key is configured and log failures, but allow the session + # require: reject Bedrock sessions with missing or invalid identity envelopes + enforcement: disabled + # Base64-encoded Ed25519 public key. Supports raw 32-byte and X.509 encoded keys after decoding. + public-key: "" + # Expected Connect Edge Bedrock authentication policy. + expected-policy: trusted_bedrock_xuid + # The default locale for Connect. By default, Connect uses the system locale #default-locale: en_US diff --git a/core/src/main/resources/proxy-config.yml b/core/src/main/resources/proxy-config.yml index c979f645..87a6b37e 100644 --- a/core/src/main/resources/proxy-config.yml +++ b/core/src/main/resources/proxy-config.yml @@ -17,6 +17,18 @@ allow-offline-mode-players: false # - pvp-endpoint # - someones-hub +# Moxy can attach a signed Bedrock/Xbox identity envelope to Bedrock sessions. +# Keep enforcement disabled unless Minekube provided the current public key. +bedrock-identity: + # disabled: do not verify or reject + # warn: verify when public-key is configured and log failures, but allow the session + # require: reject Bedrock sessions with missing or invalid identity envelopes + enforcement: disabled + # Base64-encoded Ed25519 public key. Supports raw 32-byte and X.509 encoded keys after decoding. + public-key: "" + # Expected Connect Edge Bedrock authentication policy. + expected-policy: trusted_bedrock_xuid + # bStats is a stat tracker that is entirely anonymous and tracks only basic information # about Connect, such as how many people are online, how many servers are using Connect, # what OS is being used, etc. You can learn more about bStats here: https://bstats.org/. diff --git a/core/src/test/java/com/minekube/connect/config/ConfigLoaderTest.java b/core/src/test/java/com/minekube/connect/config/ConfigLoaderTest.java index 246bb246..d2c674e6 100644 --- a/core/src/test/java/com/minekube/connect/config/ConfigLoaderTest.java +++ b/core/src/test/java/com/minekube/connect/config/ConfigLoaderTest.java @@ -34,4 +34,32 @@ void loadsDocumentedAllowOfflineModePlayersKey() throws Exception { assertEquals(Boolean.TRUE, config.getAllowOfflineModePlayers()); } + + @Test + void loadsBedrockIdentityEnforcementConfig() throws Exception { + Files.writeString(tempDir.resolve("config.yml"), String.join("\n", + "endpoint: codexp2p3", + "allow-offline-mode-players: false", + "bedrock-identity:", + " enforcement: require", + " public-key: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + " expected-policy: trusted_bedrock_xuid", + "metrics:", + " disabled: true", + " uuid: 00000000-0000-0000-0000-000000000000", + "config-version: 1", + "")); + + ConfigLoader loader = new ConfigLoader( + tempDir, + ConnectConfig.class, + new ConfigLoader.EndpointNameGenerator(new OkHttpClient()), + mock(ConnectLogger.class)); + + ConnectConfig config = loader.load(); + + assertEquals("require", config.getBedrockIdentity().getEnforcement()); + assertEquals("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", config.getBedrockIdentity().getPublicKey()); + assertEquals("trusted_bedrock_xuid", config.getBedrockIdentity().getExpectedPolicy()); + } } From 63fbedb9d2b2b78e7ac4dc48b3717e0d337763de Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 6 Jul 2026 21:28:24 +0200 Subject: [PATCH 3/4] feat: enforce Bedrock identity envelopes --- .../bedrock/BedrockIdentityVerifier.java | 15 +- .../bedrock/BedrockIdentityEnforcer.java | 146 ++++++++++ .../bedrock/BedrockIdentityVerifierTest.java | 19 ++ .../bedrock/BedrockIdentityEnforcerTest.java | 256 ++++++++++++++++++ 4 files changed, 432 insertions(+), 4 deletions(-) create mode 100644 core/src/main/java/com/minekube/connect/bedrock/BedrockIdentityEnforcer.java create mode 100644 core/src/test/java/com/minekube/connect/bedrock/BedrockIdentityEnforcerTest.java diff --git a/api/src/main/java/com/minekube/connect/api/player/bedrock/BedrockIdentityVerifier.java b/api/src/main/java/com/minekube/connect/api/player/bedrock/BedrockIdentityVerifier.java index eab31f54..c997bc75 100644 --- a/api/src/main/java/com/minekube/connect/api/player/bedrock/BedrockIdentityVerifier.java +++ b/api/src/main/java/com/minekube/connect/api/player/bedrock/BedrockIdentityVerifier.java @@ -42,9 +42,9 @@ public final class BedrockIdentityVerifier { private BedrockIdentityVerifier(Builder builder) { this.publicKey = Objects.requireNonNull(builder.publicKey, "publicKey"); this.now = builder.now; - this.endpointId = requireNonEmpty(builder.endpointId, "endpointId"); + this.endpointId = optionalNonEmpty(builder.endpointId, "endpointId"); this.endpointName = requireNonEmpty(builder.endpointName, "endpointName"); - this.orgId = requireNonEmpty(builder.orgId, "orgId"); + this.orgId = optionalNonEmpty(builder.orgId, "orgId"); this.sessionId = requireNonEmpty(builder.sessionId, "sessionId"); this.protocol = requireNonEmpty(builder.protocol, "protocol"); this.bedrockAuthPolicy = builder.bedrockAuthPolicy; @@ -185,9 +185,9 @@ private static void validate(Envelope envelope) throws BedrockIdentityVerificati } private void validateScope(Envelope envelope) throws BedrockIdentityVerificationException { - if (!endpointId.equals(envelope.endpoint.id) || + if ((endpointId != null && !endpointId.equals(envelope.endpoint.id)) || !endpointName.equals(envelope.endpoint.name) || - !orgId.equals(envelope.endpoint.org_id) || + (orgId != null && !orgId.equals(envelope.endpoint.org_id)) || !sessionId.equals(envelope.session.id) || !protocol.equals(envelope.session.protocol)) { throw new BedrockIdentityVerificationException("identity envelope scope mismatch"); @@ -238,6 +238,13 @@ private static String requireNonEmpty(String value, String name) { return value; } + private static String optionalNonEmpty(String value, String name) { + if (value == null) { + return null; + } + return requireNonEmpty(value, name); + } + private static boolean isEmpty(String value) { return value == null || value.isEmpty(); } diff --git a/core/src/main/java/com/minekube/connect/bedrock/BedrockIdentityEnforcer.java b/core/src/main/java/com/minekube/connect/bedrock/BedrockIdentityEnforcer.java new file mode 100644 index 00000000..8e89e52b --- /dev/null +++ b/core/src/main/java/com/minekube/connect/bedrock/BedrockIdentityEnforcer.java @@ -0,0 +1,146 @@ +package com.minekube.connect.bedrock; + +import com.minekube.connect.api.logger.ConnectLogger; +import com.minekube.connect.api.player.ConnectPlayer; +import com.minekube.connect.api.player.bedrock.BedrockIdentityClaims; +import com.minekube.connect.api.player.bedrock.BedrockIdentityReplayCache; +import com.minekube.connect.api.player.bedrock.BedrockIdentityVerificationException; +import com.minekube.connect.api.player.bedrock.BedrockIdentityVerifier; +import com.minekube.connect.config.ConnectConfig; +import com.minekube.connect.config.ConnectConfig.BedrockIdentityConfig; +import com.minekube.connect.network.netty.LocalSession; +import java.time.Instant; +import java.util.Base64; +import java.util.Locale; +import java.util.Objects; +import java.util.function.Supplier; + +public final class BedrockIdentityEnforcer { + private static final String MODE_DISABLED = "disabled"; + private static final String MODE_WARN = "warn"; + private static final String MODE_REQUIRE = "require"; + private static final String PROTOCOL_BEDROCK = "bedrock"; + private static final String REJECT_MESSAGE = "Bedrock identity verification failed"; + + private final ConnectConfig config; + private final ConnectLogger logger; + private final Supplier now; + private final BedrockIdentityReplayCache replayCache = new BedrockIdentityReplayCache(); + + public BedrockIdentityEnforcer(ConnectConfig config, ConnectLogger logger) { + this(config, logger, Instant::now); + } + + BedrockIdentityEnforcer(ConnectConfig config, ConnectLogger logger, Supplier now) { + this.config = Objects.requireNonNull(config, "config"); + this.logger = Objects.requireNonNull(logger, "logger"); + this.now = Objects.requireNonNull(now, "now"); + } + + public Decision verify(LocalSession.Context context) { + Objects.requireNonNull(context, "context"); + return verify(context.getPlayer()); + } + + public Decision verify(ConnectPlayer player) { + Objects.requireNonNull(player, "player"); + BedrockIdentityConfig bedrockIdentity = config.getBedrockIdentity(); + String mode = mode(bedrockIdentity); + if (MODE_DISABLED.equals(mode)) { + return Decision.allowed(null); + } + + if (MODE_WARN.equals(mode) && isEmpty(bedrockIdentity.getPublicKey())) { + return Decision.allowed(null); + } + + try { + BedrockIdentityClaims claims = verifier(player, bedrockIdentity).verify(player.getGameProfile()); + return Decision.allowed(claims); + } catch (RuntimeException | BedrockIdentityVerificationException e) { + warn(player, safeReason(e)); + if (MODE_REQUIRE.equals(mode)) { + return Decision.rejected(REJECT_MESSAGE); + } + return Decision.allowed(null); + } + } + + private BedrockIdentityVerifier verifier(ConnectPlayer player, BedrockIdentityConfig bedrockIdentity) { + byte[] publicKey = Base64.getDecoder().decode(requirePublicKey(bedrockIdentity)); + return BedrockIdentityVerifier.builder() + .publicKey(publicKey) + .now(now) + .endpointName(config.getEndpoint()) + .sessionId(player.getSessionId()) + .protocol(PROTOCOL_BEDROCK) + .bedrockAuthPolicy(bedrockIdentity.getExpectedPolicy()) + .replayCache(replayCache) + .build(); + } + + private static String mode(BedrockIdentityConfig bedrockIdentity) { + if (bedrockIdentity == null || isEmpty(bedrockIdentity.getEnforcement())) { + return MODE_DISABLED; + } + return bedrockIdentity.getEnforcement().toLowerCase(Locale.ROOT); + } + + private static String requirePublicKey(BedrockIdentityConfig bedrockIdentity) { + if (bedrockIdentity == null || isEmpty(bedrockIdentity.getPublicKey())) { + throw new IllegalArgumentException("Bedrock identity public key is not configured"); + } + return bedrockIdentity.getPublicKey(); + } + + private void warn(ConnectPlayer player, String reason) { + logger.warn("Bedrock identity verification failed for player={} session={}: {}", + player.getUsername(), player.getSessionId(), reason); + } + + private static String safeReason(Throwable error) { + if (error instanceof IllegalArgumentException) { + return "invalid public key"; + } + if (error instanceof BedrockIdentityVerificationException) { + return error.getMessage() == null ? "invalid identity envelope" : error.getMessage(); + } + return error.getClass().getSimpleName(); + } + + private static boolean isEmpty(String value) { + return value == null || value.isEmpty(); + } + + public static final class Decision { + private final boolean allowed; + private final String message; + private final BedrockIdentityClaims verifiedClaims; + + private Decision(boolean allowed, String message, BedrockIdentityClaims verifiedClaims) { + this.allowed = allowed; + this.message = message; + this.verifiedClaims = verifiedClaims; + } + + public static Decision allowed(BedrockIdentityClaims verifiedClaims) { + return new Decision(true, "", verifiedClaims); + } + + public static Decision rejected(String message) { + return new Decision(false, message, null); + } + + public boolean allowed() { + return allowed; + } + + public String message() { + return message; + } + + public BedrockIdentityClaims verifiedClaims() { + return verifiedClaims; + } + } +} diff --git a/core/src/test/java/com/minekube/connect/api/player/bedrock/BedrockIdentityVerifierTest.java b/core/src/test/java/com/minekube/connect/api/player/bedrock/BedrockIdentityVerifierTest.java index f5948192..60036c53 100644 --- a/core/src/test/java/com/minekube/connect/api/player/bedrock/BedrockIdentityVerifierTest.java +++ b/core/src/test/java/com/minekube/connect/api/player/bedrock/BedrockIdentityVerifierTest.java @@ -83,6 +83,25 @@ void rejectsScopeMismatch() throws Exception { assertTrue(error.getMessage().contains("scope")); } + @Test + void verifiesWhenEndpointIdAndOrgIdAreNotLocallyConfigured() throws Exception { + KeyPair keyPair = ed25519KeyPair(); + String envelope = signedEnvelope(keyPair, "nonce-a", "session-1", "endpoint-id", "endpoint", "org-id"); + BedrockIdentityVerifier verifier = BedrockIdentityVerifier.builder() + .publicKey(keyPair.getPublic().getEncoded()) + .now(NOW) + .endpointName("endpoint") + .sessionId("session-1") + .protocol("bedrock") + .bedrockAuthPolicy("trusted_bedrock_xuid") + .build(); + + BedrockIdentityClaims claims = verifier.verify(profileWithEnvelope(envelope)); + + assertEquals("endpoint-id", claims.getEndpointId()); + assertEquals("org-id", claims.getOrgId()); + } + @Test void rejectsPolicyMismatchWhenExpectedPolicyIsConfigured() throws Exception { KeyPair keyPair = ed25519KeyPair(); diff --git a/core/src/test/java/com/minekube/connect/bedrock/BedrockIdentityEnforcerTest.java b/core/src/test/java/com/minekube/connect/bedrock/BedrockIdentityEnforcerTest.java new file mode 100644 index 00000000..c7951233 --- /dev/null +++ b/core/src/test/java/com/minekube/connect/bedrock/BedrockIdentityEnforcerTest.java @@ -0,0 +1,256 @@ +package com.minekube.connect.bedrock; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.google.gson.Gson; +import com.minekube.connect.api.logger.ConnectLogger; +import com.minekube.connect.api.player.Auth; +import com.minekube.connect.api.player.ConnectPlayer; +import com.minekube.connect.api.player.GameProfile; +import com.minekube.connect.api.player.bedrock.BedrockIdentityVerifier; +import com.minekube.connect.config.ConnectConfig; +import com.minekube.connect.player.ConnectPlayerImpl; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.Signature; +import java.time.Instant; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class BedrockIdentityEnforcerTest { + private static final Gson GSON = new Gson(); + private static final Instant NOW = Instant.parse("2026-07-05T12:00:00Z"); + + @Test + void disabledModeAllowsMissingIdentityWithoutLogging() { + ConnectLogger logger = mock(ConnectLogger.class); + BedrockIdentityEnforcer enforcer = new BedrockIdentityEnforcer( + config("disabled", "", "trusted_bedrock_xuid"), + logger, + () -> NOW); + + BedrockIdentityEnforcer.Decision decision = enforcer.verify(player("session-1", profileWithoutEnvelope())); + + assertTrue(decision.allowed()); + verify(logger, never()).warn(any(), any()); + } + + @Test + void requireModeAllowsValidEnvelopeAndReturnsClaims() throws Exception { + KeyPair keyPair = ed25519KeyPair(); + ConnectConfig config = config( + "require", + base64(keyPair.getPublic().getEncoded()), + "trusted_bedrock_xuid"); + String envelope = signedEnvelope(keyPair, "nonce-a", "session-1", config.getEndpoint()); + BedrockIdentityEnforcer enforcer = new BedrockIdentityEnforcer(config, mock(ConnectLogger.class), () -> NOW); + + BedrockIdentityEnforcer.Decision decision = enforcer.verify(player("session-1", profileWithEnvelope(envelope))); + + assertTrue(decision.allowed()); + assertNotNull(decision.verifiedClaims()); + assertEquals("2533274790395904", decision.verifiedClaims().getBedrockXuid()); + } + + @Test + void requireModeRejectsMissingIdentity() { + BedrockIdentityEnforcer enforcer = new BedrockIdentityEnforcer( + config("require", base64(new byte[32]), "trusted_bedrock_xuid"), + mock(ConnectLogger.class), + () -> NOW); + + BedrockIdentityEnforcer.Decision decision = enforcer.verify(player("session-1", profileWithoutEnvelope())); + + assertFalse(decision.allowed()); + assertTrue(decision.message().contains("Bedrock identity verification failed")); + } + + @Test + void warnModeLogsAndAllowsMissingIdentity() { + ConnectLogger logger = mock(ConnectLogger.class); + BedrockIdentityEnforcer enforcer = new BedrockIdentityEnforcer( + config("warn", base64(new byte[32]), "trusted_bedrock_xuid"), + logger, + () -> NOW); + + BedrockIdentityEnforcer.Decision decision = enforcer.verify(player("session-1", profileWithoutEnvelope())); + + assertTrue(decision.allowed()); + verify(logger).warn(any(), any(), any(), any()); + } + + @Test + void warnModeAllowsInvalidPublicKeyWithoutLoggingKeyMaterial() { + ConnectLogger logger = mock(ConnectLogger.class); + BedrockIdentityEnforcer enforcer = new BedrockIdentityEnforcer( + config("warn", "not-base64-key-material", "trusted_bedrock_xuid"), + logger, + () -> NOW); + + BedrockIdentityEnforcer.Decision decision = enforcer.verify(player("session-1", profileWithoutEnvelope())); + + assertTrue(decision.allowed()); + ArgumentCaptor message = ArgumentCaptor.forClass(String.class); + verify(logger).warn(message.capture(), any(), any(), any()); + assertFalse(message.getValue().contains("not-base64-key-material")); + } + + @Test + void requireModeRejectsInvalidPublicKey() { + BedrockIdentityEnforcer enforcer = new BedrockIdentityEnforcer( + config("require", "not-base64-key-material", "trusted_bedrock_xuid"), + mock(ConnectLogger.class), + () -> NOW); + + BedrockIdentityEnforcer.Decision decision = enforcer.verify(player("session-1", profileWithoutEnvelope())); + + assertFalse(decision.allowed()); + assertFalse(decision.message().contains("not-base64-key-material")); + } + + @Test + void requireModeRejectsReplay() throws Exception { + KeyPair keyPair = ed25519KeyPair(); + ConnectConfig config = config( + "require", + base64(keyPair.getPublic().getEncoded()), + "trusted_bedrock_xuid"); + String envelope = signedEnvelope(keyPair, "nonce-a", "session-1", config.getEndpoint()); + BedrockIdentityEnforcer enforcer = new BedrockIdentityEnforcer(config, mock(ConnectLogger.class), () -> NOW); + ConnectPlayer player = player("session-1", profileWithEnvelope(envelope)); + + assertTrue(enforcer.verify(player).allowed()); + BedrockIdentityEnforcer.Decision replay = enforcer.verify(player); + + assertFalse(replay.allowed()); + assertTrue(replay.message().contains("Bedrock identity verification failed")); + } + + private static ConnectPlayer player(String sessionId, GameProfile profile) { + return new ConnectPlayerImpl(sessionId, profile, new Auth(false), ""); + } + + private static GameProfile profileWithoutEnvelope() { + return new GameProfile( + "BedrockSteve", + UUID.fromString("00000000-0000-0000-0000-000000000001"), + Collections.singletonList(new GameProfile.Property("textures", "skin", ""))); + } + + private static GameProfile profileWithEnvelope(String envelope) { + return new GameProfile( + "BedrockSteve", + UUID.fromString("00000000-0000-0000-0000-000000000001"), + Arrays.asList( + new GameProfile.Property("textures", "skin", ""), + new GameProfile.Property(BedrockIdentityVerifier.PROPERTY_NAME, envelope, ""))); + } + + private static ConnectConfig config(String enforcement, String publicKey, String expectedPolicy) { + ConnectConfig config = new ConnectConfig(); + ConnectConfig.BedrockIdentityConfig bedrockIdentity = config.getBedrockIdentity(); + setField(bedrockIdentity, "enforcement", enforcement); + setField(bedrockIdentity, "publicKey", publicKey); + setField(bedrockIdentity, "expectedPolicy", expectedPolicy); + return config; + } + + private static void setField(Object target, String name, Object value) { + try { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } + + private static KeyPair ed25519KeyPair() throws Exception { + return KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + } + + private static String base64(byte[] data) { + return Base64.getEncoder().encodeToString(data); + } + + private static String signedEnvelope( + KeyPair keyPair, + String nonce, + String sessionId, + String endpointName) throws Exception { + Envelope envelope = new Envelope(); + envelope.version = 1; + envelope.issuer = "minekube-connect-test"; + envelope.endpoint = new Endpoint(); + envelope.endpoint.id = "endpoint-id"; + envelope.endpoint.name = endpointName; + envelope.endpoint.org_id = "org-id"; + envelope.session = new Session(); + envelope.session.id = sessionId; + envelope.session.protocol = "bedrock"; + envelope.session.issued_at_unix_ms = NOW.toEpochMilli(); + envelope.session.expires_at_unix_ms = NOW.plusSeconds(300).toEpochMilli(); + envelope.session.nonce = nonce; + envelope.policy = new Policy(); + envelope.policy.bedrock_auth_mode = "trusted_bedrock_xuid"; + envelope.principal = new Principal(); + envelope.principal.type = "bedrock_xuid"; + envelope.principal.bedrock_xuid = "2533274790395904"; + envelope.principal.bedrock_username = "BedrockSteve"; + envelope.principal.bedrock_derived_uuid = "00000000-0000-0000-0000-000000000001"; + + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(keyPair.getPrivate()); + signer.update(GSON.toJson(envelope).getBytes(StandardCharsets.UTF_8)); + envelope.signature = Base64.getUrlEncoder().withoutPadding().encodeToString(signer.sign()); + return GSON.toJson(envelope); + } + + private static final class Envelope { + int version; + String issuer; + Endpoint endpoint; + Session session; + Policy policy; + Principal principal; + String signature; + } + + private static final class Endpoint { + String id; + String name; + String org_id; + } + + private static final class Session { + String id; + String protocol; + long issued_at_unix_ms; + long expires_at_unix_ms; + String nonce; + } + + private static final class Policy { + String bedrock_auth_mode; + } + + private static final class Principal { + String type; + String bedrock_xuid; + String bedrock_username; + String bedrock_derived_uuid; + } +} From b8e4c4c9f56ab69b82b9e930fe3ab99c94f68ede Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 6 Jul 2026 21:33:02 +0200 Subject: [PATCH 4/4] feat: reject invalid Bedrock identity sessions --- .../connect/listener/BungeeListener.java | 10 ++++++++ .../bedrock/BedrockIdentityEnforcer.java | 13 ++++++++++ .../connect/addon/data/SpigotDataAddon.java | 5 +++- .../connect/addon/data/SpigotDataHandler.java | 24 ++++++++++++++++++- .../addon/data/SpigotDataHandlerTest.java | 4 ++-- .../connect/listener/VelocityListener.java | 11 +++++++++ 6 files changed, 63 insertions(+), 4 deletions(-) diff --git a/bungee/src/main/java/com/minekube/connect/listener/BungeeListener.java b/bungee/src/main/java/com/minekube/connect/listener/BungeeListener.java index 44c34188..a31ea9b3 100644 --- a/bungee/src/main/java/com/minekube/connect/listener/BungeeListener.java +++ b/bungee/src/main/java/com/minekube/connect/listener/BungeeListener.java @@ -31,6 +31,8 @@ import com.minekube.connect.api.ProxyConnectApi; import com.minekube.connect.api.logger.ConnectLogger; import com.minekube.connect.api.player.ConnectPlayer; +import com.minekube.connect.bedrock.BedrockIdentityEnforcer; +import com.minekube.connect.bedrock.BedrockIdentityEnforcer.Decision; import com.minekube.connect.network.netty.LocalSession; import com.minekube.connect.util.LanguageManager; import com.minekube.connect.util.ReflectionUtils; @@ -64,6 +66,7 @@ public final class BungeeListener implements Listener { @Inject private ProxyConnectApi api; @Inject private LanguageManager languageManager; @Inject private ConnectLogger logger; + @Inject private BedrockIdentityEnforcer bedrockIdentityEnforcer; @EventHandler(priority = EventPriority.LOWEST) public void onPreLogin(PreLoginEvent event) { @@ -78,6 +81,13 @@ public void onPreLogin(PreLoginEvent event) { Channel channel = wrapper.getHandle(); LocalSession.context(channel, ctx -> { + Decision decision = bedrockIdentityEnforcer.verify(ctx); + if (!decision.allowed()) { + bedrockIdentityEnforcer.reject(ctx, decision); + event.setCancelReason(decision.message()); + event.setCancelled(true); + return; + } connection.setOnlineMode(false); connection.setUniqueId(ctx.getPlayer().getUniqueId()); ReflectionUtils.setValue(connection, PLAYER_NAME, ctx.getPlayer().getUsername()); diff --git a/core/src/main/java/com/minekube/connect/bedrock/BedrockIdentityEnforcer.java b/core/src/main/java/com/minekube/connect/bedrock/BedrockIdentityEnforcer.java index 8e89e52b..c726a5e4 100644 --- a/core/src/main/java/com/minekube/connect/bedrock/BedrockIdentityEnforcer.java +++ b/core/src/main/java/com/minekube/connect/bedrock/BedrockIdentityEnforcer.java @@ -1,5 +1,8 @@ package com.minekube.connect.bedrock; +import com.google.inject.Inject; +import com.google.rpc.Code; +import com.google.rpc.Status; import com.minekube.connect.api.logger.ConnectLogger; import com.minekube.connect.api.player.ConnectPlayer; import com.minekube.connect.api.player.bedrock.BedrockIdentityClaims; @@ -27,6 +30,7 @@ public final class BedrockIdentityEnforcer { private final Supplier now; private final BedrockIdentityReplayCache replayCache = new BedrockIdentityReplayCache(); + @Inject public BedrockIdentityEnforcer(ConnectConfig config, ConnectLogger logger) { this(config, logger, Instant::now); } @@ -42,6 +46,15 @@ public Decision verify(LocalSession.Context context) { return verify(context.getPlayer()); } + public void reject(LocalSession.Context context, Decision decision) { + Objects.requireNonNull(context, "context"); + Objects.requireNonNull(decision, "decision"); + context.getSessionProposal().reject(Status.newBuilder() + .setCode(Code.PERMISSION_DENIED_VALUE) + .setMessage(decision.message()) + .build()); + } + public Decision verify(ConnectPlayer player) { Objects.requireNonNull(player, "player"); BedrockIdentityConfig bedrockIdentity = config.getBedrockIdentity(); diff --git a/spigot/src/main/java/com/minekube/connect/addon/data/SpigotDataAddon.java b/spigot/src/main/java/com/minekube/connect/addon/data/SpigotDataAddon.java index e791f597..ac480ae7 100644 --- a/spigot/src/main/java/com/minekube/connect/addon/data/SpigotDataAddon.java +++ b/spigot/src/main/java/com/minekube/connect/addon/data/SpigotDataAddon.java @@ -30,6 +30,7 @@ import com.minekube.connect.api.SimpleConnectApi; import com.minekube.connect.api.inject.InjectorAddon; import com.minekube.connect.api.logger.ConnectLogger; +import com.minekube.connect.bedrock.BedrockIdentityEnforcer; import com.minekube.connect.config.ConnectConfig; import com.minekube.connect.network.netty.LocalChannelInboundHandler; import com.minekube.connect.network.netty.LocalSession; @@ -39,6 +40,7 @@ public final class SpigotDataAddon implements InjectorAddon { @Inject private ConnectConfig config; @Inject private SimpleConnectApi api; @Inject private ConnectLogger logger; + @Inject private BedrockIdentityEnforcer bedrockIdentityEnforcer; @Inject @Named("packetHandler") @@ -54,7 +56,8 @@ public void onInject(Channel channel, boolean toServer) { new SpigotDataHandler(ctx, packetHandlerName, config, - logger) + logger, + bedrockIdentityEnforcer) ); if (channel.pipeline().get(SpigotChatSessionPacketFilter.HANDLER_NAME) == null) { channel.pipeline().addBefore( diff --git a/spigot/src/main/java/com/minekube/connect/addon/data/SpigotDataHandler.java b/spigot/src/main/java/com/minekube/connect/addon/data/SpigotDataHandler.java index 5fdb7f14..6a147c10 100644 --- a/spigot/src/main/java/com/minekube/connect/addon/data/SpigotDataHandler.java +++ b/spigot/src/main/java/com/minekube/connect/addon/data/SpigotDataHandler.java @@ -31,6 +31,8 @@ import com.google.gson.Gson; import com.minekube.connect.SpigotPlugin; import com.minekube.connect.api.logger.ConnectLogger; +import com.minekube.connect.bedrock.BedrockIdentityEnforcer; +import com.minekube.connect.bedrock.BedrockIdentityEnforcer.Decision; import com.minekube.connect.config.ConnectConfig; import com.minekube.connect.network.netty.LocalSession.Context; import com.minekube.connect.util.ClassNames; @@ -45,16 +47,19 @@ public final class SpigotDataHandler extends CommonDataHandler { private final Context sessionCtx; private final String packetHandlerName; private final ConnectLogger logger; + private final BedrockIdentityEnforcer bedrockIdentityEnforcer; public SpigotDataHandler( Context sessionCtx, String packetHandlerName, ConnectConfig config, - ConnectLogger logger) { + ConnectLogger logger, + BedrockIdentityEnforcer bedrockIdentityEnforcer) { super(config); this.sessionCtx = sessionCtx; this.packetHandlerName = packetHandlerName; this.logger = logger; + this.bedrockIdentityEnforcer = bedrockIdentityEnforcer; } private void removeSelf() { @@ -150,6 +155,9 @@ public boolean channelRead(Object packet) throws Exception { } if (ClassNames.LOGIN_START_PACKET.isInstance(packet)) { debug("Processing LOGIN_START_PACKET for " + sessionCtx.getPlayer().getUsername()); + if (!enforceBedrockIdentity()) { + return false; + } Object networkManager = ctx.channel().pipeline().get(packetHandlerName); Object packetListener = ClassNames.PACKET_LISTENER.get(networkManager); @@ -229,6 +237,20 @@ public boolean channelRead(Object packet) throws Exception { return true; } + boolean enforceBedrockIdentity() { + if (bedrockIdentityEnforcer == null) { + return true; + } + Decision decision = bedrockIdentityEnforcer.verify(sessionCtx); + if (decision.allowed()) { + return true; + } + bedrockIdentityEnforcer.reject(sessionCtx, decision); + ctx.close(); + removeSelf(); + return false; + } + private static final Gson GSON = new Gson(); // source https://github.com/PaperMC/Velocity/blob/2586210ca67f2510eb4f91bf7567643f8a26ee7b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/VelocityServerConnection.java#L126 diff --git a/spigot/src/test/java/com/minekube/connect/addon/data/SpigotDataHandlerTest.java b/spigot/src/test/java/com/minekube/connect/addon/data/SpigotDataHandlerTest.java index 4c5fedc3..171df540 100644 --- a/spigot/src/test/java/com/minekube/connect/addon/data/SpigotDataHandlerTest.java +++ b/spigot/src/test/java/com/minekube/connect/addon/data/SpigotDataHandlerTest.java @@ -5,8 +5,8 @@ import io.netty.channel.embedded.EmbeddedChannel; import io.netty.channel.local.LocalAddress; -import java.net.InetSocketAddress; import java.lang.reflect.Method; +import java.net.InetSocketAddress; import org.junit.jupiter.api.Test; class SpigotDataHandlerTest { @@ -21,7 +21,7 @@ void usesSpoofedPlayerAddressWhenLocalChannelHasNoInetRemoteAddress() { @Test void removeSelfIsIdempotentWhenHandshakeReplacementReentersPipeline() throws Exception { - SpigotDataHandler handler = new SpigotDataHandler(null, "packet-handler", null, null); + SpigotDataHandler handler = new SpigotDataHandler(null, "packet-handler", null, null, null); EmbeddedChannel channel = new EmbeddedChannel(handler); Method removeSelf = SpigotDataHandler.class.getDeclaredMethod("removeSelf"); removeSelf.setAccessible(true); diff --git a/velocity/src/main/java/com/minekube/connect/listener/VelocityListener.java b/velocity/src/main/java/com/minekube/connect/listener/VelocityListener.java index 349b9ce1..14293caa 100644 --- a/velocity/src/main/java/com/minekube/connect/listener/VelocityListener.java +++ b/velocity/src/main/java/com/minekube/connect/listener/VelocityListener.java @@ -38,6 +38,8 @@ import com.minekube.connect.api.ProxyConnectApi; import com.minekube.connect.api.logger.ConnectLogger; import com.minekube.connect.api.player.ConnectPlayer; +import com.minekube.connect.bedrock.BedrockIdentityEnforcer; +import com.minekube.connect.bedrock.BedrockIdentityEnforcer.Decision; import com.minekube.connect.network.netty.LocalSession; import com.minekube.connect.util.LanguageManager; import com.velocitypowered.api.event.PostOrder; @@ -54,6 +56,7 @@ import java.util.Objects; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +import net.kyori.adventure.text.Component; public final class VelocityListener { private static final Field INITIAL_MINECRAFT_CONNECTION; @@ -90,6 +93,7 @@ public final class VelocityListener { @Inject private ProxyConnectApi api; @Inject private LanguageManager languageManager; @Inject private ConnectLogger logger; + @Inject private BedrockIdentityEnforcer bedrockIdentityEnforcer; @Subscribe(order = PostOrder.EARLY) public void onPreLogin(PreLoginEvent event) { @@ -106,6 +110,13 @@ public void onPreLogin(PreLoginEvent event) { LocalSession.context(channel, ctx -> { if (!ctx.getPlayer().getAuth().isPassthrough()) { + Decision decision = bedrockIdentityEnforcer.verify(ctx); + if (!decision.allowed()) { + bedrockIdentityEnforcer.reject(ctx, decision); + event.setResult(PreLoginEvent.PreLoginComponentResult.denied( + Component.text(decision.message()))); + return; + } // Means the TunnelService has already authenticated the player event.setResult(PreLoginEvent.PreLoginComponentResult.forceOfflineMode()); playerCache.put(event.getConnection(), ctx.getPlayer());