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 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 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")); + } +}