Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
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;
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<Instant> now;
private final BedrockIdentityReplayCache replayCache = new BedrockIdentityReplayCache();

@Inject
public BedrockIdentityEnforcer(ConnectConfig config, ConnectLogger logger) {
this(config, logger, Instant::now);
}

BedrockIdentityEnforcer(ConnectConfig config, ConnectLogger logger, Supplier<Instant> 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 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();
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;
}
}
}
21 changes: 21 additions & 0 deletions core/src/main/java/com/minekube/connect/config/ConnectConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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";
}
}
12 changes: 12 additions & 0 deletions core/src/main/resources/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions core/src/main/resources/proxy-config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading