@honeybbq/teamspeak-client 0.3.0 → 1.0.0-beta.1

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.
Files changed (178) hide show
  1. package/README.md +322 -60
  2. package/dist/abort.d.ts +5 -0
  3. package/dist/abort.d.ts.map +1 -0
  4. package/dist/address.d.ts +8 -0
  5. package/dist/address.d.ts.map +1 -1
  6. package/dist/api/voice.d.ts +24 -0
  7. package/dist/api/voice.d.ts.map +1 -0
  8. package/dist/capabilities.d.ts +62 -0
  9. package/dist/capabilities.d.ts.map +1 -0
  10. package/dist/client.d.ts +172 -39
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/commands.d.ts +45 -27
  13. package/dist/commands.d.ts.map +1 -1
  14. package/dist/crypto/crypt.d.ts +19 -4
  15. package/dist/crypto/crypt.d.ts.map +1 -1
  16. package/dist/crypto/eax.d.ts.map +1 -1
  17. package/dist/crypto/identity.d.ts +87 -8
  18. package/dist/crypto/identity.d.ts.map +1 -1
  19. package/dist/crypto/index.d.ts +2 -1
  20. package/dist/crypto/index.d.ts.map +1 -1
  21. package/dist/diagnostics.d.ts +45 -0
  22. package/dist/diagnostics.d.ts.map +1 -0
  23. package/dist/discovery/resolver.d.ts +21 -3
  24. package/dist/discovery/resolver.d.ts.map +1 -1
  25. package/dist/errors.d.ts +70 -16
  26. package/dist/errors.d.ts.map +1 -1
  27. package/dist/events.d.ts +4 -5
  28. package/dist/events.d.ts.map +1 -1
  29. package/dist/handshake/crypt-handshake.d.ts +1 -0
  30. package/dist/handshake/crypt-handshake.d.ts.map +1 -1
  31. package/dist/handshake/crypt-init2.d.ts.map +1 -1
  32. package/dist/handshake/license.d.ts +7 -0
  33. package/dist/handshake/license.d.ts.map +1 -1
  34. package/dist/handshake.d.ts +14 -5
  35. package/dist/handshake.d.ts.map +1 -1
  36. package/dist/helpers.d.ts +0 -8
  37. package/dist/helpers.d.ts.map +1 -1
  38. package/dist/index.d.ts +20 -6
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +3967 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/notifications.d.ts +7 -15
  43. package/dist/notifications.d.ts.map +1 -1
  44. package/dist/reconnect.d.ts +69 -0
  45. package/dist/reconnect.d.ts.map +1 -0
  46. package/dist/throttle.d.ts +9 -0
  47. package/dist/throttle.d.ts.map +1 -1
  48. package/dist/transfer.d.ts +27 -25
  49. package/dist/transfer.d.ts.map +1 -1
  50. package/dist/transport/generation-window.d.ts.map +1 -1
  51. package/dist/transport/handler.d.ts +37 -5
  52. package/dist/transport/handler.d.ts.map +1 -1
  53. package/dist/transport/quicklz.d.ts +19 -0
  54. package/dist/transport/quicklz.d.ts.map +1 -1
  55. package/dist/types.d.ts +277 -97
  56. package/dist/types.d.ts.map +1 -1
  57. package/package.json +21 -57
  58. package/src/abort.ts +24 -0
  59. package/src/address.ts +74 -0
  60. package/src/api/voice.ts +61 -0
  61. package/src/capabilities.ts +92 -0
  62. package/src/client.ts +1392 -0
  63. package/src/commands.ts +193 -0
  64. package/src/crypto/crypt-ops.ts +6 -0
  65. package/src/crypto/crypt.ts +274 -0
  66. package/src/crypto/eax.ts +152 -0
  67. package/src/crypto/identity.ts +484 -0
  68. package/src/crypto/index.ts +21 -0
  69. package/src/crypto/primitives.ts +141 -0
  70. package/src/diagnostics.ts +58 -0
  71. package/src/discovery/index.ts +1 -0
  72. package/src/discovery/resolver.ts +272 -0
  73. package/src/errors.ts +156 -0
  74. package/src/events.ts +28 -0
  75. package/src/handshake/crypt-handshake.ts +135 -0
  76. package/src/handshake/crypt-init2.ts +48 -0
  77. package/src/handshake/index.ts +3 -0
  78. package/src/handshake/license.test.ts +49 -0
  79. package/src/handshake/license.ts +208 -0
  80. package/src/handshake.ts +136 -0
  81. package/src/helpers.ts +10 -0
  82. package/src/index.ts +74 -0
  83. package/src/notifications.ts +136 -0
  84. package/src/reconnect.ts +141 -0
  85. package/src/throttle.ts +92 -0
  86. package/src/transfer.ts +127 -0
  87. package/src/transport/generation-window.ts +77 -0
  88. package/src/transport/handler.ts +914 -0
  89. package/src/transport/index.ts +14 -0
  90. package/src/transport/packet.ts +69 -0
  91. package/src/transport/quicklz.ts +209 -0
  92. package/src/types.ts +349 -0
  93. package/dist/address-BR-5unVo.cjs +0 -2
  94. package/dist/address-BR-5unVo.cjs.map +0 -1
  95. package/dist/address-BfmovVpp.js +0 -65
  96. package/dist/address-BfmovVpp.js.map +0 -1
  97. package/dist/address.test.d.ts +0 -2
  98. package/dist/address.test.d.ts.map +0 -1
  99. package/dist/api.d.ts +0 -17
  100. package/dist/api.d.ts.map +0 -1
  101. package/dist/api.test.d.ts +0 -2
  102. package/dist/api.test.d.ts.map +0 -1
  103. package/dist/command/command.d.ts +0 -12
  104. package/dist/command/command.d.ts.map +0 -1
  105. package/dist/command/command.test.d.ts +0 -2
  106. package/dist/command/command.test.d.ts.map +0 -1
  107. package/dist/command/index.cjs +0 -1
  108. package/dist/command/index.d.ts +0 -4
  109. package/dist/command/index.d.ts.map +0 -1
  110. package/dist/command/index.mjs +0 -3
  111. package/dist/command/parser.d.ts +0 -3
  112. package/dist/command/parser.d.ts.map +0 -1
  113. package/dist/command-BLe-C2L2.js +0 -74
  114. package/dist/command-BLe-C2L2.js.map +0 -1
  115. package/dist/command-DAK4pUBP.cjs +0 -4
  116. package/dist/command-DAK4pUBP.cjs.map +0 -1
  117. package/dist/crypt-handshake-CQkQreLx.cjs +0 -2
  118. package/dist/crypt-handshake-CQkQreLx.cjs.map +0 -1
  119. package/dist/crypt-handshake-DziOY4Lq.js +0 -55
  120. package/dist/crypt-handshake-DziOY4Lq.js.map +0 -1
  121. package/dist/crypt-init2-D70ha4LB.js +0 -158
  122. package/dist/crypt-init2-D70ha4LB.js.map +0 -1
  123. package/dist/crypt-init2-DparLekm.cjs +0 -2
  124. package/dist/crypt-init2-DparLekm.cjs.map +0 -1
  125. package/dist/crypto/eax.test.d.ts +0 -2
  126. package/dist/crypto/eax.test.d.ts.map +0 -1
  127. package/dist/crypto/identity.test.d.ts +0 -2
  128. package/dist/crypto/identity.test.d.ts.map +0 -1
  129. package/dist/crypto/index.cjs +0 -1
  130. package/dist/crypto/index.mjs +0 -3
  131. package/dist/crypto-Bg02m4ZE.js +0 -165
  132. package/dist/crypto-Bg02m4ZE.js.map +0 -1
  133. package/dist/crypto-CDlweUL3.cjs +0 -2
  134. package/dist/crypto-CDlweUL3.cjs.map +0 -1
  135. package/dist/discovery/index.cjs +0 -1
  136. package/dist/discovery/index.mjs +0 -2
  137. package/dist/handler-DI_abe9c.js +0 -483
  138. package/dist/handler-DI_abe9c.js.map +0 -1
  139. package/dist/handler-DuwoO5RC.cjs +0 -2
  140. package/dist/handler-DuwoO5RC.cjs.map +0 -1
  141. package/dist/handshake/index.cjs +0 -1
  142. package/dist/handshake/index.mjs +0 -3
  143. package/dist/handshake.test.d.ts +0 -2
  144. package/dist/handshake.test.d.ts.map +0 -1
  145. package/dist/helpers.test.d.ts +0 -2
  146. package/dist/helpers.test.d.ts.map +0 -1
  147. package/dist/index.cjs +0 -4
  148. package/dist/index.cjs.map +0 -1
  149. package/dist/index.mjs +0 -796
  150. package/dist/index.mjs.map +0 -1
  151. package/dist/integration.test.d.ts +0 -12
  152. package/dist/integration.test.d.ts.map +0 -1
  153. package/dist/notifications.test.d.ts +0 -2
  154. package/dist/notifications.test.d.ts.map +0 -1
  155. package/dist/parser-CL33R9tr.cjs +0 -2
  156. package/dist/parser-CL33R9tr.cjs.map +0 -1
  157. package/dist/parser-DZMv3Jm-.js +0 -33
  158. package/dist/parser-DZMv3Jm-.js.map +0 -1
  159. package/dist/primitives-cNNAeFCo.cjs +0 -2
  160. package/dist/primitives-cNNAeFCo.cjs.map +0 -1
  161. package/dist/primitives-xIr6Tjk4.js +0 -1457
  162. package/dist/primitives-xIr6Tjk4.js.map +0 -1
  163. package/dist/resolver-6B8dI9gr.js +0 -141
  164. package/dist/resolver-6B8dI9gr.js.map +0 -1
  165. package/dist/resolver-DCcmyWSK.cjs +0 -4
  166. package/dist/resolver-DCcmyWSK.cjs.map +0 -1
  167. package/dist/throttle.test.d.ts +0 -2
  168. package/dist/throttle.test.d.ts.map +0 -1
  169. package/dist/transport/generation-window.test.d.ts +0 -2
  170. package/dist/transport/generation-window.test.d.ts.map +0 -1
  171. package/dist/transport/index.cjs +0 -1
  172. package/dist/transport/index.mjs +0 -2
  173. package/dist/transport/packet.test.d.ts +0 -2
  174. package/dist/transport/packet.test.d.ts.map +0 -1
  175. package/dist/transport/quicklz.test.d.ts +0 -2
  176. package/dist/transport/quicklz.test.d.ts.map +0 -1
  177. package/dist/transport/whisper.test.d.ts +0 -2
  178. package/dist/transport/whisper.test.d.ts.map +0 -1
@@ -0,0 +1,484 @@
1
+ import {
2
+ createHash,
3
+ createECDH,
4
+ generateKeyPairSync,
5
+ createPrivateKey,
6
+ createPublicKey,
7
+ type KeyObject,
8
+ } from "node:crypto";
9
+ import { InvalidIdentityError } from "../errors.js";
10
+ import { asUID, type UID } from "@honeybbq/teamspeak-core";
11
+
12
+ const P256_SCALAR_SIZE = 32;
13
+ const P256_UNCOMPRESSED_KEY_SIZE = 65;
14
+ const P256_POINT_PREFIX = 0x04;
15
+
16
+ /** The most a security level can be: SHA-1 is 160 bits wide. */
17
+ const MAX_SECURITY_LEVEL = 160;
18
+
19
+ let createIdentity: (privateKey: KeyObject, offset: bigint) => Identity;
20
+ let privateKeyOf: (identity: Identity) => KeyObject;
21
+
22
+ /**
23
+ * A TeamSpeak client identity: a P-256 key pair plus a counter, the offset,
24
+ * whose hash with the public key sets the identity's security level.
25
+ *
26
+ * Immutable: raising the level returns a new identity. Opaque: the private
27
+ * key is never exposed, and `toString()`, `toJSON()` and `util.inspect()`
28
+ * describe the identity without it, so an identity that reaches a log or an
29
+ * error message does not leak; `export()` is the one way to get the secret
30
+ * form, for storing it. Make one with `generateIdentity()` or
31
+ * `parseIdentity()`.
32
+ */
33
+ export class Identity {
34
+ static {
35
+ createIdentity = (privateKey, offset) => new Identity(privateKey, offset);
36
+ privateKeyOf = (identity) => identity.#privateKey;
37
+ }
38
+
39
+ readonly #privateKey: KeyObject;
40
+ readonly #offset: bigint;
41
+ #publicKey: string | undefined;
42
+
43
+ private constructor(privateKey: KeyObject, offset: bigint) {
44
+ this.#privateKey = privateKey;
45
+ this.#offset = offset;
46
+ }
47
+
48
+ /** The counter whose hash with the public key sets the security level. */
49
+ get offset(): bigint {
50
+ return this.#offset;
51
+ }
52
+
53
+ /** The public key in TeamSpeak's encoding: base64 of its DER form. */
54
+ publicKey(): string {
55
+ if (this.#publicKey !== undefined) return this.#publicKey;
56
+ const rawJwk = this.#privateKey.export({ format: "jwk" });
57
+ if (typeof rawJwk.x !== "string" || typeof rawJwk.y !== "string") return "";
58
+
59
+ const xBytes = base64UrlToBytes(rawJwk.x, P256_SCALAR_SIZE);
60
+ const yBytes = base64UrlToBytes(rawJwk.y, P256_SCALAR_SIZE);
61
+ // Encode as TS3 canonical ASN.1: SEQUENCE { BIT STRING, INTEGER(32), INTEGER(x), INTEGER(y) }
62
+ this.#publicKey = encodeTsPublicKey(xBytes, yBytes);
63
+ return this.#publicKey;
64
+ }
65
+
66
+ /**
67
+ * The identity's unique identifier, hashed with `algorithm`: SHA-1 for a
68
+ * TeamSpeak 3 server, SHA-256 for TeamSpeak 6. A connected client's `uid`
69
+ * already uses the one its server does.
70
+ */
71
+ uid(algorithm: UIDAlgorithm): UID {
72
+ return uidFromPublicKey(this.publicKey(), algorithm);
73
+ }
74
+
75
+ /**
76
+ * The identity in the form `parseIdentity()` reads, private key included.
77
+ * Store it as you would a password.
78
+ */
79
+ export(): string {
80
+ const jwk = this.#privateKey.export({ format: "jwk" }) as { d?: string };
81
+ if (!jwk.d) return `:${this.#offset}`;
82
+ const dBytes = base64UrlToBytes(jwk.d, P256_SCALAR_SIZE);
83
+ return `${Buffer.from(dBytes).toString("base64")}:${this.#offset}`;
84
+ }
85
+
86
+ /** Describes the identity, by its SHA-1 unique identifier, without its private key. */
87
+ toString(): string {
88
+ return `identity ${this.uid("sha1")} (private key redacted)`;
89
+ }
90
+
91
+ /** The same as `toString()`, so serialising an identity cannot leak its key. */
92
+ toJSON(): string {
93
+ return this.toString();
94
+ }
95
+
96
+ /** The security level: trailing zero bits in SHA-1 of the public key and offset. */
97
+ securityLevel(): number {
98
+ return levelAt(this.publicKey(), this.#offset);
99
+ }
100
+
101
+ /**
102
+ * The same key at an offset whose security level reaches `targetLevel`, as
103
+ * a new identity; this one is unchanged, and returned as it is when it
104
+ * already reaches the level.
105
+ *
106
+ * Each level doubles the expected work, so a high level can take a long
107
+ * time; `signal` bounds it, and the event loop keeps running meanwhile. A
108
+ * level above 160, the width of SHA-1, can never be reached and is refused
109
+ * with a RangeError.
110
+ */
111
+ async upgradeToLevel(
112
+ targetLevel: number,
113
+ options: { readonly signal?: AbortSignal | undefined } = {},
114
+ ): Promise<Identity> {
115
+ checkLevel(targetLevel);
116
+ const { signal } = options;
117
+ const prefix = this.publicKey();
118
+ let offset = this.#offset;
119
+ while (true) {
120
+ signal?.throwIfAborted();
121
+ if (levelAt(prefix, offset) >= targetLevel) {
122
+ return offset === this.#offset ? this : new Identity(this.#privateKey, offset);
123
+ }
124
+ offset++;
125
+ // Yield to the event loop now and then rather than blocking it.
126
+ if (offset % 10000n === 0n) {
127
+ await new Promise<void>((resolve) => setImmediate(resolve));
128
+ }
129
+ }
130
+ }
131
+ }
132
+
133
+ // Defined outside the class body: a computed key there would have to be
134
+ // inferred, which isolatedDeclarations rules out. By its registered name, so
135
+ // this module needs no node:util.
136
+ Object.defineProperty(Identity.prototype, Symbol.for("nodejs.util.inspect.custom"), {
137
+ value(this: Identity): string {
138
+ return `Identity <${this.toString()}>`;
139
+ },
140
+ });
141
+
142
+ /**
143
+ * The private key of an identity, for the handshake. Not exported from the
144
+ * package.
145
+ *
146
+ * @internal
147
+ */
148
+ export function identityPrivateKey(identity: Identity): KeyObject {
149
+ return privateKeyOf(identity);
150
+ }
151
+
152
+ function checkLevel(level: number): void {
153
+ if (!Number.isInteger(level) || level < 0 || level > MAX_SECURITY_LEVEL) {
154
+ throw new RangeError(`a security level is an integer from 0 to 160, not ${level}`);
155
+ }
156
+ }
157
+
158
+ function levelAt(publicKey: string, offset: bigint): number {
159
+ const h = createHash("sha1");
160
+ h.update(publicKey);
161
+ h.update(offset.toString(10));
162
+ return countLeadingZeros(h.digest());
163
+ }
164
+
165
+ /** The largest offset, an unsigned 64-bit counter. */
166
+ const MAX_UINT64 = (1n << 64n) - 1n;
167
+
168
+ /**
169
+ * Read an identity from the form `Identity.export()` writes: the private key
170
+ * as canonical base64 of at most 32 bytes, a colon, and the offset as a
171
+ * decimal unsigned 64-bit integer. Anything else throws an
172
+ * InvalidIdentityError, with the underlying failure as its cause.
173
+ */
174
+ export function parseIdentity(s: string): Identity {
175
+ const colonIdx = s.lastIndexOf(":");
176
+ if (colonIdx < 0) throw new InvalidIdentityError();
177
+
178
+ const dBase64 = s.slice(0, colonIdx);
179
+ const offsetStr = s.slice(colonIdx + 1);
180
+ if (!/^\d+$/.test(offsetStr) || BigInt(offsetStr) > MAX_UINT64) {
181
+ throw new InvalidIdentityError("invalid identity offset");
182
+ }
183
+ const offset = BigInt(offsetStr);
184
+
185
+ // Node's base64 decoder skips what it cannot read, so the key is checked by
186
+ // a round trip: anything but canonical base64 would otherwise decode to some
187
+ // other key, silently.
188
+ const dBytes = Buffer.from(dBase64, "base64");
189
+ if (dBytes.length === 0 || dBytes.toString("base64") !== dBase64) {
190
+ throw new InvalidIdentityError("the private key is not canonical base64");
191
+ }
192
+ if (dBytes.length > P256_SCALAR_SIZE) {
193
+ throw new InvalidIdentityError("private key scalar too large");
194
+ }
195
+
196
+ // Pad to P256_SCALAR_SIZE
197
+ const padded = Buffer.alloc(P256_SCALAR_SIZE);
198
+ dBytes.copy(padded, P256_SCALAR_SIZE - dBytes.length);
199
+
200
+ try {
201
+ // node:crypto JWK import requires x and y to be present.
202
+ // Derive the public key from the private scalar via ECDH.
203
+ const ecdh = createECDH("prime256v1");
204
+ ecdh.setPrivateKey(padded);
205
+ const pub = ecdh.getPublicKey(null, "uncompressed"); // 65 bytes: 0x04 || x(32) || y(32)
206
+
207
+ const x = Buffer.from(pub.slice(1, 33)).toString("base64url");
208
+ const y = Buffer.from(pub.slice(33, 65)).toString("base64url");
209
+ const d = padded.toString("base64url");
210
+
211
+ const jwk = { kty: "EC", crv: "P-256", d, x, y };
212
+ return createIdentity(createPrivateKey({ key: jwk, format: "jwk" }), offset);
213
+ } catch (error) {
214
+ throw new InvalidIdentityError("invalid private key", { cause: error });
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Create an identity at the given security level, at most 160. Each level
220
+ * doubles the expected work; `signal` bounds it, and the event loop keeps
221
+ * running meanwhile.
222
+ */
223
+ export async function generateIdentity(
224
+ targetLevel: number,
225
+ options: { readonly signal?: AbortSignal | undefined } = {},
226
+ ): Promise<Identity> {
227
+ checkLevel(targetLevel);
228
+ return createIdentity(generateKey(), 0n).upgradeToLevel(targetLevel, options);
229
+ }
230
+
231
+ /**
232
+ * Create an identity at the given security level, at most 160, blocking until
233
+ * done. Fine for a low level; a high one can block the event loop for minutes,
234
+ * so prefer `generateIdentity()` there.
235
+ */
236
+ export function generateIdentitySync(targetLevel: number): Identity {
237
+ checkLevel(targetLevel);
238
+ const key = generateKey();
239
+ const prefix = createIdentity(key, 0n).publicKey();
240
+ let offset = 0n;
241
+ while (levelAt(prefix, offset) < targetLevel) offset++;
242
+ return createIdentity(key, offset);
243
+ }
244
+
245
+ function generateKey(): KeyObject {
246
+ return generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey;
247
+ }
248
+
249
+ /**
250
+ * The hash a server uses for `client_unique_identifier`, keyed by the server's
251
+ * major version.
252
+ *
253
+ * TeamSpeak 3 hashes the canonical public key with SHA-1 (20 bytes); TeamSpeak
254
+ * 6 switched to SHA-256 (32 bytes). The algorithm is not negotiable on the
255
+ * wire, so a client that guesses wrong computes a UID that matches nothing the
256
+ * server ever sends — self-identification by UID silently fails.
257
+ *
258
+ * May gain members in a minor release, should a server generation change the
259
+ * hash again: a `switch` over it needs a default case.
260
+ */
261
+ export type UIDAlgorithm = "sha1" | "sha256";
262
+
263
+ /** Pick the UID hash from a `virtualserver_version` string, e.g. "6.0.0-beta13.1". */
264
+ export function uidAlgorithmForVersion(version: string): UIDAlgorithm {
265
+ const major = Number.parseInt(version, 10);
266
+ return Number.isNaN(major) || major < 6 ? "sha1" : "sha256";
267
+ }
268
+
269
+ /**
270
+ * The unique identifier of a public key in TeamSpeak's encoding, hashed with
271
+ * `algorithm`: SHA-1 for a TeamSpeak 3 server, SHA-256 for TeamSpeak 6.
272
+ */
273
+ export function uidFromPublicKey(publicKey: string, algorithm: UIDAlgorithm): UID {
274
+ return asUID(createHash(algorithm).update(publicKey).digest("base64"));
275
+ }
276
+
277
+ export function hash512(data: Uint8Array): Uint8Array {
278
+ return createHash("sha512").update(data).digest();
279
+ }
280
+
281
+ // ---- Internal ASN.1 DER helpers for TS3 canonical P-256 public key ----------
282
+
283
+ /**
284
+ * Encode a P-256 public key as the TS3 canonical ASN.1 SEQUENCE and return
285
+ * its base64 representation.
286
+ *
287
+ * TS3 canonical format (v3+):
288
+ * SEQUENCE {
289
+ * BIT STRING { unused=7, value=0x00 } — DER: 03 02 07 00
290
+ * INTEGER 32
291
+ * INTEGER x
292
+ * INTEGER y
293
+ * }
294
+ */
295
+ function encodeTsPublicKey(x: Uint8Array, y: Uint8Array): string {
296
+ // BIT STRING with 7 unused bits + value byte 0x00 — matches C# DerBitString(0x00, 7)
297
+ const bitStringBody = Buffer.from([0x07, 0x00]);
298
+ const bitStringDer = encodeDerTlv(0x03, bitStringBody);
299
+
300
+ const integerSize = encodeDerTlv(0x02, encodePositiveInt(32));
301
+ const integerX = encodeDerTlv(0x02, encodePositiveInt(x));
302
+ const integerY = encodeDerTlv(0x02, encodePositiveInt(y));
303
+
304
+ const seqBody = Buffer.concat([bitStringDer, integerSize, integerX, integerY]);
305
+ const seqDer = encodeDerTlv(0x30, seqBody);
306
+
307
+ return seqDer.toString("base64");
308
+ }
309
+
310
+ /** Import a TS3 public key (canonical or legacy ASN.1 DER) and return the
311
+ * uncompressed 65-byte point [0x04 || x || y]. */
312
+ export function importPublicKey(data: Uint8Array): KeyObject {
313
+ const point = parseTsPublicKeyDer(data);
314
+ const jwk = {
315
+ kty: "EC",
316
+ crv: "P-256",
317
+ x: Buffer.from(point.slice(1, 33)).toString("base64url"),
318
+ y: Buffer.from(point.slice(33, 65)).toString("base64url"),
319
+ };
320
+ return createPublicKey({ key: jwk, format: "jwk" });
321
+ }
322
+
323
+ /**
324
+ * Parse both TS3 canonical and legacy ASN.1 formats and return the
325
+ * uncompressed 65-byte point [0x04 || x || y].
326
+ */
327
+ function parseTsPublicKeyDer(data: Uint8Array): Uint8Array {
328
+ // Try canonical: SEQUENCE { BIT_STRING, INTEGER(32), INTEGER(x), INTEGER(y) }
329
+ try {
330
+ return parseTsCanonicalPublicKey(data);
331
+ } catch {
332
+ // fall through to legacy
333
+ }
334
+ // Try legacy: SEQUENCE { INTEGER(x), INTEGER(y), BIT_STRING, INTEGER }
335
+ return parseTsLegacyPublicKey(data);
336
+ }
337
+
338
+ function parseTsCanonicalPublicKey(data: Uint8Array): Uint8Array {
339
+ const seqTlv = parseDerTlv(data, 0x30);
340
+ const seq = seqTlv.value;
341
+ let offset = 0;
342
+ // BIT_STRING
343
+ const bs = parseDerTlv(seq, 0x03, offset);
344
+ offset += bs.consumed;
345
+ // INTEGER (32)
346
+ const sizeInt = parseDerTlv(seq, 0x02, offset);
347
+ offset += sizeInt.consumed;
348
+ // INTEGER x
349
+ const xInt = parseDerTlv(seq, 0x02, offset);
350
+ offset += xInt.consumed;
351
+ // INTEGER y
352
+ const yInt = parseDerTlv(seq, 0x02, offset);
353
+
354
+ return buildUncompressedPoint(
355
+ positiveIntToBytes(xInt.value, P256_SCALAR_SIZE),
356
+ positiveIntToBytes(yInt.value, P256_SCALAR_SIZE),
357
+ );
358
+ }
359
+
360
+ function parseTsLegacyPublicKey(data: Uint8Array): Uint8Array {
361
+ const seqTlv = parseDerTlv(data, 0x30);
362
+ const seq = seqTlv.value;
363
+ let offset = 0;
364
+ const xInt = parseDerTlv(seq, 0x02, offset);
365
+ offset += xInt.consumed;
366
+ const yInt = parseDerTlv(seq, 0x02, offset);
367
+
368
+ return buildUncompressedPoint(
369
+ positiveIntToBytes(xInt.value, P256_SCALAR_SIZE),
370
+ positiveIntToBytes(yInt.value, P256_SCALAR_SIZE),
371
+ );
372
+ }
373
+
374
+ function buildUncompressedPoint(x: Uint8Array, y: Uint8Array): Uint8Array {
375
+ if (x.length > P256_SCALAR_SIZE || y.length > P256_SCALAR_SIZE) {
376
+ throw new Error("invalid public key point encoding");
377
+ }
378
+ const point = new Uint8Array(P256_UNCOMPRESSED_KEY_SIZE);
379
+ point[0] = P256_POINT_PREFIX;
380
+ point.set(x, 1 + P256_SCALAR_SIZE - x.length);
381
+ point.set(y, 1 + 2 * P256_SCALAR_SIZE - y.length);
382
+ return point;
383
+ }
384
+
385
+ // ---- DER encoding helpers ---------------------------------------------------
386
+
387
+ function encodeDerTlv(tag: number, value: Uint8Array): Buffer {
388
+ const lenBytes = encodeDerLength(value.length);
389
+ return Buffer.concat([Buffer.from([tag]), lenBytes, value]);
390
+ }
391
+
392
+ function encodeDerLength(len: number): Buffer {
393
+ if (len < 128) return Buffer.from([len]);
394
+ if (len < 256) return Buffer.from([0x81, len]);
395
+ return Buffer.from([0x82, (len >> 8) & 0xff, len & 0xff]);
396
+ }
397
+
398
+ /** Encode a non-negative value as a DER INTEGER (with 0x00 prefix if high bit set). */
399
+ function encodePositiveInt(v: number | Uint8Array): Buffer {
400
+ if (typeof v === "number") {
401
+ const bytes: number[] = [];
402
+ let n = v;
403
+ do {
404
+ bytes.unshift(n & 0xff);
405
+ n >>= 8;
406
+ } while (n > 0);
407
+ if ((bytes[0]! & 0x80) !== 0) bytes.unshift(0x00);
408
+ return Buffer.from(bytes);
409
+ }
410
+ // Uint8Array — strip leading zeros, add 0x00 if high bit set
411
+ let start = 0;
412
+ while (start < v.length - 1 && v[start] === 0) start++;
413
+ const trimmed = v.slice(start);
414
+ if ((trimmed[0]! & 0x80) !== 0) {
415
+ return Buffer.concat([Buffer.from([0x00]), trimmed]);
416
+ }
417
+ return Buffer.from(trimmed);
418
+ }
419
+
420
+ interface ParsedTlv {
421
+ value: Uint8Array;
422
+ consumed: number; // bytes consumed including tag+length
423
+ }
424
+
425
+ function parseDerTlv(data: Uint8Array, expectedTag: number, offset = 0): ParsedTlv {
426
+ if (data[offset] !== expectedTag) {
427
+ throw new Error(
428
+ `expected DER tag 0x${expectedTag.toString(16)}, got 0x${(data[offset] ?? 0).toString(16)}`,
429
+ );
430
+ }
431
+ let pos = offset + 1;
432
+ let len: number;
433
+ const firstLenByte = data[pos++]!;
434
+ if (firstLenByte < 0x80) {
435
+ len = firstLenByte;
436
+ } else if (firstLenByte === 0x81) {
437
+ len = data[pos++]!;
438
+ } else if (firstLenByte === 0x82) {
439
+ len = ((data[pos]! << 8) | data[pos + 1]!) >>> 0;
440
+ pos += 2;
441
+ } else {
442
+ throw new Error("unsupported DER length encoding");
443
+ }
444
+ const value = data.slice(pos, pos + len);
445
+ return { value, consumed: pos - offset + len };
446
+ }
447
+
448
+ /** Convert a DER INTEGER value (may have leading 0x00) to fixed-length bytes. */
449
+ function positiveIntToBytes(int: Uint8Array, size: number): Uint8Array {
450
+ // Strip leading 0x00 sign byte
451
+ let start = 0;
452
+ while (start < int.length - 1 && int[start] === 0x00) start++;
453
+ const stripped = int.slice(start);
454
+ if (stripped.length > size) throw new Error("integer too large");
455
+ if (stripped.length === size) return stripped;
456
+ const padded = new Uint8Array(size);
457
+ padded.set(stripped, size - stripped.length);
458
+ return padded;
459
+ }
460
+
461
+ // ---- Misc helpers -----------------------------------------------------------
462
+
463
+ function base64UrlToBytes(b64url: string, size: number): Uint8Array {
464
+ const buf = Buffer.from(b64url, "base64url");
465
+ if (buf.length === size) return buf;
466
+ const padded = Buffer.alloc(size);
467
+ buf.copy(padded, size - buf.length);
468
+ return padded;
469
+ }
470
+
471
+ function countLeadingZeros(data: Buffer): number {
472
+ let zeros = 0;
473
+ for (const b of data) {
474
+ if (b === 0) {
475
+ zeros += 8;
476
+ } else {
477
+ for (let i = 0; i < 8; i++) {
478
+ if ((b & (1 << i)) === 0) zeros++;
479
+ else return zeros;
480
+ }
481
+ }
482
+ }
483
+ return zeros;
484
+ }
@@ -0,0 +1,21 @@
1
+ export {
2
+ Identity,
3
+ parseIdentity,
4
+ generateIdentity,
5
+ generateIdentitySync,
6
+ uidFromPublicKey,
7
+ uidAlgorithmForVersion,
8
+ hash512,
9
+ importPublicKey,
10
+ } from "./identity.js";
11
+ export type { UIDAlgorithm } from "./identity.js";
12
+ export { Crypt } from "./crypt.js";
13
+ export type { KeyNonce } from "./crypt.js";
14
+ export { EAX, aesCmac } from "./eax.js";
15
+ export {
16
+ generateTemporaryKey,
17
+ sign,
18
+ verifySign,
19
+ getSharedSecret2,
20
+ clampScalar,
21
+ } from "./primitives.js";
@@ -0,0 +1,141 @@
1
+ import { createSign, createVerify, createHash, randomBytes, type KeyObject } from "node:crypto";
2
+ import { ed25519 } from "@noble/curves/ed25519.js";
3
+
4
+ const CURVE25519_KEY_SIZE = 32;
5
+ const CLAMP_MASK_LOW = 248;
6
+ const CLAMP_MASK_HIGH = 127;
7
+ const CLAMP_HIGH_BIT = 64;
8
+ const SHARED_SIGN_BIT = 0x80;
9
+ const PRIVATE_KEY_TOP_MASK = 0x7f;
10
+
11
+ /** Clamp a Curve25519 scalar in-place (required before scalar multiplication). */
12
+ export function clampScalar(key: Uint8Array): void {
13
+ if (key.length < CURVE25519_KEY_SIZE) return;
14
+ key[0] = (key[0] ?? 0) & CLAMP_MASK_LOW;
15
+ key[CURVE25519_KEY_SIZE - 1] =
16
+ ((key[CURVE25519_KEY_SIZE - 1] ?? 0) & CLAMP_MASK_HIGH) | CLAMP_HIGH_BIT;
17
+ }
18
+
19
+ /**
20
+ * Generate an ephemeral Ed25519 key pair for the TS3 handshake.
21
+ * Returns [publicKey (32 bytes, RFC-8032 encoded), privateKey (32 bytes)].
22
+ */
23
+ export function generateTemporaryKey(): [Uint8Array, Uint8Array] {
24
+ const privateKey = new Uint8Array(randomBytes(CURVE25519_KEY_SIZE));
25
+ clampScalar(privateKey);
26
+
27
+ // Base point G is in the prime-order subgroup, so G*s = G*(s mod n).
28
+ // Safe to reduce here since G has no small-order component.
29
+ const scalar = bytesToBigIntLE(privateKey) % ed25519.Point.CURVE().n;
30
+ const publicKey = ed25519.Point.BASE.multiply(scalar).toBytes();
31
+
32
+ return [publicKey, Uint8Array.from(privateKey)];
33
+ }
34
+
35
+ /**
36
+ * Sign data with a P-256 private key (SHA-256 hash, ASN.1 DER encoded).
37
+ */
38
+ export function sign(privateKey: KeyObject, data: Uint8Array): Uint8Array {
39
+ const signer = createSign("SHA256");
40
+ signer.update(data);
41
+ return signer.sign(privateKey);
42
+ }
43
+
44
+ /**
45
+ * Verify a P-256 ECDSA signature (SHA-256 hash, ASN.1 DER encoded).
46
+ */
47
+ export function verifySign(publicKey: KeyObject, data: Uint8Array, sig: Uint8Array): boolean {
48
+ try {
49
+ const verifier = createVerify("SHA256");
50
+ verifier.update(data);
51
+ return verifier.verify(publicKey, Buffer.from(sig));
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * TS3-specific shared secret derivation using Ed25519 point arithmetic.
59
+ * Mirrors NaCl's ge_scalarmult_vartime:
60
+ * 1. Negate the public point
61
+ * 2. Scalar-multiply by clamped private key bytes (NOT reduced mod n)
62
+ * 3. Flip sign bit of result
63
+ * 4. SHA-512 the resulting point bytes
64
+ *
65
+ * The scalar must NOT be reduced mod n: Ed25519 has cofactor 8, so the server's
66
+ * public key may have a small-order component, and P*s ≠ P*(s mod n) in that case.
67
+ * We use scalarMultFull to handle scalars >= n without losing the cofactor term.
68
+ */
69
+ export function getSharedSecret2(
70
+ publicKeyBytes: Uint8Array,
71
+ privateKeyBytes: Uint8Array,
72
+ ): Uint8Array {
73
+ if (
74
+ publicKeyBytes.length !== CURVE25519_KEY_SIZE ||
75
+ privateKeyBytes.length !== CURVE25519_KEY_SIZE
76
+ ) {
77
+ throw new Error("invalid key length");
78
+ }
79
+
80
+ const privCopy = Uint8Array.from(privateKeyBytes);
81
+ privCopy[CURVE25519_KEY_SIZE - 1] =
82
+ (privCopy[CURVE25519_KEY_SIZE - 1] ?? 0) & PRIVATE_KEY_TOP_MASK;
83
+
84
+ // Use raw scalar WITHOUT reducing mod n — matches NaCl's ge_scalarmult_vartime.
85
+ // Ed25519 has cofactor 8, so P*s ≠ P*(s mod n) when P has a small-order component.
86
+ // Since @noble/curves rejects scalars >= n, we decompose: P*s = P*(s%n) + (P*n)*q
87
+ const rawScalar = bytesToBigIntLE(privCopy);
88
+
89
+ const pub = ed25519.Point.fromBytes(publicKeyBytes);
90
+ const negPub = pub.negate();
91
+
92
+ const sharedPoint = scalarMultFull(negPub, rawScalar);
93
+ const sharedBytes = sharedPoint.toBytes();
94
+
95
+ // Flip sign bit (mirrors Go: shared[31] ^= 0x80)
96
+ sharedBytes[CURVE25519_KEY_SIZE - 1] =
97
+ (sharedBytes[CURVE25519_KEY_SIZE - 1] ?? 0) ^ SHARED_SIGN_BIT;
98
+
99
+ return createHash("sha512").update(sharedBytes).digest();
100
+ }
101
+
102
+ // ---- Internal ---------------------------------------------------------------
103
+
104
+ /**
105
+ * Multiply an Ed25519 point by a scalar that may be >= curve order n.
106
+ * Decomposes as: P*s = P*(s mod n) + (P*n)*q where q = floor(s/n).
107
+ * This preserves the small-order component (cofactor 8) that would be
108
+ * lost by reducing mod n first.
109
+ *
110
+ * For a clamped 255-bit scalar, q is at most 7 (since maxScalar / n < 8),
111
+ * so the second term is computed with at most one extra scalar multiplication.
112
+ */
113
+ export function scalarMultFull(
114
+ point: InstanceType<typeof ed25519.Point>,
115
+ scalar: bigint,
116
+ ): InstanceType<typeof ed25519.Point> {
117
+ const n = ed25519.Point.CURVE().n;
118
+ if (scalar < n) {
119
+ return scalar === 0n ? ed25519.Point.ZERO : point.multiply(scalar);
120
+ }
121
+ const remainder = scalar % n;
122
+ const quotient = scalar / n; // integer division, 1 <= quotient <= 7 for clamped scalars
123
+
124
+ // P * (s mod n)
125
+ const mainPart = remainder === 0n ? ed25519.Point.ZERO : point.multiply(remainder);
126
+
127
+ // (P * n) * q — compute P*n via P*(n-1)+P since multiply() rejects scalar == n
128
+ const pointTimesN = point.multiply(n - 1n).add(point);
129
+ const cofactorPart = pointTimesN.multiply(quotient);
130
+
131
+ return mainPart.add(cofactorPart);
132
+ }
133
+
134
+ /** Interpret a little-endian byte array as an unsigned BigInt. */
135
+ export function bytesToBigIntLE(bytes: Uint8Array): bigint {
136
+ let result = 0n;
137
+ for (let i = bytes.length - 1; i >= 0; i--) {
138
+ result = (result << 8n) | BigInt(bytes[i]!);
139
+ }
140
+ return result;
141
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Diagnostics channels: the SDK's tracing points, for an application or an
3
+ * APM agent (OpenTelemetry instruments `diagnostics_channel` directly) to
4
+ * subscribe to.
5
+ *
6
+ * A library does not decide where its traces go, so the SDK publishes them
7
+ * here, and with no subscriber a channel costs a property read.
8
+ *
9
+ * Messages carry the command's name, never its parameters: those hold
10
+ * password hashes, which the server accepts as passwords, and privilege keys.
11
+ *
12
+ * ```ts
13
+ * import { tracingChannel } from "node:diagnostics_channel";
14
+ *
15
+ * tracingChannel("honeybbq:teamspeak:command").subscribe({
16
+ * start: ({ command }) => console.time(command),
17
+ * asyncEnd: ({ command }) => console.timeEnd(command),
18
+ * error: ({ command, error }) => console.error(command, error),
19
+ * });
20
+ * ```
21
+ */
22
+
23
+ import {
24
+ channel,
25
+ tracingChannel,
26
+ type Channel,
27
+ type TracingChannel,
28
+ } from "node:diagnostics_channel";
29
+ import type { Client } from "./client.js";
30
+
31
+ /** What `honeybbq:teamspeak:command` reports about a command. */
32
+ export interface CommandTrace {
33
+ /** The command's name, such as `clientlist`. */
34
+ readonly command: string;
35
+ /** The client sending it. Its fields are private, so it carries no secret. */
36
+ readonly client: Client;
37
+ }
38
+
39
+ /** What `honeybbq:teamspeak:packet` reports about a command arriving from the server. */
40
+ export interface PacketTrace {
41
+ /** The command's name, such as `notifytextmessage`, or `""` for a data row. */
42
+ readonly command: string;
43
+ /** The client receiving it. */
44
+ readonly client: Client;
45
+ }
46
+
47
+ /**
48
+ * Traces every command with an answer: `start` when it is sent, `asyncEnd`
49
+ * when its answer arrives, `error` when it fails.
50
+ */
51
+ export const commandChannel: TracingChannel<CommandTrace> = tracingChannel<CommandTrace>(
52
+ "honeybbq:teamspeak:command",
53
+ );
54
+
55
+ /** Publishes every command the server sends. */
56
+ export const packetChannel: Channel<PacketTrace> = channel<PacketTrace>(
57
+ "honeybbq:teamspeak:packet",
58
+ );