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 new file mode 100644 index 000000000..745c5519c --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/bedrock/BedrockIdentityClaims.java @@ -0,0 +1,24 @@ +package com.minekube.connect.api.player.bedrock; + +import java.time.Instant; +import lombok.Value; + +@Value +public class 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 nonce; +} diff --git a/api/src/main/java/com/minekube/connect/api/player/bedrock/BedrockIdentityReplayCache.java b/api/src/main/java/com/minekube/connect/api/player/bedrock/BedrockIdentityReplayCache.java new file mode 100644 index 000000000..4e6970214 --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/bedrock/BedrockIdentityReplayCache.java @@ -0,0 +1,25 @@ +package com.minekube.connect.api.player.bedrock; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +public final class BedrockIdentityReplayCache { + private final Map seen = new HashMap<>(); + + synchronized boolean accept(String endpointId, String sessionId, String nonce, long nowUnixMs, long expiresAtUnixMs) { + Iterator> iterator = seen.entrySet().iterator(); + while (iterator.hasNext()) { + if (iterator.next().getValue() <= nowUnixMs) { + iterator.remove(); + } + } + + String key = endpointId + "\0" + sessionId + "\0" + nonce; + if (seen.containsKey(key)) { + return false; + } + seen.put(key, expiresAtUnixMs); + return true; + } +} diff --git a/api/src/main/java/com/minekube/connect/api/player/bedrock/BedrockIdentityVerificationException.java b/api/src/main/java/com/minekube/connect/api/player/bedrock/BedrockIdentityVerificationException.java new file mode 100644 index 000000000..c48c2d305 --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/bedrock/BedrockIdentityVerificationException.java @@ -0,0 +1,11 @@ +package com.minekube.connect.api.player.bedrock; + +public class BedrockIdentityVerificationException extends Exception { + public BedrockIdentityVerificationException(String message) { + super(message); + } + + public BedrockIdentityVerificationException(String message, Throwable cause) { + super(message, cause); + } +} 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 new file mode 100644 index 000000000..eab31f548 --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/bedrock/BedrockIdentityVerifier.java @@ -0,0 +1,348 @@ +package com.minekube.connect.api.player.bedrock; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; +import com.minekube.connect.api.player.GameProfile; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.Signature; +import java.security.interfaces.EdECPublicKey; +import java.security.spec.EdECPoint; +import java.security.spec.EdECPublicKeySpec; +import java.security.spec.NamedParameterSpec; +import java.security.spec.X509EncodedKeySpec; +import java.time.Instant; +import java.util.Base64; +import java.util.Objects; +import java.util.function.Supplier; + +public final class BedrockIdentityVerifier { + public static final String PROPERTY_NAME = "minekube:bedrock_identity"; + + private static final int VERSION = 1; + private static final String POLICY_LINKED_JAVA_ONLY = "linked_java_only"; + private static final String POLICY_TRUSTED_BEDROCK_XUID = "trusted_bedrock_xuid"; + 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 final PublicKey publicKey; + private final Supplier now; + private final String endpointId; + private final String endpointName; + private final String orgId; + private final String sessionId; + private final String protocol; + private final String bedrockAuthPolicy; + private final BedrockIdentityReplayCache replayCache; + + private BedrockIdentityVerifier(Builder builder) { + this.publicKey = Objects.requireNonNull(builder.publicKey, "publicKey"); + this.now = builder.now; + this.endpointId = requireNonEmpty(builder.endpointId, "endpointId"); + this.endpointName = requireNonEmpty(builder.endpointName, "endpointName"); + this.orgId = requireNonEmpty(builder.orgId, "orgId"); + this.sessionId = requireNonEmpty(builder.sessionId, "sessionId"); + this.protocol = requireNonEmpty(builder.protocol, "protocol"); + this.bedrockAuthPolicy = builder.bedrockAuthPolicy; + this.replayCache = builder.replayCache; + } + + public static Builder builder() { + return new Builder(); + } + + public BedrockIdentityClaims verify(GameProfile profile) throws BedrockIdentityVerificationException { + Objects.requireNonNull(profile, "profile"); + for (GameProfile.Property property : profile.getProperties()) { + if (PROPERTY_NAME.equals(property.getName())) { + return verify(property.getValue()); + } + } + throw new BedrockIdentityVerificationException("missing " + PROPERTY_NAME + " profile property"); + } + + public BedrockIdentityClaims verify(String signedEnvelope) throws BedrockIdentityVerificationException { + Envelope envelope = decode(signedEnvelope); + verifySignature(envelope); + validate(envelope); + validateScope(envelope); + + long nowUnixMs = now.get().toEpochMilli(); + if (nowUnixMs >= envelope.session.expires_at_unix_ms) { + throw new BedrockIdentityVerificationException("identity envelope expired"); + } + if (replayCache != null && !replayCache.accept( + envelope.endpoint.id, + envelope.session.id, + envelope.session.nonce, + nowUnixMs, + envelope.session.expires_at_unix_ms)) { + throw new BedrockIdentityVerificationException("identity envelope replayed"); + } + + return new BedrockIdentityClaims( + envelope.issuer, + envelope.endpoint.id, + envelope.endpoint.name, + envelope.endpoint.org_id, + envelope.session.id, + envelope.session.protocol, + envelope.policy.bedrock_auth_mode, + envelope.principal.type, + envelope.principal.bedrock_xuid, + envelope.principal.bedrock_username, + envelope.principal.bedrock_derived_uuid, + 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); + } + + private static Envelope decode(String signedEnvelope) throws BedrockIdentityVerificationException { + try { + Envelope envelope = GSON.fromJson(signedEnvelope, Envelope.class); + if (envelope == null) { + throw new BedrockIdentityVerificationException("decode envelope: empty envelope"); + } + return envelope; + } catch (JsonSyntaxException e) { + throw new BedrockIdentityVerificationException("decode envelope", e); + } + } + + private void verifySignature(Envelope envelope) throws BedrockIdentityVerificationException { + byte[] signature; + try { + signature = Base64.getUrlDecoder().decode(requireNonEmpty(envelope.signature, "signature")); + } catch (IllegalArgumentException e) { + throw new BedrockIdentityVerificationException("decode signature", e); + } + + String originalSignature = envelope.signature; + envelope.signature = null; + byte[] payload = GSON.toJson(envelope).getBytes(StandardCharsets.UTF_8); + envelope.signature = originalSignature; + + try { + Signature verifier = Signature.getInstance("Ed25519"); + verifier.initVerify(publicKey); + verifier.update(payload); + if (!verifier.verify(signature)) { + throw new BedrockIdentityVerificationException("invalid envelope signature"); + } + } catch (GeneralSecurityException e) { + throw new BedrockIdentityVerificationException("verify envelope signature", e); + } + } + + private static void validate(Envelope envelope) throws BedrockIdentityVerificationException { + if (envelope.version != VERSION) { + throw new BedrockIdentityVerificationException("unsupported identity envelope version " + envelope.version); + } + if (envelope.endpoint == null || + isEmpty(envelope.endpoint.id) || + isEmpty(envelope.endpoint.name) || + isEmpty(envelope.endpoint.org_id)) { + throw new BedrockIdentityVerificationException("identity envelope endpoint scope is incomplete"); + } + if (envelope.session == null || + isEmpty(envelope.session.id) || + isEmpty(envelope.session.protocol) || + 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) { + throw new BedrockIdentityVerificationException("identity envelope expiry must be after issue time"); + } + if (envelope.policy == null || !validPolicy(envelope.policy.bedrock_auth_mode)) { + throw new BedrockIdentityVerificationException("identity envelope policy is invalid"); + } + if (envelope.principal == null) { + throw new BedrockIdentityVerificationException("identity envelope principal is incomplete"); + } + if (PRINCIPAL_BEDROCK_XUID.equals(envelope.principal.type)) { + if (isEmpty(envelope.principal.bedrock_xuid)) { + throw new BedrockIdentityVerificationException("bedrock_xuid principal requires xuid"); + } + 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)) { + throw new BedrockIdentityVerificationException( + "bedrock_linked_java principal requires xuid and linked Java identity"); + } + return; + } + throw new BedrockIdentityVerificationException( + "unsupported identity envelope principal type " + envelope.principal.type); + } + + private void validateScope(Envelope envelope) throws BedrockIdentityVerificationException { + if (!endpointId.equals(envelope.endpoint.id) || + !endpointName.equals(envelope.endpoint.name) || + !orgId.equals(envelope.endpoint.org_id) || + !sessionId.equals(envelope.session.id) || + !protocol.equals(envelope.session.protocol)) { + throw new BedrockIdentityVerificationException("identity envelope scope mismatch"); + } + if (bedrockAuthPolicy != null && !bedrockAuthPolicy.equals(envelope.policy.bedrock_auth_mode)) { + throw new BedrockIdentityVerificationException("identity envelope policy mismatch"); + } + } + + private static boolean validPolicy(String policy) { + return POLICY_LINKED_JAVA_ONLY.equals(policy) || POLICY_TRUSTED_BEDROCK_XUID.equals(policy); + } + + private static PublicKey parsePublicKey(byte[] publicKey) { + try { + return KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(publicKey)); + } catch (GeneralSecurityException ignored) { + if (publicKey.length != 32) { + throw new IllegalArgumentException("publicKey must be an Ed25519 raw or X.509 public key"); + } + try { + byte[] y = publicKey.clone(); + boolean xOdd = (y[31] & 0x80) != 0; + y[31] &= 0x7f; + reverse(y); + EdECPublicKeySpec spec = new EdECPublicKeySpec( + NamedParameterSpec.ED25519, + new EdECPoint(xOdd, new BigInteger(1, y))); + return KeyFactory.getInstance("Ed25519").generatePublic(spec); + } catch (GeneralSecurityException e) { + throw new IllegalArgumentException("publicKey must be an Ed25519 raw or X.509 public key", e); + } + } + } + + private static void reverse(byte[] bytes) { + for (int i = 0, j = bytes.length - 1; i < j; i++, j--) { + byte tmp = bytes[i]; + bytes[i] = bytes[j]; + bytes[j] = tmp; + } + } + + private static String requireNonEmpty(String value, String name) { + if (isEmpty(value)) { + throw new IllegalArgumentException(name + " is required"); + } + return value; + } + + private static boolean isEmpty(String value) { + return value == null || value.isEmpty(); + } + + public static final class Builder { + private PublicKey publicKey; + private Supplier now = Instant::now; + private String endpointId; + private String endpointName; + private String orgId; + private String sessionId; + private String protocol; + private String bedrockAuthPolicy; + private BedrockIdentityReplayCache replayCache; + + public Builder publicKey(byte[] publicKey) { + this.publicKey = parsePublicKey(Objects.requireNonNull(publicKey, "publicKey").clone()); + return this; + } + + public Builder now(Instant now) { + Objects.requireNonNull(now, "now"); + this.now = () -> now; + return this; + } + + public Builder now(Supplier now) { + this.now = Objects.requireNonNull(now, "now"); + return this; + } + + public Builder endpointId(String endpointId) { + this.endpointId = endpointId; + return this; + } + + public Builder endpointName(String endpointName) { + this.endpointName = endpointName; + return this; + } + + public Builder orgId(String orgId) { + this.orgId = orgId; + return this; + } + + public Builder sessionId(String sessionId) { + this.sessionId = sessionId; + return this; + } + + public Builder protocol(String protocol) { + this.protocol = protocol; + return this; + } + + public Builder bedrockAuthPolicy(String bedrockAuthPolicy) { + this.bedrockAuthPolicy = requireNonEmpty(bedrockAuthPolicy, "bedrockAuthPolicy"); + return this; + } + + public Builder replayCache(BedrockIdentityReplayCache replayCache) { + this.replayCache = replayCache; + return this; + } + + public BedrockIdentityVerifier build() { + return new BedrockIdentityVerifier(this); + } + } + + private static final class Envelope { + int version; + String issuer; + Endpoint endpoint; + SessionScope session; + Policy policy; + Principal principal; + String signature; + } + + private static final class Endpoint { + String id; + String name; + String org_id; + } + + private static final class SessionScope { + String id; + String protocol; + long issued_at_unix_ms; + long expires_at_unix_ms; + String nonce; + } + + private static final class Policy { + String bedrock_auth_mode; + } + + private static final class Principal { + String type; + String bedrock_xuid; + String bedrock_username; + String bedrock_derived_uuid; + String linked_java_uuid; + String linked_java_name; + } +} 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 new file mode 100644 index 000000000..f5948192e --- /dev/null +++ b/core/src/test/java/com/minekube/connect/api/player/bedrock/BedrockIdentityVerifierTest.java @@ -0,0 +1,255 @@ +package com.minekube.connect.api.player.bedrock; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.Gson; +import com.minekube.connect.api.player.GameProfile; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.Signature; +import java.security.interfaces.EdECPublicKey; +import java.time.Instant; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class BedrockIdentityVerifierTest { + private static final Gson GSON = new Gson(); + private static final Instant NOW = Instant.parse("2026-07-05T12:00:00Z"); + + @Test + void verifiesEndpointScopedBedrockXuidEnvelopeFromGameProfileProperty() throws Exception { + KeyPair keyPair = ed25519KeyPair(); + String envelope = signedEnvelope(keyPair, "nonce-a", "session-1", "endpoint-id", "endpoint", "org-id"); + GameProfile profile = profileWithEnvelope(envelope); + + BedrockIdentityVerifier verifier = BedrockIdentityVerifier.builder() + .publicKey(rawEd25519PublicKey(keyPair)) + .now(NOW) + .endpointId("endpoint-id") + .endpointName("endpoint") + .orgId("org-id") + .sessionId("session-1") + .protocol("bedrock") + .replayCache(new BedrockIdentityReplayCache()) + .build(); + + BedrockIdentityClaims claims = verifier.verify(profile); + + assertEquals("bedrock_xuid", claims.getPrincipalType()); + assertEquals("2533274790395904", claims.getBedrockXuid()); + assertEquals("BedrockSteve", claims.getBedrockUsername()); + assertEquals("trusted_bedrock_xuid", claims.getBedrockAuthPolicy()); + assertEquals("endpoint-id", claims.getEndpointId()); + } + + @Test + void rejectsTamperedEnvelopeSignature() throws Exception { + KeyPair keyPair = ed25519KeyPair(); + String envelope = signedEnvelope(keyPair, "nonce-a", "session-1", "endpoint-id", "endpoint", "org-id") + .replace("BedrockSteve", "SpoofedSteve"); + BedrockIdentityVerifier verifier = verifier(keyPair, "session-1"); + + BedrockIdentityVerificationException error = assertThrows( + BedrockIdentityVerificationException.class, + () -> verifier.verify(profileWithEnvelope(envelope))); + + assertTrue(error.getMessage().contains("signature")); + } + + @Test + void rejectsScopeMismatch() throws Exception { + KeyPair keyPair = ed25519KeyPair(); + String envelope = signedEnvelope(keyPair, "nonce-a", "session-1", "endpoint-id", "endpoint", "org-id"); + BedrockIdentityVerifier verifier = BedrockIdentityVerifier.builder() + .publicKey(keyPair.getPublic().getEncoded()) + .now(NOW) + .endpointId("other-endpoint-id") + .endpointName("endpoint") + .orgId("org-id") + .sessionId("session-1") + .protocol("bedrock") + .build(); + + BedrockIdentityVerificationException error = assertThrows( + BedrockIdentityVerificationException.class, + () -> verifier.verify(profileWithEnvelope(envelope))); + + assertTrue(error.getMessage().contains("scope")); + } + + @Test + void rejectsPolicyMismatchWhenExpectedPolicyIsConfigured() throws Exception { + KeyPair keyPair = ed25519KeyPair(); + String envelope = signedEnvelope(keyPair, "nonce-a", "session-1", "endpoint-id", "endpoint", "org-id"); + BedrockIdentityVerifier verifier = BedrockIdentityVerifier.builder() + .publicKey(keyPair.getPublic().getEncoded()) + .now(NOW) + .endpointId("endpoint-id") + .endpointName("endpoint") + .orgId("org-id") + .sessionId("session-1") + .protocol("bedrock") + .bedrockAuthPolicy("linked_java_only") + .build(); + + BedrockIdentityVerificationException error = assertThrows( + BedrockIdentityVerificationException.class, + () -> verifier.verify(profileWithEnvelope(envelope))); + + assertTrue(error.getMessage().contains("policy")); + } + + @Test + void rejectsReplayWhenCacheSeesSameEndpointSessionNonceTwice() throws Exception { + KeyPair keyPair = ed25519KeyPair(); + String envelope = signedEnvelope(keyPair, "nonce-a", "session-1", "endpoint-id", "endpoint", "org-id"); + BedrockIdentityReplayCache replayCache = new BedrockIdentityReplayCache(); + BedrockIdentityVerifier verifier = BedrockIdentityVerifier.builder() + .publicKey(keyPair.getPublic().getEncoded()) + .now(NOW) + .endpointId("endpoint-id") + .endpointName("endpoint") + .orgId("org-id") + .sessionId("session-1") + .protocol("bedrock") + .replayCache(replayCache) + .build(); + + verifier.verify(profileWithEnvelope(envelope)); + BedrockIdentityVerificationException error = assertThrows( + BedrockIdentityVerificationException.class, + () -> verifier.verify(profileWithEnvelope(envelope))); + + assertTrue(error.getMessage().contains("replayed")); + } + + @Test + void rejectsProfileWithoutBedrockIdentityProperty() throws Exception { + KeyPair keyPair = ed25519KeyPair(); + BedrockIdentityVerifier verifier = verifier(keyPair, "session-1"); + GameProfile profile = new GameProfile( + "BedrockSteve", + UUID.randomUUID(), + Collections.singletonList(new GameProfile.Property("textures", "skin", ""))); + + BedrockIdentityVerificationException error = assertThrows( + BedrockIdentityVerificationException.class, + () -> verifier.verify(profile)); + + assertTrue(error.getMessage().contains(BedrockIdentityVerifier.PROPERTY_NAME)); + } + + private static BedrockIdentityVerifier verifier(KeyPair keyPair, String sessionId) { + return BedrockIdentityVerifier.builder() + .publicKey(keyPair.getPublic().getEncoded()) + .now(NOW) + .endpointId("endpoint-id") + .endpointName("endpoint") + .orgId("org-id") + .sessionId(sessionId) + .protocol("bedrock") + .build(); + } + + private static GameProfile profileWithEnvelope(String envelope) { + return new GameProfile( + "BedrockSteve", + UUID.fromString("00000000-0000-0000-0000-000000000001"), + Arrays.asList( + new GameProfile.Property("textures", "skin", ""), + new GameProfile.Property(BedrockIdentityVerifier.PROPERTY_NAME, envelope, ""))); + } + + private static KeyPair ed25519KeyPair() throws Exception { + return KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + } + + private static byte[] rawEd25519PublicKey(KeyPair keyPair) { + EdECPublicKey publicKey = (EdECPublicKey) keyPair.getPublic(); + byte[] y = publicKey.getPoint().getY().toByteArray(); + byte[] raw = new byte[32]; + for (int source = y.length - 1, target = 0; source >= 0 && target < raw.length; source--, target++) { + raw[target] = y[source]; + } + if (publicKey.getPoint().isXOdd()) { + raw[31] |= (byte) 0x80; + } + return raw; + } + + private static String signedEnvelope( + KeyPair keyPair, + String nonce, + String sessionId, + String endpointId, + String endpointName, + String orgId) throws Exception { + Envelope envelope = new Envelope(); + envelope.version = 1; + envelope.issuer = "minekube-connect-test"; + envelope.endpoint = new Endpoint(); + envelope.endpoint.id = endpointId; + envelope.endpoint.name = endpointName; + envelope.endpoint.org_id = orgId; + envelope.session = new Session(); + envelope.session.id = sessionId; + envelope.session.protocol = "bedrock"; + envelope.session.issued_at_unix_ms = NOW.toEpochMilli(); + envelope.session.expires_at_unix_ms = NOW.plusSeconds(300).toEpochMilli(); + envelope.session.nonce = nonce; + envelope.policy = new Policy(); + envelope.policy.bedrock_auth_mode = "trusted_bedrock_xuid"; + envelope.principal = new Principal(); + envelope.principal.type = "bedrock_xuid"; + envelope.principal.bedrock_xuid = "2533274790395904"; + envelope.principal.bedrock_username = "BedrockSteve"; + envelope.principal.bedrock_derived_uuid = "00000000-0000-0000-0000-000000000001"; + + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(keyPair.getPrivate()); + signer.update(GSON.toJson(envelope).getBytes(StandardCharsets.UTF_8)); + envelope.signature = Base64.getUrlEncoder().withoutPadding().encodeToString(signer.sign()); + return GSON.toJson(envelope); + } + + private static final class Envelope { + int version; + String issuer; + Endpoint endpoint; + Session session; + Policy policy; + Principal principal; + String signature; + } + + private static final class Endpoint { + String id; + String name; + String org_id; + } + + private static final class Session { + String id; + String protocol; + long issued_at_unix_ms; + long expires_at_unix_ms; + String nonce; + } + + private static final class Policy { + String bedrock_auth_mode; + } + + private static final class Principal { + String type; + String bedrock_xuid; + String bedrock_username; + String bedrock_derived_uuid; + } +}