@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 +86 -22
- package/generate.mjs +93 -70
- package/index.d.tilua +31 -7
- package/lowering.mjs +228 -187
- package/package.json +3 -3
- package/runtime/bridge.luau +830 -103
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
|
|
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 [
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
*
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
.replace("
|
|
42
|
-
.replace("
|
|
43
|
-
.replace("
|
|
44
|
-
.replace("
|
|
45
|
-
.replace("
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
{
|
|
56
|
-
{
|
|
57
|
-
{
|
|
58
|
-
{
|
|
59
|
-
{
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
18
|
-
--
|
|
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
|
|
36
|
-
-- the player and their alt accounts are banned for
|
|
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
|
}
|