@tilua-types/bridge 1.0.0 → 2.1.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.
package/README.md CHANGED
@@ -40,6 +40,55 @@ Every handler of a name runs, on the server and the client alike, and what comes
40
40
  before the first one waits for it, up to 256 messages. A name still without a
41
41
  handler after ten seconds is warned about.
42
42
 
43
+ What a side sends in one frame goes out together at the next `Heartbeat`, in the
44
+ order it was sent: one sealed packet from a client — or as few as fit under
45
+ `bridgeMaxPacket` — and one remote call to each player from the server, or a
46
+ single `FireAllClients` when the frame held only `sendAll`. So `bridgeRateLimit`
47
+ counts packets, not messages.
48
+
49
+ ### A handler's own limits
50
+
51
+ A handler can take its own limits as a third argument, on top of the config's:
52
+
53
+ ```ts
54
+ bridge.onServer("Chat", (player: Player, text: string) => { ... }, {
55
+ rateLimit: 5, // messages of this name a player may have it run a second
56
+ maxPacket: 512, // the largest message, in bytes of its arguments as JSON
57
+ })
58
+ ```
59
+
60
+ Past either, the message is dropped for that handler alone — before its
61
+ arguments are decoded — and another handler of the name still gets it. The
62
+ first message over `maxPacket` is warned about. Neither is a breach: the client
63
+ does not know a handler's limits, so a fair one can cross them. `onClient` takes
64
+ the same options, counted per handler.
65
+
66
+ ### Asking the server
67
+
68
+ `bridge.invoke` asks the server and waits for its answer, through a RemoteFunction:
69
+
70
+ ```ts
71
+ // server: one handler a name; what it returns goes back
72
+ bridge.onInvoke("Price", (player: Player, item: string) => {
73
+ return prices[item]
74
+ })
75
+
76
+ // client
77
+ const price = bridge.invoke("Price", "sword")
78
+ ```
79
+
80
+ The request is sealed and numbered like a packet, on a count of its own, and
81
+ counts toward `bridgeRateLimit`; the answer comes back plain, to that client
82
+ alone, carrying what arguments carry. What the client sent before the invoke goes
83
+ out first, so the handler sees it. `onInvoke` takes a handler's limits as
84
+ `onServer` does. The handler runs on the invoke's own thread, so it may yield.
85
+
86
+ `invoke` raises when there is no answer: no handler within ten seconds (the invoke
87
+ waits for one, as a message would), over the handler's limits, or the handler
88
+ failed. The client is told only that — the error itself is warned on the server.
89
+ A handler that returns nothing answers `nil`. There is no invoke from the server
90
+ to a client: a client that never answers would hold the server's thread for good.
91
+
43
92
  What cannot run does not build: `bridge` read as a value, a `.client` script
44
93
  calling a server's member or the other way round, and any output but `"files"`.
45
94
 
@@ -52,6 +101,10 @@ calling a server's member or the other way round, and any output but `"files"`.
52
101
  secret. A token the build did not make is a breach and gets nothing. A real one
53
102
  claims as often as it is offered: the game can clone any script, and it runs
54
103
  again wherever it lands, so a second claim cannot be told from a replay.
104
+ The claim's strings — the module's path, the place and the token — are
105
+ encrypted in the file, so neither reading the script's constants nor searching
106
+ its source turns up a token to replay (`bridgeObfuscate: false` leaves them as
107
+ written).
55
108
  - **Nothing hands it out.** The module's value is that one claim function. The
56
109
  handle is a local of the file: not in its exports, not in tilua's runtime
57
110
  (`G`, which anything can `require`). `bridge` read as a value — kept,
@@ -69,6 +122,12 @@ calling a server's member or the other way round, and any output but `"files"`.
69
122
  salt: the real remotes are named by it under the secret, in a folder named
70
123
  after it, shuffled among the decoys, so a name kept from another server finds
71
124
  nothing. The client renames the real ones once found, in the decoys' form.
125
+ - **Honeypots.** One decoy RemoteEvent and one decoy RemoteFunction are named by
126
+ the salt too. The game's client fires the first beside every packet and
127
+ invokes the second beside every invoke, each with a number one past the last,
128
+ sealed with AES-GCM, so a spy watching the remotes sees them carry traffic like
129
+ the real ones. The server lets the next number be; anything else through them —
130
+ a blob it did not seal, or a replay of one it did — is a breach.
72
131
 
73
132
  On the client a breach is reported to the server through the bridge.
74
133
 
@@ -76,7 +135,7 @@ On the client a breach is reported to the server through the bridge.
76
135
 
77
136
  With no handler, a breach bans the player for good across the experience, their
78
137
  alt accounts too, through `Players:BanAsync`; a ban that fails is a kick. In Studio
79
- it only warns. A file that loads within five seconds of the breach can still take
138
+ it is a kick that says why, and no ban. A file that loads within five seconds of the breach can still take
80
139
  it. To answer another way, register a handler on the server, which replaces the
81
140
  ban entirely:
82
141
 
@@ -109,41 +168,46 @@ those. Two more things to know:
109
168
 
110
169
  Each build writes the module beside tilua's runtime — `shared/_bridge.luau` in the
111
170
  output of a project laid out by `tilua init` — with that build's secret inside,
112
- obfuscated with [lua-obfuscator](https://www.npmjs.com/package/lua-obfuscator). The
171
+ obfuscated with [luau-obfuscator](https://www.npmjs.com/package/luau-obfuscator). The
113
172
  secret is kept nowhere else, so there is nothing to set up or check in. `--watch`
114
- keeps one secret while it runs. A project from before this has a
173
+ keeps one secret while it runs. A Rojo project isn't needed: without one the module
174
+ is written as `_bridge.luau` in `outDir`, and each file requires it by its relative
175
+ path (`require("../_bridge")`). With one, files reach it from `game`, as before.
176
+ A project from before this has a
115
177
  `src/shared/_bridge.luau` and a `.tilua` folder to delete.
116
178
 
117
179
  The module runs in one Luau VM: calling `bridge` from an Actor stops with an error
118
180
  saying so.
119
181
 
120
- Client to server, arguments travel as JSON: strings, numbers, booleans, and tables
121
- of them. Not Instances, and no `nil` between arguments. Server to client they are
122
- a remote's arguments, as they are.
123
-
124
- The obfuscation hides the secret and every string in the module, but not the
125
- names of members it reads (`.OnServerInvoke`, `crypto.aesGcm`): lua-obfuscator
126
- keeps `a.b`'s `b` as written.
127
-
128
- ## Tested
129
-
130
- `npm test` in the repository compiles a project with it — claims only in the
131
- files that call `bridge`, nothing in the runtime or the exports — and runs the
132
- module as server and client over a fake network, plain and obfuscated: traffic
133
- both ways, then a late executor making up a token, firing the packet remote with
134
- its own bytes and with a captured packet, asking for the
135
- key, and firing and invoking every decoy. Each is refused and reported. The
136
- network is a fake: nothing here has run in Roblox.
182
+ Both ways, arguments travel as JSON: strings, numbers, booleans, `nil` — between
183
+ and after the others too, counted as `select("#", ...)` counts them — and tables
184
+ of them. An Instance travels as its path of names from `game` and is looked up
185
+ again on arrival, the first child of each name: one the other side does not have
186
+ — not replicated, destroyed, or in no game — arrives as `nil`, inside a table as a
187
+ missing key. Roblox's value types travel as their components and are made again
188
+ on arrival: `Vector3`, `Vector2`, `Vector3int16`, `Vector2int16`, `CFrame`,
189
+ `Color3`, `BrickColor`, `UDim`, `UDim2`, `Rect`, `NumberRange`,
190
+ `NumberSequence`, `ColorSequence`, `Ray`, `Region3` (axis-aligned, as
191
+ `Region3.new` makes it), `PhysicalProperties`, `DateTime`, and an `EnumItem` by
192
+ its enum's name and its own. Each is checked part by part where it arrives — as
193
+ many numbers as it takes — and one that does not make the type is `nil`. The rest
194
+ (`Font`, `TweenInfo`, …) is what `JSONEncode` makes of it. A table key that starts
195
+ with `\1` is the bridge's mark, so a key of the game's that does is escaped on the
196
+ way and arrives as it was.
197
+
198
+ The obfuscation hides the secret, every string in the module, and the names of
199
+ the members it reads (`.OnServerInvoke`, `crypto.aesGcm`): luau-obfuscator runs the
200
+ whole module in its VM, so none of them is left in the source as written.
137
201
 
138
202
  <!-- compatibility:start -->
139
203
  ## Compatibility
140
204
 
141
- `@tilua-types/bridge@1.0.0` works with these tilua packages. *Released with* is the
205
+ `@tilua-types/bridge@2.1.0` works with these tilua packages. *Released with* is the
142
206
  version it was built and tested against.
143
207
 
144
208
  | Package | Range | Released with | |
145
209
  | --- | --- | --- | --- |
146
210
  | `@tilua-types/crypto` | `^1.0.0` | 1.0.0 | dependency |
147
211
  | `@tilua-types/roblox` | `^10.1.2` | 10.1.2 | dependency |
148
- | `@tilua/parser` | `^9.1.2` | 9.1.2 | peer |
212
+ | `@tilua/parser` | `^9.1.2` | 9.1.4 | peer |
149
213
  <!-- compatibility:end -->
package/generate.mjs CHANGED
@@ -1,70 +1,93 @@
1
- // @ts-check
2
- /**
3
- * The game's own bridge module: runtime/bridge.luau with the secret, the limits,
4
- * the decoy count and the crypto runtime filled in, then obfuscated. The build
5
- * writes it; the tests make it the same way.
6
- */
7
- import { existsSync, readFileSync } from "node:fs"
8
- import { createRequire } from "node:module"
9
- import { dirname, join } from "node:path"
10
- import { fileURLToPath } from "node:url"
11
-
12
- const here = dirname(fileURLToPath(import.meta.url))
13
- const require = createRequire(import.meta.url)
14
-
15
- /** @tilua-types/crypto's runtime: from its package, or the checkout beside this one. */
16
- function cryptoRuntime() {
17
- let folder
18
- try {
19
- folder = dirname(require.resolve("@tilua-types/crypto/package.json"))
20
- } catch {
21
- folder = join(here, "..", "crypto")
22
- }
23
- const file = join(folder, "runtime", "crypto.luau")
24
- if (!existsSync(file)) throw new Error("@tilua-types/crypto is not installed")
25
- return readFileSync(file, "utf8")
26
- }
27
-
28
- /** The table the crypto runtime is built into, inside the bridge module. */
29
- const CRYPTO = "BridgeCrypto"
30
-
31
- /**
32
- * The module's Luau before obfuscation.
33
- * @param {{ secret: string, decoys?: number, maxPacket?: number, rateLimit?: number }} options
34
- */
35
- export function bridgeSource({ secret, decoys = 4, maxPacket = 65536, rateLimit = 120 }) {
36
- if (!/^[0-9a-f]{32,}$/.test(secret)) throw new Error("A bridge secret is at least 32 hex characters")
37
- // Function replacers throughout: the crypto runtime holds `$` characters a
38
- // replacement string would read as patterns.
39
- return readFileSync(join(here, "runtime", "bridge.luau"), "utf8")
40
- .replace("__SECRET__", () => JSON.stringify(secret))
41
- .replace("__DECOYS__", () => String(decoys))
42
- .replace("__MAX_PACKET__", () => String(maxPacket))
43
- .replace("__RATE_LIMIT__", () => String(rateLimit))
44
- .replace("__CRYPTO_NAME__", () => CRYPTO)
45
- .replace("__CRYPTO__", () => cryptoRuntime().replace(/__NAME__/g, CRYPTO))
46
- }
47
-
48
- /** What the module goes through: every pass lua-obfuscator has, the VM included. */
49
- export const OBFUSCATION_STEPS = [
50
- { name: "InsertJunk" },
51
- { name: "RenameVariables" },
52
- { name: "Vmify" },
53
- { name: "ConstantArray" },
54
- { name: "GlobalMapping" },
55
- { name: "StringsToExpressions" },
56
- { name: "NumbersToExpressions" },
57
- { name: "EncryptStrings" },
58
- { name: "EncryptNumbers" },
59
- { name: "WrapInFunction" },
60
- ]
61
-
62
- /**
63
- * The module, obfuscated.
64
- * @param {string} source
65
- * @param {{ steps?: readonly object[] }} [options]
66
- */
67
- export async function obfuscateBridge(source, { steps = OBFUSCATION_STEPS } = {}) {
68
- const { obfuscate } = await import("lua-obfuscator")
69
- return obfuscate(source, "luaU", { steps: /** @type {any} */ (steps), minify: true })
70
- }
1
+ // @ts-check
2
+ /**
3
+ * The game's own bridge module: runtime/bridge.luau with the secret, the limits,
4
+ * the decoy count and the crypto runtime filled in, then obfuscated. The build
5
+ * writes it; the tests make it the same way.
6
+ */
7
+ import { existsSync, readFileSync } from "node:fs"
8
+ import { createRequire } from "node:module"
9
+ import { dirname, join } from "node:path"
10
+ import { fileURLToPath } from "node:url"
11
+ import { obfuscate } from "luau-obfuscator"
12
+
13
+ const here = dirname(fileURLToPath(import.meta.url))
14
+ const require = createRequire(import.meta.url)
15
+
16
+ /** @tilua-types/crypto's runtime: from its package, or the checkout beside this one. */
17
+ function cryptoRuntime() {
18
+ let folder
19
+ try {
20
+ folder = dirname(require.resolve("@tilua-types/crypto/package.json"))
21
+ } catch {
22
+ folder = join(here, "..", "crypto")
23
+ }
24
+ const file = join(folder, "runtime", "crypto.luau")
25
+ if (!existsSync(file)) throw new Error("@tilua-types/crypto is not installed")
26
+ return readFileSync(file, "utf8")
27
+ }
28
+
29
+ /** The table the crypto runtime is built into, inside the bridge module. */
30
+ const CRYPTO = "BridgeCrypto"
31
+
32
+ /**
33
+ * The module's Luau before obfuscation.
34
+ * @param {{ secret: string, decoys?: number, maxPacket?: number, rateLimit?: number }} options
35
+ */
36
+ export function bridgeSource({ secret, decoys = 4, maxPacket = 65536, rateLimit = 120 }) {
37
+ if (!/^[0-9a-f]{32,}$/.test(secret)) throw new Error("A bridge secret is at least 32 hex characters")
38
+ // Function replacers throughout: the crypto runtime holds `$` characters a
39
+ // replacement string would read as patterns.
40
+ return readFileSync(join(here, "runtime", "bridge.luau"), "utf8")
41
+ .replace("__SECRET__", () => JSON.stringify(secret))
42
+ .replace("__DECOYS__", () => String(decoys))
43
+ .replace("__MAX_PACKET__", () => String(maxPacket))
44
+ .replace("__RATE_LIMIT__", () => String(rateLimit))
45
+ .replace("__CRYPTO_NAME__", () => CRYPTO)
46
+ .replace("__CRYPTO__", () => cryptoRuntime().replace(/__NAME__/g, CRYPTO))
47
+ }
48
+
49
+ /**
50
+ * What the module goes through: every pass luau-obfuscator has, the VM included,
51
+ * each with the package's own defaults.
52
+ * @type {import("luau-obfuscator").ObfuscatePartialConfig}
53
+ */
54
+ export const OBFUSCATION_CONFIG = {
55
+ InsertJunk: { active: true },
56
+ RenameVariables: { active: true },
57
+ Vmify: { active: true },
58
+ ConstantArray: { active: true },
59
+ GlobalMapping: { active: true },
60
+ StringsToExpressions: { active: true },
61
+ NumbersToExpressions: { active: true },
62
+ EncryptStrings: { active: true },
63
+ EncryptNumbers: { active: true },
64
+ WrapInFunction: { active: true },
65
+ Minify: { active: true },
66
+ }
67
+
68
+ /**
69
+ * The module, obfuscated.
70
+ * @param {string} source
71
+ * @param {{ config?: import("luau-obfuscator").ObfuscatePartialConfig }} [options]
72
+ */
73
+ export async function obfuscateBridge(source, { config = OBFUSCATION_CONFIG } = {}) {
74
+ return obfuscate(source, config)
75
+ }
76
+
77
+ /**
78
+ * What a claim goes through: its strings encrypted — the token, the place and
79
+ * the module's path — and nothing else, so the claim stays a local of the file
80
+ * by its own name, and the file stays small.
81
+ * @type {import("luau-obfuscator").ObfuscatePartialConfig}
82
+ */
83
+ const CLAIM_CONFIG = Object.fromEntries(
84
+ Object.keys(OBFUSCATION_CONFIG).map(pass => [pass, { active: pass === "EncryptStrings" }]),
85
+ )
86
+
87
+ /**
88
+ * A file's claim line, its strings encrypted.
89
+ * @param {string} claim
90
+ */
91
+ export function obfuscateClaim(claim) {
92
+ return obfuscate(claim, CLAIM_CONFIG)
93
+ }
package/index.d.tilua CHANGED
@@ -3,36 +3,60 @@
3
3
  --
4
4
  -- -- client
5
5
  -- bridge.send("Buy", "sword", 2)
6
+ -- const price = bridge.invoke("Price", "sword")
6
7
  -- bridge.onClient("Toast", (text: string) => { ... })
7
8
  --
8
9
  -- -- server
9
- -- bridge.onServer("Buy", (player: Player, item: string, count: number) => { ... })
10
+ -- bridge.onServer("Buy", (player: Player, item: string, count: number) => { ... }, { rateLimit: 5 })
10
11
  -- bridge.sendTo(player, "Toast", "bought")
12
+ -- bridge.onInvoke("Price", (player: Player, item: string) => { return 100 })
11
13
  -- bridge.onBreach((player: Player, reason: string) => { player:Kick() })
12
14
  --
13
15
  -- `bridge` is only ever called. Kept in a variable, exported or passed along, it
14
16
  -- does not build: the handle stays in the file that claimed it. Each file that
15
17
  -- calls it claims it as it loads.
16
18
  --
17
- -- Arguments travel as JSON: strings, numbers, booleans, and tables of them.
18
- -- Client to server, each packet is sealed with AES-GCM and numbered.
19
+ -- Arguments travel as JSON: strings, numbers, booleans, `nil`, and tables of
20
+ -- them, and Instances by their path from `game`, found again on arrival — `nil`
21
+ -- where the other side has nothing there. Vector3, CFrame, Color3, UDim2, EnumItem
22
+ -- and Roblox's other value types go by their components and are made again. What
23
+ -- is sent in one frame goes as one
24
+ -- packet at the next Heartbeat. Client to server, each packet is sealed with
25
+ -- AES-GCM and numbered.
26
+
27
+ -- A handler's own limits, on top of the bridge's. Past either, a message is
28
+ -- dropped before its arguments are decoded. 0 or missing for no limit.
29
+ type bridge.HandlerOptions = {
30
+ -- Messages of this name a sender may have this handler run a second
31
+ rateLimit?: number,
32
+ -- The largest message, in bytes of its arguments as JSON
33
+ maxPacket?: number,
34
+ }
19
35
 
20
36
  declare bridge: {
21
37
  -- Client: to the server's handler of that name
22
38
  send: (name: string, ...args: any[]) => void,
39
+ -- Client: the server's onInvoke handler of that name, waited for: what it
40
+ -- returns. An error when there is no answer — no handler within ten
41
+ -- seconds, over its limits, or the handler failed.
42
+ invoke: (name: string, ...args: any[]) => any,
23
43
  -- Client: what the server sends by that name. Every handler of a name runs;
24
44
  -- what comes before the first waits for it.
25
- onClient: (name: string, handler: (...args: any[]) => void) => void,
45
+ onClient: (name: string, handler: (...args: any[]) => void, options?: bridge.HandlerOptions) => void,
26
46
  -- Server: what a client sends by that name, with the player first. Every
27
47
  -- handler of a name runs; what comes before the first waits for it.
28
- onServer: (name: string, handler: (player: Player, ...args: any[]) => void) => void,
48
+ onServer: (name: string, handler: (player: Player, ...args: any[]) => void, options?: bridge.HandlerOptions) => void,
49
+ -- Server: what answers a client's bridge.invoke of that name, with the player
50
+ -- first; what it returns goes back. One handler a name.
51
+ onInvoke: (name: string, handler: (player: Player, ...args: any[]) => any, options?: bridge.HandlerOptions) => void,
29
52
  -- Server: to one client
30
53
  sendTo: (player: Player, name: string, ...args: any[]) => void,
31
54
  -- Server: to every client
32
55
  sendAll: (name: string, ...args: any[]) => void,
33
56
  -- Server: a client used a token it did not earn, sent a packet the bridge
34
57
  -- did not seal, sent one twice or over bridgeMaxPacket, asked for its key
35
- -- twice, or touched a decoy. A handler is the game's whole answer. With none,
36
- -- the player and their alt accounts are banned for good.
58
+ -- twice, touched a decoy, or replayed a honeypot. A handler is the game's
59
+ -- whole answer. With none, the player and their alt accounts are banned for
60
+ -- good; in Studio the player is kicked instead.
37
61
  onBreach: (handler: (player: Player, reason: string) => void) => void,
38
62
  }