bedrock-portal-nethernet 2.0.11 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ import crypto from 'crypto';
2
+ export declare function getAuthorizationKey(protocolVersion: number, keyId: string): Promise<{
3
+ issuer: string;
4
+ key: crypto.KeyObject;
5
+ }>;
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getAuthorizationKey = getAuthorizationKey;
7
+ const crypto_1 = __importDefault(require("crypto"));
8
+ const debug = require('debug')('bedrock-portal-nethernet');
9
+ const AUTHORISATION_KEY_REFRESH_INTERVAL_MS = 30 * 60 * 1000;
10
+ const AUTHORISATION_FETCH_TIMEOUT_MS = 10_000;
11
+ const AUTHORISATION_SERVICE_URI_FALLBACK = 'https://authorization.franchise.minecraft-services.net';
12
+ const AUTHORISATION_SERVICE_OPENID_CONFIGURATION_PATH = '/.well-known/openid-configuration';
13
+ const AUTHORISATION_SERVICE_KEYS_PATH = '/.well-known/keys';
14
+ const MINECRAFT_SERVICES_DISCOVERY_URL = 'https://client.discovery.minecraft-services.net/api/v1.0/discovery/MinecraftPE/builds/';
15
+ let cachedAuthorizationKeyring = null;
16
+ let inflightAuthorizationKeyring = null;
17
+ async function fetchJson(url) {
18
+ let response;
19
+ try {
20
+ response = await fetch(url, { signal: AbortSignal.timeout(AUTHORISATION_FETCH_TIMEOUT_MS) });
21
+ }
22
+ catch (error) {
23
+ throw new Error(`Failed accessing ${url}: ${error instanceof Error ? error.message : String(error)}`);
24
+ }
25
+ if (!response.ok) {
26
+ throw new Error(`Unexpected HTTP response code accessing ${url}: ${response.status}`);
27
+ }
28
+ return response.json();
29
+ }
30
+ async function resolveAuthorizationUrls(protocolVersion) {
31
+ let authServiceUrl = new URL(AUTHORISATION_SERVICE_URI_FALLBACK);
32
+ let issuer = authServiceUrl.toString();
33
+ try {
34
+ const discovery = await fetchJson(new URL(String(protocolVersion), MINECRAFT_SERVICES_DISCOVERY_URL));
35
+ const authService = discovery.result?.serviceEnvironments?.auth?.prod;
36
+ if (!authService?.serviceUri) {
37
+ throw new Error('Discovery response missing auth service URI');
38
+ }
39
+ authServiceUrl = new URL(authService.serviceUri);
40
+ issuer = authService.issuer ? new URL(authService.issuer).toString() : authServiceUrl.toString();
41
+ }
42
+ catch (error) {
43
+ debug('Failed to resolve auth service info, using fallback', error);
44
+ }
45
+ let jwksUrl = new URL(AUTHORISATION_SERVICE_KEYS_PATH, authServiceUrl);
46
+ try {
47
+ const openIdConfiguration = await fetchJson(new URL(AUTHORISATION_SERVICE_OPENID_CONFIGURATION_PATH, authServiceUrl));
48
+ issuer = openIdConfiguration.issuer ? new URL(openIdConfiguration.issuer).toString() : issuer;
49
+ jwksUrl = openIdConfiguration.jwks_uri ? new URL(openIdConfiguration.jwks_uri) : jwksUrl;
50
+ }
51
+ catch (error) {
52
+ debug('Failed to resolve OpenID configuration, using default JWKS endpoint', error);
53
+ }
54
+ return { issuer, jwksUrl };
55
+ }
56
+ async function fetchAuthorizationKeyring(protocolVersion) {
57
+ const { issuer, jwksUrl } = await resolveAuthorizationUrls(protocolVersion);
58
+ const payload = await fetchJson(jwksUrl);
59
+ const keys = new Map();
60
+ for (const jwk of payload.keys || []) {
61
+ if (!jwk.kid)
62
+ continue;
63
+ keys.set(jwk.kid, crypto_1.default.createPublicKey({ key: jwk, format: 'jwk' }));
64
+ }
65
+ if (keys.size === 0) {
66
+ throw new Error('No valid authentication keys returned by Microsoft');
67
+ }
68
+ return {
69
+ fetchedAt: Date.now(),
70
+ issuer,
71
+ keys,
72
+ protocolVersion,
73
+ };
74
+ }
75
+ function isAuthorizationKeyringFresh(protocolVersion) {
76
+ return cachedAuthorizationKeyring !== null &&
77
+ cachedAuthorizationKeyring.protocolVersion === protocolVersion &&
78
+ Date.now() - cachedAuthorizationKeyring.fetchedAt < AUTHORISATION_KEY_REFRESH_INTERVAL_MS;
79
+ }
80
+ async function refreshAuthorizationKeyring(protocolVersion) {
81
+ inflightAuthorizationKeyring ??= fetchAuthorizationKeyring(protocolVersion);
82
+ try {
83
+ const keyring = await inflightAuthorizationKeyring;
84
+ cachedAuthorizationKeyring = keyring;
85
+ return keyring;
86
+ }
87
+ finally {
88
+ inflightAuthorizationKeyring = null;
89
+ }
90
+ }
91
+ async function getAuthorizationKey(protocolVersion, keyId) {
92
+ let keyring = isAuthorizationKeyringFresh(protocolVersion)
93
+ ? cachedAuthorizationKeyring
94
+ : await refreshAuthorizationKeyring(protocolVersion);
95
+ if (!keyring || !keyring.keys.has(keyId)) {
96
+ keyring = await refreshAuthorizationKeyring(protocolVersion);
97
+ }
98
+ const key = keyring.keys.get(keyId);
99
+ if (!key) {
100
+ throw new Error(`Unrecognized authentication key ID: ${keyId}`);
101
+ }
102
+ return {
103
+ issuer: keyring.issuer,
104
+ key,
105
+ };
106
+ }
@@ -6,12 +6,64 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const crypto_1 = __importDefault(require("crypto"));
7
7
  const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
8
8
  const constants_1 = require("./constants");
9
+ const authorizationKeys_1 = require("./authorizationKeys");
9
10
  const debug = require('debug')('bedrock-portal-nethernet');
11
+ const AUTHORISATION_AUDIENCE = 'api://auth-minecraft-services/multiplayer';
12
+ const AUTHORISATION_CLOCK_TOLERANCE_SECONDS = 60;
13
+ function getJwtObject(decoded, errorMessage) {
14
+ if (!decoded || typeof decoded === 'string') {
15
+ throw new Error(errorMessage);
16
+ }
17
+ return decoded;
18
+ }
19
+ function normalizeToken(token) {
20
+ return token.replace(/^MCToken\s+/i, '').trim();
21
+ }
22
+ function getJwtHeader(token) {
23
+ const decoded = jsonwebtoken_1.default.decode(normalizeToken(token), { complete: true });
24
+ if (!decoded?.header || typeof decoded.header !== 'object') {
25
+ throw new Error('Missing JWT header');
26
+ }
27
+ return decoded.header;
28
+ }
29
+ function getTokenUserData(decoded) {
30
+ return {
31
+ extraData: {
32
+ XUID: decoded.xid || decoded.XUID || decoded.xuid || '0',
33
+ displayName: decoded.xname || decoded.displayName || 'Player',
34
+ identity: decoded.identity,
35
+ PlayFabID: decoded.pfbid || decoded.playFabId || decoded.PlayFabID,
36
+ PlayFabTitleID: decoded.pfbtid || decoded.playFabTitleId || decoded.PlayFabTitleID,
37
+ },
38
+ };
39
+ }
10
40
  exports.default = (client) => {
11
41
  // Refer to the docs:
12
42
  // https://web.archive.org/web/20180917171505if_/https://confluence.yawk.at/display/PEPROTOCOL/Game+Packets#GamePackets-Login
13
43
  const getDER = (b64) => crypto_1.default.createPublicKey({ key: Buffer.from(b64, 'base64'), format: 'der', type: 'spki' });
14
- function verifyAuth(chain) {
44
+ async function verifyTokenAuth(token) {
45
+ const normalizedToken = normalizeToken(token);
46
+ const header = getJwtHeader(normalizedToken);
47
+ if (!header.kid) {
48
+ throw new Error('Authorization token missing key id');
49
+ }
50
+ const { issuer, key } = await (0, authorizationKeys_1.getAuthorizationKey)(client.options.protocolVersion, header.kid);
51
+ const decoded = getJwtObject(jsonwebtoken_1.default.verify(normalizedToken, key, {
52
+ algorithms: ['RS256'],
53
+ clockTolerance: AUTHORISATION_CLOCK_TOLERANCE_SECONDS,
54
+ issuer,
55
+ audience: AUTHORISATION_AUDIENCE,
56
+ }), 'Invalid login token');
57
+ const publicKey = decoded.cpk || decoded.clientPublicKey;
58
+ if (!publicKey) {
59
+ throw new Error('Authorization token missing client public key');
60
+ }
61
+ return {
62
+ key: publicKey,
63
+ data: getTokenUserData(decoded),
64
+ };
65
+ }
66
+ function verifyChainAuth(chain) {
15
67
  let data = {};
16
68
  // There are three JWT tokens sent to us, one signed by the client
17
69
  // one signed by Mojang with the Mojang token we have and another one
@@ -20,9 +72,9 @@ exports.default = (client) => {
20
72
  // signed by Mojang by checking the x509 public key in the JWT headers
21
73
  let didVerify = false;
22
74
  let pubKey = getDER(getX5U(chain[0])); // the first one is client signed, allow it
23
- let finalKey = null;
75
+ let finalKey = '';
24
76
  for (const token of chain) {
25
- const decoded = jsonwebtoken_1.default.verify(token, pubKey, { algorithms: ['ES384'] });
77
+ const decoded = getJwtObject(jsonwebtoken_1.default.verify(token, pubKey, { algorithms: ['ES384'] }), 'Invalid login token');
26
78
  // Check if signed by Mojang key
27
79
  const x5u = getX5U(token);
28
80
  if (x5u === constants_1.PUBLIC_KEY && !data.extraData?.XUID) {
@@ -39,12 +91,14 @@ exports.default = (client) => {
39
91
  return { key: finalKey, data };
40
92
  }
41
93
  function verifySkin(publicKey, token) {
94
+ if (getX5U(token) !== publicKey) {
95
+ throw new Error('Invalid JWT signature');
96
+ }
42
97
  const pubKey = getDER(publicKey);
43
- const decoded = jsonwebtoken_1.default.verify(token, pubKey, { algorithms: ['ES384'] });
44
- return decoded;
98
+ return getJwtObject(jsonwebtoken_1.default.verify(token, pubKey, { algorithms: ['ES384'] }), 'Invalid JWT payload');
45
99
  }
46
- client.decodeLoginJWT = (authTokens, skinTokens) => {
47
- const { key, data } = verifyAuth(authTokens);
100
+ client.decodeLoginJWT = async (authTokens, skinTokens, authToken = '') => {
101
+ const { key, data } = authToken ? await verifyTokenAuth(authToken) : verifyChainAuth(authTokens);
48
102
  const skinData = verifySkin(key, skinTokens);
49
103
  return { key, userData: data, skinData };
50
104
  };
@@ -58,8 +112,6 @@ exports.default = (client) => {
58
112
  };
59
113
  };
60
114
  function getX5U(token) {
61
- const [header] = token.split('.');
62
- const hdec = Buffer.from(header, 'base64').toString('utf-8');
63
- const hjson = JSON.parse(hdec);
64
- return hjson.x5u;
115
+ const hjson = getJwtHeader(token);
116
+ return hjson.x5u || '';
65
117
  }
@@ -1,17 +1,29 @@
1
1
  import { Connection } from './connection';
2
2
  import { Signal } from '../signaling/signal';
3
3
  import { SignalStructure } from '../signaling/struct';
4
+ type PendingCandidate = {
5
+ signal: SignalStructure;
6
+ attempts: number;
7
+ };
4
8
  export declare class Server {
5
9
  networkId: bigint;
6
10
  connectionId: bigint;
7
11
  signaling: Signal;
8
12
  connections: Map<bigint, Connection>;
13
+ pendingCandidates: Map<bigint, PendingCandidate[]>;
14
+ remoteDescriptionReady: Set<bigint>;
15
+ pendingCandidateTimers: Map<bigint, NodeJS.Timeout>;
9
16
  onOpenConnection: (conn: Connection) => void;
10
17
  onCloseConnection: (id: bigint, reason: string) => void;
11
18
  onEncapsulated: (packet: Buffer, id: bigint) => void;
12
19
  constructor(signaling: Signal, networkId?: bigint, connectionId?: bigint);
13
20
  handleCandidate(signal: SignalStructure): Promise<void>;
21
+ queueCandidate(signal: SignalStructure, reason?: string): void;
22
+ schedulePendingCandidateFlush(connectionId: bigint): void;
23
+ flushPendingCandidates(connectionId: bigint): void;
24
+ requeueCandidate(candidate: PendingCandidate, remaining: PendingCandidate[], reason: string): void;
14
25
  handleOffer(signal: SignalStructure): Promise<void>;
15
26
  listen(): Promise<void>;
16
27
  close(): void;
17
28
  }
29
+ export {};
@@ -5,6 +5,8 @@ const node_datachannel_1 = require("node-datachannel");
5
5
  const connection_1 = require("./connection");
6
6
  const struct_1 = require("../signaling/struct");
7
7
  const debugFn = require('debug')('bedrock-portal-nethernet');
8
+ const MAX_PENDING_CANDIDATE_ATTEMPTS = 50;
9
+ const PENDING_CANDIDATE_RETRY_MS = 100;
8
10
  const getRandomUint64 = () => {
9
11
  const high = Math.floor(Math.random() * 0xFFFFFFFF);
10
12
  const low = Math.floor(Math.random() * 0xFFFFFFFF);
@@ -15,6 +17,9 @@ class Server {
15
17
  connectionId;
16
18
  signaling;
17
19
  connections;
20
+ pendingCandidates;
21
+ remoteDescriptionReady;
22
+ pendingCandidateTimers;
18
23
  onOpenConnection;
19
24
  onCloseConnection;
20
25
  onEncapsulated;
@@ -23,18 +28,78 @@ class Server {
23
28
  this.networkId = networkId;
24
29
  this.connectionId = connectionId;
25
30
  this.connections = new Map();
31
+ this.pendingCandidates = new Map();
32
+ this.remoteDescriptionReady = new Set();
33
+ this.pendingCandidateTimers = new Map();
26
34
  this.onOpenConnection = () => { };
27
35
  this.onCloseConnection = () => { };
28
36
  this.onEncapsulated = () => { };
29
37
  }
30
38
  async handleCandidate(signal) {
31
39
  const conn = this.connections.get(signal.connectionId);
32
- if (conn) {
40
+ if (!conn || !this.remoteDescriptionReady.has(signal.connectionId)) {
41
+ this.queueCandidate(signal);
42
+ return;
43
+ }
44
+ try {
33
45
  conn.rtcConnection.addRemoteCandidate(signal.data, '0');
34
46
  }
47
+ catch (error) {
48
+ this.queueCandidate(signal, formatCandidateError(error));
49
+ }
50
+ }
51
+ queueCandidate(signal, reason = 'connection is not ready') {
52
+ const candidates = this.pendingCandidates.get(signal.connectionId) || [];
53
+ candidates.push({ signal, attempts: 0 });
54
+ this.pendingCandidates.set(signal.connectionId, candidates);
55
+ debugFn('Queued ICE candidate', signal.connectionId, reason);
56
+ this.schedulePendingCandidateFlush(signal.connectionId);
57
+ }
58
+ schedulePendingCandidateFlush(connectionId) {
59
+ if (this.pendingCandidateTimers.has(connectionId))
60
+ return;
61
+ const timer = setTimeout(() => {
62
+ this.pendingCandidateTimers.delete(connectionId);
63
+ this.flushPendingCandidates(connectionId);
64
+ }, PENDING_CANDIDATE_RETRY_MS);
65
+ timer.unref?.();
66
+ this.pendingCandidateTimers.set(connectionId, timer);
67
+ }
68
+ flushPendingCandidates(connectionId) {
69
+ const candidates = this.pendingCandidates.get(connectionId);
70
+ if (!candidates?.length)
71
+ return;
72
+ const conn = this.connections.get(connectionId);
73
+ const remaining = [];
74
+ for (const candidate of candidates) {
75
+ if (!conn || !this.remoteDescriptionReady.has(connectionId)) {
76
+ this.requeueCandidate(candidate, remaining, 'connection is not ready');
77
+ continue;
78
+ }
79
+ try {
80
+ conn.rtcConnection.addRemoteCandidate(candidate.signal.data, '0');
81
+ }
82
+ catch (error) {
83
+ this.requeueCandidate(candidate, remaining, formatCandidateError(error));
84
+ }
85
+ }
86
+ if (remaining.length) {
87
+ this.pendingCandidates.set(connectionId, remaining);
88
+ this.schedulePendingCandidateFlush(connectionId);
89
+ }
35
90
  else {
36
- debugFn('Received candidate for unknown connection', signal);
91
+ this.pendingCandidates.delete(connectionId);
92
+ }
93
+ }
94
+ requeueCandidate(candidate, remaining, reason) {
95
+ if (candidate.attempts >= MAX_PENDING_CANDIDATE_ATTEMPTS) {
96
+ debugFn('Dropping ICE candidate after retries', candidate.signal.connectionId, reason);
97
+ return;
37
98
  }
99
+ remaining.push({
100
+ signal: candidate.signal,
101
+ attempts: candidate.attempts + 1,
102
+ });
38
103
  }
39
104
  async handleOffer(signal) {
40
105
  if (!this.signaling.credentials) {
@@ -55,10 +120,15 @@ class Server {
55
120
  rtcConnection.onIceStateChange(state => {
56
121
  if (state === 'connected')
57
122
  this.onOpenConnection(connection);
58
- if (state === 'disconnected')
123
+ if (state === 'disconnected') {
124
+ this.remoteDescriptionReady.delete(signal.connectionId);
125
+ this.pendingCandidates.delete(signal.connectionId);
59
126
  this.onCloseConnection(signal.connectionId, 'disconnected');
127
+ }
60
128
  });
61
129
  rtcConnection.setRemoteDescription(signal.data, 'offer');
130
+ this.remoteDescriptionReady.add(signal.connectionId);
131
+ this.flushPendingCandidates(signal.connectionId);
62
132
  const answer = rtcConnection.localDescription();
63
133
  if (!answer) {
64
134
  throw new Error('No answer');
@@ -70,10 +140,14 @@ class Server {
70
140
  this.signaling.on('signal', (signal) => {
71
141
  switch (signal.type) {
72
142
  case struct_1.SignalType.ConnectRequest:
73
- this.handleOffer(signal);
143
+ this.handleOffer(signal).catch(error => {
144
+ debugFn('Failed to handle connect offer', signal, error);
145
+ });
74
146
  break;
75
147
  case struct_1.SignalType.CandidateAdd:
76
- this.handleCandidate(signal);
148
+ this.handleCandidate(signal).catch(error => {
149
+ debugFn('Failed to handle ICE candidate', signal, error);
150
+ });
77
151
  break;
78
152
  default:
79
153
  debugFn('Received signal for unknown type', signal);
@@ -81,9 +155,18 @@ class Server {
81
155
  });
82
156
  }
83
157
  close() {
158
+ for (const timer of this.pendingCandidateTimers.values()) {
159
+ clearTimeout(timer);
160
+ }
161
+ this.pendingCandidateTimers.clear();
162
+ this.pendingCandidates.clear();
163
+ this.remoteDescriptionReady.clear();
84
164
  for (const conn of this.connections.values()) {
85
165
  conn.close();
86
166
  }
87
167
  }
88
168
  }
89
169
  exports.Server = Server;
170
+ function formatCandidateError(error) {
171
+ return error instanceof Error ? error.message : String(error);
172
+ }
package/dist/options.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { CompressionAlgorithm } from './transforms/framer';
2
2
  export type Options = {
3
3
  version: string;
4
+ signalingMode: 'rpc' | 'legacy';
4
5
  autoInitPlayer: boolean;
5
6
  offline: boolean;
6
7
  connectTimeout: number;
@@ -9,14 +10,5 @@ export type Options = {
9
10
  compressionThreshold: number;
10
11
  protocolVersion: number;
11
12
  };
12
- export declare const CURRENT_VERSION = "1.26.0";
13
- export declare const defaultOptions: {
14
- version: string;
15
- autoInitPlayer: boolean;
16
- offline: boolean;
17
- connectTimeout: number;
18
- compressionAlgorithm: CompressionAlgorithm;
19
- compressionLevel: number;
20
- compressionThreshold: number;
21
- protocolVersion: number;
22
- };
13
+ export declare const CURRENT_VERSION = "1.26.20";
14
+ export declare const defaultOptions: Options;
package/dist/options.js CHANGED
@@ -3,10 +3,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.defaultOptions = exports.CURRENT_VERSION = void 0;
4
4
  const framer_1 = require("./transforms/framer");
5
5
  // Currently supported verson. Note, clients with newer versions can still connect as long as data is in minecraft-data
6
- exports.CURRENT_VERSION = '1.26.0';
6
+ exports.CURRENT_VERSION = '1.26.20';
7
7
  exports.defaultOptions = {
8
8
  // https://minecraft.wiki/w/Protocol_version#Bedrock_Edition_2
9
9
  version: exports.CURRENT_VERSION,
10
+ signalingMode: 'rpc',
10
11
  // client: If we should send SetPlayerInitialized to the server after getting play_status spawn.
11
12
  // if this is disabled, no 'spawn' event will be emitted, you should manually set
12
13
  // client.status to ClientStatus.Initialized after sending the init packet.
@@ -22,5 +23,5 @@ exports.defaultOptions = {
22
23
  // server: If true, only compress if a payload is larger than compressionThreshold
23
24
  compressionThreshold: 512,
24
25
  // server and client: The protocol version to use
25
- protocolVersion: 924,
26
+ protocolVersion: 975,
26
27
  };