diff --git a/api/src/main/java/com/minekube/connect/api/ConnectApi.java b/api/src/main/java/com/minekube/connect/api/ConnectApi.java
index c29c3171d..4a5521b83 100644
--- a/api/src/main/java/com/minekube/connect/api/ConnectApi.java
+++ b/api/src/main/java/com/minekube/connect/api/ConnectApi.java
@@ -26,7 +26,9 @@
package com.minekube.connect.api;
import com.minekube.connect.api.player.ConnectPlayer;
+import com.minekube.connect.api.player.bedrock.BedrockIdentityClaims;
import java.util.Collection;
+import java.util.Optional;
import java.util.UUID;
public interface ConnectApi {
@@ -48,7 +50,7 @@ static ConnectApi getInstance() {
int getPlayerCount();
/**
- * Method to determine if the given online player is a bedrock player
+ * Determines whether the given online player is tunneled by Connect.
*
* @param uuid The uuid of the online player
* @return true if the given online player is tunneled by Connect
@@ -62,4 +64,17 @@ static ConnectApi getInstance() {
* @return ConnectPlayer if the given uuid is a player tunneled by Connect
*/
ConnectPlayer getPlayer(UUID uuid);
+
+ /**
+ * Returns claims from a Moxy-signed Bedrock identity envelope that Connect verified for the
+ * player's current session. Java, disabled, warn-failed, rejected, and disconnected sessions
+ * return empty. The claims' XUID accessor is sensitive trusted identity material intended only
+ * for in-process authorization; callers must not log, serialize, forward, or expose it.
+ *
+ * @param player a player object returned by this API for the currently connected session
+ * @return immutable verified claims, or empty when no verified claims exist for the session
+ */
+ default Optional getVerifiedBedrockIdentity(ConnectPlayer player) {
+ return Optional.empty();
+ }
}
diff --git a/api/src/main/java/com/minekube/connect/api/player/bedrock/BedrockIdentityClaims.java b/api/src/main/java/com/minekube/connect/api/player/bedrock/BedrockIdentityClaims.java
index 745c5519c..f89a7ea5f 100644
--- a/api/src/main/java/com/minekube/connect/api/player/bedrock/BedrockIdentityClaims.java
+++ b/api/src/main/java/com/minekube/connect/api/player/bedrock/BedrockIdentityClaims.java
@@ -3,6 +3,11 @@
import java.time.Instant;
import lombok.Value;
+/**
+ * Immutable claims decoded from a signed Bedrock identity envelope. Claims returned by
+ * {@code ConnectApi#getVerifiedBedrockIdentity} have passed Connect's admission checks and are
+ * bound to the current Connect session.
+ */
@Value
public class BedrockIdentityClaims {
String issuer;
@@ -13,12 +18,155 @@ public class BedrockIdentityClaims {
String protocol;
String bedrockAuthPolicy;
String principalType;
- String bedrockXuid;
+ transient String bedrockXuid;
String bedrockUsername;
String bedrockDerivedUuid;
String linkedJavaUuid;
String linkedJavaName;
Instant issuedAt;
Instant expiresAt;
- String nonce;
+
+ /**
+ * Creates an immutable Bedrock identity claim set.
+ *
+ * @param issuer signed envelope issuer
+ * @param endpointId signed Connect endpoint ID
+ * @param endpointName signed Connect endpoint name
+ * @param orgId signed Connect organization ID
+ * @param sessionId signed Connect session ID
+ * @param protocol signed client protocol
+ * @param bedrockAuthPolicy signed Bedrock authentication policy
+ * @param principalType signed principal type
+ * @param bedrockXuid canonical Bedrock XUID
+ * @param bedrockUsername Bedrock username
+ * @param bedrockDerivedUuid deterministic UUID derived from the XUID
+ * @param linkedJavaUuid linked Java UUID, or {@code null} for an unlinked principal
+ * @param linkedJavaName linked Java name, or {@code null} for an unlinked principal
+ * @param issuedAt envelope issue time
+ * @param expiresAt envelope expiration time
+ */
+ public BedrockIdentityClaims(
+ String issuer,
+ String endpointId,
+ String endpointName,
+ String orgId,
+ String sessionId,
+ String protocol,
+ String bedrockAuthPolicy,
+ String principalType,
+ String bedrockXuid,
+ String bedrockUsername,
+ String bedrockDerivedUuid,
+ String linkedJavaUuid,
+ String linkedJavaName,
+ Instant issuedAt,
+ Instant expiresAt) {
+ this.issuer = issuer;
+ this.endpointId = endpointId;
+ this.endpointName = endpointName;
+ this.orgId = orgId;
+ this.sessionId = sessionId;
+ this.protocol = protocol;
+ this.bedrockAuthPolicy = bedrockAuthPolicy;
+ this.principalType = principalType;
+ this.bedrockXuid = bedrockXuid;
+ this.bedrockUsername = bedrockUsername;
+ this.bedrockDerivedUuid = bedrockDerivedUuid;
+ this.linkedJavaUuid = linkedJavaUuid;
+ this.linkedJavaName = linkedJavaName;
+ this.issuedAt = issuedAt;
+ this.expiresAt = expiresAt;
+ }
+
+ /**
+ * @deprecated The nonce is private replay-protection data and is deliberately not retained.
+ * This constructor remains only for source compatibility; {@code ignoredNonce} is ignored.
+ *
+ * @param issuer signed envelope issuer
+ * @param endpointId signed Connect endpoint ID
+ * @param endpointName signed Connect endpoint name
+ * @param orgId signed Connect organization ID
+ * @param sessionId signed Connect session ID
+ * @param protocol signed client protocol
+ * @param bedrockAuthPolicy signed Bedrock authentication policy
+ * @param principalType signed principal type
+ * @param bedrockXuid canonical Bedrock XUID
+ * @param bedrockUsername Bedrock username
+ * @param bedrockDerivedUuid deterministic UUID derived from the XUID
+ * @param linkedJavaUuid linked Java UUID, or {@code null} for an unlinked principal
+ * @param linkedJavaName linked Java name, or {@code null} for an unlinked principal
+ * @param issuedAt envelope issue time
+ * @param expiresAt envelope expiration time
+ * @param ignoredNonce ignored legacy nonce
+ */
+ @Deprecated
+ public BedrockIdentityClaims(
+ String issuer,
+ String endpointId,
+ String endpointName,
+ String orgId,
+ String sessionId,
+ String protocol,
+ String bedrockAuthPolicy,
+ String principalType,
+ String bedrockXuid,
+ String bedrockUsername,
+ String bedrockDerivedUuid,
+ String linkedJavaUuid,
+ String linkedJavaName,
+ Instant issuedAt,
+ Instant expiresAt,
+ String ignoredNonce) {
+ this(
+ issuer,
+ endpointId,
+ endpointName,
+ orgId,
+ sessionId,
+ protocol,
+ bedrockAuthPolicy,
+ principalType,
+ bedrockXuid,
+ bedrockUsername,
+ bedrockDerivedUuid,
+ linkedJavaUuid,
+ linkedJavaName,
+ issuedAt,
+ expiresAt);
+ }
+
+ /**
+ * @deprecated The nonce is private replay-protection data and is no longer exposed.
+ *
+ * @return always {@code null}
+ */
+ @Deprecated
+ public String getNonce() {
+ return null;
+ }
+
+ /**
+ * Returns the sensitive, trusted Bedrock XUID for in-process plugin authorization only.
+ * Do not log, serialize, forward, or expose this value to untrusted consumers.
+ *
+ * @return canonical XUID from the verified signed envelope
+ */
+ @java.beans.Transient
+ public String getBedrockXuid() {
+ return bedrockXuid;
+ }
+
+ @Override
+ public String toString() {
+ return "BedrockIdentityClaims(issuer=" + issuer
+ + ", endpointId=" + endpointId
+ + ", orgId=" + orgId
+ + ", sessionId=" + sessionId
+ + ", protocol=" + protocol
+ + ", bedrockAuthPolicy=" + bedrockAuthPolicy
+ + ", principalType=" + principalType
+ + ", issuedAt=" + issuedAt
+ + ", expiresAt=" + expiresAt
+ + ")";
+ }
}
diff --git a/api/src/main/java/com/minekube/connect/api/player/bedrock/BedrockIdentityProfiles.java b/api/src/main/java/com/minekube/connect/api/player/bedrock/BedrockIdentityProfiles.java
new file mode 100644
index 000000000..f7ca88150
--- /dev/null
+++ b/api/src/main/java/com/minekube/connect/api/player/bedrock/BedrockIdentityProfiles.java
@@ -0,0 +1,47 @@
+package com.minekube.connect.api.player.bedrock;
+
+import com.minekube.connect.api.player.GameProfile;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+/** Utilities for removing private Bedrock identity admission properties from public profiles. */
+public final class BedrockIdentityProfiles {
+ /** Private transport property carrying endpoint and organization scope for legacy Watch. */
+ public static final String SCOPE_PROPERTY_NAME = "minekube:bedrock_identity_scope";
+
+ private BedrockIdentityProfiles() {
+ }
+
+ /**
+ * Returns a profile without the signed identity envelope or transport scope property.
+ *
+ * @param profile the profile to sanitize
+ * @return {@code profile} when it contains no private properties, otherwise a sanitized copy
+ */
+ public static GameProfile withoutEnvelope(GameProfile profile) {
+ Objects.requireNonNull(profile, "profile");
+ List properties = profile.getProperties().stream()
+ .filter(BedrockIdentityProfiles::isPublic)
+ .collect(Collectors.toList());
+ if (properties.size() == profile.getProperties().size()) {
+ return profile;
+ }
+ return new GameProfile(
+ profile.getUsername(),
+ profile.getUniqueId(),
+ Collections.unmodifiableList(properties));
+ }
+
+ /**
+ * Determines whether a profile property may be exposed outside identity admission.
+ *
+ * @param property the property to classify
+ * @return true unless the property is reserved for Bedrock identity admission
+ */
+ public static boolean isPublic(GameProfile.Property property) {
+ return !BedrockIdentityVerifier.PROPERTY_NAME.equals(property.getName()) &&
+ !SCOPE_PROPERTY_NAME.equals(property.getName());
+ }
+}
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 c997bc752..865fdbcb3 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
@@ -2,11 +2,17 @@
import com.google.gson.Gson;
import com.google.gson.JsonSyntaxException;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonToken;
import com.minekube.connect.api.player.GameProfile;
+import java.io.IOException;
+import java.io.StringReader;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.Signature;
import java.security.interfaces.EdECPublicKey;
@@ -16,8 +22,12 @@
import java.security.spec.X509EncodedKeySpec;
import java.time.Instant;
import java.util.Base64;
+import java.util.HashSet;
import java.util.Objects;
+import java.util.Set;
+import java.util.UUID;
import java.util.function.Supplier;
+import java.util.regex.Pattern;
public final class BedrockIdentityVerifier {
public static final String PROPERTY_NAME = "minekube:bedrock_identity";
@@ -28,6 +38,16 @@ public final class BedrockIdentityVerifier {
private static final String PRINCIPAL_BEDROCK_XUID = "bedrock_xuid";
private static final String PRINCIPAL_BEDROCK_LINKED_JAVA = "bedrock_linked_java";
private static final Gson GSON = new Gson();
+ private static final Set ENVELOPE_FIELDS = Set.of(
+ "version", "issuer", "endpoint", "session", "policy", "principal", "signature");
+ private static final Set ENDPOINT_FIELDS = Set.of("id", "name", "org_id");
+ private static final Set SESSION_FIELDS = Set.of(
+ "id", "protocol", "issued_at_unix_ms", "expires_at_unix_ms", "nonce");
+ private static final Set POLICY_FIELDS = Set.of("bedrock_auth_mode");
+ private static final Set PRINCIPAL_FIELDS = Set.of(
+ "type", "bedrock_xuid", "bedrock_username", "bedrock_derived_uuid",
+ "linked_java_uuid", "linked_java_name");
+ private static final Pattern INTEGER = Pattern.compile("-?(?:0|[1-9][0-9]*)");
private final PublicKey publicKey;
private final Supplier now;
@@ -37,6 +57,7 @@ public final class BedrockIdentityVerifier {
private final String sessionId;
private final String protocol;
private final String bedrockAuthPolicy;
+ private final String expectedIssuer;
private final BedrockIdentityReplayCache replayCache;
private BedrockIdentityVerifier(Builder builder) {
@@ -48,6 +69,7 @@ private BedrockIdentityVerifier(Builder builder) {
this.sessionId = requireNonEmpty(builder.sessionId, "sessionId");
this.protocol = requireNonEmpty(builder.protocol, "protocol");
this.bedrockAuthPolicy = builder.bedrockAuthPolicy;
+ this.expectedIssuer = optionalNonEmpty(builder.expectedIssuer, "expectedIssuer");
this.replayCache = builder.replayCache;
}
@@ -57,24 +79,42 @@ public static Builder builder() {
public BedrockIdentityClaims verify(GameProfile profile) throws BedrockIdentityVerificationException {
Objects.requireNonNull(profile, "profile");
+ String signedEnvelope = null;
for (GameProfile.Property property : profile.getProperties()) {
if (PROPERTY_NAME.equals(property.getName())) {
- return verify(property.getValue());
+ if (signedEnvelope != null) {
+ throw new BedrockIdentityVerificationException("duplicate " + PROPERTY_NAME + " profile property");
+ }
+ signedEnvelope = property.getValue();
}
}
+ if (signedEnvelope != null) {
+ return verify(signedEnvelope, profile);
+ }
throw new BedrockIdentityVerificationException("missing " + PROPERTY_NAME + " profile property");
}
public BedrockIdentityClaims verify(String signedEnvelope) throws BedrockIdentityVerificationException {
+ return verify(signedEnvelope, null);
+ }
+
+ private BedrockIdentityClaims verify(String signedEnvelope, GameProfile profile)
+ throws BedrockIdentityVerificationException {
Envelope envelope = decode(signedEnvelope);
verifySignature(envelope);
validate(envelope);
validateScope(envelope);
+ if (profile != null) {
+ validateProfile(envelope, profile);
+ }
long nowUnixMs = now.get().toEpochMilli();
if (nowUnixMs >= envelope.session.expires_at_unix_ms) {
throw new BedrockIdentityVerificationException("identity envelope expired");
}
+ if (envelope.session.issued_at_unix_ms > nowUnixMs + 60_000) {
+ throw new BedrockIdentityVerificationException("identity envelope issued too far in the future");
+ }
if (replayCache != null && !replayCache.accept(
envelope.endpoint.id,
envelope.session.id,
@@ -99,11 +139,29 @@ public BedrockIdentityClaims verify(String signedEnvelope) throws BedrockIdentit
envelope.principal.linked_java_uuid,
envelope.principal.linked_java_name,
Instant.ofEpochMilli(envelope.session.issued_at_unix_ms),
- Instant.ofEpochMilli(envelope.session.expires_at_unix_ms),
- envelope.session.nonce);
+ Instant.ofEpochMilli(envelope.session.expires_at_unix_ms));
+ }
+
+ private static void validateProfile(Envelope envelope, GameProfile profile)
+ throws BedrockIdentityVerificationException {
+ UUID expectedUuid;
+ if (PRINCIPAL_BEDROCK_LINKED_JAVA.equals(envelope.principal.type)) {
+ expectedUuid = UUID.fromString(envelope.principal.linked_java_uuid);
+ if (!expectedUuid.equals(profile.getUniqueId()) ||
+ !envelope.principal.linked_java_name.equalsIgnoreCase(profile.getUsername())) {
+ throw new BedrockIdentityVerificationException("identity envelope profile mismatch");
+ }
+ } else {
+ expectedUuid = UUID.fromString(envelope.principal.bedrock_derived_uuid);
+ if (!expectedUuid.equals(profile.getUniqueId())) {
+ throw new BedrockIdentityVerificationException("identity envelope profile mismatch");
+ }
+ }
}
private static Envelope decode(String signedEnvelope) throws BedrockIdentityVerificationException {
+ // Gson coerces some scalar forms; validate Moxy v1's exact JSON token shapes first.
+ validateStructure(signedEnvelope);
try {
Envelope envelope = GSON.fromJson(signedEnvelope, Envelope.class);
if (envelope == null) {
@@ -115,6 +173,79 @@ private static Envelope decode(String signedEnvelope) throws BedrockIdentityVeri
}
}
+ private static void validateStructure(String signedEnvelope) throws BedrockIdentityVerificationException {
+ try (JsonReader reader = new JsonReader(new StringReader(signedEnvelope))) {
+ validateObject(reader, "envelope", ENVELOPE_FIELDS);
+ if (reader.peek() != JsonToken.END_DOCUMENT) {
+ throw new BedrockIdentityVerificationException("decode envelope: trailing JSON value");
+ }
+ } catch (IOException | IllegalStateException e) {
+ throw new BedrockIdentityVerificationException("decode envelope", e);
+ }
+ }
+
+ private static void validateObject(JsonReader reader, String object, Set allowedFields)
+ throws IOException, BedrockIdentityVerificationException {
+ reader.beginObject();
+ Set seenFields = new HashSet<>();
+ while (reader.hasNext()) {
+ String field = reader.nextName();
+ if (!seenFields.add(field)) {
+ throw new BedrockIdentityVerificationException("decode envelope: duplicate field");
+ }
+ if (!allowedFields.contains(field)) {
+ throw new BedrockIdentityVerificationException("decode envelope: unknown field");
+ }
+ Set nestedFields = nestedFields(object, field);
+ if (nestedFields == null) {
+ validateScalar(reader, object, field);
+ } else {
+ validateObject(reader, field, nestedFields);
+ }
+ }
+ reader.endObject();
+ }
+
+ private static void validateScalar(JsonReader reader, String object, String field)
+ throws IOException, BedrockIdentityVerificationException {
+ JsonToken token = reader.peek();
+ if (integerField(object, field)) {
+ if (token != JsonToken.NUMBER || !INTEGER.matcher(reader.nextString()).matches()) {
+ throw new BedrockIdentityVerificationException("decode envelope: invalid integer field");
+ }
+ return;
+ }
+ if (token != JsonToken.STRING) {
+ throw new BedrockIdentityVerificationException("decode envelope: invalid string field");
+ }
+ reader.nextString();
+ }
+
+ private static boolean integerField(String object, String field) {
+ return ("envelope".equals(object) && "version".equals(field))
+ || ("session".equals(object)
+ && ("issued_at_unix_ms".equals(field) || "expires_at_unix_ms".equals(field)));
+ }
+
+
+ private static Set nestedFields(String object, String field) {
+ if (!"envelope".equals(object)) {
+ return null;
+ }
+ switch (field) {
+ case "endpoint":
+ return ENDPOINT_FIELDS;
+ case "session":
+ return SESSION_FIELDS;
+ case "policy":
+ return POLICY_FIELDS;
+ case "principal":
+ return PRINCIPAL_FIELDS;
+ default:
+ return null;
+ }
+ }
+
private void verifySignature(Envelope envelope) throws BedrockIdentityVerificationException {
byte[] signature;
try {
@@ -144,7 +275,7 @@ private static void validate(Envelope envelope) throws BedrockIdentityVerificati
if (envelope.version != VERSION) {
throw new BedrockIdentityVerificationException("unsupported identity envelope version " + envelope.version);
}
- if (envelope.endpoint == null ||
+ if (isEmpty(envelope.issuer) || envelope.endpoint == null ||
isEmpty(envelope.endpoint.id) ||
isEmpty(envelope.endpoint.name) ||
isEmpty(envelope.endpoint.org_id)) {
@@ -156,7 +287,12 @@ private static void validate(Envelope envelope) throws BedrockIdentityVerificati
isEmpty(envelope.session.nonce)) {
throw new BedrockIdentityVerificationException("identity envelope session scope is incomplete");
}
- if (envelope.session.expires_at_unix_ms <= envelope.session.issued_at_unix_ms) {
+ if (!validNonce(envelope.session.nonce)) {
+ throw new BedrockIdentityVerificationException("identity envelope nonce is invalid");
+ }
+ if (envelope.session.issued_at_unix_ms <= 0 ||
+ envelope.session.expires_at_unix_ms <= envelope.session.issued_at_unix_ms ||
+ envelope.session.expires_at_unix_ms - envelope.session.issued_at_unix_ms > 300_000) {
throw new BedrockIdentityVerificationException("identity envelope expiry must be after issue time");
}
if (envelope.policy == null || !validPolicy(envelope.policy.bedrock_auth_mode)) {
@@ -165,16 +301,21 @@ private static void validate(Envelope envelope) throws BedrockIdentityVerificati
if (envelope.principal == null) {
throw new BedrockIdentityVerificationException("identity envelope principal is incomplete");
}
+ if (!canonicalPositiveXuid(envelope.principal.bedrock_xuid) ||
+ isEmpty(envelope.principal.bedrock_username) ||
+ !derivedUuid(envelope.principal.bedrock_xuid).equals(envelope.principal.bedrock_derived_uuid)) {
+ throw new BedrockIdentityVerificationException("Bedrock principal identity is invalid");
+ }
if (PRINCIPAL_BEDROCK_XUID.equals(envelope.principal.type)) {
- if (isEmpty(envelope.principal.bedrock_xuid)) {
- throw new BedrockIdentityVerificationException("bedrock_xuid principal requires xuid");
+ if (!POLICY_TRUSTED_BEDROCK_XUID.equals(envelope.policy.bedrock_auth_mode) ||
+ !isEmpty(envelope.principal.linked_java_uuid) || !isEmpty(envelope.principal.linked_java_name)) {
+ throw new BedrockIdentityVerificationException("bedrock_xuid principal requires trusted_bedrock_xuid policy");
}
return;
}
if (PRINCIPAL_BEDROCK_LINKED_JAVA.equals(envelope.principal.type)) {
- if (isEmpty(envelope.principal.bedrock_xuid) ||
- isEmpty(envelope.principal.linked_java_uuid) ||
- isEmpty(envelope.principal.linked_java_name)) {
+ if (isEmpty(envelope.principal.linked_java_uuid) ||
+ isEmpty(envelope.principal.linked_java_name) || !validUuid(envelope.principal.linked_java_uuid)) {
throw new BedrockIdentityVerificationException(
"bedrock_linked_java principal requires xuid and linked Java identity");
}
@@ -185,6 +326,9 @@ private static void validate(Envelope envelope) throws BedrockIdentityVerificati
}
private void validateScope(Envelope envelope) throws BedrockIdentityVerificationException {
+ if (expectedIssuer != null && !expectedIssuer.equals(envelope.issuer)) {
+ throw new BedrockIdentityVerificationException("identity envelope issuer mismatch");
+ }
if ((endpointId != null && !endpointId.equals(envelope.endpoint.id)) ||
!endpointName.equals(envelope.endpoint.name) ||
(orgId != null && !orgId.equals(envelope.endpoint.org_id)) ||
@@ -201,6 +345,19 @@ private static boolean validPolicy(String policy) {
return POLICY_LINKED_JAVA_ONLY.equals(policy) || POLICY_TRUSTED_BEDROCK_XUID.equals(policy);
}
+ private static boolean validNonce(String nonce) {
+ if (nonce.length() != 22 || nonce.indexOf('=') >= 0) {
+ return false;
+ }
+ try {
+ byte[] decoded = Base64.getUrlDecoder().decode(nonce);
+ return decoded.length == 16 && nonce.equals(
+ Base64.getUrlEncoder().withoutPadding().encodeToString(decoded));
+ } catch (IllegalArgumentException ignored) {
+ return false;
+ }
+ }
+
private static PublicKey parsePublicKey(byte[] publicKey) {
try {
return KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(publicKey));
@@ -249,6 +406,43 @@ private static boolean isEmpty(String value) {
return value == null || value.isEmpty();
}
+ private static boolean canonicalPositiveXuid(String value) {
+ if (isEmpty(value) || value.charAt(0) == '0' || !value.chars().allMatch(Character::isDigit)) {
+ return false;
+ }
+ try {
+ return Long.parseLong(value) > 0;
+ } catch (NumberFormatException ignored) {
+ return false;
+ }
+ }
+
+ private static boolean validUuid(String value) {
+ try {
+ return !new UUID(0, 0).equals(UUID.fromString(value));
+ } catch (IllegalArgumentException ignored) {
+ return false;
+ }
+ }
+
+ private static String derivedUuid(String xuid) {
+ try {
+ byte[] hash = MessageDigest.getInstance("SHA-1")
+ .digest(("FloodgateXUID:" + xuid).getBytes(StandardCharsets.UTF_8));
+ hash[6] = (byte) ((hash[6] & 0x0f) | 0x50);
+ hash[8] = (byte) ((hash[8] & 0x3f) | 0x80);
+ long most = 0;
+ long least = 0;
+ for (int i = 0; i < 8; i++) {
+ most = (most << 8) | (hash[i] & 0xffL);
+ least = (least << 8) | (hash[i + 8] & 0xffL);
+ }
+ return new UUID(most, least).toString();
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-1 unavailable", e);
+ }
+ }
+
public static final class Builder {
private PublicKey publicKey;
private Supplier now = Instant::now;
@@ -258,6 +452,7 @@ public static final class Builder {
private String sessionId;
private String protocol;
private String bedrockAuthPolicy;
+ private String expectedIssuer;
private BedrockIdentityReplayCache replayCache;
public Builder publicKey(byte[] publicKey) {
@@ -306,6 +501,17 @@ public Builder bedrockAuthPolicy(String bedrockAuthPolicy) {
return this;
}
+ /**
+ * Sets the signed issuer required during verification.
+ *
+ * @param expectedIssuer expected signed issuer, or {@code null} to skip issuer equality
+ * @return this builder
+ */
+ public Builder expectedIssuer(String expectedIssuer) {
+ this.expectedIssuer = expectedIssuer;
+ return this;
+ }
+
public Builder replayCache(BedrockIdentityReplayCache replayCache) {
this.replayCache = replayCache;
return this;
diff --git a/core/src/main/java/com/minekube/connect/ConnectPlatform.java b/core/src/main/java/com/minekube/connect/ConnectPlatform.java
index 7e589c5a6..8f0db92b8 100644
--- a/core/src/main/java/com/minekube/connect/ConnectPlatform.java
+++ b/core/src/main/java/com/minekube/connect/ConnectPlatform.java
@@ -35,6 +35,7 @@
import com.minekube.connect.api.inject.PlatformInjector;
import com.minekube.connect.api.logger.ConnectLogger;
import com.minekube.connect.api.packet.PacketHandlers;
+import com.minekube.connect.bedrock.BedrockAdmissionCoordinator;
import com.minekube.connect.config.ConfigHolder;
import com.minekube.connect.config.ConfigLoader;
import com.minekube.connect.config.ConnectConfig;
@@ -60,21 +61,37 @@ public class ConnectPlatform {
private final PlatformInjector injector;
private final ConnectLogger logger;
+ private final BedrockAdmissionCoordinator admissionCoordinator;
private ConnectConfig config;
private Injector guice;
- @Inject
public ConnectPlatform(
ConnectApi api,
PlatformInjector platformInjector,
ConnectLogger logger,
Injector guice) {
+ this(
+ api,
+ platformInjector,
+ logger,
+ guice,
+ guice.getInstance(BedrockAdmissionCoordinator.class));
+ }
+
+ @Inject
+ public ConnectPlatform(
+ ConnectApi api,
+ PlatformInjector platformInjector,
+ ConnectLogger logger,
+ Injector guice,
+ BedrockAdmissionCoordinator admissionCoordinator) {
this.api = api;
this.injector = platformInjector;
this.logger = logger;
this.guice = guice;
+ this.admissionCoordinator = admissionCoordinator;
}
@Inject
@@ -139,13 +156,20 @@ public boolean enable(Module... postInitializeModules) {
public boolean disable() {
try {
- guice.getInstance(Libp2pEndpoint.class).stop();
- } catch (ConfigurationException ignored) {
+ try {
+ guice.getInstance(Libp2pEndpoint.class).stop();
+ } catch (ConfigurationException ignored) {
+ }
+ guice.getInstance(WatchHealthServer.class).stop();
+ guice.getInstance(WatcherRegister.class).stop();
+ guice.getInstance(Tunneler.class).close();
+ } finally {
+ try {
+ admissionCoordinator.close();
+ } finally {
+ guice.getInstance(CommonPlatformInjector.class).shutdown();
+ }
}
- guice.getInstance(WatchHealthServer.class).stop();
- guice.getInstance(WatcherRegister.class).stop();
- guice.getInstance(Tunneler.class).close();
- guice.getInstance(CommonPlatformInjector.class).shutdown();
return true;
}
diff --git a/core/src/main/java/com/minekube/connect/api/ProxyConnectApi.java b/core/src/main/java/com/minekube/connect/api/ProxyConnectApi.java
index 4e9d00a0a..10af44d06 100644
--- a/core/src/main/java/com/minekube/connect/api/ProxyConnectApi.java
+++ b/core/src/main/java/com/minekube/connect/api/ProxyConnectApi.java
@@ -26,11 +26,17 @@
package com.minekube.connect.api;
import com.minekube.connect.api.logger.ConnectLogger;
-
+import com.minekube.connect.bedrock.VerifiedBedrockIdentityRegistry;
public final class ProxyConnectApi extends SimpleConnectApi {
public ProxyConnectApi(ConnectLogger logger) {
super(logger);
}
+
+ public ProxyConnectApi(
+ ConnectLogger logger,
+ VerifiedBedrockIdentityRegistry verifiedBedrockIdentities) {
+ super(logger, verifiedBedrockIdentities);
+ }
}
diff --git a/core/src/main/java/com/minekube/connect/api/SimpleConnectApi.java b/core/src/main/java/com/minekube/connect/api/SimpleConnectApi.java
index 816683425..e8b1f15c5 100644
--- a/core/src/main/java/com/minekube/connect/api/SimpleConnectApi.java
+++ b/core/src/main/java/com/minekube/connect/api/SimpleConnectApi.java
@@ -31,13 +31,14 @@
import com.google.common.collect.Maps;
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.bedrock.VerifiedBedrockIdentityRegistry;
import java.util.Collection;
import java.util.Map;
+import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
-import lombok.RequiredArgsConstructor;
-@RequiredArgsConstructor
public class SimpleConnectApi implements ConnectApi {
private final Map players = Maps.newConcurrentMap();
private final Cache pendingRemove =
@@ -46,6 +47,18 @@ public class SimpleConnectApi implements ConnectApi {
.build();
private final ConnectLogger logger;
+ private final VerifiedBedrockIdentityRegistry verifiedBedrockIdentities;
+
+ public SimpleConnectApi(ConnectLogger logger) {
+ this(logger, (VerifiedBedrockIdentityRegistry) null);
+ }
+
+ public SimpleConnectApi(
+ ConnectLogger logger,
+ VerifiedBedrockIdentityRegistry verifiedBedrockIdentities) {
+ this.logger = logger;
+ this.verifiedBedrockIdentities = verifiedBedrockIdentities;
+ }
@Override
public Collection getPlayers() {
@@ -74,7 +87,22 @@ public ConnectPlayer getPlayer(UUID uuid) {
}
public ConnectPlayer addPlayer(ConnectPlayer player) {
- return players.put(player.getUniqueId(), player);
+ ConnectPlayer publicPlayer = player;
+ ConnectPlayer previous = players.put(publicPlayer.getUniqueId(), publicPlayer);
+ if (previous != null && previous != publicPlayer) {
+ removeClaims(previous);
+ }
+ return previous;
+ }
+
+ @Override
+ public Optional getVerifiedBedrockIdentity(ConnectPlayer player) {
+ if (player == null || players.get(player.getUniqueId()) != player) {
+ return Optional.empty();
+ }
+ return verifiedBedrockIdentities == null
+ ? Optional.empty()
+ : verifiedBedrockIdentities.get(player);
}
/**
@@ -82,16 +110,32 @@ public ConnectPlayer addPlayer(ConnectPlayer player) {
* dependant event hasn't fired yet
*/
public boolean setPendingRemove(ConnectPlayer player) {
+ removeClaims(player);
pendingRemove.put(player.getUniqueId(), player);
return players.remove(player.getUniqueId(), player);
}
public void playerRemoved(UUID uuid) {
- pendingRemove.invalidate(uuid);
- players.remove(uuid);
+ ConnectPlayer pendingPlayer = pendingRemove.getIfPresent(uuid);
+ if (pendingPlayer != null) {
+ pendingRemove.invalidate(uuid);
+ removeClaims(pendingPlayer);
+ players.remove(uuid, pendingPlayer);
+ return;
+ }
+ ConnectPlayer player = players.remove(uuid);
+ if (player != null) {
+ removeClaims(player);
+ }
}
private ConnectPlayer getPendingRemovePlayer(UUID uuid) {
return pendingRemove.getIfPresent(uuid);
}
+
+ private void removeClaims(ConnectPlayer player) {
+ if (verifiedBedrockIdentities != null) {
+ verifiedBedrockIdentities.remove(player);
+ }
+ }
}
diff --git a/core/src/main/java/com/minekube/connect/bedrock/BedrockAdmissionCoordinator.java b/core/src/main/java/com/minekube/connect/bedrock/BedrockAdmissionCoordinator.java
new file mode 100644
index 000000000..b685dab62
--- /dev/null
+++ b/core/src/main/java/com/minekube/connect/bedrock/BedrockAdmissionCoordinator.java
@@ -0,0 +1,292 @@
+package com.minekube.connect.bedrock;
+
+import com.google.rpc.Status;
+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.BedrockIdentityProfiles;
+import com.minekube.connect.player.ConnectPlayerImpl;
+import com.minekube.connect.watch.SessionProposal;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.IdentityHashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.Player;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.Session;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.SessionProtocol;
+
+/** Owns raw admission input until the single verification attempt consumes it. */
+@Singleton
+public final class BedrockAdmissionCoordinator implements AutoCloseable {
+ private static final long ADMISSION_TTL_SECONDS = 30;
+
+ private final VerifiedBedrockIdentityRegistry identities;
+ private final ScheduledExecutorService cleanupExecutor;
+ private final Map admissions = new HashMap<>();
+ private final Map latestBySession = new HashMap<>();
+ private final Map players = new IdentityHashMap<>();
+ private long nextGeneration;
+ private boolean closed;
+
+ @Inject
+ public BedrockAdmissionCoordinator(VerifiedBedrockIdentityRegistry identities) {
+ this(identities, newCleanupExecutor());
+ }
+
+ BedrockAdmissionCoordinator(
+ VerifiedBedrockIdentityRegistry identities,
+ ScheduledExecutorService cleanupExecutor) {
+ this.identities = Objects.requireNonNull(identities, "identities");
+ this.cleanupExecutor = Objects.requireNonNull(cleanupExecutor, "cleanupExecutor");
+ }
+
+ public synchronized SessionProposal proposal(
+ Session raw,
+ Consumer reject,
+ String endpointId,
+ String endpointOrgId) {
+ ensureOpen();
+ String sessionId = raw == null ? "" : raw.getId();
+ removeAdmission(latestBySession.get(sessionId));
+ AdmissionToken token = new AdmissionToken(++nextGeneration);
+ Admission admission = new Admission(raw, token, sessionId);
+ admissions.put(token, admission);
+ latestBySession.put(sessionId, admission);
+ try {
+ admission.cleanup = cleanupExecutor.schedule(
+ () -> expire(token, admission),
+ ADMISSION_TTL_SECONDS,
+ TimeUnit.SECONDS);
+ } catch (RuntimeException e) {
+ removeAdmission(admission);
+ throw e;
+ }
+ return new SessionProposal(raw, reject, endpointId, endpointOrgId, token);
+ }
+
+ public synchronized ConnectPlayer stage(SessionProposal proposal) {
+ Objects.requireNonNull(proposal, "proposal");
+ AdmissionToken token = proposal.getAdmissionToken();
+ if (token == null) {
+ return publicPlayer(playerFor(proposal.getSession()));
+ }
+ Admission admission = admissions.get(token);
+ if (closed || admission == null || latestBySession.get(admission.sessionId) != admission ||
+ admission.raw == null || admission.player != null ||
+ admission.state != AdmissionState.PENDING) {
+ throw new IllegalStateException("Bedrock admission has expired or been superseded");
+ }
+ ConnectPlayer publicPlayer = publicPlayer(playerFor(admission.raw));
+ admission.player = publicPlayer;
+ players.put(publicPlayer, token);
+ return publicPlayer;
+ }
+
+ public BedrockIdentityEnforcer.Decision verify(
+ ConnectPlayer player,
+ AdmissionToken token,
+ BedrockIdentityEnforcer enforcer,
+ String endpointId,
+ String endpointOrgId,
+ SessionProtocol protocol) {
+ if (token == null) {
+ return null;
+ }
+ Admission admission;
+ GameProfile rawProfile;
+ synchronized (this) {
+ admission = admissions.get(token);
+ if (closed || admission == null || admission.player != player ||
+ latestBySession.get(admission.sessionId) != admission ||
+ admission.raw == null || admission.state != AdmissionState.PENDING) {
+ return enforcer.invalidatedAdmission();
+ }
+ admission.state = AdmissionState.VERIFYING;
+ rawProfile = profile(admission.raw.getPlayer());
+ admission.raw = null;
+ }
+ BedrockIdentityEnforcer.Decision decision;
+ try {
+ decision = enforcer.verifyAdmissionSnapshot(
+ player, rawProfile, endpointId, endpointOrgId, protocol);
+ } catch (RuntimeException | Error e) {
+ synchronized (this) {
+ removeAdmission(admission);
+ }
+ throw e;
+ }
+ synchronized (this) {
+ if (closed || admissions.get(token) != admission ||
+ latestBySession.get(admission.sessionId) != admission ||
+ admission.state != AdmissionState.VERIFYING) {
+ return enforcer.invalidatedAdmission();
+ }
+ admissions.remove(token, admission);
+ latestBySession.remove(admission.sessionId, admission);
+ players.remove(player);
+ cancel(admission);
+ admission.state = AdmissionState.CONSUMED;
+ if (decision.verifiedClaims() != null) {
+ identities.record(player, token.generation, decision.verifiedClaims());
+ }
+ return decision;
+ }
+ }
+
+ public synchronized void discard(SessionProposal proposal) {
+ Objects.requireNonNull(proposal, "proposal");
+ AdmissionToken token = proposal.getAdmissionToken();
+ if (token != null) {
+ removeAdmission(admissions.get(token));
+ }
+ }
+
+ public void reject(SessionProposal proposal, Status reason) {
+ discard(proposal);
+ proposal.reject(reason);
+ }
+
+ public synchronized void discard(ConnectPlayer player) {
+ Objects.requireNonNull(player, "player");
+ AdmissionToken token = players.get(player);
+ if (token != null) {
+ removeAdmission(admissions.get(token));
+ }
+ identities.remove(player);
+ }
+
+ @Override
+ public synchronized void close() {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ new ArrayList<>(admissions.values()).forEach(this::removeAdmission);
+ admissions.clear();
+ latestBySession.clear();
+ players.clear();
+ identities.close();
+ cleanupExecutor.shutdownNow();
+ }
+
+ private synchronized void expire(AdmissionToken token, Admission expected) {
+ if (admissions.get(token) == expected) {
+ removeAdmission(expected);
+ }
+ }
+
+ public static ConnectPlayer playerFor(Session session) {
+ Player player = session.getPlayer();
+ return new ConnectPlayerImpl(
+ session.getId(),
+ profile(player),
+ new Auth(session.getAuth().getPassthrough()),
+ "");
+ }
+
+ private static GameProfile profile(Player player) {
+ return new GameProfile(
+ player.getProfile().getName(),
+ java.util.UUID.fromString(player.getProfile().getId()),
+ Collections.unmodifiableList(new ArrayList<>(player.getProfile().getPropertiesList().stream()
+ .map(property -> new GameProfile.Property(
+ property.getName(),
+ property.getValue(),
+ property.getSignature()))
+ .toList())));
+ }
+
+ private static ConnectPlayer publicPlayer(ConnectPlayer player) {
+ GameProfile publicProfile = BedrockIdentityProfiles.withoutEnvelope(player.getGameProfile());
+ if (publicProfile == player.getGameProfile()) {
+ return player;
+ }
+ return new ConnectPlayerImpl(
+ player.getSessionId(),
+ publicProfile,
+ player.getAuth(),
+ player.getLanguageTag());
+ }
+
+ private void ensureOpen() {
+ if (closed) {
+ throw new IllegalStateException("Bedrock admission coordinator is closed");
+ }
+ }
+
+ private void removeAdmission(Admission admission) {
+ if (admission == null) {
+ return;
+ }
+ admissions.remove(admission.token, admission);
+ latestBySession.remove(admission.sessionId, admission);
+ if (admission.player != null) {
+ players.remove(admission.player);
+ identities.remove(admission.player);
+ }
+ clear(admission);
+ }
+
+ private static void clear(Admission admission) {
+ admission.raw = null;
+ admission.state = AdmissionState.CANCELLED;
+ cancel(admission);
+ }
+
+ private static void cancel(Admission admission) {
+ if (admission.cleanup != null) {
+ admission.cleanup.cancel(false);
+ }
+ }
+
+ private static ScheduledExecutorService newCleanupExecutor() {
+ ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1, runnable -> {
+ Thread thread = new Thread(runnable, "connect-bedrock-admission-cleanup");
+ thread.setDaemon(true);
+ return thread;
+ });
+ executor.setRemoveOnCancelPolicy(true);
+ executor.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
+ return executor;
+ }
+
+ /** Opaque identity-only handle; it intentionally carries no session/profile/coordinator reference. */
+ public static final class AdmissionToken {
+ private final long generation;
+
+ private AdmissionToken(long generation) {
+ this.generation = generation;
+ }
+ }
+
+ private static final class Admission {
+ private Session raw;
+ private final AdmissionToken token;
+ private final String sessionId;
+ private ConnectPlayer player;
+ private ScheduledFuture> cleanup;
+ private AdmissionState state = AdmissionState.PENDING;
+
+ private Admission(Session raw, AdmissionToken token, String sessionId) {
+ this.raw = raw;
+ this.token = token;
+ this.sessionId = sessionId;
+ }
+ }
+
+ private enum AdmissionState {
+ PENDING,
+ VERIFYING,
+ CONSUMED,
+ CANCELLED
+ }
+}
diff --git a/core/src/main/java/com/minekube/connect/bedrock/BedrockIdentityConfiguration.java b/core/src/main/java/com/minekube/connect/bedrock/BedrockIdentityConfiguration.java
new file mode 100644
index 000000000..d46741c13
--- /dev/null
+++ b/core/src/main/java/com/minekube/connect/bedrock/BedrockIdentityConfiguration.java
@@ -0,0 +1,41 @@
+package com.minekube.connect.bedrock;
+
+import com.minekube.connect.config.ConnectConfig.BedrockIdentityConfig;
+
+final class BedrockIdentityConfiguration {
+ enum Mode { DISABLED, WARN, REQUIRE, INVALID }
+
+ private final Mode mode;
+ private final String issuer;
+ private final String policy;
+
+ private BedrockIdentityConfiguration(Mode mode, String issuer, String policy) {
+ this.mode = mode;
+ this.issuer = issuer;
+ this.policy = policy;
+ }
+
+ static BedrockIdentityConfiguration from(BedrockIdentityConfig config) {
+ if (config == null) return new BedrockIdentityConfiguration(Mode.INVALID, null, null);
+ String enforcement = config.getEnforcement();
+ Mode mode;
+ if ("disabled".equals(enforcement)) mode = Mode.DISABLED;
+ else if ("warn".equals(enforcement)) mode = Mode.WARN;
+ else if ("require".equals(enforcement)) mode = Mode.REQUIRE;
+ else mode = Mode.INVALID;
+ String issuer = nonBlank(config.getExpectedIssuer()) ? config.getExpectedIssuer() : null;
+ String policy = allowedPolicy(config.getExpectedPolicy()) ? config.getExpectedPolicy() : null;
+ return new BedrockIdentityConfiguration(mode, issuer, policy);
+ }
+
+ Mode mode() { return mode; }
+ boolean isDisabled() { return mode == Mode.DISABLED; }
+ boolean isUsable() { return (mode == Mode.WARN || mode == Mode.REQUIRE) && issuer != null && policy != null; }
+ String issuer() { return issuer; }
+ String policy() { return policy; }
+
+ private static boolean nonBlank(String value) { return value != null && !value.isBlank(); }
+ private static boolean allowedPolicy(String value) {
+ return "linked_java_only".equals(value) || "trusted_bedrock_xuid".equals(value);
+ }
+}
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 379dcdf60..35e92934e 100644
--- a/core/src/main/java/com/minekube/connect/bedrock/BedrockIdentityEnforcer.java
+++ b/core/src/main/java/com/minekube/connect/bedrock/BedrockIdentityEnforcer.java
@@ -5,6 +5,7 @@
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.GameProfile;
import com.minekube.connect.api.player.bedrock.BedrockIdentityClaims;
import com.minekube.connect.api.player.bedrock.BedrockIdentityReplayCache;
import com.minekube.connect.api.player.bedrock.BedrockIdentityVerificationException;
@@ -14,10 +15,10 @@
import com.minekube.connect.network.netty.LocalSession;
import java.time.Instant;
import java.util.List;
-import java.util.Locale;
import java.util.Objects;
import java.util.function.Supplier;
import javax.inject.Named;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.SessionProtocol;
import okhttp3.OkHttpClient;
public final class BedrockIdentityEnforcer {
@@ -31,18 +32,47 @@ public final class BedrockIdentityEnforcer {
private final ConnectLogger logger;
private final Supplier now;
private final BedrockIdentityKeyProvider keyProvider;
+ private final BedrockAdmissionCoordinator admissionCoordinator;
private final BedrockIdentityReplayCache replayCache = new BedrockIdentityReplayCache();
@Inject
+ public BedrockIdentityEnforcer(
+ ConnectConfig config,
+ ConnectLogger logger,
+ BedrockIdentityKeyProvider keyProvider,
+ BedrockAdmissionCoordinator admissionCoordinator) {
+ this(config, logger, Instant::now, keyProvider, admissionCoordinator);
+ }
+
public BedrockIdentityEnforcer(
ConnectConfig config,
ConnectLogger logger,
@Named("defaultHttpClient") OkHttpClient httpClient) {
- this(config, logger, Instant::now, new BedrockIdentityKeyProvider(config, httpClient));
+ this(config, logger, Instant::now, new BedrockIdentityKeyProvider(config, httpClient),
+ (BedrockAdmissionCoordinator) null);
}
BedrockIdentityEnforcer(ConnectConfig config, ConnectLogger logger, Supplier now) {
- this(config, logger, now, new BedrockIdentityKeyProvider(config, new OkHttpClient(), now));
+ this(config, logger, now, new BedrockIdentityKeyProvider(config, new okhttp3.OkHttpClient(), now),
+ (BedrockAdmissionCoordinator) null);
+ }
+
+ BedrockIdentityEnforcer(
+ ConnectConfig config,
+ ConnectLogger logger,
+ Supplier now,
+ VerifiedBedrockIdentityRegistry ignored) {
+ this(config, logger, now, new BedrockIdentityKeyProvider(config, new okhttp3.OkHttpClient(), now),
+ (BedrockAdmissionCoordinator) null);
+ }
+
+ BedrockIdentityEnforcer(
+ ConnectConfig config,
+ ConnectLogger logger,
+ Supplier now,
+ BedrockAdmissionCoordinator admissionCoordinator) {
+ this(config, logger, now, new BedrockIdentityKeyProvider(config, new okhttp3.OkHttpClient(), now),
+ admissionCoordinator);
}
BedrockIdentityEnforcer(
@@ -50,15 +80,66 @@ public BedrockIdentityEnforcer(
ConnectLogger logger,
Supplier now,
BedrockIdentityKeyProvider keyProvider) {
+ this(config, logger, now, keyProvider, (BedrockAdmissionCoordinator) null);
+ }
+
+ BedrockIdentityEnforcer(
+ ConnectConfig config,
+ ConnectLogger logger,
+ Supplier now,
+ BedrockIdentityKeyProvider keyProvider,
+ BedrockAdmissionCoordinator admissionCoordinator) {
this.config = Objects.requireNonNull(config, "config");
this.logger = Objects.requireNonNull(logger, "logger");
this.now = Objects.requireNonNull(now, "now");
this.keyProvider = Objects.requireNonNull(keyProvider, "keyProvider");
+ this.admissionCoordinator = admissionCoordinator;
+ }
+
+ BedrockIdentityEnforcer(
+ ConnectConfig config,
+ ConnectLogger logger,
+ Supplier now,
+ BedrockIdentityKeyProvider keyProvider,
+ VerifiedBedrockIdentityRegistry ignored) {
+ this(config, logger, now, keyProvider, (BedrockAdmissionCoordinator) null);
}
public Decision verify(LocalSession.Context context) {
Objects.requireNonNull(context, "context");
- return verify(context.getPlayer(), context.getEndpointId(), context.getEndpointOrgId());
+ ConnectPlayer player = context.getPlayer();
+ Decision stagedDecision = admissionCoordinator == null ? null : admissionCoordinator.verify(
+ player, context.getAdmissionToken(), this, context.getEndpointId(),
+ context.getEndpointOrgId(), context.getProtocol());
+ if (stagedDecision != null) {
+ return stagedDecision;
+ }
+ return verifyAdmission(
+ player,
+ context.getEndpointId(),
+ context.getEndpointOrgId(),
+ context.getProtocol());
+ }
+
+ Decision verifyAdmission(
+ ConnectPlayer player,
+ String endpointId,
+ String endpointOrgId,
+ SessionProtocol protocol) {
+ return verifyAdmissionSnapshot(player, player.getGameProfile(), endpointId, endpointOrgId, protocol);
+ }
+
+ Decision verifyAdmissionSnapshot(
+ ConnectPlayer player,
+ GameProfile profile,
+ String endpointId,
+ String endpointOrgId,
+ SessionProtocol protocol) {
+ return verify(player, profile, endpointId, endpointOrgId, protocol);
+ }
+
+ Decision invalidatedAdmission() {
+ return Decision.rejected(REJECT_MESSAGE);
}
public void reject(LocalSession.Context context, Decision decision) {
@@ -71,23 +152,78 @@ public void reject(LocalSession.Context context, Decision decision) {
}
public Decision verify(ConnectPlayer player) {
- return verify(player, "", "");
+ return verify(player, player.getGameProfile(), "", "", SessionProtocol.SESSION_PROTOCOL_BEDROCK);
}
Decision verify(ConnectPlayer player, String endpointId, String endpointOrgId) {
+ return verify(
+ player,
+ player.getGameProfile(),
+ endpointId,
+ endpointOrgId,
+ SessionProtocol.SESSION_PROTOCOL_BEDROCK);
+ }
+
+ Decision verify(
+ ConnectPlayer player,
+ String endpointId,
+ String endpointOrgId,
+ SessionProtocol protocol) {
+ return verify(player, player.getGameProfile(), endpointId, endpointOrgId, protocol);
+ }
+
+ Decision verify(
+ ConnectPlayer player,
+ GameProfile profile,
+ String endpointId,
+ String endpointOrgId,
+ SessionProtocol protocol) {
Objects.requireNonNull(player, "player");
+ Objects.requireNonNull(protocol, "protocol");
BedrockIdentityConfig bedrockIdentity = config.getBedrockIdentity();
- String mode = mode(bedrockIdentity);
- if (MODE_DISABLED.equals(mode)) {
+ BedrockIdentityConfiguration identityConfiguration = BedrockIdentityConfiguration.from(bedrockIdentity);
+ if (identityConfiguration.isDisabled()) {
return Decision.allowed(null);
}
+ String mode = identityConfiguration.mode() == BedrockIdentityConfiguration.Mode.WARN ? MODE_WARN : MODE_REQUIRE;
+
+ boolean hasEnvelope = hasReservedEnvelope(profile);
+ if (protocol == SessionProtocol.SESSION_PROTOCOL_JAVA) {
+ if (!hasEnvelope) {
+ return Decision.allowed(null);
+ }
+ return rejectOrWarn(player, mode, "reserved identity property on Java session");
+ }
+ if (protocol != SessionProtocol.SESSION_PROTOCOL_BEDROCK &&
+ protocol != SessionProtocol.SESSION_PROTOCOL_UNSPECIFIED) {
+ if (!hasEnvelope) {
+ return Decision.allowed(null);
+ }
+ return rejectOrWarn(player, mode, "reserved identity property on unknown session protocol");
+ }
+ if (protocol == SessionProtocol.SESSION_PROTOCOL_UNSPECIFIED && !hasEnvelope) {
+ return Decision.allowed(null);
+ }
+
+ if (hasEnvelope && (!hasScopeValue(endpointId) || !hasScopeValue(endpointOrgId))) {
+ return rejectOrWarn(player, mode, "authenticated endpoint scope is incomplete");
+ }
+ if (!identityConfiguration.isUsable()) {
+ return Decision.rejected(REJECT_MESSAGE);
+ }
+
if (MODE_WARN.equals(mode) && !hasConfiguredKeys(bedrockIdentity)) {
return Decision.allowed(null);
}
try {
- BedrockIdentityClaims claims = verifyWithKeys(player, bedrockIdentity, endpointId, endpointOrgId);
+ BedrockIdentityClaims claims = verifyWithKeys(
+ player,
+ profile,
+ identityConfiguration,
+ endpointId,
+ endpointOrgId);
return Decision.allowed(claims);
} catch (RuntimeException | BedrockIdentityVerificationException e) {
warn(player, safeReason(e));
@@ -98,9 +234,15 @@ Decision verify(ConnectPlayer player, String endpointId, String endpointOrgId) {
}
}
+ private Decision rejectOrWarn(ConnectPlayer player, String mode, String reason) {
+ warn(player, reason);
+ return MODE_REQUIRE.equals(mode) ? Decision.rejected(REJECT_MESSAGE) : Decision.allowed(null);
+ }
+
private BedrockIdentityClaims verifyWithKeys(
ConnectPlayer player,
- BedrockIdentityConfig bedrockIdentity,
+ GameProfile profile,
+ BedrockIdentityConfiguration identityConfiguration,
String endpointId,
String endpointOrgId) throws BedrockIdentityVerificationException {
List keys = keyProvider.keys();
@@ -111,8 +253,8 @@ private BedrockIdentityClaims verifyWithKeys(
RuntimeException runtimeError = null;
for (byte[] publicKey : keys) {
try {
- return verifier(publicKey, player, bedrockIdentity, endpointId, endpointOrgId)
- .verify(player.getGameProfile());
+ return verifier(publicKey, player, identityConfiguration, endpointId, endpointOrgId)
+ .verify(profile);
} catch (BedrockIdentityVerificationException e) {
verificationError = e;
} catch (RuntimeException e) {
@@ -131,33 +273,23 @@ private BedrockIdentityClaims verifyWithKeys(
private BedrockIdentityVerifier verifier(
byte[] publicKey,
ConnectPlayer player,
- BedrockIdentityConfig bedrockIdentity,
+ BedrockIdentityConfiguration identityConfiguration,
String endpointId,
String endpointOrgId) {
BedrockIdentityVerifier.Builder builder = BedrockIdentityVerifier.builder()
.publicKey(publicKey)
.now(now)
+ .endpointId(endpointId)
.endpointName(config.getEndpoint())
+ .orgId(endpointOrgId)
.sessionId(player.getSessionId())
.protocol(PROTOCOL_BEDROCK)
- .bedrockAuthPolicy(bedrockIdentity.getExpectedPolicy())
+ .bedrockAuthPolicy(identityConfiguration.policy())
+ .expectedIssuer(identityConfiguration.issuer())
.replayCache(replayCache);
- if (!isEmpty(endpointId)) {
- builder.endpointId(endpointId);
- }
- if (!isEmpty(endpointOrgId)) {
- builder.orgId(endpointOrgId);
- }
return builder.build();
}
- private static String mode(BedrockIdentityConfig bedrockIdentity) {
- if (bedrockIdentity == null || isEmpty(bedrockIdentity.getEnforcement())) {
- return MODE_DISABLED;
- }
- return bedrockIdentity.getEnforcement().toLowerCase(Locale.ROOT);
- }
-
private static boolean hasConfiguredKeys(BedrockIdentityConfig bedrockIdentity) {
if (bedrockIdentity == null) {
return false;
@@ -187,6 +319,15 @@ private static boolean isEmpty(String value) {
return value == null || value.isEmpty();
}
+ private static boolean hasScopeValue(String value) {
+ return value != null && !value.trim().isEmpty();
+ }
+
+ private static boolean hasReservedEnvelope(GameProfile profile) {
+ return profile.getProperties().stream()
+ .anyMatch(property -> BedrockIdentityVerifier.PROPERTY_NAME.equals(property.getName()));
+ }
+
public static final class Decision {
private final boolean allowed;
private final String message;
diff --git a/core/src/main/java/com/minekube/connect/bedrock/BedrockIdentityKeyProvider.java b/core/src/main/java/com/minekube/connect/bedrock/BedrockIdentityKeyProvider.java
index cced04b5c..86ff415a7 100644
--- a/core/src/main/java/com/minekube/connect/bedrock/BedrockIdentityKeyProvider.java
+++ b/core/src/main/java/com/minekube/connect/bedrock/BedrockIdentityKeyProvider.java
@@ -2,67 +2,97 @@
import com.google.gson.Gson;
import com.google.gson.JsonSyntaxException;
+import com.minekube.connect.api.player.bedrock.BedrockIdentityVerifier;
import com.minekube.connect.config.ConnectConfig;
+import java.io.ByteArrayOutputStream;
import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
+import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
+import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;
-final class BedrockIdentityKeyProvider {
+public final class BedrockIdentityKeyProvider {
private static final Gson GSON = new Gson();
+ private static final long METADATA_BODY_LIMIT_BYTES = 64 * 1024;
+ private static final long METADATA_CALL_TIMEOUT_SECONDS = 5;
+ private static final long METADATA_FAILURE_BACKOFF_SECONDS = 5;
private final ConnectConfig config;
private final OkHttpClient httpClient;
private final Supplier now;
private CachedKeys cachedKeys;
+ private Instant nextRefreshAt = Instant.MIN;
- BedrockIdentityKeyProvider(ConnectConfig config, OkHttpClient httpClient) {
+ public BedrockIdentityKeyProvider(ConnectConfig config, OkHttpClient httpClient) {
this(config, httpClient, Instant::now);
}
BedrockIdentityKeyProvider(ConnectConfig config, OkHttpClient httpClient, Supplier now) {
this.config = Objects.requireNonNull(config, "config");
- this.httpClient = Objects.requireNonNull(httpClient, "httpClient");
+ this.httpClient = Objects.requireNonNull(httpClient, "httpClient").newBuilder()
+ .callTimeout(METADATA_CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .followRedirects(false)
+ .followSslRedirects(false)
+ .build();
this.now = Objects.requireNonNull(now, "now");
}
- List keys() {
+ synchronized List keys() {
List staticKeys = staticKeys();
String metadataUrl = config.getBedrockIdentity().getMetadataUrl();
if (metadataUrl == null || metadataUrl.isEmpty()) {
return staticKeys;
}
- if (cachedKeys != null && now.get().isBefore(cachedKeys.expiresAt)) {
+ Instant current = now.get();
+ if (cachedKeys != null && current.isBefore(cachedKeys.expiresAt)) {
return cachedKeys.keys;
}
+ if (current.isBefore(nextRefreshAt)) {
+ return staleKeys(current);
+ }
try {
- List remoteKeys = fetchKeys(metadataUrl);
- if (!remoteKeys.isEmpty()) {
- int cacheSeconds = config.getBedrockIdentity().getMetadataCacheSeconds();
+ RemoteKeys remoteKeys = fetchKeys(metadataUrl);
+ if (!remoteKeys.keys.isEmpty()) {
+ int cacheSeconds = metadataCacheSeconds(remoteKeys.cacheSeconds);
if (cacheSeconds <= 0) {
cacheSeconds = 300;
}
- cachedKeys = new CachedKeys(remoteKeys, now.get().plusSeconds(cacheSeconds));
- return remoteKeys;
+ Instant fetchedAt = now.get();
+ cachedKeys = new CachedKeys(remoteKeys.keys, fetchedAt.plusSeconds(cacheSeconds),
+ fetchedAt.plusSeconds(cacheSeconds + metadataMaxStaleSeconds()));
+ return remoteKeys.keys;
}
} catch (IOException | JsonSyntaxException | IllegalArgumentException ignored) {
- if (cachedKeys != null && !cachedKeys.keys.isEmpty()) {
- return cachedKeys.keys;
- }
+ Instant failedAt = now.get();
+ nextRefreshAt = failureBackoffUntil(failedAt);
+ return staleKeys(failedAt);
+ }
+ return Collections.emptyList();
+ }
+
+ synchronized boolean hasUsableKeys() {
+ List keys = keys();
+ String metadataUrl = config.getBedrockIdentity().getMetadataUrl();
+ if (metadataUrl == null || metadataUrl.isEmpty()) {
+ return !keys.isEmpty();
}
- return staticKeys;
+ Instant current = now.get();
+ return cachedKeys != null && !cachedKeys.keys.isEmpty() && current.isBefore(cachedKeys.staleUntil);
}
- private List fetchKeys(String metadataUrl) throws IOException {
- Request request = new Request.Builder().url(metadataUrl).get().build();
+ private RemoteKeys fetchKeys(String metadataUrl) throws IOException {
+ Request request = new Request.Builder().url(validMetadataUrl(metadataUrl)).get().build();
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new IOException("metadata status " + response.code());
@@ -71,19 +101,79 @@ private List fetchKeys(String metadataUrl) throws IOException {
if (body == null) {
throw new IOException("metadata body is empty");
}
- VerifierMetadata metadata = GSON.fromJson(body.charStream(), VerifierMetadata.class);
- if (metadata == null || !"Ed25519".equals(metadata.algorithm)) {
- return Collections.emptyList();
+ VerifierMetadata metadata = GSON.fromJson(readBody(body), VerifierMetadata.class);
+ if (metadata == null || !"Ed25519".equals(metadata.algorithm) ||
+ !config.getBedrockIdentity().getExpectedIssuer().equals(metadata.issuer)) {
+ throw new IllegalArgumentException("metadata scope is invalid");
}
List keys = new ArrayList<>();
- addKey(keys, metadata.current_public_key);
+ addMetadataKey(keys, metadata.current_public_key);
if (metadata.previous_public_keys != null) {
for (String key : metadata.previous_public_keys) {
- addKey(keys, key);
+ addMetadataKey(keys, key);
}
}
- return keys;
+ return new RemoteKeys(keys, metadata.cache_max_age_seconds);
+ }
+ }
+
+ private static HttpUrl validMetadataUrl(String configured) {
+ if (hasRawUserInfo(configured)) {
+ throw new IllegalArgumentException("metadata URL must be HTTPS without userinfo or fragment");
+ }
+ HttpUrl url = HttpUrl.parse(configured);
+ if (url == null || !"https".equals(url.scheme()) || !url.username().isEmpty()
+ || !url.password().isEmpty() || url.fragment() != null) {
+ throw new IllegalArgumentException("metadata URL must be HTTPS without userinfo or fragment");
}
+ return url;
+ }
+
+ private static boolean hasRawUserInfo(String configured) {
+ if (configured == null) {
+ return false;
+ }
+ int schemeEnd = configured.indexOf("://");
+ if (schemeEnd < 0) {
+ return false;
+ }
+ int authorityStart = schemeEnd + 3;
+ int authorityEnd = configured.length();
+ for (char delimiter : new char[] {'/', '?', '#'}) {
+ int index = configured.indexOf(delimiter, authorityStart);
+ if (index >= 0 && index < authorityEnd) {
+ authorityEnd = index;
+ }
+ }
+ int userInfoDelimiter = configured.indexOf('@', authorityStart);
+ return userInfoDelimiter >= 0 && userInfoDelimiter < authorityEnd;
+ }
+
+ private String readBody(ResponseBody body) throws IOException {
+ try (InputStream input = body.byteStream(); ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ byte[] buffer = new byte[4096];
+ int total = 0;
+ int read;
+ while ((read = input.read(buffer)) != -1) {
+ total += read;
+ if (total > METADATA_BODY_LIMIT_BYTES) {
+ throw new IOException("metadata body exceeds limit");
+ }
+ output.write(buffer, 0, read);
+ }
+ return output.toString(StandardCharsets.UTF_8);
+ }
+ }
+
+ private List staleKeys(Instant current) {
+ if (cachedKeys != null && !cachedKeys.keys.isEmpty() && current.isBefore(cachedKeys.staleUntil)) {
+ return cachedKeys.keys;
+ }
+ return Collections.emptyList();
+ }
+
+ private Instant failureBackoffUntil(Instant current) {
+ return current.plusSeconds(METADATA_FAILURE_BACKOFF_SECONDS);
}
private List staticKeys() {
@@ -101,23 +191,66 @@ private static void addKey(List keys, String encoded) {
if (encoded == null || encoded.isEmpty()) {
return;
}
+ try {
+ byte[] decoded = Base64.getDecoder().decode(encoded);
+ BedrockIdentityVerifier.builder().publicKey(decoded);
+ keys.add(decoded);
+ } catch (IllegalArgumentException ignored) {
+ // Invalid configured material is not a usable verifier key.
+ }
+ }
+
+ private static void addMetadataKey(List keys, String encoded) {
+ if (encoded == null || encoded.isEmpty()) {
+ throw new IllegalArgumentException("metadata key is empty");
+ }
byte[] decoded = Base64.getDecoder().decode(encoded);
+ if (decoded.length != 32) {
+ throw new IllegalArgumentException("metadata key is not an Ed25519 public key");
+ }
+ BedrockIdentityVerifier.builder().publicKey(decoded);
keys.add(decoded);
}
+ private int metadataCacheSeconds(int remoteCacheSeconds) {
+ int configured = config.getBedrockIdentity().getMetadataCacheSeconds();
+ if (configured <= 0) {
+ return remoteCacheSeconds;
+ }
+ return remoteCacheSeconds <= 0 ? configured : Math.min(configured, remoteCacheSeconds);
+ }
+
+ private int metadataMaxStaleSeconds() {
+ return Math.max(0, config.getBedrockIdentity().getMetadataMaxStaleSeconds());
+ }
+
private static final class CachedKeys {
private final List keys;
private final Instant expiresAt;
+ private final Instant staleUntil;
- private CachedKeys(List keys, Instant expiresAt) {
+ private CachedKeys(List keys, Instant expiresAt, Instant staleUntil) {
this.keys = Collections.unmodifiableList(new ArrayList<>(keys));
this.expiresAt = expiresAt;
+ this.staleUntil = staleUntil;
+ }
+ }
+
+ private static final class RemoteKeys {
+ private final List keys;
+ private final int cacheSeconds;
+
+ private RemoteKeys(List keys, int cacheSeconds) {
+ this.keys = keys;
+ this.cacheSeconds = cacheSeconds;
}
}
private static final class VerifierMetadata {
+ String issuer;
String algorithm;
String current_public_key;
List previous_public_keys;
+ int cache_max_age_seconds;
}
}
diff --git a/core/src/main/java/com/minekube/connect/bedrock/BedrockIdentityReadiness.java b/core/src/main/java/com/minekube/connect/bedrock/BedrockIdentityReadiness.java
new file mode 100644
index 000000000..944af62bb
--- /dev/null
+++ b/core/src/main/java/com/minekube/connect/bedrock/BedrockIdentityReadiness.java
@@ -0,0 +1,63 @@
+package com.minekube.connect.bedrock;
+
+import com.minekube.connect.config.ConnectConfig;
+import java.util.ArrayList;
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/** Determines whether this connector can safely receive trusted Bedrock identity envelopes. */
+public final class BedrockIdentityReadiness {
+ public static final String CAPABILITY = "bedrock-identity-v1";
+
+ public enum Transport {
+ WATCH,
+ LIBP2P
+ }
+
+ private final ConnectConfig config;
+ private final BedrockIdentityKeyProvider keyProvider;
+ private final Map lastReady = new EnumMap<>(Transport.class);
+
+ public BedrockIdentityReadiness(ConnectConfig config, BedrockIdentityKeyProvider keyProvider) {
+ this.config = Objects.requireNonNull(config, "config");
+ this.keyProvider = Objects.requireNonNull(keyProvider, "keyProvider");
+ for (Transport transport : Transport.values()) {
+ lastReady.put(transport, false);
+ }
+ }
+
+ public synchronized boolean isReady() {
+ return currentReady();
+ }
+
+ public synchronized boolean observe(Transport transport) {
+ boolean ready = currentReady();
+ lastReady.put(Objects.requireNonNull(transport, "transport"), ready);
+ return ready;
+ }
+
+ /** Returns true only when an advertised capability must be withdrawn or re-added. */
+ public synchronized boolean refresh(Transport transport) {
+ boolean ready = currentReady();
+ boolean changed = ready != lastReady.put(
+ Objects.requireNonNull(transport, "transport"),
+ ready);
+ return changed;
+ }
+
+ public List capabilities(List configuredCapabilities, Transport transport) {
+ List capabilities = new ArrayList<>(configuredCapabilities);
+ capabilities.removeIf(CAPABILITY::equals);
+ if (observe(transport)) {
+ capabilities.add(CAPABILITY);
+ }
+ return capabilities;
+ }
+
+ private boolean currentReady() {
+ return BedrockIdentityConfiguration.from(config.getBedrockIdentity()).isUsable()
+ && keyProvider.hasUsableKeys();
+ }
+}
diff --git a/core/src/main/java/com/minekube/connect/bedrock/VerifiedBedrockIdentityRegistry.java b/core/src/main/java/com/minekube/connect/bedrock/VerifiedBedrockIdentityRegistry.java
new file mode 100644
index 000000000..979cf66b1
--- /dev/null
+++ b/core/src/main/java/com/minekube/connect/bedrock/VerifiedBedrockIdentityRegistry.java
@@ -0,0 +1,80 @@
+package com.minekube.connect.bedrock;
+
+import com.minekube.connect.api.player.ConnectPlayer;
+import com.minekube.connect.api.player.bedrock.BedrockIdentityClaims;
+import java.util.IdentityHashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import javax.inject.Singleton;
+
+@Singleton
+public final class VerifiedBedrockIdentityRegistry implements AutoCloseable {
+ private final Map identities = new IdentityHashMap<>();
+ private boolean closed;
+
+ synchronized void record(ConnectPlayer player, BedrockIdentityClaims claims) {
+ record(player, 0, claims);
+ }
+
+ synchronized void record(
+ ConnectPlayer player,
+ long generation,
+ BedrockIdentityClaims claims) {
+ ensureOpen();
+ Objects.requireNonNull(player, "player");
+ Objects.requireNonNull(claims, "claims");
+ if (!player.getSessionId().equals(claims.getSessionId())) {
+ throw new IllegalArgumentException("Bedrock identity claims session mismatch");
+ }
+ VerifiedIdentity current = identities.get(player);
+ if (current == null || current.generation <= generation) {
+ identities.put(player, new VerifiedIdentity(
+ generation, player.getSessionId(), claims));
+ }
+ }
+
+ public synchronized Optional get(ConnectPlayer player) {
+ Objects.requireNonNull(player, "player");
+ VerifiedIdentity identity = identities.get(player);
+ if (identity == null || !player.getSessionId().equals(identity.sessionId)) {
+ return Optional.empty();
+ }
+ return Optional.of(identity.claims);
+ }
+
+ public synchronized void remove(ConnectPlayer player) {
+ Objects.requireNonNull(player, "player");
+ identities.remove(player);
+ }
+
+ @Override
+ public synchronized void close() {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ identities.clear();
+ }
+
+ private void ensureOpen() {
+ if (closed) {
+ throw new IllegalStateException("Bedrock identity registry is closed");
+ }
+ }
+
+ private static final class VerifiedIdentity {
+ private final long generation;
+ private final String sessionId;
+ private final BedrockIdentityClaims claims;
+
+ private VerifiedIdentity(
+ long generation,
+ String sessionId,
+ BedrockIdentityClaims claims) {
+ this.generation = generation;
+ this.sessionId = sessionId;
+ this.claims = claims;
+ }
+ }
+}
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 ffaeb80c2..85d7aaa0e 100644
--- a/core/src/main/java/com/minekube/connect/config/ConnectConfig.java
+++ b/core/src/main/java/com/minekube/connect/config/ConnectConfig.java
@@ -94,27 +94,36 @@ public static class MetricsConfig {
@Getter
public static class BedrockIdentityConfig {
/**
- * disabled, warn, or require.
+ * Exact enforcement mode: disabled, warn, or require. Any other value is invalid.
*/
private String enforcement = "disabled";
/**
- * Base64-encoded Ed25519 public key used to verify identity envelopes.
+ * Base64-encoded Ed25519 public key used when no metadata URL is configured.
*/
private String publicKey = "";
/**
- * Additional Base64-encoded Ed25519 public keys accepted during rotation.
+ * Additional Base64-encoded Ed25519 keys used when no metadata URL is configured.
*/
private List publicKeys = Collections.emptyList();
/**
- * Public metadata endpoint for current and previous verifier keys.
+ * Authoritative HTTPS metadata endpoint for current and previous verifier keys.
+ * Userinfo, fragments, and redirects are rejected; static keys are not a fallback.
*/
private String metadataUrl = "";
/**
- * Local cache duration for successful metadata fetches.
+ * Maximum local cache duration for successful metadata fetches.
*/
private int metadataCacheSeconds = 300;
/**
- * Expected Connect Edge Bedrock authentication policy.
+ * Maximum additional time expired remote metadata may be used when refresh fails.
+ */
+ private int metadataMaxStaleSeconds = 600;
+ /**
+ * Required non-blank issuer published by Moxy metadata and signed envelopes.
+ */
+ private String expectedIssuer = "minekube-connect";
+ /**
+ * Required exact policy: linked_java_only or trusted_bedrock_xuid.
*/
private String expectedPolicy = "trusted_bedrock_xuid";
}
diff --git a/core/src/main/java/com/minekube/connect/module/CommonModule.java b/core/src/main/java/com/minekube/connect/module/CommonModule.java
index 93a89ccc3..e7849f558 100644
--- a/core/src/main/java/com/minekube/connect/module/CommonModule.java
+++ b/core/src/main/java/com/minekube/connect/module/CommonModule.java
@@ -39,6 +39,8 @@
import com.minekube.connect.api.inject.PlatformInjector;
import com.minekube.connect.api.logger.ConnectLogger;
import com.minekube.connect.api.packet.PacketHandlers;
+import com.minekube.connect.bedrock.BedrockIdentityKeyProvider;
+import com.minekube.connect.bedrock.BedrockIdentityReadiness;
import com.minekube.connect.config.ConfigHolder;
import com.minekube.connect.config.ConfigLoader;
import com.minekube.connect.config.ConfigLoader.EndpointNameGenerator;
@@ -123,6 +125,22 @@ public OkHttpClient defaultOkHttpClient() {
return HttpUtils.defaultOkHttpClient();
}
+ @Provides
+ @Singleton
+ public BedrockIdentityKeyProvider bedrockIdentityKeyProvider(
+ ConnectConfig config,
+ @Named("defaultHttpClient") OkHttpClient httpClient) {
+ return new BedrockIdentityKeyProvider(config, httpClient);
+ }
+
+ @Provides
+ @Singleton
+ public BedrockIdentityReadiness bedrockIdentityReadiness(
+ ConnectConfig config,
+ BedrockIdentityKeyProvider keyProvider) {
+ return new BedrockIdentityReadiness(config, keyProvider);
+ }
+
@Provides
@Singleton
@Named("connectToken")
diff --git a/core/src/main/java/com/minekube/connect/module/ProxyCommonModule.java b/core/src/main/java/com/minekube/connect/module/ProxyCommonModule.java
index 4a61120c6..e3ccc8ecc 100644
--- a/core/src/main/java/com/minekube/connect/module/ProxyCommonModule.java
+++ b/core/src/main/java/com/minekube/connect/module/ProxyCommonModule.java
@@ -31,6 +31,7 @@
import com.minekube.connect.api.ProxyConnectApi;
import com.minekube.connect.api.SimpleConnectApi;
import com.minekube.connect.api.logger.ConnectLogger;
+import com.minekube.connect.bedrock.VerifiedBedrockIdentityRegistry;
import com.minekube.connect.config.ConnectConfig;
import com.minekube.connect.config.ProxyConnectConfig;
import java.nio.file.Path;
@@ -56,8 +57,9 @@ public Class extends ConnectConfig> configClass() {
@Provides
@Singleton
public ProxyConnectApi proxyApi(
- ConnectLogger logger
+ ConnectLogger logger,
+ VerifiedBedrockIdentityRegistry verifiedBedrockIdentities
) {
- return new ProxyConnectApi(logger);
+ return new ProxyConnectApi(logger, verifiedBedrockIdentities);
}
}
diff --git a/core/src/main/java/com/minekube/connect/module/ServerCommonModule.java b/core/src/main/java/com/minekube/connect/module/ServerCommonModule.java
index 336c068fb..5c8d11683 100644
--- a/core/src/main/java/com/minekube/connect/module/ServerCommonModule.java
+++ b/core/src/main/java/com/minekube/connect/module/ServerCommonModule.java
@@ -30,6 +30,7 @@
import com.google.inject.name.Named;
import com.minekube.connect.api.SimpleConnectApi;
import com.minekube.connect.api.logger.ConnectLogger;
+import com.minekube.connect.bedrock.VerifiedBedrockIdentityRegistry;
import com.minekube.connect.config.ConnectConfig;
import java.nio.file.Path;
@@ -47,7 +48,9 @@ public Class extends ConnectConfig> configClass() {
@Provides
@Singleton
- public SimpleConnectApi api(ConnectLogger logger) {
- return new SimpleConnectApi(logger);
+ public SimpleConnectApi api(
+ ConnectLogger logger,
+ VerifiedBedrockIdentityRegistry verifiedBedrockIdentities) {
+ return new SimpleConnectApi(logger, verifiedBedrockIdentities);
}
}
diff --git a/core/src/main/java/com/minekube/connect/network/netty/LocalChannelInboundHandler.java b/core/src/main/java/com/minekube/connect/network/netty/LocalChannelInboundHandler.java
index 469841a56..355f2d511 100644
--- a/core/src/main/java/com/minekube/connect/network/netty/LocalChannelInboundHandler.java
+++ b/core/src/main/java/com/minekube/connect/network/netty/LocalChannelInboundHandler.java
@@ -29,6 +29,7 @@
import com.google.rpc.Status;
import com.minekube.connect.api.SimpleConnectApi;
import com.minekube.connect.api.logger.ConnectLogger;
+import com.minekube.connect.bedrock.BedrockAdmissionCoordinator;
import com.minekube.connect.network.netty.LocalSession.Context;
import com.minekube.connect.tunnel.TunnelConn;
import com.minekube.connect.tunnel.Tunneler;
@@ -38,10 +39,8 @@
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import java.util.concurrent.atomic.AtomicLong;
-import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
-@RequiredArgsConstructor
public class LocalChannelInboundHandler extends SimpleChannelInboundHandler {
private static final String FORCE_TUNNEL_SERVICE_ADDR = System.getenv(
"CONNECT_FORCE_TUNNEL_SERVICE_ADDR");
@@ -50,14 +49,44 @@ public class LocalChannelInboundHandler extends SimpleChannelInboundHandler new Property(
- property.getName(),
- property.getValue(),
- property.getSignature()))
- .collect(Collectors.toList())
- ),
- new Auth(
- s.getAuth().getPassthrough()
- ),
- "" // TODO extract from http accept language header
- );
+ public LocalSession(
+ ConnectLogger logger,
+ SimpleConnectApi api,
+ Tunneler tunneler,
+ SocketAddress targetAddress,
+ SessionProposal sessionProposal) {
+ this(logger, api, tunneler, targetAddress, sessionProposal, null);
+ }
+
+ public LocalSession(
+ ConnectLogger logger,
+ SimpleConnectApi api,
+ Tunneler tunneler,
+ SocketAddress targetAddress,
+ SessionProposal sessionProposal,
+ BedrockAdmissionCoordinator admissionCoordinator) {
+ this.logger = logger;
+ this.api = api;
+ this.tunneler = tunneler;
+ this.targetAddress = targetAddress;
+ this.sessionProposal = sessionProposal;
+ this.admissionCoordinator = admissionCoordinator;
}
/**
@@ -135,13 +134,33 @@ public void connect() {
throw new IllegalStateException("Connection has already been connected.");
}
+ ConnectPlayer stagedPlayer = null;
+ try {
+ stagedPlayer = admissionCoordinator == null
+ ? BedrockAdmissionCoordinator.playerFor(sessionProposal.getSession())
+ : admissionCoordinator.stage(sessionProposal);
+ connect(stagedPlayer);
+ } catch (RuntimeException | Error e) {
+ if (admissionCoordinator != null) {
+ if (stagedPlayer == null) {
+ admissionCoordinator.discard(sessionProposal);
+ } else {
+ admissionCoordinator.discard(stagedPlayer);
+ }
+ }
+ throw e;
+ }
+ }
+
+ private void connect(ConnectPlayer stagedPlayer) {
final Context context = new Context(
- fromProto(sessionProposal.getSession()),
+ stagedPlayer,
createAddress(sessionProposal.getSession().getPlayer().getAddr()),
sessionProposal,
sessionProposal.getEndpointId(),
- sessionProposal.getEndpointOrgId()
- );
+ sessionProposal.getEndpointOrgId(),
+ sessionProposal.getProtocol(),
+ sessionProposal.getAdmissionToken());
// Use platform-specific event loop if available (e.g., BungeeCord's event loops)
// Otherwise fall back to default event loop group
@@ -161,7 +180,7 @@ public void connect() {
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause)
throws Exception {
- LocalSession.this.exceptionCaught(cause);
+ LocalSession.this.exceptionCaught(cause, context.player);
super.exceptionCaught(ctx, cause);
}
@@ -170,7 +189,7 @@ public void initChannel(@NotNull LocalChannelWithSessionContext channel) {
channel.setContext(context);
ChannelPipeline pipeline = channel.pipeline();
pipeline.addLast("connect-tunnel", new LocalChannelInboundHandler(
- context, logger, tunneler, api
+ context, logger, tunneler, api, admissionCoordinator
));
}
})
@@ -188,14 +207,24 @@ public void initChannel(@NotNull LocalChannelWithSessionContext channel) {
.connect()
.addListener((future) -> {
if (!future.isSuccess()) {
- exceptionCaught(future.cause());
+ exceptionCaught(future.cause(), context.player);
return;
}
- api.addPlayer(context.player);
+ try {
+ ConnectPlayer displaced = api.addPlayer(context.player);
+ if (admissionCoordinator != null && displaced != null && displaced != context.player) {
+ admissionCoordinator.discard(displaced);
+ }
+ } catch (RuntimeException | Error e) {
+ exceptionCaught(e, context.player);
+ }
});
}
- private void exceptionCaught(Throwable cause) {
+ private void exceptionCaught(Throwable cause, ConnectPlayer player) {
+ if (admissionCoordinator != null) {
+ admissionCoordinator.discard(player);
+ }
cause.printStackTrace();
// Reject session proposal in case we are still able to.
sessionProposal.reject(StatusProto.fromThrowable(cause));
@@ -235,6 +264,8 @@ public static class Context {
final @NotNull SessionProposal sessionProposal;
final @NotNull String endpointId;
final @NotNull String endpointOrgId;
+ final @NotNull SessionProtocol protocol;
+ final com.minekube.connect.bedrock.BedrockAdmissionCoordinator.AdmissionToken admissionToken;
AtomicReference tunnelConn = new AtomicReference<>(null);
}
diff --git a/core/src/main/java/com/minekube/connect/register/WatcherRegister.java b/core/src/main/java/com/minekube/connect/register/WatcherRegister.java
index eff9c3953..88029157d 100644
--- a/core/src/main/java/com/minekube/connect/register/WatcherRegister.java
+++ b/core/src/main/java/com/minekube/connect/register/WatcherRegister.java
@@ -31,6 +31,9 @@
import com.minekube.connect.api.SimpleConnectApi;
import com.minekube.connect.api.inject.PlatformInjector;
import com.minekube.connect.api.logger.ConnectLogger;
+import com.minekube.connect.bedrock.BedrockAdmissionCoordinator;
+import com.minekube.connect.bedrock.BedrockIdentityReadiness;
+import com.minekube.connect.bedrock.BedrockIdentityReadiness.Transport;
import com.minekube.connect.network.netty.LocalSession;
import com.minekube.connect.tunnel.Tunneler;
import com.minekube.connect.tunnel.p2p.Libp2pEndpoint;
@@ -42,6 +45,7 @@
import com.minekube.connect.watch.WatchBootstrap;
import com.minekube.connect.watch.WatchClient;
import com.minekube.connect.watch.Watcher;
+import io.grpc.protobuf.StatusProto;
import io.netty.util.concurrent.DefaultThreadFactory;
import java.io.IOException;
import java.time.Duration;
@@ -62,6 +66,8 @@ public class WatcherRegister {
@Inject private ConnectLogger logger;
@Inject private SimpleConnectApi api;
@Inject private Libp2pEndpoint libp2pEndpoint;
+ @Inject private BedrockIdentityReadiness bedrockIdentityReadiness;
+ @Inject private BedrockAdmissionCoordinator admissionCoordinator;
// volatile: written from injection thread (start/stop) and read from the
// scheduler thread (retry) and OkHttp dispatcher (WatcherImpl callbacks).
@@ -90,6 +96,7 @@ public synchronized void start() {
.setMaxIntervalMillis(60000 * 5) // 5 minutes
.setMultiplier(1.5) // 50% increase per back off
.build();
+ scheduler.scheduleWithFixedDelay(this::refreshWatchReadiness, 5, 5, TimeUnit.SECONDS);
watch();
}
}
@@ -102,6 +109,12 @@ public boolean isHealthy() {
return healthy.get();
}
+ private void refreshWatchReadiness() {
+ if (bedrockIdentityReadiness != null && bedrockIdentityReadiness.refresh(Transport.WATCH)) {
+ watch();
+ }
+ }
+
public synchronized void stop() {
// Gate the whole teardown so a concurrent stop() races safely.
// A stop() before start() is a no-op (started is already false).
@@ -214,6 +227,14 @@ private synchronized void enterLegacyWatchFallback() {
watch();
}
+ private void reject(SessionProposal proposal, Status reason) {
+ if (admissionCoordinator == null) {
+ proposal.reject(reason);
+ } else {
+ admissionCoordinator.reject(proposal, reason);
+ }
+ }
+
private class WatcherImpl implements Watcher {
private volatile boolean ignoreTerminalEvents;
@@ -247,7 +268,7 @@ public void onProposal(SessionProposal proposal) {
&& proposal.getSession().getTunnelTransportsCount() == 0) {
logger.info("Got session proposal with empty tunnel service address " +
"from WatchService, rejecting it");
- proposal.reject(Status.newBuilder()
+ reject(proposal, Status.newBuilder()
.setCode(Code.INVALID_ARGUMENT_VALUE)
.setMessage("tunnel service address must not be empty")
.build());
@@ -256,7 +277,7 @@ public void onProposal(SessionProposal proposal) {
if (proposal.getSession().getPlayer().getAddr().isEmpty()) {
logger.info("Got session proposal with empty player address " +
"from WatchService, rejecting it");
- proposal.reject(Status.newBuilder()
+ reject(proposal, Status.newBuilder()
.setCode(Code.INVALID_ARGUMENT_VALUE)
.setMessage("player address must not be empty")
.build());
@@ -271,11 +292,17 @@ public void onProposal(SessionProposal proposal) {
return;
}
- tunneler.prepare(proposal.getSession());
- new LocalSession(logger, api, tunneler,
- platformInjector.getServerSocketAddress(),
- proposal
- ).connect();
+ try {
+ tunneler.prepare(proposal.getSession());
+ new LocalSession(logger, api, tunneler,
+ platformInjector.getServerSocketAddress(),
+ proposal,
+ admissionCoordinator
+ ).connect();
+ } catch (RuntimeException | Error e) {
+ reject(proposal, StatusProto.fromThrowable(e));
+ throw e;
+ }
}
@Override
diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pEndpointRuntime.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pEndpointRuntime.java
index 56653424f..e1870c4ae 100644
--- a/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pEndpointRuntime.java
+++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pEndpointRuntime.java
@@ -28,6 +28,9 @@
import com.minekube.connect.api.SimpleConnectApi;
import com.minekube.connect.api.inject.PlatformInjector;
import com.minekube.connect.api.logger.ConnectLogger;
+import com.minekube.connect.bedrock.BedrockIdentityReadiness;
+import com.minekube.connect.bedrock.BedrockIdentityReadiness.Transport;
+import com.minekube.connect.bedrock.BedrockAdmissionCoordinator;
import com.minekube.connect.config.ConnectConfig;
import com.minekube.connect.network.netty.LocalSession;
import com.minekube.connect.platform.util.PlatformUtils;
@@ -76,6 +79,8 @@ final class Libp2pEndpointRuntime {
private final ConnectLogger logger;
private final PlatformInjector platformInjector;
private final SimpleConnectApi api;
+ private final BedrockIdentityReadiness bedrockIdentityReadiness;
+ private final BedrockAdmissionCoordinator admissionCoordinator;
private final String endpointInstanceId = newEndpointInstanceId();
private final AtomicLong sequence = new AtomicLong();
@@ -97,7 +102,9 @@ final class Libp2pEndpointRuntime {
PlatformUtils platformUtils,
ConnectLogger logger,
PlatformInjector platformInjector,
- SimpleConnectApi api) {
+ SimpleConnectApi api,
+ BedrockIdentityReadiness bedrockIdentityReadiness,
+ BedrockAdmissionCoordinator admissionCoordinator) {
this.dataDirectory = dataDirectory;
this.connectConfig = connectConfig;
this.connectToken = connectToken;
@@ -105,6 +112,21 @@ final class Libp2pEndpointRuntime {
this.logger = logger;
this.platformInjector = platformInjector;
this.api = api;
+ this.bedrockIdentityReadiness = bedrockIdentityReadiness;
+ this.admissionCoordinator = admissionCoordinator;
+ }
+
+ Libp2pEndpointRuntime(
+ @Named("dataDirectory") Path dataDirectory,
+ ConnectConfig connectConfig,
+ @Named("connectToken") String connectToken,
+ PlatformUtils platformUtils,
+ ConnectLogger logger,
+ PlatformInjector platformInjector,
+ SimpleConnectApi api,
+ BedrockIdentityReadiness bedrockIdentityReadiness) {
+ this(dataDirectory, connectConfig, connectToken, platformUtils, logger, platformInjector, api,
+ bedrockIdentityReadiness, null);
}
@Inject
@@ -210,7 +232,9 @@ private void installSessionResponder(Host host) {
api,
tunneler,
platformInjector.getServerSocketAddress(),
- proposal).connect());
+ proposal,
+ admissionCoordinator).connect(),
+ admissionCoordinator);
host.addProtocolHandler(new StrictProtocolBinding(
Libp2pSessionResponder.PROTOCOL_ID,
new ProtocolHandler(Long.MAX_VALUE, Long.MAX_VALUE) {
@@ -265,7 +289,9 @@ private ActiveRegistration registerOnce(OfflineMode offlineMode, EndpointAuthTyp
: connectConfig.getSuperEndpoints(),
offlineMode,
authType,
- libp2pConfig.capabilities(),
+ bedrockIdentityReadiness.capabilities(
+ libp2pConfig.capabilities(),
+ Transport.LIBP2P),
this::currentCapacity);
client = new PeerRegistrationClient(handshake);
PeerRegisterResult result = await(client.install(
@@ -337,6 +363,21 @@ private void startRegistrationLoop(OfflineMode offlineMode, EndpointAuthType aut
return thread;
});
registerAndWatch(offlineMode, authType);
+ registrationExecutor.scheduleWithFixedDelay(
+ this::refreshRegistrationReadiness, 5, 5, TimeUnit.SECONDS);
+ }
+
+ private void refreshRegistrationReadiness() {
+ if (!bedrockIdentityReadiness.refresh(Transport.LIBP2P)) {
+ return;
+ }
+ ActiveRegistration registration;
+ synchronized (this) {
+ registration = activeRegistration;
+ }
+ if (registration != null) {
+ registration.close();
+ }
}
private void scheduleRegisterRetry(OfflineMode offlineMode, EndpointAuthType authType, long delaySeconds) {
diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pSessionMapper.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pSessionMapper.java
index 277938b90..97d312fdc 100644
--- a/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pSessionMapper.java
+++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pSessionMapper.java
@@ -62,6 +62,7 @@ static Session toWatchSession(SessionOffer offer) {
.setId(offer.getSessionId())
.setPlayer(player)
.setAuth(Authentication.newBuilder().setPassthrough(passthrough))
+ .setProtocolValue(offer.getProtocolValue())
.addTunnelTransports(TunnelTransport.newBuilder()
.setType(TunnelTransport.Type.TYPE_LIBP2P)
.setAddress("same-stream"))
diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pSessionResponder.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pSessionResponder.java
index 981f34ec5..08ad0ca0f 100644
--- a/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pSessionResponder.java
+++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pSessionResponder.java
@@ -28,6 +28,7 @@
import com.google.rpc.Code;
import com.google.rpc.Status;
import com.minekube.connect.tunnel.Tunneler;
+import com.minekube.connect.bedrock.BedrockAdmissionCoordinator;
import com.minekube.connect.watch.SessionProposal;
import io.libp2p.core.PeerId;
import io.libp2p.core.Stream;
@@ -54,13 +55,14 @@ final class Libp2pSessionResponder {
private final LongSupplier clock;
private final Set allowedEdgePeerIds;
private final Starter starter;
+ private final BedrockAdmissionCoordinator admissionCoordinator;
Libp2pSessionResponder(Starter starter) {
- this(null, System::currentTimeMillis, Collections.emptySet(), starter);
+ this(null, System::currentTimeMillis, Collections.emptySet(), starter, null);
}
Libp2pSessionResponder(String expectedEndpoint, LongSupplier clock, Starter starter) {
- this(expectedEndpoint, clock, Collections.emptySet(), starter);
+ this(expectedEndpoint, clock, Collections.emptySet(), starter, null);
}
Libp2pSessionResponder(
@@ -68,11 +70,21 @@ final class Libp2pSessionResponder {
LongSupplier clock,
Collection allowedEdgePeerIds,
Starter starter) {
+ this(expectedEndpoint, clock, allowedEdgePeerIds, starter, null);
+ }
+
+ Libp2pSessionResponder(
+ String expectedEndpoint,
+ LongSupplier clock,
+ Collection allowedEdgePeerIds,
+ Starter starter,
+ BedrockAdmissionCoordinator admissionCoordinator) {
this.expectedEndpoint = expectedEndpoint;
this.clock = Objects.requireNonNull(clock, "clock");
this.allowedEdgePeerIds = Collections.unmodifiableSet(new HashSet<>(
Objects.requireNonNull(allowedEdgePeerIds, "allowedEdgePeerIds")));
this.starter = Objects.requireNonNull(starter, "starter");
+ this.admissionCoordinator = admissionCoordinator;
}
void install(Stream stream) {
@@ -95,29 +107,45 @@ void handleOffer(Stream stream, SessionOffer offer) {
stream.close();
return;
}
- SessionProposal proposal = new SessionProposal(
- Libp2pSessionMapper.toWatchSession(offer),
- reason -> {
+ String sessionId = offer.getSessionId();
+ String endpointId = offer.getEndpointId();
+ String endpointOrgId = offer.getEndpointOrgId();
+ java.util.function.Consumer rejectProposal = reason -> {
writeResponse(stream, SessionResponse.newBuilder()
- .setSessionId(offer.getSessionId())
+ .setSessionId(sessionId)
.setRejected(SessionRejected.newBuilder().setReason(reason))
.build());
stream.close();
- },
- offer.getEndpointId(),
- offer.getEndpointOrgId());
- Tunneler tunneler = new Tunneler(new SameStreamTunnelTransport(stream, sessionId ->
- writeResponse(stream, SessionResponse.newBuilder()
- .setSessionId(sessionId)
- .setAccepted(SessionAccepted.newBuilder().setSameStreamData(true))
- .build())));
+ };
+ minekube.connect.v1alpha1.WatchServiceOuterClass.Session raw = Libp2pSessionMapper.toWatchSession(offer);
+ SessionProposal proposal = admissionCoordinator == null
+ ? new SessionProposal(raw, rejectProposal, endpointId, endpointOrgId)
+ : admissionCoordinator.proposal(raw, rejectProposal, endpointId, endpointOrgId);
try {
+ Tunneler tunneler = new Tunneler(new SameStreamTunnelTransport(stream, acceptedSessionId ->
+ writeResponse(stream, SessionResponse.newBuilder()
+ .setSessionId(acceptedSessionId)
+ .setAccepted(SessionAccepted.newBuilder().setSameStreamData(true))
+ .build())));
starter.start(proposal, tunneler);
} catch (RuntimeException e) {
- proposal.reject(Status.newBuilder()
+ reject(proposal, Status.newBuilder()
.setCode(Code.INTERNAL_VALUE)
.setMessage(e.getMessage() == null ? e.toString() : e.getMessage())
.build());
+ } catch (Error e) {
+ if (admissionCoordinator != null) {
+ admissionCoordinator.discard(proposal);
+ }
+ throw e;
+ }
+ }
+
+ private void reject(SessionProposal proposal, Status reason) {
+ if (admissionCoordinator == null) {
+ proposal.reject(reason);
+ } else {
+ admissionCoordinator.reject(proposal, reason);
}
}
diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/PeerRegistrationHandshake.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/PeerRegistrationHandshake.java
index f7ecf9fee..9194887f1 100644
--- a/core/src/main/java/com/minekube/connect/tunnel/p2p/PeerRegistrationHandshake.java
+++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/PeerRegistrationHandshake.java
@@ -31,8 +31,8 @@
import java.util.Objects;
import java.util.Set;
import java.util.function.Supplier;
-import minekube.connect.v1alpha1.ConnectLibp2P.EndpointPeerRecord;
import minekube.connect.v1alpha1.ConnectLibp2P.EndpointAuthType;
+import minekube.connect.v1alpha1.ConnectLibp2P.EndpointPeerRecord;
import minekube.connect.v1alpha1.ConnectLibp2P.OfflineMode;
import minekube.connect.v1alpha1.ConnectLibp2P.PeerCapacity;
import minekube.connect.v1alpha1.ConnectLibp2P.PeerRegisterChallenge;
@@ -40,6 +40,7 @@
import minekube.connect.v1alpha1.ConnectLibp2P.PeerRegisterInit;
final class PeerRegistrationHandshake {
+ static final String BEDROCK_IDENTITY_V1_CAPABILITY = "bedrock-identity-v1";
private final EndpointPeerIdentity identity;
private final String endpoint;
private final String token;
diff --git a/core/src/main/java/com/minekube/connect/watch/SessionProposal.java b/core/src/main/java/com/minekube/connect/watch/SessionProposal.java
index 436281867..54d101842 100644
--- a/core/src/main/java/com/minekube/connect/watch/SessionProposal.java
+++ b/core/src/main/java/com/minekube/connect/watch/SessionProposal.java
@@ -27,27 +27,33 @@
import com.google.gson.Gson;
import com.google.gson.JsonSyntaxException;
-import java.util.concurrent.atomic.AtomicReference;
+import com.minekube.connect.api.player.ConnectPlayer;
+import com.minekube.connect.api.player.bedrock.BedrockIdentityProfiles;
+import com.minekube.connect.api.player.bedrock.BedrockIdentityVerifier;
+import com.minekube.connect.bedrock.BedrockAdmissionCoordinator.AdmissionToken;
import java.util.function.Consumer;
import lombok.Getter;
import minekube.connect.v1alpha1.WatchServiceOuterClass.Session;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.SessionProtocol;
public class SessionProposal {
private static final Gson GSON = new Gson();
- private static final String SCOPE_PROPERTY_NAME = "minekube:bedrock_identity_scope";
-
@Getter
private final Session session;
+ @Getter
+ private final AdmissionToken admissionToken;
private final Consumer reject;
@Getter
private final String endpointId;
@Getter
private final String endpointOrgId;
+ @Getter
+ private final SessionProtocol protocol;
- private final AtomicReference state = new AtomicReference<>(State.ACCEPTED);
+ private final java.util.concurrent.atomic.AtomicReference state = new java.util.concurrent.atomic.AtomicReference<>(State.ACCEPTED);
public SessionProposal(Session session, Consumer reject) {
- this(session, reject, "", "");
+ this(session, reject, "", "", null);
}
public SessionProposal(
@@ -55,11 +61,24 @@ public SessionProposal(
Consumer reject,
String endpointId,
String endpointOrgId) {
- this.session = session;
+ this(session, reject, endpointId, endpointOrgId, null);
+ }
+
+ public SessionProposal(
+ Session session,
+ Consumer reject,
+ String endpointId,
+ String endpointOrgId,
+ AdmissionToken admissionToken) {
+ this.session = withoutPrivateIdentity(session);
+ this.admissionToken = admissionToken;
this.reject = reject;
Scope scope = parseScope(session);
this.endpointId = firstNonEmpty(endpointId, scope.endpoint_id);
this.endpointOrgId = firstNonEmpty(endpointOrgId, scope.endpoint_org_id);
+ this.protocol = session == null
+ ? SessionProtocol.SESSION_PROTOCOL_UNSPECIFIED
+ : session.getProtocol();
}
public enum State {
@@ -91,7 +110,7 @@ private static Scope parseScope(Session session) {
return new Scope();
}
for (var property : session.getPlayer().getProfile().getPropertiesList()) {
- if (SCOPE_PROPERTY_NAME.equals(property.getName())) {
+ if (BedrockIdentityProfiles.SCOPE_PROPERTY_NAME.equals(property.getName())) {
try {
Scope scope = GSON.fromJson(property.getValue(), Scope.class);
return scope == null ? new Scope() : scope;
@@ -103,6 +122,32 @@ private static Scope parseScope(Session session) {
return new Scope();
}
+ private static Session withoutPrivateIdentity(Session session) {
+ if (!hasPrivateIdentity(session)) {
+ return session;
+ }
+ var profile = session.getPlayer().getProfile().toBuilder().clearProperties();
+ for (var property : session.getPlayer().getProfile().getPropertiesList()) {
+ if (!BedrockIdentityVerifier.PROPERTY_NAME.equals(property.getName()) &&
+ !BedrockIdentityProfiles.SCOPE_PROPERTY_NAME.equals(property.getName())) {
+ profile.addProperties(property);
+ }
+ }
+ return session.toBuilder()
+ .setPlayer(session.getPlayer().toBuilder().setProfile(profile))
+ .build();
+ }
+
+ private static boolean hasPrivateIdentity(Session session) {
+ if (session == null || !session.hasPlayer() || !session.getPlayer().hasProfile()) {
+ return false;
+ }
+ return session.getPlayer().getProfile().getPropertiesList().stream()
+ .anyMatch(property ->
+ BedrockIdentityVerifier.PROPERTY_NAME.equals(property.getName()) ||
+ BedrockIdentityProfiles.SCOPE_PROPERTY_NAME.equals(property.getName()));
+ }
+
private static String firstNonEmpty(String preferred, String fallback) {
if (preferred != null && !preferred.isEmpty()) {
return preferred;
diff --git a/core/src/main/java/com/minekube/connect/watch/WatchClient.java b/core/src/main/java/com/minekube/connect/watch/WatchClient.java
index 75aea7798..4fa6e51f3 100644
--- a/core/src/main/java/com/minekube/connect/watch/WatchClient.java
+++ b/core/src/main/java/com/minekube/connect/watch/WatchClient.java
@@ -28,6 +28,10 @@
import com.google.inject.Inject;
import com.google.inject.name.Named;
import com.google.protobuf.InvalidProtocolBufferException;
+import com.minekube.connect.bedrock.BedrockIdentityKeyProvider;
+import com.minekube.connect.bedrock.BedrockIdentityReadiness;
+import com.minekube.connect.bedrock.BedrockIdentityReadiness.Transport;
+import com.minekube.connect.bedrock.BedrockAdmissionCoordinator;
import com.minekube.connect.config.ConnectConfig;
import java.io.IOException;
import minekube.connect.v1alpha1.WatchServiceOuterClass.SessionRejection;
@@ -47,22 +51,40 @@ public class WatchClient {
private static final String ENDPOINT_HEADER = "Connect-Endpoint";
private static final String ENDPOINT_OFFLINE_MODE_HEADER = ENDPOINT_HEADER + "-Offline-Mode";
private static final String ENDPOINT_PARENTS_HEADER = ENDPOINT_HEADER + "-Parents";
+ private static final String CAPABILITIES_HEADER = "Connect-Capabilities";
+ private static final String BEDROCK_IDENTITY_V1_CAPABILITY = "bedrock-identity-v1";
private static final String WATCH_URL = System.getenv().getOrDefault(
"CONNECT_WATCH_URL", "wss://watch-connect.minekube.net");
private final OkHttpClient httpClient;
private final ConnectConfig config;
+ private final BedrockIdentityReadiness bedrockIdentityReadiness;
+ private final BedrockAdmissionCoordinator admissionCoordinator;
@Inject
- public WatchClient(@Named("watchHttpClient") OkHttpClient httpClient, ConnectConfig config) {
+ public WatchClient(
+ @Named("watchHttpClient") OkHttpClient httpClient,
+ ConnectConfig config,
+ BedrockIdentityReadiness bedrockIdentityReadiness,
+ BedrockAdmissionCoordinator admissionCoordinator) {
this.httpClient = httpClient;
this.config = config;
+ this.bedrockIdentityReadiness = bedrockIdentityReadiness;
+ this.admissionCoordinator = admissionCoordinator;
+ }
+
+ public WatchClient(@Named("watchHttpClient") OkHttpClient httpClient, ConnectConfig config) {
+ this(httpClient, config, new BedrockIdentityReadiness(
+ config, new BedrockIdentityKeyProvider(config, new OkHttpClient())), null);
}
public WebSocket watch(Watcher watcher) {
Request.Builder request = new Request.Builder()
.url(WATCH_URL)
.header(ENDPOINT_HEADER, config.getEndpoint());
+ if (bedrockIdentityReadiness.observe(Transport.WATCH)) {
+ request.header(CAPABILITIES_HEADER, BEDROCK_IDENTITY_V1_CAPABILITY);
+ }
if (config.getAllowOfflineModePlayers() != null) {
request = request.header(ENDPOINT_OFFLINE_MODE_HEADER,
@@ -122,21 +144,22 @@ public void onMessage(@NotNull WebSocket webSocket, @NotNull ByteString bytes) {
return;
}
- SessionProposal prop = new SessionProposal(
- res.getSession(),
- reason -> {
- Builder rejection = SessionRejection.newBuilder()
- .setId(res.getSession().getId());
+ String sessionId = res.getSession().getId();
+ java.util.function.Consumer rejectProposal = reason -> {
+ Builder responseRejection = SessionRejection.newBuilder()
+ .setId(sessionId);
if (reason != null) {
- rejection.setReason(reason);
+ responseRejection.setReason(reason);
}
webSocket.send(ByteString.of(WatchRequest.newBuilder()
- .setSessionRejection(rejection)
+ .setSessionRejection(responseRejection)
.build()
.toByteArray()
));
- }
- );
+ };
+ SessionProposal prop = admissionCoordinator == null
+ ? new SessionProposal(res.getSession(), rejectProposal)
+ : admissionCoordinator.proposal(res.getSession(), rejectProposal, "", "");
watcher.onProposal(prop);
}
diff --git a/core/src/main/proto/com/minekube/connect/v1alpha1/connect_libp2p.proto b/core/src/main/proto/com/minekube/connect/v1alpha1/connect_libp2p.proto
index 728982f59..04940d5c9 100644
--- a/core/src/main/proto/com/minekube/connect/v1alpha1/connect_libp2p.proto
+++ b/core/src/main/proto/com/minekube/connect/v1alpha1/connect_libp2p.proto
@@ -5,6 +5,7 @@ syntax = "proto3";
package minekube.connect.v1alpha1;
import "google/rpc/status.proto";
+import "com/minekube/connect/v1alpha1/watch_service.proto";
message PeerRegisterInit {
string endpoint = 1;
@@ -95,6 +96,8 @@ message SessionOffer {
int64 deadline_unix_ms = 5;
string endpoint_id = 6;
string endpoint_org_id = 7;
+ // Mirrors the authenticated legacy Session protocol discriminator.
+ SessionProtocol protocol = 8;
}
message SessionPlayer {
diff --git a/core/src/main/proto/com/minekube/connect/v1alpha1/watch_service.proto b/core/src/main/proto/com/minekube/connect/v1alpha1/watch_service.proto
index c664157e4..800c7a352 100644
--- a/core/src/main/proto/com/minekube/connect/v1alpha1/watch_service.proto
+++ b/core/src/main/proto/com/minekube/connect/v1alpha1/watch_service.proto
@@ -4,6 +4,12 @@ package minekube.connect.v1alpha1;
import "google/rpc/status.proto";
+enum SessionProtocol {
+ SESSION_PROTOCOL_UNSPECIFIED = 0;
+ SESSION_PROTOCOL_JAVA = 1;
+ SESSION_PROTOCOL_BEDROCK = 2;
+}
+
service WatchService {
// Watch watches for session proposals for taking player connections.
rpc Watch(stream WatchRequest) returns (stream WatchResponse);
@@ -52,6 +58,9 @@ message Session {
// Additional transport-specific addresses for establishing the player connection.
// Older clients ignore this field and continue using tunnel_service_addr.
repeated TunnelTransport tunnel_transports = 5;
+ // The player protocol determined by the authenticated Connect Edge.
+ // Legacy senders omit this field and decode as SESSION_PROTOCOL_UNSPECIFIED.
+ SessionProtocol protocol = 6;
}
message TunnelTransport {
diff --git a/core/src/main/resources/config.yml b/core/src/main/resources/config.yml
index 76260502d..139238f6a 100644
--- a/core/src/main/resources/config.yml
+++ b/core/src/main/resources/config.yml
@@ -16,21 +16,28 @@ allow-offline-mode-players: false
# - someones-hub
# Moxy can attach a signed Bedrock/Xbox identity envelope to Bedrock sessions.
-# Keep enforcement disabled unless Minekube provided the current public key.
+# Keep enforcement disabled unless Minekube provided trusted key configuration.
bedrock-identity:
# disabled: do not verify or reject
- # warn: verify when public-key is configured and log failures, but allow the session
+ # warn: verify when any key source is configured and log failures, but allow the session
# require: reject Bedrock sessions with missing or invalid identity envelopes
+ # Values are exact; malformed identity settings never advertise trusted identity.
enforcement: disabled
- # Base64-encoded Ed25519 public key. Supports raw 32-byte and X.509 encoded keys after decoding.
+ # Base64-encoded Ed25519 key used only when metadata-url is empty.
+ # Supports raw 32-byte and X.509 encoded keys after decoding.
public-key: ""
- # Additional Base64-encoded Ed25519 public keys accepted during key rotation.
+ # Additional Base64-encoded Ed25519 keys used only when metadata-url is empty.
public-keys: []
- # Public metadata URL for Moxy verifier keys. Prefer this once Minekube publishes it.
+ # Authoritative exact HTTPS metadata URL for Moxy verifier keys.
+ # Userinfo, fragments, and redirects are rejected; static keys are not a fallback.
metadata-url: ""
- # Seconds to cache successful verifier metadata.
+ # Maximum seconds to cache successful metadata; a smaller published max-age takes precedence.
metadata-cache-seconds: 300
- # Expected Connect Edge Bedrock authentication policy.
+ # Hard stale-if-error bound for expired remote keys. Require mode fails closed after this.
+ metadata-max-stale-seconds: 600
+ # Required non-blank issuer expected in Moxy metadata and signed envelopes.
+ expected-issuer: minekube-connect
+ # Required exact policy: linked_java_only or trusted_bedrock_xuid.
expected-policy: trusted_bedrock_xuid
# The default locale for Connect. By default, Connect uses the system locale
diff --git a/core/src/main/resources/proxy-config.yml b/core/src/main/resources/proxy-config.yml
index 7ba5c66a0..d161c863d 100644
--- a/core/src/main/resources/proxy-config.yml
+++ b/core/src/main/resources/proxy-config.yml
@@ -18,21 +18,28 @@ allow-offline-mode-players: false
# - someones-hub
# Moxy can attach a signed Bedrock/Xbox identity envelope to Bedrock sessions.
-# Keep enforcement disabled unless Minekube provided the current public key.
+# Keep enforcement disabled unless Minekube provided trusted key configuration.
bedrock-identity:
# disabled: do not verify or reject
- # warn: verify when public-key is configured and log failures, but allow the session
+ # warn: verify when any key source is configured and log failures, but allow the session
# require: reject Bedrock sessions with missing or invalid identity envelopes
+ # Values are exact; malformed identity settings never advertise trusted identity.
enforcement: disabled
- # Base64-encoded Ed25519 public key. Supports raw 32-byte and X.509 encoded keys after decoding.
+ # Base64-encoded Ed25519 key used only when metadata-url is empty.
+ # Supports raw 32-byte and X.509 encoded keys after decoding.
public-key: ""
- # Additional Base64-encoded Ed25519 public keys accepted during key rotation.
+ # Additional Base64-encoded Ed25519 keys used only when metadata-url is empty.
public-keys: []
- # Public metadata URL for Moxy verifier keys. Prefer this once Minekube publishes it.
+ # Authoritative exact HTTPS metadata URL for Moxy verifier keys.
+ # Userinfo, fragments, and redirects are rejected; static keys are not a fallback.
metadata-url: ""
- # Seconds to cache successful verifier metadata.
+ # Maximum seconds to cache successful metadata; a smaller published max-age takes precedence.
metadata-cache-seconds: 300
- # Expected Connect Edge Bedrock authentication policy.
+ # Hard stale-if-error bound for expired remote keys. Require mode fails closed after this.
+ metadata-max-stale-seconds: 600
+ # Required non-blank issuer expected in Moxy metadata and signed envelopes.
+ expected-issuer: minekube-connect
+ # Required exact policy: linked_java_only or trusted_bedrock_xuid.
expected-policy: trusted_bedrock_xuid
# bStats is a stat tracker that is entirely anonymous and tracks only basic information
diff --git a/core/src/test/java/com/minekube/connect/api/player/bedrock/BedrockIdentityClaimsExposureTest.java b/core/src/test/java/com/minekube/connect/api/player/bedrock/BedrockIdentityClaimsExposureTest.java
new file mode 100644
index 000000000..0d4ffb0b9
--- /dev/null
+++ b/core/src/test/java/com/minekube/connect/api/player/bedrock/BedrockIdentityClaimsExposureTest.java
@@ -0,0 +1,73 @@
+package com.minekube.connect.api.player.bedrock;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonPrimitive;
+import java.beans.Introspector;
+import java.beans.PropertyDescriptor;
+import java.beans.Transient;
+import java.lang.reflect.Method;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+class BedrockIdentityClaimsExposureTest {
+ @Test
+ void exposesXuidOnlyThroughTheExplicitSensitiveGetter() {
+ String xuid = "12345678901234567";
+ BedrockIdentityClaims claims = new BedrockIdentityClaims(
+ "minekube-connect", "endpoint-id", "endpoint", "org-id", "session-id",
+ "bedrock", "trusted_bedrock_xuid", "unlinked", xuid, "BedrockSteve",
+ "f912bf90-8349-565f-9dc0-9891923c0cc3", null, null,
+ null, null);
+
+ assertEquals(xuid, claims.getBedrockXuid());
+ assertFalse(claims.toString().contains(xuid));
+ assertFalse(new GsonBuilder()
+ .registerTypeAdapter(Instant.class, (com.google.gson.JsonSerializer)
+ (value, type, context) -> new JsonPrimitive(value.toString()))
+ .create()
+ .toJson(claims)
+ .contains(xuid));
+ assertFalse(claims.toString().contains("signed-envelope"));
+ assertFalse(claims.toString().contains("replay-nonce"));
+ assertFalse(claims.toString().contains("bedrock_identity_scope"));
+ }
+
+ @Test
+ void excludesSensitiveXuidFromGetterBasedSerialization() throws Exception {
+ String xuid = "12345678901234567";
+ BedrockIdentityClaims claims = new BedrockIdentityClaims(
+ "minekube-connect", "endpoint-id", "endpoint", "org-id", "session-id",
+ "bedrock", "trusted_bedrock_xuid", "unlinked", xuid, "BedrockSteve",
+ "f912bf90-8349-565f-9dc0-9891923c0cc3", null, null,
+ null, null);
+
+ Method getter = BedrockIdentityClaims.class.getMethod("getBedrockXuid");
+ Map serialized = new HashMap<>();
+ for (PropertyDescriptor property :
+ Introspector.getBeanInfo(BedrockIdentityClaims.class, Object.class)
+ .getPropertyDescriptors()) {
+ Method readMethod = property.getReadMethod();
+ if (readMethod != null && !readMethod.isAnnotationPresent(Transient.class)) {
+ serialized.put(property.getName(), invoke(readMethod, claims));
+ }
+ }
+
+ assertTrue(getter.isAnnotationPresent(Transient.class));
+ assertFalse(serialized.containsKey("bedrockXuid"));
+ assertFalse(serialized.containsValue(xuid));
+ }
+
+ private static Object invoke(Method method, Object target) {
+ try {
+ return method.invoke(target);
+ } catch (ReflectiveOperationException e) {
+ throw new AssertionError(e);
+ }
+ }
+}
diff --git a/core/src/test/java/com/minekube/connect/api/player/bedrock/BedrockIdentityFixtureManifestTest.java b/core/src/test/java/com/minekube/connect/api/player/bedrock/BedrockIdentityFixtureManifestTest.java
new file mode 100644
index 000000000..4e5ccd28a
--- /dev/null
+++ b/core/src/test/java/com/minekube/connect/api/player/bedrock/BedrockIdentityFixtureManifestTest.java
@@ -0,0 +1,163 @@
+package com.minekube.connect.api.player.bedrock;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import com.google.gson.Gson;
+import com.minekube.connect.api.player.GameProfile;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.List;
+import java.util.UUID;
+import org.junit.jupiter.api.Test;
+
+class BedrockIdentityFixtureManifestTest {
+ @Test
+ void moxyOwnedV1FixturesMatchPublishedSha256ManifestByteForByte() throws Exception {
+ Manifest manifest;
+ try (InputStream input = resource("manifest.json")) {
+ manifest = new Gson().fromJson(new String(input.readAllBytes(), StandardCharsets.UTF_8), Manifest.class);
+ }
+
+ assertEquals("minekube-bedrock-identity-v1-test-vectors", manifest.schema);
+ assertEquals(23, manifest.vectors.size());
+ for (Vector vector : manifest.vectors) {
+ byte[] bytes;
+ try (InputStream input = resource(vector.file)) {
+ bytes = input.readAllBytes();
+ }
+ assertEquals(vector.sha256, hex(MessageDigest.getInstance("SHA-256").digest(bytes)), vector.file);
+ }
+ }
+
+ @Test
+ void verifiesTheMoxySignedUnlinkedBedrockFixture() throws Exception {
+ Manifest manifest;
+ try (InputStream input = resource("manifest.json")) {
+ manifest = new Gson().fromJson(new String(input.readAllBytes(), StandardCharsets.UTF_8), Manifest.class);
+ }
+ String envelope;
+ try (InputStream input = resource("v1-bedrock-xuid-valid.json")) {
+ envelope = new String(input.readAllBytes(), StandardCharsets.UTF_8);
+ }
+
+ BedrockIdentityClaims claims = BedrockIdentityVerifier.builder()
+ .publicKey(Base64.getDecoder().decode(manifest.public_key_base64))
+ .expectedIssuer("minekube-connect")
+ .now(Instant.ofEpochMilli(manifest.verification_time_unix_ms))
+ .endpointId("endpoint-id")
+ .endpointName("fixture-endpoint")
+ .orgId("org-id")
+ .sessionId("session-id")
+ .protocol("bedrock")
+ .bedrockAuthPolicy("trusted_bedrock_xuid")
+ .build()
+ .verify(envelope);
+
+ assertEquals("bedrock_xuid", claims.getPrincipalType());
+ }
+
+ @Test
+ void appliesMoxyFixtureAcceptanceSemanticsToEveryEnvelopeVector() throws Exception {
+ Manifest manifest;
+ try (InputStream input = resource("manifest.json")) {
+ manifest = new Gson().fromJson(new String(input.readAllBytes(), StandardCharsets.UTF_8), Manifest.class);
+ }
+ for (Vector vector : manifest.vectors) {
+ if ("v1-duplicate-property-profile.json".equals(vector.file)) {
+ continue;
+ }
+ String envelope;
+ try (InputStream input = resource(vector.file)) {
+ envelope = new String(input.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ BedrockIdentityVerifier verifier = BedrockIdentityVerifier.builder()
+ .publicKey(Base64.getDecoder().decode(manifest.public_key_base64))
+ .expectedIssuer("minekube-connect")
+ .now(Instant.ofEpochMilli(manifest.verification_time_unix_ms))
+ .endpointId("endpoint-id")
+ .endpointName("fixture-endpoint")
+ .orgId("org-id")
+ .sessionId("session-id")
+ .protocol("bedrock")
+ .build();
+ if (vector.expected.startsWith("valid_")) {
+ assertDoesNotThrow(() -> verifier.verify(envelope), vector.file);
+ } else {
+ assertThrows(BedrockIdentityVerificationException.class,
+ () -> verifier.verify(envelope), vector.file);
+ }
+ }
+ }
+
+ @Test
+ void rejectsTheMoxyDuplicateReservedPropertyProfileFixture() throws Exception {
+ Manifest manifest;
+ try (InputStream input = resource("manifest.json")) {
+ manifest = new Gson().fromJson(new String(input.readAllBytes(), StandardCharsets.UTF_8), Manifest.class);
+ }
+ ProfileFixture fixture;
+ try (InputStream input = resource("v1-duplicate-property-profile.json")) {
+ fixture = new Gson().fromJson(new String(input.readAllBytes(), StandardCharsets.UTF_8), ProfileFixture.class);
+ }
+ GameProfile profile = new GameProfile("BedrockFox",
+ UUID.fromString("cafc7598-0ef3-527f-8f28-60af2d9ca6bc"),
+ fixture.properties.stream().map(property -> new GameProfile.Property(property.name, property.value, ""))
+ .toList());
+ BedrockIdentityVerifier verifier = BedrockIdentityVerifier.builder()
+ .publicKey(Base64.getDecoder().decode(manifest.public_key_base64))
+ .expectedIssuer("minekube-connect")
+ .now(Instant.ofEpochMilli(manifest.verification_time_unix_ms))
+ .endpointId("endpoint-id")
+ .endpointName("fixture-endpoint")
+ .orgId("org-id")
+ .sessionId("session-id")
+ .protocol("bedrock")
+ .build();
+
+ assertThrows(BedrockIdentityVerificationException.class, () -> verifier.verify(profile));
+ }
+
+ private static InputStream resource(String file) {
+ InputStream input = BedrockIdentityFixtureManifestTest.class.getResourceAsStream(
+ "/bedrock-identity-v1/" + file);
+ if (input == null) {
+ throw new AssertionError("missing Moxy fixture " + file);
+ }
+ return input;
+ }
+
+ private static String hex(byte[] bytes) {
+ StringBuilder out = new StringBuilder(bytes.length * 2);
+ for (byte value : bytes) {
+ out.append(String.format("%02x", value));
+ }
+ return out.toString();
+ }
+
+ private static final class Manifest {
+ String schema;
+ String public_key_base64;
+ long verification_time_unix_ms;
+ List vectors;
+ }
+
+ private static final class Vector {
+ String file;
+ String sha256;
+ String expected;
+ }
+
+ private static final class ProfileFixture {
+ List properties;
+ }
+
+ private static final class PropertyFixture {
+ String name;
+ String value;
+ }
+}
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 60036c53d..b148b992d 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
@@ -21,11 +21,12 @@
class BedrockIdentityVerifierTest {
private static final Gson GSON = new Gson();
private static final Instant NOW = Instant.parse("2026-07-05T12:00:00Z");
+ private static final String VALID_NONCE = "AAAAAAAAAAAAAAAAAAAAAA";
@Test
void verifiesEndpointScopedBedrockXuidEnvelopeFromGameProfileProperty() throws Exception {
KeyPair keyPair = ed25519KeyPair();
- String envelope = signedEnvelope(keyPair, "nonce-a", "session-1", "endpoint-id", "endpoint", "org-id");
+ String envelope = signedEnvelope(keyPair, VALID_NONCE, "session-1", "endpoint-id", "endpoint", "org-id");
GameProfile profile = profileWithEnvelope(envelope);
BedrockIdentityVerifier verifier = BedrockIdentityVerifier.builder()
@@ -49,9 +50,19 @@ void verifiesEndpointScopedBedrockXuidEnvelopeFromGameProfileProperty() throws E
}
@Test
- void rejectsTamperedEnvelopeSignature() throws Exception {
+ void rejectsSignedEnvelopeWithQuotedVersion() throws Exception {
KeyPair keyPair = ed25519KeyPair();
String envelope = signedEnvelope(keyPair, "nonce-a", "session-1", "endpoint-id", "endpoint", "org-id")
+ .replace("\"version\":1", "\"version\":\"1\"");
+
+ assertThrows(BedrockIdentityVerificationException.class,
+ () -> verifier(keyPair, "session-1").verify(profileWithEnvelope(envelope)));
+ }
+
+ @Test
+ void rejectsTamperedEnvelopeSignature() throws Exception {
+ KeyPair keyPair = ed25519KeyPair();
+ String envelope = signedEnvelope(keyPair, VALID_NONCE, "session-1", "endpoint-id", "endpoint", "org-id")
.replace("BedrockSteve", "SpoofedSteve");
BedrockIdentityVerifier verifier = verifier(keyPair, "session-1");
@@ -62,10 +73,30 @@ void rejectsTamperedEnvelopeSignature() throws Exception {
assertTrue(error.getMessage().contains("signature"));
}
+ @Test
+ void rejectsNonceUnlessItIsCanonicalUnpaddedBase64UrlForSixteenBytes() throws Exception {
+ KeyPair keyPair = ed25519KeyPair();
+ BedrockIdentityVerifier verifier = verifier(keyPair, "session-1");
+ for (String nonce : Arrays.asList(
+ Base64.getUrlEncoder().withoutPadding().encodeToString(new byte[15]),
+ Base64.getUrlEncoder().withoutPadding().encodeToString(new byte[17]),
+ VALID_NONCE + "==",
+ "AAAAAAAAAAAAAAAAAAAAA+")) {
+ String envelope = signedEnvelope(
+ keyPair, nonce, "session-1", "endpoint-id", "endpoint", "org-id");
+
+ BedrockIdentityVerificationException error = assertThrows(
+ BedrockIdentityVerificationException.class,
+ () -> verifier.verify(profileWithEnvelope(envelope)));
+
+ assertTrue(error.getMessage().contains("nonce"));
+ }
+ }
+
@Test
void rejectsScopeMismatch() throws Exception {
KeyPair keyPair = ed25519KeyPair();
- String envelope = signedEnvelope(keyPair, "nonce-a", "session-1", "endpoint-id", "endpoint", "org-id");
+ String envelope = signedEnvelope(keyPair, VALID_NONCE, "session-1", "endpoint-id", "endpoint", "org-id");
BedrockIdentityVerifier verifier = BedrockIdentityVerifier.builder()
.publicKey(keyPair.getPublic().getEncoded())
.now(NOW)
@@ -86,7 +117,7 @@ void rejectsScopeMismatch() throws Exception {
@Test
void verifiesWhenEndpointIdAndOrgIdAreNotLocallyConfigured() throws Exception {
KeyPair keyPair = ed25519KeyPair();
- String envelope = signedEnvelope(keyPair, "nonce-a", "session-1", "endpoint-id", "endpoint", "org-id");
+ String envelope = signedEnvelope(keyPair, VALID_NONCE, "session-1", "endpoint-id", "endpoint", "org-id");
BedrockIdentityVerifier verifier = BedrockIdentityVerifier.builder()
.publicKey(keyPair.getPublic().getEncoded())
.now(NOW)
@@ -105,7 +136,7 @@ void verifiesWhenEndpointIdAndOrgIdAreNotLocallyConfigured() throws Exception {
@Test
void rejectsPolicyMismatchWhenExpectedPolicyIsConfigured() throws Exception {
KeyPair keyPair = ed25519KeyPair();
- String envelope = signedEnvelope(keyPair, "nonce-a", "session-1", "endpoint-id", "endpoint", "org-id");
+ String envelope = signedEnvelope(keyPair, VALID_NONCE, "session-1", "endpoint-id", "endpoint", "org-id");
BedrockIdentityVerifier verifier = BedrockIdentityVerifier.builder()
.publicKey(keyPair.getPublic().getEncoded())
.now(NOW)
@@ -127,7 +158,7 @@ void rejectsPolicyMismatchWhenExpectedPolicyIsConfigured() throws Exception {
@Test
void rejectsReplayWhenCacheSeesSameEndpointSessionNonceTwice() throws Exception {
KeyPair keyPair = ed25519KeyPair();
- String envelope = signedEnvelope(keyPair, "nonce-a", "session-1", "endpoint-id", "endpoint", "org-id");
+ String envelope = signedEnvelope(keyPair, VALID_NONCE, "session-1", "endpoint-id", "endpoint", "org-id");
BedrockIdentityReplayCache replayCache = new BedrockIdentityReplayCache();
BedrockIdentityVerifier verifier = BedrockIdentityVerifier.builder()
.publicKey(keyPair.getPublic().getEncoded())
@@ -164,6 +195,116 @@ void rejectsProfileWithoutBedrockIdentityProperty() throws Exception {
assertTrue(error.getMessage().contains(BedrockIdentityVerifier.PROPERTY_NAME));
}
+ @Test
+ void rejectsBedrockProfileUuidMismatch() throws Exception {
+ KeyPair keyPair = ed25519KeyPair();
+ String envelope = signedEnvelope(keyPair, VALID_NONCE, "session-1", "endpoint-id", "endpoint", "org-id");
+ GameProfile profile = profileWithEnvelope(
+ "BedrockSteve",
+ UUID.fromString("00000000-0000-0000-0000-000000000001"),
+ envelope);
+
+ BedrockIdentityVerificationException error = assertThrows(
+ BedrockIdentityVerificationException.class,
+ () -> verifier(keyPair, "session-1").verify(profile));
+
+ assertTrue(error.getMessage().contains("profile"));
+ }
+
+ @Test
+ void verifiesUnlinkedProfileWithConfiguredNamePrefix() throws Exception {
+ KeyPair keyPair = ed25519KeyPair();
+ String envelope = signedEnvelope(keyPair, VALID_NONCE, "session-1", "endpoint-id", "endpoint", "org-id");
+ GameProfile profile = profileWithEnvelope(
+ "bedrock_BedrockSteve",
+ UUID.fromString("f912bf90-8349-565f-9dc0-9891923c0cc3"),
+ envelope);
+
+ BedrockIdentityClaims claims = verifier(keyPair, "session-1").verify(profile);
+
+ assertEquals("BedrockSteve", claims.getBedrockUsername());
+ }
+
+ @Test
+ void verifiesLinkedJavaProfileBinding() throws Exception {
+ KeyPair keyPair = ed25519KeyPair();
+ String envelope = signedLinkedEnvelope(
+ keyPair, VALID_NONCE, "session-1", "endpoint-id", "endpoint", "org-id");
+ GameProfile profile = profileWithEnvelope(
+ "javasteve",
+ UUID.fromString("b5f7f978-5f58-4a21-b105-737f16c90785"),
+ envelope);
+
+ BedrockIdentityClaims claims = verifier(keyPair, "session-1").verify(profile);
+
+ assertEquals("b5f7f978-5f58-4a21-b105-737f16c90785", claims.getLinkedJavaUuid());
+ assertEquals("JavaSteve", claims.getLinkedJavaName());
+ }
+
+ @Test
+ void rejectsLinkedJavaProfileNameMismatchIgnoringCase() throws Exception {
+ KeyPair keyPair = ed25519KeyPair();
+ String envelope = signedLinkedEnvelope(
+ keyPair, VALID_NONCE, "session-1", "endpoint-id", "endpoint", "org-id");
+ GameProfile profile = profileWithEnvelope(
+ "DifferentJavaName",
+ UUID.fromString("b5f7f978-5f58-4a21-b105-737f16c90785"),
+ envelope);
+
+ BedrockIdentityVerificationException error = assertThrows(
+ BedrockIdentityVerificationException.class,
+ () -> verifier(keyPair, "session-1").verify(profile));
+
+ assertTrue(error.getMessage().contains("profile"));
+ }
+
+ @Test
+ void rejectsUnknownEnvelopeFieldsBeforeSignatureVerification() throws Exception {
+ KeyPair keyPair = ed25519KeyPair();
+ String envelope = signedEnvelope(
+ keyPair, VALID_NONCE, "session-1", "endpoint-id", "endpoint", "org-id")
+ .replace("\"version\":1,", "\"version\":1,\"unexpected\":true,");
+
+ BedrockIdentityVerificationException error = assertThrows(
+ BedrockIdentityVerificationException.class,
+ () -> verifier(keyPair, "session-1").verify(profileWithEnvelope(envelope)));
+
+ assertTrue(error.getMessage().contains("unknown"));
+ }
+
+ @Test
+ void rejectsHostileUnknownFieldWithFixedBoundedError() throws Exception {
+ KeyPair keyPair = ed25519KeyPair();
+ String hostileField = "forged-log\n" + "x".repeat(4096);
+ String envelope = signedEnvelope(
+ keyPair, VALID_NONCE, "session-1", "endpoint-id", "endpoint", "org-id")
+ .replace(
+ "\"version\":1,",
+ "\"version\":1," + GSON.toJson(hostileField) + ":true,");
+
+ BedrockIdentityVerificationException error = assertThrows(
+ BedrockIdentityVerificationException.class,
+ () -> verifier(keyPair, "session-1").verify(profileWithEnvelope(envelope)));
+
+ assertEquals("decode envelope: unknown field", error.getMessage());
+ }
+
+ @Test
+ void rejectsDuplicateNestedEnvelopeFieldsBeforeSignatureVerification() throws Exception {
+ KeyPair keyPair = ed25519KeyPair();
+ String envelope = signedEnvelope(
+ keyPair, VALID_NONCE, "session-1", "endpoint-id", "endpoint", "org-id")
+ .replace(
+ "\"endpoint\":{\"id\":\"endpoint-id\",",
+ "\"endpoint\":{\"id\":\"endpoint-id\",\"id\":\"endpoint-id\",");
+
+ BedrockIdentityVerificationException error = assertThrows(
+ BedrockIdentityVerificationException.class,
+ () -> verifier(keyPair, "session-1").verify(profileWithEnvelope(envelope)));
+
+ assertTrue(error.getMessage().contains("duplicate"));
+ }
+
private static BedrockIdentityVerifier verifier(KeyPair keyPair, String sessionId) {
return BedrockIdentityVerifier.builder()
.publicKey(keyPair.getPublic().getEncoded())
@@ -177,9 +318,16 @@ private static BedrockIdentityVerifier verifier(KeyPair keyPair, String sessionI
}
private static GameProfile profileWithEnvelope(String envelope) {
- return new GameProfile(
+ return profileWithEnvelope(
"BedrockSteve",
- UUID.fromString("00000000-0000-0000-0000-000000000001"),
+ UUID.fromString("f912bf90-8349-565f-9dc0-9891923c0cc3"),
+ envelope);
+ }
+
+ private static GameProfile profileWithEnvelope(String username, UUID uniqueId, String envelope) {
+ return new GameProfile(
+ username,
+ uniqueId,
Arrays.asList(
new GameProfile.Property("textures", "skin", ""),
new GameProfile.Property(BedrockIdentityVerifier.PROPERTY_NAME, envelope, "")));
@@ -209,6 +357,31 @@ private static String signedEnvelope(
String endpointId,
String endpointName,
String orgId) throws Exception {
+ Envelope envelope = envelope(nonce, sessionId, endpointId, endpointName, orgId);
+ return sign(keyPair, envelope);
+ }
+
+ private static String signedLinkedEnvelope(
+ KeyPair keyPair,
+ String nonce,
+ String sessionId,
+ String endpointId,
+ String endpointName,
+ String orgId) throws Exception {
+ Envelope envelope = envelope(nonce, sessionId, endpointId, endpointName, orgId);
+ envelope.policy.bedrock_auth_mode = "linked_java_only";
+ envelope.principal.type = "bedrock_linked_java";
+ envelope.principal.linked_java_uuid = "b5f7f978-5f58-4a21-b105-737f16c90785";
+ envelope.principal.linked_java_name = "JavaSteve";
+ return sign(keyPair, envelope);
+ }
+
+ private static Envelope envelope(
+ String nonce,
+ String sessionId,
+ String endpointId,
+ String endpointName,
+ String orgId) {
Envelope envelope = new Envelope();
envelope.version = 1;
envelope.issuer = "minekube-connect-test";
@@ -228,8 +401,12 @@ private static String signedEnvelope(
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";
+ envelope.principal.bedrock_derived_uuid = "f912bf90-8349-565f-9dc0-9891923c0cc3";
+
+ return envelope;
+ }
+ private static String sign(KeyPair keyPair, Envelope envelope) throws Exception {
Signature signer = Signature.getInstance("Ed25519");
signer.initSign(keyPair.getPrivate());
signer.update(GSON.toJson(envelope).getBytes(StandardCharsets.UTF_8));
@@ -270,5 +447,7 @@ private static final class Principal {
String bedrock_xuid;
String bedrock_username;
String bedrock_derived_uuid;
+ String linked_java_uuid;
+ String linked_java_name;
}
}
diff --git a/core/src/test/java/com/minekube/connect/bedrock/BedrockAdmissionCoordinatorTest.java b/core/src/test/java/com/minekube/connect/bedrock/BedrockAdmissionCoordinatorTest.java
new file mode 100644
index 000000000..5b19c3774
--- /dev/null
+++ b/core/src/test/java/com/minekube/connect/bedrock/BedrockAdmissionCoordinatorTest.java
@@ -0,0 +1,182 @@
+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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+import com.minekube.connect.api.logger.ConnectLogger;
+import com.minekube.connect.api.player.ConnectPlayer;
+import com.minekube.connect.config.ConnectConfig;
+import com.minekube.connect.watch.SessionProposal;
+import java.lang.reflect.Field;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.Session;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.SessionProtocol;
+import org.junit.jupiter.api.Test;
+
+class BedrockAdmissionCoordinatorTest {
+ @Test
+ void admissionTokenCanVerifyExactlyOnce() {
+ BedrockAdmissionCoordinator coordinator = new BedrockAdmissionCoordinator(
+ new VerifiedBedrockIdentityRegistry(), cleanupExecutor());
+ SessionProposal proposal = coordinator.proposal(
+ VerifiedBedrockIdentityRegistryTest.session(false), reason -> {}, "", "");
+ ConnectPlayer player = coordinator.stage(proposal);
+ BedrockIdentityEnforcer enforcer = new BedrockIdentityEnforcer(
+ new ConnectConfig(), mock(ConnectLogger.class), java.time.Instant::now);
+
+ try {
+ BedrockIdentityEnforcer.Decision first = coordinator.verify(
+ player,
+ proposal.getAdmissionToken(),
+ enforcer,
+ "",
+ "",
+ SessionProtocol.SESSION_PROTOCOL_BEDROCK);
+ BedrockIdentityEnforcer.Decision replay = coordinator.verify(
+ player,
+ proposal.getAdmissionToken(),
+ enforcer,
+ "",
+ "",
+ SessionProtocol.SESSION_PROTOCOL_BEDROCK);
+
+ assertTrue(first.allowed());
+ assertFalse(replay.allowed());
+ System.out.println("TOKEN consumption: firstVerification=allowed, secondVerification=rejected");
+ } finally {
+ coordinator.close();
+ }
+ }
+
+ @Test
+ void proposalGenerationsAreOpaqueAndMonotonic() throws Exception {
+ ScheduledThreadPoolExecutor cleanupExecutor = cleanupExecutor();
+ BedrockAdmissionCoordinator coordinator = new BedrockAdmissionCoordinator(
+ new VerifiedBedrockIdentityRegistry(), cleanupExecutor);
+ try {
+ SessionProposal first = coordinator.proposal(
+ VerifiedBedrockIdentityRegistryTest.session(false), reason -> {}, "", "");
+ SessionProposal second = coordinator.proposal(
+ VerifiedBedrockIdentityRegistryTest.session(false), reason -> {}, "", "");
+ Field[] tokenFields = first.getAdmissionToken().getClass().getDeclaredFields();
+
+ assertEquals(1, tokenFields.length);
+ assertEquals(long.class, tokenFields[0].getType());
+ tokenFields[0].setAccessible(true);
+ assertTrue(tokenFields[0].getLong(first.getAdmissionToken())
+ < tokenFields[0].getLong(second.getAdmissionToken()));
+ System.out.println("TOKEN shape: fields=1, fieldType=long, newerGeneration=true");
+ } finally {
+ coordinator.close();
+ }
+ }
+
+ @Test
+ void delayedOlderProposalCannotReplaceNewerGeneration() {
+ ScheduledThreadPoolExecutor cleanupExecutor = cleanupExecutor();
+ BedrockAdmissionCoordinator coordinator = new BedrockAdmissionCoordinator(
+ new VerifiedBedrockIdentityRegistry(), cleanupExecutor);
+ try {
+ SessionProposal older = coordinator.proposal(
+ VerifiedBedrockIdentityRegistryTest.session(false), reason -> {}, "", "");
+ SessionProposal newer = coordinator.proposal(
+ VerifiedBedrockIdentityRegistryTest.session(true), reason -> {}, "", "");
+
+ coordinator.stage(newer);
+
+ assertThrows(IllegalStateException.class, () -> coordinator.stage(older));
+ assertEquals(1, cleanupExecutor.getQueue().size());
+ } finally {
+ coordinator.close();
+ }
+ }
+
+ @Test
+ void coordinatorCreatedJavaProposalHasGenerationToken() {
+ ScheduledThreadPoolExecutor cleanupExecutor = cleanupExecutor();
+ BedrockAdmissionCoordinator coordinator = new BedrockAdmissionCoordinator(
+ new VerifiedBedrockIdentityRegistry(), cleanupExecutor);
+ try {
+ SessionProposal proposal = coordinator.proposal(
+ javaSession("session-1"), reason -> {}, "", "");
+
+ assertNotNull(proposal.getAdmissionToken());
+ assertEquals(1, cleanupExecutor.getQueue().size());
+ } finally {
+ coordinator.close();
+ }
+ }
+
+ @Test
+ void delayedNoIdentityProposalCannotReplaceNewerBedrockGeneration() {
+ BedrockAdmissionCoordinator coordinator = new BedrockAdmissionCoordinator(
+ new VerifiedBedrockIdentityRegistry(), cleanupExecutor());
+ try {
+ SessionProposal older = coordinator.proposal(
+ javaSession("session-1"), reason -> {}, "", "");
+ SessionProposal newer = coordinator.proposal(
+ VerifiedBedrockIdentityRegistryTest.session(false), reason -> {}, "", "");
+
+ coordinator.stage(newer);
+
+ assertThrows(IllegalStateException.class, () -> coordinator.stage(older));
+ } finally {
+ coordinator.close();
+ }
+ }
+
+ @Test
+ void noIdentityJavaControlRaceKeepsNewestProposal() {
+ ScheduledThreadPoolExecutor cleanupExecutor = cleanupExecutor();
+ BedrockAdmissionCoordinator coordinator = new BedrockAdmissionCoordinator(
+ new VerifiedBedrockIdentityRegistry(), cleanupExecutor);
+ try {
+ SessionProposal older = coordinator.proposal(
+ javaSession("session-1"), reason -> {}, "", "");
+ SessionProposal newer = coordinator.proposal(
+ javaSession("session-1"), reason -> {}, "", "");
+
+ coordinator.stage(newer);
+
+ assertThrows(IllegalStateException.class, () -> coordinator.stage(older));
+ assertNotNull(newer.getAdmissionToken());
+ assertEquals(1, cleanupExecutor.getQueue().size());
+ } finally {
+ coordinator.close();
+ }
+ }
+
+ @Test
+ void noIdentityProposalCannotStageOrRegisterAfterClose() {
+ BedrockAdmissionCoordinator coordinator = new BedrockAdmissionCoordinator(
+ new VerifiedBedrockIdentityRegistry(), cleanupExecutor());
+ SessionProposal proposal = coordinator.proposal(
+ javaSession("session-1"), reason -> {}, "", "");
+
+ coordinator.close();
+
+ assertThrows(IllegalStateException.class, () -> coordinator.stage(proposal));
+ assertThrows(IllegalStateException.class, () -> coordinator.proposal(
+ javaSession("session-2"), reason -> {}, "", ""));
+ }
+
+ private static Session javaSession(String sessionId) {
+ Session source = VerifiedBedrockIdentityRegistryTest.session(false);
+ return source.toBuilder()
+ .setId(sessionId)
+ .setProtocol(SessionProtocol.SESSION_PROTOCOL_JAVA)
+ .setPlayer(source.getPlayer().toBuilder()
+ .setProfile(source.getPlayer().getProfile().toBuilder().clearProperties()))
+ .build();
+ }
+
+ private static ScheduledThreadPoolExecutor cleanupExecutor() {
+ ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1);
+ executor.setRemoveOnCancelPolicy(true);
+ return executor;
+ }
+}
diff --git a/core/src/test/java/com/minekube/connect/bedrock/BedrockIdentityConfigurationTest.java b/core/src/test/java/com/minekube/connect/bedrock/BedrockIdentityConfigurationTest.java
new file mode 100644
index 000000000..badb45264
--- /dev/null
+++ b/core/src/test/java/com/minekube/connect/bedrock/BedrockIdentityConfigurationTest.java
@@ -0,0 +1,63 @@
+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.assertTrue;
+
+import com.minekube.connect.config.ConnectConfig;
+import java.lang.reflect.Field;
+import org.junit.jupiter.api.Test;
+
+class BedrockIdentityConfigurationTest {
+ @Test
+ void acceptsOnlyExactClosedConfigurationValues() {
+ ConnectConfig.BedrockIdentityConfig config = new ConnectConfig().getBedrockIdentity();
+
+ for (String enforcement : new String[] {"warn", "require"}) {
+ setField(config, "enforcement", enforcement);
+ assertTrue(BedrockIdentityConfiguration.from(config).isUsable());
+ }
+
+ for (String enforcement : new String[] {"WARN", "REQUIRE", "warn ", "require\n", "unknown"}) {
+ setField(config, "enforcement", enforcement);
+ assertFalse(BedrockIdentityConfiguration.from(config).isUsable());
+ }
+
+ setField(config, "enforcement", "require");
+ for (String policy : new String[] {"linked_java_only", "trusted_bedrock_xuid"}) {
+ setField(config, "expectedPolicy", policy);
+ assertTrue(BedrockIdentityConfiguration.from(config).isUsable());
+ }
+ for (String policy : new String[] {"TRUSTED_BEDROCK_XUID", "trusted_bedrock_xuid ", "unknown"}) {
+ setField(config, "expectedPolicy", policy);
+ assertFalse(BedrockIdentityConfiguration.from(config).isUsable());
+ }
+ }
+
+ @Test
+ void requiresNonblankIssuerWithoutNormalizingIt() {
+ ConnectConfig.BedrockIdentityConfig config = new ConnectConfig().getBedrockIdentity();
+ setField(config, "enforcement", "require");
+
+ for (String issuer : new String[] {"", " ", "\t\n"}) {
+ setField(config, "expectedIssuer", issuer);
+ assertFalse(BedrockIdentityConfiguration.from(config).isUsable());
+ }
+
+ String issuer = " minekube-connect ";
+ setField(config, "expectedIssuer", issuer);
+ BedrockIdentityConfiguration configuration = BedrockIdentityConfiguration.from(config);
+ assertTrue(configuration.isUsable());
+ assertEquals(issuer, configuration.issuer());
+ }
+
+ 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);
+ }
+ }
+}
diff --git a/core/src/test/java/com/minekube/connect/bedrock/BedrockIdentityEnforcerTest.java b/core/src/test/java/com/minekube/connect/bedrock/BedrockIdentityEnforcerTest.java
index 9fe7b52ea..ce0ab97b9 100644
--- a/core/src/test/java/com/minekube/connect/bedrock/BedrockIdentityEnforcerTest.java
+++ b/core/src/test/java/com/minekube/connect/bedrock/BedrockIdentityEnforcerTest.java
@@ -3,11 +3,14 @@
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.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
import com.google.gson.Gson;
import com.minekube.connect.api.logger.ConnectLogger;
@@ -16,23 +19,191 @@
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.network.netty.LocalSession;
import com.minekube.connect.player.ConnectPlayerImpl;
+import java.io.IOException;
+import java.lang.reflect.Constructor;
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.lang.reflect.Field;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collections;
import java.util.UUID;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.Authentication;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.GameProfileProperty;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.Player;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.SessionProtocol;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Protocol;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
class BedrockIdentityEnforcerTest {
+ @Test
+ void enforcerDoesNotOwnACompatibilityClaimsRegistry() {
+ assertFalse(java.util.Arrays.stream(BedrockIdentityEnforcer.class.getDeclaredFields())
+ .map(Field::getType)
+ .anyMatch(VerifiedBedrockIdentityRegistry.class::equals));
+ }
+
private static final Gson GSON = new Gson();
private static final Instant NOW = Instant.parse("2026-07-05T12:00:00Z");
+ private static final String VALID_NONCE = "AAAAAAAAAAAAAAAAAAAAAA";
+
+ @Test
+ void retainsLegacyHttpClientConstructor() throws Exception {
+ Constructor constructor = BedrockIdentityEnforcer.class.getConstructor(
+ ConnectConfig.class,
+ ConnectLogger.class,
+ OkHttpClient.class);
+
+ assertNotNull(constructor.newInstance(
+ new ConnectConfig(), mock(ConnectLogger.class), new OkHttpClient()));
+ }
+
+ @Test
+ void legacyConstructorUsesContextAdmissionRegistry() throws Exception {
+ KeyPair keyPair = ed25519KeyPair();
+ ConnectConfig config = config(
+ "require",
+ base64(keyPair.getPublic().getEncoded()),
+ "trusted_bedrock_xuid");
+ String envelope = signedEnvelope(
+ keyPair,
+ VALID_NONCE,
+ "session-1",
+ config.getEndpoint(),
+ "minekube-connect-test",
+ Instant.now());
+ VerifiedBedrockIdentityRegistry admissionRegistry = new VerifiedBedrockIdentityRegistry();
+ BedrockAdmissionCoordinator coordinator = new BedrockAdmissionCoordinator(
+ admissionRegistry, new java.util.concurrent.ScheduledThreadPoolExecutor(1));
+ try {
+ com.minekube.connect.watch.SessionProposal proposal = coordinator.proposal(
+ session(envelope), reason -> {}, "endpoint-id", "org-id");
+ ConnectPlayer player = coordinator.stage(proposal);
+ LocalSession.Context context = mock(LocalSession.Context.class);
+ when(context.getPlayer()).thenReturn(player);
+ when(context.getEndpointId()).thenReturn("endpoint-id");
+ when(context.getEndpointOrgId()).thenReturn("org-id");
+ when(context.getProtocol()).thenReturn(SessionProtocol.SESSION_PROTOCOL_BEDROCK);
+ when(context.getAdmissionToken()).thenReturn(proposal.getAdmissionToken());
+ BedrockIdentityEnforcer enforcer = new BedrockIdentityEnforcer(
+ config, mock(ConnectLogger.class), () -> Instant.now(),
+ new BedrockIdentityKeyProvider(config, new OkHttpClient()), coordinator);
+
+ BedrockIdentityEnforcer.Decision decision = enforcer.verify(context);
+
+ assertTrue(decision.allowed());
+ assertNotNull(decision.verifiedClaims());
+ assertSame(decision.verifiedClaims(), admissionRegistry.get(player).orElseThrow());
+ } finally {
+ coordinator.close();
+ }
+ }
+
+ @Test
+ void slowMetadataVerificationDoesNotBlockUnrelatedStageOrRemove() throws Exception {
+ VerifiedBedrockIdentityRegistry registry = new VerifiedBedrockIdentityRegistry();
+ try (SlowAdmission slowAdmission = new SlowAdmission(registry)) {
+ Future verification = slowAdmission.start();
+
+ ConnectPlayer unrelated = slowAdmission.completeWhileBlocked(() -> slowAdmission.coordinator.stage(
+ slowAdmission.coordinator.proposal(session("unrelated-envelope").toBuilder()
+ .setId("session-2").build(), reason -> {}, "", "")));
+ slowAdmission.completeWhileBlocked(() -> {
+ slowAdmission.coordinator.discard(unrelated);
+ return null;
+ });
+
+ BedrockIdentityEnforcer.Decision decision = slowAdmission.finish(verification);
+ assertTrue(decision.allowed());
+ assertNotNull(decision.verifiedClaims());
+ assertSame(decision.verifiedClaims(), registry.get(slowAdmission.player).orElseThrow());
+ }
+ }
+
+ @Test
+ void slowMetadataVerificationDoesNotBlockExpiryAndCannotPublishAfterIt() throws Exception {
+ AtomicReference expiry = new AtomicReference<>();
+ ScheduledExecutorService cleanupExecutor = mock(ScheduledExecutorService.class);
+ when(cleanupExecutor.schedule(any(Runnable.class), anyLong(), any(TimeUnit.class)))
+ .thenAnswer(invocation -> {
+ expiry.set(invocation.getArgument(0));
+ return mock(ScheduledFuture.class);
+ });
+ VerifiedBedrockIdentityRegistry registry = new VerifiedBedrockIdentityRegistry();
+ try (SlowAdmission slowAdmission = new SlowAdmission(registry, cleanupExecutor)) {
+ Future verification = slowAdmission.start();
+
+ slowAdmission.completeWhileBlocked(() -> {
+ expiry.get().run();
+ return null;
+ });
+
+ BedrockIdentityEnforcer.Decision decision = slowAdmission.finish(verification);
+ assertFalse(decision.allowed());
+ assertTrue(registry.get(slowAdmission.player).isEmpty());
+ System.out.println("SLOW verification + expiry: cleanupCompletedWhileBlocked=true, "
+ + "staleVerificationAllowed=false, staleClaimsPublished=false");
+ }
+ }
+
+ @Test
+ void slowMetadataVerificationDoesNotBlockCloseAndCannotPublishAfterIt() throws Exception {
+ VerifiedBedrockIdentityRegistry registry = new VerifiedBedrockIdentityRegistry();
+ try (SlowAdmission slowAdmission = new SlowAdmission(registry)) {
+ Future verification = slowAdmission.start();
+
+ slowAdmission.completeWhileBlocked(() -> {
+ slowAdmission.coordinator.close();
+ return null;
+ });
+
+ BedrockIdentityEnforcer.Decision decision = slowAdmission.finish(verification);
+ assertFalse(decision.allowed());
+ assertTrue(registry.get(slowAdmission.player).isEmpty());
+ System.out.println("SLOW verification + close: closeCompletedWhileBlocked=true, "
+ + "staleVerificationAllowed=false, staleClaimsPublished=false");
+ }
+ }
+
+ @Test
+ void replacementDuringSlowMetadataVerificationCannotPublishStaleClaims() throws Exception {
+ VerifiedBedrockIdentityRegistry registry = new VerifiedBedrockIdentityRegistry();
+ try (SlowAdmission slowAdmission = new SlowAdmission(registry)) {
+ Future verification = slowAdmission.start();
+
+ ConnectPlayer replacement = slowAdmission.completeWhileBlocked(
+ () -> slowAdmission.coordinator.stage(slowAdmission.coordinator.proposal(
+ session(slowAdmission.envelope), reason -> {}, "", "")));
+
+ BedrockIdentityEnforcer.Decision decision = slowAdmission.finish(verification);
+ assertFalse(decision.allowed());
+ assertTrue(registry.get(slowAdmission.player).isEmpty());
+ assertFalse(replacement.getGameProfile().toString().contains(VALID_NONCE));
+ System.out.println("SLOW verification + replacement: replacementCompletedWhileBlocked=true, "
+ + "staleVerificationAllowed=false, staleClaimsPublished=false, "
+ + "replacementPrivateEnvelope=false");
+ }
+ }
@Test
void disabledModeAllowsMissingIdentityWithoutLogging() {
@@ -55,16 +226,145 @@ void requireModeAllowsValidEnvelopeAndReturnsClaims() throws Exception {
"require",
base64(keyPair.getPublic().getEncoded()),
"trusted_bedrock_xuid");
- String envelope = signedEnvelope(keyPair, "nonce-a", "session-1", config.getEndpoint());
+ String envelope = signedEnvelope(keyPair, VALID_NONCE, "session-1", config.getEndpoint());
BedrockIdentityEnforcer enforcer = new BedrockIdentityEnforcer(config, mock(ConnectLogger.class), () -> NOW);
- BedrockIdentityEnforcer.Decision decision = enforcer.verify(player("session-1", profileWithEnvelope(envelope)));
+ BedrockIdentityEnforcer.Decision decision = enforcer.verify(
+ player("session-1", profileWithEnvelope(envelope)),
+ "endpoint-id",
+ "org-id");
assertTrue(decision.allowed());
assertNotNull(decision.verifiedClaims());
assertEquals("2533274790395904", decision.verifiedClaims().getBedrockXuid());
}
+ @Test
+ void malformedConfigurationRejectsOtherwiseValidIdentity() throws Exception {
+ KeyPair keyPair = ed25519KeyPair();
+ ConnectConfig normalizedMode = config(
+ "REQUIRE",
+ base64(keyPair.getPublic().getEncoded()),
+ "trusted_bedrock_xuid");
+ String normalizedModeEnvelope = signedEnvelope(
+ keyPair, VALID_NONCE, "session-1", normalizedMode.getEndpoint());
+ BedrockIdentityEnforcer normalizedModeEnforcer = new BedrockIdentityEnforcer(
+ normalizedMode, mock(ConnectLogger.class), () -> NOW);
+
+ assertFalse(normalizedModeEnforcer.verify(
+ player("session-1", profileWithEnvelope(normalizedModeEnvelope)),
+ "endpoint-id",
+ "org-id").allowed());
+
+ ConnectConfig blankIssuer = config(
+ "require",
+ base64(keyPair.getPublic().getEncoded()),
+ "trusted_bedrock_xuid");
+ setField(blankIssuer.getBedrockIdentity(), "expectedIssuer", " ");
+ String blankIssuerEnvelope = signedEnvelope(
+ keyPair, VALID_NONCE, "session-2", blankIssuer.getEndpoint(), " ");
+ BedrockIdentityEnforcer blankIssuerEnforcer = new BedrockIdentityEnforcer(
+ blankIssuer, mock(ConnectLogger.class), () -> NOW);
+
+ assertFalse(blankIssuerEnforcer.verify(
+ player("session-2", profileWithEnvelope(blankIssuerEnvelope)),
+ "endpoint-id",
+ "org-id").allowed());
+ }
+
+ @Test
+ void admissionPublishesClaimsWithoutExposingOrRetainingEnvelope() throws Exception {
+ KeyPair keyPair = ed25519KeyPair();
+ ConnectConfig config = config(
+ "require",
+ base64(keyPair.getPublic().getEncoded()),
+ "trusted_bedrock_xuid");
+ String envelope = signedEnvelope(keyPair, VALID_NONCE, "session-1", config.getEndpoint());
+ VerifiedBedrockIdentityRegistry registry = new VerifiedBedrockIdentityRegistry();
+ BedrockAdmissionCoordinator coordinator = new BedrockAdmissionCoordinator(
+ registry, new java.util.concurrent.ScheduledThreadPoolExecutor(1));
+ com.minekube.connect.watch.SessionProposal proposal = coordinator.proposal(
+ session(envelope), reason -> {}, "endpoint-id", "org-id");
+ ConnectPlayer player = coordinator.stage(proposal);
+ BedrockIdentityEnforcer enforcer = new BedrockIdentityEnforcer(
+ config,
+ mock(ConnectLogger.class),
+ () -> NOW,
+ coordinator);
+
+ BedrockIdentityEnforcer.Decision decision = coordinator.verify(player, proposal.getAdmissionToken(), enforcer,
+ "endpoint-id", "org-id", SessionProtocol.SESSION_PROTOCOL_BEDROCK);
+
+ assertTrue(decision.allowed());
+ assertNotNull(decision.verifiedClaims());
+ assertTrue(registry.get(player).isPresent());
+ assertFalse(player.getGameProfile().toString().contains(VALID_NONCE));
+ }
+
+ @Test
+ void disabledAdmissionDropsEnvelopeWithoutPublishingClaims() {
+ VerifiedBedrockIdentityRegistry registry = new VerifiedBedrockIdentityRegistry();
+ BedrockAdmissionCoordinator coordinator = new BedrockAdmissionCoordinator(registry, new java.util.concurrent.ScheduledThreadPoolExecutor(1));
+ com.minekube.connect.watch.SessionProposal proposal = coordinator.proposal(session("invalid-envelope-nonce-a"), reason -> {}, "", "");
+ ConnectPlayer player = coordinator.stage(proposal);
+ BedrockIdentityEnforcer enforcer = new BedrockIdentityEnforcer(
+ config("disabled", "", "trusted_bedrock_xuid"),
+ mock(ConnectLogger.class),
+ () -> NOW,
+ coordinator);
+
+ BedrockIdentityEnforcer.Decision decision = coordinator.verify(player, proposal.getAdmissionToken(), enforcer,
+ "", "", SessionProtocol.SESSION_PROTOCOL_BEDROCK);
+
+ assertTrue(decision.allowed());
+ assertEquals(null, decision.verifiedClaims());
+ assertTrue(registry.get(player).isEmpty());
+ assertFalse(player.getGameProfile().toString().contains("nonce-a"));
+ }
+
+ @Test
+ void warnFailedAdmissionDropsEnvelopeWithoutPublishingClaims() throws Exception {
+ KeyPair keyPair = ed25519KeyPair();
+ VerifiedBedrockIdentityRegistry registry = new VerifiedBedrockIdentityRegistry();
+ BedrockAdmissionCoordinator coordinator = new BedrockAdmissionCoordinator(registry, new java.util.concurrent.ScheduledThreadPoolExecutor(1));
+ com.minekube.connect.watch.SessionProposal proposal = coordinator.proposal(session("invalid-envelope-nonce-a"), reason -> {}, "", "");
+ ConnectPlayer player = coordinator.stage(proposal);
+ BedrockIdentityEnforcer enforcer = new BedrockIdentityEnforcer(
+ config("warn", base64(keyPair.getPublic().getEncoded()), "trusted_bedrock_xuid"),
+ mock(ConnectLogger.class),
+ () -> NOW,
+ coordinator);
+
+ BedrockIdentityEnforcer.Decision decision = coordinator.verify(player, proposal.getAdmissionToken(), enforcer,
+ "", "", SessionProtocol.SESSION_PROTOCOL_BEDROCK);
+
+ assertTrue(decision.allowed());
+ assertEquals(null, decision.verifiedClaims());
+ assertTrue(registry.get(player).isEmpty());
+ assertFalse(player.getGameProfile().toString().contains("nonce-a"));
+ }
+
+ @Test
+ void rejectedAdmissionDropsEnvelopeWithoutPublishingClaims() throws Exception {
+ KeyPair keyPair = ed25519KeyPair();
+ VerifiedBedrockIdentityRegistry registry = new VerifiedBedrockIdentityRegistry();
+ BedrockAdmissionCoordinator coordinator = new BedrockAdmissionCoordinator(registry, new java.util.concurrent.ScheduledThreadPoolExecutor(1));
+ com.minekube.connect.watch.SessionProposal proposal = coordinator.proposal(session("invalid-envelope-nonce-a"), reason -> {}, "", "");
+ ConnectPlayer player = coordinator.stage(proposal);
+ BedrockIdentityEnforcer enforcer = new BedrockIdentityEnforcer(
+ config("require", base64(keyPair.getPublic().getEncoded()), "trusted_bedrock_xuid"),
+ mock(ConnectLogger.class),
+ () -> NOW,
+ coordinator);
+
+ BedrockIdentityEnforcer.Decision decision = coordinator.verify(player, proposal.getAdmissionToken(), enforcer,
+ "", "", SessionProtocol.SESSION_PROTOCOL_BEDROCK);
+
+ assertFalse(decision.allowed());
+ assertTrue(registry.get(player).isEmpty());
+ assertFalse(player.getGameProfile().toString().contains("nonce-a"));
+ }
+
@Test
void requireModeAllowsEnvelopeSignedByAdditionalPublicKey() throws Exception {
KeyPair keyPair = ed25519KeyPair();
@@ -73,10 +373,13 @@ void requireModeAllowsEnvelopeSignedByAdditionalPublicKey() throws Exception {
"",
"trusted_bedrock_xuid");
setField(config.getBedrockIdentity(), "publicKeys", Collections.singletonList(base64(keyPair.getPublic().getEncoded())));
- String envelope = signedEnvelope(keyPair, "nonce-a", "session-1", config.getEndpoint());
+ String envelope = signedEnvelope(keyPair, VALID_NONCE, "session-1", config.getEndpoint());
BedrockIdentityEnforcer enforcer = new BedrockIdentityEnforcer(config, mock(ConnectLogger.class), () -> NOW);
- BedrockIdentityEnforcer.Decision decision = enforcer.verify(player("session-1", profileWithEnvelope(envelope)));
+ BedrockIdentityEnforcer.Decision decision = enforcer.verify(
+ player("session-1", profileWithEnvelope(envelope)),
+ "endpoint-id",
+ "org-id");
assertTrue(decision.allowed());
assertNotNull(decision.verifiedClaims());
@@ -89,7 +392,7 @@ void requireModeRejectsEndpointScopeMismatchWhenLocalScopeIsPresent() throws Exc
"require",
base64(keyPair.getPublic().getEncoded()),
"trusted_bedrock_xuid");
- String envelope = signedEnvelope(keyPair, "nonce-a", "session-1", config.getEndpoint());
+ String envelope = signedEnvelope(keyPair, VALID_NONCE, "session-1", config.getEndpoint());
BedrockIdentityEnforcer enforcer = new BedrockIdentityEnforcer(config, mock(ConnectLogger.class), () -> NOW);
BedrockIdentityEnforcer.Decision decision = enforcer.verify(
@@ -108,7 +411,7 @@ void requireModeRejectsOrgScopeMismatchWhenLocalScopeIsPresent() throws Exceptio
"require",
base64(keyPair.getPublic().getEncoded()),
"trusted_bedrock_xuid");
- String envelope = signedEnvelope(keyPair, "nonce-a", "session-1", config.getEndpoint());
+ String envelope = signedEnvelope(keyPair, VALID_NONCE, "session-1", config.getEndpoint());
BedrockIdentityEnforcer enforcer = new BedrockIdentityEnforcer(config, mock(ConnectLogger.class), () -> NOW);
BedrockIdentityEnforcer.Decision decision = enforcer.verify(
@@ -120,6 +423,21 @@ void requireModeRejectsOrgScopeMismatchWhenLocalScopeIsPresent() throws Exceptio
assertTrue(decision.message().contains("Bedrock identity verification failed"));
}
+ @Test
+ void requireModeRejectsIdentityWhenEitherAuthenticatedScopeValueIsMissing() throws Exception {
+ KeyPair keyPair = ed25519KeyPair();
+ ConnectConfig config = config(
+ "require",
+ base64(keyPair.getPublic().getEncoded()),
+ "trusted_bedrock_xuid");
+ String envelope = signedEnvelope(keyPair, VALID_NONCE, "session-1", config.getEndpoint());
+ BedrockIdentityEnforcer enforcer = new BedrockIdentityEnforcer(config, mock(ConnectLogger.class), () -> NOW);
+ ConnectPlayer player = player("session-1", profileWithEnvelope(envelope));
+
+ assertFalse(enforcer.verify(player, "", "org-id").allowed());
+ assertFalse(enforcer.verify(player, "endpoint-id", " ").allowed());
+ }
+
@Test
void requireModeRejectsMissingIdentity() {
BedrockIdentityEnforcer enforcer = new BedrockIdentityEnforcer(
@@ -133,6 +451,97 @@ void requireModeRejectsMissingIdentity() {
assertTrue(decision.message().contains("Bedrock identity verification failed"));
}
+ @Test
+ void requireModeLeavesMarkedJavaWithoutReservedIdentityUnchanged() {
+ 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()),
+ "",
+ "",
+ SessionProtocol.SESSION_PROTOCOL_JAVA);
+
+ assertTrue(decision.allowed());
+ assertEquals(null, decision.verifiedClaims());
+ }
+
+ @Test
+ void requireModeRejectsReservedIdentityOnPassthroughJavaAdmission() {
+ ConnectPlayer player = BedrockAdmissionCoordinator.playerFor(
+ session("reserved-envelope-nonce-a", true));
+ BedrockIdentityEnforcer enforcer = new BedrockIdentityEnforcer(
+ config("require", base64(new byte[32]), "trusted_bedrock_xuid"),
+ mock(ConnectLogger.class),
+ () -> NOW);
+
+ BedrockIdentityEnforcer.Decision decision = enforcer.verify(
+ player,
+ "",
+ "",
+ SessionProtocol.SESSION_PROTOCOL_JAVA);
+
+ assertFalse(decision.allowed());
+ }
+
+ @Test
+ void requireModeRejectsMarkedBedrockWithoutReservedIdentity() {
+ 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()),
+ "",
+ "",
+ SessionProtocol.SESSION_PROTOCOL_BEDROCK);
+
+ assertFalse(decision.allowed());
+ }
+
+ @Test
+ void requireModeAllowsValidLegacyUnspecifiedEnvelope() throws Exception {
+ KeyPair keyPair = ed25519KeyPair();
+ ConnectConfig config = config(
+ "require",
+ base64(keyPair.getPublic().getEncoded()),
+ "trusted_bedrock_xuid");
+ String envelope = signedEnvelope(keyPair, VALID_NONCE, "session-1", config.getEndpoint());
+ BedrockIdentityEnforcer enforcer = new BedrockIdentityEnforcer(config, mock(ConnectLogger.class), () -> NOW);
+
+ BedrockIdentityEnforcer.Decision decision = enforcer.verify(
+ player("session-1", profileWithEnvelope(envelope)),
+ "endpoint-id",
+ "org-id",
+ SessionProtocol.SESSION_PROTOCOL_UNSPECIFIED);
+
+ assertTrue(decision.allowed());
+ assertNotNull(decision.verifiedClaims());
+ }
+
+ @Test
+ void warnModeNeverPublishesClaimsForUnrecognizedProtocol() throws Exception {
+ KeyPair keyPair = ed25519KeyPair();
+ ConnectConfig config = config(
+ "warn",
+ base64(keyPair.getPublic().getEncoded()),
+ "trusted_bedrock_xuid");
+ String envelope = signedEnvelope(keyPair, VALID_NONCE, "session-1", config.getEndpoint());
+ BedrockIdentityEnforcer enforcer = new BedrockIdentityEnforcer(config, mock(ConnectLogger.class), () -> NOW);
+
+ BedrockIdentityEnforcer.Decision decision = enforcer.verify(
+ player("session-1", profileWithEnvelope(envelope)),
+ "",
+ "",
+ SessionProtocol.UNRECOGNIZED);
+
+ assertTrue(decision.allowed());
+ assertEquals(null, decision.verifiedClaims());
+ }
+
@Test
void warnModeLogsAndAllowsMissingIdentity() {
ConnectLogger logger = mock(ConnectLogger.class);
@@ -183,12 +592,12 @@ void requireModeRejectsReplay() throws Exception {
"require",
base64(keyPair.getPublic().getEncoded()),
"trusted_bedrock_xuid");
- String envelope = signedEnvelope(keyPair, "nonce-a", "session-1", config.getEndpoint());
+ String envelope = signedEnvelope(keyPair, VALID_NONCE, "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);
+ assertTrue(enforcer.verify(player, "endpoint-id", "org-id").allowed());
+ BedrockIdentityEnforcer.Decision replay = enforcer.verify(player, "endpoint-id", "org-id");
assertFalse(replay.allowed());
assertTrue(replay.message().contains("Bedrock identity verification failed"));
@@ -201,14 +610,14 @@ private static ConnectPlayer player(String sessionId, GameProfile profile) {
private static GameProfile profileWithoutEnvelope() {
return new GameProfile(
"BedrockSteve",
- UUID.fromString("00000000-0000-0000-0000-000000000001"),
+ UUID.fromString("f912bf90-8349-565f-9dc0-9891923c0cc3"),
Collections.singletonList(new GameProfile.Property("textures", "skin", "")));
}
private static GameProfile profileWithEnvelope(String envelope) {
return new GameProfile(
"BedrockSteve",
- UUID.fromString("00000000-0000-0000-0000-000000000001"),
+ UUID.fromString("f912bf90-8349-565f-9dc0-9891923c0cc3"),
Arrays.asList(
new GameProfile.Property("textures", "skin", ""),
new GameProfile.Property(BedrockIdentityVerifier.PROPERTY_NAME, envelope, "")));
@@ -220,6 +629,7 @@ private static ConnectConfig config(String enforcement, String publicKey, String
setField(bedrockIdentity, "enforcement", enforcement);
setField(bedrockIdentity, "publicKey", publicKey);
setField(bedrockIdentity, "expectedPolicy", expectedPolicy);
+ setField(bedrockIdentity, "expectedIssuer", "minekube-connect-test");
return config;
}
@@ -241,14 +651,44 @@ private static String base64(byte[] data) {
return Base64.getEncoder().encodeToString(data);
}
+ private static String metadata(KeyPair keyPair) {
+ byte[] encoded = keyPair.getPublic().getEncoded();
+ byte[] rawPublicKey = Arrays.copyOfRange(encoded, encoded.length - 32, encoded.length);
+ return "{"
+ + "\"issuer\":\"minekube-connect-test\","
+ + "\"algorithm\":\"Ed25519\","
+ + "\"current_public_key\":\"" + base64(rawPublicKey) + "\","
+ + "\"cache_max_age_seconds\":120"
+ + "}";
+ }
+
private static String signedEnvelope(
KeyPair keyPair,
String nonce,
String sessionId,
String endpointName) throws Exception {
+ return signedEnvelope(keyPair, nonce, sessionId, endpointName, "minekube-connect-test");
+ }
+
+ private static String signedEnvelope(
+ KeyPair keyPair,
+ String nonce,
+ String sessionId,
+ String endpointName,
+ String issuer) throws Exception {
+ return signedEnvelope(keyPair, nonce, sessionId, endpointName, issuer, NOW);
+ }
+
+ private static String signedEnvelope(
+ KeyPair keyPair,
+ String nonce,
+ String sessionId,
+ String endpointName,
+ String issuer,
+ Instant issuedAt) throws Exception {
Envelope envelope = new Envelope();
envelope.version = 1;
- envelope.issuer = "minekube-connect-test";
+ envelope.issuer = issuer;
envelope.endpoint = new Endpoint();
envelope.endpoint.id = "endpoint-id";
envelope.endpoint.name = endpointName;
@@ -256,8 +696,8 @@ private static String signedEnvelope(
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.issued_at_unix_ms = issuedAt.toEpochMilli();
+ envelope.session.expires_at_unix_ms = issuedAt.plusSeconds(300).toEpochMilli();
envelope.session.nonce = nonce;
envelope.policy = new Policy();
envelope.policy.bedrock_auth_mode = "trusted_bedrock_xuid";
@@ -265,7 +705,7 @@ private static String signedEnvelope(
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";
+ envelope.principal.bedrock_derived_uuid = "f912bf90-8349-565f-9dc0-9891923c0cc3";
Signature signer = Signature.getInstance("Ed25519");
signer.initSign(keyPair.getPrivate());
@@ -274,6 +714,33 @@ private static String signedEnvelope(
return GSON.toJson(envelope);
}
+ private static minekube.connect.v1alpha1.WatchServiceOuterClass.Session session(String envelope) {
+ return session(envelope, false);
+ }
+
+ private static minekube.connect.v1alpha1.WatchServiceOuterClass.Session session(
+ String envelope,
+ boolean passthrough) {
+ return minekube.connect.v1alpha1.WatchServiceOuterClass.Session.newBuilder()
+ .setId("session-1")
+ .setAuth(Authentication.newBuilder().setPassthrough(passthrough))
+ .setPlayer(Player.newBuilder()
+ .setAddr("127.0.0.1")
+ .setProfile(minekube.connect.v1alpha1.WatchServiceOuterClass.GameProfile.newBuilder()
+ .setId("f912bf90-8349-565f-9dc0-9891923c0cc3")
+ .setName("BedrockSteve")
+ .addProperties(GameProfileProperty.newBuilder()
+ .setName("textures")
+ .setValue("skin"))
+ .addProperties(GameProfileProperty.newBuilder()
+ .setName(BedrockIdentityVerifier.PROPERTY_NAME)
+ .setValue(envelope))
+ .addProperties(GameProfileProperty.newBuilder()
+ .setName("minekube:bedrock_identity_scope")
+ .setValue("private-endpoint-id"))))
+ .build();
+ }
+
private static final class Envelope {
int version;
String issuer;
@@ -308,4 +775,93 @@ private static final class Principal {
String bedrock_username;
String bedrock_derived_uuid;
}
+
+ private static final class SlowAdmission implements AutoCloseable {
+ private final VerifiedBedrockIdentityRegistry registry;
+ private final BedrockAdmissionCoordinator coordinator;
+ private final CountDownLatch fetchStarted = new CountDownLatch(1);
+ private final CountDownLatch releaseFetch = new CountDownLatch(1);
+ private final ExecutorService executor = Executors.newFixedThreadPool(2);
+ private final ConnectPlayer player;
+ private final String envelope;
+ private final BedrockIdentityEnforcer enforcer;
+ private final LocalSession.Context context;
+
+ private SlowAdmission(VerifiedBedrockIdentityRegistry registry) throws Exception {
+ this(registry, new java.util.concurrent.ScheduledThreadPoolExecutor(1));
+ }
+
+ private SlowAdmission(
+ VerifiedBedrockIdentityRegistry registry,
+ ScheduledExecutorService cleanupExecutor) throws Exception {
+ this.registry = registry;
+ KeyPair keyPair = ed25519KeyPair();
+ ConnectConfig config = config("require", "", "trusted_bedrock_xuid");
+ setField(config.getBedrockIdentity(), "metadataUrl", "https://metadata.example/keys");
+ envelope = signedEnvelope(keyPair, VALID_NONCE, "session-1", config.getEndpoint());
+ coordinator = new BedrockAdmissionCoordinator(registry, cleanupExecutor);
+ com.minekube.connect.watch.SessionProposal proposal = coordinator.proposal(
+ session(envelope), reason -> {}, "endpoint-id", "org-id");
+ player = coordinator.stage(proposal);
+ OkHttpClient httpClient = new OkHttpClient.Builder()
+ .addInterceptor(chain -> {
+ fetchStarted.countDown();
+ try {
+ if (!releaseFetch.await(5, TimeUnit.SECONDS)) {
+ throw new IOException("metadata test release timed out");
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException("metadata test interrupted", e);
+ }
+ return new Response.Builder()
+ .request(chain.request())
+ .protocol(Protocol.HTTP_1_1)
+ .code(200)
+ .message("OK")
+ .body(ResponseBody.create(
+ MediaType.get("application/json"),
+ metadata(keyPair)))
+ .build();
+ })
+ .build();
+ BedrockIdentityKeyProvider keyProvider = new BedrockIdentityKeyProvider(config, httpClient, () -> NOW);
+ enforcer = new BedrockIdentityEnforcer(
+ config,
+ mock(ConnectLogger.class),
+ () -> NOW,
+ keyProvider,
+ coordinator);
+ context = mock(LocalSession.Context.class);
+ when(context.getPlayer()).thenReturn(player);
+ when(context.getEndpointId()).thenReturn("endpoint-id");
+ when(context.getEndpointOrgId()).thenReturn("org-id");
+ when(context.getProtocol()).thenReturn(SessionProtocol.SESSION_PROTOCOL_BEDROCK);
+ when(context.getAdmissionToken()).thenReturn(proposal.getAdmissionToken());
+ }
+
+ private Future start() throws Exception {
+ Future verification = executor.submit(() -> enforcer.verify(context));
+ assertTrue(fetchStarted.await(2, TimeUnit.SECONDS));
+ return verification;
+ }
+
+ private T completeWhileBlocked(Callable operation) throws Exception {
+ return executor.submit(operation).get(500, TimeUnit.MILLISECONDS);
+ }
+
+ private BedrockIdentityEnforcer.Decision finish(
+ Future verification) throws Exception {
+ releaseFetch.countDown();
+ return verification.get(2, TimeUnit.SECONDS);
+ }
+
+ @Override
+ public void close() throws Exception {
+ releaseFetch.countDown();
+ executor.shutdownNow();
+ executor.awaitTermination(2, TimeUnit.SECONDS);
+ coordinator.close();
+ }
+ }
}
diff --git a/core/src/test/java/com/minekube/connect/bedrock/BedrockIdentityKeyProviderTest.java b/core/src/test/java/com/minekube/connect/bedrock/BedrockIdentityKeyProviderTest.java
index cd525c206..c25f9073a 100644
--- a/core/src/test/java/com/minekube/connect/bedrock/BedrockIdentityKeyProviderTest.java
+++ b/core/src/test/java/com/minekube/connect/bedrock/BedrockIdentityKeyProviderTest.java
@@ -2,12 +2,22 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
import com.minekube.connect.config.ConnectConfig;
import java.lang.reflect.Field;
+import java.time.Instant;
+import java.time.Duration;
import java.util.Base64;
import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
import okhttp3.OkHttpClient;
+import okhttp3.Request;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import org.junit.jupiter.api.Test;
@@ -27,6 +37,14 @@ void returnsStaticConfiguredKeys() {
assertKeys(provider.keys(), first, second);
}
+ @Test
+ void rejectsStaticKeyThatTheVerifierCannotParse() {
+ ConnectConfig staticConfig = config("");
+ setField(staticConfig.getBedrockIdentity(), "publicKey",
+ Base64.getEncoder().encodeToString(new byte[44]));
+ assertTrue(new BedrockIdentityKeyProvider(staticConfig, new OkHttpClient()).keys().isEmpty());
+ }
+
@Test
void fetchesCurrentAndPreviousMetadataKeys() throws Exception {
byte[] current = filledKey((byte) 3);
@@ -41,9 +59,9 @@ void fetchesCurrentAndPreviousMetadataKeys() throws Exception {
+ "\"previous_public_keys\":[\"" + Base64.getEncoder().encodeToString(previous) + "\"],"
+ "\"cache_max_age_seconds\":120"
+ "}"));
- ConnectConfig config = config(server.url("/.well-known/minekube-connect/bedrock-identity-keys.json").toString());
+ ConnectConfig config = config(httpsUrl(server, "/.well-known/minekube-connect/bedrock-identity-keys.json"));
- BedrockIdentityKeyProvider provider = new BedrockIdentityKeyProvider(config, new OkHttpClient());
+ BedrockIdentityKeyProvider provider = new BedrockIdentityKeyProvider(config, httpsTestClient(server));
assertKeys(provider.keys(), current, previous);
assertEquals("/.well-known/minekube-connect/bedrock-identity-keys.json", server.takeRequest().getPath());
@@ -51,16 +69,198 @@ void fetchesCurrentAndPreviousMetadataKeys() throws Exception {
}
@Test
- void fallsBackToStaticKeysWhenMetadataFetchFails() throws Exception {
+ void rejectsPlaintextMetadataUrlBeforeFetching() throws Exception {
+ byte[] current = filledKey((byte) 11);
+ try (MockWebServer server = new MockWebServer()) {
+ server.enqueue(new MockResponse().setBody(metadata("minekube-connect", current, 120)));
+ BedrockIdentityKeyProvider provider = new BedrockIdentityKeyProvider(
+ config(server.url("/keys.json").toString()), new OkHttpClient());
+
+ assertTrue(provider.keys().isEmpty());
+ assertEquals(0, server.getRequestCount());
+ }
+ }
+
+ @Test
+ void rejectsMetadataUrlsWithRawUserinfoDelimiterBeforeFetching() throws Exception {
+ byte[] current = filledKey((byte) 12);
+ try (MockWebServer server = new MockWebServer()) {
+ String authorityAndPath = httpsUrl(server, "/keys.json").substring("https://".length());
+ for (String metadataUrl : List.of(
+ "https://@" + authorityAndPath,
+ "https://:@" + authorityAndPath)) {
+ server.enqueue(new MockResponse().setBody(metadata("minekube-connect", current, 120)));
+
+ BedrockIdentityKeyProvider provider = new BedrockIdentityKeyProvider(
+ config(metadataUrl), httpsTestClient(server));
+
+ assertTrue(provider.keys().isEmpty());
+ }
+ assertEquals(0, server.getRequestCount());
+ }
+ }
+
+ @Test
+ void failsClosedWhenConfiguredMetadataCannotBeInitiallyValidated() throws Exception {
byte[] fallback = filledKey((byte) 5);
try (MockWebServer server = new MockWebServer()) {
server.enqueue(new MockResponse().setResponseCode(503));
- ConnectConfig config = config(server.url("/keys.json").toString());
+ ConnectConfig config = config(httpsUrl(server, "/keys.json"));
setField(config.getBedrockIdentity(), "publicKey", Base64.getEncoder().encodeToString(fallback));
- BedrockIdentityKeyProvider provider = new BedrockIdentityKeyProvider(config, new OkHttpClient());
+ BedrockIdentityKeyProvider provider = new BedrockIdentityKeyProvider(config, httpsTestClient(server));
+
+ assertTrue(provider.keys().isEmpty());
+ }
+ }
+
+ @Test
+ void rejectsMetadataWithUnexpectedIssuerOrInvalidKeySize() throws Exception {
+ try (MockWebServer server = new MockWebServer()) {
+ server.enqueue(new MockResponse().setBody("{"
+ + "\"issuer\":\"unexpected\","
+ + "\"algorithm\":\"Ed25519\","
+ + "\"current_public_key\":\"" + Base64.getEncoder().encodeToString(new byte[31]) + "\","
+ + "\"cache_max_age_seconds\":120"
+ + "}"));
+ BedrockIdentityKeyProvider provider = new BedrockIdentityKeyProvider(
+ config(httpsUrl(server, "/keys.json")), httpsTestClient(server));
+
+ assertTrue(provider.keys().isEmpty());
+ }
+ }
+
+ @Test
+ void usesBoundedStaleKeysWhenMetadataScopeRefreshIsInvalid() throws Exception {
+ byte[] current = filledKey((byte) 6);
+ AtomicReference now = new AtomicReference<>(Instant.parse("2026-07-15T12:00:00Z"));
+ try (MockWebServer server = new MockWebServer()) {
+ server.enqueue(new MockResponse().setBody(metadata("minekube-connect", current, 1)));
+ server.enqueue(new MockResponse().setBody(metadata("unexpected", current, 1)));
+ server.enqueue(new MockResponse().setBody(metadata("unexpected", current, 1)));
+ ConnectConfig config = config(httpsUrl(server, "/keys.json"));
+ setField(config.getBedrockIdentity(), "metadataMaxStaleSeconds", 10);
+ BedrockIdentityKeyProvider provider = new BedrockIdentityKeyProvider(
+ config,
+ httpsTestClient(server),
+ now::get);
+
+ assertKeys(provider.keys(), current);
+ now.set(now.get().plusSeconds(2));
+ assertKeys(provider.keys(), current);
+ now.set(now.get().plusSeconds(10));
+ assertTrue(provider.keys().isEmpty());
+ }
+ }
- assertKeys(provider.keys(), fallback);
+ @Test
+ void rejectsRedirectAndOversizedMetadataBeforeCaching() throws Exception {
+ byte[] current = filledKey((byte) 7);
+ try (MockWebServer server = new MockWebServer()) {
+ server.enqueue(new MockResponse().setResponseCode(302).setHeader("Location", "/redirected"));
+ server.enqueue(new MockResponse().setBody(metadata("minekube-connect", current, 120) + " ".repeat(65_537)));
+ ConnectConfig config = config(httpsUrl(server, "/keys.json"));
+ BedrockIdentityKeyProvider provider = new BedrockIdentityKeyProvider(config, httpsTestClient(server));
+
+ assertTrue(provider.keys().isEmpty());
+ assertTrue(new BedrockIdentityKeyProvider(config, httpsTestClient(server)).keys().isEmpty());
+ assertEquals(2, server.getRequestCount());
+ }
+ }
+
+ @Test
+ void limitsMetadataCallsToFiveSeconds() throws Exception {
+ byte[] current = filledKey((byte) 8);
+ try (MockWebServer server = new MockWebServer()) {
+ server.enqueue(new MockResponse()
+ .setBody(metadata("minekube-connect", current, 120))
+ .setHeadersDelay(6, TimeUnit.SECONDS));
+ BedrockIdentityKeyProvider provider = new BedrockIdentityKeyProvider(
+ config(httpsUrl(server, "/keys.json")), httpsTestClient(server));
+
+ assertTimeoutPreemptively(Duration.ofMillis(5_500), () -> assertTrue(provider.keys().isEmpty()));
+ }
+ }
+
+ @Test
+ void sharesOneExpiredMetadataRefreshAcrossConcurrentCallers() throws Exception {
+ byte[] current = filledKey((byte) 9);
+ try (MockWebServer server = new MockWebServer()) {
+ server.enqueue(new MockResponse()
+ .setBody(metadata("minekube-connect", current, 120))
+ .setHeadersDelay(250, TimeUnit.MILLISECONDS));
+ BedrockIdentityKeyProvider provider = new BedrockIdentityKeyProvider(
+ config(httpsUrl(server, "/keys.json")), httpsTestClient(server));
+ ExecutorService executor = Executors.newFixedThreadPool(8);
+ try {
+ for (int i = 0; i < 8; i++) {
+ executor.submit(provider::keys);
+ }
+ } finally {
+ executor.shutdown();
+ assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS));
+ }
+ assertEquals(1, server.getRequestCount());
+ }
+ }
+
+ @Test
+ void backsOffFailedRefreshesAndFailsClosedAfterHardStaleDeadline() throws Exception {
+ byte[] current = filledKey((byte) 10);
+ AtomicReference now = new AtomicReference<>(Instant.parse("2026-07-15T12:00:00Z"));
+ try (MockWebServer server = new MockWebServer()) {
+ server.enqueue(new MockResponse().setBody(metadata("minekube-connect", current, 1)));
+ server.enqueue(new MockResponse().setResponseCode(503));
+ server.enqueue(new MockResponse().setResponseCode(503));
+ ConnectConfig config = config(httpsUrl(server, "/keys.json"));
+ setField(config.getBedrockIdentity(), "metadataMaxStaleSeconds", 10);
+ BedrockIdentityKeyProvider provider = new BedrockIdentityKeyProvider(
+ config, httpsTestClient(server), now::get);
+
+ assertKeys(provider.keys(), current);
+ now.set(now.get().plusSeconds(10));
+ assertKeys(provider.keys(), current);
+ assertKeys(provider.keys(), current);
+ assertEquals(2, server.getRequestCount());
+ now.set(Instant.parse("2026-07-15T12:00:12Z"));
+ assertTrue(provider.keys().isEmpty());
+ assertTrue(provider.keys().isEmpty());
+ assertEquals(2, server.getRequestCount());
+ now.set(Instant.parse("2026-07-15T12:00:15Z"));
+ assertTrue(provider.keys().isEmpty());
+ assertEquals(3, server.getRequestCount());
+ }
+ }
+
+ @Test
+ void usesPostFailureTimeForStaleEligibilityAndRetryBackoff() throws Exception {
+ Instant fetchedAt = Instant.parse("2026-07-15T12:00:00Z");
+ AtomicReference beforeRequest = new AtomicReference<>(fetchedAt);
+ AtomicReference afterSecondRequest = new AtomicReference<>(fetchedAt.plusSeconds(15));
+ byte[] current = filledKey((byte) 11);
+ try (MockWebServer server = new MockWebServer()) {
+ server.enqueue(new MockResponse().setBody(metadata("minekube-connect", current, 1)));
+ server.enqueue(new MockResponse().setResponseCode(503));
+ server.enqueue(new MockResponse().setResponseCode(503));
+ ConnectConfig config = config(httpsUrl(server, "/keys.json"));
+ setField(config.getBedrockIdentity(), "metadataMaxStaleSeconds", 10);
+ BedrockIdentityKeyProvider provider = new BedrockIdentityKeyProvider(
+ config,
+ httpsTestClient(server),
+ () -> server.getRequestCount() >= 2
+ ? afterSecondRequest.get()
+ : beforeRequest.get());
+
+ assertKeys(provider.keys(), current);
+ beforeRequest.set(fetchedAt.plusSeconds(10));
+
+ assertTrue(provider.keys().isEmpty());
+ assertTrue(provider.keys().isEmpty());
+ assertEquals(2, server.getRequestCount());
+
+ afterSecondRequest.set(fetchedAt.plusSeconds(20));
+ assertTrue(provider.keys().isEmpty());
+ assertEquals(3, server.getRequestCount());
}
}
@@ -70,12 +270,37 @@ private static ConnectConfig config(String metadataUrl) {
return config;
}
+ private static String httpsUrl(MockWebServer server, String path) {
+ return server.url(path).toString().replaceFirst("^http:", "https:");
+ }
+
+ private static OkHttpClient httpsTestClient(MockWebServer server) {
+ return new OkHttpClient.Builder().addInterceptor(chain -> {
+ Request request = chain.request();
+ String path = request.url().encodedPath();
+ if (request.url().encodedQuery() != null) {
+ path += "?" + request.url().encodedQuery();
+ }
+ return new OkHttpClient.Builder().callTimeout(5, TimeUnit.SECONDS).followRedirects(false)
+ .build().newCall(request.newBuilder().url(server.url(path)).build()).execute();
+ }).build();
+ }
+
private static byte[] filledKey(byte value) {
byte[] key = new byte[32];
java.util.Arrays.fill(key, value);
return key;
}
+ private static String metadata(String issuer, byte[] current, int cacheSeconds) {
+ return "{"
+ + "\"issuer\":\"" + issuer + "\","
+ + "\"algorithm\":\"Ed25519\","
+ + "\"current_public_key\":\"" + Base64.getEncoder().encodeToString(current) + "\","
+ + "\"cache_max_age_seconds\":" + cacheSeconds
+ + "}";
+ }
+
private static void assertKeys(List actual, byte[]... expected) {
assertEquals(expected.length, actual.size());
for (int i = 0; i < expected.length; i++) {
diff --git a/core/src/test/java/com/minekube/connect/bedrock/BedrockIdentityReadinessTest.java b/core/src/test/java/com/minekube/connect/bedrock/BedrockIdentityReadinessTest.java
new file mode 100644
index 000000000..e2c9006bd
--- /dev/null
+++ b/core/src/test/java/com/minekube/connect/bedrock/BedrockIdentityReadinessTest.java
@@ -0,0 +1,115 @@
+package com.minekube.connect.bedrock;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.minekube.connect.bedrock.BedrockIdentityReadiness.Transport;
+import com.minekube.connect.config.ConnectConfig;
+import java.lang.reflect.Field;
+import java.util.Base64;
+import okhttp3.OkHttpClient;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import org.junit.jupiter.api.Test;
+
+class BedrockIdentityReadinessTest {
+ @Test
+ void requiresNonDisabledEnforcementAndValidatedStaticKey() {
+ ConnectConfig disabled = config("disabled");
+ setField(disabled.getBedrockIdentity(), "publicKey", encodedKey((byte) 1));
+ assertFalse(new BedrockIdentityReadiness(disabled,
+ new BedrockIdentityKeyProvider(disabled, new OkHttpClient())).isReady());
+
+ ConnectConfig malformed = config("require");
+ setField(malformed.getBedrockIdentity(), "publicKey", "not-base64");
+ assertFalse(new BedrockIdentityReadiness(malformed,
+ new BedrockIdentityKeyProvider(malformed, new OkHttpClient())).isReady());
+
+ ConnectConfig unparseable = config("require");
+ setField(unparseable.getBedrockIdentity(), "publicKey",
+ Base64.getEncoder().encodeToString(new byte[44]));
+ assertFalse(new BedrockIdentityReadiness(unparseable,
+ new BedrockIdentityKeyProvider(unparseable, new OkHttpClient())).isReady());
+
+ ConnectConfig valid = config("require");
+ setField(valid.getBedrockIdentity(), "publicKey", encodedKey((byte) 2));
+ assertTrue(new BedrockIdentityReadiness(valid,
+ new BedrockIdentityKeyProvider(valid, new OkHttpClient())).isReady());
+
+ ConnectConfig unknownMode = config("bogus");
+ setField(unknownMode.getBedrockIdentity(), "publicKey", encodedKey((byte) 4));
+ assertFalse(new BedrockIdentityReadiness(unknownMode,
+ new BedrockIdentityKeyProvider(unknownMode, new OkHttpClient())).isReady());
+ }
+
+ @Test
+ void rejectsNormalizedModeAndBlankIssuerFromReadiness() {
+ ConnectConfig normalizedMode = config("REQUIRE");
+ setField(normalizedMode.getBedrockIdentity(), "publicKey", encodedKey((byte) 5));
+ assertFalse(new BedrockIdentityReadiness(normalizedMode,
+ new BedrockIdentityKeyProvider(normalizedMode, new OkHttpClient())).isReady());
+
+ ConnectConfig blankIssuer = config("require");
+ setField(blankIssuer.getBedrockIdentity(), "expectedIssuer", " ");
+ setField(blankIssuer.getBedrockIdentity(), "publicKey", encodedKey((byte) 6));
+ assertFalse(new BedrockIdentityReadiness(blankIssuer,
+ new BedrockIdentityKeyProvider(blankIssuer, new OkHttpClient())).isReady());
+ }
+
+ @Test
+ void fansReadinessTransitionsToWatchAndLibp2pIndependently() {
+ ConnectConfig config = config("require");
+ setField(config.getBedrockIdentity(), "publicKey", encodedKey((byte) 4));
+ BedrockIdentityReadiness readiness = new BedrockIdentityReadiness(
+ config,
+ new BedrockIdentityKeyProvider(config, new OkHttpClient()));
+
+ assertTrue(readiness.observe(Transport.WATCH));
+ assertTrue(readiness.observe(Transport.LIBP2P));
+ setField(config.getBedrockIdentity(), "enforcement", "disabled");
+
+ assertTrue(readiness.refresh(Transport.WATCH));
+ assertTrue(readiness.refresh(Transport.LIBP2P));
+ assertFalse(readiness.refresh(Transport.WATCH));
+ assertFalse(readiness.refresh(Transport.LIBP2P));
+
+ setField(config.getBedrockIdentity(), "enforcement", "require");
+ assertTrue(readiness.refresh(Transport.LIBP2P));
+ assertTrue(readiness.refresh(Transport.WATCH));
+ }
+
+ @Test
+ void metadataRequiresInitialSuccessfulValidationBeforeAdvertising() throws Exception {
+ try (MockWebServer server = new MockWebServer()) {
+ server.enqueue(new MockResponse().setResponseCode(503));
+ ConnectConfig config = config("require");
+ setField(config.getBedrockIdentity(), "metadataUrl", server.url("/keys").toString());
+ setField(config.getBedrockIdentity(), "publicKey", encodedKey((byte) 3));
+
+ assertFalse(new BedrockIdentityReadiness(config,
+ new BedrockIdentityKeyProvider(config, new OkHttpClient())).isReady());
+ }
+ }
+
+ private static ConnectConfig config(String enforcement) {
+ ConnectConfig config = new ConnectConfig();
+ setField(config.getBedrockIdentity(), "enforcement", enforcement);
+ return config;
+ }
+
+ private static String encodedKey(byte value) {
+ byte[] key = new byte[32];
+ java.util.Arrays.fill(key, value);
+ return Base64.getEncoder().encodeToString(key);
+ }
+
+ 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);
+ }
+ }
+}
diff --git a/core/src/test/java/com/minekube/connect/bedrock/ConnectPlatformBedrockIdentityLifecycleTest.java b/core/src/test/java/com/minekube/connect/bedrock/ConnectPlatformBedrockIdentityLifecycleTest.java
new file mode 100644
index 000000000..7f397c2e3
--- /dev/null
+++ b/core/src/test/java/com/minekube/connect/bedrock/ConnectPlatformBedrockIdentityLifecycleTest.java
@@ -0,0 +1,46 @@
+package com.minekube.connect.bedrock;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.inject.Injector;
+import com.minekube.connect.ConnectPlatform;
+import com.minekube.connect.api.ConnectApi;
+import com.minekube.connect.api.inject.PlatformInjector;
+import com.minekube.connect.api.logger.ConnectLogger;
+import com.minekube.connect.inject.CommonPlatformInjector;
+import com.minekube.connect.register.WatchHealthServer;
+import com.minekube.connect.register.WatcherRegister;
+import com.minekube.connect.tunnel.Tunneler;
+import com.minekube.connect.tunnel.p2p.Libp2pEndpoint;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import org.junit.jupiter.api.Test;
+
+class ConnectPlatformBedrockIdentityLifecycleTest {
+ @Test
+ void fourArgumentConstructorUsesInjectorRegistryAndClosesItOnDisable() {
+ ScheduledThreadPoolExecutor cleanupExecutor = new ScheduledThreadPoolExecutor(1);
+ cleanupExecutor.setRemoveOnCancelPolicy(true);
+ VerifiedBedrockIdentityRegistry registry = new VerifiedBedrockIdentityRegistry();
+ BedrockAdmissionCoordinator coordinator = new BedrockAdmissionCoordinator(registry, cleanupExecutor);
+ Injector guice = mock(Injector.class);
+ when(guice.getInstance(BedrockAdmissionCoordinator.class)).thenReturn(coordinator);
+ when(guice.getInstance(Libp2pEndpoint.class)).thenReturn(mock(Libp2pEndpoint.class));
+ when(guice.getInstance(WatchHealthServer.class)).thenReturn(mock(WatchHealthServer.class));
+ when(guice.getInstance(WatcherRegister.class)).thenReturn(mock(WatcherRegister.class));
+ when(guice.getInstance(Tunneler.class)).thenReturn(mock(Tunneler.class));
+ when(guice.getInstance(CommonPlatformInjector.class)).thenReturn(mock(CommonPlatformInjector.class));
+ ConnectPlatform platform = new ConnectPlatform(
+ mock(ConnectApi.class),
+ mock(PlatformInjector.class),
+ mock(ConnectLogger.class),
+ guice);
+
+ assertTrue(platform.disable());
+
+ assertTrue(cleanupExecutor.isShutdown());
+ assertTrue(cleanupExecutor.getQueue().isEmpty());
+ System.out.println("PLATFORM disable: coordinatorExecutorShutdown=true, pendingCleanupTasks=0");
+ }
+}
diff --git a/core/src/test/java/com/minekube/connect/bedrock/SimpleConnectApiBedrockIdentityTest.java b/core/src/test/java/com/minekube/connect/bedrock/SimpleConnectApiBedrockIdentityTest.java
new file mode 100644
index 000000000..dd8340486
--- /dev/null
+++ b/core/src/test/java/com/minekube/connect/bedrock/SimpleConnectApiBedrockIdentityTest.java
@@ -0,0 +1,205 @@
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+import com.google.gson.Gson;
+import com.minekube.connect.api.SimpleConnectApi;
+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.Auth;
+import com.minekube.connect.api.player.GameProfile;
+import com.minekube.connect.network.netty.LocalSession;
+import com.minekube.connect.player.ConnectPlayerImpl;
+import com.minekube.connect.watch.SessionProposal;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.lang.reflect.Field;
+import java.util.concurrent.ExecutorService;
+import org.junit.jupiter.api.Test;
+
+class SimpleConnectApiBedrockIdentityTest {
+ @Test
+ void apiRetainsOnlyClaimsRegistryAtTheIdentityBoundary() {
+ assertTrue(Arrays.stream(SimpleConnectApi.class.getDeclaredFields())
+ .map(Field::getType)
+ .anyMatch(VerifiedBedrockIdentityRegistry.class::equals));
+ assertFalse(Arrays.stream(SimpleConnectApi.class.getDeclaredFields())
+ .map(Field::getType)
+ .anyMatch(BedrockAdmissionCoordinator.class::equals));
+ assertFalse(Arrays.stream(SimpleConnectApi.class.getDeclaredFields())
+ .map(Field::getType)
+ .anyMatch(ExecutorService.class::isAssignableFrom));
+ }
+
+ @Test
+ void keepsRegistryAndAdmissionCapabilityOutOfPublicApis() {
+ for (Class> type : List.of(SimpleConnectApi.class, LocalSession.Context.class)) {
+ for (Method method : type.getMethods()) {
+ assertFalse(VerifiedBedrockIdentityRegistry.class.isAssignableFrom(method.getReturnType()));
+ assertFalse(Arrays.stream(method.getParameterTypes())
+ .anyMatch(VerifiedBedrockIdentityRegistry.class::isAssignableFrom));
+ }
+ }
+ assertFalse(Arrays.stream(LocalSession.Context.class.getDeclaredFields())
+ .anyMatch(field -> VerifiedBedrockIdentityRegistry.class.isAssignableFrom(field.getType())));
+
+ VerifiedBedrockIdentityRegistry registry = new VerifiedBedrockIdentityRegistry();
+ BedrockAdmissionCoordinator coordinator = new BedrockAdmissionCoordinator(registry);
+ try {
+ SimpleConnectApi api = new SimpleConnectApi(mock(ConnectLogger.class), registry);
+ ConnectPlayer player = coordinator.stage(coordinator.proposal(
+ VerifiedBedrockIdentityRegistryTest.session(false),
+ reason -> {}, "", ""));
+
+ assertNoPrivateIdentityReachable(player);
+ api.addPlayer(player);
+ assertNoPrivateIdentityReachable(api.getPlayer(player.getUniqueId()));
+ } finally {
+ coordinator.close();
+ }
+ }
+
+ @Test
+ void sameUuidReplacementRemovesDisplacedSessionClaims() {
+ VerifiedBedrockIdentityRegistry registry = new VerifiedBedrockIdentityRegistry();
+ SimpleConnectApi api = new SimpleConnectApi(mock(ConnectLogger.class), registry);
+ UUID uuid = UUID.randomUUID();
+ ConnectPlayer first = player("session-a", uuid);
+ ConnectPlayer second = player("session-b", uuid);
+ registry.record(first, VerifiedBedrockIdentityRegistryTest.claims("session-a"));
+ registry.record(second, VerifiedBedrockIdentityRegistryTest.claims("session-b"));
+
+ api.addPlayer(first);
+ api.addPlayer(second);
+
+ assertFalse(registry.get(first).isPresent());
+ assertTrue(registry.get(second).isPresent());
+ api.playerRemoved(uuid);
+ assertFalse(registry.get(second).isPresent());
+ }
+ @Test
+ void exposesOnlySanitizedPlayerAndNonceFreeVerifiedClaims() {
+ VerifiedBedrockIdentityRegistry registry = new VerifiedBedrockIdentityRegistry();
+ BedrockAdmissionCoordinator coordinator = new BedrockAdmissionCoordinator(registry);
+ SimpleConnectApi api = new SimpleConnectApi(mock(ConnectLogger.class), registry);
+ SessionProposal proposal = coordinator.proposal(
+ VerifiedBedrockIdentityRegistryTest.session(false), reason -> {}, "", "");
+ ConnectPlayer player = coordinator.stage(proposal);
+ BedrockIdentityClaims claims = VerifiedBedrockIdentityRegistryTest.claims("session-1");
+ registry.record(player, claims);
+ assertTrue(api.getVerifiedBedrockIdentity(player).isEmpty());
+ api.addPlayer(player);
+
+ ConnectPlayer exposed = api.getPlayer(player.getUniqueId());
+
+ assertEquals(1, exposed.getGameProfile().getProperties().size());
+ assertEquals("textures", exposed.getGameProfile().getProperties().get(0).getName());
+ assertFalse(exposed.getGameProfile().toString().contains("signed-envelope"));
+ assertFalse(exposed.getGameProfile().toString().contains("replay-nonce-a"));
+ assertFalse(exposed.getGameProfile().toString().contains("private-endpoint-id"));
+ assertSame(claims, api.getVerifiedBedrockIdentity(exposed).orElseThrow());
+ assertFalse(claims.toString().contains("replay-nonce-a"));
+ api.playerRemoved(player.getUniqueId());
+ assertTrue(api.getVerifiedBedrockIdentity(exposed).isEmpty());
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ void preservesNonceApiDescriptorWithoutExposingReplayMaterial() throws Exception {
+ Instant issuedAt = Instant.parse("2026-07-15T12:00:00Z");
+ BedrockIdentityClaims claims = new BedrockIdentityClaims(
+ "issuer",
+ "endpoint-id",
+ "endpoint",
+ "org-id",
+ "session-1",
+ "bedrock",
+ "trusted_bedrock_xuid",
+ "bedrock_xuid",
+ "2533274790395904",
+ "BedrockSteve",
+ "f912bf90-8349-565f-9dc0-9891923c0cc3",
+ null,
+ null,
+ issuedAt,
+ issuedAt.plusSeconds(300),
+ "replay-nonce-a");
+
+ assertNotNull(BedrockIdentityClaims.class.getConstructor(
+ String.class,
+ String.class,
+ String.class,
+ String.class,
+ String.class,
+ String.class,
+ String.class,
+ String.class,
+ String.class,
+ String.class,
+ String.class,
+ String.class,
+ String.class,
+ Instant.class,
+ Instant.class,
+ String.class));
+ assertNull(claims.getNonce());
+ assertFalse(claims.toString().contains("replay-nonce-a"));
+ }
+
+ @Test
+ void rejectsEqualButNonCurrentPlayerClaimLookup() {
+ VerifiedBedrockIdentityRegistry registry = new VerifiedBedrockIdentityRegistry();
+ SimpleConnectApi api = new SimpleConnectApi(mock(ConnectLogger.class), registry);
+ UUID uuid = UUID.randomUUID();
+ ConnectPlayer current = player("session-1", uuid);
+ ConnectPlayer equalPlayer = player("session-1", uuid);
+ BedrockIdentityClaims claims = VerifiedBedrockIdentityRegistryTest.claims("session-1");
+ registry.record(current, claims);
+ api.addPlayer(current);
+
+ assertTrue(current.equals(equalPlayer));
+ assertSame(claims, api.getVerifiedBedrockIdentity(current).orElseThrow());
+ assertTrue(api.getVerifiedBedrockIdentity(equalPlayer).isEmpty());
+ }
+
+ private static ConnectPlayer player(String sessionId, UUID uuid) {
+ return new ConnectPlayerImpl(sessionId,
+ new GameProfile("BedrockSteve", uuid, Collections.emptyList()), new Auth(false), "");
+ }
+
+ private static void assertNoPrivateIdentityReachable(ConnectPlayer player) {
+ assertSame(ConnectPlayerImpl.class, player.getClass());
+ assertTrue(Modifier.isFinal(player.getClass().getModifiers()));
+ assertTrue(Arrays.stream(player.getClass().getDeclaredFields())
+ .noneMatch(field ->
+ VerifiedBedrockIdentityRegistry.class.isAssignableFrom(field.getType()) ||
+ Map.class.isAssignableFrom(field.getType()) ||
+ field.getName().contains("admission") ||
+ field.getName().contains("claims") ||
+ field.getName().contains("registry")));
+
+ String serialized = new Gson().toJson(player);
+ for (String privateValue : List.of(
+ "signed-envelope",
+ "replay-nonce-a",
+ "private-endpoint-id",
+ "VerifiedBedrockIdentityRegistry",
+ "admissionProfiles",
+ "identities")) {
+ assertFalse(serialized.contains(privateValue));
+ }
+ }
+}
diff --git a/core/src/test/java/com/minekube/connect/bedrock/VerifiedBedrockIdentityRegistryTest.java b/core/src/test/java/com/minekube/connect/bedrock/VerifiedBedrockIdentityRegistryTest.java
new file mode 100644
index 000000000..4bd2338a0
--- /dev/null
+++ b/core/src/test/java/com/minekube/connect/bedrock/VerifiedBedrockIdentityRegistryTest.java
@@ -0,0 +1,147 @@
+package com.minekube.connect.bedrock;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+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.BedrockIdentityClaims;
+import com.minekube.connect.player.ConnectPlayerImpl;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.UUID;
+import java.util.concurrent.ExecutorService;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.Authentication;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.GameProfileProperty;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.Player;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.Session;
+import org.junit.jupiter.api.Test;
+
+class VerifiedBedrockIdentityRegistryTest {
+ @Test
+ void containsClaimsOnlyStateAndNoAdmissionLifecycle() {
+ for (Field field : VerifiedBedrockIdentityRegistry.class.getDeclaredFields()) {
+ assertFalse(ExecutorService.class.isAssignableFrom(field.getType()));
+ assertFalse(Session.class.isAssignableFrom(field.getType()));
+ assertFalse(field.getName().toLowerCase().contains("admission"));
+ }
+ for (Method method : VerifiedBedrockIdentityRegistry.class.getDeclaredMethods()) {
+ assertFalse(Session.class.isAssignableFrom(method.getReturnType()));
+ assertFalse(Arrays.stream(method.getParameterTypes()).anyMatch(Session.class::isAssignableFrom));
+ assertFalse(method.getName().toLowerCase().contains("admission"));
+ }
+ assertThrows(NoSuchFieldException.class,
+ () -> VerifiedBedrockIdentityRegistry.class.getDeclaredField("ADMISSION_OWNERS"));
+ }
+
+ @Test
+ void rejectsClaimsForAnotherSession() {
+ VerifiedBedrockIdentityRegistry registry = new VerifiedBedrockIdentityRegistry();
+ ConnectPlayer player = player("session-1");
+
+ assertThrows(IllegalArgumentException.class,
+ () -> registry.record(player, claims("session-2")));
+ assertTrue(registry.get(player).isEmpty());
+ }
+
+ @Test
+ void bindsClaimsToExactPlayerIdentity() {
+ VerifiedBedrockIdentityRegistry registry = new VerifiedBedrockIdentityRegistry();
+ UUID uuid = UUID.randomUUID();
+ ConnectPlayer player = player("session-1", uuid);
+ ConnectPlayer equalPlayer = player("session-1", uuid);
+ BedrockIdentityClaims claims = claims("session-1");
+
+ assertTrue(player.equals(equalPlayer));
+ registry.record(player, claims);
+
+ assertSame(claims, registry.get(player).orElseThrow());
+ assertTrue(registry.get(equalPlayer).isEmpty());
+ registry.remove(equalPlayer);
+ assertSame(claims, registry.get(player).orElseThrow());
+ }
+
+ @Test
+ void recordsAndRemovesClaimsForTheExactPlayer() {
+ VerifiedBedrockIdentityRegistry registry = new VerifiedBedrockIdentityRegistry();
+ ConnectPlayer player = player("session-1");
+
+ registry.record(player, claims("session-1"));
+ assertTrue(registry.get(player).isPresent());
+
+ registry.remove(player);
+ assertTrue(registry.get(player).isEmpty());
+ }
+
+ @Test
+ void closeClearsClaimsAndRejectsFurtherRecords() {
+ VerifiedBedrockIdentityRegistry registry = new VerifiedBedrockIdentityRegistry();
+ ConnectPlayer player = player("session-1");
+ registry.record(player, claims("session-1"));
+
+ registry.close();
+
+ assertTrue(registry.get(player).isEmpty());
+ assertThrows(IllegalStateException.class,
+ () -> registry.record(player, claims("session-1")));
+ }
+
+ private static ConnectPlayer player(String sessionId) {
+ return player(sessionId, UUID.randomUUID());
+ }
+
+ private static ConnectPlayer player(String sessionId, UUID uuid) {
+ return new ConnectPlayerImpl(
+ sessionId,
+ new GameProfile("BedrockSteve", uuid, Collections.emptyList()),
+ new Auth(false),
+ "");
+ }
+
+ static BedrockIdentityClaims claims(String sessionId) {
+ Instant issuedAt = Instant.parse("2026-07-15T12:00:00Z");
+ return new BedrockIdentityClaims(
+ "minekube-connect",
+ "endpoint-id",
+ "endpoint",
+ "org-id",
+ sessionId,
+ "bedrock",
+ "trusted_bedrock_xuid",
+ "bedrock_xuid",
+ "2533274790395904",
+ "BedrockSteve",
+ "f912bf90-8349-565f-9dc0-9891923c0cc3",
+ null,
+ null,
+ issuedAt,
+ issuedAt.plusSeconds(300));
+ }
+
+ static Session session(boolean passthrough) {
+ return Session.newBuilder()
+ .setId("session-1")
+ .setAuth(Authentication.newBuilder().setPassthrough(passthrough))
+ .setPlayer(Player.newBuilder()
+ .setAddr("127.0.0.1")
+ .setProfile(minekube.connect.v1alpha1.WatchServiceOuterClass.GameProfile.newBuilder()
+ .setId("f912bf90-8349-565f-9dc0-9891923c0cc3")
+ .setName("BedrockSteve")
+ .addProperties(GameProfileProperty.newBuilder()
+ .setName("textures")
+ .setValue("skin"))
+ .addProperties(GameProfileProperty.newBuilder()
+ .setName("minekube:bedrock_identity")
+ .setValue("signed-envelope-replay-nonce-a"))
+ .addProperties(GameProfileProperty.newBuilder()
+ .setName("minekube:bedrock_identity_scope")
+ .setValue("private-endpoint-id"))))
+ .build();
+ }
+}
diff --git a/core/src/test/java/com/minekube/connect/network/netty/LocalSessionBedrockIdentityTest.java b/core/src/test/java/com/minekube/connect/network/netty/LocalSessionBedrockIdentityTest.java
new file mode 100644
index 000000000..c70ad60ae
--- /dev/null
+++ b/core/src/test/java/com/minekube/connect/network/netty/LocalSessionBedrockIdentityTest.java
@@ -0,0 +1,163 @@
+package com.minekube.connect.network.netty;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+import com.minekube.connect.api.SimpleConnectApi;
+import com.minekube.connect.api.logger.ConnectLogger;
+import com.minekube.connect.api.player.ConnectPlayer;
+import com.minekube.connect.bedrock.BedrockAdmissionCoordinator;
+import com.minekube.connect.bedrock.VerifiedBedrockIdentityRegistry;
+import com.minekube.connect.tunnel.Tunneler;
+import com.minekube.connect.watch.SessionProposal;
+import io.netty.bootstrap.ServerBootstrap;
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelInitializer;
+import io.netty.channel.DefaultEventLoopGroup;
+import io.netty.channel.local.LocalAddress;
+import io.netty.channel.local.LocalServerChannel;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.net.InetSocketAddress;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.Authentication;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.GameProfile;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.GameProfileProperty;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.Player;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.Session;
+import org.junit.jupiter.api.Test;
+
+class LocalSessionBedrockIdentityTest {
+ @Test
+ void discardsStagedAdmissionWhenSynchronousSetupFails() throws Exception {
+ VerifiedBedrockIdentityRegistry registry = new VerifiedBedrockIdentityRegistry();
+ BedrockAdmissionCoordinator coordinator = new BedrockAdmissionCoordinator(registry);
+ Constructor> productionConstructor = Arrays.stream(LocalSession.class.getConstructors())
+ .filter(constructor -> Arrays.equals(
+ constructor.getParameterTypes(),
+ new Class>[] {
+ ConnectLogger.class,
+ SimpleConnectApi.class,
+ Tunneler.class,
+ java.net.SocketAddress.class,
+ SessionProposal.class,
+ BedrockAdmissionCoordinator.class
+ }))
+ .findFirst()
+ .orElse(null);
+ assertNotNull(productionConstructor);
+ Session session = Session.newBuilder()
+ .setId("session-1")
+ .setAuth(Authentication.getDefaultInstance())
+ .setPlayer(Player.newBuilder()
+ .setAddr("")
+ .setProfile(GameProfile.newBuilder()
+ .setId("00000000-0000-0000-0000-000000000001")
+ .setName("Player")
+ .addProperties(GameProfileProperty.newBuilder()
+ .setName("minekube:bedrock_identity")
+ .setValue("signed-envelope"))))
+ .build();
+ SessionProposal proposal = coordinator.proposal(session, reason -> {}, "", "");
+ LocalSession localSession = (LocalSession) productionConstructor.newInstance(
+ mock(ConnectLogger.class),
+ new SimpleConnectApi(mock(ConnectLogger.class), registry),
+ mock(Tunneler.class),
+ new InetSocketAddress("127.0.0.1", 25565),
+ proposal,
+ coordinator);
+
+ try {
+ assertThrows(IllegalArgumentException.class, localSession::connect);
+
+ Field admissions = BedrockAdmissionCoordinator.class.getDeclaredField("admissions");
+ admissions.setAccessible(true);
+ assertTrue(((Map, ?>) admissions.get(coordinator)).isEmpty());
+ } finally {
+ coordinator.close();
+ }
+ }
+
+ @Test
+ void discardsSameUuidDisplacedAdmissionAfterConnect() throws Exception {
+ VerifiedBedrockIdentityRegistry registry = new VerifiedBedrockIdentityRegistry();
+ BedrockAdmissionCoordinator coordinator = new BedrockAdmissionCoordinator(registry);
+ Session firstSession = privateSession("session-1");
+ Session secondSession = privateSession("session-2");
+ SessionProposal firstProposal = coordinator.proposal(firstSession, reason -> {}, "", "");
+ SessionProposal secondProposal = coordinator.proposal(secondSession, reason -> {}, "", "");
+ ConnectPlayer firstPlayer = coordinator.stage(firstProposal);
+ SimpleConnectApi api = new SimpleConnectApi(mock(ConnectLogger.class), registry);
+ api.addPlayer(firstPlayer);
+ Field admissions = BedrockAdmissionCoordinator.class.getDeclaredField("admissions");
+ admissions.setAccessible(true);
+ DefaultEventLoopGroup eventLoopGroup = new DefaultEventLoopGroup(1);
+ AtomicReference acceptedChannel = new AtomicReference<>();
+ LocalAddress target = new LocalAddress("connect-replacement-" + UUID.randomUUID());
+ Channel server = null;
+ LocalSession.setPlatformEventLoopGroup(eventLoopGroup);
+ try {
+ server = new ServerBootstrap()
+ .group(eventLoopGroup)
+ .channel(LocalServerChannel.class)
+ .childHandler(new ChannelInitializer() {
+ @Override
+ protected void initChannel(Channel channel) {
+ acceptedChannel.set(channel);
+ }
+ })
+ .bind(target)
+ .sync()
+ .channel();
+ new LocalSession(
+ mock(ConnectLogger.class),
+ api,
+ mock(Tunneler.class),
+ target,
+ secondProposal,
+ coordinator).connect();
+
+ await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> {
+ synchronized (coordinator) {
+ Map, ?> pending = (Map, ?>) admissions.get(coordinator);
+ assertFalse(pending.containsKey(firstProposal.getAdmissionToken()));
+ assertTrue(pending.containsKey(secondProposal.getAdmissionToken()));
+ }
+ });
+ } finally {
+ Channel child = acceptedChannel.get();
+ if (child != null) {
+ child.close().syncUninterruptibly();
+ }
+ if (server != null) {
+ server.close().syncUninterruptibly();
+ }
+ LocalSession.setPlatformEventLoopGroup(null);
+ eventLoopGroup.shutdownGracefully().syncUninterruptibly();
+ coordinator.close();
+ }
+ }
+
+ private static Session privateSession(String sessionId) {
+ return Session.newBuilder()
+ .setId(sessionId)
+ .setAuth(Authentication.getDefaultInstance())
+ .setPlayer(Player.newBuilder()
+ .setAddr("127.0.0.1")
+ .setProfile(GameProfile.newBuilder()
+ .setId("00000000-0000-0000-0000-000000000001")
+ .setName("Player")
+ .addProperties(GameProfileProperty.newBuilder()
+ .setName("minekube:bedrock_identity")
+ .setValue("signed-envelope"))))
+ .build();
+ }
+}
diff --git a/core/src/test/java/com/minekube/connect/register/WatcherRegisterTest.java b/core/src/test/java/com/minekube/connect/register/WatcherRegisterTest.java
index 29cfe1998..463000b4e 100644
--- a/core/src/test/java/com/minekube/connect/register/WatcherRegisterTest.java
+++ b/core/src/test/java/com/minekube/connect/register/WatcherRegisterTest.java
@@ -19,6 +19,12 @@
import com.minekube.connect.api.SimpleConnectApi;
import com.minekube.connect.api.inject.PlatformInjector;
import com.minekube.connect.api.logger.ConnectLogger;
+import com.minekube.connect.bedrock.BedrockAdmissionCoordinator;
+import com.minekube.connect.bedrock.BedrockIdentityKeyProvider;
+import com.minekube.connect.bedrock.BedrockIdentityReadiness;
+import com.minekube.connect.bedrock.BedrockIdentityReadiness.Transport;
+import com.minekube.connect.bedrock.VerifiedBedrockIdentityRegistry;
+import com.minekube.connect.config.ConnectConfig;
import com.minekube.connect.tunnel.p2p.Libp2pEndpoint;
import com.minekube.connect.tunnel.Tunneler;
import com.minekube.connect.watch.SessionProposal;
@@ -28,16 +34,20 @@
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.net.InetSocketAddress;
+import java.util.Base64;
import java.util.List;
+import java.util.Map;
import java.util.Timer;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.atomic.AtomicBoolean;
import org.mockito.ArgumentCaptor;
import minekube.connect.v1alpha1.WatchServiceOuterClass.GameProfile;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.GameProfileProperty;
import minekube.connect.v1alpha1.WatchServiceOuterClass.Player;
import minekube.connect.v1alpha1.WatchServiceOuterClass.Session;
import minekube.connect.v1alpha1.WatchServiceOuterClass.TunnelTransport;
import minekube.connect.v1alpha1.WatchServiceOuterClass.TunnelTransport.Type;
+import okhttp3.OkHttpClient;
import okhttp3.WebSocket;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
@@ -99,6 +109,30 @@ void startAfterStopCreatesNewScheduler() throws Exception {
assertFalse(secondScheduler.isShutdown());
}
+ @Test
+ void readinessTransitionReconnectsWatchAndWithdrawsThePreviousRegistration() throws Exception {
+ Fixture fixture = newFixture();
+ ConnectConfig config = identityConfig();
+ BedrockIdentityReadiness readiness = new BedrockIdentityReadiness(
+ config,
+ new BedrockIdentityKeyProvider(config, new OkHttpClient()));
+ readiness.observe(Transport.WATCH);
+ inject(fixture.register, "bedrockIdentityReadiness", readiness);
+ WebSocket webSocket = mock(WebSocket.class);
+ when(fixture.watchClient.watch(any(Watcher.class))).thenReturn(webSocket);
+ register = fixture.register;
+ register.start();
+
+ invokeRefreshWatchReadiness(register);
+ verify(fixture.watchClient).watch(any(Watcher.class));
+
+ setField(config.getBedrockIdentity(), "enforcement", "disabled");
+ invokeRefreshWatchReadiness(register);
+
+ verify(fixture.watchClient, times(2)).watch(any(Watcher.class));
+ verify(webSocket).close(1000, "watcher is reconnecting");
+ }
+
@Test
void stopBeforeStartDoesNotCreateScheduler() throws Exception {
WatcherRegister register = new WatcherRegister();
@@ -371,6 +405,39 @@ void acceptsLibp2pOnlyProposalWithoutLegacyTunnelServiceAddr() throws Exception
verify(fixture.platformInjector).getServerSocketAddress();
}
+ @Test
+ void invalidWatchProposalCancelsPrivateAdmissionImmediately() throws Exception {
+ BedrockAdmissionCoordinator coordinator = new BedrockAdmissionCoordinator(
+ new VerifiedBedrockIdentityRegistry());
+ try {
+ Fixture fixture = newFixture(coordinator);
+ register = fixture.register;
+ register.start();
+ ArgumentCaptor watcher = ArgumentCaptor.forClass(Watcher.class);
+ verify(fixture.watchClient).watch(watcher.capture());
+ Session session = Session.newBuilder()
+ .setId("session-1")
+ .setPlayer(Player.newBuilder()
+ .setAddr("127.0.0.1")
+ .setProfile(GameProfile.newBuilder()
+ .setId("00000000-0000-0000-0000-000000000001")
+ .setName("Player")
+ .addProperties(GameProfileProperty.newBuilder()
+ .setName("minekube:bedrock_identity")
+ .setValue("signed-envelope"))))
+ .build();
+ SessionProposal proposal = coordinator.proposal(session, reason -> {}, "", "");
+
+ watcher.getValue().onProposal(proposal);
+
+ Field admissions = BedrockAdmissionCoordinator.class.getDeclaredField("admissions");
+ admissions.setAccessible(true);
+ assertTrue(((Map, ?>) admissions.get(coordinator)).isEmpty());
+ } finally {
+ coordinator.close();
+ }
+ }
+
private static WatcherRegister newRegister() throws Exception {
return newFixture().register;
}
@@ -380,6 +447,10 @@ private static WatchBootstrap emptyBootstrap() {
}
private static Fixture newFixture() throws Exception {
+ return newFixture(null);
+ }
+
+ private static Fixture newFixture(BedrockAdmissionCoordinator admissionCoordinator) throws Exception {
WatcherRegister register = new WatcherRegister();
WatchClient watchClient = mock(WatchClient.class);
when(watchClient.watch(any(Watcher.class))).thenReturn(mock(WebSocket.class));
@@ -388,8 +459,11 @@ private static Fixture newFixture() throws Exception {
inject(register, "tunneler", mock(Tunneler.class));
inject(register, "platformInjector", mock(PlatformInjector.class));
inject(register, "logger", mock(ConnectLogger.class));
- inject(register, "api", mock(SimpleConnectApi.class));
+ inject(register, "api", new SimpleConnectApi(mock(ConnectLogger.class)));
inject(register, "libp2pEndpoint", mock(Libp2pEndpoint.class));
+ if (admissionCoordinator != null) {
+ inject(register, "admissionCoordinator", admissionCoordinator);
+ }
when(((PlatformInjector) getField(register, "platformInjector")).getServerSocketAddress())
.thenReturn(new InetSocketAddress("127.0.0.1", 25565));
return new Fixture(register, watchClient, (Tunneler) getField(register, "tunneler"),
@@ -404,6 +478,20 @@ private static void inject(WatcherRegister register, String fieldName, Object va
field.set(register, value);
}
+ private static ConnectConfig identityConfig() throws Exception {
+ ConnectConfig config = new ConnectConfig();
+ setField(config.getBedrockIdentity(), "enforcement", "require");
+ setField(config.getBedrockIdentity(), "publicKey",
+ Base64.getEncoder().encodeToString(new byte[32]));
+ return config;
+ }
+
+ private static void setField(Object target, String fieldName, Object value) throws Exception {
+ Field field = target.getClass().getDeclaredField(fieldName);
+ field.setAccessible(true);
+ field.set(target, value);
+ }
+
private static ScheduledExecutorService scheduler(WatcherRegister register) throws Exception {
return (ScheduledExecutorService) getField(register, "scheduler");
}
@@ -424,6 +512,12 @@ private static void invokeRetry(WatcherRegister register) throws Exception {
method.invoke(register);
}
+ private static void invokeRefreshWatchReadiness(WatcherRegister register) throws Exception {
+ Method method = WatcherRegister.class.getDeclaredMethod("refreshWatchReadiness");
+ method.setAccessible(true);
+ method.invoke(register);
+ }
+
private static void assertNoTimerFields(Class> type) {
for (Field field : type.getDeclaredFields()) {
assertFalse(Timer.class.isAssignableFrom(field.getType()),
diff --git a/core/src/test/java/com/minekube/connect/tunnel/p2p/Libp2pRuntimeTest.java b/core/src/test/java/com/minekube/connect/tunnel/p2p/Libp2pRuntimeTest.java
index 2dffbe767..21b2cdd3a 100644
--- a/core/src/test/java/com/minekube/connect/tunnel/p2p/Libp2pRuntimeTest.java
+++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/Libp2pRuntimeTest.java
@@ -26,14 +26,109 @@
package com.minekube.connect.tunnel.p2p;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import com.minekube.connect.api.SimpleConnectApi;
+import com.minekube.connect.api.inject.PlatformInjector;
+import com.minekube.connect.api.logger.ConnectLogger;
+import com.minekube.connect.bedrock.BedrockIdentityKeyProvider;
+import com.minekube.connect.bedrock.BedrockIdentityReadiness;
+import com.minekube.connect.bedrock.BedrockIdentityReadiness.Transport;
+import com.minekube.connect.config.ConnectConfig;
+import com.minekube.connect.platform.util.PlatformUtils;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.file.Path;
+import java.util.Base64;
+import java.util.List;
+import minekube.connect.v1alpha1.ConnectLibp2P.OfflineMode;
+import minekube.connect.v1alpha1.ConnectLibp2P.PeerCapacity;
+import minekube.connect.v1alpha1.ConnectLibp2P.PeerRegisterResult;
+import okhttp3.OkHttpClient;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
class Libp2pRuntimeTest {
+ @TempDir Path tempDir;
@Test
void resolvesJvmLibp2pHostClass() {
assertEquals(11, Libp2pRuntime.minimumJavaFeatureVersion());
assertEquals("io.libp2p.core.Host", Libp2pRuntime.hostClassName());
}
+
+ @Test
+ void readinessTransitionClosesTheActiveRegistrationForReregistration() throws Exception {
+ ConnectConfig config = identityConfig();
+ BedrockIdentityReadiness readiness = new BedrockIdentityReadiness(
+ config,
+ new BedrockIdentityKeyProvider(config, new OkHttpClient()));
+ readiness.observe(Transport.LIBP2P);
+ Libp2pEndpointRuntime runtime = new Libp2pEndpointRuntime(
+ Path.of("."),
+ config,
+ "token",
+ mock(PlatformUtils.class),
+ mock(ConnectLogger.class),
+ mock(PlatformInjector.class),
+ mock(SimpleConnectApi.class),
+ readiness);
+ PeerRegistrationClient client = new PeerRegistrationClient(new PeerRegistrationHandshake(
+ EndpointPeerIdentity.loadOrCreate(tempDir.resolve("libp2p-identity.key")),
+ "endpoint",
+ "token",
+ "instance",
+ List.of(),
+ OfflineMode.OFFLINE_MODE_ALLOWED,
+ List.of(),
+ PeerCapacity.getDefaultInstance()));
+ setActiveRegistration(runtime, client);
+
+ invokeRefreshRegistrationReadiness(runtime);
+ assertFalse(client.closedFuture().isDone());
+
+ setField(config.getBedrockIdentity(), "enforcement", "disabled");
+ invokeRefreshRegistrationReadiness(runtime);
+
+ assertTrue(client.closedFuture().isDone());
+ }
+
+ private static ConnectConfig identityConfig() throws Exception {
+ ConnectConfig config = new ConnectConfig();
+ setField(config.getBedrockIdentity(), "enforcement", "require");
+ setField(config.getBedrockIdentity(), "publicKey",
+ Base64.getEncoder().encodeToString(new byte[32]));
+ return config;
+ }
+
+ private static void setField(Object target, String name, Object value) throws Exception {
+ Field field = target.getClass().getDeclaredField(name);
+ field.setAccessible(true);
+ field.set(target, value);
+ }
+
+ private static void setActiveRegistration(
+ Libp2pEndpointRuntime runtime,
+ PeerRegistrationClient client) throws Exception {
+ Class> registrationClass = Class.forName(
+ Libp2pEndpointRuntime.class.getName() + "$ActiveRegistration");
+ Constructor> constructor = registrationClass.getDeclaredConstructor(
+ PeerRegistrationClient.class,
+ PeerRegisterResult.class);
+ constructor.setAccessible(true);
+ Object registration = constructor.newInstance(client, PeerRegisterResult.getDefaultInstance());
+ Field field = Libp2pEndpointRuntime.class.getDeclaredField("activeRegistration");
+ field.setAccessible(true);
+ field.set(runtime, registration);
+ }
+
+ private static void invokeRefreshRegistrationReadiness(Libp2pEndpointRuntime runtime)
+ throws Exception {
+ Method method = Libp2pEndpointRuntime.class.getDeclaredMethod("refreshRegistrationReadiness");
+ method.setAccessible(true);
+ method.invoke(runtime);
+ }
}
diff --git a/core/src/test/java/com/minekube/connect/tunnel/p2p/Libp2pSessionMapperTest.java b/core/src/test/java/com/minekube/connect/tunnel/p2p/Libp2pSessionMapperTest.java
index ff4ceed2d..48961f2bf 100644
--- a/core/src/test/java/com/minekube/connect/tunnel/p2p/Libp2pSessionMapperTest.java
+++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/Libp2pSessionMapperTest.java
@@ -8,11 +8,34 @@
import minekube.connect.v1alpha1.ConnectLibp2P.SessionOffer;
import minekube.connect.v1alpha1.ConnectLibp2P.SessionPlayer;
import minekube.connect.v1alpha1.WatchServiceOuterClass.Session;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.SessionProtocol;
import minekube.connect.v1alpha1.WatchServiceOuterClass.TunnelTransport.Type;
import org.junit.jupiter.api.Test;
class Libp2pSessionMapperTest {
+ @Test
+ void preservesTrustedProtocolMarkerFromLibp2pOffer() {
+ SessionOffer offer = SessionOffer.newBuilder()
+ .setProtocol(SessionProtocol.SESSION_PROTOCOL_BEDROCK)
+ .build();
+
+ assertEquals(SessionProtocol.SESSION_PROTOCOL_BEDROCK,
+ Libp2pSessionMapper.toWatchSession(offer).getProtocol());
+ assertEquals(SessionProtocol.SESSION_PROTOCOL_UNSPECIFIED,
+ Libp2pSessionMapper.toWatchSession(SessionOffer.getDefaultInstance()).getProtocol());
+ }
+
+ @Test
+ void preservesUnknownProtocolMarkerValueFromLibp2pOffer() {
+ SessionOffer offer = SessionOffer.newBuilder().setProtocolValue(99).build();
+
+ Session session = Libp2pSessionMapper.toWatchSession(offer);
+
+ assertEquals(99, session.getProtocolValue());
+ assertEquals(SessionProtocol.UNRECOGNIZED, session.getProtocol());
+ }
+
@Test
void mapsLibp2pOfferToExistingWatchSessionShape() {
SessionOffer offer = SessionOffer.newBuilder()
diff --git a/core/src/test/java/com/minekube/connect/tunnel/p2p/Libp2pSessionResponderTest.java b/core/src/test/java/com/minekube/connect/tunnel/p2p/Libp2pSessionResponderTest.java
index 972c4481f..2bbd9bca3 100644
--- a/core/src/test/java/com/minekube/connect/tunnel/p2p/Libp2pSessionResponderTest.java
+++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/Libp2pSessionResponderTest.java
@@ -1,6 +1,8 @@
package com.minekube.connect.tunnel.p2p;
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.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
@@ -9,6 +11,9 @@
import static org.mockito.Mockito.when;
import com.google.rpc.Code;
+import com.minekube.connect.api.player.ConnectPlayer;
+import com.minekube.connect.bedrock.BedrockAdmissionCoordinator;
+import com.minekube.connect.bedrock.VerifiedBedrockIdentityRegistry;
import com.minekube.connect.tunnel.Tunneler;
import com.minekube.connect.watch.SessionProposal;
import io.libp2p.core.Stream;
@@ -18,11 +23,14 @@
import io.netty.channel.ChannelHandler;
import io.netty.channel.embedded.EmbeddedChannel;
import java.io.ByteArrayOutputStream;
+import java.lang.reflect.Field;
import java.util.Collections;
+import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import minekube.connect.v1alpha1.ConnectLibp2P.SessionAccepted;
import minekube.connect.v1alpha1.ConnectLibp2P.SessionAuthentication;
import minekube.connect.v1alpha1.ConnectLibp2P.SessionGameProfile;
+import minekube.connect.v1alpha1.ConnectLibp2P.SessionGameProfileProperty;
import minekube.connect.v1alpha1.ConnectLibp2P.SessionOffer;
import minekube.connect.v1alpha1.ConnectLibp2P.SessionPlayer;
import minekube.connect.v1alpha1.ConnectLibp2P.SessionResponse;
@@ -33,6 +41,50 @@ class Libp2pSessionResponderTest {
private static final String ALLOWED_PEER = "12D3KooWNXa3WQenRYKVJCHxcadnp3JPcxRALCqk97qpMiqAG1tt";
private static final String OTHER_PEER = "12D3KooWEzZpASrUwA3s8CM3UCDCCYjQzfh91ZyJnxRqaZ9xTi31";
+ @Test
+ void privateOfferUsesCoordinatorTokenAndSanitizedProposal() throws Exception {
+ Stream stream = mock(Stream.class);
+ VerifiedBedrockIdentityRegistry registry = new VerifiedBedrockIdentityRegistry();
+ BedrockAdmissionCoordinator coordinator = new BedrockAdmissionCoordinator(registry);
+ AtomicReference proposalRef = new AtomicReference<>();
+ AtomicReference playerRef = new AtomicReference<>();
+ SessionOffer source = offer();
+ SessionOffer privateOffer = source.toBuilder()
+ .setPlayer(source.getPlayer().toBuilder()
+ .setProfile(source.getPlayer().getProfile().toBuilder()
+ .addProperties(SessionGameProfileProperty.newBuilder()
+ .setName("minekube:bedrock_identity")
+ .setValue("signed-envelope-replay-nonce-a"))))
+ .build();
+ Libp2pSessionResponder responder = new Libp2pSessionResponder(
+ "endpoint",
+ System::currentTimeMillis,
+ Collections.emptySet(),
+ (proposal, tunneler) -> {
+ proposalRef.set(proposal);
+ playerRef.set(coordinator.stage(proposal));
+ tunneler.tunnel(proposal.getSession(), new NoopHandler());
+ },
+ coordinator);
+
+ try {
+ responder.handleOffer(stream, privateOffer);
+
+ assertNotNull(proposalRef.get().getAdmissionToken());
+ assertFalse(proposalRef.get().getSession().toString()
+ .contains("signed-envelope-replay-nonce-a"));
+ assertFalse(playerRef.get().getGameProfile().toString()
+ .contains("signed-envelope-replay-nonce-a"));
+ SessionResponse response = writtenResponse(stream);
+ assertTrue(response.hasAccepted());
+ System.out.println("LIBP2P admission: session=" + response.getSessionId()
+ + ", result=ACCEPTED, sameStream=" + response.getAccepted().getSameStreamData()
+ + ", token=opaque, proposalPrivateEnvelope=false, stagedPlayerPrivateEnvelope=false");
+ } finally {
+ coordinator.close();
+ }
+ }
+
@Test
void sendsAcceptedWhenLocalSessionOpensSameStreamTunnel() throws Exception {
Stream stream = mock(Stream.class);
@@ -150,6 +202,38 @@ void rejectsOfferFromUnauthorizedConnectEdgePeer() throws Exception {
assertEquals(Code.PERMISSION_DENIED_VALUE, response.getRejected().getReason().getCode());
}
+ @Test
+ void starterFailureCancelsPrivateAdmissionImmediately() throws Exception {
+ BedrockAdmissionCoordinator coordinator = new BedrockAdmissionCoordinator(
+ new VerifiedBedrockIdentityRegistry());
+ SessionOffer source = offer();
+ SessionOffer privateOffer = source.toBuilder()
+ .setPlayer(source.getPlayer().toBuilder()
+ .setProfile(source.getPlayer().getProfile().toBuilder()
+ .addProperties(SessionGameProfileProperty.newBuilder()
+ .setName("minekube:bedrock_identity")
+ .setValue("signed-envelope"))))
+ .build();
+ Libp2pSessionResponder responder = new Libp2pSessionResponder(
+ "endpoint",
+ System::currentTimeMillis,
+ Collections.emptySet(),
+ (proposal, tunneler) -> {
+ throw new IllegalStateException("setup failed");
+ },
+ coordinator);
+
+ try {
+ responder.handleOffer(mock(Stream.class), privateOffer);
+
+ Field admissions = BedrockAdmissionCoordinator.class.getDeclaredField("admissions");
+ admissions.setAccessible(true);
+ assertTrue(((Map, ?>) admissions.get(coordinator)).isEmpty());
+ } finally {
+ coordinator.close();
+ }
+ }
+
private static SessionOffer offer() {
return SessionOffer.newBuilder()
.setSessionId("session-1")
diff --git a/core/src/test/java/com/minekube/connect/tunnel/p2p/PeerRegistrationHandshakeTest.java b/core/src/test/java/com/minekube/connect/tunnel/p2p/PeerRegistrationHandshakeTest.java
index 880af63a7..c35916b6a 100644
--- a/core/src/test/java/com/minekube/connect/tunnel/p2p/PeerRegistrationHandshakeTest.java
+++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/PeerRegistrationHandshakeTest.java
@@ -2,6 +2,7 @@
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.google.protobuf.ByteString;
@@ -12,8 +13,8 @@
import java.util.Base64;
import java.util.Collections;
import java.util.concurrent.atomic.AtomicInteger;
-import minekube.connect.v1alpha1.ConnectLibp2P.EndpointPeerRecord;
import minekube.connect.v1alpha1.ConnectLibp2P.EndpointAuthType;
+import minekube.connect.v1alpha1.ConnectLibp2P.EndpointPeerRecord;
import minekube.connect.v1alpha1.ConnectLibp2P.OfflineMode;
import minekube.connect.v1alpha1.ConnectLibp2P.PeerCapacity;
import minekube.connect.v1alpha1.ConnectLibp2P.PeerRegisterChallenge;
@@ -38,7 +39,7 @@ void buildsInitAndSignedCommitFromChallenge() throws Exception {
Collections.singletonList("parent"),
OfflineMode.OFFLINE_MODE_ALLOWED,
EndpointAuthType.ENDPOINT_AUTH_TYPE_PROXIED,
- Arrays.asList("session", "status"),
+ Arrays.asList("session", "status", "bedrock-identity-v1"),
PeerCapacity.newBuilder().setMaxSessions(100).setActiveSessions(3).build());
String relayAddr = "/dns4/relay.example/tcp/4001/p2p/relay/p2p-circuit/p2p/" + identity.peerId();
@@ -51,7 +52,7 @@ void buildsInitAndSignedCommitFromChallenge() throws Exception {
assertEquals(identity.publicKeyBase64(), init.getEndpointPublicKey());
assertEquals(OfflineMode.OFFLINE_MODE_ALLOWED, init.getOfflineMode());
assertEquals(EndpointAuthType.ENDPOINT_AUTH_TYPE_PROXIED, init.getAuthType());
- assertEquals(Arrays.asList("session", "status"), init.getCapabilitiesList());
+ assertEquals(Arrays.asList("session", "status", "bedrock-identity-v1"), init.getCapabilitiesList());
PeerRegisterChallenge challenge = PeerRegisterChallenge.newBuilder()
.setEndpointId("endpoint-id")
@@ -71,7 +72,7 @@ void buildsInitAndSignedCommitFromChallenge() throws Exception {
assertEquals("publisher", record.getPublisherId());
assertEquals("publisher-peer", record.getPublisherPeerId());
assertEquals("local", record.getRegion());
- assertEquals(Arrays.asList("session", "status"), record.getCapabilitiesList());
+ assertEquals(Arrays.asList("session", "status", "bedrock-identity-v1"), record.getCapabilitiesList());
assertEquals(EndpointAuthType.ENDPOINT_AUTH_TYPE_PROXIED, record.getAuthType());
assertEquals(init.getObservedAddrsList(), record.getAddrsList());
assertEquals(Collections.emptyList(), record.getDirectAddrsList());
@@ -87,6 +88,23 @@ void buildsInitAndSignedCommitFromChallenge() throws Exception {
commit.getSignature().toByteArray()));
}
+ @Test
+ void doesNotInventBedrockIdentityCapability() throws Exception {
+ EndpointPeerIdentity identity = EndpointPeerIdentity.loadOrCreate(tempDir.resolve("libp2p-identity.key"));
+ PeerRegistrationHandshake handshake = new PeerRegistrationHandshake(
+ identity,
+ "endpoint",
+ "token",
+ "instance",
+ Collections.emptyList(),
+ OfflineMode.OFFLINE_MODE_ALLOWED,
+ Arrays.asList("session", "status"),
+ PeerCapacity.newBuilder().setMaxSessions(100).setActiveSessions(3).build());
+
+ assertFalse(handshake.init(Collections.emptyList()).getCapabilitiesList()
+ .contains(PeerRegistrationHandshake.BEDROCK_IDENTITY_V1_CAPABILITY));
+ }
+
@Test
void commitUsesFreshCapacityFromSupplier() throws Exception {
EndpointPeerIdentity identity = EndpointPeerIdentity.loadOrCreate(tempDir.resolve("libp2p-identity.key"));
diff --git a/core/src/test/java/com/minekube/connect/watch/SessionProposalTest.java b/core/src/test/java/com/minekube/connect/watch/SessionProposalTest.java
index 25ccc88ba..e86e57e8c 100644
--- a/core/src/test/java/com/minekube/connect/watch/SessionProposalTest.java
+++ b/core/src/test/java/com/minekube/connect/watch/SessionProposalTest.java
@@ -1,15 +1,38 @@
package com.minekube.connect.watch;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import com.google.gson.Gson;
+import java.lang.reflect.Field;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
import minekube.connect.v1alpha1.WatchServiceOuterClass.Authentication;
import minekube.connect.v1alpha1.WatchServiceOuterClass.GameProfile;
import minekube.connect.v1alpha1.WatchServiceOuterClass.GameProfileProperty;
import minekube.connect.v1alpha1.WatchServiceOuterClass.Player;
import minekube.connect.v1alpha1.WatchServiceOuterClass.Session;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.SessionProtocol;
import org.junit.jupiter.api.Test;
class SessionProposalTest {
+ @Test
+ void preservesTrustedProtocolMarkerWithoutInferringJava() {
+ Session bedrock = Session.newBuilder().setProtocol(SessionProtocol.SESSION_PROTOCOL_BEDROCK).build();
+ Session java = Session.newBuilder().setProtocol(SessionProtocol.SESSION_PROTOCOL_JAVA).build();
+ Session legacy = Session.getDefaultInstance();
+ Session unknown = Session.newBuilder().setProtocolValue(99).build();
+
+ assertEquals(SessionProtocol.SESSION_PROTOCOL_BEDROCK,
+ new SessionProposal(bedrock, reason -> {}).getProtocol());
+ assertEquals(SessionProtocol.SESSION_PROTOCOL_JAVA,
+ new SessionProposal(java, reason -> {}).getProtocol());
+ assertEquals(SessionProtocol.SESSION_PROTOCOL_UNSPECIFIED,
+ new SessionProposal(legacy, reason -> {}).getProtocol());
+ assertEquals(SessionProtocol.UNRECOGNIZED,
+ new SessionProposal(unknown, reason -> {}).getProtocol());
+ }
+
@Test
void parsesBedrockIdentityScopeSidecarFromLegacyWatchSession() {
Session session = Session.newBuilder()
@@ -29,5 +52,73 @@ void parsesBedrockIdentityScopeSidecarFromLegacyWatchSession() {
assertEquals("endpoint-id", proposal.getEndpointId());
assertEquals("org-id", proposal.getEndpointOrgId());
+ assertEquals(0, proposal.getSession().getPlayer().getProfile().getPropertiesCount());
+ assertFalse(proposal.getSession().toString().contains("bedrock_identity_scope"));
+ }
+
+ @Test
+ void publicSessionAndLoggingExcludeRawIdentityEnvelope() {
+ Session session = Session.newBuilder()
+ .setId("session-1")
+ .setPlayer(Player.newBuilder()
+ .setProfile(GameProfile.newBuilder()
+ .setId("f912bf90-8349-565f-9dc0-9891923c0cc3")
+ .setName("BedrockSteve")
+ .addProperties(GameProfileProperty.newBuilder()
+ .setName("textures")
+ .setValue("skin"))
+ .addProperties(GameProfileProperty.newBuilder()
+ .setName("minekube:bedrock_identity")
+ .setValue("signed-envelope-replay-nonce-a"))
+ .addProperties(GameProfileProperty.newBuilder()
+ .setName("minekube:bedrock_identity_scope")
+ .setValue("private-endpoint-id"))))
+ .build();
+
+ SessionProposal proposal = new SessionProposal(session, reason -> {});
+
+ assertEquals(1, proposal.getSession().getPlayer().getProfile().getPropertiesCount());
+ assertEquals("textures", proposal.getSession().getPlayer().getProfile().getProperties(0).getName());
+ assertFalse(proposal.getSession().toString().contains("replay-nonce-a"));
+ assertFalse(proposal.getSession().toString().contains("private-endpoint-id"));
+ assertFalse(proposal.toString().contains("replay-nonce-a"));
+ assertFalse(proposal.toString().contains("private-endpoint-id"));
+ }
+
+ @Test
+ void reflectiveAndGsonReachableProposalStateNeverContainsRawIdentity() throws Exception {
+ Session session = Session.newBuilder()
+ .setId("session-1")
+ .setPlayer(Player.newBuilder()
+ .setProfile(GameProfile.newBuilder()
+ .setId("f912bf90-8349-565f-9dc0-9891923c0cc3")
+ .setName("BedrockSteve")
+ .addProperties(GameProfileProperty.newBuilder()
+ .setName("minekube:bedrock_identity")
+ .setValue("signed-envelope-replay-nonce-a"))))
+ .build();
+
+ SessionProposal proposal = new SessionProposal(session, reason -> {});
+
+ for (Field field : SessionProposal.class.getDeclaredFields()) {
+ field.setAccessible(true);
+ Object value = field.get(proposal);
+ if (value instanceof AtomicReference>) {
+ value = ((AtomicReference>) value).get();
+ }
+ if (value instanceof Session) {
+ Session reflectedSession = (Session) value;
+ assertFalse(reflectedSession.toString().contains("signed-envelope-replay-nonce-a"),
+ () -> "raw session leaked through " + field.getName());
+ assertFalse(new Gson().toJson(reflectedSession.getPlayer().getProfile().getPropertiesList()
+ .stream()
+ .map(property -> Map.of("name", property.getName(),
+ "value", property.getValue(),
+ "signature", property.getSignature()))
+ .toList())
+ .contains("signed-envelope-replay-nonce-a"),
+ () -> "raw session was Gson-reachable through " + field.getName());
+ }
+ }
}
}
diff --git a/core/src/test/java/com/minekube/connect/watch/WatchClientTest.java b/core/src/test/java/com/minekube/connect/watch/WatchClientTest.java
new file mode 100644
index 000000000..547ffeb5b
--- /dev/null
+++ b/core/src/test/java/com/minekube/connect/watch/WatchClientTest.java
@@ -0,0 +1,105 @@
+package com.minekube.connect.watch;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import com.minekube.connect.api.player.ConnectPlayer;
+import com.minekube.connect.bedrock.BedrockAdmissionCoordinator;
+import com.minekube.connect.bedrock.BedrockIdentityKeyProvider;
+import com.minekube.connect.bedrock.BedrockIdentityReadiness;
+import com.minekube.connect.bedrock.VerifiedBedrockIdentityRegistry;
+import com.minekube.connect.config.ConnectConfig;
+import java.util.concurrent.atomic.AtomicReference;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.Authentication;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.GameProfile;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.GameProfileProperty;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.Player;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.Session;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.SessionProtocol;
+import minekube.connect.v1alpha1.WatchServiceOuterClass.WatchResponse;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.WebSocket;
+import okhttp3.WebSocketListener;
+import okio.ByteString;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+class WatchClientTest {
+ @Test
+ void privateWatchSessionUsesCoordinatorTokenAndSanitizedProposal() {
+ ConnectConfig config = new ConnectConfig();
+ OkHttpClient httpClient = mock(OkHttpClient.class);
+ VerifiedBedrockIdentityRegistry registry = new VerifiedBedrockIdentityRegistry();
+ BedrockAdmissionCoordinator coordinator = new BedrockAdmissionCoordinator(registry);
+ WatchClient client = new WatchClient(
+ httpClient,
+ config,
+ new BedrockIdentityReadiness(
+ config,
+ new BedrockIdentityKeyProvider(config, new OkHttpClient())),
+ coordinator);
+ AtomicReference proposalRef = new AtomicReference<>();
+ Watcher watcher = new Watcher() {
+ @Override public void onOpen(WatchBootstrap bootstrap) { }
+ @Override public void onProposal(SessionProposal proposal) { proposalRef.set(proposal); }
+ @Override public void onCompleted() { }
+ @Override public void onError(Throwable throwable) { }
+ };
+
+ try {
+ client.watch(watcher);
+ ArgumentCaptor listener = ArgumentCaptor.forClass(WebSocketListener.class);
+ verify(httpClient).newWebSocket(any(Request.class), listener.capture());
+ Session raw = Session.newBuilder()
+ .setId("session-1")
+ .setProtocol(SessionProtocol.SESSION_PROTOCOL_BEDROCK)
+ .setAuth(Authentication.getDefaultInstance())
+ .setPlayer(Player.newBuilder()
+ .setAddr("127.0.0.1")
+ .setProfile(GameProfile.newBuilder()
+ .setId("f912bf90-8349-565f-9dc0-9891923c0cc3")
+ .setName("BedrockSteve")
+ .addProperties(GameProfileProperty.newBuilder()
+ .setName("minekube:bedrock_identity")
+ .setValue("signed-envelope-replay-nonce-a"))))
+ .build();
+
+ listener.getValue().onMessage(
+ mock(WebSocket.class),
+ ByteString.of(WatchResponse.newBuilder().setSession(raw).build().toByteArray()));
+
+ SessionProposal proposal = proposalRef.get();
+ assertNotNull(proposal);
+ assertNotNull(proposal.getAdmissionToken());
+ assertFalse(proposal.getSession().toString().contains("signed-envelope-replay-nonce-a"));
+ ConnectPlayer player = coordinator.stage(proposal);
+ assertFalse(player.getGameProfile().toString().contains("signed-envelope-replay-nonce-a"));
+ System.out.println("WATCH admission: session=" + proposal.getSession().getId() + ", token=opaque, "
+ + "proposalPrivateEnvelope=false, stagedPlayerPrivateEnvelope=false");
+ } finally {
+ coordinator.close();
+ }
+ }
+
+ @Test
+ void defaultDisabledConfigurationDoesNotAdvertiseBedrockIdentity() {
+ OkHttpClient httpClient = mock(OkHttpClient.class);
+ WatchClient client = new WatchClient(httpClient, new ConnectConfig());
+
+ client.watch(new Watcher() {
+ @Override public void onOpen(WatchBootstrap bootstrap) { }
+ @Override public void onProposal(SessionProposal proposal) { }
+ @Override public void onCompleted() { }
+ @Override public void onError(Throwable throwable) { }
+ });
+
+ ArgumentCaptor request = ArgumentCaptor.forClass(Request.class);
+ verify(httpClient).newWebSocket(request.capture(), any(WebSocketListener.class));
+ assertFalse(request.getValue().headers("Connect-Capabilities")
+ .contains("bedrock-identity-v1"));
+ }
+}
diff --git a/core/src/test/resources/bedrock-identity-v1/manifest.json b/core/src/test/resources/bedrock-identity-v1/manifest.json
new file mode 100644
index 000000000..756ca2dfd
--- /dev/null
+++ b/core/src/test/resources/bedrock-identity-v1/manifest.json
@@ -0,0 +1 @@
+{"schema":"minekube-bedrock-identity-v1-test-vectors","public_key_base64":"EvLQyZOnKxAUFaKqU2AhyOLXzPL6cHBNZjqwiLPxQy4=","verification_time_unix_ms":1783260000000,"vectors":[{"file":"v1-bedrock-xuid-valid.json","sha256":"31fa01f75de23077cb829d11a40b2215d66d5e8f7a765e54ce16967d51468acd","expected":"valid_bedrock_xuid"},{"file":"v1-bedrock-linked-java-linked-policy-valid.json","sha256":"de3f58519b3941b7e2d0d70f0b1dea855143077fbcd2420f8868d8243d8bf783","expected":"valid_bedrock_linked_java"},{"file":"v1-bedrock-linked-java-trusted-policy-valid.json","sha256":"37fec466da0cf01d9e911c23592d5f78fbdf0369202582881361fb9ddc4842f7","expected":"valid_bedrock_linked_java"},{"file":"v1-xuid-tampered.json","sha256":"eaf1c424619b153b5c61bff6592a184547ad4af0b9ceb04d2a2fdb34a02d32fb","expected":"invalid_signature"},{"file":"v1-linked-java-name-tampered.json","sha256":"5f4366f4494290817790205292b50394328fc76203d65667802f8d4980d052aa","expected":"invalid_signature"},{"file":"v1-expired.json","sha256":"c2d57d916634f0a131e107e50cd945b096f8df02cf3dd7e3e8c5805285e602c4","expected":"expired"},{"file":"v1-issued-in-future.json","sha256":"a97516a183f2f8f6ab729c16f40839460a98310e1cf6035f4f1d8f2dd6a14151","expected":"issued_in_future"},{"file":"v1-overlong-lifetime.json","sha256":"0be4bf8dccbb472b04286581f3314dd2fe40bfeaa75f9ceef92b760768817f46","expected":"overlong_lifetime"},{"file":"v1-wrong-endpoint.json","sha256":"94ded83ac6f9974bfa825b494b1d06d36f306a1dfc3751984031499467539f6c","expected":"scope_mismatch"},{"file":"v1-wrong-org.json","sha256":"c91eb75df6ac48787510957aef2ec6ddaa2694bd5b0dede19a6599e8031f13bd","expected":"scope_mismatch"},{"file":"v1-wrong-session.json","sha256":"1cef7f79b4b839de58da9c4fd4615f88fab7f8c8fbe1bd2a2f6b16b47f5e238c","expected":"scope_mismatch"},{"file":"v1-wrong-protocol.json","sha256":"d3452bd57687cf7c39cccd281ca8ddc11fc55e2db1f75b048618977c79e02b77","expected":"protocol_mismatch"},{"file":"v1-wrong-policy.json","sha256":"ac0b45d2dc43e5808089d89d3dfdee3c47112c47b37f81f761dc8c8184151c3a","expected":"policy_principal_mismatch"},{"file":"v1-wrong-issuer.json","sha256":"5a7c36fa867356750366fb8981a42a7ca53d3e7be2f47c256b60547a368dce2e","expected":"issuer_mismatch"},{"file":"v1-xuid-missing.json","sha256":"3707e7a3f1541d9183287e52cfbeb97da01031f9bd344658a1f02f814d7da2de","expected":"invalid_xuid"},{"file":"v1-xuid-leading-zero.json","sha256":"af15bc5a578472dcaef143d5254d49fe031159c7d9b120bcf81875d6bc23f443","expected":"invalid_xuid"},{"file":"v1-xuid-overflow.json","sha256":"434fb26686ab76a967c3924268b2d68d94dacd1bc17c1df253e4ca346325b616","expected":"invalid_xuid"},{"file":"v1-username-missing.json","sha256":"851925d9b34f39a171064b7ef826588b08e1d566a88b0d728678ad846a57e323","expected":"invalid_username"},{"file":"v1-derived-uuid-invalid.json","sha256":"12aa80473859da7e284339f95dd988c0a8c980ee1d9570de3ab2aed90cd19721","expected":"invalid_derived_uuid"},{"file":"v1-linked-java-uuid-invalid.json","sha256":"824dfb2c86cceb59ff933c75fd5ea7e7936c2555f7ec4cc6334040b99737d369","expected":"invalid_linked_identity"},{"file":"v1-linked-java-name-missing.json","sha256":"64f0b3576f2958c592c9fcb2a664fc6ecd485fbf4514e0dbb215f345361fcc87","expected":"invalid_linked_identity"},{"file":"v1-version-unknown.json","sha256":"5c6bc8094aa595ac70cdcb23f01dbca78107784daafcab9fa4d0727a8d88c0f1","expected":"unsupported_version"},{"file":"v1-duplicate-property-profile.json","sha256":"b027d07de73ce7352d0bcd62045b2ad2c025d1567fd8764c2c713fbd07470df0","expected":"duplicate_property"}]}
diff --git a/core/src/test/resources/bedrock-identity-v1/v1-bedrock-linked-java-linked-policy-valid.json b/core/src/test/resources/bedrock-identity-v1/v1-bedrock-linked-java-linked-policy-valid.json
new file mode 100644
index 000000000..4d142eedd
--- /dev/null
+++ b/core/src/test/resources/bedrock-identity-v1/v1-bedrock-linked-java-linked-policy-valid.json
@@ -0,0 +1 @@
+{"version":1,"issuer":"minekube-connect","endpoint":{"id":"endpoint-id","name":"fixture-endpoint","org_id":"org-id"},"session":{"id":"session-id","protocol":"bedrock","issued_at_unix_ms":1783260000000,"expires_at_unix_ms":1783260300000,"nonce":"Zml4dHVyZS1ub25jZS0wMQ"},"policy":{"bedrock_auth_mode":"linked_java_only"},"principal":{"type":"bedrock_linked_java","bedrock_xuid":"2535460020985370","bedrock_username":"BedrockFox","bedrock_derived_uuid":"cafc7598-0ef3-527f-8f28-60af2d9ca6bc","linked_java_uuid":"c66dfcbc-4bd2-4a29-8c76-eadf80faa08a","linked_java_name":"RoboFlax2"},"signature":"N7rSui9ZkrF1GPO7vuikqm2Vhd84JEHZy6m8uhF8Mj74_VKcD_83NPYcKnbn-dloKmxt6MHz2MZ2BYT7dPsjAw"}
diff --git a/core/src/test/resources/bedrock-identity-v1/v1-bedrock-linked-java-trusted-policy-valid.json b/core/src/test/resources/bedrock-identity-v1/v1-bedrock-linked-java-trusted-policy-valid.json
new file mode 100644
index 000000000..0fbc2f0cf
--- /dev/null
+++ b/core/src/test/resources/bedrock-identity-v1/v1-bedrock-linked-java-trusted-policy-valid.json
@@ -0,0 +1 @@
+{"version":1,"issuer":"minekube-connect","endpoint":{"id":"endpoint-id","name":"fixture-endpoint","org_id":"org-id"},"session":{"id":"session-id","protocol":"bedrock","issued_at_unix_ms":1783260000000,"expires_at_unix_ms":1783260300000,"nonce":"Zml4dHVyZS1ub25jZS0wMQ"},"policy":{"bedrock_auth_mode":"trusted_bedrock_xuid"},"principal":{"type":"bedrock_linked_java","bedrock_xuid":"2535460020985370","bedrock_username":"BedrockFox","bedrock_derived_uuid":"cafc7598-0ef3-527f-8f28-60af2d9ca6bc","linked_java_uuid":"c66dfcbc-4bd2-4a29-8c76-eadf80faa08a","linked_java_name":"RoboFlax2"},"signature":"4AwrecUlJXJ4tMPN6W6_7J4yFYUVJdND3vLF398M0_eqkeuWEHCCkP0CRuzYxXwbrm-JqnfwLwRjTfmP3TLVCg"}
diff --git a/core/src/test/resources/bedrock-identity-v1/v1-bedrock-xuid-valid.json b/core/src/test/resources/bedrock-identity-v1/v1-bedrock-xuid-valid.json
new file mode 100644
index 000000000..3e1bb51b5
--- /dev/null
+++ b/core/src/test/resources/bedrock-identity-v1/v1-bedrock-xuid-valid.json
@@ -0,0 +1 @@
+{"version":1,"issuer":"minekube-connect","endpoint":{"id":"endpoint-id","name":"fixture-endpoint","org_id":"org-id"},"session":{"id":"session-id","protocol":"bedrock","issued_at_unix_ms":1783260000000,"expires_at_unix_ms":1783260300000,"nonce":"Zml4dHVyZS1ub25jZS0wMQ"},"policy":{"bedrock_auth_mode":"trusted_bedrock_xuid"},"principal":{"type":"bedrock_xuid","bedrock_xuid":"2535460020985370","bedrock_username":"BedrockFox","bedrock_derived_uuid":"cafc7598-0ef3-527f-8f28-60af2d9ca6bc"},"signature":"lE_3F4QDD4gXthyYPAjR4_waY13_God5RweOCm14kZdi5UFZmsWWFhlJXGq9XNH1BjoKg3t0S9mPz69g9P3lDA"}
diff --git a/core/src/test/resources/bedrock-identity-v1/v1-derived-uuid-invalid.json b/core/src/test/resources/bedrock-identity-v1/v1-derived-uuid-invalid.json
new file mode 100644
index 000000000..7cfbbf6f3
--- /dev/null
+++ b/core/src/test/resources/bedrock-identity-v1/v1-derived-uuid-invalid.json
@@ -0,0 +1 @@
+{"version":1,"issuer":"minekube-connect","endpoint":{"id":"endpoint-id","name":"fixture-endpoint","org_id":"org-id"},"session":{"id":"session-id","protocol":"bedrock","issued_at_unix_ms":1783260000000,"expires_at_unix_ms":1783260300000,"nonce":"Zml4dHVyZS1ub25jZS0wMQ"},"policy":{"bedrock_auth_mode":"trusted_bedrock_xuid"},"principal":{"type":"bedrock_xuid","bedrock_xuid":"2535460020985370","bedrock_username":"BedrockFox","bedrock_derived_uuid":"00000000-0000-5000-8000-000000000001"},"signature":"KDXuRmXaZXtkLQlUJfi9egACcGaerXR9qZqRNUtOb7sFftVgunGJ57Oq7NWlcMVnDzQPLYRPow_xfzR3i6wXCw"}
diff --git a/core/src/test/resources/bedrock-identity-v1/v1-duplicate-property-profile.json b/core/src/test/resources/bedrock-identity-v1/v1-duplicate-property-profile.json
new file mode 100644
index 000000000..eb01fbb2e
--- /dev/null
+++ b/core/src/test/resources/bedrock-identity-v1/v1-duplicate-property-profile.json
@@ -0,0 +1 @@
+{"properties":[{"name":"minekube:bedrock_identity","value":"{\"version\":1,\"issuer\":\"minekube-connect\",\"endpoint\":{\"id\":\"endpoint-id\",\"name\":\"fixture-endpoint\",\"org_id\":\"org-id\"},\"session\":{\"id\":\"session-id\",\"protocol\":\"bedrock\",\"issued_at_unix_ms\":1783260000000,\"expires_at_unix_ms\":1783260300000,\"nonce\":\"Zml4dHVyZS1ub25jZS0wMQ\"},\"policy\":{\"bedrock_auth_mode\":\"trusted_bedrock_xuid\"},\"principal\":{\"type\":\"bedrock_xuid\",\"bedrock_xuid\":\"2535460020985370\",\"bedrock_username\":\"BedrockFox\",\"bedrock_derived_uuid\":\"cafc7598-0ef3-527f-8f28-60af2d9ca6bc\"},\"signature\":\"lE_3F4QDD4gXthyYPAjR4_waY13_God5RweOCm14kZdi5UFZmsWWFhlJXGq9XNH1BjoKg3t0S9mPz69g9P3lDA\"}"},{"name":"minekube:bedrock_identity","value":"{\"version\":1,\"issuer\":\"minekube-connect\",\"endpoint\":{\"id\":\"endpoint-id\",\"name\":\"fixture-endpoint\",\"org_id\":\"org-id\"},\"session\":{\"id\":\"session-id\",\"protocol\":\"bedrock\",\"issued_at_unix_ms\":1783260000000,\"expires_at_unix_ms\":1783260300000,\"nonce\":\"Zml4dHVyZS1ub25jZS0wMQ\"},\"policy\":{\"bedrock_auth_mode\":\"trusted_bedrock_xuid\"},\"principal\":{\"type\":\"bedrock_xuid\",\"bedrock_xuid\":\"2535460020985370\",\"bedrock_username\":\"BedrockFox\",\"bedrock_derived_uuid\":\"cafc7598-0ef3-527f-8f28-60af2d9ca6bc\"},\"signature\":\"lE_3F4QDD4gXthyYPAjR4_waY13_God5RweOCm14kZdi5UFZmsWWFhlJXGq9XNH1BjoKg3t0S9mPz69g9P3lDA\"}"}]}
diff --git a/core/src/test/resources/bedrock-identity-v1/v1-expired.json b/core/src/test/resources/bedrock-identity-v1/v1-expired.json
new file mode 100644
index 000000000..85da2d0b6
--- /dev/null
+++ b/core/src/test/resources/bedrock-identity-v1/v1-expired.json
@@ -0,0 +1 @@
+{"version":1,"issuer":"minekube-connect","endpoint":{"id":"endpoint-id","name":"fixture-endpoint","org_id":"org-id"},"session":{"id":"session-id","protocol":"bedrock","issued_at_unix_ms":1783259400000,"expires_at_unix_ms":1783259700000,"nonce":"Zml4dHVyZS1ub25jZS0wMQ"},"policy":{"bedrock_auth_mode":"trusted_bedrock_xuid"},"principal":{"type":"bedrock_xuid","bedrock_xuid":"2535460020985370","bedrock_username":"BedrockFox","bedrock_derived_uuid":"cafc7598-0ef3-527f-8f28-60af2d9ca6bc"},"signature":"1S6FKvkyGwGlqXWw6OCPJfVbk9AZe56-F1kwEl0Xn7VSxXL-rmsRWiy4PAiEqnutIUqN3sFoHTyrXV3S1xL-AQ"}
diff --git a/core/src/test/resources/bedrock-identity-v1/v1-issued-in-future.json b/core/src/test/resources/bedrock-identity-v1/v1-issued-in-future.json
new file mode 100644
index 000000000..09b7731f7
--- /dev/null
+++ b/core/src/test/resources/bedrock-identity-v1/v1-issued-in-future.json
@@ -0,0 +1 @@
+{"version":1,"issuer":"minekube-connect","endpoint":{"id":"endpoint-id","name":"fixture-endpoint","org_id":"org-id"},"session":{"id":"session-id","protocol":"bedrock","issued_at_unix_ms":1783260060001,"expires_at_unix_ms":1783260120000,"nonce":"Zml4dHVyZS1ub25jZS0wMQ"},"policy":{"bedrock_auth_mode":"trusted_bedrock_xuid"},"principal":{"type":"bedrock_xuid","bedrock_xuid":"2535460020985370","bedrock_username":"BedrockFox","bedrock_derived_uuid":"cafc7598-0ef3-527f-8f28-60af2d9ca6bc"},"signature":"K6skd8wV8z0Lh_f_-t3s1thz8AHdbxRuXPQp47E0635dC-Vs0i97LWKcZ9ivUeC5EXqmRcnZchjzMS6kpXgrCQ"}
diff --git a/core/src/test/resources/bedrock-identity-v1/v1-linked-java-name-missing.json b/core/src/test/resources/bedrock-identity-v1/v1-linked-java-name-missing.json
new file mode 100644
index 000000000..72c0556b8
--- /dev/null
+++ b/core/src/test/resources/bedrock-identity-v1/v1-linked-java-name-missing.json
@@ -0,0 +1 @@
+{"version":1,"issuer":"minekube-connect","endpoint":{"id":"endpoint-id","name":"fixture-endpoint","org_id":"org-id"},"session":{"id":"session-id","protocol":"bedrock","issued_at_unix_ms":1783260000000,"expires_at_unix_ms":1783260300000,"nonce":"Zml4dHVyZS1ub25jZS0wMQ"},"policy":{"bedrock_auth_mode":"linked_java_only"},"principal":{"type":"bedrock_linked_java","bedrock_xuid":"2535460020985370","bedrock_username":"BedrockFox","bedrock_derived_uuid":"cafc7598-0ef3-527f-8f28-60af2d9ca6bc","linked_java_uuid":"c66dfcbc-4bd2-4a29-8c76-eadf80faa08a"},"signature":"y3wbVkJg5a18n9Zf_E-RwH6ZHfbuQ_zajfn9eKNSXfFItE9t5AnYGARZPEZ98wqu6Ys-cWDrWUkGE9iAG-peAQ"}
diff --git a/core/src/test/resources/bedrock-identity-v1/v1-linked-java-name-tampered.json b/core/src/test/resources/bedrock-identity-v1/v1-linked-java-name-tampered.json
new file mode 100644
index 000000000..a689e4825
--- /dev/null
+++ b/core/src/test/resources/bedrock-identity-v1/v1-linked-java-name-tampered.json
@@ -0,0 +1 @@
+{"version":1,"issuer":"minekube-connect","endpoint":{"id":"endpoint-id","name":"fixture-endpoint","org_id":"org-id"},"session":{"id":"session-id","protocol":"bedrock","issued_at_unix_ms":1783260000000,"expires_at_unix_ms":1783260300000,"nonce":"Zml4dHVyZS1ub25jZS0wMQ"},"policy":{"bedrock_auth_mode":"linked_java_only"},"principal":{"type":"bedrock_linked_java","bedrock_xuid":"2535460020985370","bedrock_username":"BedrockFox","bedrock_derived_uuid":"cafc7598-0ef3-527f-8f28-60af2d9ca6bc","linked_java_uuid":"c66dfcbc-4bd2-4a29-8c76-eadf80faa08a","linked_java_name":"ForgedName"},"signature":"N7rSui9ZkrF1GPO7vuikqm2Vhd84JEHZy6m8uhF8Mj74_VKcD_83NPYcKnbn-dloKmxt6MHz2MZ2BYT7dPsjAw"}
diff --git a/core/src/test/resources/bedrock-identity-v1/v1-linked-java-uuid-invalid.json b/core/src/test/resources/bedrock-identity-v1/v1-linked-java-uuid-invalid.json
new file mode 100644
index 000000000..37875585c
--- /dev/null
+++ b/core/src/test/resources/bedrock-identity-v1/v1-linked-java-uuid-invalid.json
@@ -0,0 +1 @@
+{"version":1,"issuer":"minekube-connect","endpoint":{"id":"endpoint-id","name":"fixture-endpoint","org_id":"org-id"},"session":{"id":"session-id","protocol":"bedrock","issued_at_unix_ms":1783260000000,"expires_at_unix_ms":1783260300000,"nonce":"Zml4dHVyZS1ub25jZS0wMQ"},"policy":{"bedrock_auth_mode":"linked_java_only"},"principal":{"type":"bedrock_linked_java","bedrock_xuid":"2535460020985370","bedrock_username":"BedrockFox","bedrock_derived_uuid":"cafc7598-0ef3-527f-8f28-60af2d9ca6bc","linked_java_uuid":"not-a-uuid","linked_java_name":"RoboFlax2"},"signature":"0GfEMqOWrlMIwxwD5X6i7Kg5ITtCb6uAMQUywhLGOwMiwP9ihcbA2A13FfjOoEDNU2fbuDVvghkK0sJU-6ncAQ"}
diff --git a/core/src/test/resources/bedrock-identity-v1/v1-overlong-lifetime.json b/core/src/test/resources/bedrock-identity-v1/v1-overlong-lifetime.json
new file mode 100644
index 000000000..98f4711f1
--- /dev/null
+++ b/core/src/test/resources/bedrock-identity-v1/v1-overlong-lifetime.json
@@ -0,0 +1 @@
+{"version":1,"issuer":"minekube-connect","endpoint":{"id":"endpoint-id","name":"fixture-endpoint","org_id":"org-id"},"session":{"id":"session-id","protocol":"bedrock","issued_at_unix_ms":1783260000000,"expires_at_unix_ms":1783260300001,"nonce":"Zml4dHVyZS1ub25jZS0wMQ"},"policy":{"bedrock_auth_mode":"trusted_bedrock_xuid"},"principal":{"type":"bedrock_xuid","bedrock_xuid":"2535460020985370","bedrock_username":"BedrockFox","bedrock_derived_uuid":"cafc7598-0ef3-527f-8f28-60af2d9ca6bc"},"signature":"MArA88FdoDsubpSR7U2ULYRCIuH1ORuZY7ChENfvy9sKmFwuft09s6hd_yBpCdCZjJEZz2cB56xSSlv_TynxDw"}
diff --git a/core/src/test/resources/bedrock-identity-v1/v1-username-missing.json b/core/src/test/resources/bedrock-identity-v1/v1-username-missing.json
new file mode 100644
index 000000000..01f203fc5
--- /dev/null
+++ b/core/src/test/resources/bedrock-identity-v1/v1-username-missing.json
@@ -0,0 +1 @@
+{"version":1,"issuer":"minekube-connect","endpoint":{"id":"endpoint-id","name":"fixture-endpoint","org_id":"org-id"},"session":{"id":"session-id","protocol":"bedrock","issued_at_unix_ms":1783260000000,"expires_at_unix_ms":1783260300000,"nonce":"Zml4dHVyZS1ub25jZS0wMQ"},"policy":{"bedrock_auth_mode":"trusted_bedrock_xuid"},"principal":{"type":"bedrock_xuid","bedrock_xuid":"2535460020985370","bedrock_derived_uuid":"cafc7598-0ef3-527f-8f28-60af2d9ca6bc"},"signature":"abyFUwgB5nSPZQigAYATFbGtsekME-cAeoDAKXoG56QKA3Ms0TpTDzW0SnAwAR09Wog6Jun1HciGUO1iwPIgDg"}
diff --git a/core/src/test/resources/bedrock-identity-v1/v1-version-unknown.json b/core/src/test/resources/bedrock-identity-v1/v1-version-unknown.json
new file mode 100644
index 000000000..3e1304482
--- /dev/null
+++ b/core/src/test/resources/bedrock-identity-v1/v1-version-unknown.json
@@ -0,0 +1 @@
+{"version":2,"issuer":"minekube-connect","endpoint":{"id":"endpoint-id","name":"fixture-endpoint","org_id":"org-id"},"session":{"id":"session-id","protocol":"bedrock","issued_at_unix_ms":1783260000000,"expires_at_unix_ms":1783260300000,"nonce":"Zml4dHVyZS1ub25jZS0wMQ"},"policy":{"bedrock_auth_mode":"trusted_bedrock_xuid"},"principal":{"type":"bedrock_xuid","bedrock_xuid":"2535460020985370","bedrock_username":"BedrockFox","bedrock_derived_uuid":"cafc7598-0ef3-527f-8f28-60af2d9ca6bc"},"signature":"YORnA4Eq67YH9dRre3L1B89SD7fkJ6C64dI15zhfnVCZl21mW0PCwPhnkLJpGYDPQshcio5bDUWKqQGf8vzpCw"}
diff --git a/core/src/test/resources/bedrock-identity-v1/v1-wrong-endpoint.json b/core/src/test/resources/bedrock-identity-v1/v1-wrong-endpoint.json
new file mode 100644
index 000000000..8c5c126b5
--- /dev/null
+++ b/core/src/test/resources/bedrock-identity-v1/v1-wrong-endpoint.json
@@ -0,0 +1 @@
+{"version":1,"issuer":"minekube-connect","endpoint":{"id":"other-endpoint-id","name":"fixture-endpoint","org_id":"org-id"},"session":{"id":"session-id","protocol":"bedrock","issued_at_unix_ms":1783260000000,"expires_at_unix_ms":1783260300000,"nonce":"Zml4dHVyZS1ub25jZS0wMQ"},"policy":{"bedrock_auth_mode":"trusted_bedrock_xuid"},"principal":{"type":"bedrock_xuid","bedrock_xuid":"2535460020985370","bedrock_username":"BedrockFox","bedrock_derived_uuid":"cafc7598-0ef3-527f-8f28-60af2d9ca6bc"},"signature":"izu6etz0jkB-EC9hxXXEV_7KN9yQPfqtWBMfNuEI3dvIs1PosQTjrbe5ZajvWMSBf0tG1QPXWxedyT1VetS3Dw"}
diff --git a/core/src/test/resources/bedrock-identity-v1/v1-wrong-issuer.json b/core/src/test/resources/bedrock-identity-v1/v1-wrong-issuer.json
new file mode 100644
index 000000000..43d11da3c
--- /dev/null
+++ b/core/src/test/resources/bedrock-identity-v1/v1-wrong-issuer.json
@@ -0,0 +1 @@
+{"version":1,"issuer":"other-issuer","endpoint":{"id":"endpoint-id","name":"fixture-endpoint","org_id":"org-id"},"session":{"id":"session-id","protocol":"bedrock","issued_at_unix_ms":1783260000000,"expires_at_unix_ms":1783260300000,"nonce":"Zml4dHVyZS1ub25jZS0wMQ"},"policy":{"bedrock_auth_mode":"trusted_bedrock_xuid"},"principal":{"type":"bedrock_xuid","bedrock_xuid":"2535460020985370","bedrock_username":"BedrockFox","bedrock_derived_uuid":"cafc7598-0ef3-527f-8f28-60af2d9ca6bc"},"signature":"HF2ugEK0mwzEon2PNhcVvuKth33fWiaAYNo3FngajmugzfG-bChKDXES5RMgKTWdRmNEy7KhhKq0F7ed8UNjAg"}
diff --git a/core/src/test/resources/bedrock-identity-v1/v1-wrong-org.json b/core/src/test/resources/bedrock-identity-v1/v1-wrong-org.json
new file mode 100644
index 000000000..477ef2c4c
--- /dev/null
+++ b/core/src/test/resources/bedrock-identity-v1/v1-wrong-org.json
@@ -0,0 +1 @@
+{"version":1,"issuer":"minekube-connect","endpoint":{"id":"endpoint-id","name":"fixture-endpoint","org_id":"other-org-id"},"session":{"id":"session-id","protocol":"bedrock","issued_at_unix_ms":1783260000000,"expires_at_unix_ms":1783260300000,"nonce":"Zml4dHVyZS1ub25jZS0wMQ"},"policy":{"bedrock_auth_mode":"trusted_bedrock_xuid"},"principal":{"type":"bedrock_xuid","bedrock_xuid":"2535460020985370","bedrock_username":"BedrockFox","bedrock_derived_uuid":"cafc7598-0ef3-527f-8f28-60af2d9ca6bc"},"signature":"OH3wdOavvN29XxzlwfYxBoOcJW7otb9vN3EtwIApNbJqeis655e7e0uNGRVULKemZlvyCjNfY8JqlRPw4WAQAg"}
diff --git a/core/src/test/resources/bedrock-identity-v1/v1-wrong-policy.json b/core/src/test/resources/bedrock-identity-v1/v1-wrong-policy.json
new file mode 100644
index 000000000..675b4f1da
--- /dev/null
+++ b/core/src/test/resources/bedrock-identity-v1/v1-wrong-policy.json
@@ -0,0 +1 @@
+{"version":1,"issuer":"minekube-connect","endpoint":{"id":"endpoint-id","name":"fixture-endpoint","org_id":"org-id"},"session":{"id":"session-id","protocol":"bedrock","issued_at_unix_ms":1783260000000,"expires_at_unix_ms":1783260300000,"nonce":"Zml4dHVyZS1ub25jZS0wMQ"},"policy":{"bedrock_auth_mode":"linked_java_only"},"principal":{"type":"bedrock_xuid","bedrock_xuid":"2535460020985370","bedrock_username":"BedrockFox","bedrock_derived_uuid":"cafc7598-0ef3-527f-8f28-60af2d9ca6bc"},"signature":"LsbjGmRBRGz9hRDUrAvn8jBSUPqiFH8R-9cYK4Xc05anwVK2t9P_VYuM0zxknrXXN287qMMqG3BYZOMOEmXjCA"}
diff --git a/core/src/test/resources/bedrock-identity-v1/v1-wrong-protocol.json b/core/src/test/resources/bedrock-identity-v1/v1-wrong-protocol.json
new file mode 100644
index 000000000..d4af98312
--- /dev/null
+++ b/core/src/test/resources/bedrock-identity-v1/v1-wrong-protocol.json
@@ -0,0 +1 @@
+{"version":1,"issuer":"minekube-connect","endpoint":{"id":"endpoint-id","name":"fixture-endpoint","org_id":"org-id"},"session":{"id":"session-id","protocol":"java","issued_at_unix_ms":1783260000000,"expires_at_unix_ms":1783260300000,"nonce":"Zml4dHVyZS1ub25jZS0wMQ"},"policy":{"bedrock_auth_mode":"trusted_bedrock_xuid"},"principal":{"type":"bedrock_xuid","bedrock_xuid":"2535460020985370","bedrock_username":"BedrockFox","bedrock_derived_uuid":"cafc7598-0ef3-527f-8f28-60af2d9ca6bc"},"signature":"5SFWqtplAqiQhSxSnp-pCRAaZxaoDDoCTWS75BDuDDbLU3oaf80neTxEwr1_x_B-ux5gkVGlKc0vi0VlWmV2CA"}
diff --git a/core/src/test/resources/bedrock-identity-v1/v1-wrong-session.json b/core/src/test/resources/bedrock-identity-v1/v1-wrong-session.json
new file mode 100644
index 000000000..64be5535d
--- /dev/null
+++ b/core/src/test/resources/bedrock-identity-v1/v1-wrong-session.json
@@ -0,0 +1 @@
+{"version":1,"issuer":"minekube-connect","endpoint":{"id":"endpoint-id","name":"fixture-endpoint","org_id":"org-id"},"session":{"id":"other-session-id","protocol":"bedrock","issued_at_unix_ms":1783260000000,"expires_at_unix_ms":1783260300000,"nonce":"Zml4dHVyZS1ub25jZS0wMQ"},"policy":{"bedrock_auth_mode":"trusted_bedrock_xuid"},"principal":{"type":"bedrock_xuid","bedrock_xuid":"2535460020985370","bedrock_username":"BedrockFox","bedrock_derived_uuid":"cafc7598-0ef3-527f-8f28-60af2d9ca6bc"},"signature":"3kQSZnP_38ZszKnX_y0Jj2dKnM83jwNE4U4RF5Mj19sWZfwK4Ez24WI4tAIGpRfUlNwApfiN4ZShsxzJcwAPBw"}
diff --git a/core/src/test/resources/bedrock-identity-v1/v1-xuid-leading-zero.json b/core/src/test/resources/bedrock-identity-v1/v1-xuid-leading-zero.json
new file mode 100644
index 000000000..7732ca143
--- /dev/null
+++ b/core/src/test/resources/bedrock-identity-v1/v1-xuid-leading-zero.json
@@ -0,0 +1 @@
+{"version":1,"issuer":"minekube-connect","endpoint":{"id":"endpoint-id","name":"fixture-endpoint","org_id":"org-id"},"session":{"id":"session-id","protocol":"bedrock","issued_at_unix_ms":1783260000000,"expires_at_unix_ms":1783260300000,"nonce":"Zml4dHVyZS1ub25jZS0wMQ"},"policy":{"bedrock_auth_mode":"trusted_bedrock_xuid"},"principal":{"type":"bedrock_xuid","bedrock_xuid":"02535460020985370","bedrock_username":"BedrockFox","bedrock_derived_uuid":"cafc7598-0ef3-527f-8f28-60af2d9ca6bc"},"signature":"lZBW3hsYtCi3Datm3W2C0HK5IxQsIF8TlPd0LBwL7luVJEgqv4mcjjH4XOTRVzAwkPkjT0CKE03gfZDbCIQ0CA"}
diff --git a/core/src/test/resources/bedrock-identity-v1/v1-xuid-missing.json b/core/src/test/resources/bedrock-identity-v1/v1-xuid-missing.json
new file mode 100644
index 000000000..fe84aa697
--- /dev/null
+++ b/core/src/test/resources/bedrock-identity-v1/v1-xuid-missing.json
@@ -0,0 +1 @@
+{"version":1,"issuer":"minekube-connect","endpoint":{"id":"endpoint-id","name":"fixture-endpoint","org_id":"org-id"},"session":{"id":"session-id","protocol":"bedrock","issued_at_unix_ms":1783260000000,"expires_at_unix_ms":1783260300000,"nonce":"Zml4dHVyZS1ub25jZS0wMQ"},"policy":{"bedrock_auth_mode":"trusted_bedrock_xuid"},"principal":{"type":"bedrock_xuid","bedrock_username":"BedrockFox","bedrock_derived_uuid":"cafc7598-0ef3-527f-8f28-60af2d9ca6bc"},"signature":"HbWVB7KkUB8nr5FXv1Qsw8HipGLlNZto6ULC7rMn2yvV_MI-jJO8ZW_P2U6azOr0ZFyj8w_edp3fxoZ0MWeeCQ"}
diff --git a/core/src/test/resources/bedrock-identity-v1/v1-xuid-overflow.json b/core/src/test/resources/bedrock-identity-v1/v1-xuid-overflow.json
new file mode 100644
index 000000000..6eacc6b87
--- /dev/null
+++ b/core/src/test/resources/bedrock-identity-v1/v1-xuid-overflow.json
@@ -0,0 +1 @@
+{"version":1,"issuer":"minekube-connect","endpoint":{"id":"endpoint-id","name":"fixture-endpoint","org_id":"org-id"},"session":{"id":"session-id","protocol":"bedrock","issued_at_unix_ms":1783260000000,"expires_at_unix_ms":1783260300000,"nonce":"Zml4dHVyZS1ub25jZS0wMQ"},"policy":{"bedrock_auth_mode":"trusted_bedrock_xuid"},"principal":{"type":"bedrock_xuid","bedrock_xuid":"9223372036854775808","bedrock_username":"BedrockFox","bedrock_derived_uuid":"cafc7598-0ef3-527f-8f28-60af2d9ca6bc"},"signature":"T0X5JgeYSOGbnfsolaNeKjNqMUSrqxWCj2UcV9eB4LMTGwvWLyb_hiafX4l-hgEWlR-eF8cU9OS51vz4hq6UAQ"}
diff --git a/core/src/test/resources/bedrock-identity-v1/v1-xuid-tampered.json b/core/src/test/resources/bedrock-identity-v1/v1-xuid-tampered.json
new file mode 100644
index 000000000..6bacabd6a
--- /dev/null
+++ b/core/src/test/resources/bedrock-identity-v1/v1-xuid-tampered.json
@@ -0,0 +1 @@
+{"version":1,"issuer":"minekube-connect","endpoint":{"id":"endpoint-id","name":"fixture-endpoint","org_id":"org-id"},"session":{"id":"session-id","protocol":"bedrock","issued_at_unix_ms":1783260000000,"expires_at_unix_ms":1783260300000,"nonce":"Zml4dHVyZS1ub25jZS0wMQ"},"policy":{"bedrock_auth_mode":"trusted_bedrock_xuid"},"principal":{"type":"bedrock_xuid","bedrock_xuid":"2535460020985371","bedrock_username":"BedrockFox","bedrock_derived_uuid":"cafc7598-0ef3-527f-8f28-60af2d9ca6bc"},"signature":"lE_3F4QDD4gXthyYPAjR4_waY13_God5RweOCm14kZdi5UFZmsWWFhlJXGq9XNH1BjoKg3t0S9mPz69g9P3lDA"}
diff --git a/spigot/src/main/java/com/minekube/connect/SpigotPlatform.java b/spigot/src/main/java/com/minekube/connect/SpigotPlatform.java
index 6af100a16..5cc901f33 100644
--- a/spigot/src/main/java/com/minekube/connect/SpigotPlatform.java
+++ b/spigot/src/main/java/com/minekube/connect/SpigotPlatform.java
@@ -31,18 +31,25 @@
import com.minekube.connect.api.ConnectApi;
import com.minekube.connect.api.inject.PlatformInjector;
import com.minekube.connect.api.logger.ConnectLogger;
+import com.minekube.connect.bedrock.BedrockAdmissionCoordinator;
import org.bukkit.Bukkit;
import org.bukkit.plugin.java.JavaPlugin;
public final class SpigotPlatform extends ConnectPlatform {
@Inject private JavaPlugin plugin;
- @Inject
public SpigotPlatform(ConnectApi api, PlatformInjector platformInjector,
ConnectLogger logger, Injector injector) {
super(api, platformInjector, logger, injector);
}
+ @Inject
+ public SpigotPlatform(ConnectApi api, PlatformInjector platformInjector,
+ ConnectLogger logger, Injector injector,
+ BedrockAdmissionCoordinator admissionCoordinator) {
+ super(api, platformInjector, logger, injector, admissionCoordinator);
+ }
+
@Override
public boolean enable(Module... postInitializeModules) {
boolean success = super.enable(postInitializeModules);
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 6a147c10a..ca29e0a2d 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,7 @@
import com.google.gson.Gson;
import com.minekube.connect.SpigotPlugin;
import com.minekube.connect.api.logger.ConnectLogger;
+import com.minekube.connect.api.player.bedrock.BedrockIdentityProfiles;
import com.minekube.connect.bedrock.BedrockIdentityEnforcer;
import com.minekube.connect.bedrock.BedrockIdentityEnforcer.Decision;
import com.minekube.connect.config.ConnectConfig;
@@ -265,11 +266,15 @@ private String createBungeeForwardingAddress(String virtualHost) {
.append('\0')
.append(sessionCtx.getPlayer().getUniqueId().toString().replaceAll("-", ""))
.append('\0');
- GSON.toJson(UnaryOperator.identity().apply(
- sessionCtx.getPlayer().getGameProfile().getProperties()), data);
+ data.append(forwardedPropertiesJson(sessionCtx.getPlayer().getGameProfile()));
return data.toString();
}
+ static String forwardedPropertiesJson(com.minekube.connect.api.player.GameProfile profile) {
+ return GSON.toJson(UnaryOperator.identity().apply(
+ BedrockIdentityProfiles.withoutEnvelope(profile).getProperties()));
+ }
+
private String getPlayerRemoteAddressAsString() {
return playerRemoteAddressAsString(
ctx.channel().remoteAddress(),
diff --git a/spigot/src/main/java/com/minekube/connect/inject/spigot/SpigotInjector.java b/spigot/src/main/java/com/minekube/connect/inject/spigot/SpigotInjector.java
index 0e865c205..f4500573c 100644
--- a/spigot/src/main/java/com/minekube/connect/inject/spigot/SpigotInjector.java
+++ b/spigot/src/main/java/com/minekube/connect/inject/spigot/SpigotInjector.java
@@ -26,6 +26,8 @@
package com.minekube.connect.inject.spigot;
import com.minekube.connect.api.logger.ConnectLogger;
+import com.minekube.connect.bedrock.BedrockIdentityEnforcer;
+import com.minekube.connect.bedrock.BedrockIdentityEnforcer.Decision;
import com.minekube.connect.inject.CommonPlatformInjector;
import com.minekube.connect.network.netty.LocalServerChannelWrapper;
import com.minekube.connect.network.netty.LocalSession;
@@ -52,12 +54,12 @@
import java.util.List;
import java.util.NoSuchElementException;
import lombok.Getter;
-import lombok.RequiredArgsConstructor;
import org.checkerframework.checker.nullness.qual.NonNull;
-@RequiredArgsConstructor
public final class SpigotInjector extends CommonPlatformInjector {
private final ConnectLogger logger;
+ private final BedrockIdentityEnforcer bedrockIdentityEnforcer;
+ private final String packetHandlerName;
/**
* Used to determine if ViaVersion is set up to a state where Connect players will fail at
* joining if injection is enabled
@@ -72,6 +74,21 @@ public final class SpigotInjector extends CommonPlatformInjector {
@Getter private boolean injected;
+ public SpigotInjector(ConnectLogger logger, boolean isViaVersion) {
+ this(logger, null, null, isViaVersion);
+ }
+
+ public SpigotInjector(
+ ConnectLogger logger,
+ BedrockIdentityEnforcer bedrockIdentityEnforcer,
+ String packetHandlerName,
+ boolean isViaVersion) {
+ this.logger = logger;
+ this.bedrockIdentityEnforcer = bedrockIdentityEnforcer;
+ this.packetHandlerName = packetHandlerName;
+ this.isViaVersion = isViaVersion;
+ }
+
// We need to disable the enforceSecureProfile in server.properties.
// This has no effect if the server is already in offline mode.
// private static void disableEnforceSecureProfile() {
@@ -154,16 +171,21 @@ public void injectClient(ChannelFuture future) {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
Channel channel = (Channel) msg;
- // only need to inject if is a local session & auth passthrough is disabled
LocalSession.context(channel)
- .filter(context -> !context.getPlayer().getAuth().isPassthrough())
- .ifPresent($ -> channel.pipeline().addLast("connect-injector",
+ .ifPresent(context -> channel.pipeline().addLast("connect-injector",
new ChannelInboundHandlerAdapter() {
@Override
public void channelActive(ChannelHandlerContext childCtx)
throws Exception {
- injectAddonsCall(childCtx.channel(), false);
- addInjectedClient(childCtx.channel());
+ if (context.getPlayer().getAuth().isPassthrough()) {
+ if (bedrockIdentityEnforcer != null && packetHandlerName != null) {
+ addPassthroughAdmissionHandler(childCtx.channel(), context);
+ trackPassthroughClient(childCtx.channel());
+ }
+ } else {
+ injectAddonsCall(childCtx.channel(), false);
+ addInjectedClient(childCtx.channel());
+ }
childCtx.pipeline().remove(this);
super.channelActive(childCtx);
}
@@ -173,6 +195,35 @@ public void channelActive(ChannelHandlerContext childCtx)
});
}
+ boolean trackPassthroughClient(Channel channel) {
+ boolean added = addInjectedClient(channel);
+ if (added) {
+ channel.closeFuture().addListener(ignored -> removeInjectedClient(channel));
+ }
+ return added;
+ }
+
+ private void addPassthroughAdmissionHandler(Channel channel, LocalSession.Context sessionContext) {
+ channel.pipeline().addBefore(
+ packetHandlerName,
+ "connect-bedrock-identity",
+ new ChannelInboundHandlerAdapter() {
+ @Override
+ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
+ if (ClassNames.LOGIN_START_PACKET.isInstance(msg)) {
+ Decision decision = bedrockIdentityEnforcer.verify(sessionContext);
+ ctx.pipeline().remove(this);
+ if (!decision.allowed()) {
+ bedrockIdentityEnforcer.reject(sessionContext, decision);
+ ctx.close();
+ return;
+ }
+ }
+ super.channelRead(ctx, msg);
+ }
+ });
+ }
+
public Object getServerConnection() throws IllegalAccessException, InvocationTargetException {
if (serverConnection != null) {
return serverConnection;
diff --git a/spigot/src/main/java/com/minekube/connect/listener/PaperProfileProperties.java b/spigot/src/main/java/com/minekube/connect/listener/PaperProfileProperties.java
index 6338df844..32c6083df 100644
--- a/spigot/src/main/java/com/minekube/connect/listener/PaperProfileProperties.java
+++ b/spigot/src/main/java/com/minekube/connect/listener/PaperProfileProperties.java
@@ -26,6 +26,7 @@
package com.minekube.connect.listener;
import com.destroystokyo.paper.profile.ProfileProperty;
+import com.minekube.connect.api.player.bedrock.BedrockIdentityProfiles;
import java.util.HashSet;
import java.util.Set;
@@ -35,7 +36,8 @@ private PaperProfileProperties() {
static Set fromConnectProfile(com.minekube.connect.api.player.GameProfile connectProfile) {
Set properties = new HashSet<>();
- for (com.minekube.connect.api.player.GameProfile.Property property : connectProfile.getProperties()) {
+ for (com.minekube.connect.api.player.GameProfile.Property property :
+ BedrockIdentityProfiles.withoutEnvelope(connectProfile).getProperties()) {
String signature = property.getSignature();
properties.add(signature == null || signature.isEmpty()
? new ProfileProperty(property.getName(), property.getValue())
diff --git a/spigot/src/main/java/com/minekube/connect/module/SpigotPlatformModule.java b/spigot/src/main/java/com/minekube/connect/module/SpigotPlatformModule.java
index 343b113f6..241aea992 100644
--- a/spigot/src/main/java/com/minekube/connect/module/SpigotPlatformModule.java
+++ b/spigot/src/main/java/com/minekube/connect/module/SpigotPlatformModule.java
@@ -34,6 +34,7 @@
import com.minekube.connect.SpigotPlugin;
import com.minekube.connect.api.ConnectApi;
import com.minekube.connect.api.logger.ConnectLogger;
+import com.minekube.connect.bedrock.BedrockIdentityEnforcer;
import com.minekube.connect.inject.CommonPlatformInjector;
import com.minekube.connect.inject.spigot.SpigotInjector;
import com.minekube.connect.listener.SpigotListenerRegistration;
@@ -101,7 +102,10 @@ public ListenerRegistration listenerRegistration() {
@Provides
@Singleton
- public CommonPlatformInjector platformInjector(ConnectLogger logger) {
+ public CommonPlatformInjector platformInjector(
+ ConnectLogger logger,
+ BedrockIdentityEnforcer bedrockIdentityEnforcer,
+ @Named("packetHandler") String packetHandlerName) {
final String VIAVERSION_DOWNLOAD_URL = "https://ci.viaversion.com/job/ViaVersion/";
boolean isViaVersion = Bukkit.getPluginManager().getPlugin("ViaVersion") != null;
if (isViaVersion) {
@@ -114,7 +118,7 @@ public CommonPlatformInjector platformInjector(ConnectLogger logger) {
}
}
- return new SpigotInjector(logger, isViaVersion);
+ return new SpigotInjector(logger, bedrockIdentityEnforcer, packetHandlerName, isViaVersion);
}
@Provides
diff --git a/spigot/src/main/java/com/minekube/connect/util/SpigotGameProfiles.java b/spigot/src/main/java/com/minekube/connect/util/SpigotGameProfiles.java
index 01883982c..3f769a65d 100644
--- a/spigot/src/main/java/com/minekube/connect/util/SpigotGameProfiles.java
+++ b/spigot/src/main/java/com/minekube/connect/util/SpigotGameProfiles.java
@@ -27,6 +27,7 @@
import com.mojang.authlib.GameProfile;
import com.mojang.authlib.properties.Property;
+import com.minekube.connect.api.player.bedrock.BedrockIdentityProfiles;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
@@ -37,6 +38,7 @@ private SpigotGameProfiles() {
}
public static GameProfile fromConnectProfile(com.minekube.connect.api.player.GameProfile connectProfile) {
+ connectProfile = BedrockIdentityProfiles.withoutEnvelope(connectProfile);
GameProfile profile = newGameProfile(connectProfile);
if (profile != null) {
return profile;
diff --git a/spigot/src/test/java/com/minekube/connect/SpigotPlatformConstructorTest.java b/spigot/src/test/java/com/minekube/connect/SpigotPlatformConstructorTest.java
new file mode 100644
index 000000000..78831dd94
--- /dev/null
+++ b/spigot/src/test/java/com/minekube/connect/SpigotPlatformConstructorTest.java
@@ -0,0 +1,41 @@
+package com.minekube.connect;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.minekube.connect.api.ConnectApi;
+import com.minekube.connect.api.inject.PlatformInjector;
+import com.minekube.connect.api.logger.ConnectLogger;
+import com.minekube.connect.bedrock.BedrockAdmissionCoordinator;
+import com.minekube.connect.bedrock.VerifiedBedrockIdentityRegistry;
+import java.lang.reflect.Constructor;
+import org.junit.jupiter.api.Test;
+
+class SpigotPlatformConstructorTest {
+ @Test
+ void retainsFourArgumentConstruction() {
+ VerifiedBedrockIdentityRegistry registry = new VerifiedBedrockIdentityRegistry();
+ BedrockAdmissionCoordinator coordinator = new BedrockAdmissionCoordinator(registry);
+ Injector injector = Guice.createInjector(
+ binder -> binder.bind(BedrockAdmissionCoordinator.class).toInstance(coordinator));
+ try {
+ assertNotNull(new SpigotPlatform(null, null, null, injector));
+ } finally {
+ coordinator.close();
+ }
+ }
+
+ @Test
+ void injectsLifecycleOwnedCoordinator() throws Exception {
+ Constructor constructor = SpigotPlatform.class.getConstructor(
+ ConnectApi.class,
+ PlatformInjector.class,
+ ConnectLogger.class,
+ Injector.class,
+ BedrockAdmissionCoordinator.class);
+
+ assertNotNull(constructor.getAnnotation(Inject.class));
+ }
+}
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 171df5401..938f8b915 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
@@ -2,11 +2,15 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import com.minekube.connect.api.player.GameProfile;
import io.netty.channel.embedded.EmbeddedChannel;
import io.netty.channel.local.LocalAddress;
import java.lang.reflect.Method;
import java.net.InetSocketAddress;
+import java.util.Arrays;
+import java.util.UUID;
import org.junit.jupiter.api.Test;
class SpigotDataHandlerTest {
@@ -31,4 +35,28 @@ void removeSelfIsIdempotentWhenHandshakeReplacementReentersPipeline() throws Exc
assertDoesNotThrow(() -> removeSelf.invoke(handler));
channel.finishAndReleaseAll();
}
+
+ @Test
+ void bungeeForwardedPropertiesExcludeIdentityEnvelopeAndNonce() {
+ GameProfile profile = new GameProfile(
+ "BedrockSteve",
+ UUID.fromString("f912bf90-8349-565f-9dc0-9891923c0cc3"),
+ Arrays.asList(
+ new GameProfile.Property("textures", "skin", "signature"),
+ new GameProfile.Property(
+ "minekube:bedrock_identity",
+ "signed-envelope-replay-nonce-a",
+ ""),
+ new GameProfile.Property(
+ "minekube:bedrock_identity_scope",
+ "private-endpoint-id",
+ "")));
+
+ String properties = SpigotDataHandler.forwardedPropertiesJson(profile);
+
+ assertFalse(properties.contains("minekube:bedrock_identity"));
+ assertFalse(properties.contains("minekube:bedrock_identity_scope"));
+ assertFalse(properties.contains("replay-nonce-a"));
+ assertFalse(properties.contains("private-endpoint-id"));
+ }
}
diff --git a/spigot/src/test/java/com/minekube/connect/addon/data/SpigotGameProfilesTest.java b/spigot/src/test/java/com/minekube/connect/addon/data/SpigotGameProfilesTest.java
index f7ca6e92d..9cadff639 100644
--- a/spigot/src/test/java/com/minekube/connect/addon/data/SpigotGameProfilesTest.java
+++ b/spigot/src/test/java/com/minekube/connect/addon/data/SpigotGameProfilesTest.java
@@ -26,12 +26,13 @@
package com.minekube.connect.addon.data;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.minekube.connect.api.player.GameProfile;
import com.minekube.connect.util.SpigotGameProfiles;
import com.mojang.authlib.properties.Property;
-import java.util.Collections;
+import java.util.Arrays;
import java.util.UUID;
import org.junit.jupiter.api.Test;
@@ -42,7 +43,16 @@ void copiesSignedTexturePropertyFromConnectProfile() {
GameProfile connectProfile = new GameProfile(
"RoboFlax2",
uuid,
- Collections.singletonList(new GameProfile.Property("textures", "skin-value", "skin-signature"))
+ Arrays.asList(
+ new GameProfile.Property("textures", "skin-value", "skin-signature"),
+ new GameProfile.Property(
+ "minekube:bedrock_identity",
+ "signed-envelope-replay-nonce-a",
+ ""),
+ new GameProfile.Property(
+ "minekube:bedrock_identity_scope",
+ "private-endpoint-id",
+ ""))
);
com.mojang.authlib.GameProfile profile = SpigotGameProfiles.fromConnectProfile(connectProfile);
@@ -53,5 +63,9 @@ void copiesSignedTexturePropertyFromConnectProfile() {
assertEquals("skin-value", texture.getValue());
assertEquals("skin-signature", texture.getSignature());
assertTrue(texture.hasSignature());
+ assertFalse(profile.getProperties().containsKey("minekube:bedrock_identity"));
+ assertFalse(profile.getProperties().containsKey("minekube:bedrock_identity_scope"));
+ assertFalse(profile.toString().contains("replay-nonce-a"));
+ assertFalse(profile.toString().contains("private-endpoint-id"));
}
}
diff --git a/spigot/src/test/java/com/minekube/connect/inject/spigot/SpigotInjectorTest.java b/spigot/src/test/java/com/minekube/connect/inject/spigot/SpigotInjectorTest.java
new file mode 100644
index 000000000..6d030f996
--- /dev/null
+++ b/spigot/src/test/java/com/minekube/connect/inject/spigot/SpigotInjectorTest.java
@@ -0,0 +1,32 @@
+package com.minekube.connect.inject.spigot;
+
+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 com.minekube.connect.api.logger.ConnectLogger;
+import io.netty.channel.embedded.EmbeddedChannel;
+import java.lang.reflect.Constructor;
+import org.junit.jupiter.api.Test;
+
+class SpigotInjectorTest {
+ @Test
+ void retainsLegacyTwoArgumentConstructor() throws Exception {
+ Constructor constructor = SpigotInjector.class.getConstructor(
+ ConnectLogger.class, boolean.class);
+
+ assertNotNull(constructor.newInstance(new Object[] {null, false}));
+ }
+
+ @Test
+ void passthroughChannelTrackingIsRemovedOnDisconnect() {
+ SpigotInjector injector = new SpigotInjector(null, null, "packet_handler", false);
+ EmbeddedChannel channel = new EmbeddedChannel();
+
+ assertTrue(injector.trackPassthroughClient(channel));
+
+ channel.close().syncUninterruptibly();
+
+ assertFalse(injector.removeInjectedClient(channel));
+ }
+}
diff --git a/spigot/src/test/java/com/minekube/connect/listener/PaperProfilePropertiesTest.java b/spigot/src/test/java/com/minekube/connect/listener/PaperProfilePropertiesTest.java
index 61faa60ea..867a9cb52 100644
--- a/spigot/src/test/java/com/minekube/connect/listener/PaperProfilePropertiesTest.java
+++ b/spigot/src/test/java/com/minekube/connect/listener/PaperProfilePropertiesTest.java
@@ -26,11 +26,12 @@
package com.minekube.connect.listener;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.destroystokyo.paper.profile.ProfileProperty;
import com.minekube.connect.api.player.GameProfile;
-import java.util.Collections;
+import java.util.Arrays;
import java.util.UUID;
import org.junit.jupiter.api.Test;
@@ -40,7 +41,16 @@ void convertsSignedTexturePropertyForPaperProfilePrefill() {
GameProfile connectProfile = new GameProfile(
"RoboFlax2",
UUID.fromString("c66dfcbc-4bd2-4a29-8c76-eadf80faa08a"),
- Collections.singletonList(new GameProfile.Property("textures", "skin-value", "skin-signature"))
+ Arrays.asList(
+ new GameProfile.Property("textures", "skin-value", "skin-signature"),
+ new GameProfile.Property(
+ "minekube:bedrock_identity",
+ "signed-envelope-replay-nonce-a",
+ ""),
+ new GameProfile.Property(
+ "minekube:bedrock_identity_scope",
+ "private-endpoint-id",
+ ""))
);
ProfileProperty texture = PaperProfileProperties.fromConnectProfile(connectProfile)
@@ -51,5 +61,13 @@ void convertsSignedTexturePropertyForPaperProfilePrefill() {
assertEquals("skin-value", texture.getValue());
assertEquals("skin-signature", texture.getSignature());
assertTrue(texture.isSigned());
+ assertFalse(PaperProfileProperties.fromConnectProfile(connectProfile).stream()
+ .anyMatch(property -> "minekube:bedrock_identity".equals(property.getName())));
+ assertFalse(PaperProfileProperties.fromConnectProfile(connectProfile).stream()
+ .anyMatch(property -> "minekube:bedrock_identity_scope".equals(property.getName())));
+ assertFalse(PaperProfileProperties.fromConnectProfile(connectProfile).toString()
+ .contains("replay-nonce-a"));
+ assertFalse(PaperProfileProperties.fromConnectProfile(connectProfile).toString()
+ .contains("private-endpoint-id"));
}
}
diff --git a/velocity/src/main/java/com/minekube/connect/listener/VelocityGameProfiles.java b/velocity/src/main/java/com/minekube/connect/listener/VelocityGameProfiles.java
new file mode 100644
index 000000000..7f09a447c
--- /dev/null
+++ b/velocity/src/main/java/com/minekube/connect/listener/VelocityGameProfiles.java
@@ -0,0 +1,35 @@
+package com.minekube.connect.listener;
+
+import com.minekube.connect.api.player.ConnectPlayer;
+import com.minekube.connect.api.player.bedrock.BedrockIdentityProfiles;
+import com.minekube.connect.api.player.bedrock.BedrockIdentityVerifier;
+import com.velocitypowered.api.util.GameProfile;
+import com.velocitypowered.api.util.GameProfile.Property;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+final class VelocityGameProfiles {
+ private VelocityGameProfiles() {
+ }
+
+ static GameProfile fromConnectPlayer(GameProfile base, ConnectPlayer player) {
+ List properties = Stream.concat(
+ base.getProperties().stream()
+ .filter(property -> !isPrivateIdentity(property)),
+ BedrockIdentityProfiles.withoutEnvelope(player.getGameProfile()).getProperties().stream()
+ .map(property -> new Property(
+ property.getName(),
+ property.getValue(),
+ property.getSignature())))
+ .collect(Collectors.toList());
+ return base.withId(player.getUniqueId())
+ .withName(player.getUsername())
+ .withProperties(properties);
+ }
+
+ private static boolean isPrivateIdentity(Property property) {
+ return BedrockIdentityVerifier.PROPERTY_NAME.equals(property.getName()) ||
+ BedrockIdentityProfiles.SCOPE_PROPERTY_NAME.equals(property.getName());
+ }
+}
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 14293caaf..5580391a0 100644
--- a/velocity/src/main/java/com/minekube/connect/listener/VelocityListener.java
+++ b/velocity/src/main/java/com/minekube/connect/listener/VelocityListener.java
@@ -50,12 +50,10 @@
import com.velocitypowered.api.event.player.GameProfileRequestEvent;
import com.velocitypowered.api.proxy.InboundConnection;
import com.velocitypowered.api.util.GameProfile;
-import com.velocitypowered.api.util.GameProfile.Property;
import io.netty.channel.Channel;
import java.lang.reflect.Field;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
-import java.util.stream.Collectors;
import net.kyori.adventure.text.Component;
public final class VelocityListener {
@@ -109,14 +107,14 @@ public void onPreLogin(PreLoginEvent event) {
Channel channel = getCastedValue(mcConnection, CHANNEL);
LocalSession.context(channel, ctx -> {
+ Decision decision = bedrockIdentityEnforcer.verify(ctx);
+ if (!decision.allowed()) {
+ bedrockIdentityEnforcer.reject(ctx, decision);
+ event.setResult(PreLoginEvent.PreLoginComponentResult.denied(
+ Component.text(decision.message())));
+ return;
+ }
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());
@@ -136,19 +134,10 @@ public void onGameProfileRequest(GameProfileRequestEvent event) {
if (player != null) {
playerCache.invalidate(event.getConnection());
// Use the game profile received from WatchService for this connection
- event.setGameProfile(gameProfileFromPlayer(event.getGameProfile(), player));
+ event.setGameProfile(VelocityGameProfiles.fromConnectPlayer(event.getGameProfile(), player));
}
}
- private GameProfile gameProfileFromPlayer(GameProfile base, ConnectPlayer player) {
- return base
- .withId(player.getUniqueId())
- .withName(player.getUsername())
- .addProperties(player.getGameProfile().getProperties().stream()
- .map(p -> new Property(p.getName(), p.getValue(), p.getSignature()))
- .collect(Collectors.toList()));
- }
-
@Subscribe(order = PostOrder.LAST)
public void onLogin(LoginEvent event) {
if (event.getResult().isAllowed()) {
diff --git a/velocity/src/test/java/com/minekube/connect/listener/VelocityGameProfilesTest.java b/velocity/src/test/java/com/minekube/connect/listener/VelocityGameProfilesTest.java
new file mode 100644
index 000000000..248455376
--- /dev/null
+++ b/velocity/src/test/java/com/minekube/connect/listener/VelocityGameProfilesTest.java
@@ -0,0 +1,65 @@
+package com.minekube.connect.listener;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+import com.minekube.connect.api.player.Auth;
+import com.minekube.connect.api.player.ConnectPlayer;
+import com.minekube.connect.api.player.bedrock.BedrockIdentityVerifier;
+import com.minekube.connect.player.ConnectPlayerImpl;
+import com.velocitypowered.api.util.GameProfile;
+import com.velocitypowered.api.util.GameProfile.Property;
+import java.util.Arrays;
+import java.util.UUID;
+import org.junit.jupiter.api.Test;
+
+class VelocityGameProfilesTest {
+ @Test
+ void forwardedProfileExcludesIdentityEnvelopeAndNonceFromEverySource() {
+ UUID uuid = UUID.fromString("f912bf90-8349-565f-9dc0-9891923c0cc3");
+ GameProfile base = new GameProfile(
+ UUID.randomUUID(),
+ "Original",
+ Arrays.asList(
+ new Property("base", "value", ""),
+ new Property(
+ BedrockIdentityVerifier.PROPERTY_NAME,
+ "base-envelope-replay-nonce-a",
+ ""),
+ new Property(
+ "minekube:bedrock_identity_scope",
+ "base-private-endpoint-a",
+ "")));
+ ConnectPlayer player = new ConnectPlayerImpl(
+ "session-1",
+ new com.minekube.connect.api.player.GameProfile(
+ "BedrockSteve",
+ uuid,
+ Arrays.asList(
+ new com.minekube.connect.api.player.GameProfile.Property(
+ "textures",
+ "skin",
+ "signature"),
+ new com.minekube.connect.api.player.GameProfile.Property(
+ BedrockIdentityVerifier.PROPERTY_NAME,
+ "connect-envelope-replay-nonce-b",
+ ""),
+ new com.minekube.connect.api.player.GameProfile.Property(
+ "minekube:bedrock_identity_scope",
+ "connect-private-endpoint-b",
+ ""))),
+ new Auth(false),
+ "");
+
+ GameProfile forwarded = VelocityGameProfiles.fromConnectPlayer(base, player);
+
+ assertEquals(uuid, forwarded.getId());
+ assertEquals("BedrockSteve", forwarded.getName());
+ assertFalse(forwarded.getProperties().stream()
+ .anyMatch(property -> BedrockIdentityVerifier.PROPERTY_NAME.equals(property.getName())));
+ assertFalse(forwarded.getProperties().stream()
+ .anyMatch(property -> "minekube:bedrock_identity_scope".equals(property.getName())));
+ assertFalse(forwarded.toString().contains("replay-nonce"));
+ assertFalse(forwarded.toString().contains("private-endpoint"));
+ }
+}