@honeybbq/teamspeak-client 0.3.1 → 1.0.0-beta.2
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.
- package/README.md +345 -64
- package/dist/abort.d.ts +5 -0
- package/dist/abort.d.ts.map +1 -0
- package/dist/address.d.ts +8 -0
- package/dist/address.d.ts.map +1 -1
- package/dist/api/voice.d.ts +24 -0
- package/dist/api/voice.d.ts.map +1 -0
- package/dist/capabilities.d.ts +62 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/client.d.ts +173 -39
- package/dist/client.d.ts.map +1 -1
- package/dist/commands.d.ts +45 -27
- package/dist/commands.d.ts.map +1 -1
- package/dist/crypto/crypt.d.ts +19 -4
- package/dist/crypto/crypt.d.ts.map +1 -1
- package/dist/crypto/identity.d.ts +87 -8
- package/dist/crypto/identity.d.ts.map +1 -1
- package/dist/crypto/index.d.ts +2 -1
- package/dist/crypto/index.d.ts.map +1 -1
- package/dist/diagnostics.d.ts +45 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/discovery/resolver.d.ts +21 -3
- package/dist/discovery/resolver.d.ts.map +1 -1
- package/dist/errors.d.ts +70 -16
- package/dist/errors.d.ts.map +1 -1
- package/dist/events.d.ts +4 -5
- package/dist/events.d.ts.map +1 -1
- package/dist/handshake/crypt-handshake.d.ts +1 -0
- package/dist/handshake/crypt-handshake.d.ts.map +1 -1
- package/dist/handshake/crypt-init2.d.ts.map +1 -1
- package/dist/handshake/license.d.ts +7 -0
- package/dist/handshake/license.d.ts.map +1 -1
- package/dist/handshake.d.ts +14 -5
- package/dist/handshake.d.ts.map +1 -1
- package/dist/helpers.d.ts +0 -8
- package/dist/helpers.d.ts.map +1 -1
- package/dist/index.d.ts +20 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3996 -0
- package/dist/index.js.map +1 -0
- package/dist/notifications.d.ts +7 -15
- package/dist/notifications.d.ts.map +1 -1
- package/dist/reconnect.d.ts +69 -0
- package/dist/reconnect.d.ts.map +1 -0
- package/dist/throttle.d.ts +9 -0
- package/dist/throttle.d.ts.map +1 -1
- package/dist/transfer.d.ts +27 -25
- package/dist/transfer.d.ts.map +1 -1
- package/dist/transport/generation-window.d.ts +15 -0
- package/dist/transport/generation-window.d.ts.map +1 -1
- package/dist/transport/handler.d.ts +44 -5
- package/dist/transport/handler.d.ts.map +1 -1
- package/dist/transport/quicklz.d.ts +19 -0
- package/dist/transport/quicklz.d.ts.map +1 -1
- package/dist/types.d.ts +277 -97
- package/dist/types.d.ts.map +1 -1
- package/package.json +21 -56
- package/src/abort.ts +24 -0
- package/src/address.ts +74 -0
- package/src/api/voice.ts +61 -0
- package/src/capabilities.ts +92 -0
- package/src/client.ts +1393 -0
- package/src/commands.ts +193 -0
- package/src/crypto/crypt-ops.ts +6 -0
- package/src/crypto/crypt.ts +274 -0
- package/src/crypto/eax.ts +152 -0
- package/src/crypto/identity.ts +484 -0
- package/src/crypto/index.ts +21 -0
- package/src/crypto/primitives.ts +141 -0
- package/src/diagnostics.ts +58 -0
- package/src/discovery/index.ts +1 -0
- package/src/discovery/resolver.ts +272 -0
- package/src/errors.ts +156 -0
- package/src/events.ts +28 -0
- package/src/handshake/crypt-handshake.ts +135 -0
- package/src/handshake/crypt-init2.ts +48 -0
- package/src/handshake/index.ts +3 -0
- package/src/handshake/license.fuzz.test.ts +87 -0
- package/src/handshake/license.test.ts +49 -0
- package/src/handshake/license.ts +208 -0
- package/src/handshake.ts +137 -0
- package/src/helpers.ts +10 -0
- package/src/index.ts +74 -0
- package/src/notifications.ts +136 -0
- package/src/reconnect.ts +141 -0
- package/src/throttle.ts +92 -0
- package/src/transfer.ts +127 -0
- package/src/transport/generation-window.ts +108 -0
- package/src/transport/handler.ts +917 -0
- package/src/transport/index.ts +14 -0
- package/src/transport/packet.ts +69 -0
- package/src/transport/quicklz.ts +209 -0
- package/src/types.ts +350 -0
- package/dist/address-BR-5unVo.cjs +0 -2
- package/dist/address-BR-5unVo.cjs.map +0 -1
- package/dist/address-BfmovVpp.js +0 -65
- package/dist/address-BfmovVpp.js.map +0 -1
- package/dist/api.d.ts +0 -17
- package/dist/api.d.ts.map +0 -1
- package/dist/command/command.d.ts +0 -12
- package/dist/command/command.d.ts.map +0 -1
- package/dist/command/index.cjs +0 -1
- package/dist/command/index.d.ts +0 -4
- package/dist/command/index.d.ts.map +0 -1
- package/dist/command/index.mjs +0 -3
- package/dist/command/parser.d.ts +0 -3
- package/dist/command/parser.d.ts.map +0 -1
- package/dist/command-BLe-C2L2.js +0 -74
- package/dist/command-BLe-C2L2.js.map +0 -1
- package/dist/command-DAK4pUBP.cjs +0 -4
- package/dist/command-DAK4pUBP.cjs.map +0 -1
- package/dist/crypt-handshake-CQkQreLx.cjs +0 -2
- package/dist/crypt-handshake-CQkQreLx.cjs.map +0 -1
- package/dist/crypt-handshake-DziOY4Lq.js +0 -55
- package/dist/crypt-handshake-DziOY4Lq.js.map +0 -1
- package/dist/crypt-init2-D70ha4LB.js +0 -158
- package/dist/crypt-init2-D70ha4LB.js.map +0 -1
- package/dist/crypt-init2-DparLekm.cjs +0 -2
- package/dist/crypt-init2-DparLekm.cjs.map +0 -1
- package/dist/crypto/index.cjs +0 -1
- package/dist/crypto/index.mjs +0 -3
- package/dist/crypto-Bg02m4ZE.js +0 -165
- package/dist/crypto-Bg02m4ZE.js.map +0 -1
- package/dist/crypto-CDlweUL3.cjs +0 -2
- package/dist/crypto-CDlweUL3.cjs.map +0 -1
- package/dist/discovery/index.cjs +0 -1
- package/dist/discovery/index.mjs +0 -2
- package/dist/handler-DI_abe9c.js +0 -483
- package/dist/handler-DI_abe9c.js.map +0 -1
- package/dist/handler-DuwoO5RC.cjs +0 -2
- package/dist/handler-DuwoO5RC.cjs.map +0 -1
- package/dist/handshake/index.cjs +0 -1
- package/dist/handshake/index.mjs +0 -3
- package/dist/index.cjs +0 -4
- package/dist/index.cjs.map +0 -1
- package/dist/index.mjs +0 -827
- package/dist/index.mjs.map +0 -1
- package/dist/parser-CL33R9tr.cjs +0 -2
- package/dist/parser-CL33R9tr.cjs.map +0 -1
- package/dist/parser-DZMv3Jm-.js +0 -33
- package/dist/parser-DZMv3Jm-.js.map +0 -1
- package/dist/primitives-cNNAeFCo.cjs +0 -2
- package/dist/primitives-cNNAeFCo.cjs.map +0 -1
- package/dist/primitives-xIr6Tjk4.js +0 -1457
- package/dist/primitives-xIr6Tjk4.js.map +0 -1
- package/dist/resolver-6B8dI9gr.js +0 -141
- package/dist/resolver-6B8dI9gr.js.map +0 -1
- package/dist/resolver-DCcmyWSK.cjs +0 -4
- package/dist/resolver-DCcmyWSK.cjs.map +0 -1
- package/dist/transport/index.cjs +0 -1
- package/dist/transport/index.mjs +0 -2
package/src/commands.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { parseCommand } from "@honeybbq/teamspeak-core/internal";
|
|
2
|
+
import { ServerError } from "@honeybbq/teamspeak-core";
|
|
3
|
+
import { parseErrorCode } from "@honeybbq/teamspeak-core/internal";
|
|
4
|
+
|
|
5
|
+
import type { ResponseSpec } from "@honeybbq/teamspeak-core/internal";
|
|
6
|
+
|
|
7
|
+
export interface CommandResult {
|
|
8
|
+
err: Error | null;
|
|
9
|
+
data: Record<string, string>[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A row waiting for the `error` that says which command it answered. */
|
|
13
|
+
interface BufferedRow {
|
|
14
|
+
/** The notification that carried it, or "" for a nameless data row. */
|
|
15
|
+
readonly source: string;
|
|
16
|
+
readonly params: Record<string, string>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface PendingCommand {
|
|
20
|
+
readonly resolve: (result: CommandResult) => void;
|
|
21
|
+
readonly spec: ResponseSpec | undefined;
|
|
22
|
+
/** The request's value for `spec.correlated.key`, when there is one. */
|
|
23
|
+
readonly correlation: string | undefined;
|
|
24
|
+
/** Correlated answers that arrived before the command's own `error`. */
|
|
25
|
+
readonly correlated: Record<string, string>[];
|
|
26
|
+
/** The command's `error` result, held while its correlated answer is due. */
|
|
27
|
+
settled?: CommandResult;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Matches the server's answers to the commands that asked for them.
|
|
32
|
+
*
|
|
33
|
+
* The server handles one client's commands in order and closes each answer
|
|
34
|
+
* with `error … return_code=N`, so every row seen since the previous `error`
|
|
35
|
+
* belongs to command N. Rows are therefore held, tagged with where they came
|
|
36
|
+
* from, until that `error` says whose they are — attributing them on arrival
|
|
37
|
+
* would mean guessing which in-flight command the server is answering, and a
|
|
38
|
+
* command can be registered here well before it is sent (it first waits on the
|
|
39
|
+
* throttle and the middleware chain).
|
|
40
|
+
*
|
|
41
|
+
* A nameless row always belongs to the command. A notification row belongs to
|
|
42
|
+
* it only if the command declared that notification as its answer (see
|
|
43
|
+
* command/responses.ts): the same `notifyclientmoved` that answers nothing can
|
|
44
|
+
* arrive between any two commands.
|
|
45
|
+
*
|
|
46
|
+
* The welcome sequence is the exception. The server pushes channellist,
|
|
47
|
+
* channelgrouplist and the rest unprompted right after the handshake, before
|
|
48
|
+
* `channellistfinished`; without the gate they would land in the first
|
|
49
|
+
* command's answer as duplicate rows and a phantom cid=0.
|
|
50
|
+
*/
|
|
51
|
+
export class CommandTracker {
|
|
52
|
+
readonly #pending = new Map<number, PendingCommand>();
|
|
53
|
+
readonly #ownClientID: () => number;
|
|
54
|
+
#nextRC = 0;
|
|
55
|
+
#buffer: BufferedRow[] = [];
|
|
56
|
+
#welcomeComplete = false;
|
|
57
|
+
|
|
58
|
+
/** @param ownClientID the client's clid, for answers filtered by invoker. */
|
|
59
|
+
constructor(ownClientID: () => number = () => 0) {
|
|
60
|
+
this.#ownClientID = ownClientID;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
register(
|
|
64
|
+
spec?: ResponseSpec,
|
|
65
|
+
correlation?: string,
|
|
66
|
+
): [rc: number, promise: Promise<CommandResult>] {
|
|
67
|
+
this.#nextRC++;
|
|
68
|
+
const rc = this.#nextRC;
|
|
69
|
+
const promise = new Promise<CommandResult>((resolve) => {
|
|
70
|
+
this.#pending.set(rc, { resolve, spec, correlation, correlated: [] });
|
|
71
|
+
});
|
|
72
|
+
return [rc, promise];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
unregister(rc: number): void {
|
|
76
|
+
this.#pending.delete(rc);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Marks the end of the welcome sequence and drops what it left behind. */
|
|
80
|
+
signalWelcomeComplete(): void {
|
|
81
|
+
this.#welcomeComplete = true;
|
|
82
|
+
this.#buffer = [];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Whether some in-flight command declared `name` as its answer. */
|
|
86
|
+
expects(name: string): boolean {
|
|
87
|
+
for (const p of this.#pending.values()) {
|
|
88
|
+
if (p.spec?.notifications?.includes(name)) return true;
|
|
89
|
+
if (p.spec?.correlated?.notifications.includes(name)) return true;
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Hold a row until the next `error` says which command it answered. */
|
|
95
|
+
buffer(params: Record<string, string>, source = ""): void {
|
|
96
|
+
if (!this.#welcomeComplete) return;
|
|
97
|
+
if (this.#pending.size === 0) return;
|
|
98
|
+
this.#buffer.push({ source, params });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Offer a notification to commands answered after their `error`.
|
|
103
|
+
*
|
|
104
|
+
* Matching is on the echoed request field, so it does not matter whether the
|
|
105
|
+
* notification beats the `error` or follows it.
|
|
106
|
+
*/
|
|
107
|
+
offerCorrelated(name: string, params: Record<string, string>): void {
|
|
108
|
+
for (const [rc, p] of this.#pending) {
|
|
109
|
+
const c = p.spec?.correlated;
|
|
110
|
+
if (!c || p.correlation === undefined || !c.notifications.includes(name)) continue;
|
|
111
|
+
if (params[c.key] !== p.correlation) continue;
|
|
112
|
+
|
|
113
|
+
if (name === c.failure && (params["status"] ?? "0") !== "0") {
|
|
114
|
+
const err = new ServerError(parseErrorCode(params["status"]), params["msg"] ?? "", {
|
|
115
|
+
extraMessage: params["extra_msg"] || undefined,
|
|
116
|
+
});
|
|
117
|
+
this.#settle(rc, p, { err, data: [] });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
p.correlated.push(params);
|
|
121
|
+
if (p.settled) this.#settle(rc, p, { err: null, data: [...p.settled.data, ...p.correlated] });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
resolve(rc: number, err: Error | null): void {
|
|
127
|
+
const rows = this.#buffer;
|
|
128
|
+
this.#buffer = [];
|
|
129
|
+
const p = this.#pending.get(rc);
|
|
130
|
+
if (!p) return;
|
|
131
|
+
|
|
132
|
+
const data = rows.filter((row) => this.#answers(p, row)).map((row) => row.params);
|
|
133
|
+
if (err) {
|
|
134
|
+
this.#settle(rc, p, { err, data });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
// Answered after the error: hold the result until the answer arrives, or
|
|
138
|
+
// the caller's timeout gives up on it.
|
|
139
|
+
if (p.spec?.correlated && p.correlation !== undefined && p.correlated.length === 0) {
|
|
140
|
+
p.settled = { err: null, data };
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
this.#settle(rc, p, { err: null, data: [...data, ...p.correlated] });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
discardBuffer(): void {
|
|
147
|
+
this.#buffer = [];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Forget every command and fail it with `err`.
|
|
152
|
+
*
|
|
153
|
+
* Dropping them silently would leave each caller waiting out its full
|
|
154
|
+
* timeout for an answer that can no longer come.
|
|
155
|
+
*/
|
|
156
|
+
reset(err: Error): void {
|
|
157
|
+
const pending = [...this.#pending.values()];
|
|
158
|
+
this.#pending.clear();
|
|
159
|
+
for (const p of pending) p.resolve({ err, data: [] });
|
|
160
|
+
this.#buffer = [];
|
|
161
|
+
this.#welcomeComplete = false;
|
|
162
|
+
this.#nextRC = 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#answers(p: PendingCommand, row: BufferedRow): boolean {
|
|
166
|
+
if (row.source === "") return true;
|
|
167
|
+
if (!p.spec?.notifications?.includes(row.source)) return false;
|
|
168
|
+
if (!p.spec.ownRowsOnly) return true;
|
|
169
|
+
return row.params["invokerid"] === String(this.#ownClientID());
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#settle(rc: number, p: PendingCommand, result: CommandResult): void {
|
|
173
|
+
this.#pending.delete(rc);
|
|
174
|
+
p.resolve(result);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Append the tracker's return_code to a command.
|
|
180
|
+
*
|
|
181
|
+
* The return_code belongs to the tracker: it is how an answer finds its
|
|
182
|
+
* command, so a caller-supplied one would detach the command from its answer.
|
|
183
|
+
* Checked as a parameter rather than as text, because a message body that
|
|
184
|
+
* merely contains "return_code=" used to suppress the real one and time the
|
|
185
|
+
* command out.
|
|
186
|
+
*/
|
|
187
|
+
export function appendReturnCode(cmd: string, rc: number): string {
|
|
188
|
+
const params = parseCommand(cmd)?.params ?? {};
|
|
189
|
+
if (Object.hasOwn(params, "return_code")) {
|
|
190
|
+
throw new TypeError("commands must not carry their own return_code; the client assigns it");
|
|
191
|
+
}
|
|
192
|
+
return `${cmd} return_code=${rc}`;
|
|
193
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { createHash, createECDH, randomBytes } from "node:crypto";
|
|
2
|
+
import { CryptoInitError, FakeSignatureMismatchError } from "../errors.js";
|
|
3
|
+
import type { Identity } from "./identity.js";
|
|
4
|
+
import { identityPrivateKey, importPublicKey } from "./identity.js";
|
|
5
|
+
import { EAX } from "./eax.js";
|
|
6
|
+
|
|
7
|
+
const FAKE_SIGNATURE_SIZE = 8;
|
|
8
|
+
const IV_ALPHA_SIZE = 10;
|
|
9
|
+
const INIT1_PACKET_TYPE = 8;
|
|
10
|
+
const PACKET_TYPE_MASK = 0x0f;
|
|
11
|
+
|
|
12
|
+
const INIT1_MAC = Buffer.from("TS3INIT1");
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The most squarings the Init1 puzzle may ask for. TS3 3.13 and TS6 6.0 both
|
|
16
|
+
* ask for 10000; a level far beyond that is a server trying to pin the event
|
|
17
|
+
* loop, since the squarings run synchronously.
|
|
18
|
+
*/
|
|
19
|
+
export const MAX_RSA_CHALLENGE_LEVEL: number = 100_000;
|
|
20
|
+
|
|
21
|
+
export interface KeyNonce {
|
|
22
|
+
key: Uint8Array;
|
|
23
|
+
nonce: Uint8Array;
|
|
24
|
+
gen: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Precomputed dummy key/nonce matching the TS3 client pre-crypto placeholder. */
|
|
28
|
+
const DUMMY_KEY = Buffer.from("c:\\windows\\syste");
|
|
29
|
+
const DUMMY_NONCE = Buffer.from("m\\firewall32.cpl");
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A connection's encryption state. The session material — the IV the keys
|
|
33
|
+
* derive from — lives in private fields, so it shows in no `util.inspect()`
|
|
34
|
+
* or `JSON.stringify()` of anything holding a Crypt.
|
|
35
|
+
*/
|
|
36
|
+
export class Crypt {
|
|
37
|
+
readonly identity: Identity;
|
|
38
|
+
|
|
39
|
+
#ivStruct: Uint8Array = new Uint8Array(0);
|
|
40
|
+
#fakeSignature: Uint8Array = new Uint8Array(FAKE_SIGNATURE_SIZE);
|
|
41
|
+
#alpha: Uint8Array = new Uint8Array(0);
|
|
42
|
+
#cryptoInitComplete = false;
|
|
43
|
+
|
|
44
|
+
readonly #cachedKeys = new Map<bigint, KeyNonce>();
|
|
45
|
+
|
|
46
|
+
constructor(identity: Identity) {
|
|
47
|
+
this.identity = identity;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Whether the session keys are set up; until then packets use the dummy key. */
|
|
51
|
+
get cryptoInitComplete(): boolean {
|
|
52
|
+
return this.#cryptoInitComplete;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The signature unencrypted packets carry: zeros until crypto init completes. */
|
|
56
|
+
get fakeSignature(): Uint8Array {
|
|
57
|
+
return this.#fakeSignature;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The client's random contribution to the IV, sent in clientinitiv; empty before. */
|
|
61
|
+
get alpha(): Uint8Array {
|
|
62
|
+
return this.#alpha;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Choose the client's random contribution to the IV, and return it. */
|
|
66
|
+
newAlpha(): Uint8Array {
|
|
67
|
+
this.#alpha = new Uint8Array(randomBytes(IV_ALPHA_SIZE));
|
|
68
|
+
return this.#alpha;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
solveRsaChallenge(data: Uint8Array, offset: number, level: number): Uint8Array {
|
|
72
|
+
if (!Number.isInteger(level) || level < 0 || level > MAX_RSA_CHALLENGE_LEVEL) {
|
|
73
|
+
throw new CryptoInitError(`RSA challenge level ${level} out of range`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const xBytes = data.slice(offset, offset + 64);
|
|
77
|
+
const nBytes = data.slice(offset + 64, offset + 128);
|
|
78
|
+
|
|
79
|
+
let y = bytesToBigInt(xBytes);
|
|
80
|
+
const n = bytesToBigInt(nBytes);
|
|
81
|
+
if (n === 0n) throw new CryptoInitError("RSA challenge modulus is zero");
|
|
82
|
+
|
|
83
|
+
for (let i = 0; i < level; i++) {
|
|
84
|
+
y = (y * y) % n;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return bigIntToBytes(y, 64);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
initCrypto(alpha: string, beta: string, omega: string): void {
|
|
91
|
+
const alphaBytes = Buffer.from(alpha, "base64");
|
|
92
|
+
const betaBytes = Buffer.from(beta, "base64");
|
|
93
|
+
const omegaBytes = Buffer.from(omega, "base64");
|
|
94
|
+
|
|
95
|
+
let sharedSecret: Uint8Array;
|
|
96
|
+
try {
|
|
97
|
+
sharedSecret = this.#getSharedSecret(importPublicKey(omegaBytes));
|
|
98
|
+
} catch (error) {
|
|
99
|
+
throw new CryptoInitError("the server's public key is invalid", { cause: error });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
this.setSharedSecret(alphaBytes, betaBytes, sharedSecret);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
setSharedSecret(alpha: Uint8Array, beta: Uint8Array, sharedKey: Uint8Array): void {
|
|
106
|
+
this.#ivStruct = new Uint8Array(IV_ALPHA_SIZE + beta.length);
|
|
107
|
+
for (let i = 0; i < IV_ALPHA_SIZE; i++) {
|
|
108
|
+
const a = sharedKey[i];
|
|
109
|
+
const b = alpha[i];
|
|
110
|
+
this.#ivStruct[i] = ((a !== undefined ? a : 0) ^ (b !== undefined ? b : 0)) & 0xff;
|
|
111
|
+
}
|
|
112
|
+
for (let i = 0; i < beta.length; i++) {
|
|
113
|
+
const a = sharedKey[IV_ALPHA_SIZE + i];
|
|
114
|
+
const b = beta[i];
|
|
115
|
+
this.#ivStruct[IV_ALPHA_SIZE + i] =
|
|
116
|
+
((a !== undefined ? a : 0) ^ (b !== undefined ? b : 0)) & 0xff;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Keys derive from the IV: any cached before it changed belong to another
|
|
120
|
+
// IV, the empty one before crypto init in particular, which is public.
|
|
121
|
+
this.#cachedKeys.clear();
|
|
122
|
+
|
|
123
|
+
const digest = createHash("sha1").update(this.#ivStruct).digest();
|
|
124
|
+
this.#fakeSignature = new Uint8Array(digest.buffer, digest.byteOffset, FAKE_SIGNATURE_SIZE);
|
|
125
|
+
this.#cryptoInitComplete = true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
getKeyNonce(
|
|
129
|
+
fromServer: boolean,
|
|
130
|
+
packetID: number,
|
|
131
|
+
generationID: number,
|
|
132
|
+
packetType: number,
|
|
133
|
+
dummy: boolean,
|
|
134
|
+
): [key: Uint8Array, nonce: Uint8Array] {
|
|
135
|
+
if (dummy) {
|
|
136
|
+
return [Uint8Array.from(DUMMY_KEY), Uint8Array.from(DUMMY_NONCE)];
|
|
137
|
+
}
|
|
138
|
+
// Before crypto init there is no IV, and a key derived from the empty one
|
|
139
|
+
// is a key anyone can compute.
|
|
140
|
+
if (!this.#cryptoInitComplete) {
|
|
141
|
+
throw new CryptoInitError("no session key before crypto init completes");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const cacheKey = makeCacheKey(fromServer, packetType, generationID);
|
|
145
|
+
let kn = this.#cachedKeys.get(cacheKey);
|
|
146
|
+
|
|
147
|
+
if (kn === undefined) {
|
|
148
|
+
const tmpToHash = new Uint8Array(6 + this.#ivStruct.length);
|
|
149
|
+
tmpToHash[0] = fromServer ? 0x30 : 0x31;
|
|
150
|
+
tmpToHash[1] = packetType & PACKET_TYPE_MASK;
|
|
151
|
+
new DataView(tmpToHash.buffer).setUint32(2, generationID, false);
|
|
152
|
+
tmpToHash.set(this.#ivStruct, 6);
|
|
153
|
+
|
|
154
|
+
const hash = createHash("sha256").update(tmpToHash).digest();
|
|
155
|
+
kn = {
|
|
156
|
+
key: Uint8Array.from(hash.slice(0, 16)),
|
|
157
|
+
nonce: Uint8Array.from(hash.slice(16, 32)),
|
|
158
|
+
gen: generationID,
|
|
159
|
+
};
|
|
160
|
+
this.#cachedKeys.set(cacheKey, kn);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const key = Uint8Array.from(kn.key);
|
|
164
|
+
key[0] = (key[0] !== undefined ? key[0] : 0) ^ ((packetID >> 8) & 0xff);
|
|
165
|
+
key[1] = (key[1] !== undefined ? key[1] : 0) ^ (packetID & 0xff);
|
|
166
|
+
|
|
167
|
+
return [key, kn.nonce];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
encrypt(
|
|
171
|
+
packetType: number,
|
|
172
|
+
packetID: number,
|
|
173
|
+
generationID: number,
|
|
174
|
+
header: Uint8Array,
|
|
175
|
+
plaintext: Uint8Array,
|
|
176
|
+
dummy: boolean,
|
|
177
|
+
unencrypted: boolean,
|
|
178
|
+
): [ciphertext: Uint8Array, mac: Uint8Array] {
|
|
179
|
+
if (packetType === INIT1_PACKET_TYPE) {
|
|
180
|
+
return [plaintext, INIT1_MAC];
|
|
181
|
+
}
|
|
182
|
+
if (unencrypted) {
|
|
183
|
+
return [plaintext, this.#fakeSignature];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const [key, nonce] = this.getKeyNonce(false, packetID, generationID, packetType, dummy);
|
|
187
|
+
const eax = new EAX(key);
|
|
188
|
+
return eax.encrypt(nonce, header, plaintext);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
decrypt(
|
|
192
|
+
packetType: number,
|
|
193
|
+
packetID: number,
|
|
194
|
+
generationID: number,
|
|
195
|
+
header: Uint8Array,
|
|
196
|
+
ciphertext: Uint8Array,
|
|
197
|
+
tag: Uint8Array,
|
|
198
|
+
dummy: boolean,
|
|
199
|
+
unencrypted: boolean,
|
|
200
|
+
): Uint8Array {
|
|
201
|
+
if (packetType === INIT1_PACKET_TYPE) {
|
|
202
|
+
return ciphertext;
|
|
203
|
+
}
|
|
204
|
+
if (unencrypted) {
|
|
205
|
+
const fsSub = tag.slice(0, FAKE_SIGNATURE_SIZE);
|
|
206
|
+
if (!Buffer.from(fsSub).equals(Buffer.from(this.#fakeSignature))) {
|
|
207
|
+
throw new FakeSignatureMismatchError();
|
|
208
|
+
}
|
|
209
|
+
return ciphertext;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const [key, nonce] = this.getKeyNonce(true, packetID, generationID, packetType, dummy);
|
|
213
|
+
const eax = new EAX(key);
|
|
214
|
+
return eax.decrypt(nonce, header, ciphertext, tag);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
#getSharedSecret(serverPubKey: import("node:crypto").KeyObject): Uint8Array {
|
|
218
|
+
const privJwk = identityPrivateKey(this.identity).export({ format: "jwk" }) as {
|
|
219
|
+
d: string;
|
|
220
|
+
};
|
|
221
|
+
const pubJwk = serverPubKey.export({ format: "jwk" }) as {
|
|
222
|
+
x: string;
|
|
223
|
+
y: string;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const ecdh = createECDH("prime256v1");
|
|
227
|
+
const dBytes = Buffer.from(privJwk.d, "base64url");
|
|
228
|
+
ecdh.setPrivateKey(dBytes);
|
|
229
|
+
|
|
230
|
+
const xBytes = base64UrlToBytes(pubJwk.x, 32);
|
|
231
|
+
const yBytes = base64UrlToBytes(pubJwk.y, 32);
|
|
232
|
+
const uncompressed = Buffer.alloc(65);
|
|
233
|
+
uncompressed[0] = 0x04;
|
|
234
|
+
xBytes.copy(uncompressed, 1);
|
|
235
|
+
yBytes.copy(uncompressed, 33);
|
|
236
|
+
|
|
237
|
+
const rawSecret = ecdh.computeSecret(uncompressed);
|
|
238
|
+
return createHash("sha1").update(rawSecret).digest();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---- Helpers ----------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
function makeCacheKey(fromServer: boolean, packetType: number, generationID: number): bigint {
|
|
245
|
+
let key = 0n;
|
|
246
|
+
if (fromServer) key |= 1n << 40n;
|
|
247
|
+
key |= BigInt(packetType & PACKET_TYPE_MASK) << 32n;
|
|
248
|
+
key |= BigInt(generationID);
|
|
249
|
+
return key;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function bytesToBigInt(bytes: Uint8Array): bigint {
|
|
253
|
+
let result = 0n;
|
|
254
|
+
for (const b of bytes) result = (result << 8n) | BigInt(b);
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function bigIntToBytes(value: bigint, size: number): Uint8Array {
|
|
259
|
+
const result = new Uint8Array(size);
|
|
260
|
+
let v = value;
|
|
261
|
+
for (let i = size - 1; i >= 0; i--) {
|
|
262
|
+
result[i] = Number(v & 0xffn);
|
|
263
|
+
v >>= 8n;
|
|
264
|
+
}
|
|
265
|
+
return result;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function base64UrlToBytes(b64url: string, size: number): Buffer {
|
|
269
|
+
const buf = Buffer.from(b64url, "base64url");
|
|
270
|
+
if (buf.length === size) return buf;
|
|
271
|
+
const padded = Buffer.alloc(size);
|
|
272
|
+
buf.copy(padded, size - buf.length);
|
|
273
|
+
return padded;
|
|
274
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { createCipheriv, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { EAXTagMismatchError } from "../errors.js";
|
|
3
|
+
|
|
4
|
+
const EAX_TAG_SIZE = 8;
|
|
5
|
+
const EAX_BLOCK_SIZE = 16;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* AES-EAX AEAD implementation for TS3 (64-bit tag, AES-128).
|
|
9
|
+
* Uses AES-CMAC for authentication and AES-CTR for encryption.
|
|
10
|
+
*/
|
|
11
|
+
export class EAX {
|
|
12
|
+
readonly #key: Uint8Array;
|
|
13
|
+
|
|
14
|
+
constructor(key: Uint8Array) {
|
|
15
|
+
if (key.length !== 16) throw new Error("EAX requires a 16-byte key");
|
|
16
|
+
this.#key = key;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
encrypt(
|
|
20
|
+
nonce: Uint8Array,
|
|
21
|
+
header: Uint8Array,
|
|
22
|
+
plaintext: Uint8Array,
|
|
23
|
+
): [ciphertext: Uint8Array, tag: Uint8Array] {
|
|
24
|
+
const nStar = this.#cmac(0, nonce);
|
|
25
|
+
const hStar = this.#cmac(1, header);
|
|
26
|
+
|
|
27
|
+
const ciphertext = aesCtr(this.#key, nStar, plaintext);
|
|
28
|
+
|
|
29
|
+
const cStar = this.#cmac(2, ciphertext);
|
|
30
|
+
|
|
31
|
+
const tag = new Uint8Array(EAX_TAG_SIZE);
|
|
32
|
+
for (let i = 0; i < EAX_TAG_SIZE; i++) {
|
|
33
|
+
tag[i] = nStar[i]! ^ hStar[i]! ^ cStar[i]!;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return [ciphertext, tag];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
decrypt(
|
|
40
|
+
nonce: Uint8Array,
|
|
41
|
+
header: Uint8Array,
|
|
42
|
+
ciphertext: Uint8Array,
|
|
43
|
+
tag: Uint8Array,
|
|
44
|
+
): Uint8Array {
|
|
45
|
+
const nStar = this.#cmac(0, nonce);
|
|
46
|
+
const hStar = this.#cmac(1, header);
|
|
47
|
+
const cStar = this.#cmac(2, ciphertext);
|
|
48
|
+
|
|
49
|
+
const expected = new Uint8Array(EAX_TAG_SIZE);
|
|
50
|
+
for (let i = 0; i < EAX_TAG_SIZE; i++) {
|
|
51
|
+
expected[i] = nStar[i]! ^ hStar[i]! ^ cStar[i]!;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!timingSafeEqual(Buffer.from(expected), Buffer.from(tag.slice(0, EAX_TAG_SIZE)))) {
|
|
55
|
+
throw new EAXTagMismatchError();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return aesCtr(this.#key, nStar, ciphertext);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* AES-CMAC per RFC 4493:
|
|
63
|
+
* input = [0…0 tag] ++ data (one block prefix)
|
|
64
|
+
*/
|
|
65
|
+
#cmac(tagByte: number, data: Uint8Array): Uint8Array {
|
|
66
|
+
const input = new Uint8Array(EAX_BLOCK_SIZE + data.length);
|
|
67
|
+
// first block: zeros then tag byte at the last position
|
|
68
|
+
input[EAX_BLOCK_SIZE - 1] = tagByte;
|
|
69
|
+
input.set(data, EAX_BLOCK_SIZE);
|
|
70
|
+
|
|
71
|
+
return aesCmac(this.#key, input);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---- AES-CTR ----------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
function aesCtr(key: Uint8Array, iv: Uint8Array, data: Uint8Array): Uint8Array {
|
|
78
|
+
const cipher = createCipheriv("aes-128-ctr", Buffer.from(key), Buffer.from(iv));
|
|
79
|
+
return Buffer.concat([cipher.update(Buffer.from(data)), cipher.final()]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---- AES-CMAC (RFC 4493) ----------------------------------------------------
|
|
83
|
+
|
|
84
|
+
function aesEcb(key: Uint8Array, block: Uint8Array): Uint8Array {
|
|
85
|
+
// Node.js does not expose ECB directly; simulate via CTR with zero IV and
|
|
86
|
+
// a zero-input (CTR of 0-block XOR with input = ECB)
|
|
87
|
+
const cipher = createCipheriv("aes-128-ecb", Buffer.from(key), null);
|
|
88
|
+
cipher.setAutoPadding(false);
|
|
89
|
+
return Buffer.concat([cipher.update(Buffer.from(block)), cipher.final()]);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function xorBlock(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
93
|
+
const out = new Uint8Array(EAX_BLOCK_SIZE);
|
|
94
|
+
for (let i = 0; i < EAX_BLOCK_SIZE; i++) out[i] = a[i]! ^ b[i]!;
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function shiftLeft1(b: Uint8Array): Uint8Array {
|
|
99
|
+
const out = new Uint8Array(EAX_BLOCK_SIZE);
|
|
100
|
+
let carry = 0;
|
|
101
|
+
for (let i = EAX_BLOCK_SIZE - 1; i >= 0; i--) {
|
|
102
|
+
const shifted = ((b[i]! << 1) | carry) & 0xff;
|
|
103
|
+
carry = b[i]! >> 7;
|
|
104
|
+
out[i] = shifted;
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const RB = new Uint8Array(EAX_BLOCK_SIZE);
|
|
110
|
+
RB[EAX_BLOCK_SIZE - 1] = 0x87;
|
|
111
|
+
|
|
112
|
+
function generateSubkeys(key: Uint8Array): [Uint8Array, Uint8Array] {
|
|
113
|
+
const zero = new Uint8Array(EAX_BLOCK_SIZE);
|
|
114
|
+
const L = aesEcb(key, zero);
|
|
115
|
+
|
|
116
|
+
let K1 = shiftLeft1(L);
|
|
117
|
+
if ((L[0]! & 0x80) !== 0) K1 = xorBlock(K1, RB);
|
|
118
|
+
|
|
119
|
+
let K2 = shiftLeft1(K1);
|
|
120
|
+
if ((K1[0]! & 0x80) !== 0) K2 = xorBlock(K2, RB);
|
|
121
|
+
|
|
122
|
+
return [K1, K2];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function aesCmac(key: Uint8Array, message: Uint8Array): Uint8Array {
|
|
126
|
+
const [K1, K2] = generateSubkeys(key);
|
|
127
|
+
|
|
128
|
+
const n = Math.max(1, Math.ceil(message.length / EAX_BLOCK_SIZE));
|
|
129
|
+
const lastBlockComplete = message.length > 0 && message.length % EAX_BLOCK_SIZE === 0;
|
|
130
|
+
|
|
131
|
+
let X: Uint8Array = new Uint8Array(EAX_BLOCK_SIZE);
|
|
132
|
+
|
|
133
|
+
for (let i = 0; i < n - 1; i++) {
|
|
134
|
+
const block = message.slice(i * EAX_BLOCK_SIZE, (i + 1) * EAX_BLOCK_SIZE);
|
|
135
|
+
X = Uint8Array.from(aesEcb(key, xorBlock(X, block)));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const lastBlock = new Uint8Array(EAX_BLOCK_SIZE);
|
|
139
|
+
const lastStart = (n - 1) * EAX_BLOCK_SIZE;
|
|
140
|
+
const lastSlice = message.slice(lastStart);
|
|
141
|
+
lastBlock.set(lastSlice);
|
|
142
|
+
|
|
143
|
+
let Mn: Uint8Array;
|
|
144
|
+
if (lastBlockComplete) {
|
|
145
|
+
Mn = xorBlock(lastBlock, K1);
|
|
146
|
+
} else {
|
|
147
|
+
if (lastSlice.length < EAX_BLOCK_SIZE) lastBlock[lastSlice.length] = 0x80;
|
|
148
|
+
Mn = xorBlock(lastBlock, K2);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return Uint8Array.from(aesEcb(key, xorBlock(X, Mn)));
|
|
152
|
+
}
|