Skip to content

Commit e6bf226

Browse files
committed
feat: 🎉 begin project
1 parent d67d6ed commit e6bf226

File tree

9 files changed

+328
-34
lines changed

9 files changed

+328
-34
lines changed

.github/workflows/build.yml

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,25 @@
1-
# name: Build
1+
name: Build
22

3-
# on:
4-
# push:
5-
# branches: [main]
6-
# pull_request:
7-
# branches: [main]
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
88

9-
# jobs:
10-
# build:
11-
# runs-on: ubuntu-latest
12-
# strategy:
13-
# matrix:
14-
# node-version: [16.x]
15-
# steps:
16-
# - uses: actions/checkout@v2
17-
# - name: Node.js
18-
# uses: actions/setup-node@v2
19-
# with:
20-
# node-version: ${{ matrix.node-version }}
21-
# - run: npm install
22-
# - name: Build code
23-
# run: npm run build
24-
# - name: Test code
25-
# run: npm run test
9+
jobs:
10+
build:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
node-version: [18.x]
15+
steps:
16+
- uses: actions/checkout@v2
17+
- name: Node.js
18+
uses: actions/setup-node@v2
19+
with:
20+
node-version: ${{ matrix.node-version }}
21+
- run: npm install
22+
- name: Build code
23+
run: npm run build
24+
# - name: Test code
25+
# run: npm run test

README.md

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,29 @@
1-
# MinecraftJS Template
1+
# Yggdrasil
22

3-
Fill with your own content
3+
![GitHub Workflow Status](https://img.shields.io/github/workflow/status/MinecraftJS/Yggdrasil/Build?style=for-the-badge)
4+
![GitHub](https://img.shields.io/github/license/MinecraftJS/Yggdrasil?style=for-the-badge)
5+
![npm (scoped)](https://img.shields.io/npm/v/@minecraft-js/yggdrasil?style=for-the-badge)
6+
7+
Yggdrasil wrapper written in TypeScript
8+
9+
# Documentation
10+
11+
## Installation
12+
13+
Install the package:
14+
15+
```bash
16+
$ npm install @minecraft-js/yggdrasil
17+
```
18+
19+
And then import it in your JavaScript/TypeScript file
20+
21+
```ts
22+
const { yggdrasil } = require('@minecraft-js/yggdrasil'); // CommonJS
23+
24+
import { yggdrasil } from '@minecraft-js/yggdrasil'; // ES6
25+
```
26+
27+
## Usage
28+
29+
This library is meant for client and server authentication. See [this](https://wiki.vg/Protocol_Encryption) for more information.

package-lock.json

Lines changed: 31 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,30 @@
11
{
2-
"name": "template",
2+
"name": "@minecraft-js/yggdrasil",
33
"version": "1.0.0",
4-
"description": "todo: change",
4+
"description": "Yggdrasil wrapper written in TypeScript",
55
"main": "dist/index.js",
66
"scripts": {
77
"test": "echo \"Error: no test specified\" && exit 1",
88
"build": "tsc"
99
},
1010
"repository": {
1111
"type": "git",
12-
"url": "git+https://github.com/MinecraftJS/template.git"
12+
"url": "git+https://github.com/MinecraftJS/Yggdrasil.git"
1313
},
1414
"keywords": [
1515
"minecraftjs"
1616
],
17-
"author": "todo: change",
17+
"author": "RichardDorian",
1818
"license": "MIT",
1919
"bugs": {
20-
"url": "https://github.com/MinecraftJS/template/issues"
20+
"url": "https://github.com/MinecraftJS/Yggdrasil/issues"
21+
},
22+
"homepage": "https://github.com/MinecraftJS/Yggdrasil#readme",
23+
"dependencies": {
24+
"@minecraft-js/uuid": "^1.0.3"
2125
},
22-
"homepage": "https://github.com/MinecraftJS/template#readme",
23-
"dependencies": {},
2426
"devDependencies": {
27+
"@types/node": "^18.7.18",
2528
"prettier": "^2.7.1",
2629
"typescript": "^4.8.2"
2730
},

src/Client.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { parseUUID, UUID } from '@minecraft-js/uuid';
2+
import { URLSearchParams } from 'node:url';
3+
import {
4+
EmptyResponseError,
5+
InsufficientPrivilegesError,
6+
UserBannedError,
7+
} from './utils/errors';
8+
import { generateHexDigest } from './utils/hash';
9+
import { request } from './utils/request';
10+
11+
export class YggdrasilClient {
12+
/** Default instance using the default host */
13+
public static readonly instance = new YggdrasilClient();
14+
/** Headers to apply to every single requests */
15+
public static readonly HEADERS = {
16+
'User-Agent':
17+
'MinecraftJS/1.0.0 (https://github.com/MinecraftJS/Yggdrasil)',
18+
'Content-Type': 'application/json',
19+
};
20+
21+
/** Host this instance is bound to */
22+
public readonly host: string;
23+
24+
public constructor(host?: string) {
25+
this.host = host ?? 'https://sessionserver.mojang.com';
26+
}
27+
28+
public async join(
29+
accessToken: string,
30+
selectedProfile: UUID,
31+
serverId: string,
32+
sharedSecret: Buffer,
33+
serverPublicKey: Buffer
34+
): Promise<void> {
35+
const hash = generateHexDigest(serverId, sharedSecret, serverPublicKey);
36+
37+
const { body, response } = await request(
38+
this.host + '/session/minecraft/join',
39+
JSON.stringify({
40+
accessToken,
41+
selectedProfile,
42+
serverId: hash,
43+
}),
44+
{
45+
headers: YggdrasilClient.HEADERS,
46+
method: 'POST',
47+
}
48+
);
49+
50+
switch (response.statusCode) {
51+
case 204:
52+
return;
53+
54+
case 403:
55+
const error = JSON.parse(body.toString()).error;
56+
57+
switch (error) {
58+
case 'InsufficientPrivilegesException':
59+
throw new InsufficientPrivilegesError(
60+
'Xbox profile has multiplayer disabled'
61+
);
62+
63+
case 'UserBannedException':
64+
throw new UserBannedError('User is banned from multiplayer');
65+
66+
default:
67+
throw new Error(error);
68+
}
69+
70+
default:
71+
throw new Error('Unexpected status code ' + response.statusCode);
72+
}
73+
}
74+
75+
/**
76+
* Ask the authentication server if an online player connected to the server
77+
*
78+
* Note: The `id` and `name` fields are then sent back to the client using a Login Success packet. The profile id in the
79+
* json response has format `11111111222233334444555555555555` which needs to be changed into format
80+
* `11111111-2222-3333-4444-555555555555` before sending it back to the client.
81+
*
82+
* @param username The username is case insensitive and must match the client's username (which was received in the LoginStartPacket).
83+
* Note that this is the in-game nickname of the selected profile, not the Mojang account name (which is never sent to the server).
84+
* Servers should use the name sent in the "name" field.
85+
* @param serverId ASCII encoding of the server id string from EncryptionRequestPacket
86+
* @param sharedSecret Shared secret between the client and the server
87+
* @param serverPublicKey Server's public key from EncryptionRequestPacket
88+
* @param ip The ip field is optional and when present should be the IP address of the connecting player; it is the one that
89+
* originally initiated the session request. The notchian server includes this only when `prevent-proxy-connections`
90+
* is set to true in `server.properties`.
91+
* @returns Object containing the UUID and the player's skin
92+
*/
93+
public async hasJoined(
94+
username: string,
95+
serverId: string,
96+
sharedSecret: Buffer,
97+
serverPublicKey: Buffer,
98+
ip?: string
99+
): Promise<HasJoinedResponse> {
100+
const hash = generateHexDigest(serverId, sharedSecret, serverPublicKey);
101+
102+
const params = new URLSearchParams();
103+
params.set('username', username);
104+
params.set('serverId', hash);
105+
if (ip) params.set('ip', ip);
106+
107+
const { body, response } = await request(
108+
this.host + '/session/minecraft/hasJoined?' + params.toString(),
109+
null,
110+
{
111+
headers: {
112+
'User-Agent': YggdrasilClient.HEADERS['User-Agent'],
113+
},
114+
}
115+
);
116+
117+
switch (response.statusCode) {
118+
case 200:
119+
const parsed = JSON.parse(body.toString());
120+
parsed.id = parseUUID(parsed.id);
121+
return parsed;
122+
123+
case 204:
124+
throw new EmptyResponseError(
125+
'Received an empty response from the server'
126+
);
127+
128+
default:
129+
throw new Error('Unexpected status code ' + response.statusCode);
130+
}
131+
}
132+
}
133+
134+
export const yggdrasil = YggdrasilClient.instance;
135+
136+
export interface HasJoinedResponse {
137+
id: UUID;
138+
name: string;
139+
properties: {
140+
name: string;
141+
value: string;
142+
signature: string;
143+
}[];
144+
}

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './Client';
2+
export * from './utils/errors';
3+
export * from './utils/hash';

src/utils/errors.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export class EmptyResponseError extends Error {}
2+
export class InsufficientPrivilegesError extends Error {}
3+
export class UserBannedError extends Error {}

src/utils/hash.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* Most of the code here
3+
* is just adapted to fit
4+
* my needs
5+
* @see https://gist.github.com/andrewrk/4425843?permalink_comment_id=3265398#gistcomment-3265398
6+
* for the original code
7+
*/
8+
9+
import { createHash } from 'node:crypto';
10+
11+
/**
12+
* Generate a server hash from its serverId, shared secret and public key
13+
* @param serverId ASCII encoding of the server id string from EncryptionRequestPacket
14+
* @param sharedSecret Shared secret between the client and the server
15+
* @param serverPublicKey Server's public key from EncryptionRequestPacket
16+
* @returns The hash
17+
*/
18+
export function generateHexDigest(
19+
serverId: string,
20+
sharedSecret: Buffer,
21+
serverPublicKey: Buffer
22+
): string {
23+
// The hex digest is the hash made below.
24+
// However, when this hash is negative (meaning its MSB is 1, as it is in two's complement), instead of leaving it
25+
// like that, we make it positive and simply put a '-' in front of it. This is a simple process: as you always do
26+
// with 2's complement you simply flip all bits and add 1
27+
28+
let hash = createHash('sha1')
29+
.update(serverId)
30+
.update(sharedSecret)
31+
.update(serverPublicKey)
32+
.digest();
33+
34+
// Negative check: check if the most significant bit of the hash is a 1.
35+
const isNegative = (hash.readUInt8(0) & (1 << 7)) !== 0; // when 0, it is positive
36+
37+
if (isNegative) {
38+
// Flip all bits and add one. Start at the right to make sure the carry works
39+
const inverted = Buffer.allocUnsafe(hash.length);
40+
let carry = 0;
41+
for (let i = hash.length - 1; i >= 0; i--) {
42+
let num = hash.readUInt8(i) ^ 0b11111111; // a byte XOR a byte of 1's = the inverse of the byte
43+
if (i === hash.length - 1) num++;
44+
num += carry;
45+
carry = Math.max(0, num - 0b11111111);
46+
num = Math.min(0b11111111, num);
47+
inverted.writeUInt8(num, i);
48+
}
49+
hash = inverted;
50+
}
51+
let result = hash.toString('hex').replace(/^0+/, '');
52+
// If the result was negative, add a '-' sign
53+
if (isNegative) result = `-${result}`;
54+
55+
return result;
56+
}

0 commit comments

Comments
 (0)