@tilua-types/bridge 2.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 +13 -17
- package/generate.mjs +93 -70
- package/lowering.mjs +228 -187
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -101,6 +101,10 @@ calling a server's member or the other way round, and any output but `"files"`.
|
|
|
101
101
|
secret. A token the build did not make is a breach and gets nothing. A real one
|
|
102
102
|
claims as often as it is offered: the game can clone any script, and it runs
|
|
103
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).
|
|
104
108
|
- **Nothing hands it out.** The module's value is that one claim function. The
|
|
105
109
|
handle is a local of the file: not in its exports, not in tilua's runtime
|
|
106
110
|
(`G`, which anything can `require`). `bridge` read as a value — kept,
|
|
@@ -164,9 +168,12 @@ those. Two more things to know:
|
|
|
164
168
|
|
|
165
169
|
Each build writes the module beside tilua's runtime — `shared/_bridge.luau` in the
|
|
166
170
|
output of a project laid out by `tilua init` — with that build's secret inside,
|
|
167
|
-
obfuscated with [
|
|
171
|
+
obfuscated with [luau-obfuscator](https://www.npmjs.com/package/luau-obfuscator). The
|
|
168
172
|
secret is kept nowhere else, so there is nothing to set up or check in. `--watch`
|
|
169
|
-
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
|
|
170
177
|
`src/shared/_bridge.luau` and a `.tilua` folder to delete.
|
|
171
178
|
|
|
172
179
|
The module runs in one Luau VM: calling `bridge` from an Actor stops with an error
|
|
@@ -188,25 +195,14 @@ many numbers as it takes — and one that does not make the type is `nil`. The r
|
|
|
188
195
|
with `\1` is the bridge's mark, so a key of the game's that does is escaped on the
|
|
189
196
|
way and arrives as it was.
|
|
190
197
|
|
|
191
|
-
The obfuscation hides the secret
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
## Tested
|
|
196
|
-
|
|
197
|
-
`npm test` in the repository compiles a project with it — claims only in the
|
|
198
|
-
files that call `bridge`, nothing in the runtime or the exports — and runs the
|
|
199
|
-
module as server and client over a fake network, plain and obfuscated: traffic
|
|
200
|
-
both ways, one packet a frame each way in order, invokes answered and refused, Instances, value types and `nil`s found again,
|
|
201
|
-
each handler's limits, then a late executor making up a token, firing the packet remote with
|
|
202
|
-
its own bytes and with a captured packet, asking for the
|
|
203
|
-
key, and firing and invoking every decoy. Each is refused and reported. The
|
|
204
|
-
network is a fake: nothing here has run in Roblox.
|
|
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.
|
|
205
201
|
|
|
206
202
|
<!-- compatibility:start -->
|
|
207
203
|
## Compatibility
|
|
208
204
|
|
|
209
|
-
`@tilua-types/bridge@2.
|
|
205
|
+
`@tilua-types/bridge@2.1.0` works with these tilua packages. *Released with* is the
|
|
210
206
|
version it was built and tested against.
|
|
211
207
|
|
|
212
208
|
| Package | Range | Released with | |
|
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/lowering.mjs
CHANGED
|
@@ -1,187 +1,228 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
/**
|
|
3
|
-
* What tilua lowers for `bridge`, and the module it writes.
|
|
4
|
-
*
|
|
5
|
-
* `bridge.send("Buy", item)` is a call of a handle the file holds in a local:
|
|
6
|
-
* `__tilua_bridge.send("Buy", item)`. That local is the first line of every
|
|
7
|
-
* file that calls `bridge`, put there as the host's prelude:
|
|
8
|
-
*
|
|
9
|
-
* local __tilua_bridge = require(<the bridge module>)("<place>", "<token>")
|
|
10
|
-
*
|
|
11
|
-
* where the token is the HMAC of the file's place under the build's secret. It
|
|
12
|
-
* is a local of the file, so neither the module's exports nor the runtime's
|
|
13
|
-
* table (`G`, which anything can `require`) ever hold it. The bridge module
|
|
14
|
-
* hands a handle to whoever offers a place's token, as often as it is offered:
|
|
15
|
-
* the game can clone any script, and it runs again where it lands.
|
|
16
|
-
*
|
|
17
|
-
* Each build has a new secret, so its tokens and its module go with it: what was
|
|
18
|
-
* read from one update is no use on the next. A watching process keeps one, or
|
|
19
|
-
* every file would change on every save.
|
|
20
|
-
*
|
|
21
|
-
* `bridge` read as a value — kept in a variable, exported, passed along — would
|
|
22
|
-
* carry the handle out of the file, so it does not build. Nor does calling a
|
|
23
|
-
* member a `.client` or `.server` file cannot run.
|
|
24
|
-
*
|
|
25
|
-
* The layout is `@tilua-types/roblox`'s, with the claim added to its prelude.
|
|
26
|
-
*/
|
|
27
|
-
import { createHmac, randomBytes } from "node:crypto"
|
|
28
|
-
import { dirname, join, relative, resolve, sep } from "node:path"
|
|
29
|
-
import { luauString } from "@tilua/parser"
|
|
30
|
-
import roblox from "@tilua-types/roblox/lowering.mjs"
|
|
31
|
-
import { bridgeSource, obfuscateBridge } from "./generate.mjs"
|
|
32
|
-
|
|
33
|
-
/** @typedef {import("@tilua/parser").LoweringPlugin} LoweringPlugin */
|
|
34
|
-
|
|
35
|
-
const HANDLE = "__tilua_bridge"
|
|
36
|
-
const CLIENT_MEMBERS = new Set(["send", "invoke", "onClient"])
|
|
37
|
-
const SERVER_MEMBERS = new Set(["onServer", "onInvoke", "sendTo", "sendAll", "onBreach"])
|
|
38
|
-
/** Members taking a handler, which runs on a thread of the module's. */
|
|
39
|
-
const HANDLERS = new Set(["onClient", "onServer", "onInvoke", "onBreach"])
|
|
40
|
-
export const MODULE_NAME = "_bridge.luau"
|
|
41
|
-
|
|
42
|
-
/** A handler runs on a thread the module starts, away from the `xpcall` that
|
|
43
|
-
* reports errors in the project's files: the same guard roblox's `task.spawn`
|
|
44
|
-
* gives a callback. */
|
|
45
|
-
const GUARD = `local __NAME__ = {}
|
|
46
|
-
local fail = __FAIL__
|
|
47
|
-
|
|
48
|
-
function __NAME__.guard(callback)
|
|
49
|
-
if fail == nil then
|
|
50
|
-
return callback
|
|
51
|
-
end
|
|
52
|
-
return function(...)
|
|
53
|
-
local results = table.pack(xpcall(callback, fail, ...))
|
|
54
|
-
if not results[1] then
|
|
55
|
-
error(results[2], 0)
|
|
56
|
-
end
|
|
57
|
-
return table.unpack(results, 2, results.n)
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
`
|
|
61
|
-
|
|
62
|
-
/** The files that called `bridge` in this build, as the call sites name them. */
|
|
63
|
-
const used = new Set()
|
|
64
|
-
|
|
65
|
-
/** A secret and its
|
|
66
|
-
* build writes the same files until something changes
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if (!
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (side === "
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
const project = projectOf(layout.directory)
|
|
129
|
-
|
|
130
|
-
// The module sits beside the runtime, where every script reaches it
|
|
131
|
-
const modulePath = join(dirname(base.runtime ?? layout.runtime), MODULE_NAME)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* What tilua lowers for `bridge`, and the module it writes.
|
|
4
|
+
*
|
|
5
|
+
* `bridge.send("Buy", item)` is a call of a handle the file holds in a local:
|
|
6
|
+
* `__tilua_bridge.send("Buy", item)`. That local is the first line of every
|
|
7
|
+
* file that calls `bridge`, put there as the host's prelude:
|
|
8
|
+
*
|
|
9
|
+
* local __tilua_bridge = require(<the bridge module>)("<place>", "<token>")
|
|
10
|
+
*
|
|
11
|
+
* where the token is the HMAC of the file's place under the build's secret. It
|
|
12
|
+
* is a local of the file, so neither the module's exports nor the runtime's
|
|
13
|
+
* table (`G`, which anything can `require`) ever hold it. The bridge module
|
|
14
|
+
* hands a handle to whoever offers a place's token, as often as it is offered:
|
|
15
|
+
* the game can clone any script, and it runs again where it lands.
|
|
16
|
+
*
|
|
17
|
+
* Each build has a new secret, so its tokens and its module go with it: what was
|
|
18
|
+
* read from one update is no use on the next. A watching process keeps one, or
|
|
19
|
+
* every file would change on every save.
|
|
20
|
+
*
|
|
21
|
+
* `bridge` read as a value — kept in a variable, exported, passed along — would
|
|
22
|
+
* carry the handle out of the file, so it does not build. Nor does calling a
|
|
23
|
+
* member a `.client` or `.server` file cannot run.
|
|
24
|
+
*
|
|
25
|
+
* The layout is `@tilua-types/roblox`'s, with the claim added to its prelude.
|
|
26
|
+
*/
|
|
27
|
+
import { createHmac, randomBytes } from "node:crypto"
|
|
28
|
+
import { dirname, join, relative, resolve, sep } from "node:path"
|
|
29
|
+
import { luauString } from "@tilua/parser"
|
|
30
|
+
import roblox from "@tilua-types/roblox/lowering.mjs"
|
|
31
|
+
import { bridgeSource, obfuscateBridge, obfuscateClaim } from "./generate.mjs"
|
|
32
|
+
|
|
33
|
+
/** @typedef {import("@tilua/parser").LoweringPlugin} LoweringPlugin */
|
|
34
|
+
|
|
35
|
+
const HANDLE = "__tilua_bridge"
|
|
36
|
+
const CLIENT_MEMBERS = new Set(["send", "invoke", "onClient"])
|
|
37
|
+
const SERVER_MEMBERS = new Set(["onServer", "onInvoke", "sendTo", "sendAll", "onBreach"])
|
|
38
|
+
/** Members taking a handler, which runs on a thread of the module's. */
|
|
39
|
+
const HANDLERS = new Set(["onClient", "onServer", "onInvoke", "onBreach"])
|
|
40
|
+
export const MODULE_NAME = "_bridge.luau"
|
|
41
|
+
|
|
42
|
+
/** A handler runs on a thread the module starts, away from the `xpcall` that
|
|
43
|
+
* reports errors in the project's files: the same guard roblox's `task.spawn`
|
|
44
|
+
* gives a callback. */
|
|
45
|
+
const GUARD = `local __NAME__ = {}
|
|
46
|
+
local fail = __FAIL__
|
|
47
|
+
|
|
48
|
+
function __NAME__.guard(callback)
|
|
49
|
+
if fail == nil then
|
|
50
|
+
return callback
|
|
51
|
+
end
|
|
52
|
+
return function(...)
|
|
53
|
+
local results = table.pack(xpcall(callback, fail, ...))
|
|
54
|
+
if not results[1] then
|
|
55
|
+
error(results[2], 0)
|
|
56
|
+
end
|
|
57
|
+
return table.unpack(results, 2, results.n)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
`
|
|
61
|
+
|
|
62
|
+
/** The files that called `bridge` in this build, as the call sites name them. */
|
|
63
|
+
const used = new Set()
|
|
64
|
+
|
|
65
|
+
/** A secret, its module and its files' claims for each project this process
|
|
66
|
+
* builds, so a watching build writes the same files until something changes
|
|
67
|
+
* them: obfuscating anew would change every one. */
|
|
68
|
+
const projects = new Map()
|
|
69
|
+
|
|
70
|
+
/** @param {string} message */
|
|
71
|
+
const raise = message => `error(${luauString(message)}, 0)`
|
|
72
|
+
|
|
73
|
+
const REFUSED = "bridge: the module refused this file's claim: it is from another build — see the warnings above"
|
|
74
|
+
|
|
75
|
+
/** @param {string | undefined} file */
|
|
76
|
+
const sideOf = file => (file?.endsWith(".client.tilua") ? "client" : file?.endsWith(".server.tilua") ? "server" : undefined)
|
|
77
|
+
|
|
78
|
+
/** @param {string} directory */
|
|
79
|
+
function projectOf(directory) {
|
|
80
|
+
let project = projects.get(directory)
|
|
81
|
+
if (!project) {
|
|
82
|
+
project = { secret: randomBytes(32).toString("hex"), modules: new Map(), claims: new Map() }
|
|
83
|
+
projects.set(directory, project)
|
|
84
|
+
}
|
|
85
|
+
return project
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @type {LoweringPlugin} */
|
|
89
|
+
const plugin = {
|
|
90
|
+
runtime: { guard: GUARD },
|
|
91
|
+
|
|
92
|
+
globalCall({ name, at, perFile, report, use }) {
|
|
93
|
+
if (name === "bridge") {
|
|
94
|
+
report("bridge is not a function: call one of its members, as bridge.send(...)")
|
|
95
|
+
return { callee: "error" }
|
|
96
|
+
}
|
|
97
|
+
if (!name.startsWith("bridge.")) return undefined
|
|
98
|
+
const member = name.slice("bridge.".length)
|
|
99
|
+
// A member that is not one is the type checker's to say
|
|
100
|
+
if (!CLIENT_MEMBERS.has(member) && !SERVER_MEMBERS.has(member)) return { callee: `(function() ${raise(`bridge has no member '${member}'`)} end)` }
|
|
101
|
+
if (!perFile) report(`bridge.${member}: the bridge needs "output": "files", where each file claims its own handle`)
|
|
102
|
+
const side = sideOf(at.file)
|
|
103
|
+
if (side === "client" && SERVER_MEMBERS.has(member)) report(`bridge.${member} runs on the server, and this is a client script`)
|
|
104
|
+
if (side === "server" && CLIENT_MEMBERS.has(member)) report(`bridge.${member} runs on the client, and this is a server script`)
|
|
105
|
+
if (at.file !== undefined) used.add(at.file)
|
|
106
|
+
const handle = `(${HANDLE} or ${raise(REFUSED)})`
|
|
107
|
+
if (!HANDLERS.has(member)) return { callee: `${handle}.${member}` }
|
|
108
|
+
const guard = `${use("guard")}.guard`
|
|
109
|
+
return {
|
|
110
|
+
callee: member === "onBreach"
|
|
111
|
+
? `(function(handler) ${handle}.${member}(${guard}(handler)) end)`
|
|
112
|
+
: `(function(name, handler, options) ${handle}.${member}(name, ${guard}(handler), options) end)`,
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
globalValue({ name, report }) {
|
|
117
|
+
if (name === "bridge" || name.startsWith("bridge.")) {
|
|
118
|
+
report("bridge can only be called — bridge.send(...) — never kept, passed or exported")
|
|
119
|
+
return "nil"
|
|
120
|
+
}
|
|
121
|
+
return undefined
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
files(layout) {
|
|
125
|
+
// A new build: what calls `bridge` is gathered again as it lowers
|
|
126
|
+
used.clear()
|
|
127
|
+
const base = roblox.files?.(layout) ?? {}
|
|
128
|
+
const project = projectOf(layout.directory)
|
|
129
|
+
|
|
130
|
+
// The module sits beside the runtime, where every script reaches it
|
|
131
|
+
const modulePath = join(dirname(base.runtime ?? layout.runtime), MODULE_NAME)
|
|
132
|
+
// Where a file is in the game: the Rojo project's place for it, or —
|
|
133
|
+
// with no project to say — its path under outDir, as tilua places it
|
|
134
|
+
/** @param {string} file */
|
|
135
|
+
const placeOf = file => base.place?.(file) ?? placeInFolder(relative(layout.outDir, file))
|
|
136
|
+
const modulePlace = base.place?.(modulePath)
|
|
137
|
+
/** How `file` reaches the module: by its place from `game` when the
|
|
138
|
+
* Rojo project puts it there, by the path between them otherwise.
|
|
139
|
+
* @param {string} file */
|
|
140
|
+
const locate = file => modulePlace
|
|
141
|
+
? [
|
|
142
|
+
`game:GetService(${luauString(modulePlace[0])})`,
|
|
143
|
+
...modulePlace.slice(1).map(part => `:WaitForChild(${luauString(part)})`),
|
|
144
|
+
].join("")
|
|
145
|
+
: luauString(pathBetween(placeOf(file), placeOf(modulePath), base.root))
|
|
146
|
+
|
|
147
|
+
/** @param {string} file */
|
|
148
|
+
const callsBridge = file => {
|
|
149
|
+
const written = relative(layout.outDir, file).split(sep).join("/")
|
|
150
|
+
for (const source of used) {
|
|
151
|
+
const output = relative(layout.rootDir, resolve(layout.directory, source)).split(sep).join("/").replace(/\.tilua$/, ".luau")
|
|
152
|
+
if (output === written) return true
|
|
153
|
+
}
|
|
154
|
+
return false
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
...base,
|
|
159
|
+
prelude(file) {
|
|
160
|
+
const before = base.prelude?.(file)
|
|
161
|
+
if (!callsBridge(file)) return before
|
|
162
|
+
// A module and a script of one name share a place in the game
|
|
163
|
+
const kind = /\.(server|client|local|legacy|plugin)\.luau?$/.exec(file)?.[1]
|
|
164
|
+
// A place by path still has the kind in its name: once is enough
|
|
165
|
+
const placed = placeOf(file).join("/")
|
|
166
|
+
const inGame = kind && placed.endsWith(`.${kind}`) ? placed.slice(0, -kind.length - 1) : placed
|
|
167
|
+
const where = `${inGame}${kind ? `.${kind}` : ""}`
|
|
168
|
+
const token = createHmac("sha256", project.secret).update(where).digest("hex")
|
|
169
|
+
const plain = `local ${HANDLE} = require(${locate(file)})(${luauString(where)}, ${luauString(token)})`
|
|
170
|
+
// The token is all a claim needs, so it is not left in the file
|
|
171
|
+
// as written, nor where the module is: hidden as the module is
|
|
172
|
+
let claim = plain
|
|
173
|
+
if (layout.options.bridgeObfuscate !== false) {
|
|
174
|
+
claim = project.claims.get(plain)
|
|
175
|
+
if (claim === undefined) {
|
|
176
|
+
claim = obfuscateClaim(plain)
|
|
177
|
+
project.claims.set(plain, claim)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return before === undefined ? claim : `${before}\n${claim}`
|
|
181
|
+
},
|
|
182
|
+
async output() {
|
|
183
|
+
if (!used.size) return { files: [] }
|
|
184
|
+
const { bridgeMaxPacket: maxPacket, bridgeRateLimit: rateLimit, bridgeObfuscate } = layout.options
|
|
185
|
+
const key = JSON.stringify([maxPacket, rateLimit, bridgeObfuscate])
|
|
186
|
+
if (!project.modules.has(key)) {
|
|
187
|
+
const source = bridgeSource({
|
|
188
|
+
secret: project.secret,
|
|
189
|
+
maxPacket: typeof maxPacket === "number" ? maxPacket : undefined,
|
|
190
|
+
rateLimit: typeof rateLimit === "number" ? rateLimit : undefined,
|
|
191
|
+
})
|
|
192
|
+
project.modules.set(key, bridgeObfuscate === false ? source : await obfuscateBridge(source))
|
|
193
|
+
}
|
|
194
|
+
const clientOnly = [...used].every(file => sideOf(file) === "client")
|
|
195
|
+
return {
|
|
196
|
+
files: [{ file: modulePath, code: project.modules.get(key) }],
|
|
197
|
+
problems: clientOnly
|
|
198
|
+
? [{ severity: /** @type {const} */ ("warning"), message: "Only client scripts call bridge: nothing starts it on the server, so the clients will wait for it" }]
|
|
199
|
+
: [],
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** A file's place from `outDir`, as tilua gives it when no host does: its path
|
|
207
|
+
* without the extension, and `init.luau` the folder it is in.
|
|
208
|
+
* @param {string} path */
|
|
209
|
+
function placeInFolder(path) {
|
|
210
|
+
const parts = path.split(sep).join("/").replace(/\.luau?$/, "").split("/").filter(part => part !== "")
|
|
211
|
+
if (parts.at(-1) === "init") parts.pop()
|
|
212
|
+
return parts
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** What a file at `from` passes to `require` to reach the one at `to`, both as
|
|
216
|
+
* places: tilua's own rule, so the module is reached as the runtime is.
|
|
217
|
+
* @param {readonly string[]} from @param {readonly string[]} to @param {string | undefined} root */
|
|
218
|
+
function pathBetween(from, to, root) {
|
|
219
|
+
if (root !== undefined && from[0] !== to[0]) return `${root}/${to.join("/")}`
|
|
220
|
+
if (to.length > from.length && from.every((part, i) => to[i] === part)) return `@self/${to.slice(from.length).join("/")}`
|
|
221
|
+
const base = from.slice(0, -1)
|
|
222
|
+
let shared = 0
|
|
223
|
+
while (shared < base.length && shared < to.length - 1 && base[shared] === to[shared]) shared++
|
|
224
|
+
const up = base.length - shared
|
|
225
|
+
return `${up === 0 ? "./" : "../".repeat(up)}${to.slice(shared).join("/")}`
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export default plugin
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tilua-types/bridge",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "bridge: Roblox client-server networking through a per-game, obfuscated module that hands each file its handle once, with AES-GCM packets and decoy remotes",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"type": "module",
|
|
@@ -33,14 +33,14 @@
|
|
|
33
33
|
"bridgeObfuscate": {
|
|
34
34
|
"type": "boolean",
|
|
35
35
|
"default": true,
|
|
36
|
-
"description": "Obfuscate the bridge module. Off only to read it while debugging; never ship that."
|
|
36
|
+
"description": "Obfuscate the bridge module, and the strings of the claim at the top of each file that calls it. Off only to read it while debugging; never ship that."
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@tilua-types/crypto": "^1.0.0",
|
|
42
42
|
"@tilua-types/roblox": "^10.1.2",
|
|
43
|
-
"
|
|
43
|
+
"luau-obfuscator": "^1.1.0"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
46
|
"@tilua/parser": "^9.1.2"
|