From 8f234456d38ec86c88175eafb0b5adbd333a7ab8 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 3 Aug 2026 16:15:22 +0200 Subject: [PATCH 1/4] feat: consume Bedrock principal v2 in Connect Java --- AGENTS.md | 9 + .../com/minekube/connect/api/ConnectApi.java | 9 + .../bedrock/BedrockIdentityProfiles.java | 5 +- .../principal/BedrockPrincipalVerifier.java | 8 + .../BedrockPrincipalVerifierFactory.java | 13 + .../api/player/principal/CanonicalXuid.java | 31 ++ .../DefaultBedrockPrincipalVerifier.java | 507 ++++++++++++++++++ .../principal/EffectiveGameProfile.java | 12 + .../ImmutableVerifiedBedrockPrincipal.java | 53 ++ .../api/player/principal/LinkProvenance.java | 29 + .../player/principal/PrincipalBindings.java | 83 +++ .../api/player/principal/PrincipalError.java | 18 + .../PrincipalVerificationException.java | 17 + .../principal/SignedPrincipalEnvelope.java | 32 ++ .../api/player/principal/StrictJson.java | 184 +++++++ .../api/player/principal/SubjectKind.java | 24 + .../principal/TrustedProposalContext.java | 19 + .../principal/VerificationEvidence.java | 20 + .../principal/VerifiedBedrockPrincipal.java | 15 + .../principal/VerifiedLinkedJavaIdentity.java | 26 + .../player/principal/VerifiedPrincipal.java | 7 + .../principal/VerifierConfiguration.java | 68 +++ .../connect.base-conventions.gradle.kts | 4 +- .../listener/BungeeLateReassertListener.java | 5 +- .../connect/api/SimpleConnectApi.java | 11 + .../bedrock/BedrockAdmissionCoordinator.java | 45 +- .../BedrockPrincipalAdmissionException.java | 18 + .../BedrockPrincipalConfiguration.java | 54 ++ .../bedrock/BedrockPrincipalConsumer.java | 127 +++++ .../bedrock/BedrockPrincipalReadiness.java | 147 +++++ .../VerifiedBedrockIdentityRegistry.java | 39 +- .../connect/config/ConnectConfig.java | 27 + .../netty/LocalChannelInboundHandler.java | 7 +- .../connect/network/netty/LocalSession.java | 10 +- .../tunnel/p2p/Libp2pEndpointRuntime.java | 20 +- .../tunnel/p2p/Libp2pSessionMapper.java | 6 + .../connect/tunnel/p2p/P2PFrameCodec.java | 58 ++ .../connect/tunnel/p2p/P2PFrameDecoder.java | 3 + .../tunnel/p2p/PeerRegistrationClient.java | 138 ++++- .../tunnel/p2p/PeerRegistrationHandshake.java | 21 +- .../connect/watch/SessionProposal.java | 47 +- .../minekube/connect/watch/WatchClient.java | 35 +- .../connect/v1alpha1/connect_libp2p.proto | 17 + .../connect/v1alpha1/watch_service.proto | 81 ++- core/src/main/resources/config.yml | 13 + core/src/main/resources/proxy-config.yml | 13 + .../bedrock/BedrockPrincipalConsumerTest.java | 138 +++++ .../BedrockPrincipalGenerationConfigTest.java | 106 ++++ .../BedrockPrincipalReadinessTest.java | 100 ++++ .../BedrockPrincipalCoreVectorTest.java | 237 ++++++++ .../BedrockPrincipalWireBoundaryTest.java | 63 +++ .../PrincipalConstructionBoundaryTest.java | 57 ++ .../principal/PrincipalPrivacyTest.java | 103 ++++ .../connect/tunnel/p2p/P2PFrameCodecTest.java | 23 + .../p2p/PeerRegistrationClientTest.java | 91 ++++ .../p2p/PeerRegistrationHandshakeTest.java | 6 + .../connect/watch/WatchClientTest.java | 44 ++ .../resources/bedrock-principal-v2/UPSTREAM | 4 + .../bedrock-principal-v2/core-vectors.json | 144 +++++ .../bedrock-principal-v2/v2.schema.json | 53 ++ docs/bedrock-identity.md | 40 ++ .../connect/addon/data/SpigotDataHandler.java | 10 +- .../connect/listener/SpigotListener.java | 12 +- velocity/build.gradle.kts | 4 +- .../VelocityLateReassertListener.java | 2 +- 65 files changed, 3309 insertions(+), 63 deletions(-) create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/BedrockPrincipalVerifier.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/BedrockPrincipalVerifierFactory.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/CanonicalXuid.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/DefaultBedrockPrincipalVerifier.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/EffectiveGameProfile.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/ImmutableVerifiedBedrockPrincipal.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/LinkProvenance.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/PrincipalBindings.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/PrincipalError.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/PrincipalVerificationException.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/SignedPrincipalEnvelope.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/StrictJson.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/SubjectKind.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/TrustedProposalContext.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/VerificationEvidence.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/VerifiedBedrockPrincipal.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/VerifiedLinkedJavaIdentity.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/VerifiedPrincipal.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/VerifierConfiguration.java create mode 100644 core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalAdmissionException.java create mode 100644 core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalConfiguration.java create mode 100644 core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalConsumer.java create mode 100644 core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalReadiness.java create mode 100644 core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalConsumerTest.java create mode 100644 core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalGenerationConfigTest.java create mode 100644 core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalReadinessTest.java create mode 100644 core/src/test/java/com/minekube/connect/principal/BedrockPrincipalCoreVectorTest.java create mode 100644 core/src/test/java/com/minekube/connect/principal/BedrockPrincipalWireBoundaryTest.java create mode 100644 core/src/test/java/com/minekube/connect/principal/PrincipalConstructionBoundaryTest.java create mode 100644 core/src/test/java/com/minekube/connect/principal/PrincipalPrivacyTest.java create mode 100644 core/src/test/resources/bedrock-principal-v2/UPSTREAM create mode 100644 core/src/test/resources/bedrock-principal-v2/core-vectors.json create mode 100644 core/src/test/resources/bedrock-principal-v2/v2.schema.json diff --git a/AGENTS.md b/AGENTS.md index 9ba1712da..aa1f1f95a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,6 +206,15 @@ curl -I -L --fail https://github.com/minekube/connect-java/releases/download/ getVerifiedBedrockIdentity(ConnectPlayer player) { return Optional.empty(); } + + /** + * Returns the verifier-created v2 Bedrock principal for this player's current session. + * Callers must not log or serialize identity/link accessors. + */ + default Optional getVerifiedBedrockPrincipal(ConnectPlayer player) { + return Optional.empty(); + } } 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 index f7ca88150..d29aa1be6 100644 --- 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 @@ -10,6 +10,8 @@ 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"; + /** Reserved property name: v2 is accepted only from authenticated wire field 12. */ + public static final String PRINCIPAL_V2_PROPERTY_NAME = "minekube:bedrock_principal_v2"; private BedrockIdentityProfiles() { } @@ -42,6 +44,7 @@ public static GameProfile withoutEnvelope(GameProfile profile) { */ public static boolean isPublic(GameProfile.Property property) { return !BedrockIdentityVerifier.PROPERTY_NAME.equals(property.getName()) && - !SCOPE_PROPERTY_NAME.equals(property.getName()); + !SCOPE_PROPERTY_NAME.equals(property.getName()) && + !PRINCIPAL_V2_PROPERTY_NAME.equals(property.getName()); } } diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/BedrockPrincipalVerifier.java b/api/src/main/java/com/minekube/connect/api/player/principal/BedrockPrincipalVerifier.java new file mode 100644 index 000000000..ec04442c8 --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/BedrockPrincipalVerifier.java @@ -0,0 +1,8 @@ +package com.minekube.connect.api.player.principal; + +/** Strict Bedrock signed-principal v2 verifier. */ +public interface BedrockPrincipalVerifier { + VerifiedBedrockPrincipal verifyAndConsume( + SignedPrincipalEnvelope envelope, + TrustedProposalContext expected) throws PrincipalVerificationException; +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/BedrockPrincipalVerifierFactory.java b/api/src/main/java/com/minekube/connect/api/player/principal/BedrockPrincipalVerifierFactory.java new file mode 100644 index 000000000..57e0e0a4e --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/BedrockPrincipalVerifierFactory.java @@ -0,0 +1,13 @@ +package com.minekube.connect.api.player.principal; + +import java.util.Objects; + +/** The sole public construction entry point for a Bedrock principal verifier. */ +public final class BedrockPrincipalVerifierFactory { + private BedrockPrincipalVerifierFactory() {} + + public static BedrockPrincipalVerifier create(VerifierConfiguration configuration) { + return new DefaultBedrockPrincipalVerifier( + Objects.requireNonNull(configuration, "configuration")); + } +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/CanonicalXuid.java b/api/src/main/java/com/minekube/connect/api/player/principal/CanonicalXuid.java new file mode 100644 index 000000000..723996a63 --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/CanonicalXuid.java @@ -0,0 +1,31 @@ +package com.minekube.connect.api.player.principal; + +import java.util.Objects; + +/** A canonical positive decimal XUID verified by the SDK. */ +public final class CanonicalXuid { + private final transient String value; + + CanonicalXuid(String value) { + this.value = Objects.requireNonNull(value, "value"); + } + + public String value() { + return value; + } + + @Override + public boolean equals(Object other) { + return other instanceof CanonicalXuid && value.equals(((CanonicalXuid) other).value); + } + + @Override + public int hashCode() { + return value.hashCode(); + } + + @Override + public String toString() { + return "CanonicalXuid[redacted]"; + } +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/DefaultBedrockPrincipalVerifier.java b/api/src/main/java/com/minekube/connect/api/player/principal/DefaultBedrockPrincipalVerifier.java new file mode 100644 index 000000000..608bc4111 --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/DefaultBedrockPrincipalVerifier.java @@ -0,0 +1,507 @@ +package com.minekube.connect.api.player.principal; + +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.MessageDigest; +import java.security.PublicKey; +import java.security.Signature; +import java.security.spec.EdECPoint; +import java.security.spec.EdECPublicKeySpec; +import java.security.spec.NamedParameterSpec; +import java.time.Clock; +import java.time.Instant; +import java.util.Base64; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +final class DefaultBedrockPrincipalVerifier implements BedrockPrincipalVerifier { + static final String WIRE_TYPE = "connect-bedrock-principal+jws;v=2"; + static final String CAPABILITY = "bedrock-verified-principal-v2"; + private static final int MAX_HEADER_BYTES = 2 * 1024; + private static final int MAX_PAYLOAD_BYTES = 12 * 1024; + private static final long MAX_UNIX_TIMESTAMP = 253_402_300_799L; + private static final Set HEADER_FIELDS = Set.of("alg", "typ", "kid"); + private static final Set PAYLOAD_FIELDS = Set.of( + "version", "issuer", "trust_domain", "audience", "subject_kind", + "canonical_xuid", "canonical_unlinked_uuid", "linked_java", + "bedrock_display_name", "endpoint_id", "organization_id", + "connect_session_id", "connect_session_nonce", "policy_revision", + "source_protocol", "source_protocol_version", "iat", "nbf", "exp", + "jti", "verification_method"); + private static final Set REQUIRED_PAYLOAD_FIELDS = Set.of( + "version", "issuer", "trust_domain", "audience", "subject_kind", + "canonical_xuid", "canonical_unlinked_uuid", "bedrock_display_name", + "endpoint_id", "organization_id", "connect_session_id", + "connect_session_nonce", "policy_revision", "source_protocol", + "source_protocol_version", "iat", "nbf", "exp", "jti", + "verification_method"); + private static final Set LINK_FIELDS = Set.of("uuid", "name", "provenance"); + private static final Set PROVENANCE_FIELDS = + Set.of("provider", "record_id", "revision", "verified_at"); + + private final Map keys; + private final Clock clock; + private final ReplayCache replay; + + DefaultBedrockPrincipalVerifier(VerifierConfiguration configuration) { + this.clock = configuration.clock(); + this.replay = new ReplayCache(configuration.replayCapacity(), clock); + Map parsed = new HashMap<>(); + configuration.publicKeys().forEach((kid, key) -> parsed.put(kid, parsePublicKey(key))); + this.keys = Map.copyOf(parsed); + } + + @Override + public VerifiedBedrockPrincipal verifyAndConsume( + SignedPrincipalEnvelope envelope, + TrustedProposalContext expected) throws PrincipalVerificationException { + if (envelope == null || expected == null) throw reject(PrincipalError.MALFORMED); + ParsedEnvelope parsed = parse(envelope.compact()); + Claims claims = parsed.claims; + if (!validTrust(claims.issuer, 128) + || !validTrust(claims.trustDomain, 256) + || !validTrust(claims.audience, 256) + || !validTrust(expected.issuer(), 128) + || !validTrust(expected.trustDomain(), 256) + || !validTrust(expected.audience(), 256) + || !same(claims.issuer, expected.issuer()) + || !same(claims.trustDomain, expected.trustDomain()) + || !same(claims.audience, expected.audience())) { + throw reject(PrincipalError.TRUST); + } + + PublicKey key = keys.get(parsed.kid); + if (key == null) throw reject(PrincipalError.TRUST); + if (!verifySignature(key, parsed.signingInput, parsed.signature)) { + throw reject(PrincipalError.SIGNATURE); + } + + byte[] nonce = decodeCanonical16(claims.connectSessionNonce); + decodeCanonical16(claims.jti); + if (!validBinding(claims.sourceProtocol, claims.sourceProtocolVersion, + claims.endpointId, claims.organizationId, claims.connectSessionId) + || !validBinding(expected.sourceProtocol(), expected.sourceProtocolVersion(), + expected.endpointId(), expected.organizationId(), expected.connectSessionId()) + || !MessageDigest.isEqual(nonce, expected.connectSessionNonce()) + || !same(claims.endpointId, expected.endpointId()) + || !same(claims.organizationId, expected.organizationId()) + || !same(claims.connectSessionId, expected.connectSessionId()) + || !same(claims.sourceProtocol, expected.sourceProtocol()) + || claims.sourceProtocolVersion != expected.sourceProtocolVersion() + || claims.policyRevision != expected.policyRevision() + || expected.policyRevision() <= 0) { + throw reject(PrincipalError.BINDING_MISMATCH); + } + + validateTime(claims, clock.instant().getEpochSecond()); + ImmutableVerifiedBedrockPrincipal principal = principal(claims, parsed.kid, expected); + replay.consume(new ReplayValue( + claims.trustDomain, claims.issuer, claims.jti, parsed.kid, + claims.endpointId, claims.connectSessionId, nonce, claims.exp + 5)); + return principal; + } + + private static ParsedEnvelope parse(String compact) throws PrincipalVerificationException { + try { + String[] parts = compact.split("\\.", -1); + if (parts.length != 3 || parts[0].isEmpty() || parts[1].isEmpty() || parts[2].isEmpty()) { + throw malformed(); + } + byte[] headerBytes = decodeCanonical(parts[0], MAX_HEADER_BYTES); + byte[] payloadBytes = decodeCanonical(parts[1], MAX_PAYLOAD_BYTES); + byte[] signature = decodeCanonical(parts[2], 64); + if (signature.length != 64) throw malformed(); + Map header = StrictJson.parseObject(utf8(headerBytes), headerBytes.length); + exact(header, HEADER_FIELDS, HEADER_FIELDS); + if (!"EdDSA".equals(string(header, "alg")) + || !WIRE_TYPE.equals(string(header, "typ"))) throw malformed(); + String kid = bounded(string(header, "kid"), 1, 128); + + Map payload = StrictJson.parseObject(utf8(payloadBytes), payloadBytes.length); + exact(payload, PAYLOAD_FIELDS, REQUIRED_PAYLOAD_FIELDS); + Claims claims = Claims.from(payload); + return new ParsedEnvelope(kid, claims, + (parts[0] + "." + parts[1]).getBytes(StandardCharsets.US_ASCII), signature); + } catch (IllegalArgumentException | CharacterCodingException ignored) { + throw reject(PrincipalError.MALFORMED); + } + } + + private static ImmutableVerifiedBedrockPrincipal principal( + Claims claims, + String kid, + TrustedProposalContext expected) throws PrincipalVerificationException { + if (claims.version != 2 + || claims.canonicalXuid.length() > 19 + || claims.canonicalXuid.isEmpty() + || claims.canonicalXuid.charAt(0) == '0') { + throw reject(PrincipalError.IDENTITY); + } + long xuid; + try { + xuid = Long.parseLong(claims.canonicalXuid); + } catch (NumberFormatException ignored) { + throw reject(PrincipalError.IDENTITY); + } + if (xuid <= 0 || !Long.toString(xuid).equals(claims.canonicalXuid)) { + throw reject(PrincipalError.IDENTITY); + } + UUID unlinked = canonicalUuid(claims.canonicalUnlinkedUuid, PrincipalError.IDENTITY); + UUID expectedUuid = new UUID(0L, xuid); + if (!unlinked.equals(expectedUuid) + || claims.bedrockDisplayName.isEmpty() + || utf8Length(claims.bedrockDisplayName) > 64 + || !validVerificationMethod(claims.verificationMethod)) { + throw reject(PrincipalError.IDENTITY); + } + + SubjectKind kind = SubjectKind.fromWireName(claims.subjectKind); + VerifiedLinkedJavaIdentity linked = null; + if (kind == SubjectKind.BEDROCK_XUID) { + if (claims.linkedJava != null) throw reject(PrincipalError.LINK); + } else { + if (claims.linkedJava == null) throw reject(PrincipalError.LINK); + Linked link = claims.linkedJava; + UUID javaUuid = canonicalUuid(link.uuid, PrincipalError.LINK); + if (!validJavaName(link.name) + || !"moxy_account_link_v1".equals(link.provider) + || link.recordId.isEmpty() + || utf8Length(link.recordId) > 128 + || link.revision <= 0 + || !numericDate(link.verifiedAt) + || Math.abs(link.verifiedAt - claims.iat) > 5) { + throw reject(PrincipalError.LINK); + } + linked = new VerifiedLinkedJavaIdentity(javaUuid, link.name, + new LinkProvenance(link.provider, link.recordId, link.revision, + Instant.ofEpochSecond(link.verifiedAt))); + } + return new ImmutableVerifiedBedrockPrincipal( + kind, new CanonicalXuid(claims.canonicalXuid), unlinked, linked, + claims.bedrockDisplayName, + new VerificationEvidence(kid, claims.verificationMethod, + Instant.ofEpochSecond(claims.iat), Instant.ofEpochSecond(claims.nbf), + Instant.ofEpochSecond(claims.exp)), + new PrincipalBindings(expected.issuer(), expected.trustDomain(), expected.audience(), + expected.endpointId(), expected.organizationId(), expected.connectSessionId(), + expected.connectSessionNonce(), expected.sourceProtocol(), + expected.sourceProtocolVersion(), expected.policyRevision())); + } + + private static void validateTime(Claims claims, long now) throws PrincipalVerificationException { + if (!numericDate(claims.iat) || !numericDate(claims.nbf) || !numericDate(claims.exp) + || claims.nbf > claims.iat || claims.iat > claims.exp + || claims.exp - claims.iat > 30 + || claims.nbf > now + 5 || claims.iat > now + 5 || claims.exp < now - 5) { + throw reject(PrincipalError.TIME); + } + } + + private static boolean numericDate(long value) { + return value >= 0 && value <= MAX_UNIX_TIMESTAMP; + } + + private static boolean validVerificationMethod(String value) { + return "minecraft_legacy_chain+client_jwt+ecdh_v1".equals(value) + || "minecraft_full_jwks+client_jwt+ecdh_v1".equals(value); + } + + private static boolean validJavaName(String value) { + if (value.isEmpty() || value.length() > 16) return false; + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + if (!(character >= 'A' && character <= 'Z') + && !(character >= 'a' && character <= 'z') + && !(character >= '0' && character <= '9') + && character != '_') return false; + } + return true; + } + + private static UUID canonicalUuid(String value, PrincipalError error) + throws PrincipalVerificationException { + try { + UUID uuid = UUID.fromString(value); + if (value.length() != 36 || !uuid.toString().equals(value)) throw new IllegalArgumentException(); + return uuid; + } catch (IllegalArgumentException ignored) { + throw reject(error); + } + } + + private static boolean validBinding( + String protocol, int version, String endpoint, String organization, String session) { + return "bedrock".equals(protocol) && version >= 1 + && boundedBinding(endpoint) && boundedBinding(organization) && boundedBinding(session); + } + + private static boolean boundedBinding(String value) { + return value != null && utf8Length(value) >= 1 && utf8Length(value) <= 128; + } + + private static boolean validTrust(String value, int maximum) { + return value != null && utf8Length(value) >= 1 && utf8Length(value) <= maximum; + } + + private static boolean same(String left, String right) { + return MessageDigest.isEqual( + left.getBytes(StandardCharsets.UTF_8), right.getBytes(StandardCharsets.UTF_8)); + } + + private static boolean verifySignature(PublicKey key, byte[] input, byte[] signature) + throws PrincipalVerificationException { + try { + Signature verifier = Signature.getInstance("Ed25519"); + verifier.initVerify(key); + verifier.update(input); + return verifier.verify(signature); + } catch (GeneralSecurityException ignored) { + throw reject(PrincipalError.INTERNAL); + } + } + + private static byte[] decodeCanonical16(String value) throws PrincipalVerificationException { + if (value.length() != 22) throw reject(PrincipalError.MALFORMED); + try { + byte[] decoded = decodeCanonical(value, 16); + if (decoded.length != 16) throw malformed(); + return decoded; + } catch (IllegalArgumentException ignored) { + throw reject(PrincipalError.MALFORMED); + } + } + + private static byte[] decodeCanonical(String value, int maximum) { + if (value.indexOf('=') >= 0) throw malformed(); + byte[] decoded = Base64.getUrlDecoder().decode(value); + if (decoded.length > maximum + || !Base64.getUrlEncoder().withoutPadding().encodeToString(decoded).equals(value)) { + throw malformed(); + } + return decoded; + } + + private static String utf8(byte[] value) throws CharacterCodingException { + return StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(value)).toString(); + } + + private static int utf8Length(String value) { + return value.getBytes(StandardCharsets.UTF_8).length; + } + + private static PublicKey parsePublicKey(byte[] raw) { + try { + byte[] y = raw.clone(); + boolean xOdd = (y[31] & 0x80) != 0; + y[31] &= 0x7f; + for (int left = 0, right = y.length - 1; left < right; left++, right--) { + byte swap = y[left]; + y[left] = y[right]; + y[right] = swap; + } + return KeyFactory.getInstance("Ed25519").generatePublic(new EdECPublicKeySpec( + NamedParameterSpec.ED25519, new EdECPoint(xOdd, new BigInteger(1, y)))); + } catch (GeneralSecurityException | RuntimeException ignored) { + throw new IllegalArgumentException("invalid verifier public key"); + } + } + + private static void exact(Map object, Set allowed, Set required) { + if (!allowed.containsAll(object.keySet()) || !object.keySet().containsAll(required)) { + throw malformed(); + } + } + + private static String string(Map object, String name) { + Object value = object.get(name); + if (!(value instanceof String)) throw malformed(); + return (String) value; + } + + private static long integer(Map object, String name) { + Object value = object.get(name); + if (!(value instanceof Long)) throw malformed(); + return (Long) value; + } + + @SuppressWarnings("unchecked") + private static Map object(Map parent, String name) { + Object value = parent.get(name); + if (!(value instanceof Map)) throw malformed(); + return (Map) value; + } + + private static String bounded(String value, int minimum, int maximum) { + int length = utf8Length(value); + if (length < minimum || length > maximum) throw malformed(); + return value; + } + + private static IllegalArgumentException malformed() { + return new IllegalArgumentException(PrincipalError.MALFORMED.name()); + } + + private static PrincipalVerificationException reject(PrincipalError error) { + return new PrincipalVerificationException(error); + } + + private static final class ParsedEnvelope { + private final String kid; + private final Claims claims; + private final byte[] signingInput; + private final byte[] signature; + + private ParsedEnvelope(String kid, Claims claims, byte[] signingInput, byte[] signature) { + this.kid = kid; + this.claims = claims; + this.signingInput = signingInput; + this.signature = signature; + } + } + + private static final class Claims { + private int version; + private String issuer; + private String trustDomain; + private String audience; + private String subjectKind; + private String canonicalXuid; + private String canonicalUnlinkedUuid; + private Linked linkedJava; + private String bedrockDisplayName; + private String endpointId; + private String organizationId; + private String connectSessionId; + private String connectSessionNonce; + private long policyRevision; + private String sourceProtocol; + private int sourceProtocolVersion; + private long iat; + private long nbf; + private long exp; + private String jti; + private String verificationMethod; + + private static Claims from(Map value) { + Claims claims = new Claims(); + long version = integer(value, "version"); + long protocolVersion = integer(value, "source_protocol_version"); + if (version < Integer.MIN_VALUE || version > Integer.MAX_VALUE + || protocolVersion < Integer.MIN_VALUE || protocolVersion > Integer.MAX_VALUE) { + throw malformed(); + } + claims.version = (int) version; + claims.issuer = bounded(string(value, "issuer"), 1, 128); + claims.trustDomain = bounded(string(value, "trust_domain"), 1, 256); + claims.audience = bounded(string(value, "audience"), 1, 256); + claims.subjectKind = string(value, "subject_kind"); + claims.canonicalXuid = string(value, "canonical_xuid"); + claims.canonicalUnlinkedUuid = string(value, "canonical_unlinked_uuid"); + claims.bedrockDisplayName = string(value, "bedrock_display_name"); + claims.endpointId = bounded(string(value, "endpoint_id"), 1, 128); + claims.organizationId = bounded(string(value, "organization_id"), 1, 128); + claims.connectSessionId = bounded(string(value, "connect_session_id"), 1, 128); + claims.connectSessionNonce = string(value, "connect_session_nonce"); + claims.policyRevision = integer(value, "policy_revision"); + claims.sourceProtocol = string(value, "source_protocol"); + claims.sourceProtocolVersion = (int) protocolVersion; + claims.iat = integer(value, "iat"); + claims.nbf = integer(value, "nbf"); + claims.exp = integer(value, "exp"); + claims.jti = string(value, "jti"); + claims.verificationMethod = string(value, "verification_method"); + if (value.containsKey("linked_java")) claims.linkedJava = Linked.from(object(value, "linked_java")); + return claims; + } + } + + private static final class Linked { + private String uuid; + private String name; + private String provider; + private String recordId; + private long revision; + private long verifiedAt; + + private static Linked from(Map value) { + exact(value, LINK_FIELDS, LINK_FIELDS); + Map provenance = object(value, "provenance"); + exact(provenance, PROVENANCE_FIELDS, PROVENANCE_FIELDS); + Linked linked = new Linked(); + linked.uuid = string(value, "uuid"); + linked.name = string(value, "name"); + linked.provider = string(provenance, "provider"); + linked.recordId = string(provenance, "record_id"); + linked.revision = integer(provenance, "revision"); + linked.verifiedAt = integer(provenance, "verified_at"); + return linked; + } + } + + private static final class ReplayValue { + private final String trustDomain; + private final String issuer; + private final String jti; + @SuppressWarnings("unused") private final String kid; + @SuppressWarnings("unused") private final String endpointId; + @SuppressWarnings("unused") private final String sessionId; + @SuppressWarnings("unused") private final byte[] nonce; + private final long expiresAt; + + private ReplayValue(String trustDomain, String issuer, String jti, String kid, + String endpointId, String sessionId, byte[] nonce, long expiresAt) { + this.trustDomain = trustDomain; + this.issuer = issuer; + this.jti = jti; + this.kid = kid; + this.endpointId = endpointId; + this.sessionId = sessionId; + this.nonce = nonce.clone(); + this.expiresAt = expiresAt; + } + } + + private static final class ReplayCache { + private final int capacity; + private final Clock clock; + private final Map entries = new HashMap<>(); + + private ReplayCache(int capacity, Clock clock) { + this.capacity = capacity; + this.clock = clock; + } + + private synchronized void consume(ReplayValue value) throws PrincipalVerificationException { + long now = clock.instant().getEpochSecond(); + String key = value.trustDomain + '\0' + value.issuer + '\0' + value.jti; + ReplayValue existing = entries.get(key); + if (existing != null) { + if (now <= existing.expiresAt) throw reject(PrincipalError.REPLAY); + entries.remove(key); + } + if (entries.size() >= capacity) { + int removed = 0; + Iterator> iterator = entries.entrySet().iterator(); + while (iterator.hasNext() && removed < 64) { + if (now > iterator.next().getValue().expiresAt) { + iterator.remove(); + removed++; + } + } + } + if (entries.size() >= capacity) throw reject(PrincipalError.CAPACITY); + entries.put(key, value); + } + } +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/EffectiveGameProfile.java b/api/src/main/java/com/minekube/connect/api/player/principal/EffectiveGameProfile.java new file mode 100644 index 000000000..6c5c3f079 --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/EffectiveGameProfile.java @@ -0,0 +1,12 @@ +package com.minekube.connect.api.player.principal; + +import java.util.Objects; +import java.util.UUID; + +/** The single game profile selected by a verified principal. */ +public record EffectiveGameProfile(UUID uuid, String name) { + public EffectiveGameProfile { + Objects.requireNonNull(uuid, "uuid"); + Objects.requireNonNull(name, "name"); + } +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/ImmutableVerifiedBedrockPrincipal.java b/api/src/main/java/com/minekube/connect/api/player/principal/ImmutableVerifiedBedrockPrincipal.java new file mode 100644 index 000000000..e74590be8 --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/ImmutableVerifiedBedrockPrincipal.java @@ -0,0 +1,53 @@ +package com.minekube.connect.api.player.principal; + +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +final class ImmutableVerifiedBedrockPrincipal implements VerifiedBedrockPrincipal { + private final transient SubjectKind subjectKind; + private final transient CanonicalXuid xuid; + private final transient UUID canonicalUnlinkedUuid; + private final transient VerifiedLinkedJavaIdentity linkedJava; + private final transient String bedrockDisplayName; + private final transient VerificationEvidence verification; + private final transient PrincipalBindings bindings; + + ImmutableVerifiedBedrockPrincipal( + SubjectKind subjectKind, + CanonicalXuid xuid, + UUID canonicalUnlinkedUuid, + VerifiedLinkedJavaIdentity linkedJava, + String bedrockDisplayName, + VerificationEvidence verification, + PrincipalBindings bindings) { + this.subjectKind = Objects.requireNonNull(subjectKind, "subjectKind"); + this.xuid = Objects.requireNonNull(xuid, "xuid"); + this.canonicalUnlinkedUuid = Objects.requireNonNull(canonicalUnlinkedUuid, "canonicalUnlinkedUuid"); + this.linkedJava = linkedJava; + this.bedrockDisplayName = Objects.requireNonNull(bedrockDisplayName, "bedrockDisplayName"); + this.verification = Objects.requireNonNull(verification, "verification"); + this.bindings = Objects.requireNonNull(bindings, "bindings"); + } + + @Override public SubjectKind subjectKind() { return subjectKind; } + @Override public CanonicalXuid xuid() { return xuid; } + @Override public UUID canonicalUnlinkedUuid() { return canonicalUnlinkedUuid; } + @Override public Optional linkedJava() { return Optional.ofNullable(linkedJava); } + @Override public String bedrockDisplayName() { return bedrockDisplayName; } + @Override public VerificationEvidence verification() { return verification; } + @Override public PrincipalBindings bindings() { return bindings; } + + @Override + public EffectiveGameProfile effectiveGameProfile() { + return linkedJava == null + ? new EffectiveGameProfile(canonicalUnlinkedUuid, bedrockDisplayName) + : new EffectiveGameProfile(linkedJava.uuid(), linkedJava.name()); + } + + @Override + public String toString() { + return "VerifiedBedrockPrincipal[subjectKind=" + subjectKind + ", linked=" + + (linkedJava != null) + ", verification=" + verification + "]"; + } +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/LinkProvenance.java b/api/src/main/java/com/minekube/connect/api/player/principal/LinkProvenance.java new file mode 100644 index 000000000..a176d7fc9 --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/LinkProvenance.java @@ -0,0 +1,29 @@ +package com.minekube.connect.api.player.principal; + +import java.time.Instant; +import java.util.Objects; + +/** Non-credential provenance for an independently verified Java account link. */ +public final class LinkProvenance { + private final transient String provider; + private final transient String recordId; + private final long revision; + private final Instant verifiedAt; + + public LinkProvenance(String provider, String recordId, long revision, Instant verifiedAt) { + this.provider = Objects.requireNonNull(provider, "provider"); + this.recordId = Objects.requireNonNull(recordId, "recordId"); + this.revision = revision; + this.verifiedAt = Objects.requireNonNull(verifiedAt, "verifiedAt"); + } + + public String provider() { return provider; } + public String recordId() { return recordId; } + public long revision() { return revision; } + public Instant verifiedAt() { return verifiedAt; } + + @Override + public String toString() { + return "LinkProvenance[revision=" + revision + ", verifiedAt=" + verifiedAt + "]"; + } +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/PrincipalBindings.java b/api/src/main/java/com/minekube/connect/api/player/principal/PrincipalBindings.java new file mode 100644 index 000000000..c44b18903 --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/PrincipalBindings.java @@ -0,0 +1,83 @@ +package com.minekube.connect.api.player.principal; + +import java.util.Arrays; +import java.util.Objects; + +/** Authenticated proposal bindings matched by a verified principal. */ +public class PrincipalBindings { + private final String issuer; + private final String trustDomain; + private final String audience; + private final String endpointId; + private final String organizationId; + private final String connectSessionId; + private final transient byte[] connectSessionNonce; + private final String sourceProtocol; + private final int sourceProtocolVersion; + private final long policyRevision; + + public PrincipalBindings( + String issuer, + String trustDomain, + String audience, + String endpointId, + String organizationId, + String connectSessionId, + byte[] connectSessionNonce, + String sourceProtocol, + int sourceProtocolVersion, + long policyRevision) { + this.issuer = Objects.requireNonNull(issuer, "issuer"); + this.trustDomain = Objects.requireNonNull(trustDomain, "trustDomain"); + this.audience = Objects.requireNonNull(audience, "audience"); + this.endpointId = Objects.requireNonNull(endpointId, "endpointId"); + this.organizationId = Objects.requireNonNull(organizationId, "organizationId"); + this.connectSessionId = Objects.requireNonNull(connectSessionId, "connectSessionId"); + this.connectSessionNonce = Objects.requireNonNull(connectSessionNonce, "connectSessionNonce").clone(); + this.sourceProtocol = Objects.requireNonNull(sourceProtocol, "sourceProtocol"); + this.sourceProtocolVersion = sourceProtocolVersion; + this.policyRevision = policyRevision; + } + + public String issuer() { return issuer; } + public String trustDomain() { return trustDomain; } + public String audience() { return audience; } + public String endpointId() { return endpointId; } + public String organizationId() { return organizationId; } + public String connectSessionId() { return connectSessionId; } + public byte[] connectSessionNonce() { return connectSessionNonce.clone(); } + public String sourceProtocol() { return sourceProtocol; } + public int sourceProtocolVersion() { return sourceProtocolVersion; } + public long policyRevision() { return policyRevision; } + + @Override + public String toString() { + return "PrincipalBindings[issuer=" + issuer + ", trustDomain=" + trustDomain + + ", audience=" + audience + ", sourceProtocol=" + sourceProtocol + + ", sourceProtocolVersion=" + sourceProtocolVersion + + ", policyRevision=" + policyRevision + "]"; + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof PrincipalBindings)) return false; + PrincipalBindings that = (PrincipalBindings) other; + return sourceProtocolVersion == that.sourceProtocolVersion + && policyRevision == that.policyRevision + && issuer.equals(that.issuer) + && trustDomain.equals(that.trustDomain) + && audience.equals(that.audience) + && endpointId.equals(that.endpointId) + && organizationId.equals(that.organizationId) + && connectSessionId.equals(that.connectSessionId) + && Arrays.equals(connectSessionNonce, that.connectSessionNonce) + && sourceProtocol.equals(that.sourceProtocol); + } + + @Override + public int hashCode() { + return Objects.hash(issuer, trustDomain, audience, endpointId, organizationId, + connectSessionId, Arrays.hashCode(connectSessionNonce), sourceProtocol, + sourceProtocolVersion, policyRevision); + } +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/PrincipalError.java b/api/src/main/java/com/minekube/connect/api/player/principal/PrincipalError.java new file mode 100644 index 000000000..8405c6d56 --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/PrincipalError.java @@ -0,0 +1,18 @@ +package com.minekube.connect.api.player.principal; + +/** Stable, privacy-safe Bedrock principal v2 verification categories. */ +public enum PrincipalError { + MALFORMED, + TRUST, + SIGNATURE, + BINDING_MISMATCH, + TIME, + IDENTITY, + LINK, + REPLAY, + CAPACITY, + METADATA_UNAVAILABLE, + KEY_REVOKED, + READINESS, + INTERNAL +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/PrincipalVerificationException.java b/api/src/main/java/com/minekube/connect/api/player/principal/PrincipalVerificationException.java new file mode 100644 index 000000000..09374bd59 --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/PrincipalVerificationException.java @@ -0,0 +1,17 @@ +package com.minekube.connect.api.player.principal; + +import java.util.Objects; + +/** A privacy-safe verification rejection whose only contract is its bounded category. */ +public final class PrincipalVerificationException extends Exception { + private final PrincipalError error; + + public PrincipalVerificationException(PrincipalError error) { + super(Objects.requireNonNull(error, "error").name(), null, false, false); + this.error = error; + } + + public PrincipalError error() { + return error; + } +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/SignedPrincipalEnvelope.java b/api/src/main/java/com/minekube/connect/api/player/principal/SignedPrincipalEnvelope.java new file mode 100644 index 000000000..906dcf8ec --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/SignedPrincipalEnvelope.java @@ -0,0 +1,32 @@ +package com.minekube.connect.api.player.principal; + +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +/** Opaque, bounded compact-JWS input. */ +public final class SignedPrincipalEnvelope { + public static final int MAX_COMPACT_BYTES = 16 * 1024; + private final transient String compact; + + private SignedPrincipalEnvelope(String compact) { + this.compact = compact; + } + + public static SignedPrincipalEnvelope of(String compact) { + Objects.requireNonNull(compact, "compact"); + int size = compact.getBytes(StandardCharsets.UTF_8).length; + if (size == 0 || size > MAX_COMPACT_BYTES) { + throw new IllegalArgumentException(PrincipalError.MALFORMED.name()); + } + return new SignedPrincipalEnvelope(compact); + } + + String compact() { + return compact; + } + + @Override + public String toString() { + return "SignedPrincipalEnvelope[redacted]"; + } +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/StrictJson.java b/api/src/main/java/com/minekube/connect/api/player/principal/StrictJson.java new file mode 100644 index 000000000..0007816af --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/StrictJson.java @@ -0,0 +1,184 @@ +package com.minekube.connect.api.player.principal; + +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +/** Minimal closed-object JSON parser for the frozen principal envelope. */ +final class StrictJson { + private final String input; + private int position; + private int stringBytes; + + private StrictJson(String input) { + this.input = input; + } + + static Map parseObject(String input, int decodedBytes) { + StrictJson parser = new StrictJson(input); + Map object = parser.object(1); + parser.space(); + if (parser.position != input.length() + || parser.stringBytes > 24_576 + || decodedBytes + parser.stringBytes > 65_536) { + throw malformed(); + } + return object; + } + + private Map object(int depth) { + if (depth > 4) throw malformed(); + expect('{'); + space(); + Map result = new LinkedHashMap<>(); + if (take('}')) return result; + while (true) { + if (result.size() == 32) throw malformed(); + String name = string(); + if (result.containsKey(name)) throw malformed(); + space(); + expect(':'); + space(); + result.put(name, value(depth)); + space(); + if (take('}')) return result; + expect(','); + space(); + } + } + + private Object value(int depth) { + if (position >= input.length()) throw malformed(); + char value = input.charAt(position); + if (value == '"') return string(); + if (value == '{') return object(depth + 1); + if (value == 't') return literal("true", Boolean.TRUE); + if (value == 'f') return literal("false", Boolean.FALSE); + if (value == 'n') return literal("null", null); + if (value == '[') throw malformed(); + return number(); + } + + private Object literal(String literal, Object value) { + if (!input.regionMatches(position, literal, 0, literal.length())) throw malformed(); + position += literal.length(); + return value; + } + + private Long number() { + int start = position; + if (take('-') && position == input.length()) throw malformed(); + if (take('0')) { + if (position < input.length() && Character.isDigit(input.charAt(position))) throw malformed(); + } else { + int digits = position; + while (position < input.length() && input.charAt(position) >= '0' + && input.charAt(position) <= '9') position++; + if (position == digits) throw malformed(); + } + if (position < input.length()) { + char next = input.charAt(position); + if (next == '.' || next == 'e' || next == 'E' || next == '+') throw malformed(); + } + try { + return Long.valueOf(input.substring(start, position)); + } catch (NumberFormatException ignored) { + throw malformed(); + } + } + + private String string() { + expect('"'); + StringBuilder result = new StringBuilder(); + while (position < input.length()) { + char value = input.charAt(position++); + if (value == '"') { + String decoded = result.toString(); + if (decoded.indexOf('\0') >= 0) throw malformed(); + stringBytes += decoded.getBytes(StandardCharsets.UTF_8).length; + return decoded; + } + if (value < 0x20) throw malformed(); + if (value == '\\') { + if (position == input.length()) throw malformed(); + char escape = input.charAt(position++); + switch (escape) { + case '"': result.append('"'); break; + case '\\': result.append('\\'); break; + case '/': result.append('/'); break; + case 'b': result.append('\b'); break; + case 'f': result.append('\f'); break; + case 'n': result.append('\n'); break; + case 'r': result.append('\r'); break; + case 't': result.append('\t'); break; + case 'u': appendUnicode(result); break; + default: throw malformed(); + } + continue; + } + if (Character.isHighSurrogate(value)) { + if (position == input.length() || !Character.isLowSurrogate(input.charAt(position))) { + throw malformed(); + } + result.append(value).append(input.charAt(position++)); + } else if (Character.isLowSurrogate(value)) { + throw malformed(); + } else { + result.append(value); + } + } + throw malformed(); + } + + private void appendUnicode(StringBuilder result) { + char high = hex16(); + if (Character.isHighSurrogate(high)) { + if (position + 2 > input.length() + || input.charAt(position) != '\\' + || input.charAt(position + 1) != 'u') throw malformed(); + position += 2; + char low = hex16(); + if (!Character.isLowSurrogate(low)) throw malformed(); + result.append(high).append(low); + } else if (Character.isLowSurrogate(high)) { + throw malformed(); + } else { + result.append(high); + } + } + + private char hex16() { + if (position + 4 > input.length()) throw malformed(); + int result = 0; + for (int index = 0; index < 4; index++) { + int digit = Character.digit(input.charAt(position++), 16); + if (digit < 0) throw malformed(); + result = (result << 4) | digit; + } + return (char) result; + } + + private void expect(char value) { + if (!take(value)) throw malformed(); + } + + private boolean take(char value) { + if (position < input.length() && input.charAt(position) == value) { + position++; + return true; + } + return false; + } + + private void space() { + while (position < input.length()) { + char value = input.charAt(position); + if (value != ' ' && value != '\n' && value != '\r' && value != '\t') return; + position++; + } + } + + private static IllegalArgumentException malformed() { + return new IllegalArgumentException(PrincipalError.MALFORMED.name()); + } +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/SubjectKind.java b/api/src/main/java/com/minekube/connect/api/player/principal/SubjectKind.java new file mode 100644 index 000000000..65258bbfc --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/SubjectKind.java @@ -0,0 +1,24 @@ +package com.minekube.connect.api.player.principal; + +/** The closed Bedrock principal v2 subject set. */ +public enum SubjectKind { + BEDROCK_XUID("bedrock_xuid"), + BEDROCK_LINKED_JAVA("bedrock_linked_java"); + + private final String wireName; + + SubjectKind(String wireName) { + this.wireName = wireName; + } + + public String wireName() { + return wireName; + } + + static SubjectKind fromWireName(String value) throws PrincipalVerificationException { + for (SubjectKind kind : values()) { + if (kind.wireName.equals(value)) return kind; + } + throw new PrincipalVerificationException(PrincipalError.IDENTITY); + } +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/TrustedProposalContext.java b/api/src/main/java/com/minekube/connect/api/player/principal/TrustedProposalContext.java new file mode 100644 index 000000000..7b27f6c0a --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/TrustedProposalContext.java @@ -0,0 +1,19 @@ +package com.minekube.connect.api.player.principal; + +/** Trusted bindings constructed from the authenticated proposal/session path. */ +public final class TrustedProposalContext extends PrincipalBindings { + public TrustedProposalContext( + String issuer, + String trustDomain, + String audience, + String endpointId, + String organizationId, + String connectSessionId, + byte[] connectSessionNonce, + String sourceProtocol, + int sourceProtocolVersion, + long policyRevision) { + super(issuer, trustDomain, audience, endpointId, organizationId, connectSessionId, + connectSessionNonce, sourceProtocol, sourceProtocolVersion, policyRevision); + } +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/VerificationEvidence.java b/api/src/main/java/com/minekube/connect/api/player/principal/VerificationEvidence.java new file mode 100644 index 000000000..431ca98c0 --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/VerificationEvidence.java @@ -0,0 +1,20 @@ +package com.minekube.connect.api.player.principal; + +import java.time.Instant; +import java.util.Objects; + +/** Bounded, non-secret evidence about a successful verification. */ +public record VerificationEvidence( + String kid, + String verificationMethod, + Instant issuedAt, + Instant notBefore, + Instant expiresAt) { + public VerificationEvidence { + Objects.requireNonNull(kid, "kid"); + Objects.requireNonNull(verificationMethod, "verificationMethod"); + Objects.requireNonNull(issuedAt, "issuedAt"); + Objects.requireNonNull(notBefore, "notBefore"); + Objects.requireNonNull(expiresAt, "expiresAt"); + } +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/VerifiedBedrockPrincipal.java b/api/src/main/java/com/minekube/connect/api/player/principal/VerifiedBedrockPrincipal.java new file mode 100644 index 000000000..f5163def2 --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/VerifiedBedrockPrincipal.java @@ -0,0 +1,15 @@ +package com.minekube.connect.api.player.principal; + +import java.util.Optional; +import java.util.UUID; + +/** A Bedrock principal returned only after complete v2 verification and replay consumption. */ +public sealed interface VerifiedBedrockPrincipal extends VerifiedPrincipal + permits ImmutableVerifiedBedrockPrincipal { + CanonicalXuid xuid(); + UUID canonicalUnlinkedUuid(); + Optional linkedJava(); + String bedrockDisplayName(); + VerificationEvidence verification(); + PrincipalBindings bindings(); +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/VerifiedLinkedJavaIdentity.java b/api/src/main/java/com/minekube/connect/api/player/principal/VerifiedLinkedJavaIdentity.java new file mode 100644 index 000000000..7fb1a525c --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/VerifiedLinkedJavaIdentity.java @@ -0,0 +1,26 @@ +package com.minekube.connect.api.player.principal; + +import java.util.Objects; +import java.util.UUID; + +/** Immutable Java identity selected only after link provenance verification. */ +public final class VerifiedLinkedJavaIdentity { + private final transient UUID uuid; + private final transient String name; + private final transient LinkProvenance provenance; + + public VerifiedLinkedJavaIdentity(UUID uuid, String name, LinkProvenance provenance) { + this.uuid = Objects.requireNonNull(uuid, "uuid"); + this.name = Objects.requireNonNull(name, "name"); + this.provenance = Objects.requireNonNull(provenance, "provenance"); + } + + public UUID uuid() { return uuid; } + public String name() { return name; } + public LinkProvenance provenance() { return provenance; } + + @Override + public String toString() { + return "VerifiedLinkedJavaIdentity[redacted]"; + } +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/VerifiedPrincipal.java b/api/src/main/java/com/minekube/connect/api/player/principal/VerifiedPrincipal.java new file mode 100644 index 000000000..9f33334db --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/VerifiedPrincipal.java @@ -0,0 +1,7 @@ +package com.minekube.connect.api.player.principal; + +/** A verifier-created principal. Host code cannot implement this sealed hierarchy. */ +public sealed interface VerifiedPrincipal permits VerifiedBedrockPrincipal { + SubjectKind subjectKind(); + EffectiveGameProfile effectiveGameProfile(); +} diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/VerifierConfiguration.java b/api/src/main/java/com/minekube/connect/api/player/principal/VerifierConfiguration.java new file mode 100644 index 000000000..531c73289 --- /dev/null +++ b/api/src/main/java/com/minekube/connect/api/player/principal/VerifierConfiguration.java @@ -0,0 +1,68 @@ +package com.minekube.connect.api.player.principal; + +import java.time.Clock; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** Immutable verifier dependencies. Static keys are keyed by the frozen non-secret JWS kid. */ +public final class VerifierConfiguration { + private final Map publicKeys; + private final Clock clock; + private final int replayCapacity; + + private VerifierConfiguration(Builder builder) { + Map keys = new LinkedHashMap<>(); + builder.publicKeys.forEach((kid, key) -> keys.put(kid, key.clone())); + this.publicKeys = Collections.unmodifiableMap(keys); + this.clock = builder.clock; + this.replayCapacity = builder.replayCapacity; + } + + public static Builder builder() { + return new Builder(); + } + + Map publicKeys() { + Map copy = new LinkedHashMap<>(); + publicKeys.forEach((kid, key) -> copy.put(kid, key.clone())); + return copy; + } + + Clock clock() { return clock; } + int replayCapacity() { return replayCapacity; } + + public static final class Builder { + private final Map publicKeys = new LinkedHashMap<>(); + private Clock clock = Clock.systemUTC(); + private int replayCapacity = 65_536; + + public Builder publicKey(String kid, byte[] rawEd25519PublicKey) { + Objects.requireNonNull(kid, "kid"); + Objects.requireNonNull(rawEd25519PublicKey, "rawEd25519PublicKey"); + if (kid.isEmpty() || kid.length() > 128 || rawEd25519PublicKey.length != 32) { + throw new IllegalArgumentException("invalid verifier public key"); + } + publicKeys.put(kid, rawEd25519PublicKey.clone()); + return this; + } + + public Builder clock(Clock clock) { + this.clock = Objects.requireNonNull(clock, "clock"); + return this; + } + + public Builder replayCapacity(int replayCapacity) { + if (replayCapacity <= 0 || replayCapacity > 65_536) { + throw new IllegalArgumentException("replayCapacity must be between 1 and 65536"); + } + this.replayCapacity = replayCapacity; + return this; + } + + public VerifierConfiguration build() { + return new VerifierConfiguration(this); + } + } +} diff --git a/build-logic/src/main/kotlin/connect.base-conventions.gradle.kts b/build-logic/src/main/kotlin/connect.base-conventions.gradle.kts index a64bf8267..0189a5e47 100644 --- a/build-logic/src/main/kotlin/connect.base-conventions.gradle.kts +++ b/build-logic/src/main/kotlin/connect.base-conventions.gradle.kts @@ -28,8 +28,8 @@ tasks { } java { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 withSourcesJar() } diff --git a/bungee/src/main/java/com/minekube/connect/listener/BungeeLateReassertListener.java b/bungee/src/main/java/com/minekube/connect/listener/BungeeLateReassertListener.java index 5d7ee5033..6d867b6b3 100644 --- a/bungee/src/main/java/com/minekube/connect/listener/BungeeLateReassertListener.java +++ b/bungee/src/main/java/com/minekube/connect/listener/BungeeLateReassertListener.java @@ -84,9 +84,8 @@ private void reassert(PendingConnection connection, ConnectPlayer player) { } if (connection.isOnlineMode()) { connection.setOnlineMode(false); - logger.debug("Re-asserted offline mode for Connect session {} at pre-login; another " - + "plugin had changed it (set login-reassert.enabled to false to allow that)", - player.getUsername()); + logger.debug("Re-asserted offline mode for a Connect session at pre-login; another " + + "plugin had changed it (set login-reassert.enabled to false to allow that)"); } if (!config.getLoginReassert().isRestoreFullProfile()) { return; 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 e8b1f15c5..0adc63b06 100644 --- a/core/src/main/java/com/minekube/connect/api/SimpleConnectApi.java +++ b/core/src/main/java/com/minekube/connect/api/SimpleConnectApi.java @@ -32,6 +32,7 @@ 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.principal.VerifiedBedrockPrincipal; import com.minekube.connect.bedrock.VerifiedBedrockIdentityRegistry; import java.util.Collection; import java.util.Map; @@ -105,6 +106,16 @@ public Optional getVerifiedBedrockIdentity(ConnectPlayer : verifiedBedrockIdentities.get(player); } + @Override + public Optional getVerifiedBedrockPrincipal(ConnectPlayer player) { + if (player == null || players.get(player.getUniqueId()) != player) { + return Optional.empty(); + } + return verifiedBedrockIdentities == null + ? Optional.empty() + : verifiedBedrockIdentities.getPrincipal(player); + } + /** * This method is invoked when the player is no longer on the server, but the related platform- * dependant event hasn't fired yet diff --git a/core/src/main/java/com/minekube/connect/bedrock/BedrockAdmissionCoordinator.java b/core/src/main/java/com/minekube/connect/bedrock/BedrockAdmissionCoordinator.java index 08b9ca7c0..8201d27c8 100644 --- a/core/src/main/java/com/minekube/connect/bedrock/BedrockAdmissionCoordinator.java +++ b/core/src/main/java/com/minekube/connect/bedrock/BedrockAdmissionCoordinator.java @@ -7,6 +7,7 @@ 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.api.player.principal.VerifiedBedrockPrincipal; import com.minekube.connect.player.ConnectPlayerImpl; import com.minekube.connect.watch.SessionProposal; import java.util.ArrayList; @@ -30,6 +31,7 @@ public final class BedrockAdmissionCoordinator implements AutoCloseable { private static final long ADMISSION_TTL_SECONDS = 30; private final VerifiedBedrockIdentityRegistry identities; + private final BedrockPrincipalConsumer principalConsumer; private final ScheduledExecutorService cleanupExecutor; private final Map admissions = new HashMap<>(); private final Map latestBySession = new HashMap<>(); @@ -38,14 +40,28 @@ public final class BedrockAdmissionCoordinator implements AutoCloseable { private boolean closed; @Inject + public BedrockAdmissionCoordinator( + VerifiedBedrockIdentityRegistry identities, + BedrockPrincipalConsumer principalConsumer) { + this(identities, principalConsumer, newCleanupExecutor()); + } + public BedrockAdmissionCoordinator(VerifiedBedrockIdentityRegistry identities) { - this(identities, newCleanupExecutor()); + this(identities, null, newCleanupExecutor()); } BedrockAdmissionCoordinator( VerifiedBedrockIdentityRegistry identities, ScheduledExecutorService cleanupExecutor) { + this(identities, null, cleanupExecutor); + } + + private BedrockAdmissionCoordinator( + VerifiedBedrockIdentityRegistry identities, + BedrockPrincipalConsumer principalConsumer, + ScheduledExecutorService cleanupExecutor) { this.identities = Objects.requireNonNull(identities, "identities"); + this.principalConsumer = principalConsumer; this.cleanupExecutor = Objects.requireNonNull(cleanupExecutor, "cleanupExecutor"); } @@ -85,7 +101,12 @@ public synchronized ConnectPlayer stage(SessionProposal proposal) { admission.state != AdmissionState.PENDING) { throw new IllegalStateException("Bedrock admission has expired or been superseded"); } - ConnectPlayer publicPlayer = publicPlayer(playerFor(admission.raw)); + if (principalConsumer != null) { + admission.principal = principalConsumer.verify(admission.raw).orElse(null); + } + ConnectPlayer publicPlayer = admission.principal == null + ? publicPlayer(playerFor(admission.raw)) + : playerFor(admission.raw, admission.principal); admission.player = publicPlayer; players.put(publicPlayer, token); return publicPlayer; @@ -116,8 +137,10 @@ public BedrockIdentityEnforcer.Decision verify( } BedrockIdentityEnforcer.Decision decision; try { - decision = enforcer.verifyAdmissionSnapshot( - player, rawProfile, endpointId, endpointOrgId, protocol); + decision = admission.principal == null + ? enforcer.verifyAdmissionSnapshot( + player, rawProfile, endpointId, endpointOrgId, protocol) + : BedrockIdentityEnforcer.Decision.allowed(null); } catch (RuntimeException | Error e) { synchronized (this) { removeAdmission(admission); @@ -138,6 +161,9 @@ public BedrockIdentityEnforcer.Decision verify( if (decision.verifiedClaims() != null) { identities.record(player, token.generation, decision.verifiedClaims()); } + if (admission.principal != null) { + identities.recordPrincipal(player, token.generation, admission.principal); + } return decision; } } @@ -193,6 +219,16 @@ public static ConnectPlayer playerFor(Session session) { ""); } + private static ConnectPlayer playerFor(Session session, VerifiedBedrockPrincipal principal) { + Player player = session.getPlayer(); + var effective = principal.effectiveGameProfile(); + GameProfile original = profile(player); + GameProfile selected = new GameProfile( + effective.name(), effective.uuid(), BedrockIdentityProfiles.withoutEnvelope(original).getProperties()); + return new ConnectPlayerImpl( + session.getId(), selected, new Auth(session.getAuth().getPassthrough()), ""); + } + private static GameProfile profile(Player player) { return new GameProfile( player.getProfile().getName(), @@ -273,6 +309,7 @@ private static final class Admission { private final AdmissionToken token; private final String sessionId; private ConnectPlayer player; + private VerifiedBedrockPrincipal principal; private ScheduledFuture cleanup; private AdmissionState state = AdmissionState.PENDING; diff --git a/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalAdmissionException.java b/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalAdmissionException.java new file mode 100644 index 000000000..a8d014e80 --- /dev/null +++ b/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalAdmissionException.java @@ -0,0 +1,18 @@ +package com.minekube.connect.bedrock; + +import com.minekube.connect.api.player.principal.PrincipalError; +import java.util.Objects; + +/** Privacy-safe boundary exception used before a platform profile is applied. */ +public final class BedrockPrincipalAdmissionException extends RuntimeException { + private final PrincipalError error; + + BedrockPrincipalAdmissionException(PrincipalError error) { + super(Objects.requireNonNull(error, "error").name(), null, false, false); + this.error = error; + } + + public PrincipalError error() { + return error; + } +} diff --git a/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalConfiguration.java b/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalConfiguration.java new file mode 100644 index 000000000..7b1e327b0 --- /dev/null +++ b/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalConfiguration.java @@ -0,0 +1,54 @@ +package com.minekube.connect.bedrock; + +import com.minekube.connect.config.ConnectConfig.BedrockPrincipalConfig; +import java.net.URI; + +/** Closed local generation-2 configuration view. */ +final class BedrockPrincipalConfiguration { + static final String METADATA_PATH = "/.well-known/minekube-connect/bedrock-principal-v2.json"; + + private final boolean capable; + + private BedrockPrincipalConfiguration(boolean capable) { + this.capable = capable; + } + + static BedrockPrincipalConfiguration from(BedrockPrincipalConfig config) { + if (config == null) return new BedrockPrincipalConfiguration(false); + boolean capable = config.getConfigGeneration() == 2 + && "require".equals(config.getMode()) + && bounded(config.getIssuer(), 128) + && bounded(config.getTrustDomain(), 256) + && bounded(config.getAudience(), 256) + && validOrigin(config.getMetadataOrigin(), config.getTrustDomain()) + && METADATA_PATH.equals(config.getMetadataPath()); + return new BedrockPrincipalConfiguration(capable); + } + + boolean isCapable() { + return capable; + } + + private static boolean bounded(String value, int maximum) { + return value != null && !value.isEmpty() + && value.getBytes(java.nio.charset.StandardCharsets.UTF_8).length <= maximum; + } + + private static boolean validOrigin(String value, String trustDomain) { + try { + URI origin = URI.create(value); + boolean valid = "https".equals(origin.getScheme()) + && origin.getHost() != null + && origin.getRawUserInfo() == null + && origin.getPort() == -1 + && (origin.getRawPath() == null || origin.getRawPath().isEmpty()) + && origin.getRawQuery() == null + && origin.getRawFragment() == null; + if (!valid) return false; + return !"urn:minekube:connect:production".equals(trustDomain) + || "https://connect.minekube.com".equals(value); + } catch (IllegalArgumentException ignored) { + return false; + } + } +} diff --git a/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalConsumer.java b/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalConsumer.java new file mode 100644 index 000000000..f55a6586e --- /dev/null +++ b/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalConsumer.java @@ -0,0 +1,127 @@ +package com.minekube.connect.bedrock; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import com.minekube.connect.api.player.bedrock.BedrockIdentityProfiles; +import com.minekube.connect.api.player.principal.BedrockPrincipalVerifier; +import com.minekube.connect.api.player.principal.BedrockPrincipalVerifierFactory; +import com.minekube.connect.api.player.principal.PrincipalError; +import com.minekube.connect.api.player.principal.PrincipalVerificationException; +import com.minekube.connect.api.player.principal.SignedPrincipalEnvelope; +import com.minekube.connect.api.player.principal.TrustedProposalContext; +import com.minekube.connect.api.player.principal.VerifiedBedrockPrincipal; +import com.minekube.connect.api.player.principal.VerifierConfiguration; +import com.minekube.connect.config.ConfigHolder; +import com.minekube.connect.config.ConnectConfig; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.util.Base64; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Supplier; +import minekube.connect.v1alpha1.WatchServiceOuterClass.Session; +import minekube.connect.v1alpha1.WatchServiceOuterClass.SessionProtocol; + +/** Consumes the frozen opaque Watch/libp2p v2 fields before host profile application. */ +@Singleton +public final class BedrockPrincipalConsumer { + private final Supplier config; + private final Clock clock; + private BedrockPrincipalVerifier verifier; + + @Inject + public BedrockPrincipalConsumer(ConfigHolder configHolder) { + this(Objects.requireNonNull(configHolder, "configHolder")::get, Clock.systemUTC()); + } + + BedrockPrincipalConsumer(ConnectConfig config, Clock clock) { + this(() -> Objects.requireNonNull(config, "config"), clock); + } + + private BedrockPrincipalConsumer(Supplier config, Clock clock) { + this.config = Objects.requireNonNull(config, "config"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + public Optional verify(Session session) { + Objects.requireNonNull(session, "session"); + if (hasInjectedProperty(session)) { + throw new BedrockPrincipalAdmissionException(PrincipalError.BINDING_MISMATCH); + } + if (session.getSignedBedrockPrincipalV2().isEmpty()) { + return Optional.empty(); + } + if (!BedrockPrincipalConfiguration.from(config().getBedrockPrincipal()).isCapable()) { + throw new BedrockPrincipalAdmissionException(PrincipalError.READINESS); + } + if (session.getProtocol() != SessionProtocol.SESSION_PROTOCOL_BEDROCK + || session.getConnectSessionNonce().size() != 16 + || session.getSourceProtocolVersion() < 1 + || session.getPolicyRevision() <= 0 + || session.getEndpointId().isEmpty() + || session.getOrganizationId().isEmpty() + || session.getId().isEmpty()) { + throw new BedrockPrincipalAdmissionException(PrincipalError.BINDING_MISMATCH); + } + ConnectConfig.BedrockPrincipalConfig principalConfig = config().getBedrockPrincipal(); + TrustedProposalContext expected = new TrustedProposalContext( + principalConfig.getIssuer(), principalConfig.getTrustDomain(), principalConfig.getAudience(), + session.getEndpointId(), session.getOrganizationId(), session.getId(), + session.getConnectSessionNonce().toByteArray(), "bedrock", + session.getSourceProtocolVersion(), session.getPolicyRevision()); + try { + return Optional.of(verifier().verifyAndConsume( + SignedPrincipalEnvelope.of(strictUtf8(session.getSignedBedrockPrincipalV2().toByteArray())), + expected)); + } catch (PrincipalVerificationException error) { + throw new BedrockPrincipalAdmissionException(error.error()); + } catch (IllegalArgumentException | CharacterCodingException ignored) { + throw new BedrockPrincipalAdmissionException(PrincipalError.MALFORMED); + } + } + + private synchronized BedrockPrincipalVerifier verifier() { + if (verifier != null) return verifier; + ConnectConfig.BedrockPrincipalConfig principalConfig = config().getBedrockPrincipal(); + VerifierConfiguration.Builder configuration = VerifierConfiguration.builder().clock(clock); + Map pins = principalConfig.getPublicKeys(); + if (pins == null || pins.isEmpty()) { + throw new BedrockPrincipalAdmissionException(PrincipalError.METADATA_UNAVAILABLE); + } + try { + pins.forEach((kid, encoded) -> { + byte[] decoded = Base64.getUrlDecoder().decode(encoded); + if (!Base64.getUrlEncoder().withoutPadding().encodeToString(decoded).equals(encoded)) { + throw new IllegalArgumentException(); + } + configuration.publicKey(kid, decoded); + }); + verifier = BedrockPrincipalVerifierFactory.create(configuration.build()); + return verifier; + } catch (IllegalArgumentException ignored) { + throw new BedrockPrincipalAdmissionException(PrincipalError.TRUST); + } + } + + private ConnectConfig config() { + return Objects.requireNonNull(config.get(), "config"); + } + + private static boolean hasInjectedProperty(Session session) { + return session.hasPlayer() && session.getPlayer().hasProfile() + && session.getPlayer().getProfile().getPropertiesList().stream() + .anyMatch(property -> BedrockIdentityProfiles.PRINCIPAL_V2_PROPERTY_NAME + .equals(property.getName())); + } + + private static String strictUtf8(byte[] value) throws CharacterCodingException { + return StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(value)).toString(); + } +} diff --git a/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalReadiness.java b/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalReadiness.java new file mode 100644 index 000000000..96a0644fa --- /dev/null +++ b/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalReadiness.java @@ -0,0 +1,147 @@ +package com.minekube.connect.bedrock; + +import com.google.protobuf.ByteString; +import com.minekube.connect.config.ConnectConfig; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import minekube.connect.v1alpha1.WatchServiceOuterClass.PrincipalError; +import minekube.connect.v1alpha1.WatchServiceOuterClass.ReadinessAttestation; +import minekube.connect.v1alpha1.WatchServiceOuterClass.ReadinessChallenge; +import minekube.connect.v1alpha1.WatchServiceOuterClass.TunnelTransport; + +/** Honest generation-2 capability and challenge answers for the signed-principal consumer. */ +public final class BedrockPrincipalReadiness { + public static final String CAPABILITY = "bedrock-verified-principal-v2"; + private static final String MODE = "require"; + private static final String CORE_VECTOR_SHA256 = + "4f2a442ee71bfd35af2ef1f3944489d17551aa77fed2c08220f2aa77032b6196"; + + public enum Transport { + WATCH(TunnelTransport.Type.TYPE_WEBSOCKET), + LIBP2P(TunnelTransport.Type.TYPE_LIBP2P); + + private final TunnelTransport.Type wireType; + + Transport(TunnelTransport.Type wireType) { + this.wireType = wireType; + } + } + + private final ConnectConfig config; + private final Clock clock; + + public BedrockPrincipalReadiness(ConnectConfig config) { + this(config, Clock.systemUTC()); + } + + BedrockPrincipalReadiness(ConnectConfig config, Clock clock) { + this.config = Objects.requireNonNull(config, "config"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + public boolean isReady() { + return BedrockPrincipalConfiguration.from(config.getBedrockPrincipal()).isCapable() + && usablePins(config.getBedrockPrincipal().getPublicKeys()); + } + + public byte[] revision() { + if (!isReady()) return new byte[0]; + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + ConnectConfig.BedrockPrincipalConfig principal = config.getBedrockPrincipal(); + update(digest, Integer.toString(principal.getConfigGeneration())); + update(digest, principal.getMode()); + update(digest, principal.getIssuer()); + update(digest, principal.getTrustDomain()); + update(digest, principal.getAudience()); + update(digest, principal.getMetadataOrigin()); + update(digest, principal.getMetadataPath()); + update(digest, CORE_VECTOR_SHA256); + principal.getPublicKeys().entrySet().stream() + .sorted(Map.Entry.comparingByKey(Comparator.naturalOrder())) + .forEach(entry -> { + update(digest, entry.getKey()); + update(digest, entry.getValue()); + }); + return digest.digest(); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 unavailable"); + } + } + + public List capabilities(List configuredCapabilities, Transport transport) { + Objects.requireNonNull(transport, "transport"); + List capabilities = new ArrayList<>(configuredCapabilities); + capabilities.removeIf(CAPABILITY::equals); + if (isReady()) capabilities.add(CAPABILITY); + return List.copyOf(capabilities); + } + + public ReadinessAttestation attest(ReadinessChallenge challenge, Transport transport) { + Objects.requireNonNull(challenge, "challenge"); + Objects.requireNonNull(transport, "transport"); + long now = clock.instant().getEpochSecond(); + boolean valid = validChallenge(challenge, transport, now); + boolean ready = valid && isReady(); + ReadinessAttestation.Builder answer = ReadinessAttestation.newBuilder() + .setChallenge(challenge) + .setCapability(CAPABILITY) + .setMode(MODE) + .setObservedAtUnix(now); + byte[] revision = revision(); + if (revision.length == 32) answer.setReadinessRevision(ByteString.copyFrom(revision)); + if (ready) { + return answer.setResult(ReadinessAttestation.Result.RESULT_READY).build(); + } + return answer.setResult(ReadinessAttestation.Result.RESULT_NOT_READY) + .setReason(PrincipalError.PRINCIPAL_ERROR_READINESS) + .build(); + } + + private static boolean validChallenge(ReadinessChallenge challenge, Transport transport, long now) { + return !challenge.getRequestId().isEmpty() + && challenge.getNonce().size() == 16 + && !challenge.getEndpointId().isEmpty() + && !challenge.getOrganizationId().isEmpty() + && !challenge.getConnectorInstanceId().isEmpty() + && !challenge.getLeaseId().isEmpty() + && challenge.getTransport() == transport.wireType + && challenge.getPolicyRevision() > 0 + && challenge.getExpiresAtUnix() - challenge.getIssuedAtUnix() == 30 + && now >= challenge.getIssuedAtUnix() + && now <= challenge.getExpiresAtUnix(); + } + + private static boolean usablePins(Map pins) { + if (pins == null || pins.isEmpty()) return false; + try { + for (Map.Entry pin : pins.entrySet()) { + if (pin.getKey() == null || pin.getKey().isEmpty() || pin.getKey().length() > 128 + || pin.getValue() == null) return false; + byte[] decoded = Base64.getUrlDecoder().decode(pin.getValue()); + if (decoded.length != 32 || !Base64.getUrlEncoder().withoutPadding() + .encodeToString(decoded).equals(pin.getValue())) return false; + } + return true; + } catch (IllegalArgumentException ignored) { + return false; + } + } + + private static void update(MessageDigest digest, String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + digest.update((byte) (bytes.length >>> 24)); + digest.update((byte) (bytes.length >>> 16)); + digest.update((byte) (bytes.length >>> 8)); + digest.update((byte) bytes.length); + digest.update(bytes); + } +} diff --git a/core/src/main/java/com/minekube/connect/bedrock/VerifiedBedrockIdentityRegistry.java b/core/src/main/java/com/minekube/connect/bedrock/VerifiedBedrockIdentityRegistry.java index 6d0124289..91d9b8125 100644 --- a/core/src/main/java/com/minekube/connect/bedrock/VerifiedBedrockIdentityRegistry.java +++ b/core/src/main/java/com/minekube/connect/bedrock/VerifiedBedrockIdentityRegistry.java @@ -3,6 +3,7 @@ import com.google.inject.Singleton; import com.minekube.connect.api.player.ConnectPlayer; import com.minekube.connect.api.player.bedrock.BedrockIdentityClaims; +import com.minekube.connect.api.player.principal.VerifiedBedrockPrincipal; import java.util.IdentityHashMap; import java.util.Map; import java.util.Objects; @@ -34,13 +35,39 @@ synchronized void record( } } + synchronized void recordPrincipal( + ConnectPlayer player, + long generation, + VerifiedBedrockPrincipal principal) { + ensureOpen(); + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(principal, "principal"); + if (!player.getSessionId().equals(principal.bindings().connectSessionId())) { + throw new IllegalArgumentException("Bedrock principal session mismatch"); + } + VerifiedIdentity current = identities.get(player); + if (current == null || current.generation <= generation) { + identities.put(player, new VerifiedIdentity( + generation, player.getSessionId(), null, principal)); + } + } + 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); + return Optional.ofNullable(identity.claims); + } + + public synchronized Optional getPrincipal(ConnectPlayer player) { + Objects.requireNonNull(player, "player"); + VerifiedIdentity identity = identities.get(player); + if (identity == null || !player.getSessionId().equals(identity.sessionId)) { + return Optional.empty(); + } + return Optional.ofNullable(identity.principal); } public synchronized void remove(ConnectPlayer player) { @@ -67,14 +94,24 @@ private static final class VerifiedIdentity { private final long generation; private final String sessionId; private final BedrockIdentityClaims claims; + private final VerifiedBedrockPrincipal principal; private VerifiedIdentity( long generation, String sessionId, BedrockIdentityClaims claims) { + this(generation, sessionId, claims, null); + } + + private VerifiedIdentity( + long generation, + String sessionId, + BedrockIdentityClaims claims, + VerifiedBedrockPrincipal principal) { this.generation = generation; this.sessionId = sessionId; this.claims = claims; + this.principal = principal; } } } 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 85d7aaa0e..b7631b406 100644 --- a/core/src/main/java/com/minekube/connect/config/ConnectConfig.java +++ b/core/src/main/java/com/minekube/connect/config/ConnectConfig.java @@ -28,6 +28,7 @@ import com.minekube.connect.util.Utils; import java.util.Collections; import java.util.List; +import java.util.Map; import lombok.Getter; /** @@ -60,6 +61,12 @@ public class ConnectConfig { */ private BedrockIdentityConfig bedrockIdentity = new BedrockIdentityConfig(); + /** + * Generation-2 signed-principal settings. A missing section remains generation zero and + * cannot advertise v2, preserving existing configuration files without migration. + */ + private BedrockPrincipalConfig bedrockPrincipal = new BedrockPrincipalConfig(); + /** * Optional parent endpoint names sent to WatchService as Connect-Endpoint-Parents. * Connect Java does not expose a public endpoint-control API. @@ -127,4 +134,24 @@ public static class BedrockIdentityConfig { */ private String expectedPolicy = "trusted_bedrock_xuid"; } + + @Getter + public static class BedrockPrincipalConfig { + /** Exact local configuration generation. Only generation 2 can become v2-capable. */ + private int configGeneration; + /** Exact v2 enforcement mode. Only require can advertise v2 readiness. */ + private String mode = "disabled"; + /** Locally trusted issuer. */ + private String issuer = ""; + /** Locally trusted administrative key namespace. */ + private String trustDomain = ""; + /** Locally trusted singleton audience. */ + private String audience = ""; + /** Pinned HTTPS metadata origin, without a path. */ + private String metadataOrigin = ""; + /** Frozen metadata path. */ + private String metadataPath = "/.well-known/minekube-connect/bedrock-principal-v2.json"; + /** Optional static Ed25519 pins keyed by kid are configured by host integration. */ + private Map publicKeys = Collections.emptyMap(); + } } 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 355f2d511..eb4ed4987 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 @@ -100,7 +100,8 @@ public static void onChannelClosed( } if (api.setPendingRemove(context.getPlayer())) { - if (!context.getPlayer().getUsername().isEmpty()) { // might be just a ping request + if (!context.getSessionProposal().hasBedrockPrincipalV2() + && !context.getPlayer().getUsername().isEmpty()) { // might be just a ping request logger.translatedInfo("connect.ingame.disconnect_name", context.getPlayer().getUsername()); } @@ -195,7 +196,9 @@ public void channelInactive(@NotNull ChannelHandlerContext ctx) throws Exception } private String playerName() { - return context.getPlayer().getUsername(); + return context.getSessionProposal().hasBedrockPrincipalV2() + ? "" + : context.getPlayer().getUsername(); } private String sessionId() { diff --git a/core/src/main/java/com/minekube/connect/network/netty/LocalSession.java b/core/src/main/java/com/minekube/connect/network/netty/LocalSession.java index 051caf556..73980f0b6 100644 --- a/core/src/main/java/com/minekube/connect/network/netty/LocalSession.java +++ b/core/src/main/java/com/minekube/connect/network/netty/LocalSession.java @@ -201,7 +201,7 @@ public void initChannel(@NotNull LocalChannelWithSessionContext channel) { } logger.debug("Connecting {} to local downstream server {}", - context.player.getUsername(), targetAddress); + logPlayer(context), targetAddress); bootstrap .remoteAddress(targetAddress) .connect() @@ -221,11 +221,17 @@ public void initChannel(@NotNull LocalChannelWithSessionContext channel) { }); } + private static String logPlayer(Context context) { + return context.sessionProposal.hasBedrockPrincipalV2() + ? "" + : context.player.getUsername(); + } + private void exceptionCaught(Throwable cause, ConnectPlayer player) { if (admissionCoordinator != null) { admissionCoordinator.discard(player); } - cause.printStackTrace(); + logger.warn("Connect local session failed (category={})", cause.getClass().getSimpleName()); // Reject session proposal in case we are still able to. sessionProposal.reject(StatusProto.fromThrowable(cause)); } 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 e1870c4ae..68e935b38 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 @@ -29,6 +29,7 @@ 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.BedrockPrincipalReadiness; import com.minekube.connect.bedrock.BedrockIdentityReadiness.Transport; import com.minekube.connect.bedrock.BedrockAdmissionCoordinator; import com.minekube.connect.config.ConnectConfig; @@ -80,6 +81,7 @@ final class Libp2pEndpointRuntime { private final PlatformInjector platformInjector; private final SimpleConnectApi api; private final BedrockIdentityReadiness bedrockIdentityReadiness; + private final BedrockPrincipalReadiness bedrockPrincipalReadiness; private final BedrockAdmissionCoordinator admissionCoordinator; private final String endpointInstanceId = newEndpointInstanceId(); private final AtomicLong sequence = new AtomicLong(); @@ -113,6 +115,9 @@ final class Libp2pEndpointRuntime { this.platformInjector = platformInjector; this.api = api; this.bedrockIdentityReadiness = bedrockIdentityReadiness; + this.bedrockPrincipalReadiness = connectConfig == null + ? null + : new BedrockPrincipalReadiness(connectConfig); this.admissionCoordinator = admissionCoordinator; } @@ -289,11 +294,9 @@ private ActiveRegistration registerOnce(OfflineMode offlineMode, EndpointAuthTyp : connectConfig.getSuperEndpoints(), offlineMode, authType, - bedrockIdentityReadiness.capabilities( - libp2pConfig.capabilities(), - Transport.LIBP2P), + principalCapabilities(), this::currentCapacity); - client = new PeerRegistrationClient(handshake); + client = new PeerRegistrationClient(handshake, bedrockPrincipalReadiness); PeerRegisterResult result = await(client.install( stream, this::refreshObservedAddrs, @@ -315,6 +318,15 @@ private ActiveRegistration registerOnce(OfflineMode offlineMode, EndpointAuthTyp : lastError; } + private List principalCapabilities() { + List legacy = bedrockIdentityReadiness.capabilities( + libp2pConfig.capabilities(), Transport.LIBP2P); + return bedrockPrincipalReadiness == null + ? legacy + : bedrockPrincipalReadiness.capabilities( + legacy, BedrockPrincipalReadiness.Transport.LIBP2P); + } + static List registerAttemptAddresses(List registerAddrs, int attemptsPerAddress) { if (registerAddrs == null || registerAddrs.isEmpty()) { return Collections.emptyList(); 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 97d312fdc..a335f8f6d 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 @@ -63,6 +63,12 @@ static Session toWatchSession(SessionOffer offer) { .setPlayer(player) .setAuth(Authentication.newBuilder().setPassthrough(passthrough)) .setProtocolValue(offer.getProtocolValue()) + .setEndpointId(offer.getEndpointId()) + .setOrganizationId(offer.getEndpointOrgId()) + .setConnectSessionNonce(offer.getConnectSessionNonce()) + .setSourceProtocolVersion(offer.getSourceProtocolVersion()) + .setPolicyRevision(offer.getPolicyRevision()) + .setSignedBedrockPrincipalV2(offer.getSignedBedrockPrincipalV2()) .addTunnelTransports(TunnelTransport.newBuilder() .setType(TunnelTransport.Type.TYPE_LIBP2P) .setAddress("same-stream")) diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/P2PFrameCodec.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/P2PFrameCodec.java index 1c1098d1b..f2eb40e4f 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/P2PFrameCodec.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/P2PFrameCodec.java @@ -34,6 +34,11 @@ public final class P2PFrameCodec { public static final int MAX_CONTROL_FRAME_SIZE = 1 << 20; + public static final int MAX_KIND_PREFIXED_FRAME_SIZE = 4096; + public static final byte RENEWAL_COMMIT = 0x01; + public static final byte RENEWAL_RESULT = 0x02; + public static final byte READINESS_CHALLENGE = 0x03; + public static final byte READINESS_ATTESTATION = 0x04; private P2PFrameCodec() { } @@ -66,6 +71,59 @@ public static T read( return parser.parseFrom(payload); } + public static void writeKindPrefixed(OutputStream out, byte kind, MessageLite message) + throws IOException { + requireKind(kind); + byte[] payload = message.toByteArray(); + int length = 1 + payload.length; + if (length > MAX_KIND_PREFIXED_FRAME_SIZE) { + throw new IllegalArgumentException("kind-prefixed frame exceeds maximum size"); + } + writeVarint(out, length); + out.write(kind); + out.write(payload); + out.flush(); + } + + public static KindPrefixedFrame readKindPrefixed(InputStream in) throws IOException { + long length = readVarint(in); + if (length < 1) { + throw new IllegalArgumentException("kind-prefixed frame has no kind"); + } + if (length > MAX_KIND_PREFIXED_FRAME_SIZE) { + throw new IllegalArgumentException("kind-prefixed frame exceeds maximum size"); + } + int kind = in.read(); + if (kind < 0) throw new EOFException("truncated kind-prefixed frame"); + requireKind((byte) kind); + byte[] payload = in.readNBytes((int) length - 1); + if (payload.length != length - 1) { + throw new EOFException("truncated kind-prefixed frame payload"); + } + return new KindPrefixedFrame((byte) kind, payload); + } + + public record KindPrefixedFrame(byte kind, byte[] protobuf) { + public KindPrefixedFrame { + protobuf = protobuf.clone(); + } + + @Override + public byte[] protobuf() { + return protobuf.clone(); + } + + public T parse(Parser parser) throws IOException { + return parser.parseFrom(protobuf); + } + } + + private static void requireKind(byte kind) { + if (kind < RENEWAL_COMMIT || kind > READINESS_ATTESTATION) { + throw new IllegalArgumentException("unknown kind-prefixed frame kind"); + } + } + private static void writeVarint(OutputStream out, int value) throws IOException { long v = value & 0xffffffffL; while (v >= 0x80) { diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/P2PFrameDecoder.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/P2PFrameDecoder.java index ef6d8fde8..876d257eb 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/P2PFrameDecoder.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/P2PFrameDecoder.java @@ -37,6 +37,9 @@ final class P2PFrameDecoder extends ByteToMessageDecoder P2PFrameDecoder(Parser parser, int maxFrameSize) { this.parser = parser; this.maxFrameSize = maxFrameSize; + // Negotiation may put the final naked result and first kind-prefixed frame in one read. + // Deliver one frame before decoding the remainder so the handler can switch codecs. + setSingleDecode(true); } @Override diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/PeerRegistrationClient.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/PeerRegistrationClient.java index c9dc80f2f..4d144f7f0 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/PeerRegistrationClient.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/PeerRegistrationClient.java @@ -24,11 +24,14 @@ package com.minekube.connect.tunnel.p2p; import com.google.protobuf.MessageLite; +import com.minekube.connect.bedrock.BedrockPrincipalReadiness; import io.libp2p.core.Stream; +import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.handler.codec.ByteToMessageDecoder; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.List; @@ -43,15 +46,22 @@ import java.util.function.Supplier; import minekube.connect.v1alpha1.ConnectLibp2P.PeerRegisterChallenge; import minekube.connect.v1alpha1.ConnectLibp2P.PeerRegisterResult; +import minekube.connect.v1alpha1.WatchServiceOuterClass.ReadinessChallenge; final class PeerRegistrationClient { private final PeerRegistrationHandshake handshake; private final ScheduledExecutorService renewExecutor; + private final BedrockPrincipalReadiness readiness; private final CompletableFuture closed = new CompletableFuture<>(); private volatile Stream stream; PeerRegistrationClient(PeerRegistrationHandshake handshake) { + this(handshake, null); + } + + PeerRegistrationClient(PeerRegistrationHandshake handshake, BedrockPrincipalReadiness readiness) { this.handshake = Objects.requireNonNull(handshake, "handshake"); + this.readiness = readiness; this.renewExecutor = Executors.newSingleThreadScheduledExecutor(runnable -> { Thread thread = new Thread(runnable, "connect-libp2p-registration-renew"); thread.setDaemon(true); @@ -115,10 +125,11 @@ private void installResultHandler( PeerRegisterResult.parser(), P2PFrameCodec.MAX_CONTROL_FRAME_SIZE); ctx.pipeline().addLast(resultDecoder); - ctx.pipeline().addLast(new ResultHandler(stream, challenge, observedAddrsSupplier, sequence, result)); + ctx.pipeline().addLast(new ResultHandler( + stream, resultDecoder, challenge, observedAddrsSupplier, sequence, result)); } - private static void writeFrame(Stream stream, MessageLite message) { + private synchronized void writeFrame(Stream stream, MessageLite message) { try { ByteArrayOutputStream out = new ByteArrayOutputStream(); P2PFrameCodec.write(out, message); @@ -128,6 +139,16 @@ private static void writeFrame(Stream stream, MessageLite message) { } } + private synchronized void writeKindFrame(Stream stream, byte kind, MessageLite message) { + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + P2PFrameCodec.writeKindPrefixed(out, kind, message); + stream.writeAndFlush(Unpooled.wrappedBuffer(out.toByteArray())); + } catch (IOException e) { + throw new IllegalStateException("encode kind-prefixed libp2p registration frame", e); + } + } + private final class ChallengeHandler extends SimpleChannelInboundHandler { private final Stream stream; private final ChannelHandler decoder; @@ -183,19 +204,25 @@ static long renewDelayMillis(PeerRegisterChallenge challenge) { private final class ResultHandler extends SimpleChannelInboundHandler { private final Stream stream; + private final ChannelHandler legacyDecoder; private final PeerRegisterChallenge challenge; private final Supplier> observedAddrsSupplier; private final AtomicLong sequence; private final CompletableFuture result; private volatile ScheduledFuture ackTimeout; + private volatile boolean offerAttempted; + private volatile boolean framed; + private volatile boolean awaitingResult = true; private ResultHandler( Stream stream, + ChannelHandler legacyDecoder, PeerRegisterChallenge challenge, Supplier> observedAddrsSupplier, long sequence, CompletableFuture result) { this.stream = stream; + this.legacyDecoder = legacyDecoder; this.challenge = challenge; this.observedAddrsSupplier = observedAddrsSupplier; this.sequence = new AtomicLong(sequence); @@ -204,8 +231,28 @@ private ResultHandler( @Override protected void channelRead0(ChannelHandlerContext ctx, PeerRegisterResult msg) { + handleResult(ctx, msg); + } + + private void handleResult(ChannelHandlerContext ctx, PeerRegisterResult msg) { + if (!awaitingResult) { + failRegistration(new IllegalArgumentException( + "unexpected duplicate libp2p registration result")); + ctx.close(); + return; + } + awaitingResult = false; cancelAckTimeout(); result.complete(msg); + if (!framed && offerAttempted && msg.hasModeResult() + && msg.getModeResult().getVersion() == 2 + && msg.getModeResult().getAccepted()) { + framed = true; + ctx.pipeline().addLast(new KindFrameDecoder()); + ctx.pipeline().addLast(new KindFrameHandler(this)); + ctx.pipeline().remove(this); + ctx.pipeline().remove(legacyDecoder); + } scheduleRenew(); } @@ -213,11 +260,21 @@ private void scheduleRenew() { renewExecutor.schedule(() -> { if (!stream.closeFuture().isDone()) { try { - writeFrame(stream, handshake.commit( + boolean offer = !framed && !offerAttempted + && readiness != null && readiness.isReady(); + MessageLite commit = handshake.commit( challenge, observedAddrsSupplier.get(), sequence.incrementAndGet(), - System.currentTimeMillis())); + System.currentTimeMillis(), + offer); + awaitingResult = true; + if (framed) { + writeKindFrame(stream, P2PFrameCodec.RENEWAL_COMMIT, commit); + } else { + writeFrame(stream, commit); + if (offer) offerAttempted = true; + } scheduleAckTimeout(); } catch (RuntimeException e) { failRegistration(e); @@ -265,6 +322,79 @@ public void channelInactive(ChannelHandlerContext ctx) throws Exception { } } + private final class KindFrameHandler + extends SimpleChannelInboundHandler { + private final ResultHandler registration; + + private KindFrameHandler(ResultHandler registration) { + this.registration = registration; + } + + @Override + protected void channelRead0( + ChannelHandlerContext ctx, P2PFrameCodec.KindPrefixedFrame frame) throws Exception { + if (frame.kind() == P2PFrameCodec.RENEWAL_RESULT) { + registration.handleResult(ctx, frame.parse(PeerRegisterResult.parser())); + return; + } + if (frame.kind() == P2PFrameCodec.READINESS_CHALLENGE && readiness != null) { + ReadinessChallenge challenge = frame.parse(ReadinessChallenge.parser()); + writeKindFrame(stream, P2PFrameCodec.READINESS_ATTESTATION, + readiness.attest(challenge, BedrockPrincipalReadiness.Transport.LIBP2P)); + return; + } + registration.failRegistration(new IllegalArgumentException( + "unexpected kind-prefixed registration frame")); + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { + registration.failRegistration(cause); + ctx.close(); + } + } + + private static final class KindFrameDecoder extends ByteToMessageDecoder { + @Override + protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { + in.markReaderIndex(); + long length = 0; + int shift = 0; + boolean complete = false; + for (int index = 0; index < 10; index++) { + if (!in.isReadable()) { + in.resetReaderIndex(); + return; + } + int value = in.readUnsignedByte(); + if (index == 9 && value > 1) { + throw new IllegalArgumentException("kind-prefixed frame length overflow"); + } + length |= (long) (value & 0x7f) << shift; + if ((value & 0x80) == 0) { + complete = true; + break; + } + shift += 7; + } + if (!complete || length < 1 || length > P2PFrameCodec.MAX_KIND_PREFIXED_FRAME_SIZE) { + throw new IllegalArgumentException("invalid kind-prefixed frame length"); + } + if (in.readableBytes() < length) { + in.resetReaderIndex(); + return; + } + byte kind = in.readByte(); + if (kind < P2PFrameCodec.RENEWAL_COMMIT + || kind > P2PFrameCodec.READINESS_ATTESTATION) { + throw new IllegalArgumentException("unknown kind-prefixed frame kind"); + } + byte[] payload = new byte[(int) length - 1]; + in.readBytes(payload); + out.add(new P2PFrameCodec.KindPrefixedFrame(kind, payload)); + } + } + static long renewAckTimeoutMillis(PeerRegisterChallenge challenge) { long renewDelay = renewDelayMillis(challenge); if (challenge.getKvTtlMs() > 0) { 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 9194887f1..c148f5c7f 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 @@ -38,6 +38,7 @@ import minekube.connect.v1alpha1.ConnectLibp2P.PeerRegisterChallenge; import minekube.connect.v1alpha1.ConnectLibp2P.PeerRegisterCommit; import minekube.connect.v1alpha1.ConnectLibp2P.PeerRegisterInit; +import minekube.connect.v1alpha1.ConnectLibp2P.RegistrationModeOffer; final class PeerRegistrationHandshake { static final String BEDROCK_IDENTITY_V1_CAPABILITY = "bedrock-identity-v1"; @@ -131,6 +132,15 @@ PeerRegisterInit init(List observedAddrs) { } PeerRegisterCommit commit(PeerRegisterChallenge challenge, List addrs, long sequence, long nowUnixMs) { + return commit(challenge, addrs, sequence, nowUnixMs, false); + } + + PeerRegisterCommit commit( + PeerRegisterChallenge challenge, + List addrs, + long sequence, + long nowUnixMs, + boolean offerKindPrefixedV1) { long ttlMs = challenge.getKvTtlMs() > 0 ? challenge.getKvTtlMs() : 45_000; List recordAddrs = recordRelayCircuitAddrs(challenge, addrs); if (recordAddrs.isEmpty() && challenge.getRelayAddrsList().isEmpty()) { @@ -158,10 +168,15 @@ PeerRegisterCommit commit(PeerRegisterChallenge challenge, List addrs, l .setExpiresAtUnixMs(nowUnixMs + ttlMs) .setNonce(challenge.getNonce()) .build(); - return PeerRegisterCommit.newBuilder() + PeerRegisterCommit.Builder commit = PeerRegisterCommit.newBuilder() .setRecord(record) - .setSignature(ByteString.copyFrom(identity.sign(PeerRecordSigningPayload.bytes(record)))) - .build(); + .setSignature(ByteString.copyFrom(identity.sign(PeerRecordSigningPayload.bytes(record)))); + if (offerKindPrefixedV1) { + commit.setModeOffer(RegistrationModeOffer.newBuilder() + .setVersion(2) + .setFraming("kind-prefixed-v1")); + } + return commit.build(); } private List recordRelayCircuitAddrs(PeerRegisterChallenge challenge, List reservedAddrs) { 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 54d101842..a4eafe5f3 100644 --- a/core/src/main/java/com/minekube/connect/watch/SessionProposal.java +++ b/core/src/main/java/com/minekube/connect/watch/SessionProposal.java @@ -49,6 +49,7 @@ public class SessionProposal { private final String endpointOrgId; @Getter private final SessionProtocol protocol; + private final boolean bedrockPrincipalV2; private final java.util.concurrent.atomic.AtomicReference state = new java.util.concurrent.atomic.AtomicReference<>(State.ACCEPTED); @@ -70,12 +71,15 @@ public SessionProposal( String endpointId, String endpointOrgId, AdmissionToken admissionToken) { + this.bedrockPrincipalV2 = session != null && !session.getSignedBedrockPrincipalV2().isEmpty(); 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.endpointId = firstNonEmpty(endpointId, + firstNonEmpty(session == null ? "" : session.getEndpointId(), scope.endpoint_id)); + this.endpointOrgId = firstNonEmpty(endpointOrgId, + firstNonEmpty(session == null ? "" : session.getOrganizationId(), scope.endpoint_org_id)); this.protocol = session == null ? SessionProtocol.SESSION_PROTOCOL_UNSPECIFIED : session.getProtocol(); @@ -96,11 +100,15 @@ public State getState() { return state.get(); } + /** Whether the private authenticated proposal carried a v2 principal envelope. */ + public boolean hasBedrockPrincipalV2() { + return bedrockPrincipalV2; + } + @Override public String toString() { - return "SessionProposal{" + - "session=" + session + - '}'; + return "SessionProposal[sessionId=" + (session == null ? "" : session.getId()) + + ", protocol=" + protocol + ", bedrockPrincipalV2=" + bedrockPrincipalV2 + ']'; } private static Scope parseScope(Session session) { @@ -126,26 +134,35 @@ 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); + var sanitized = session.toBuilder() + .clearConnectSessionNonce() + .clearSignedBedrockPrincipalV2(); + if (session.hasPlayer() && session.getPlayer().hasProfile()) { + 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()) && + !BedrockIdentityProfiles.PRINCIPAL_V2_PROPERTY_NAME.equals(property.getName())) { + profile.addProperties(property); + } } + sanitized.setPlayer(session.getPlayer().toBuilder().setProfile(profile)); } - return session.toBuilder() - .setPlayer(session.getPlayer().toBuilder().setProfile(profile)) - .build(); + return sanitized.build(); } private static boolean hasPrivateIdentity(Session session) { - if (session == null || !session.hasPlayer() || !session.getPlayer().hasProfile()) { + if (session == null) { return false; } + if (!session.getConnectSessionNonce().isEmpty() + || !session.getSignedBedrockPrincipalV2().isEmpty()) return true; + if (!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())); + BedrockIdentityProfiles.SCOPE_PROPERTY_NAME.equals(property.getName()) || + BedrockIdentityProfiles.PRINCIPAL_V2_PROPERTY_NAME.equals(property.getName())); } private static String firstNonEmpty(String preferred, String fallback) { 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 4fa6e51f3..a3d03553f 100644 --- a/core/src/main/java/com/minekube/connect/watch/WatchClient.java +++ b/core/src/main/java/com/minekube/connect/watch/WatchClient.java @@ -32,6 +32,7 @@ import com.minekube.connect.bedrock.BedrockIdentityReadiness; import com.minekube.connect.bedrock.BedrockIdentityReadiness.Transport; import com.minekube.connect.bedrock.BedrockAdmissionCoordinator; +import com.minekube.connect.bedrock.BedrockPrincipalReadiness; import com.minekube.connect.config.ConnectConfig; import java.io.IOException; import minekube.connect.v1alpha1.WatchServiceOuterClass.SessionRejection; @@ -52,7 +53,6 @@ public class WatchClient { 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"); @@ -60,22 +60,35 @@ public class WatchClient { private final ConnectConfig config; private final BedrockIdentityReadiness bedrockIdentityReadiness; private final BedrockAdmissionCoordinator admissionCoordinator; + private final BedrockPrincipalReadiness bedrockPrincipalReadiness; @Inject public WatchClient( @Named("watchHttpClient") OkHttpClient httpClient, ConnectConfig config, BedrockIdentityReadiness bedrockIdentityReadiness, + BedrockPrincipalReadiness bedrockPrincipalReadiness, BedrockAdmissionCoordinator admissionCoordinator) { this.httpClient = httpClient; this.config = config; this.bedrockIdentityReadiness = bedrockIdentityReadiness; + this.bedrockPrincipalReadiness = bedrockPrincipalReadiness; this.admissionCoordinator = admissionCoordinator; } + public WatchClient( + @Named("watchHttpClient") OkHttpClient httpClient, + ConnectConfig config, + BedrockIdentityReadiness bedrockIdentityReadiness, + BedrockAdmissionCoordinator admissionCoordinator) { + this(httpClient, config, bedrockIdentityReadiness, + new BedrockPrincipalReadiness(config), admissionCoordinator); + } + public WatchClient(@Named("watchHttpClient") OkHttpClient httpClient, ConnectConfig config) { this(httpClient, config, new BedrockIdentityReadiness( - config, new BedrockIdentityKeyProvider(config, new OkHttpClient())), null); + config, new BedrockIdentityKeyProvider(config, new OkHttpClient())), + new BedrockPrincipalReadiness(config), null); } public WebSocket watch(Watcher watcher) { @@ -83,7 +96,10 @@ public WebSocket watch(Watcher watcher) { .url(WATCH_URL) .header(ENDPOINT_HEADER, config.getEndpoint()); if (bedrockIdentityReadiness.observe(Transport.WATCH)) { - request.header(CAPABILITIES_HEADER, BEDROCK_IDENTITY_V1_CAPABILITY); + request.header(CAPABILITIES_HEADER, BedrockIdentityReadiness.CAPABILITY); + } + if (bedrockPrincipalReadiness.isReady()) { + request.addHeader(CAPABILITIES_HEADER, BedrockPrincipalReadiness.CAPABILITY); } if (config.getAllowOfflineModePlayers() != null) { @@ -144,6 +160,19 @@ public void onMessage(@NotNull WebSocket webSocket, @NotNull ByteString bytes) { return; } + if (res.hasReadinessChallenge()) { + webSocket.send(ByteString.of(WatchRequest.newBuilder() + .setReadinessAttestation(bedrockPrincipalReadiness.attest( + res.getReadinessChallenge(), + BedrockPrincipalReadiness.Transport.WATCH)) + .build().toByteArray())); + return; + } + if (!res.hasSession()) { + webSocket.close(1002, "unknown Watch response payload"); + return; + } + String sessionId = res.getSession().getId(); java.util.function.Consumer rejectProposal = reason -> { Builder responseRejection = SessionRejection.newBuilder() 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 04940d5c9..d73b79941 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 @@ -80,12 +80,25 @@ message PeerRegisterChallenge { message PeerRegisterCommit { EndpointPeerRecord record = 1; bytes signature = 2; + RegistrationModeOffer mode_offer = 3; } message PeerRegisterResult { string endpoint_id = 1; string endpoint_hash = 2; uint64 kv_revision = 3; + RegistrationModeResult mode_result = 4; +} + +message RegistrationModeOffer { + uint32 version = 1; + string framing = 2; +} + +message RegistrationModeResult { + uint32 version = 1; + bool accepted = 2; + PrincipalError reason = 3; } message SessionOffer { @@ -98,6 +111,10 @@ message SessionOffer { string endpoint_org_id = 7; // Mirrors the authenticated legacy Session protocol discriminator. SessionProtocol protocol = 8; + bytes connect_session_nonce = 9; + int32 source_protocol_version = 10; + int64 policy_revision = 11; + bytes signed_bedrock_principal_v2 = 12; } 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 800c7a352..e1ef51c92 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 @@ -16,19 +16,21 @@ service WatchService { } message WatchRequest { - // Sending this message rejects a session proposed by the WatchService. This message should be sent to inform - // the WatchService that the server will not try to make a take the proposed session. The only purpose of - // this message is to provide quicker feedback to the player that he will not be connected with an optional - // localized reason. See https://github.com/grpc/grpc/blob/master/src/proto/grpc/status/status.proto. - // If the session is not rejected the watcher should establish the connection for the proposed session. - // If neither of these actions happen the proposal times out out and the player receives a connection timeout - // error indicating that the endpoint is currently unavailable. - SessionRejection session_rejection = 1; + oneof payload { + // Sending this message rejects a session proposed by the WatchService. + SessionRejection session_rejection = 1; + // The readiness answer for a challenge sent on this authenticated Watch lease. + ReadinessAttestation readiness_attestation = 2; + } } message WatchResponse { - // The proposed session that intents to connect. - Session session = 1; + oneof payload { + // The proposed session that intents to connect. + Session session = 1; + // A readiness challenge bound to this authenticated Watch lease. + ReadinessChallenge readiness_challenge = 2; + } } message SessionRejection { @@ -61,6 +63,65 @@ message Session { // The player protocol determined by the authenticated Connect Edge. // Legacy senders omit this field and decode as SESSION_PROTOCOL_UNSPECIFIED. SessionProtocol protocol = 6; + // The selected endpoint ID from the final authenticated proposal snapshot. + string endpoint_id = 7; + // The selected endpoint organization ID from the final authenticated proposal snapshot. + string organization_id = 8; + // A per-session 16-byte random nonce. Legacy senders omit this field. + bytes connect_session_nonce = 9; + // The authenticated source protocol version negotiated by the Connect Edge. + int32 source_protocol_version = 10; + // The positive endpoint policy revision from the final proposal snapshot. + int64 policy_revision = 11; + // The compact Bedrock principal v2 JWS. V1 identity metadata remains separate. + bytes signed_bedrock_principal_v2 = 12; +} + +// PrincipalError is the internal, bounded Bedrock principal v2 failure category. +enum PrincipalError { + PRINCIPAL_ERROR_UNSPECIFIED = 0; + PRINCIPAL_ERROR_MALFORMED = 1; + PRINCIPAL_ERROR_TRUST = 2; + PRINCIPAL_ERROR_SIGNATURE = 3; + PRINCIPAL_ERROR_BINDING_MISMATCH = 4; + PRINCIPAL_ERROR_TIME = 5; + PRINCIPAL_ERROR_IDENTITY = 6; + PRINCIPAL_ERROR_LINK = 7; + PRINCIPAL_ERROR_REPLAY = 8; + PRINCIPAL_ERROR_CAPACITY = 9; + PRINCIPAL_ERROR_METADATA_UNAVAILABLE = 10; + PRINCIPAL_ERROR_KEY_REVOKED = 11; + PRINCIPAL_ERROR_READINESS = 12; + PRINCIPAL_ERROR_INTERNAL = 13; +} + +message ReadinessChallenge { + string request_id = 1; + bytes nonce = 2; + string endpoint_id = 3; + string organization_id = 4; + string connector_instance_id = 5; + string lease_id = 6; + TunnelTransport.Type transport = 7; + int64 policy_revision = 8; + int64 issued_at_unix = 9; + int64 expires_at_unix = 10; +} + +message ReadinessAttestation { + enum Result { + RESULT_UNSPECIFIED = 0; + RESULT_READY = 1; + RESULT_NOT_READY = 2; + } + + ReadinessChallenge challenge = 1; + string capability = 2; + string mode = 3; + bytes readiness_revision = 4; + int64 observed_at_unix = 5; + Result result = 6; + PrincipalError reason = 7; } message TunnelTransport { diff --git a/core/src/main/resources/config.yml b/core/src/main/resources/config.yml index 6b80aacf1..e9263410b 100644 --- a/core/src/main/resources/config.yml +++ b/core/src/main/resources/config.yml @@ -44,6 +44,19 @@ bedrock-identity: # Required exact policy: linked_java_only or trusted_bedrock_xuid. expected-policy: trusted_bedrock_xuid +# Signed Bedrock principal v2 is additive to the legacy identity path above. New generated +# configurations require it; files created before this section existed remain generation 0, +# preserve their legacy behavior, and never advertise v2 until explicitly upgraded. +bedrock-principal: + config-generation: 2 + mode: require + issuer: minekube-connect + trust-domain: urn:minekube:connect:production + audience: urn:minekube:connect:bedrock-principal:v2 + metadata-origin: https://connect.minekube.com + metadata-path: /.well-known/minekube-connect/bedrock-principal-v2.json + public-keys: {} + # The default locale for Connect. By default, Connect uses the system locale #default-locale: en_US diff --git a/core/src/main/resources/proxy-config.yml b/core/src/main/resources/proxy-config.yml index 4a7bbb782..52a4f4478 100644 --- a/core/src/main/resources/proxy-config.yml +++ b/core/src/main/resources/proxy-config.yml @@ -77,6 +77,19 @@ bedrock-identity: # Required exact policy: linked_java_only or trusted_bedrock_xuid. expected-policy: trusted_bedrock_xuid +# Signed Bedrock principal v2 is additive to the legacy identity path above. New generated +# configurations require it; files created before this section existed remain generation 0, +# preserve their legacy behavior, and never advertise v2 until explicitly upgraded. +bedrock-principal: + config-generation: 2 + mode: require + issuer: minekube-connect + trust-domain: urn:minekube:connect:production + audience: urn:minekube:connect:bedrock-principal:v2 + metadata-origin: https://connect.minekube.com + metadata-path: /.well-known/minekube-connect/bedrock-principal-v2.json + public-keys: {} + # bStats is a stat tracker that is entirely anonymous and tracks only basic information # about Connect, such as how many people are online, how many servers are using Connect, # what OS is being used, etc. You can learn more about bStats here: https://bstats.org/. diff --git a/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalConsumerTest.java b/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalConsumerTest.java new file mode 100644 index 000000000..1398dd388 --- /dev/null +++ b/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalConsumerTest.java @@ -0,0 +1,138 @@ +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.protobuf.ByteString; +import com.minekube.connect.api.player.principal.PrincipalError; +import com.minekube.connect.config.ConnectConfig; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Base64; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import minekube.connect.v1alpha1.WatchServiceOuterClass.Authentication; +import minekube.connect.v1alpha1.WatchServiceOuterClass.GameProfile; +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 BedrockPrincipalConsumerTest { + @Test + void verifiesWireEnvelopeAndAppliesOnlyEffectiveLinkedProfile() throws Exception { + JsonObject vector = vector("valid-linked"); + BedrockPrincipalConsumer consumer = consumer(vector); + Session session = session(vector); + + var principal = consumer.verify(session).orElseThrow(); + assertEquals(UUID.fromString("123e4567-e89b-12d3-a456-426614174000"), + principal.effectiveGameProfile().uuid()); + assertEquals("JavaOne", principal.effectiveGameProfile().name()); + + VerifiedBedrockIdentityRegistry registry = new VerifiedBedrockIdentityRegistry(); + BedrockAdmissionCoordinator coordinator = new BedrockAdmissionCoordinator(registry, consumer(vector)); + try { + var proposal = coordinator.proposal(session, ignored -> {}, "", ""); + var player = coordinator.stage(proposal); + assertEquals(principal.effectiveGameProfile().uuid(), player.getUniqueId()); + assertEquals(principal.effectiveGameProfile().name(), player.getUsername()); + assertTrue(player.getGameProfile().getProperties().isEmpty()); + assertTrue(registry.getPrincipal(player).isEmpty()); + + var decision = coordinator.verify(player, proposal.getAdmissionToken(), + new BedrockIdentityEnforcer( + config(), org.mockito.Mockito.mock(com.minekube.connect.api.logger.ConnectLogger.class), + () -> Instant.ofEpochSecond(vector.get("verification_time_unix").getAsLong())), + "", "", SessionProtocol.SESSION_PROTOCOL_BEDROCK); + assertTrue(decision.allowed()); + assertTrue(registry.getPrincipal(player).isPresent()); + } finally { + coordinator.close(); + } + } + + @Test + void malformedCompanionBindingFailsBeforeProfileApplication() throws Exception { + JsonObject vector = vector("valid-unlinked"); + Session malformed = session(vector).toBuilder().clearConnectSessionNonce().build(); + BedrockPrincipalAdmissionException error = assertThrows( + BedrockPrincipalAdmissionException.class, + () -> consumer(vector).verify(malformed)); + assertEquals(PrincipalError.BINDING_MISMATCH, error.error()); + assertFalse(error.toString().contains(vector.get("compact_jws").getAsString())); + } + + private static BedrockPrincipalConsumer consumer(JsonObject vector) { + return new BedrockPrincipalConsumer(config(), Clock.fixed( + Instant.ofEpochSecond(vector.get("verification_time_unix").getAsLong()), ZoneOffset.UTC)); + } + + private static ConnectConfig config() { + ConnectConfig config = new ConnectConfig(); + Object principal = config.getBedrockPrincipal(); + set(principal, "configGeneration", 2); + set(principal, "mode", "require"); + set(principal, "issuer", "minekube-connect-test"); + set(principal, "trustDomain", "urn:minekube:connect:test:corpus-v2"); + set(principal, "audience", "urn:minekube:connect:test:bedrock-principal:v2"); + set(principal, "metadataOrigin", "https://metadata.example"); + set(principal, "publicKeys", Map.of( + "connect-v2-test", "diQm8c6MI-Zwn1nie8hq4wqf3mYLuI96uJBC6NHCTDg")); + return config; + } + + private static Session session(JsonObject vector) { + JsonObject context = vector.getAsJsonObject("trusted_context"); + return Session.newBuilder() + .setId(context.get("connect_session_id").getAsString()) + .setPlayer(Player.newBuilder() + .setAddr("127.0.0.1") + .setProfile(GameProfile.newBuilder() + .setId("00000000-0000-0000-0000-000000000099") + .setName("UntrustedCarrier"))) + .setAuth(Authentication.newBuilder().setPassthrough(false)) + .setProtocol(SessionProtocol.SESSION_PROTOCOL_BEDROCK) + .setEndpointId(context.get("endpoint_id").getAsString()) + .setOrganizationId(context.get("organization_id").getAsString()) + .setConnectSessionNonce(ByteString.copyFrom(Base64.getUrlDecoder() + .decode(context.get("connect_session_nonce").getAsString()))) + .setSourceProtocolVersion(context.get("source_protocol_version").getAsInt()) + .setPolicyRevision(context.get("policy_revision").getAsLong()) + .setSignedBedrockPrincipalV2(ByteString.copyFromUtf8( + vector.get("compact_jws").getAsString())) + .build(); + } + + private JsonObject vector(String name) { + try (var reader = new InputStreamReader(Objects.requireNonNull(getClass().getResourceAsStream( + "/bedrock-principal-v2/core-vectors.json")), StandardCharsets.UTF_8)) { + for (var value : JsonParser.parseReader(reader).getAsJsonArray()) { + if (name.equals(value.getAsJsonObject().get("name").getAsString())) { + return value.getAsJsonObject(); + } + } + throw new AssertionError("missing vector " + name); + } catch (java.io.IOException error) { + throw new AssertionError(error); + } + } + + private static void set(Object target, String name, Object value) { + try { + var field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException error) { + throw new AssertionError(error); + } + } +} diff --git a/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalGenerationConfigTest.java b/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalGenerationConfigTest.java new file mode 100644 index 000000000..e1d3d1bd1 --- /dev/null +++ b/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalGenerationConfigTest.java @@ -0,0 +1,106 @@ +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 static org.mockito.Mockito.mock; + +import com.minekube.connect.api.logger.ConnectLogger; +import com.minekube.connect.config.ConfigLoader; +import com.minekube.connect.config.ConnectConfig; +import com.minekube.connect.config.ProxyConnectConfig; +import java.nio.file.Files; +import java.nio.file.Path; +import okhttp3.OkHttpClient; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class BedrockPrincipalGenerationConfigTest { + @TempDir Path tempDir; + + @Test + void newlyGeneratedServerAndProxyConfigsDefaultV2ToRequire() throws Exception { + ConnectConfig server = load(ConnectConfig.class, tempDir.resolve("server")); + ProxyConnectConfig proxy = load(ProxyConnectConfig.class, tempDir.resolve("proxy")); + for (ConnectConfig config : new ConnectConfig[] {server, proxy}) { + assertEquals(2, config.getBedrockPrincipal().getConfigGeneration()); + assertEquals("require", config.getBedrockPrincipal().getMode()); + assertEquals("minekube-connect", config.getBedrockPrincipal().getIssuer()); + assertEquals("urn:minekube:connect:production", config.getBedrockPrincipal().getTrustDomain()); + assertEquals("urn:minekube:connect:bedrock-principal:v2", + config.getBedrockPrincipal().getAudience()); + } + } + + @Test + void generationOneFileRemainsByteIdenticalAndNonAdvertising() throws Exception { + Path directory = tempDir.resolve("legacy"); + Files.createDirectories(directory); + Path file = directory.resolve("config.yml"); + String legacy = String.join("\n", + "endpoint: legacy", + "allow-offline-mode-players: false", + "bedrock-identity:", + " enforcement: warn", + " metadata-url: https://watch-connect.minekube.net/.well-known/minekube-connect/bedrock-identity-keys.json", + " expected-issuer: minekube-connect", + " expected-policy: trusted_bedrock_xuid", + "metrics:", + " disabled: true", + " uuid: 00000000-0000-0000-0000-000000000000", + "config-version: 1", + ""); + Files.writeString(file, legacy); + + ConnectConfig config = load(ConnectConfig.class, directory); + assertEquals(legacy, Files.readString(file)); + assertEquals(0, config.getBedrockPrincipal().getConfigGeneration()); + assertFalse(BedrockPrincipalConfiguration.from(config.getBedrockPrincipal()).isCapable()); + assertEquals("warn", config.getBedrockIdentity().getEnforcement()); + } + + @Test + void onlyExactGenerationTwoRequireIsCapable() { + ConnectConfig.BedrockPrincipalConfig config = new ConnectConfig().getBedrockPrincipal(); + assertFalse(BedrockPrincipalConfiguration.from(config).isCapable()); + TestFields.set(config, "configGeneration", 2); + TestFields.set(config, "mode", "require"); + TestFields.set(config, "issuer", "minekube-connect"); + TestFields.set(config, "trustDomain", "urn:minekube:connect:production"); + TestFields.set(config, "audience", "urn:minekube:connect:bedrock-principal:v2"); + TestFields.set(config, "metadataOrigin", "https://connect.minekube.com"); + assertTrue(BedrockPrincipalConfiguration.from(config).isCapable()); + TestFields.set(config, "mode", "warn"); + assertFalse(BedrockPrincipalConfiguration.from(config).isCapable()); + TestFields.set(config, "mode", "REQUIRE"); + assertFalse(BedrockPrincipalConfiguration.from(config).isCapable()); + TestFields.set(config, "mode", "require"); + TestFields.set(config, "configGeneration", 3); + assertFalse(BedrockPrincipalConfiguration.from(config).isCapable()); + } + + private T load(Class type, Path directory) throws Exception { + Files.createDirectories(directory); + return new ConfigLoader(directory, type, + new ConfigLoader.EndpointNameGenerator(new OkHttpClient.Builder() + .addInterceptor(chain -> new okhttp3.Response.Builder() + .request(chain.request()).protocol(okhttp3.Protocol.HTTP_1_1) + .code(200).message("OK") + .body(okhttp3.ResponseBody.create( + okhttp3.MediaType.get("text/plain"), "generated")) + .build()) + .build()), mock(ConnectLogger.class)).load(); + } + + private static final class TestFields { + static void set(Object target, String name, Object value) { + try { + var field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException error) { + throw new AssertionError(error); + } + } + } +} diff --git a/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalReadinessTest.java b/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalReadinessTest.java new file mode 100644 index 000000000..ae10ac403 --- /dev/null +++ b/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalReadinessTest.java @@ -0,0 +1,100 @@ +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.google.protobuf.ByteString; +import com.minekube.connect.config.ConnectConfig; +import java.lang.reflect.Field; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Base64; +import java.util.Map; +import minekube.connect.v1alpha1.WatchServiceOuterClass.PrincipalError; +import minekube.connect.v1alpha1.WatchServiceOuterClass.ReadinessAttestation; +import minekube.connect.v1alpha1.WatchServiceOuterClass.ReadinessChallenge; +import minekube.connect.v1alpha1.WatchServiceOuterClass.TunnelTransport; +import org.junit.jupiter.api.Test; + +class BedrockPrincipalReadinessTest { + private static final long NOW = 1_722_470_400L; + + @Test + void advertisesOnlyGenerationTwoRequireWithUsableStaticPin() throws Exception { + ConnectConfig ready = configured("require", 2, validPins()); + BedrockPrincipalReadiness readiness = readiness(ready); + + assertTrue(readiness.isReady()); + assertEquals(32, readiness.revision().length); + assertEquals(BedrockPrincipalReadiness.CAPABILITY, + readiness.capabilities(java.util.List.of(), BedrockPrincipalReadiness.Transport.WATCH).get(0)); + + assertFalse(readiness(configured("warn", 2, validPins())).isReady()); + assertFalse(readiness(configured("require", 1, validPins())).isReady()); + assertFalse(readiness(configured("require", 2, Map.of())).isReady()); + assertFalse(readiness(configured("require", 2, Map.of("kid", "not-base64"))).isReady()); + } + + @Test + void attestationEchoesValidChallengeAndFailsClosedForWrongTransport() throws Exception { + BedrockPrincipalReadiness readiness = readiness(configured("require", 2, validPins())); + ReadinessChallenge challenge = challenge(TunnelTransport.Type.TYPE_WEBSOCKET); + + ReadinessAttestation answer = readiness.attest(challenge, BedrockPrincipalReadiness.Transport.WATCH); + assertEquals(challenge, answer.getChallenge()); + assertEquals(BedrockPrincipalReadiness.CAPABILITY, answer.getCapability()); + assertEquals("require", answer.getMode()); + assertEquals(32, answer.getReadinessRevision().size()); + assertEquals(NOW, answer.getObservedAtUnix()); + assertEquals(ReadinessAttestation.Result.RESULT_READY, answer.getResult()); + assertEquals(PrincipalError.PRINCIPAL_ERROR_UNSPECIFIED, answer.getReason()); + + ReadinessAttestation refused = readiness.attest(challenge, BedrockPrincipalReadiness.Transport.LIBP2P); + assertEquals(ReadinessAttestation.Result.RESULT_NOT_READY, refused.getResult()); + assertEquals(PrincipalError.PRINCIPAL_ERROR_READINESS, refused.getReason()); + } + + private static ReadinessChallenge challenge(TunnelTransport.Type transport) { + return ReadinessChallenge.newBuilder() + .setRequestId("request") + .setNonce(ByteString.copyFrom(new byte[16])) + .setEndpointId("endpoint-id") + .setOrganizationId("organization-id") + .setConnectorInstanceId("instance-id") + .setLeaseId("lease-id") + .setTransport(transport) + .setPolicyRevision(7) + .setIssuedAtUnix(NOW - 1) + .setExpiresAtUnix(NOW + 29) + .build(); + } + + private static Map validPins() { + return Map.of("kid-1", Base64.getUrlEncoder().withoutPadding().encodeToString(new byte[32])); + } + + private static ConnectConfig configured(String mode, int generation, Map pins) throws Exception { + ConnectConfig config = new ConnectConfig(); + set(config.getBedrockPrincipal(), "configGeneration", generation); + set(config.getBedrockPrincipal(), "mode", mode); + set(config.getBedrockPrincipal(), "issuer", "minekube-connect"); + set(config.getBedrockPrincipal(), "trustDomain", "urn:minekube:connect:production"); + set(config.getBedrockPrincipal(), "audience", "urn:minekube:connect:bedrock-principal:v2"); + set(config.getBedrockPrincipal(), "metadataOrigin", "https://connect.minekube.com"); + set(config.getBedrockPrincipal(), "publicKeys", pins); + return config; + } + + private static BedrockPrincipalReadiness readiness(ConnectConfig config) { + return new BedrockPrincipalReadiness( + config, Clock.fixed(Instant.ofEpochSecond(NOW), ZoneOffset.UTC)); + } + + private static void set(Object target, String name, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/core/src/test/java/com/minekube/connect/principal/BedrockPrincipalCoreVectorTest.java b/core/src/test/java/com/minekube/connect/principal/BedrockPrincipalCoreVectorTest.java new file mode 100644 index 000000000..eba5dbdb0 --- /dev/null +++ b/core/src/test/java/com/minekube/connect/principal/BedrockPrincipalCoreVectorTest.java @@ -0,0 +1,237 @@ +package com.minekube.connect.principal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.Gson; +import com.google.gson.annotations.SerializedName; +import com.minekube.connect.api.player.principal.BedrockPrincipalVerifier; +import com.minekube.connect.api.player.principal.BedrockPrincipalVerifierFactory; +import com.minekube.connect.api.player.principal.PrincipalError; +import com.minekube.connect.api.player.principal.PrincipalVerificationException; +import com.minekube.connect.api.player.principal.SignedPrincipalEnvelope; +import com.minekube.connect.api.player.principal.TrustedProposalContext; +import com.minekube.connect.api.player.principal.VerifierConfiguration; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class BedrockPrincipalCoreVectorTest { + private static final String VECTOR_SHA256 = + "4f2a442ee71bfd35af2ef1f3944489d17551aa77fed2c08220f2aa77032b6196"; + private static final byte[] TEST_PUBLIC_KEY = Base64.getUrlDecoder() + .decode("diQm8c6MI-Zwn1nie8hq4wqf3mYLuI96uJBC6NHCTDg"); + + @Test + void verifiesAgainstLiteralCoreVectorOutcomes() throws Exception { + byte[] literal = resourceBytes("/bedrock-principal-v2/core-vectors.json"); + assertEquals(VECTOR_SHA256, hex(MessageDigest.getInstance("SHA-256").digest(literal))); + Vector[] vectors = new Gson().fromJson( + new InputStreamReader( + Objects.requireNonNull(getClass().getResourceAsStream( + "/bedrock-principal-v2/core-vectors.json")), + StandardCharsets.UTF_8), + Vector[].class); + assertEquals(6, vectors.length); + + for (Vector vector : vectors) { + BedrockPrincipalVerifier verifier = BedrockPrincipalVerifierFactory.create( + VerifierConfiguration.builder() + .publicKey("connect-v2-test", TEST_PUBLIC_KEY) + .clock(Clock.fixed( + Instant.ofEpochSecond(vector.verificationTimeUnix), + ZoneOffset.UTC)) + .build()); + TrustedProposalContext expected = vector.trustedContext.toContext(); + if (!"OK".equals(vector.expectedError)) { + PrincipalVerificationException error = assertThrows( + PrincipalVerificationException.class, + () -> verifier.verifyAndConsume( + SignedPrincipalEnvelope.of(vector.compactJws), expected), + vector.name); + assertEquals(PrincipalError.valueOf(vector.expectedError), error.error(), vector.name); + continue; + } + + var principal = verifier.verifyAndConsume( + SignedPrincipalEnvelope.of(vector.compactJws), expected); + ExpectedPrincipal literalPrincipal = vector.expectedPrincipal; + assertNotNull(literalPrincipal, vector.name); + assertEquals(literalPrincipal.subjectKind, principal.subjectKind().wireName(), vector.name); + assertEquals(literalPrincipal.canonicalXuid, principal.xuid().value(), vector.name); + assertEquals(UUID.fromString(literalPrincipal.canonicalUnlinkedUuid), + principal.canonicalUnlinkedUuid(), vector.name); + assertEquals(literalPrincipal.bedrockDisplayName, principal.bedrockDisplayName(), vector.name); + assertEquals(UUID.fromString(literalPrincipal.effectiveUuid), + principal.effectiveGameProfile().uuid(), vector.name); + assertEquals(literalPrincipal.effectiveName, + principal.effectiveGameProfile().name(), vector.name); + assertEquals(literalPrincipal.verificationMethod, + principal.verification().verificationMethod(), vector.name); + assertEquals(literalPrincipal.kid, principal.verification().kid(), vector.name); + assertEquals(literalPrincipal.policyRevision, + principal.bindings().policyRevision(), vector.name); + if (literalPrincipal.linkedJava == null) { + assertTrue(principal.linkedJava().isEmpty(), vector.name); + } else { + var linked = principal.linkedJava().orElseThrow(); + assertEquals(UUID.fromString(literalPrincipal.linkedJava.uuid), linked.uuid(), vector.name); + assertEquals(literalPrincipal.linkedJava.name, linked.name(), vector.name); + assertEquals(literalPrincipal.linkedJava.provider, + linked.provenance().provider(), vector.name); + assertEquals(literalPrincipal.linkedJava.recordId, + linked.provenance().recordId(), vector.name); + assertEquals(literalPrincipal.linkedJava.revision, + linked.provenance().revision(), vector.name); + assertEquals(Instant.ofEpochSecond(literalPrincipal.linkedJava.verifiedAtUnix), + linked.provenance().verifiedAt(), vector.name); + } + } + } + + @Test + void consumesReplayExactlyOnce() throws Exception { + Vector vector = Arrays.stream(vectors()) + .filter(candidate -> candidate.name.equals("valid-unlinked")) + .findFirst() + .orElseThrow(); + BedrockPrincipalVerifier verifier = BedrockPrincipalVerifierFactory.create( + VerifierConfiguration.builder() + .publicKey("connect-v2-test", TEST_PUBLIC_KEY) + .clock(Clock.fixed( + Instant.ofEpochSecond(vector.verificationTimeUnix), ZoneOffset.UTC)) + .build()); + SignedPrincipalEnvelope envelope = SignedPrincipalEnvelope.of(vector.compactJws); + assertNotNull(verifier.verifyAndConsume(envelope, vector.trustedContext.toContext())); + PrincipalVerificationException error = assertThrows( + PrincipalVerificationException.class, + () -> verifier.verifyAndConsume(envelope, vector.trustedContext.toContext())); + assertEquals(PrincipalError.REPLAY, error.error()); + } + + @Test + void concurrentReplayConsumptionHasOneAnonymousWinner() throws Exception { + Vector vector = Arrays.stream(vectors()) + .filter(candidate -> candidate.name.equals("valid-unlinked")) + .findFirst().orElseThrow(); + BedrockPrincipalVerifier verifier = BedrockPrincipalVerifierFactory.create( + VerifierConfiguration.builder().publicKey("connect-v2-test", TEST_PUBLIC_KEY) + .clock(Clock.fixed(Instant.ofEpochSecond( + vector.verificationTimeUnix), ZoneOffset.UTC)).build()); + SignedPrincipalEnvelope envelope = SignedPrincipalEnvelope.of(vector.compactJws); + AtomicInteger successes = new AtomicInteger(); + AtomicInteger replays = new AtomicInteger(); + CountDownLatch start = new CountDownLatch(1); + var executor = Executors.newFixedThreadPool(16); + try { + List> results = new java.util.ArrayList<>(); + for (int index = 0; index < 32; index++) { + results.add(executor.submit(() -> { + start.await(); + try { + verifier.verifyAndConsume(envelope, vector.trustedContext.toContext()); + successes.incrementAndGet(); + } catch (PrincipalVerificationException error) { + if (error.error() != PrincipalError.REPLAY) throw error; + replays.incrementAndGet(); + } + return null; + })); + } + start.countDown(); + for (var result : results) result.get(); + } finally { + executor.shutdownNow(); + } + assertEquals(1, successes.get()); + assertEquals(31, replays.get()); + } + + private Vector[] vectors() { + return new Gson().fromJson( + new InputStreamReader( + Objects.requireNonNull(getClass().getResourceAsStream( + "/bedrock-principal-v2/core-vectors.json")), + StandardCharsets.UTF_8), + Vector[].class); + } + + private static byte[] resourceBytes(String name) throws Exception { + try (var in = Objects.requireNonNull( + BedrockPrincipalCoreVectorTest.class.getResourceAsStream(name))) { + return in.readAllBytes(); + } + } + + private static String hex(byte[] bytes) { + StringBuilder result = new StringBuilder(bytes.length * 2); + for (byte value : bytes) result.append(String.format("%02x", value)); + return result.toString(); + } + + private static final class Vector { + String name; + @SerializedName("compact_jws") String compactJws; + @SerializedName("trusted_context") TrustedContext trustedContext; + @SerializedName("verification_time_unix") long verificationTimeUnix; + @SerializedName("expected_error") String expectedError; + @SerializedName("expected_principal") ExpectedPrincipal expectedPrincipal; + } + + private static final class TrustedContext { + String issuer; + @SerializedName("trust_domain") String trustDomain; + String audience; + @SerializedName("endpoint_id") String endpointId; + @SerializedName("organization_id") String organizationId; + @SerializedName("connect_session_id") String connectSessionId; + @SerializedName("connect_session_nonce") String connectSessionNonce; + @SerializedName("source_protocol") String sourceProtocol; + @SerializedName("source_protocol_version") int sourceProtocolVersion; + @SerializedName("policy_revision") long policyRevision; + + TrustedProposalContext toContext() { + return new TrustedProposalContext( + issuer, trustDomain, audience, endpointId, organizationId, connectSessionId, + Base64.getUrlDecoder().decode(connectSessionNonce), sourceProtocol, + sourceProtocolVersion, policyRevision); + } + } + + private static final class ExpectedPrincipal { + @SerializedName("subject_kind") String subjectKind; + @SerializedName("canonical_xuid") String canonicalXuid; + @SerializedName("canonical_unlinked_uuid") String canonicalUnlinkedUuid; + @SerializedName("bedrock_display_name") String bedrockDisplayName; + @SerializedName("effective_uuid") String effectiveUuid; + @SerializedName("effective_name") String effectiveName; + @SerializedName("verification_method") String verificationMethod; + String kid; + @SerializedName("policy_revision") long policyRevision; + @SerializedName("linked_java") ExpectedLinkedJava linkedJava; + } + + private static final class ExpectedLinkedJava { + String uuid; + String name; + String provider; + @SerializedName("record_id") String recordId; + long revision; + @SerializedName("verified_at_unix") long verifiedAtUnix; + } +} diff --git a/core/src/test/java/com/minekube/connect/principal/BedrockPrincipalWireBoundaryTest.java b/core/src/test/java/com/minekube/connect/principal/BedrockPrincipalWireBoundaryTest.java new file mode 100644 index 000000000..de6929c49 --- /dev/null +++ b/core/src/test/java/com/minekube/connect/principal/BedrockPrincipalWireBoundaryTest.java @@ -0,0 +1,63 @@ +package com.minekube.connect.principal; + +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.Descriptors.Descriptor; +import com.google.protobuf.Descriptors.FieldDescriptor; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import minekube.connect.v1alpha1.ConnectLibp2P; +import minekube.connect.v1alpha1.WatchServiceOuterClass; +import org.junit.jupiter.api.Test; + +class BedrockPrincipalWireBoundaryTest { + private static final Set FORBIDDEN = Set.of( + "xuid", "bedrock_display_name", "linked_java_uuid", "linked_java_name", + "link_record_id", "jti"); + + @Test + void watchSessionUsesOnlyFrozenOpaqueV2Fields() { + Descriptor session = WatchServiceOuterClass.Session.getDescriptor(); + assertFields(session, List.of( + "id:1", "tunnel_service_addr:2", "player:3", "auth:4", "tunnel_transports:5", + "protocol:6", "endpoint_id:7", "organization_id:8", "connect_session_nonce:9", + "source_protocol_version:10", "policy_revision:11", "signed_bedrock_principal_v2:12")); + assertEquals(List.of("payload"), WatchServiceOuterClass.WatchRequest.getDescriptor() + .getOneofs().stream().map(oneof -> oneof.getName()).toList()); + assertEquals(List.of("payload"), WatchServiceOuterClass.WatchResponse.getDescriptor() + .getOneofs().stream().map(oneof -> oneof.getName()).toList()); + assertNoRawIdentity(WatchServiceOuterClass.getDescriptor().getMessageTypes()); + } + + @Test + void libp2pOfferUsesOnlyFrozenOpaqueV2Fields() { + assertFields(ConnectLibp2P.SessionOffer.getDescriptor(), List.of( + "session_id:1", "endpoint:2", "player:3", "auth:4", "deadline_unix_ms:5", + "endpoint_id:6", "endpoint_org_id:7", "protocol:8", "connect_session_nonce:9", + "source_protocol_version:10", "policy_revision:11", "signed_bedrock_principal_v2:12")); + assertNoRawIdentity(ConnectLibp2P.getDescriptor().getMessageTypes()); + } + + private static void assertFields(Descriptor descriptor, List expected) { + assertEquals(expected, descriptor.getFields().stream() + .map(field -> field.getName() + ":" + field.getNumber()).toList()); + } + + private static void assertNoRawIdentity(List roots) { + for (Descriptor root : roots) assertNoRawIdentity(root); + } + + private static void assertNoRawIdentity(Descriptor descriptor) { + Set names = descriptor.getFields().stream() + .map(FieldDescriptor::getName).collect(Collectors.toSet()); + assertTrue(names.stream().noneMatch(FORBIDDEN::contains), descriptor.getFullName()); + for (FieldDescriptor field : descriptor.getFields()) { + if (field.getJavaType() == FieldDescriptor.JavaType.MESSAGE) { + assertNoRawIdentity(field.getMessageType()); + } + } + } +} diff --git a/core/src/test/java/com/minekube/connect/principal/PrincipalConstructionBoundaryTest.java b/core/src/test/java/com/minekube/connect/principal/PrincipalConstructionBoundaryTest.java new file mode 100644 index 000000000..04a80bd88 --- /dev/null +++ b/core/src/test/java/com/minekube/connect/principal/PrincipalConstructionBoundaryTest.java @@ -0,0 +1,57 @@ +package com.minekube.connect.principal; + +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.principal.VerifiedBedrockPrincipal; +import com.minekube.connect.api.player.principal.VerifiedPrincipal; +import java.lang.reflect.Modifier; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import javax.tools.DiagnosticCollector; +import javax.tools.JavaFileObject; +import javax.tools.StandardJavaFileManager; +import javax.tools.ToolProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class PrincipalConstructionBoundaryTest { + @TempDir Path tempDir; + + @Test + void sealedHierarchyPermitsOnlySdkImplementations() throws Exception { + assertTrue(VerifiedPrincipal.class.isSealed()); + assertTrue(VerifiedBedrockPrincipal.class.isSealed()); + assertEquals(List.of(VerifiedBedrockPrincipal.class), + List.of(VerifiedPrincipal.class.getPermittedSubclasses())); + Class implementation = VerifiedBedrockPrincipal.class.getPermittedSubclasses()[0]; + assertEquals("ImmutableVerifiedBedrockPrincipal", implementation.getSimpleName()); + assertTrue(Modifier.isFinal(implementation.getModifiers())); + assertFalse(Modifier.isPublic(implementation.getModifiers())); + } + + @Test + void hostCannotForgeVerifiedPrincipal() throws Exception { + Path source = tempDir.resolve("Forged.java"); + Files.writeString(source, + "import com.minekube.connect.api.player.principal.*;\n" + + "final class Forged implements VerifiedBedrockPrincipal {}\n", + StandardCharsets.UTF_8); + var compiler = ToolProvider.getSystemJavaCompiler(); + var diagnostics = new DiagnosticCollector(); + try (StandardJavaFileManager files = compiler.getStandardFileManager( + diagnostics, null, StandardCharsets.UTF_8)) { + var units = files.getJavaFileObjects(source.toFile()); + boolean success = compiler.getTask( + null, files, diagnostics, + List.of("-classpath", System.getProperty("java.class.path")), null, units).call(); + assertFalse(success); + } + assertTrue(diagnostics.getDiagnostics().stream() + .map(Object::toString) + .anyMatch(message -> message.contains("sealed"))); + } +} diff --git a/core/src/test/java/com/minekube/connect/principal/PrincipalPrivacyTest.java b/core/src/test/java/com/minekube/connect/principal/PrincipalPrivacyTest.java new file mode 100644 index 000000000..03961e147 --- /dev/null +++ b/core/src/test/java/com/minekube/connect/principal/PrincipalPrivacyTest.java @@ -0,0 +1,103 @@ +package com.minekube.connect.principal; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.protobuf.ByteString; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.minekube.connect.api.player.principal.BedrockPrincipalVerifierFactory; +import com.minekube.connect.api.player.principal.PrincipalError; +import com.minekube.connect.api.player.principal.PrincipalVerificationException; +import com.minekube.connect.api.player.principal.SignedPrincipalEnvelope; +import com.minekube.connect.api.player.principal.TrustedProposalContext; +import com.minekube.connect.api.player.principal.VerifierConfiguration; +import com.minekube.connect.watch.SessionProposal; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Base64; +import java.util.Objects; +import minekube.connect.v1alpha1.WatchServiceOuterClass.Session; +import org.junit.jupiter.api.Test; + +class PrincipalPrivacyTest { + @Test + void principalAndErrorsDoNotSerializeOrLogRawIdentityMaterial() throws Exception { + JsonObject vector = linkedVector(); + JsonObject context = vector.getAsJsonObject("trusted_context"); + String compact = vector.get("compact_jws").getAsString(); + var verifier = BedrockPrincipalVerifierFactory.create(VerifierConfiguration.builder() + .publicKey("connect-v2-test", Base64.getUrlDecoder() + .decode("diQm8c6MI-Zwn1nie8hq4wqf3mYLuI96uJBC6NHCTDg")) + .clock(Clock.fixed(Instant.ofEpochSecond( + vector.get("verification_time_unix").getAsLong()), ZoneOffset.UTC)) + .build()); + TrustedProposalContext trusted = new TrustedProposalContext( + context.get("issuer").getAsString(), + context.get("trust_domain").getAsString(), + context.get("audience").getAsString(), + context.get("endpoint_id").getAsString(), + context.get("organization_id").getAsString(), + context.get("connect_session_id").getAsString(), + Base64.getUrlDecoder().decode(context.get("connect_session_nonce").getAsString()), + context.get("source_protocol").getAsString(), + context.get("source_protocol_version").getAsInt(), + context.get("policy_revision").getAsLong()); + var principal = verifier.verifyAndConsume(SignedPrincipalEnvelope.of(compact), trusted); + + String capture = principal + "\n" + new Gson().toJson(principal) + "\n" + + principal.xuid() + "\n" + principal.linkedJava().orElseThrow(); + for (String forbidden : new String[] { + "BedrockOne", "JavaOne", "record-test-1", "123e4567-e89b-12d3-a456-426614174000", + compact, "AAAAAAAAAAAAAAAAAAAAAA", "AgICAgICAgICAgICAgICAg" + }) { + assertFalse(capture.contains(forbidden), forbidden); + } + + PrincipalVerificationException error = new PrincipalVerificationException(PrincipalError.SIGNATURE); + assertTrue(error.toString().endsWith(PrincipalError.SIGNATURE.name())); + assertNull(error.getCause()); + assertTrue(error.getStackTrace().length == 0); + } + + @Test + void proposalStringCannotExposeEnvelopeNonceOrProfile() { + String envelope = "compact-envelope-sentinel"; + String display = "Bedrock-display-sentinel"; + SessionProposal proposal = new SessionProposal(Session.newBuilder() + .setId("session-correlation") + .setConnectSessionNonce(ByteString.copyFromUtf8("nonce-sentinel-1")) + .setSignedBedrockPrincipalV2(ByteString.copyFromUtf8(envelope)) + .setPlayer(minekube.connect.v1alpha1.WatchServiceOuterClass.Player.newBuilder() + .setProfile(minekube.connect.v1alpha1.WatchServiceOuterClass.GameProfile + .newBuilder().setName(display))) + .build(), ignored -> { }); + + String capture = proposal.toString() + proposal.getSession(); + assertTrue(proposal.hasBedrockPrincipalV2()); + assertFalse(capture.contains(envelope)); + assertFalse(capture.contains("nonce-sentinel-1")); + assertFalse(proposal.toString().contains(display)); + } + + private JsonObject linkedVector() { + try (var reader = new InputStreamReader( + Objects.requireNonNull(getClass().getResourceAsStream( + "/bedrock-principal-v2/core-vectors.json")), StandardCharsets.UTF_8)) { + JsonArray vectors = JsonParser.parseReader(reader).getAsJsonArray(); + for (var value : vectors) { + JsonObject vector = value.getAsJsonObject(); + if ("valid-linked".equals(vector.get("name").getAsString())) return vector; + } + throw new AssertionError("missing valid-linked vector"); + } catch (java.io.IOException error) { + throw new AssertionError(error); + } + } +} diff --git a/core/src/test/java/com/minekube/connect/tunnel/p2p/P2PFrameCodecTest.java b/core/src/test/java/com/minekube/connect/tunnel/p2p/P2PFrameCodecTest.java index 3c502976e..d11211533 100644 --- a/core/src/test/java/com/minekube/connect/tunnel/p2p/P2PFrameCodecTest.java +++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/P2PFrameCodecTest.java @@ -6,11 +6,34 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import minekube.connect.v1alpha1.ConnectLibp2P.PeerRegisterResult; import minekube.connect.v1alpha1.ConnectLibp2P.SessionAck; import minekube.connect.v1alpha1.ConnectLibp2P.SessionResponse; import org.junit.jupiter.api.Test; class P2PFrameCodecTest { + @Test + void roundTripsFrozenKindPrefixedFraming() throws Exception { + PeerRegisterResult message = PeerRegisterResult.newBuilder().setKvRevision(42).build(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + + P2PFrameCodec.writeKindPrefixed(out, P2PFrameCodec.RENEWAL_RESULT, message); + P2PFrameCodec.KindPrefixedFrame frame = P2PFrameCodec.readKindPrefixed( + new ByteArrayInputStream(out.toByteArray())); + + assertEquals(P2PFrameCodec.RENEWAL_RESULT, frame.kind()); + assertEquals(message, frame.parse(PeerRegisterResult.parser())); + assertEquals(1 + message.getSerializedSize(), out.toByteArray()[0]); + } + + @Test + void kindPrefixedFramingRejectsUnknownAndOversizedFrames() { + assertThrows(IllegalArgumentException.class, () -> P2PFrameCodec.writeKindPrefixed( + new ByteArrayOutputStream(), (byte) 0x05, PeerRegisterResult.getDefaultInstance())); + byte[] oversized = new byte[] {(byte) 0x81, 0x20}; // 4097 + assertThrows(IllegalArgumentException.class, () -> P2PFrameCodec.readKindPrefixed( + new ByteArrayInputStream(oversized))); + } @Test void roundTripsVarintDelimitedProtoFrames() throws Exception { diff --git a/core/src/test/java/com/minekube/connect/tunnel/p2p/PeerRegistrationClientTest.java b/core/src/test/java/com/minekube/connect/tunnel/p2p/PeerRegistrationClientTest.java index 1b6abbc6e..4b9b0394d 100644 --- a/core/src/test/java/com/minekube/connect/tunnel/p2p/PeerRegistrationClientTest.java +++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/PeerRegistrationClientTest.java @@ -3,12 +3,15 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.google.protobuf.ByteString; +import com.minekube.connect.bedrock.BedrockPrincipalReadiness; +import com.minekube.connect.config.ConnectConfig; import io.libp2p.core.Stream; import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBufInputStream; @@ -16,8 +19,11 @@ import io.netty.channel.embedded.EmbeddedChannel; import java.io.ByteArrayOutputStream; import java.nio.file.Path; +import java.lang.reflect.Field; import java.util.Arrays; +import java.util.Base64; import java.util.Collections; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -27,6 +33,10 @@ import minekube.connect.v1alpha1.ConnectLibp2P.PeerRegisterCommit; import minekube.connect.v1alpha1.ConnectLibp2P.PeerRegisterInit; import minekube.connect.v1alpha1.ConnectLibp2P.PeerRegisterResult; +import minekube.connect.v1alpha1.ConnectLibp2P.RegistrationModeResult; +import minekube.connect.v1alpha1.WatchServiceOuterClass.ReadinessAttestation; +import minekube.connect.v1alpha1.WatchServiceOuterClass.ReadinessChallenge; +import minekube.connect.v1alpha1.WatchServiceOuterClass.TunnelTransport; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.mockito.ArgumentCaptor; @@ -258,6 +268,87 @@ void closesRegistrationWhenRenewAckTimesOut() throws Exception { assertTrue(client.closedFuture().isCompletedExceptionally()); } + @Test + void negotiatesFramingBeforeAnsweringReadinessChallenge() 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).build()); + Stream stream = mock(Stream.class); + when(stream.closeFuture()).thenReturn(new CompletableFuture<>()); + PeerRegistrationClient client = new PeerRegistrationClient(handshake, readyPrincipalConsumer()); + + client.install(stream, Collections.singletonList( + "/ip4/127.0.0.1/tcp/1234/p2p/" + identity.peerId()), 9, 1_000); + ArgumentCaptor handlers = ArgumentCaptor.forClass(ChannelHandler.class); + verify(stream, times(2)).pushHandler(handlers.capture()); + EmbeddedChannel channel = new EmbeddedChannel(handlers.getAllValues().toArray(ChannelHandler[]::new)); + channel.writeInbound(frame(PeerRegisterChallenge.newBuilder() + .setEndpointId("endpoint-id").setEndpointHash("endpoint-hash") + .setPublisherId("publisher").setPublisherPeerId("publisher-peer") + .setRegion("local").setKvTtlMs(10_000).setRenewIntervalMs(1_000) + .setNonce(ByteString.copyFromUtf8("nonce")).build())); + channel.writeInbound(frame(PeerRegisterResult.newBuilder().setKvRevision(1).build())); + + ArgumentCaptor offeredFrame = ArgumentCaptor.forClass(Object.class); + verify(stream, timeout(2_500).times(3)).writeAndFlush(offeredFrame.capture()); + PeerRegisterCommit offered = P2PFrameCodec.read( + new ByteBufInputStream((ByteBuf) offeredFrame.getAllValues().get(2)), + PeerRegisterCommit.parser(), P2PFrameCodec.MAX_CONTROL_FRAME_SIZE); + assertEquals("kind-prefixed-v1", offered.getModeOffer().getFraming()); + + long now = System.currentTimeMillis() / 1_000; + ReadinessChallenge readinessChallenge = ReadinessChallenge.newBuilder() + .setRequestId("request").setNonce(ByteString.copyFrom(new byte[16])) + .setEndpointId("endpoint-id").setOrganizationId("organization-id") + .setConnectorInstanceId("instance").setLeaseId("lease") + .setTransport(TunnelTransport.Type.TYPE_LIBP2P).setPolicyRevision(2) + .setIssuedAtUnix(now).setExpiresAtUnix(now + 30).build(); + clearInvocations(stream); + channel.writeInbound(negotiationAndChallenge( + PeerRegisterResult.newBuilder().setKvRevision(2) + .setModeResult(RegistrationModeResult.newBuilder() + .setVersion(2).setAccepted(true)).build(), + readinessChallenge)); + + ArgumentCaptor answerFrame = ArgumentCaptor.forClass(Object.class); + verify(stream, timeout(500)).writeAndFlush(answerFrame.capture()); + P2PFrameCodec.KindPrefixedFrame answer = P2PFrameCodec.readKindPrefixed( + new ByteBufInputStream((ByteBuf) answerFrame.getValue())); + assertEquals(P2PFrameCodec.READINESS_ATTESTATION, answer.kind()); + assertEquals(ReadinessAttestation.Result.RESULT_READY, + answer.parse(ReadinessAttestation.parser()).getResult()); + client.close(); + } + + private static BedrockPrincipalReadiness readyPrincipalConsumer() throws Exception { + ConnectConfig config = new ConnectConfig(); + set(config.getBedrockPrincipal(), "configGeneration", 2); + set(config.getBedrockPrincipal(), "mode", "require"); + set(config.getBedrockPrincipal(), "issuer", "minekube-connect"); + set(config.getBedrockPrincipal(), "trustDomain", "urn:minekube:connect:production"); + set(config.getBedrockPrincipal(), "audience", "urn:minekube:connect:bedrock-principal:v2"); + set(config.getBedrockPrincipal(), "metadataOrigin", "https://connect.minekube.com"); + set(config.getBedrockPrincipal(), "publicKeys", Map.of("kid", Base64.getUrlEncoder() + .withoutPadding().encodeToString(new byte[32]))); + return new BedrockPrincipalReadiness(config); + } + + private static void set(Object target, String name, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } + + private static ByteBuf negotiationAndChallenge( + PeerRegisterResult result, ReadinessChallenge challenge) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + P2PFrameCodec.write(out, result); + P2PFrameCodec.writeKindPrefixed(out, P2PFrameCodec.READINESS_CHALLENGE, challenge); + return io.netty.buffer.Unpooled.wrappedBuffer(out.toByteArray()); + } + private static ByteBuf frame(com.google.protobuf.MessageLite message) throws Exception { ByteArrayOutputStream out = new ByteArrayOutputStream(); P2PFrameCodec.write(out, message); 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 c35916b6a..5cb755ec4 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 @@ -64,6 +64,7 @@ void buildsInitAndSignedCommitFromChallenge() throws Exception { .setNonce(ByteString.copyFromUtf8("nonce")) .build(); PeerRegisterCommit commit = handshake.commit(challenge, init.getObservedAddrsList(), 7, 1_000); + assertFalse(commit.hasModeOffer()); EndpointPeerRecord record = commit.getRecord(); assertEquals("endpoint", record.getEndpoint()); @@ -86,6 +87,11 @@ void buildsInitAndSignedCommitFromChallenge() throws Exception { assertTrue(publicKey.verify( PeerRecordSigningPayload.bytes(record), commit.getSignature().toByteArray())); + + PeerRegisterCommit offered = handshake.commit( + challenge, init.getObservedAddrsList(), 8, 2_000, true); + assertEquals(2, offered.getModeOffer().getVersion()); + assertEquals("kind-prefixed-v1", offered.getModeOffer().getFraming()); } @Test diff --git a/core/src/test/java/com/minekube/connect/watch/WatchClientTest.java b/core/src/test/java/com/minekube/connect/watch/WatchClientTest.java index 547ffeb5b..e4ea81a99 100644 --- a/core/src/test/java/com/minekube/connect/watch/WatchClientTest.java +++ b/core/src/test/java/com/minekube/connect/watch/WatchClientTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -10,6 +11,7 @@ import com.minekube.connect.bedrock.BedrockAdmissionCoordinator; import com.minekube.connect.bedrock.BedrockIdentityKeyProvider; import com.minekube.connect.bedrock.BedrockIdentityReadiness; +import com.minekube.connect.bedrock.BedrockPrincipalReadiness; import com.minekube.connect.bedrock.VerifiedBedrockIdentityRegistry; import com.minekube.connect.config.ConnectConfig; import java.util.concurrent.atomic.AtomicReference; @@ -20,6 +22,9 @@ import minekube.connect.v1alpha1.WatchServiceOuterClass.Session; import minekube.connect.v1alpha1.WatchServiceOuterClass.SessionProtocol; import minekube.connect.v1alpha1.WatchServiceOuterClass.WatchResponse; +import minekube.connect.v1alpha1.WatchServiceOuterClass.ReadinessChallenge; +import minekube.connect.v1alpha1.WatchServiceOuterClass.TunnelTransport; +import minekube.connect.v1alpha1.WatchServiceOuterClass.WatchRequest; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.WebSocket; @@ -102,4 +107,43 @@ void defaultDisabledConfigurationDoesNotAdvertiseBedrockIdentity() { assertFalse(request.getValue().headers("Connect-Capabilities") .contains("bedrock-identity-v1")); } + + @Test + void readinessChallengeIsAnsweredAndNeverDeliveredAsSession() { + ConnectConfig config = new ConnectConfig(); + OkHttpClient httpClient = mock(OkHttpClient.class); + WatchClient client = new WatchClient(httpClient, config); + Watcher watcher = mock(Watcher.class); + WebSocket socket = mock(WebSocket.class); + + client.watch(watcher); + ArgumentCaptor listener = ArgumentCaptor.forClass(WebSocketListener.class); + verify(httpClient).newWebSocket(any(Request.class), listener.capture()); + ReadinessChallenge challenge = ReadinessChallenge.newBuilder() + .setRequestId("request") + .setNonce(com.google.protobuf.ByteString.copyFrom(new byte[16])) + .setEndpointId("endpoint") + .setOrganizationId("organization") + .setConnectorInstanceId("instance") + .setLeaseId("lease") + .setTransport(TunnelTransport.Type.TYPE_WEBSOCKET) + .setPolicyRevision(1) + .setIssuedAtUnix(1) + .setExpiresAtUnix(31) + .build(); + + listener.getValue().onMessage(socket, ByteString.of(WatchResponse.newBuilder() + .setReadinessChallenge(challenge).build().toByteArray())); + + ArgumentCaptor response = ArgumentCaptor.forClass(ByteString.class); + verify(socket).send(response.capture()); + WatchRequest request; + try { + request = WatchRequest.parseFrom(response.getValue().toByteArray()); + } catch (com.google.protobuf.InvalidProtocolBufferException error) { + throw new AssertionError(error); + } + assertTrue(request.hasReadinessAttestation()); + verify(watcher, org.mockito.Mockito.never()).onProposal(any()); + } } diff --git a/core/src/test/resources/bedrock-principal-v2/UPSTREAM b/core/src/test/resources/bedrock-principal-v2/UPSTREAM new file mode 100644 index 000000000..aea55bfbb --- /dev/null +++ b/core/src/test/resources/bedrock-principal-v2/UPSTREAM @@ -0,0 +1,4 @@ +repository=https://github.com/minekube/connect +commit=7fcd6f40f326c38b16f94bca37f188e18ae4daa7 +core-vectors.json.sha256=4f2a442ee71bfd35af2ef1f3944489d17551aa77fed2c08220f2aa77032b6196 +v2.schema.json.sha256=648677745c5babd03e0e59c1ff5a98cbb0e3a45ab8311632731ee73dd32a807c diff --git a/core/src/test/resources/bedrock-principal-v2/core-vectors.json b/core/src/test/resources/bedrock-principal-v2/core-vectors.json new file mode 100644 index 000000000..58b45e0b5 --- /dev/null +++ b/core/src/test/resources/bedrock-principal-v2/core-vectors.json @@ -0,0 +1,144 @@ +[ + { + "name": "valid-unlinked", + "compact_jws": "eyJhbGciOiJFZERTQSIsInR5cCI6ImNvbm5lY3QtYmVkcm9jay1wcmluY2lwYWwrandzO3Y9MiIsImtpZCI6ImNvbm5lY3QtdjItdGVzdCJ9.eyJhdWRpZW5jZSI6InVybjptaW5la3ViZTpjb25uZWN0OnRlc3Q6YmVkcm9jay1wcmluY2lwYWw6djIiLCJiZWRyb2NrX2Rpc3BsYXlfbmFtZSI6IkJlZHJvY2tPbmUiLCJjYW5vbmljYWxfdW5saW5rZWRfdXVpZCI6IjAwMDAwMDAwLTAwMDAtMDAwMC0wMDAwLTAwMDAwMDAwMDAwMSIsImNhbm9uaWNhbF94dWlkIjoiMSIsImNvbm5lY3Rfc2Vzc2lvbl9pZCI6InNlc3Npb24tdGVzdCIsImNvbm5lY3Rfc2Vzc2lvbl9ub25jZSI6IkFBQUFBQUFBQUFBQUFBQUFBQUFBQUEiLCJlbmRwb2ludF9pZCI6ImVuZHBvaW50LXRlc3QiLCJleHAiOjE3ODU0OTIwMzAsImlhdCI6MTc4NTQ5MjAwMCwiaXNzdWVyIjoibWluZWt1YmUtY29ubmVjdC10ZXN0IiwianRpIjoiQVFFQkFRRUJBUUVCQVFFQkFRRUJBUSIsIm5iZiI6MTc4NTQ5MjAwMCwib3JnYW5pemF0aW9uX2lkIjoib3JnYW5pemF0aW9uLXRlc3QiLCJwb2xpY3lfcmV2aXNpb24iOjcsInNvdXJjZV9wcm90b2NvbCI6ImJlZHJvY2siLCJzb3VyY2VfcHJvdG9jb2xfdmVyc2lvbiI6Nzc2LCJzdWJqZWN0X2tpbmQiOiJiZWRyb2NrX3h1aWQiLCJ0cnVzdF9kb21haW4iOiJ1cm46bWluZWt1YmU6Y29ubmVjdDp0ZXN0OmNvcnB1cy12MiIsInZlcmlmaWNhdGlvbl9tZXRob2QiOiJtaW5lY3JhZnRfbGVnYWN5X2NoYWluK2NsaWVudF9qd3QrZWNkaF92MSIsInZlcnNpb24iOjJ9.QmT-XuIYP3fbeVvsG1HzGKZXSyETGMBr3F8snZH_vssZoijXE2VsUDESRVm9bcS1aXAGQPqlo5nv8ZsYWAq1DQ", + "trusted_context": { + "issuer": "minekube-connect-test", + "trust_domain": "urn:minekube:connect:test:corpus-v2", + "audience": "urn:minekube:connect:test:bedrock-principal:v2", + "endpoint_id": "endpoint-test", + "organization_id": "organization-test", + "connect_session_id": "session-test", + "connect_session_nonce": "AAAAAAAAAAAAAAAAAAAAAA", + "source_protocol": "bedrock", + "source_protocol_version": 776, + "policy_revision": 7 + }, + "verification_time_unix": 1785492000, + "expected_error": "OK", + "expected_principal": { + "subject_kind": "bedrock_xuid", + "canonical_xuid": "1", + "canonical_unlinked_uuid": "00000000-0000-0000-0000-000000000001", + "bedrock_display_name": "BedrockOne", + "effective_uuid": "00000000-0000-0000-0000-000000000001", + "effective_name": "BedrockOne", + "verification_method": "minecraft_legacy_chain+client_jwt+ecdh_v1", + "kid": "connect-v2-test", + "policy_revision": 7 + } + }, + { + "name": "valid-linked", + "compact_jws": "eyJhbGciOiJFZERTQSIsInR5cCI6ImNvbm5lY3QtYmVkcm9jay1wcmluY2lwYWwrandzO3Y9MiIsImtpZCI6ImNvbm5lY3QtdjItdGVzdCJ9.eyJhdWRpZW5jZSI6InVybjptaW5la3ViZTpjb25uZWN0OnRlc3Q6YmVkcm9jay1wcmluY2lwYWw6djIiLCJiZWRyb2NrX2Rpc3BsYXlfbmFtZSI6IkJlZHJvY2tPbmUiLCJjYW5vbmljYWxfdW5saW5rZWRfdXVpZCI6IjAwMDAwMDAwLTAwMDAtMDAwMC0wMDAwLTAwMDAwMDAwMDAwMSIsImNhbm9uaWNhbF94dWlkIjoiMSIsImNvbm5lY3Rfc2Vzc2lvbl9pZCI6InNlc3Npb24tdGVzdCIsImNvbm5lY3Rfc2Vzc2lvbl9ub25jZSI6IkFBQUFBQUFBQUFBQUFBQUFBQUFBQUEiLCJlbmRwb2ludF9pZCI6ImVuZHBvaW50LXRlc3QiLCJleHAiOjE3ODU0OTIwMzAsImlhdCI6MTc4NTQ5MjAwMCwiaXNzdWVyIjoibWluZWt1YmUtY29ubmVjdC10ZXN0IiwianRpIjoiQWdJQ0FnSUNBZ0lDQWdJQ0FnSUNBZyIsImxpbmtlZF9qYXZhIjp7Im5hbWUiOiJKYXZhT25lIiwicHJvdmVuYW5jZSI6eyJwcm92aWRlciI6Im1veHlfYWNjb3VudF9saW5rX3YxIiwicmVjb3JkX2lkIjoicmVjb3JkLXRlc3QtMSIsInJldmlzaW9uIjozLCJ2ZXJpZmllZF9hdCI6MTc4NTQ5MTk5NX0sInV1aWQiOiIxMjNlNDU2Ny1lODliLTEyZDMtYTQ1Ni00MjY2MTQxNzQwMDAifSwibmJmIjoxNzg1NDkyMDAwLCJvcmdhbml6YXRpb25faWQiOiJvcmdhbml6YXRpb24tdGVzdCIsInBvbGljeV9yZXZpc2lvbiI6Nywic291cmNlX3Byb3RvY29sIjoiYmVkcm9jayIsInNvdXJjZV9wcm90b2NvbF92ZXJzaW9uIjo3NzYsInN1YmplY3Rfa2luZCI6ImJlZHJvY2tfbGlua2VkX2phdmEiLCJ0cnVzdF9kb21haW4iOiJ1cm46bWluZWt1YmU6Y29ubmVjdDp0ZXN0OmNvcnB1cy12MiIsInZlcmlmaWNhdGlvbl9tZXRob2QiOiJtaW5lY3JhZnRfZnVsbF9qd2tzK2NsaWVudF9qd3QrZWNkaF92MSIsInZlcnNpb24iOjJ9.S4OQjj4dNz69jsAbCFQu866Bc3YX6i01wMMdzDQw4PGX3MTfNjIxD1XGX3Jp7BQN21RKFJcHDyGlNsVNc5NeCA", + "trusted_context": { + "issuer": "minekube-connect-test", + "trust_domain": "urn:minekube:connect:test:corpus-v2", + "audience": "urn:minekube:connect:test:bedrock-principal:v2", + "endpoint_id": "endpoint-test", + "organization_id": "organization-test", + "connect_session_id": "session-test", + "connect_session_nonce": "AAAAAAAAAAAAAAAAAAAAAA", + "source_protocol": "bedrock", + "source_protocol_version": 776, + "policy_revision": 7 + }, + "verification_time_unix": 1785492000, + "expected_error": "OK", + "expected_principal": { + "subject_kind": "bedrock_linked_java", + "canonical_xuid": "1", + "canonical_unlinked_uuid": "00000000-0000-0000-0000-000000000001", + "bedrock_display_name": "BedrockOne", + "effective_uuid": "123e4567-e89b-12d3-a456-426614174000", + "effective_name": "JavaOne", + "verification_method": "minecraft_full_jwks+client_jwt+ecdh_v1", + "kid": "connect-v2-test", + "policy_revision": 7, + "linked_java": { + "uuid": "123e4567-e89b-12d3-a456-426614174000", + "name": "JavaOne", + "provider": "moxy_account_link_v1", + "record_id": "record-test-1", + "revision": 3, + "verified_at_unix": 1785491995 + } + } + }, + { + "name": "malformed-jti-tail-bits", + "compact_jws": "eyJhbGciOiJFZERTQSIsInR5cCI6ImNvbm5lY3QtYmVkcm9jay1wcmluY2lwYWwrandzO3Y9MiIsImtpZCI6ImNvbm5lY3QtdjItdGVzdCJ9.eyJhdWRpZW5jZSI6InVybjptaW5la3ViZTpjb25uZWN0OnRlc3Q6YmVkcm9jay1wcmluY2lwYWw6djIiLCJiZWRyb2NrX2Rpc3BsYXlfbmFtZSI6IkJlZHJvY2tPbmUiLCJjYW5vbmljYWxfdW5saW5rZWRfdXVpZCI6IjAwMDAwMDAwLTAwMDAtMDAwMC0wMDAwLTAwMDAwMDAwMDAwMSIsImNhbm9uaWNhbF94dWlkIjoiMSIsImNvbm5lY3Rfc2Vzc2lvbl9pZCI6InNlc3Npb24tdGVzdCIsImNvbm5lY3Rfc2Vzc2lvbl9ub25jZSI6IkFBQUFBQUFBQUFBQUFBQUFBQUFBQUEiLCJlbmRwb2ludF9pZCI6ImVuZHBvaW50LXRlc3QiLCJleHAiOjE3ODU0OTIwMzAsImlhdCI6MTc4NTQ5MjAwMCwiaXNzdWVyIjoibWluZWt1YmUtY29ubmVjdC10ZXN0IiwianRpIjoiQUFBQUFBQUFBQUFBQUFBQUFBQUFBQiIsIm5iZiI6MTc4NTQ5MjAwMCwib3JnYW5pemF0aW9uX2lkIjoib3JnYW5pemF0aW9uLXRlc3QiLCJwb2xpY3lfcmV2aXNpb24iOjcsInNvdXJjZV9wcm90b2NvbCI6ImJlZHJvY2siLCJzb3VyY2VfcHJvdG9jb2xfdmVyc2lvbiI6Nzc2LCJzdWJqZWN0X2tpbmQiOiJiZWRyb2NrX3h1aWQiLCJ0cnVzdF9kb21haW4iOiJ1cm46bWluZWt1YmU6Y29ubmVjdDp0ZXN0OmNvcnB1cy12MiIsInZlcmlmaWNhdGlvbl9tZXRob2QiOiJtaW5lY3JhZnRfbGVnYWN5X2NoYWluK2NsaWVudF9qd3QrZWNkaF92MSIsInZlcnNpb24iOjJ9.ixTwonoVJy6lhy3OOyyvQ3SOBhZRziaCNsIiOQtJjLTLt6sepLVE_d0urHww2aorNhHxa427FNelaIb9YTABDQ", + "trusted_context": { + "issuer": "minekube-connect-test", + "trust_domain": "urn:minekube:connect:test:corpus-v2", + "audience": "urn:minekube:connect:test:bedrock-principal:v2", + "endpoint_id": "endpoint-test", + "organization_id": "organization-test", + "connect_session_id": "session-test", + "connect_session_nonce": "AAAAAAAAAAAAAAAAAAAAAA", + "source_protocol": "bedrock", + "source_protocol_version": 776, + "policy_revision": 7 + }, + "verification_time_unix": 1785492000, + "expected_error": "MALFORMED", + "expected_principal": null + }, + { + "name": "policy-revision-mismatch", + "compact_jws": "eyJhbGciOiJFZERTQSIsInR5cCI6ImNvbm5lY3QtYmVkcm9jay1wcmluY2lwYWwrandzO3Y9MiIsImtpZCI6ImNvbm5lY3QtdjItdGVzdCJ9.eyJhdWRpZW5jZSI6InVybjptaW5la3ViZTpjb25uZWN0OnRlc3Q6YmVkcm9jay1wcmluY2lwYWw6djIiLCJiZWRyb2NrX2Rpc3BsYXlfbmFtZSI6IkJlZHJvY2tPbmUiLCJjYW5vbmljYWxfdW5saW5rZWRfdXVpZCI6IjAwMDAwMDAwLTAwMDAtMDAwMC0wMDAwLTAwMDAwMDAwMDAwMSIsImNhbm9uaWNhbF94dWlkIjoiMSIsImNvbm5lY3Rfc2Vzc2lvbl9pZCI6InNlc3Npb24tdGVzdCIsImNvbm5lY3Rfc2Vzc2lvbl9ub25jZSI6IkFBQUFBQUFBQUFBQUFBQUFBQUFBQUEiLCJlbmRwb2ludF9pZCI6ImVuZHBvaW50LXRlc3QiLCJleHAiOjE3ODU0OTIwMzAsImlhdCI6MTc4NTQ5MjAwMCwiaXNzdWVyIjoibWluZWt1YmUtY29ubmVjdC10ZXN0IiwianRpIjoiQkFRRUJBUUVCQVFFQkFRRUJBUUVCQSIsIm5iZiI6MTc4NTQ5MjAwMCwib3JnYW5pemF0aW9uX2lkIjoib3JnYW5pemF0aW9uLXRlc3QiLCJwb2xpY3lfcmV2aXNpb24iOjcsInNvdXJjZV9wcm90b2NvbCI6ImJlZHJvY2siLCJzb3VyY2VfcHJvdG9jb2xfdmVyc2lvbiI6Nzc2LCJzdWJqZWN0X2tpbmQiOiJiZWRyb2NrX3h1aWQiLCJ0cnVzdF9kb21haW4iOiJ1cm46bWluZWt1YmU6Y29ubmVjdDp0ZXN0OmNvcnB1cy12MiIsInZlcmlmaWNhdGlvbl9tZXRob2QiOiJtaW5lY3JhZnRfbGVnYWN5X2NoYWluK2NsaWVudF9qd3QrZWNkaF92MSIsInZlcnNpb24iOjJ9.3CBE3q1423w7COZr3nOmPPG6p8Q5R-UoKT_5ge4mHfWcGbLWL740phPKItjcul6jQg6wfZVeKDaSdGv0VNGvDA", + "trusted_context": { + "issuer": "minekube-connect-test", + "trust_domain": "urn:minekube:connect:test:corpus-v2", + "audience": "urn:minekube:connect:test:bedrock-principal:v2", + "endpoint_id": "endpoint-test", + "organization_id": "organization-test", + "connect_session_id": "session-test", + "connect_session_nonce": "AAAAAAAAAAAAAAAAAAAAAA", + "source_protocol": "bedrock", + "source_protocol_version": 776, + "policy_revision": 8 + }, + "verification_time_unix": 1785492000, + "expected_error": "BINDING_MISMATCH", + "expected_principal": null + }, + { + "name": "lifetime-thirty-one", + "compact_jws": "eyJhbGciOiJFZERTQSIsInR5cCI6ImNvbm5lY3QtYmVkcm9jay1wcmluY2lwYWwrandzO3Y9MiIsImtpZCI6ImNvbm5lY3QtdjItdGVzdCJ9.eyJhdWRpZW5jZSI6InVybjptaW5la3ViZTpjb25uZWN0OnRlc3Q6YmVkcm9jay1wcmluY2lwYWw6djIiLCJiZWRyb2NrX2Rpc3BsYXlfbmFtZSI6IkJlZHJvY2tPbmUiLCJjYW5vbmljYWxfdW5saW5rZWRfdXVpZCI6IjAwMDAwMDAwLTAwMDAtMDAwMC0wMDAwLTAwMDAwMDAwMDAwMSIsImNhbm9uaWNhbF94dWlkIjoiMSIsImNvbm5lY3Rfc2Vzc2lvbl9pZCI6InNlc3Npb24tdGVzdCIsImNvbm5lY3Rfc2Vzc2lvbl9ub25jZSI6IkFBQUFBQUFBQUFBQUFBQUFBQUFBQUEiLCJlbmRwb2ludF9pZCI6ImVuZHBvaW50LXRlc3QiLCJleHAiOjE3ODU0OTIwMzEsImlhdCI6MTc4NTQ5MjAwMCwiaXNzdWVyIjoibWluZWt1YmUtY29ubmVjdC10ZXN0IiwianRpIjoiQlFVRkJRVUZCUVVGQlFVRkJRVUZCUSIsIm5iZiI6MTc4NTQ5MjAwMCwib3JnYW5pemF0aW9uX2lkIjoib3JnYW5pemF0aW9uLXRlc3QiLCJwb2xpY3lfcmV2aXNpb24iOjcsInNvdXJjZV9wcm90b2NvbCI6ImJlZHJvY2siLCJzb3VyY2VfcHJvdG9jb2xfdmVyc2lvbiI6Nzc2LCJzdWJqZWN0X2tpbmQiOiJiZWRyb2NrX3h1aWQiLCJ0cnVzdF9kb21haW4iOiJ1cm46bWluZWt1YmU6Y29ubmVjdDp0ZXN0OmNvcnB1cy12MiIsInZlcmlmaWNhdGlvbl9tZXRob2QiOiJtaW5lY3JhZnRfbGVnYWN5X2NoYWluK2NsaWVudF9qd3QrZWNkaF92MSIsInZlcnNpb24iOjJ9.neB1VaIdiZm1Nov9WsAAMW4R0AuC8vYOV7O3TyLK-dH262xWy6dsiv33gKq3JfK8aiEtBlotfey4IRLH6cM0Cw", + "trusted_context": { + "issuer": "minekube-connect-test", + "trust_domain": "urn:minekube:connect:test:corpus-v2", + "audience": "urn:minekube:connect:test:bedrock-principal:v2", + "endpoint_id": "endpoint-test", + "organization_id": "organization-test", + "connect_session_id": "session-test", + "connect_session_nonce": "AAAAAAAAAAAAAAAAAAAAAA", + "source_protocol": "bedrock", + "source_protocol_version": 776, + "policy_revision": 7 + }, + "verification_time_unix": 1785492000, + "expected_error": "TIME", + "expected_principal": null + }, + { + "name": "tampered-signature", + "compact_jws": "eyJhbGciOiJFZERTQSIsInR5cCI6ImNvbm5lY3QtYmVkcm9jay1wcmluY2lwYWwrandzO3Y9MiIsImtpZCI6ImNvbm5lY3QtdjItdGVzdCJ9.eyJhdWRpZW5jZSI6InVybjptaW5la3ViZTpjb25uZWN0OnRlc3Q6YmVkcm9jay1wcmluY2lwYWw6djIiLCJiZWRyb2NrX2Rpc3BsYXlfbmFtZSI6IkJlZHJvY2tPbmUiLCJjYW5vbmljYWxfdW5saW5rZWRfdXVpZCI6IjAwMDAwMDAwLTAwMDAtMDAwMC0wMDAwLTAwMDAwMDAwMDAwMSIsImNhbm9uaWNhbF94dWlkIjoiMSIsImNvbm5lY3Rfc2Vzc2lvbl9pZCI6InNlc3Npb24tdGVzdCIsImNvbm5lY3Rfc2Vzc2lvbl9ub25jZSI6IkFBQUFBQUFBQUFBQUFBQUFBQUFBQUEiLCJlbmRwb2ludF9pZCI6ImVuZHBvaW50LXRlc3QiLCJleHAiOjE3ODU0OTIwMzAsImlhdCI6MTc4NTQ5MjAwMCwiaXNzdWVyIjoibWluZWt1YmUtY29ubmVjdC10ZXN0IiwianRpIjoiQmdZR0JnWUdCZ1lHQmdZR0JnWUdCZyIsIm5iZiI6MTc4NTQ5MjAwMCwib3JnYW5pemF0aW9uX2lkIjoib3JnYW5pemF0aW9uLXRlc3QiLCJwb2xpY3lfcmV2aXNpb24iOjcsInNvdXJjZV9wcm90b2NvbCI6ImJlZHJvY2siLCJzb3VyY2VfcHJvdG9jb2xfdmVyc2lvbiI6Nzc2LCJzdWJqZWN0X2tpbmQiOiJiZWRyb2NrX3h1aWQiLCJ0cnVzdF9kb21haW4iOiJ1cm46bWluZWt1YmU6Y29ubmVjdDp0ZXN0OmNvcnB1cy12MiIsInZlcmlmaWNhdGlvbl9tZXRob2QiOiJtaW5lY3JhZnRfbGVnYWN5X2NoYWluK2NsaWVudF9qd3QrZWNkaF92MSIsInZlcnNpb24iOjJ9.B1gHheFVnd1Wa4-6OpCOdi0xLO-N2qdm_jKuPKXoxHqJI701x82b9G0rnoo2SYWuLkayE9vvwk7V9K2CNgnKDA", + "trusted_context": { + "issuer": "minekube-connect-test", + "trust_domain": "urn:minekube:connect:test:corpus-v2", + "audience": "urn:minekube:connect:test:bedrock-principal:v2", + "endpoint_id": "endpoint-test", + "organization_id": "organization-test", + "connect_session_id": "session-test", + "connect_session_nonce": "AAAAAAAAAAAAAAAAAAAAAA", + "source_protocol": "bedrock", + "source_protocol_version": 776, + "policy_revision": 7 + }, + "verification_time_unix": 1785492000, + "expected_error": "SIGNATURE", + "expected_principal": null + } +] diff --git a/core/src/test/resources/bedrock-principal-v2/v2.schema.json b/core/src/test/resources/bedrock-principal-v2/v2.schema.json new file mode 100644 index 000000000..b521c5e17 --- /dev/null +++ b/core/src/test/resources/bedrock-principal-v2/v2.schema.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://connect.minekube.com/schema/bedrock-principal/v2", + "type": "object", + "additionalProperties": false, + "required": ["version", "issuer", "trust_domain", "audience", "subject_kind", "canonical_xuid", "canonical_unlinked_uuid", "bedrock_display_name", "endpoint_id", "organization_id", "connect_session_id", "connect_session_nonce", "policy_revision", "source_protocol", "source_protocol_version", "iat", "nbf", "exp", "jti", "verification_method"], + "properties": { + "version": {"const": 2}, + "issuer": {"type": "string", "minLength": 1, "maxLength": 128}, + "trust_domain": {"type": "string", "minLength": 1, "maxLength": 256}, + "audience": {"type": "string", "minLength": 1, "maxLength": 256}, + "subject_kind": {"enum": ["bedrock_xuid", "bedrock_linked_java"]}, + "canonical_xuid": {"type": "string", "pattern": "^[1-9][0-9]{0,18}$", "maxLength": 19}, + "canonical_unlinked_uuid": {"type": "string", "pattern": "^00000000-0000-0000-[0-9a-f]{4}-[0-9a-f]{12}$", "maxLength": 36}, + "linked_java": { + "type": "object", + "additionalProperties": false, + "required": ["uuid", "name", "provenance"], + "properties": { + "uuid": {"type": "string", "format": "uuid", "maxLength": 36}, + "name": {"type": "string", "pattern": "^[A-Za-z0-9_]{1,16}$", "minLength": 1, "maxLength": 16}, + "provenance": { + "type": "object", + "additionalProperties": false, + "required": ["provider", "record_id", "revision", "verified_at"], + "properties": { + "provider": {"const": "moxy_account_link_v1"}, + "record_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "revision": {"type": "integer", "minimum": 1, "maximum": 9223372036854775807}, + "verified_at": {"type": "integer", "minimum": 0, "maximum": 253402300799} + } + } + } + }, + "bedrock_display_name": {"type": "string", "minLength": 1, "maxLength": 64}, + "endpoint_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "organization_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "connect_session_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "connect_session_nonce": {"type": "string", "pattern": "^[A-Za-z0-9_-]{22}$", "minLength": 22, "maxLength": 22}, + "policy_revision": {"type": "integer", "minimum": 1, "maximum": 9223372036854775807}, + "source_protocol": {"const": "bedrock"}, + "source_protocol_version": {"type": "integer", "minimum": 1, "maximum": 2147483647}, + "iat": {"type": "integer", "minimum": 0, "maximum": 253402300799}, + "nbf": {"type": "integer", "minimum": 0, "maximum": 253402300799}, + "exp": {"type": "integer", "minimum": 0, "maximum": 253402300799}, + "jti": {"type": "string", "pattern": "^[A-Za-z0-9_-]{22}$", "minLength": 22, "maxLength": 22}, + "verification_method": {"enum": ["minecraft_legacy_chain+client_jwt+ecdh_v1", "minecraft_full_jwks+client_jwt+ecdh_v1"]} + }, + "allOf": [ + {"if": {"properties": {"subject_kind": {"const": "bedrock_xuid"}}}, "then": {"not": {"required": ["linked_java"]}}}, + {"if": {"properties": {"subject_kind": {"const": "bedrock_linked_java"}}}, "then": {"required": ["linked_java"]}} + ] +} diff --git a/docs/bedrock-identity.md b/docs/bedrock-identity.md index 62ca3690f..de37320c2 100644 --- a/docs/bedrock-identity.md +++ b/docs/bedrock-identity.md @@ -1,5 +1,45 @@ # Bedrock identity defaults +Connect Java supports two additive identity paths. Existing `config-version: 1` files retain the +original `bedrock-identity` v1 behavior and `enforcement: warn` unchanged. Merely upgrading the +plugin does not rewrite such a file or advertise generation-2 capability. + +## Signed principal v2 + +New generated configuration includes `bedrock-principal.config-generation: 2` and `mode: require`. +The v2 consumer accepts the compact signed principal only from the authenticated Watch/libp2p +session field; a game-profile property with the reserved v2 name is rejected. It verifies the +closed schema, Ed25519 signature, proposal bindings, time bounds, identity/link invariants, and an +atomic one-use replay key before applying the verifier-selected effective profile. A linked Java +profile always takes precedence over the derived Bedrock profile. + +The connector advertises `bedrock-verified-principal-v2` only when generation 2 is in `require` +mode and its trust configuration and static Ed25519 pins are usable. Watch readiness challenges +are answered on their authenticated lease. Libp2p first negotiates `kind-prefixed-v1` framing on +a successful legacy renewal, then answers challenges on that same registration stream. Losing +configuration, keys, framing, or the stream fails closed and suppresses operational readiness. + +Static v2 pins use canonical unpadded base64url and contain the raw 32-byte Ed25519 public key: + +```yaml +bedrock-principal: + config-generation: 2 + mode: require + issuer: minekube-connect + trust-domain: urn:minekube:connect:production + audience: urn:minekube:connect:bedrock-principal:v2 + metadata-origin: https://connect.minekube.com + metadata-path: /.well-known/minekube-connect/bedrock-principal-v2.json + public-keys: + "": "" +``` + +Verification failures expose only the bounded `PrincipalError` category. Compact envelopes, +nonces, replay IDs, XUIDs, and link material are excluded from exception messages, stack traces, +principal `toString()` output, and sanitized session proposals. + +## Legacy v1 + Connect Edge authenticates Bedrock players with Microsoft/Xbox and signs a short-lived, endpoint-scoped identity before forwarding the session. A newly installed Connect Java plugin trusts that Minekube-signed identity without extra operator configuration: it validates the 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 40392754f..92dc3f074 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 @@ -158,7 +158,7 @@ public boolean channelRead(Object packet) throws Exception { return true; // next is LOGIN_START_PACKET } if (ClassNames.LOGIN_START_PACKET.isInstance(packet)) { - debug("Processing LOGIN_START_PACKET for " + sessionCtx.getPlayer().getUsername()); + debug("Processing LOGIN_START_PACKET for " + logPlayer()); if (!enforceBedrockIdentity()) { return false; } @@ -230,7 +230,7 @@ public boolean channelRead(Object packet) throws Exception { GameProfile profileToUse = returnedProfile != null ? (GameProfile) returnedProfile : gameProfile; if (config.isDebug()) { - debug("callPlayerPreLoginEvents returned profile: " + profileToUse); + debug("callPlayerPreLoginEvents returned a profile for " + logPlayer()); } ClassNames.START_CLIENT_VERIFICATION.invoke(packetListener, profileToUse); @@ -243,6 +243,12 @@ public boolean channelRead(Object packet) throws Exception { return true; } + private String logPlayer() { + return sessionCtx.getSessionProposal().hasBedrockPrincipalV2() + ? "" + : sessionCtx.getPlayer().getUsername(); + } + boolean enforceBedrockIdentity() { if (bedrockIdentityEnforcer == null) { return true; diff --git a/spigot/src/main/java/com/minekube/connect/listener/SpigotListener.java b/spigot/src/main/java/com/minekube/connect/listener/SpigotListener.java index 4b5f14867..cdf2ab9b6 100644 --- a/spigot/src/main/java/com/minekube/connect/listener/SpigotListener.java +++ b/spigot/src/main/java/com/minekube/connect/listener/SpigotListener.java @@ -55,10 +55,14 @@ public void onPlayerLogin(PlayerLoginEvent event) { if (player != null) { //todo we should probably move this log message earlier in the process, so that we know // that Connect has done its job - logger.translatedInfo( - "connect.ingame.login_name", - player.getUsername(), player.getUniqueId() - ); + if (api.getVerifiedBedrockPrincipal(player).isPresent()) { + logger.info("A verified Bedrock principal v2 session joined"); + } else { + logger.translatedInfo( + "connect.ingame.login_name", + player.getUsername(), player.getUniqueId() + ); + } languageManager.loadLocale(player.getLanguageTag()); } } diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index 29da2bc68..82d75f7bb 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -5,8 +5,8 @@ var guavaVersion = "25.1-jre" java { // For Velocity API - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } dependencies { diff --git a/velocity/src/main/java/com/minekube/connect/listener/VelocityLateReassertListener.java b/velocity/src/main/java/com/minekube/connect/listener/VelocityLateReassertListener.java index 8dfc57de0..1deaccf62 100644 --- a/velocity/src/main/java/com/minekube/connect/listener/VelocityLateReassertListener.java +++ b/velocity/src/main/java/com/minekube/connect/listener/VelocityLateReassertListener.java @@ -183,7 +183,7 @@ void onGameProfileRequestLate(GameProfileRequestEvent event) { return; } event.setGameProfile(wanted); - logger.debug("Re-asserted the game profile of Connect session {}", player.getUsername()); + logger.debug("Re-asserted the game profile of a Connect session"); } private boolean enabled() { From 1c30ffc0f47b4122b0e32ab48528abb841faef46 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Mon, 3 Aug 2026 16:57:06 +0200 Subject: [PATCH 2/4] no-mistakes(review): Harden Bedrock v2 admission and negotiated readiness --- .../DefaultBedrockPrincipalVerifier.java | 57 +++++++++++++++- .../BedrockPrincipalConfiguration.java | 19 ++++-- .../bedrock/BedrockPrincipalConsumer.java | 36 ++++++++-- .../bedrock/BedrockPrincipalReadiness.java | 19 ++++-- .../tunnel/p2p/Libp2pEndpointRuntime.java | 19 ++++-- .../tunnel/p2p/PeerRegistrationClient.java | 11 +++- .../tunnel/p2p/PeerRegistrationHandshake.java | 65 ++++++++++++++++++- .../bedrock/BedrockPrincipalConsumerTest.java | 21 ++++++ .../BedrockPrincipalGenerationConfigTest.java | 12 ++++ .../BedrockPrincipalReadinessTest.java | 16 ++++- .../BedrockPrincipalCoreVectorTest.java | 36 ++++++++++ .../p2p/PeerRegistrationClientTest.java | 8 ++- .../p2p/PeerRegistrationHandshakeTest.java | 22 +++++++ 13 files changed, 311 insertions(+), 30 deletions(-) diff --git a/api/src/main/java/com/minekube/connect/api/player/principal/DefaultBedrockPrincipalVerifier.java b/api/src/main/java/com/minekube/connect/api/player/principal/DefaultBedrockPrincipalVerifier.java index 608bc4111..63a0c4fd8 100644 --- a/api/src/main/java/com/minekube/connect/api/player/principal/DefaultBedrockPrincipalVerifier.java +++ b/api/src/main/java/com/minekube/connect/api/player/principal/DefaultBedrockPrincipalVerifier.java @@ -27,6 +27,16 @@ final class DefaultBedrockPrincipalVerifier implements BedrockPrincipalVerifier static final String CAPABILITY = "bedrock-verified-principal-v2"; private static final int MAX_HEADER_BYTES = 2 * 1024; private static final int MAX_PAYLOAD_BYTES = 12 * 1024; + private static final BigInteger ED25519_FIELD_PRIME = BigInteger.ONE.shiftLeft(255) + .subtract(BigInteger.valueOf(19)); + private static final BigInteger ED25519_D = BigInteger.valueOf(-121665) + .multiply(BigInteger.valueOf(121666).modInverse(ED25519_FIELD_PRIME)) + .mod(ED25519_FIELD_PRIME); + private static final BigInteger ED25519_SQRT_M1 = BigInteger.valueOf(2) + .modPow(ED25519_FIELD_PRIME.subtract(BigInteger.ONE).shiftRight(2), + ED25519_FIELD_PRIME); + private static final BigInteger ED25519_SQRT_EXP = ED25519_FIELD_PRIME + .add(BigInteger.valueOf(3)).shiftRight(3); private static final long MAX_UNIX_TIMESTAMP = 253_402_300_799L; private static final Set HEADER_FIELDS = Set.of("alg", "typ", "kid"); private static final Set PAYLOAD_FIELDS = Set.of( @@ -147,11 +157,12 @@ private static ImmutableVerifiedBedrockPrincipal principal( } long xuid; try { - xuid = Long.parseLong(claims.canonicalXuid); + xuid = Long.parseUnsignedLong(claims.canonicalXuid); } catch (NumberFormatException ignored) { throw reject(PrincipalError.IDENTITY); } - if (xuid <= 0 || !Long.toString(xuid).equals(claims.canonicalXuid)) { + if (Long.compareUnsigned(xuid, 0L) <= 0 + || !Long.toUnsignedString(xuid).equals(claims.canonicalXuid)) { throw reject(PrincipalError.IDENTITY); } UUID unlinked = canonicalUuid(claims.canonicalUnlinkedUuid, PrincipalError.IDENTITY); @@ -310,6 +321,11 @@ private static PublicKey parsePublicKey(byte[] raw) { y[left] = y[right]; y[right] = swap; } + BigInteger yCoordinate = new BigInteger(1, y); + if (yCoordinate.compareTo(ED25519_FIELD_PRIME) >= 0 + || !validEd25519Point(yCoordinate, xOdd)) { + throw new IllegalArgumentException("invalid verifier public key"); + } return KeyFactory.getInstance("Ed25519").generatePublic(new EdECPublicKeySpec( NamedParameterSpec.ED25519, new EdECPoint(xOdd, new BigInteger(1, y)))); } catch (GeneralSecurityException | RuntimeException ignored) { @@ -317,6 +333,43 @@ private static PublicKey parsePublicKey(byte[] raw) { } } + private static boolean validEd25519Point(BigInteger y, boolean xOdd) { + BigInteger ySquared = y.multiply(y).mod(ED25519_FIELD_PRIME); + BigInteger denominator = ED25519_D.multiply(ySquared).add(BigInteger.ONE) + .mod(ED25519_FIELD_PRIME); + if (denominator.signum() == 0) return false; + BigInteger xSquared = ySquared.subtract(BigInteger.ONE) + .multiply(denominator.modInverse(ED25519_FIELD_PRIME)) + .mod(ED25519_FIELD_PRIME); + BigInteger x = xSquared.modPow(ED25519_SQRT_EXP, ED25519_FIELD_PRIME); + if (!x.multiply(x).mod(ED25519_FIELD_PRIME).equals(xSquared)) { + x = x.multiply(ED25519_SQRT_M1).mod(ED25519_FIELD_PRIME); + } + if (!x.multiply(x).mod(ED25519_FIELD_PRIME).equals(xSquared) + || x.signum() == 0) { + return false; + } + if (x.testBit(0) != xOdd) x = ED25519_FIELD_PRIME.subtract(x); + return !smallOrder(x, y); + } + + private static boolean smallOrder(BigInteger x, BigInteger y) { + for (int count = 0; count < 3; count++) { + BigInteger product = ED25519_D.multiply(x).multiply(x).multiply(y).multiply(y) + .mod(ED25519_FIELD_PRIME); + BigInteger nextX = x.multiply(y).shiftLeft(1) + .multiply(BigInteger.ONE.add(product).modInverse(ED25519_FIELD_PRIME)) + .mod(ED25519_FIELD_PRIME); + BigInteger nextY = y.multiply(y).add(x.multiply(x)) + .multiply(BigInteger.ONE.subtract(product).mod(ED25519_FIELD_PRIME) + .modInverse(ED25519_FIELD_PRIME)) + .mod(ED25519_FIELD_PRIME); + x = nextX; + y = nextY; + } + return x.signum() == 0 && y.equals(BigInteger.ONE); + } + private static void exact(Map object, Set allowed, Set required) { if (!allowed.containsAll(object.keySet()) || !object.keySet().containsAll(required)) { throw malformed(); diff --git a/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalConfiguration.java b/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalConfiguration.java index 7b1e327b0..9dc8f404d 100644 --- a/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalConfiguration.java +++ b/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalConfiguration.java @@ -8,33 +8,40 @@ final class BedrockPrincipalConfiguration { static final String METADATA_PATH = "/.well-known/minekube-connect/bedrock-principal-v2.json"; private final boolean capable; + private final boolean required; - private BedrockPrincipalConfiguration(boolean capable) { + private BedrockPrincipalConfiguration(boolean capable, boolean required) { this.capable = capable; + this.required = required; } static BedrockPrincipalConfiguration from(BedrockPrincipalConfig config) { - if (config == null) return new BedrockPrincipalConfiguration(false); - boolean capable = config.getConfigGeneration() == 2 - && "require".equals(config.getMode()) + if (config == null) return new BedrockPrincipalConfiguration(false, false); + boolean required = config.getConfigGeneration() == 2 && "require".equals(config.getMode()); + boolean capable = required && bounded(config.getIssuer(), 128) && bounded(config.getTrustDomain(), 256) && bounded(config.getAudience(), 256) && validOrigin(config.getMetadataOrigin(), config.getTrustDomain()) && METADATA_PATH.equals(config.getMetadataPath()); - return new BedrockPrincipalConfiguration(capable); + return new BedrockPrincipalConfiguration(capable, required); } boolean isCapable() { return capable; } + boolean isRequired() { + return required; + } + private static boolean bounded(String value, int maximum) { return value != null && !value.isEmpty() && value.getBytes(java.nio.charset.StandardCharsets.UTF_8).length <= maximum; } private static boolean validOrigin(String value, String trustDomain) { + if (value == null) return false; try { URI origin = URI.create(value); boolean valid = "https".equals(origin.getScheme()) @@ -47,7 +54,7 @@ private static boolean validOrigin(String value, String trustDomain) { if (!valid) return false; return !"urn:minekube:connect:production".equals(trustDomain) || "https://connect.minekube.com".equals(value); - } catch (IllegalArgumentException ignored) { + } catch (RuntimeException ignored) { return false; } } diff --git a/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalConsumer.java b/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalConsumer.java index f55a6586e..8f0e82136 100644 --- a/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalConsumer.java +++ b/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalConsumer.java @@ -2,6 +2,7 @@ import com.google.inject.Inject; import com.google.inject.Singleton; +import com.google.protobuf.ByteString; import com.minekube.connect.api.player.bedrock.BedrockIdentityProfiles; import com.minekube.connect.api.player.principal.BedrockPrincipalVerifier; import com.minekube.connect.api.player.principal.BedrockPrincipalVerifierFactory; @@ -29,6 +30,7 @@ /** Consumes the frozen opaque Watch/libp2p v2 fields before host profile application. */ @Singleton public final class BedrockPrincipalConsumer { + private static final int MAX_ENVELOPE_BYTES = 16 * 1024; private final Supplier config; private final Clock clock; private BedrockPrincipalVerifier verifier; @@ -52,10 +54,22 @@ public Optional verify(Session session) { if (hasInjectedProperty(session)) { throw new BedrockPrincipalAdmissionException(PrincipalError.BINDING_MISMATCH); } + ConnectConfig currentConfig; + BedrockPrincipalConfiguration principalConfiguration; + try { + currentConfig = config(); + principalConfiguration = BedrockPrincipalConfiguration.from( + currentConfig.getBedrockPrincipal()); + } catch (RuntimeException ignored) { + throw new BedrockPrincipalAdmissionException(PrincipalError.READINESS); + } if (session.getSignedBedrockPrincipalV2().isEmpty()) { + if (principalConfiguration.isRequired()) { + throw new BedrockPrincipalAdmissionException(PrincipalError.READINESS); + } return Optional.empty(); } - if (!BedrockPrincipalConfiguration.from(config().getBedrockPrincipal()).isCapable()) { + if (!principalConfiguration.isCapable()) { throw new BedrockPrincipalAdmissionException(PrincipalError.READINESS); } if (session.getProtocol() != SessionProtocol.SESSION_PROTOCOL_BEDROCK @@ -67,15 +81,19 @@ public Optional verify(Session session) { || session.getId().isEmpty()) { throw new BedrockPrincipalAdmissionException(PrincipalError.BINDING_MISMATCH); } - ConnectConfig.BedrockPrincipalConfig principalConfig = config().getBedrockPrincipal(); + ConnectConfig.BedrockPrincipalConfig principalConfig = currentConfig.getBedrockPrincipal(); TrustedProposalContext expected = new TrustedProposalContext( principalConfig.getIssuer(), principalConfig.getTrustDomain(), principalConfig.getAudience(), session.getEndpointId(), session.getOrganizationId(), session.getId(), session.getConnectSessionNonce().toByteArray(), "bedrock", session.getSourceProtocolVersion(), session.getPolicyRevision()); try { + ByteString envelope = session.getSignedBedrockPrincipalV2(); + if (envelope.size() > MAX_ENVELOPE_BYTES) { + throw new BedrockPrincipalAdmissionException(PrincipalError.MALFORMED); + } return Optional.of(verifier().verifyAndConsume( - SignedPrincipalEnvelope.of(strictUtf8(session.getSignedBedrockPrincipalV2().toByteArray())), + SignedPrincipalEnvelope.of(strictUtf8(envelope.toByteArray())), expected)); } catch (PrincipalVerificationException error) { throw new BedrockPrincipalAdmissionException(error.error()); @@ -86,7 +104,15 @@ public Optional verify(Session session) { private synchronized BedrockPrincipalVerifier verifier() { if (verifier != null) return verifier; - ConnectConfig.BedrockPrincipalConfig principalConfig = config().getBedrockPrincipal(); + ConnectConfig.BedrockPrincipalConfig principalConfig; + try { + principalConfig = config().getBedrockPrincipal(); + } catch (RuntimeException ignored) { + throw new BedrockPrincipalAdmissionException(PrincipalError.READINESS); + } + if (principalConfig == null) { + throw new BedrockPrincipalAdmissionException(PrincipalError.READINESS); + } VerifierConfiguration.Builder configuration = VerifierConfiguration.builder().clock(clock); Map pins = principalConfig.getPublicKeys(); if (pins == null || pins.isEmpty()) { @@ -102,7 +128,7 @@ private synchronized BedrockPrincipalVerifier verifier() { }); verifier = BedrockPrincipalVerifierFactory.create(configuration.build()); return verifier; - } catch (IllegalArgumentException ignored) { + } catch (RuntimeException ignored) { throw new BedrockPrincipalAdmissionException(PrincipalError.TRUST); } } diff --git a/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalReadiness.java b/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalReadiness.java index 96a0644fa..e8b6aef90 100644 --- a/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalReadiness.java +++ b/core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalReadiness.java @@ -1,6 +1,8 @@ package com.minekube.connect.bedrock; import com.google.protobuf.ByteString; +import com.minekube.connect.api.player.principal.BedrockPrincipalVerifierFactory; +import com.minekube.connect.api.player.principal.VerifierConfiguration; import com.minekube.connect.config.ConnectConfig; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -48,8 +50,13 @@ public BedrockPrincipalReadiness(ConnectConfig config) { } public boolean isReady() { - return BedrockPrincipalConfiguration.from(config.getBedrockPrincipal()).isCapable() - && usablePins(config.getBedrockPrincipal().getPublicKeys()); + try { + ConnectConfig.BedrockPrincipalConfig principal = config.getBedrockPrincipal(); + return BedrockPrincipalConfiguration.from(principal).isCapable() + && usablePins(principal.getPublicKeys()); + } catch (RuntimeException ignored) { + return false; + } } public byte[] revision() { @@ -123,15 +130,17 @@ private static boolean validChallenge(ReadinessChallenge challenge, Transport tr private static boolean usablePins(Map pins) { if (pins == null || pins.isEmpty()) return false; try { + VerifierConfiguration.Builder configuration = VerifierConfiguration.builder(); for (Map.Entry pin : pins.entrySet()) { - if (pin.getKey() == null || pin.getKey().isEmpty() || pin.getKey().length() > 128 - || pin.getValue() == null) return false; + if (pin.getKey() == null || pin.getValue() == null) return false; byte[] decoded = Base64.getUrlDecoder().decode(pin.getValue()); if (decoded.length != 32 || !Base64.getUrlEncoder().withoutPadding() .encodeToString(decoded).equals(pin.getValue())) return false; + configuration.publicKey(pin.getKey(), decoded); } + BedrockPrincipalVerifierFactory.create(configuration.build()); return true; - } catch (IllegalArgumentException ignored) { + } catch (RuntimeException ignored) { return false; } } 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 68e935b38..ea3e51c4a 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 @@ -284,6 +284,7 @@ private ActiveRegistration registerOnce(OfflineMode offlineMode, EndpointAuthTyp PeerRegistrationClient client = null; try { Stream stream = openRegisterStream(address); + List capabilities = principalCapabilities(); PeerRegistrationHandshake handshake = new PeerRegistrationHandshake( identity, connectConfig.getEndpoint(), @@ -294,7 +295,8 @@ private ActiveRegistration registerOnce(OfflineMode offlineMode, EndpointAuthTyp : connectConfig.getSuperEndpoints(), offlineMode, authType, - principalCapabilities(), + capabilities, + framedPrincipalCapabilities(capabilities), this::currentCapacity); client = new PeerRegistrationClient(handshake, bedrockPrincipalReadiness); PeerRegisterResult result = await(client.install( @@ -321,10 +323,17 @@ private ActiveRegistration registerOnce(OfflineMode offlineMode, EndpointAuthTyp private List principalCapabilities() { List legacy = bedrockIdentityReadiness.capabilities( libp2pConfig.capabilities(), Transport.LIBP2P); - return bedrockPrincipalReadiness == null - ? legacy - : bedrockPrincipalReadiness.capabilities( - legacy, BedrockPrincipalReadiness.Transport.LIBP2P); + legacy.removeIf(BedrockPrincipalReadiness.CAPABILITY::equals); + return List.copyOf(legacy); + } + + private List framedPrincipalCapabilities(List legacy) { + if (bedrockPrincipalReadiness == null || !bedrockPrincipalReadiness.isReady()) { + return legacy; + } + List framed = new ArrayList<>(legacy); + framed.add(BedrockPrincipalReadiness.CAPABILITY); + return List.copyOf(framed); } static List registerAttemptAddresses(List registerAddrs, int attemptsPerAddress) { diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/PeerRegistrationClient.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/PeerRegistrationClient.java index 4d144f7f0..caa84ce34 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/PeerRegistrationClient.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/PeerRegistrationClient.java @@ -243,6 +243,14 @@ private void handleResult(ChannelHandlerContext ctx, PeerRegisterResult msg) { } awaitingResult = false; cancelAckTimeout(); + if (msg.hasModeResult() + && msg.getModeResult().getVersion() == 2 + && !msg.getModeResult().getAccepted() + && framed) { + framed = false; + failRegistration(new IllegalStateException("libp2p registration framing rejected")); + return; + } result.complete(msg); if (!framed && offerAttempted && msg.hasModeResult() && msg.getModeResult().getVersion() == 2 @@ -267,7 +275,8 @@ private void scheduleRenew() { observedAddrsSupplier.get(), sequence.incrementAndGet(), System.currentTimeMillis(), - offer); + offer, + framed); awaitingResult = true; if (framed) { writeKindFrame(stream, P2PFrameCodec.RENEWAL_COMMIT, commit); 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 c148f5c7f..79db06b72 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 @@ -50,6 +50,7 @@ final class PeerRegistrationHandshake { private final OfflineMode offlineMode; private final EndpointAuthType authType; private final List capabilities; + private final List framedCapabilities; private final Supplier capacitySupplier; PeerRegistrationHandshake( @@ -78,6 +79,36 @@ final class PeerRegistrationHandshake { this(identity, endpoint, token, endpointInstanceId, parentEndpoints, offlineMode, authType, capabilities, () -> capacity); } + PeerRegistrationHandshake( + EndpointPeerIdentity identity, + String endpoint, + String token, + String endpointInstanceId, + List parentEndpoints, + OfflineMode offlineMode, + List capabilities, + List framedCapabilities, + PeerCapacity capacity) { + this(identity, endpoint, token, endpointInstanceId, parentEndpoints, offlineMode, + EndpointAuthType.ENDPOINT_AUTH_TYPE_UNSPECIFIED, capabilities, + framedCapabilities, () -> capacity); + } + + PeerRegistrationHandshake( + EndpointPeerIdentity identity, + String endpoint, + String token, + String endpointInstanceId, + List parentEndpoints, + OfflineMode offlineMode, + EndpointAuthType authType, + List capabilities, + List framedCapabilities, + PeerCapacity capacity) { + this(identity, endpoint, token, endpointInstanceId, parentEndpoints, offlineMode, + authType, capabilities, framedCapabilities, () -> capacity); + } + PeerRegistrationHandshake( EndpointPeerIdentity identity, String endpoint, @@ -88,7 +119,8 @@ final class PeerRegistrationHandshake { List capabilities, Supplier capacitySupplier) { this(identity, endpoint, token, endpointInstanceId, parentEndpoints, offlineMode, - EndpointAuthType.ENDPOINT_AUTH_TYPE_UNSPECIFIED, capabilities, capacitySupplier); + EndpointAuthType.ENDPOINT_AUTH_TYPE_UNSPECIFIED, capabilities, capabilities, + capacitySupplier); } PeerRegistrationHandshake( @@ -101,6 +133,21 @@ final class PeerRegistrationHandshake { EndpointAuthType authType, List capabilities, Supplier capacitySupplier) { + this(identity, endpoint, token, endpointInstanceId, parentEndpoints, offlineMode, + authType, capabilities, capabilities, capacitySupplier); + } + + PeerRegistrationHandshake( + EndpointPeerIdentity identity, + String endpoint, + String token, + String endpointInstanceId, + List parentEndpoints, + OfflineMode offlineMode, + EndpointAuthType authType, + List capabilities, + List framedCapabilities, + Supplier capacitySupplier) { this.identity = Objects.requireNonNull(identity, "identity"); this.endpoint = Objects.requireNonNull(endpoint, "endpoint"); this.token = Objects.requireNonNull(token, "token"); @@ -109,6 +156,8 @@ final class PeerRegistrationHandshake { this.offlineMode = Objects.requireNonNull(offlineMode, "offlineMode"); this.authType = Objects.requireNonNull(authType, "authType"); this.capabilities = new ArrayList<>(Objects.requireNonNull(capabilities, "capabilities")); + this.framedCapabilities = new ArrayList<>(Objects.requireNonNull( + framedCapabilities, "framedCapabilities")); this.capacitySupplier = Objects.requireNonNull(capacitySupplier, "capacitySupplier"); } @@ -132,7 +181,7 @@ PeerRegisterInit init(List observedAddrs) { } PeerRegisterCommit commit(PeerRegisterChallenge challenge, List addrs, long sequence, long nowUnixMs) { - return commit(challenge, addrs, sequence, nowUnixMs, false); + return commit(challenge, addrs, sequence, nowUnixMs, false, false); } PeerRegisterCommit commit( @@ -141,6 +190,16 @@ PeerRegisterCommit commit( long sequence, long nowUnixMs, boolean offerKindPrefixedV1) { + return commit(challenge, addrs, sequence, nowUnixMs, offerKindPrefixedV1, false); + } + + PeerRegisterCommit commit( + PeerRegisterChallenge challenge, + List addrs, + long sequence, + long nowUnixMs, + boolean offerKindPrefixedV1, + boolean useFramedCapabilities) { long ttlMs = challenge.getKvTtlMs() > 0 ? challenge.getKvTtlMs() : 45_000; List recordAddrs = recordRelayCircuitAddrs(challenge, addrs); if (recordAddrs.isEmpty() && challenge.getRelayAddrsList().isEmpty()) { @@ -158,7 +217,7 @@ PeerRegisterCommit commit( .setPublisherPeerId(challenge.getPublisherPeerId()) .setRegion(challenge.getRegion()) .addAllAddrs(recordAddrs) - .addAllCapabilities(capabilities) + .addAllCapabilities(useFramedCapabilities ? framedCapabilities : capabilities) .setCapacity(capacity()) .setOfflineMode(offlineMode) .setAuthType(authType) diff --git a/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalConsumerTest.java b/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalConsumerTest.java index 1398dd388..79b8fee3c 100644 --- a/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalConsumerTest.java +++ b/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalConsumerTest.java @@ -71,6 +71,27 @@ void malformedCompanionBindingFailsBeforeProfileApplication() throws Exception { assertFalse(error.toString().contains(vector.get("compact_jws").getAsString())); } + @Test + void requireModeRejectsSessionsMissingV2Principal() throws Exception { + JsonObject vector = vector("valid-unlinked"); + BedrockPrincipalAdmissionException error = assertThrows( + BedrockPrincipalAdmissionException.class, + () -> consumer(vector).verify(session(vector).toBuilder() + .clearSignedBedrockPrincipalV2().build())); + assertEquals(PrincipalError.READINESS, error.error()); + } + + @Test + void oversizedV2EnvelopeIsRejected() throws Exception { + JsonObject vector = vector("valid-unlinked"); + BedrockPrincipalAdmissionException error = assertThrows( + BedrockPrincipalAdmissionException.class, + () -> consumer(vector).verify(session(vector).toBuilder() + .setSignedBedrockPrincipalV2(ByteString.copyFrom(new byte[16 * 1024 + 1])) + .build())); + assertEquals(PrincipalError.MALFORMED, error.error()); + } + private static BedrockPrincipalConsumer consumer(JsonObject vector) { return new BedrockPrincipalConsumer(config(), Clock.fixed( Instant.ofEpochSecond(vector.get("verification_time_unix").getAsLong()), ZoneOffset.UTC)); diff --git a/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalGenerationConfigTest.java b/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalGenerationConfigTest.java index e1d3d1bd1..bcc086a63 100644 --- a/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalGenerationConfigTest.java +++ b/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalGenerationConfigTest.java @@ -79,6 +79,18 @@ void onlyExactGenerationTwoRequireIsCapable() { assertFalse(BedrockPrincipalConfiguration.from(config).isCapable()); } + @Test + void malformedOriginIsNotCapable() { + ConnectConfig.BedrockPrincipalConfig config = new ConnectConfig().getBedrockPrincipal(); + TestFields.set(config, "configGeneration", 2); + TestFields.set(config, "mode", "require"); + TestFields.set(config, "issuer", "minekube-connect"); + TestFields.set(config, "trustDomain", "urn:minekube:connect:production"); + TestFields.set(config, "audience", "urn:minekube:connect:bedrock-principal:v2"); + TestFields.set(config, "metadataOrigin", null); + assertFalse(BedrockPrincipalConfiguration.from(config).isCapable()); + } + private T load(Class type, Path directory) throws Exception { Files.createDirectories(directory); return new ConfigLoader(directory, type, diff --git a/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalReadinessTest.java b/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalReadinessTest.java index ae10ac403..fa43d5c6f 100644 --- a/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalReadinessTest.java +++ b/core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalReadinessTest.java @@ -37,6 +37,20 @@ void advertisesOnlyGenerationTwoRequireWithUsableStaticPin() throws Exception { assertFalse(readiness(configured("require", 2, Map.of("kid", "not-base64"))).isReady()); } + @Test + void doesNotAdvertiseAnUnusableEd25519PublicKey() throws Exception { + byte[] invalid = new byte[32]; + assertFalse(readiness(configured("require", 2, Map.of("kid", + Base64.getUrlEncoder().withoutPadding().encodeToString(invalid)))).isReady()); + } + + @Test + void malformedPrincipalConfigFailsClosed() throws Exception { + ConnectConfig config = configured("require", 2, validPins()); + set(config, "bedrockPrincipal", null); + assertFalse(readiness(config).isReady()); + } + @Test void attestationEchoesValidChallengeAndFailsClosedForWrongTransport() throws Exception { BedrockPrincipalReadiness readiness = readiness(configured("require", 2, validPins())); @@ -72,7 +86,7 @@ private static ReadinessChallenge challenge(TunnelTransport.Type transport) { } private static Map validPins() { - return Map.of("kid-1", Base64.getUrlEncoder().withoutPadding().encodeToString(new byte[32])); + return Map.of("kid-1", "diQm8c6MI-Zwn1nie8hq4wqf3mYLuI96uJBC6NHCTDg"); } private static ConnectConfig configured(String mode, int generation, Map pins) throws Exception { diff --git a/core/src/test/java/com/minekube/connect/principal/BedrockPrincipalCoreVectorTest.java b/core/src/test/java/com/minekube/connect/principal/BedrockPrincipalCoreVectorTest.java index eba5dbdb0..a5a4e8402 100644 --- a/core/src/test/java/com/minekube/connect/principal/BedrockPrincipalCoreVectorTest.java +++ b/core/src/test/java/com/minekube/connect/principal/BedrockPrincipalCoreVectorTest.java @@ -18,6 +18,9 @@ import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.Signature; import java.time.Clock; import java.time.Instant; import java.time.ZoneOffset; @@ -124,6 +127,39 @@ void consumesReplayExactlyOnce() throws Exception { assertEquals(PrincipalError.REPLAY, error.error()); } + @Test + void acceptsNineteenDigitUnsignedXuidAndDerivesLow64Uuid() throws Exception { + Vector vector = Arrays.stream(vectors()) + .filter(candidate -> candidate.name.equals("valid-unlinked")) + .findFirst().orElseThrow(); + String[] parts = vector.compactJws.split("\\."); + var payload = com.google.gson.JsonParser.parseString(new String( + Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8)).getAsJsonObject(); + payload.addProperty("canonical_xuid", "9223372036854775808"); + payload.addProperty("canonical_unlinked_uuid", "00000000-0000-0000-8000-000000000000"); + String encodedPayload = Base64.getUrlEncoder().withoutPadding().encodeToString( + new Gson().toJson(payload).getBytes(StandardCharsets.UTF_8)); + + KeyPair keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(keyPair.getPrivate()); + String signingInput = parts[0] + "." + encodedPayload; + signer.update(signingInput.getBytes(StandardCharsets.US_ASCII)); + String compact = signingInput + "." + Base64.getUrlEncoder().withoutPadding() + .encodeToString(signer.sign()); + byte[] encodedKey = keyPair.getPublic().getEncoded(); + byte[] rawKey = Arrays.copyOfRange(encodedKey, encodedKey.length - 32, encodedKey.length); + + var principal = BedrockPrincipalVerifierFactory.create( + VerifierConfiguration.builder().publicKey("connect-v2-test", rawKey) + .clock(Clock.fixed(Instant.ofEpochSecond(vector.verificationTimeUnix), ZoneOffset.UTC)) + .build()) + .verifyAndConsume(SignedPrincipalEnvelope.of(compact), vector.trustedContext.toContext()); + assertEquals("9223372036854775808", principal.xuid().value()); + assertEquals(UUID.fromString("00000000-0000-0000-8000-000000000000"), + principal.canonicalUnlinkedUuid()); + } + @Test void concurrentReplayConsumptionHasOneAnonymousWinner() throws Exception { Vector vector = Arrays.stream(vectors()) diff --git a/core/src/test/java/com/minekube/connect/tunnel/p2p/PeerRegistrationClientTest.java b/core/src/test/java/com/minekube/connect/tunnel/p2p/PeerRegistrationClientTest.java index 4b9b0394d..39bd0d42e 100644 --- a/core/src/test/java/com/minekube/connect/tunnel/p2p/PeerRegistrationClientTest.java +++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/PeerRegistrationClientTest.java @@ -1,6 +1,7 @@ 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 static org.mockito.Mockito.clearInvocations; @@ -274,6 +275,7 @@ void negotiatesFramingBeforeAnsweringReadinessChallenge() throws Exception { PeerRegistrationHandshake handshake = new PeerRegistrationHandshake( identity, "endpoint", "token", "instance", Collections.emptyList(), OfflineMode.OFFLINE_MODE_ALLOWED, Arrays.asList("session", "status"), + Arrays.asList("session", "status", BedrockPrincipalReadiness.CAPABILITY), PeerCapacity.newBuilder().setMaxSessions(100).build()); Stream stream = mock(Stream.class); when(stream.closeFuture()).thenReturn(new CompletableFuture<>()); @@ -297,6 +299,7 @@ void negotiatesFramingBeforeAnsweringReadinessChallenge() throws Exception { new ByteBufInputStream((ByteBuf) offeredFrame.getAllValues().get(2)), PeerRegisterCommit.parser(), P2PFrameCodec.MAX_CONTROL_FRAME_SIZE); assertEquals("kind-prefixed-v1", offered.getModeOffer().getFraming()); + assertFalse(offered.getRecord().getCapabilitiesList().contains(BedrockPrincipalReadiness.CAPABILITY)); long now = System.currentTimeMillis() / 1_000; ReadinessChallenge readinessChallenge = ReadinessChallenge.newBuilder() @@ -319,6 +322,7 @@ void negotiatesFramingBeforeAnsweringReadinessChallenge() throws Exception { assertEquals(P2PFrameCodec.READINESS_ATTESTATION, answer.kind()); assertEquals(ReadinessAttestation.Result.RESULT_READY, answer.parse(ReadinessAttestation.parser()).getResult()); + client.close(); } @@ -330,8 +334,8 @@ private static BedrockPrincipalReadiness readyPrincipalConsumer() throws Excepti set(config.getBedrockPrincipal(), "trustDomain", "urn:minekube:connect:production"); set(config.getBedrockPrincipal(), "audience", "urn:minekube:connect:bedrock-principal:v2"); set(config.getBedrockPrincipal(), "metadataOrigin", "https://connect.minekube.com"); - set(config.getBedrockPrincipal(), "publicKeys", Map.of("kid", Base64.getUrlEncoder() - .withoutPadding().encodeToString(new byte[32]))); + set(config.getBedrockPrincipal(), "publicKeys", Map.of( + "kid", "diQm8c6MI-Zwn1nie8hq4wqf3mYLuI96uJBC6NHCTDg")); return new BedrockPrincipalReadiness(config); } 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 5cb755ec4..68428321d 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 @@ -94,6 +94,28 @@ void buildsInitAndSignedCommitFromChallenge() throws Exception { assertEquals("kind-prefixed-v1", offered.getModeOffer().getFraming()); } + @Test + void onlyAdvertisesNegotiatedCapabilitiesAfterFraming() 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"), + Arrays.asList("session", "status", "bedrock-verified-principal-v2"), + PeerCapacity.newBuilder().setMaxSessions(100).build()); + PeerRegisterChallenge challenge = PeerRegisterChallenge.newBuilder() + .setEndpointId("endpoint-id").setEndpointHash("endpoint-hash") + .setPublisherId("publisher").setPublisherPeerId("publisher-peer") + .setRegion("local").setKvTtlMs(45_000).setNonce(ByteString.copyFromUtf8("nonce")) + .build(); + + PeerRegisterCommit offered = handshake.commit(challenge, Collections.emptyList(), 1, 1_000, true); + assertFalse(offered.getRecord().getCapabilitiesList().contains("bedrock-verified-principal-v2")); + PeerRegisterCommit negotiated = handshake.commit( + challenge, Collections.emptyList(), 2, 2_000, false, true); + assertTrue(negotiated.getRecord().getCapabilitiesList().contains("bedrock-verified-principal-v2")); + } + @Test void doesNotInventBedrockIdentityCapability() throws Exception { EndpointPeerIdentity identity = EndpointPeerIdentity.loadOrCreate(tempDir.resolve("libp2p-identity.key")); From 311746282976f98cbc0f1a241617178a47536f9b Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Mon, 3 Aug 2026 17:12:42 +0200 Subject: [PATCH 3/4] no-mistakes(review): Bind shared Bedrock readiness across startup and libp2p --- .../com/minekube/connect/module/CommonModule.java | 7 +++++++ .../minekube/connect/tunnel/p2p/Libp2pEndpoint.java | 4 ++++ .../connect/tunnel/p2p/Libp2pEndpointRuntime.java | 7 +++---- .../connect/startup/PluginGraphStartupTest.java | 11 ++++++++++- .../tunnel/p2p/Libp2pEndpointRuntimeInitTest.java | 10 ++++++++++ 5 files changed, 34 insertions(+), 5 deletions(-) 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 95d9f689c..14ff93bbb 100644 --- a/core/src/main/java/com/minekube/connect/module/CommonModule.java +++ b/core/src/main/java/com/minekube/connect/module/CommonModule.java @@ -41,6 +41,7 @@ import com.minekube.connect.api.packet.PacketHandlers; import com.minekube.connect.bedrock.BedrockIdentityKeyProvider; import com.minekube.connect.bedrock.BedrockIdentityReadiness; +import com.minekube.connect.bedrock.BedrockPrincipalReadiness; import com.minekube.connect.config.ConfigHolder; import com.minekube.connect.config.ConfigLoader; import com.minekube.connect.config.ConfigLoader.EndpointNameGenerator; @@ -137,6 +138,12 @@ public BedrockIdentityReadiness bedrockIdentityReadiness( return new BedrockIdentityReadiness(configHolder.get(), keyProvider); } + @Provides + @Singleton + public BedrockPrincipalReadiness bedrockPrincipalReadiness(ConfigHolder configHolder) { + return new BedrockPrincipalReadiness(configHolder.get()); + } + @Provides @Singleton @Named("connectToken") diff --git a/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pEndpoint.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pEndpoint.java index b44909195..7e4dda828 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pEndpoint.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pEndpoint.java @@ -30,6 +30,7 @@ import com.minekube.connect.api.logger.ConnectLogger; import com.minekube.connect.bedrock.BedrockAdmissionCoordinator; import com.minekube.connect.bedrock.BedrockIdentityReadiness; +import com.minekube.connect.bedrock.BedrockPrincipalReadiness; import com.minekube.connect.config.ConnectConfig; import com.minekube.connect.platform.util.PlatformUtils; import java.lang.reflect.Constructor; @@ -58,6 +59,7 @@ public Libp2pEndpoint( PlatformInjector platformInjector, SimpleConnectApi api, BedrockIdentityReadiness bedrockIdentityReadiness, + BedrockPrincipalReadiness bedrockPrincipalReadiness, BedrockAdmissionCoordinator admissionCoordinator) { this.logger = logger; try { @@ -74,6 +76,7 @@ public Libp2pEndpoint( PlatformInjector.class, SimpleConnectApi.class, BedrockIdentityReadiness.class, + BedrockPrincipalReadiness.class, BedrockAdmissionCoordinator.class); constructor.setAccessible(true); this.runtime = constructor.newInstance( @@ -85,6 +88,7 @@ public Libp2pEndpoint( platformInjector, api, bedrockIdentityReadiness, + bedrockPrincipalReadiness, admissionCoordinator); this.startMethod = runtimeClass.getDeclaredMethod("start"); this.startBootstrapMethod = runtimeClass.getDeclaredMethod( 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 ea3e51c4a..c25c21e91 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 @@ -106,6 +106,7 @@ final class Libp2pEndpointRuntime { PlatformInjector platformInjector, SimpleConnectApi api, BedrockIdentityReadiness bedrockIdentityReadiness, + BedrockPrincipalReadiness bedrockPrincipalReadiness, BedrockAdmissionCoordinator admissionCoordinator) { this.dataDirectory = dataDirectory; this.connectConfig = connectConfig; @@ -115,9 +116,7 @@ final class Libp2pEndpointRuntime { this.platformInjector = platformInjector; this.api = api; this.bedrockIdentityReadiness = bedrockIdentityReadiness; - this.bedrockPrincipalReadiness = connectConfig == null - ? null - : new BedrockPrincipalReadiness(connectConfig); + this.bedrockPrincipalReadiness = bedrockPrincipalReadiness; this.admissionCoordinator = admissionCoordinator; } @@ -131,7 +130,7 @@ final class Libp2pEndpointRuntime { SimpleConnectApi api, BedrockIdentityReadiness bedrockIdentityReadiness) { this(dataDirectory, connectConfig, connectToken, platformUtils, logger, platformInjector, api, - bedrockIdentityReadiness, null); + bedrockIdentityReadiness, null, null); } @Inject diff --git a/core/src/test/java/com/minekube/connect/startup/PluginGraphStartupTest.java b/core/src/test/java/com/minekube/connect/startup/PluginGraphStartupTest.java index f8955f99c..6865153b6 100644 --- a/core/src/test/java/com/minekube/connect/startup/PluginGraphStartupTest.java +++ b/core/src/test/java/com/minekube/connect/startup/PluginGraphStartupTest.java @@ -26,6 +26,7 @@ package com.minekube.connect.startup; 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.Mockito.mock; @@ -39,7 +40,9 @@ import com.minekube.connect.bedrock.BedrockAdmissionCoordinator; import com.minekube.connect.bedrock.BedrockIdentityEnforcer; import com.minekube.connect.bedrock.BedrockIdentityKeyProvider; +import com.minekube.connect.bedrock.BedrockPrincipalReadiness; import com.minekube.connect.config.ConfigHolder; +import com.minekube.connect.config.ConnectConfig; import com.minekube.connect.inject.CommonPlatformInjector; import com.minekube.connect.module.ServerCommonModule; import com.minekube.connect.platform.util.PlatformUtils; @@ -142,7 +145,13 @@ protected void configure() { }); assertNotNull(injector.getInstance(ConnectApi.class)); - assertNotNull(injector.getInstance(ConfigHolder.class)); + ConfigHolder configHolder = injector.getInstance(ConfigHolder.class); + assertNotNull(configHolder); + configHolder.set(new ConnectConfig()); + BedrockPrincipalReadiness principalReadiness = + injector.getInstance(BedrockPrincipalReadiness.class); + assertNotNull(principalReadiness); + assertSame(principalReadiness, injector.getInstance(BedrockPrincipalReadiness.class)); assertNotNull(injector.getInstance(BedrockAdmissionCoordinator.class)); assertNotNull(injector.getInstance(BedrockIdentityEnforcer.class)); assertNotNull(injector.getInstance(BedrockIdentityKeyProvider.class)); diff --git a/core/src/test/java/com/minekube/connect/tunnel/p2p/Libp2pEndpointRuntimeInitTest.java b/core/src/test/java/com/minekube/connect/tunnel/p2p/Libp2pEndpointRuntimeInitTest.java index 11363b0b3..b6faed45d 100644 --- a/core/src/test/java/com/minekube/connect/tunnel/p2p/Libp2pEndpointRuntimeInitTest.java +++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/Libp2pEndpointRuntimeInitTest.java @@ -31,7 +31,9 @@ import com.minekube.connect.api.logger.ConnectLogger; import com.minekube.connect.bedrock.BedrockAdmissionCoordinator; +import com.minekube.connect.bedrock.BedrockPrincipalReadiness; import com.minekube.connect.bedrock.VerifiedBedrockIdentityRegistry; +import com.minekube.connect.config.ConnectConfig; import java.lang.reflect.Field; import java.nio.file.Path; import org.junit.jupiter.api.Test; @@ -51,6 +53,8 @@ class Libp2pEndpointRuntimeInitTest { void initializesRuntimeAcrossIsolatedLoaderBoundary( @TempDir Path dataDirectory) throws Exception { ConnectLogger logger = mock(ConnectLogger.class); + BedrockPrincipalReadiness principalReadiness = + new BedrockPrincipalReadiness(new ConnectConfig()); BedrockAdmissionCoordinator admissionCoordinator = new BedrockAdmissionCoordinator( new VerifiedBedrockIdentityRegistry()); @@ -64,6 +68,7 @@ void initializesRuntimeAcrossIsolatedLoaderBoundary( null, // PlatformInjector null, // SimpleConnectApi null, // BedrockIdentityReadiness + principalReadiness, admissionCoordinator); Field runtimeField = Libp2pEndpoint.class.getDeclaredField("runtime"); @@ -82,6 +87,11 @@ void initializesRuntimeAcrossIsolatedLoaderBoundary( assertNotNull(runtimeAdmissionCoordinator); assertSame(admissionCoordinator, runtimeAdmissionCoordinator); + + Field principalReadinessField = runtime.getClass() + .getDeclaredField("bedrockPrincipalReadiness"); + principalReadinessField.setAccessible(true); + assertSame(principalReadiness, principalReadinessField.get(runtime)); } finally { admissionCoordinator.close(); } From 059b9678fa68f8cefa06fb2d9986e9ced8a9b657 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Mon, 3 Aug 2026 17:27:51 +0200 Subject: [PATCH 4/4] no-mistakes(document): Document Bedrock v2 defaults and remove stale duplicates --- AGENTS.md | 9 +++-- README.md | 6 +-- core/src/main/resources/config.yml | 3 +- core/src/main/resources/proxy-config.yml | 3 +- docs/bedrock-identity.md | 38 ++++++++++--------- ...2026-07-06-bedrock-identity-enforcement.md | 7 ++-- ...-06-bedrock-identity-enforcement-design.md | 7 ++-- 7 files changed, 40 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aa1f1f95a..4edd68a2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -208,10 +208,11 @@ curl -I -L --fail https://github.com/minekube/connect-java/releases/download/