@crashcontinuum/crashcart-stamp 1.0.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 +91 -0
- package/bin/crashcart-stamp.mjs +739 -0
- package/package.json +33 -0
package/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# @crashcontinuum/crashcart-stamp
|
|
2
|
+
|
|
3
|
+
License stamping tool for CrashCart game bundles.
|
|
4
|
+
|
|
5
|
+
Stamps your `.crashcart` files with a license from [Crash BASIC Arcade](https://arcade.crashbasic.com), embedding your subscription tier and commercial use rights directly into the game.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# Use directly with npx (no install required)
|
|
11
|
+
npx @crashcontinuum/crashcart-stamp mygame.crashcart
|
|
12
|
+
|
|
13
|
+
# Or install globally
|
|
14
|
+
npm install -g @crashcontinuum/crashcart-stamp
|
|
15
|
+
crashcart-stamp mygame.crashcart
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
crashcart-stamp <crashcart-file> [options]
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Options
|
|
25
|
+
|
|
26
|
+
| Option | Description |
|
|
27
|
+
|--------|-------------|
|
|
28
|
+
| `-o, --output <file>` | Output file path (default: `<input>.stamped.crashcart`) |
|
|
29
|
+
| `--arcade-url <url>` | Arcade base URL (default: `https://arcade.crashbasic.com`) |
|
|
30
|
+
| `-h, --help` | Show help message |
|
|
31
|
+
| `-v, --version` | Show version number |
|
|
32
|
+
|
|
33
|
+
### Examples
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Stamp a crashcart (creates mygame.stamped.crashcart)
|
|
37
|
+
npx @crashcontinuum/crashcart-stamp mygame.crashcart
|
|
38
|
+
|
|
39
|
+
# Stamp with custom output filename
|
|
40
|
+
npx @crashcontinuum/crashcart-stamp mygame.crashcart -o mygame-licensed.crashcart
|
|
41
|
+
|
|
42
|
+
# Use a local development server
|
|
43
|
+
npx @crashcontinuum/crashcart-stamp mygame.crashcart --arcade-url http://localhost:3000
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## How It Works
|
|
47
|
+
|
|
48
|
+
1. **Reads** your CrashCart file and computes its SHA-256 checksum
|
|
49
|
+
2. **Opens** your browser for authentication with Crash BASIC Arcade
|
|
50
|
+
3. **Fetches** a signed license stamp from the Arcade API
|
|
51
|
+
4. **Creates** a stamped copy of your CrashCart (original file unchanged)
|
|
52
|
+
|
|
53
|
+
The stamp is cryptographically signed and bound to your specific build via the checksum. Any modifications to the CrashCart will invalidate the stamp.
|
|
54
|
+
|
|
55
|
+
**Note:** The original file is never modified. A new `.stamped.crashcart` file is created.
|
|
56
|
+
|
|
57
|
+
## License Tiers
|
|
58
|
+
|
|
59
|
+
| Tier | Commercial Use | Splash Screen | Subscription |
|
|
60
|
+
|------|----------------|---------------|--------------|
|
|
61
|
+
| **Free** | No | 3 seconds, required | Free |
|
|
62
|
+
| **Indie** | Yes | 2 seconds, skippable | Premium ($4.99/mo) |
|
|
63
|
+
| **Studio** | Yes | Removable | Multiplayer ($9.99/mo) |
|
|
64
|
+
|
|
65
|
+
Upgrade your license at: https://arcade.crashbasic.com/pricing
|
|
66
|
+
|
|
67
|
+
## Stamped File Format
|
|
68
|
+
|
|
69
|
+
The stamp is appended to the end of the CrashCart file:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
[Original CrashCart Data]
|
|
73
|
+
[CRSTAMP\0 magic - 8 bytes]
|
|
74
|
+
[Stamp length - 4 bytes uint32 LE]
|
|
75
|
+
[JSON stamp data]
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The original CrashCart content is not modified, ensuring the stamp can be verified by computing:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
checksum = SHA256(file[0:metadataOffset + metadataLength + 64])
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Requirements
|
|
85
|
+
|
|
86
|
+
- Node.js 18.0.0 or later
|
|
87
|
+
- A Crash BASIC Arcade account (free or paid)
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
© 2026 Crash Continuum LLC. All rights reserved.
|
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CrashCart License Stamping Tool
|
|
4
|
+
*
|
|
5
|
+
* Stamps a .crashcart file with a license from Crash BASIC Arcade.
|
|
6
|
+
* Opens a browser for authentication, then fetches and applies the license stamp.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* npx crashcart-stamp <crashcart-file> [options]
|
|
10
|
+
*
|
|
11
|
+
* Options:
|
|
12
|
+
* --arcade-url <url> Arcade base URL (default: https://arcade.crashbasic.com)
|
|
13
|
+
* --output <file> Output file path (default: <input>.stamped.crashcart)
|
|
14
|
+
* --help Show this help message
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'fs';
|
|
18
|
+
import http from 'http';
|
|
19
|
+
import { URL } from 'url';
|
|
20
|
+
import crypto from 'crypto';
|
|
21
|
+
import { exec } from 'child_process';
|
|
22
|
+
import readline from 'readline';
|
|
23
|
+
|
|
24
|
+
// Configuration
|
|
25
|
+
const DEFAULT_ARCADE_URL = 'https://arcade.crashbasic.com';
|
|
26
|
+
const AUTH_CALLBACK_PORT = 19847; // Random high port for callback
|
|
27
|
+
const STAMP_MAGIC = Buffer.from('CRSTAMP\0'); // 8 bytes: license stamp marker
|
|
28
|
+
|
|
29
|
+
// CrashCart constants
|
|
30
|
+
const CRASHCART_MAGIC = Buffer.from('CRSHCART');
|
|
31
|
+
const HEADER_SIZE = 96;
|
|
32
|
+
const SIGNATURE_SIZE = 64;
|
|
33
|
+
const NONCE_SIZE = 12;
|
|
34
|
+
const TAG_SIZE = 16;
|
|
35
|
+
const HKDF_INFO = new TextEncoder().encode('crashcart-aes-key');
|
|
36
|
+
|
|
37
|
+
// Version
|
|
38
|
+
const VERSION = '1.0.0';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse command line arguments
|
|
42
|
+
*/
|
|
43
|
+
function parseArgs() {
|
|
44
|
+
const args = process.argv.slice(2);
|
|
45
|
+
const options = {
|
|
46
|
+
inputFile: null,
|
|
47
|
+
outputFile: null,
|
|
48
|
+
arcadeUrl: DEFAULT_ARCADE_URL,
|
|
49
|
+
help: false,
|
|
50
|
+
version: false,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
for (let i = 0; i < args.length; i++) {
|
|
54
|
+
const arg = args[i];
|
|
55
|
+
|
|
56
|
+
if (arg === '--help' || arg === '-h') {
|
|
57
|
+
options.help = true;
|
|
58
|
+
} else if (arg === '--version' || arg === '-v') {
|
|
59
|
+
options.version = true;
|
|
60
|
+
} else if (arg === '--arcade-url' && args[i + 1]) {
|
|
61
|
+
options.arcadeUrl = args[++i];
|
|
62
|
+
} else if ((arg === '--output' || arg === '-o') && args[i + 1]) {
|
|
63
|
+
options.outputFile = args[++i];
|
|
64
|
+
} else if (!arg.startsWith('-') && !options.inputFile) {
|
|
65
|
+
options.inputFile = arg;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return options;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Show help message
|
|
74
|
+
*/
|
|
75
|
+
function showHelp() {
|
|
76
|
+
console.log(`
|
|
77
|
+
CrashCart License Stamping Tool v${VERSION}
|
|
78
|
+
|
|
79
|
+
Stamps a .crashcart file with a license from Crash BASIC Arcade.
|
|
80
|
+
This embeds your subscription tier and commercial use rights into the game.
|
|
81
|
+
|
|
82
|
+
USAGE:
|
|
83
|
+
npx crashcart-stamp <crashcart-file> [options]
|
|
84
|
+
crashcart-stamp <crashcart-file> [options]
|
|
85
|
+
|
|
86
|
+
OPTIONS:
|
|
87
|
+
--arcade-url <url> Arcade base URL (default: ${DEFAULT_ARCADE_URL})
|
|
88
|
+
-o, --output <file> Output file path (default: <input>.stamped.crashcart)
|
|
89
|
+
-h, --help Show this help message
|
|
90
|
+
-v, --version Show version number
|
|
91
|
+
|
|
92
|
+
NOTE: The original file is never modified. A stamped copy is always created.
|
|
93
|
+
|
|
94
|
+
EXAMPLES:
|
|
95
|
+
# Stamp a crashcart (creates mygame.stamped.crashcart)
|
|
96
|
+
npx crashcart-stamp mygame.crashcart
|
|
97
|
+
|
|
98
|
+
# Stamp with custom output
|
|
99
|
+
npx crashcart-stamp mygame.crashcart -o mygame-licensed.crashcart
|
|
100
|
+
|
|
101
|
+
# Use a different Arcade server (for development)
|
|
102
|
+
npx crashcart-stamp mygame.crashcart --arcade-url http://localhost:3000
|
|
103
|
+
|
|
104
|
+
LICENSE TIERS:
|
|
105
|
+
Free - Non-commercial use, 3-second splash screen
|
|
106
|
+
Indie - Commercial use allowed, 2-second skippable splash
|
|
107
|
+
Studio - Commercial use allowed, splash screen removable
|
|
108
|
+
|
|
109
|
+
Upgrade your license at: ${DEFAULT_ARCADE_URL}/pricing
|
|
110
|
+
`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Derive AES-256 key from game secret using HKDF-SHA256
|
|
115
|
+
*/
|
|
116
|
+
function deriveKey(gameSecret, salt) {
|
|
117
|
+
// HKDF extract
|
|
118
|
+
const prk = crypto.createHmac('sha256', salt).update(gameSecret).digest();
|
|
119
|
+
|
|
120
|
+
// HKDF expand (we only need 32 bytes for AES-256)
|
|
121
|
+
const info = Buffer.concat([HKDF_INFO, Buffer.from([0x01])]);
|
|
122
|
+
const okm = crypto.createHmac('sha256', prk).update(info).digest();
|
|
123
|
+
|
|
124
|
+
return okm.subarray(0, 32);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Decrypt data using AES-256-GCM
|
|
129
|
+
*/
|
|
130
|
+
function decrypt(ciphertext, key, nonce) {
|
|
131
|
+
// Ciphertext includes the 16-byte auth tag at the end
|
|
132
|
+
const tag = ciphertext.subarray(ciphertext.length - TAG_SIZE);
|
|
133
|
+
const encrypted = ciphertext.subarray(0, ciphertext.length - TAG_SIZE);
|
|
134
|
+
|
|
135
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
|
|
136
|
+
decipher.setAuthTag(tag);
|
|
137
|
+
|
|
138
|
+
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
|
139
|
+
return decrypted;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Read and decrypt the CrashCart manifest
|
|
144
|
+
*/
|
|
145
|
+
function readManifest(filePath, gameSecret = null) {
|
|
146
|
+
const fd = fs.openSync(filePath, 'r');
|
|
147
|
+
const headerBuf = Buffer.alloc(HEADER_SIZE);
|
|
148
|
+
fs.readSync(fd, headerBuf, 0, HEADER_SIZE, 0);
|
|
149
|
+
|
|
150
|
+
// Verify magic
|
|
151
|
+
const magic = headerBuf.subarray(0, 8);
|
|
152
|
+
if (!magic.equals(CRASHCART_MAGIC)) {
|
|
153
|
+
fs.closeSync(fd);
|
|
154
|
+
throw new Error('Invalid CrashCart file');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const metadataOffset = Number(headerBuf.readBigUInt64LE(16));
|
|
158
|
+
const metadataLength = Number(headerBuf.readBigUInt64LE(24));
|
|
159
|
+
const salt = headerBuf.subarray(32, 64);
|
|
160
|
+
|
|
161
|
+
// Read encrypted metadata
|
|
162
|
+
const metadataBuf = Buffer.alloc(metadataLength);
|
|
163
|
+
fs.readSync(fd, metadataBuf, 0, metadataLength, metadataOffset);
|
|
164
|
+
fs.closeSync(fd);
|
|
165
|
+
|
|
166
|
+
// Extract nonce (first 12 bytes) and ciphertext
|
|
167
|
+
const nonce = metadataBuf.subarray(0, NONCE_SIZE);
|
|
168
|
+
const ciphertext = metadataBuf.subarray(NONCE_SIZE);
|
|
169
|
+
|
|
170
|
+
// Use provided secret or default to all zeros
|
|
171
|
+
const secret = gameSecret || Buffer.alloc(32);
|
|
172
|
+
|
|
173
|
+
// Derive decryption key
|
|
174
|
+
const key = deriveKey(secret, salt);
|
|
175
|
+
|
|
176
|
+
// Decrypt
|
|
177
|
+
try {
|
|
178
|
+
const plaintext = decrypt(ciphertext, key, nonce);
|
|
179
|
+
return JSON.parse(plaintext.toString('utf8'));
|
|
180
|
+
} catch (err) {
|
|
181
|
+
if (!gameSecret) {
|
|
182
|
+
// If using default (all zeros) failed, the cart might be encrypted
|
|
183
|
+
throw new Error('ENCRYPTED');
|
|
184
|
+
}
|
|
185
|
+
throw new Error('Failed to decrypt manifest - wrong game secret?');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Prompt for game secret
|
|
191
|
+
*/
|
|
192
|
+
async function promptForSecret() {
|
|
193
|
+
const rl = readline.createInterface({
|
|
194
|
+
input: process.stdin,
|
|
195
|
+
output: process.stdout,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
return new Promise((resolve) => {
|
|
199
|
+
rl.question('Enter game secret (hex or base64): ', (answer) => {
|
|
200
|
+
rl.close();
|
|
201
|
+
const trimmed = answer.trim();
|
|
202
|
+
if (!trimmed) {
|
|
203
|
+
resolve(null);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
// Try to parse as hex first, then base64
|
|
207
|
+
if (/^[0-9a-fA-F]+$/.test(trimmed) && trimmed.length === 64) {
|
|
208
|
+
resolve(Buffer.from(trimmed, 'hex'));
|
|
209
|
+
} else {
|
|
210
|
+
resolve(Buffer.from(trimmed, 'base64'));
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Read and parse CrashCart header to extract game info
|
|
218
|
+
*/
|
|
219
|
+
function readCrashCartHeader(filePath) {
|
|
220
|
+
const fd = fs.openSync(filePath, 'r');
|
|
221
|
+
const headerBuf = Buffer.alloc(HEADER_SIZE);
|
|
222
|
+
fs.readSync(fd, headerBuf, 0, HEADER_SIZE, 0);
|
|
223
|
+
fs.closeSync(fd);
|
|
224
|
+
|
|
225
|
+
// Verify magic bytes
|
|
226
|
+
const magic = headerBuf.subarray(0, 8);
|
|
227
|
+
if (!magic.equals(CRASHCART_MAGIC)) {
|
|
228
|
+
throw new Error('Invalid CrashCart file: bad magic bytes');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Parse header
|
|
232
|
+
const version = headerBuf.readUInt16LE(8);
|
|
233
|
+
const flags = headerBuf.readUInt16LE(10);
|
|
234
|
+
const fileCount = headerBuf.readUInt32LE(12);
|
|
235
|
+
const metadataOffset = headerBuf.readBigUInt64LE(16);
|
|
236
|
+
const metadataLength = headerBuf.readBigUInt64LE(24);
|
|
237
|
+
const salt = headerBuf.subarray(32, 64);
|
|
238
|
+
const authorKeyHash = headerBuf.subarray(64, 96);
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
version,
|
|
242
|
+
flags,
|
|
243
|
+
fileCount,
|
|
244
|
+
metadataOffset: Number(metadataOffset),
|
|
245
|
+
metadataLength: Number(metadataLength),
|
|
246
|
+
salt: salt.toString('hex'),
|
|
247
|
+
authorKeyHash: authorKeyHash.toString('hex'),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Calculate the original CrashCart size from its header.
|
|
253
|
+
*
|
|
254
|
+
* The CrashCart format is:
|
|
255
|
+
* [Header: 96 bytes]
|
|
256
|
+
* [Data section: variable]
|
|
257
|
+
* [Metadata section: at metadataOffset, metadataLength bytes]
|
|
258
|
+
* [Signature: 64 bytes]
|
|
259
|
+
*
|
|
260
|
+
* So original size = metadataOffset + metadataLength + 64
|
|
261
|
+
*
|
|
262
|
+
* This is what we checksum, and what the runtime will checksum to verify.
|
|
263
|
+
*/
|
|
264
|
+
function getOriginalCrashCartSize(filePath) {
|
|
265
|
+
const fd = fs.openSync(filePath, 'r');
|
|
266
|
+
const headerBuf = Buffer.alloc(HEADER_SIZE);
|
|
267
|
+
fs.readSync(fd, headerBuf, 0, HEADER_SIZE, 0);
|
|
268
|
+
fs.closeSync(fd);
|
|
269
|
+
|
|
270
|
+
// Verify magic
|
|
271
|
+
const magic = headerBuf.subarray(0, 8);
|
|
272
|
+
if (!magic.equals(CRASHCART_MAGIC)) {
|
|
273
|
+
throw new Error('Invalid CrashCart file');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const metadataOffset = headerBuf.readBigUInt64LE(16);
|
|
277
|
+
const metadataLength = headerBuf.readBigUInt64LE(24);
|
|
278
|
+
|
|
279
|
+
// Original size = metadata end + signature
|
|
280
|
+
return Number(metadataOffset) + Number(metadataLength) + SIGNATURE_SIZE;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Check if crashcart already has a stamp appended
|
|
285
|
+
*/
|
|
286
|
+
function hasExistingStamp(filePath) {
|
|
287
|
+
const stats = fs.statSync(filePath);
|
|
288
|
+
const originalSize = getOriginalCrashCartSize(filePath);
|
|
289
|
+
|
|
290
|
+
// If file is larger than original CrashCart, there's extra data (stamp)
|
|
291
|
+
return stats.size > originalSize;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Compute SHA-256 checksum of the original CrashCart content.
|
|
296
|
+
*
|
|
297
|
+
* We hash exactly [0, originalSize) bytes - the CrashCart without any stamp.
|
|
298
|
+
* The runtime does the same calculation to verify the stamp.
|
|
299
|
+
*
|
|
300
|
+
* File structure after stamping:
|
|
301
|
+
* [Original CrashCart: header + data + metadata + signature] ← checksum this
|
|
302
|
+
* [CRSTAMP\0 magic + length + JSON stamp]
|
|
303
|
+
*/
|
|
304
|
+
function computeChecksum(filePath) {
|
|
305
|
+
const originalSize = getOriginalCrashCartSize(filePath);
|
|
306
|
+
|
|
307
|
+
// Read only the original CrashCart bytes
|
|
308
|
+
const fd = fs.openSync(filePath, 'r');
|
|
309
|
+
const data = Buffer.alloc(originalSize);
|
|
310
|
+
fs.readSync(fd, data, 0, originalSize, 0);
|
|
311
|
+
fs.closeSync(fd);
|
|
312
|
+
|
|
313
|
+
const hash = crypto.createHash('sha256');
|
|
314
|
+
hash.update(data);
|
|
315
|
+
return hash.digest('hex');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Open browser for authentication
|
|
320
|
+
*/
|
|
321
|
+
function openBrowser(url) {
|
|
322
|
+
const platform = process.platform;
|
|
323
|
+
|
|
324
|
+
let cmd;
|
|
325
|
+
if (platform === 'darwin') {
|
|
326
|
+
cmd = `open "${url}"`;
|
|
327
|
+
} else if (platform === 'win32') {
|
|
328
|
+
cmd = `start "" "${url}"`;
|
|
329
|
+
} else {
|
|
330
|
+
cmd = `xdg-open "${url}"`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
exec(cmd, (err) => {
|
|
334
|
+
if (err) {
|
|
335
|
+
console.log(`\nCould not open browser automatically.`);
|
|
336
|
+
console.log(`Please open this URL manually:\n ${url}\n`);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Start local server and wait for auth callback
|
|
343
|
+
*/
|
|
344
|
+
function waitForAuthCallback(arcadeUrl, state) {
|
|
345
|
+
return new Promise((resolve, reject) => {
|
|
346
|
+
let timeoutHandle = null;
|
|
347
|
+
|
|
348
|
+
const cleanup = () => {
|
|
349
|
+
if (timeoutHandle) {
|
|
350
|
+
clearTimeout(timeoutHandle);
|
|
351
|
+
timeoutHandle = null;
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const server = http.createServer((req, res) => {
|
|
356
|
+
const url = new URL(req.url, `http://localhost:${AUTH_CALLBACK_PORT}`);
|
|
357
|
+
|
|
358
|
+
if (url.pathname === '/callback') {
|
|
359
|
+
const token = url.searchParams.get('token');
|
|
360
|
+
const returnedState = url.searchParams.get('state');
|
|
361
|
+
const error = url.searchParams.get('error');
|
|
362
|
+
|
|
363
|
+
// Send response to browser
|
|
364
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
365
|
+
|
|
366
|
+
if (error) {
|
|
367
|
+
res.end(`
|
|
368
|
+
<!DOCTYPE html>
|
|
369
|
+
<html>
|
|
370
|
+
<head><meta charset="utf-8"><title>Authentication Failed</title></head>
|
|
371
|
+
<body style="font-family: system-ui, -apple-system, sans-serif; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f8fafc; color: #1e293b;">
|
|
372
|
+
<div style="text-align: center; padding: 40px; background: #fff; border-radius: 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); max-width: 400px;">
|
|
373
|
+
<div style="width: 64px; height: 64px; background: rgba(220, 38, 38, 0.1); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 20px;">
|
|
374
|
+
<span style="font-size: 32px; color: #dc2626;">✕</span>
|
|
375
|
+
</div>
|
|
376
|
+
<h1 style="color: #dc2626; margin: 0 0 12px; font-size: 24px;">Authentication Failed</h1>
|
|
377
|
+
<p style="margin: 0 0 16px; color: #1e293b;">${escapeHtml(error)}</p>
|
|
378
|
+
<p style="color: #64748b; margin: 0; font-size: 14px;">You can close this window.</p>
|
|
379
|
+
</div>
|
|
380
|
+
</body>
|
|
381
|
+
</html>
|
|
382
|
+
`);
|
|
383
|
+
cleanup();
|
|
384
|
+
server.close();
|
|
385
|
+
reject(new Error(error));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (returnedState !== state) {
|
|
390
|
+
res.end(`
|
|
391
|
+
<!DOCTYPE html>
|
|
392
|
+
<html>
|
|
393
|
+
<head><meta charset="utf-8"><title>Security Error</title></head>
|
|
394
|
+
<body style="font-family: system-ui, -apple-system, sans-serif; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f8fafc; color: #1e293b;">
|
|
395
|
+
<div style="text-align: center; padding: 40px; background: #fff; border-radius: 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); max-width: 400px;">
|
|
396
|
+
<div style="width: 64px; height: 64px; background: rgba(220, 38, 38, 0.1); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 20px;">
|
|
397
|
+
<span style="font-size: 32px; color: #dc2626;">✕</span>
|
|
398
|
+
</div>
|
|
399
|
+
<h1 style="color: #dc2626; margin: 0 0 12px; font-size: 24px;">Security Error</h1>
|
|
400
|
+
<p style="margin: 0 0 16px; color: #1e293b;">State mismatch - possible CSRF attack. Please try again.</p>
|
|
401
|
+
<p style="color: #64748b; margin: 0; font-size: 14px;">You can close this window.</p>
|
|
402
|
+
</div>
|
|
403
|
+
</body>
|
|
404
|
+
</html>
|
|
405
|
+
`);
|
|
406
|
+
cleanup();
|
|
407
|
+
server.close();
|
|
408
|
+
reject(new Error('State mismatch'));
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
res.end(`
|
|
413
|
+
<!DOCTYPE html>
|
|
414
|
+
<html>
|
|
415
|
+
<head><meta charset="utf-8"><title>Authenticated!</title></head>
|
|
416
|
+
<body style="font-family: system-ui, -apple-system, sans-serif; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f8fafc; color: #1e293b;">
|
|
417
|
+
<div style="text-align: center; padding: 40px; background: #fff; border-radius: 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); max-width: 400px;">
|
|
418
|
+
<div style="width: 64px; height: 64px; background: rgba(20, 184, 166, 0.1); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 20px;">
|
|
419
|
+
<span style="font-size: 32px; color: #14b8a6;">✓</span>
|
|
420
|
+
</div>
|
|
421
|
+
<h1 style="color: #14b8a6; margin: 0 0 12px; font-size: 24px;">Authenticated!</h1>
|
|
422
|
+
<p style="margin: 0 0 16px; color: #1e293b;">You can close this window and return to your terminal.</p>
|
|
423
|
+
<p style="color: #64748b; margin: 0; font-size: 14px;">This window will close automatically...</p>
|
|
424
|
+
</div>
|
|
425
|
+
<script>setTimeout(() => window.close(), 2000);</script>
|
|
426
|
+
</body>
|
|
427
|
+
</html>
|
|
428
|
+
`);
|
|
429
|
+
|
|
430
|
+
cleanup();
|
|
431
|
+
server.close();
|
|
432
|
+
resolve(token);
|
|
433
|
+
} else {
|
|
434
|
+
res.writeHead(404);
|
|
435
|
+
res.end('Not found');
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
server.listen(AUTH_CALLBACK_PORT, '127.0.0.1', () => {
|
|
440
|
+
console.log(`Waiting for authentication...`);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
server.on('error', (err) => {
|
|
444
|
+
cleanup();
|
|
445
|
+
if (err.code === 'EADDRINUSE') {
|
|
446
|
+
reject(new Error(`Port ${AUTH_CALLBACK_PORT} is already in use. Please close any other instances.`));
|
|
447
|
+
} else {
|
|
448
|
+
reject(err);
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// Timeout after 5 minutes
|
|
453
|
+
timeoutHandle = setTimeout(() => {
|
|
454
|
+
server.close();
|
|
455
|
+
reject(new Error('Authentication timed out (5 minutes)'));
|
|
456
|
+
}, 5 * 60 * 1000);
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Escape HTML to prevent XSS in error messages
|
|
462
|
+
*/
|
|
463
|
+
function escapeHtml(text) {
|
|
464
|
+
return text
|
|
465
|
+
.replace(/&/g, '&')
|
|
466
|
+
.replace(/</g, '<')
|
|
467
|
+
.replace(/>/g, '>')
|
|
468
|
+
.replace(/"/g, '"');
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Authenticate with the Arcade
|
|
473
|
+
*/
|
|
474
|
+
async function authenticate(arcadeUrl) {
|
|
475
|
+
// Generate state for CSRF protection
|
|
476
|
+
const state = crypto.randomBytes(16).toString('hex');
|
|
477
|
+
|
|
478
|
+
// Build auth URL
|
|
479
|
+
const callbackUrl = `http://127.0.0.1:${AUTH_CALLBACK_PORT}/callback`;
|
|
480
|
+
const authUrl = `${arcadeUrl}/auth/cli?callback=${encodeURIComponent(callbackUrl)}&state=${state}`;
|
|
481
|
+
|
|
482
|
+
console.log(`\nOpening browser for authentication...`);
|
|
483
|
+
console.log(`If the browser doesn't open, visit:\n ${authUrl}\n`);
|
|
484
|
+
|
|
485
|
+
openBrowser(authUrl);
|
|
486
|
+
|
|
487
|
+
// Wait for callback
|
|
488
|
+
const token = await waitForAuthCallback(arcadeUrl, state);
|
|
489
|
+
return token;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Fetch license stamp from Arcade API
|
|
494
|
+
*/
|
|
495
|
+
async function fetchLicenseStamp(arcadeUrl, token, gameId, gameName, checksum) {
|
|
496
|
+
const stampResponse = await fetch(`${arcadeUrl}/api/license/stamp`, {
|
|
497
|
+
method: 'POST',
|
|
498
|
+
headers: {
|
|
499
|
+
'Authorization': `Bearer ${token}`,
|
|
500
|
+
'Content-Type': 'application/json',
|
|
501
|
+
},
|
|
502
|
+
body: JSON.stringify({ gameId, gameName, checksum }),
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
if (!stampResponse.ok) {
|
|
506
|
+
const error = await stampResponse.json().catch(() => ({}));
|
|
507
|
+
|
|
508
|
+
if (stampResponse.status === 401) {
|
|
509
|
+
throw new Error('Authentication failed. Please try again.');
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
throw new Error(error.error || `Failed to fetch license: ${stampResponse.status}`);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return stampResponse.json();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Apply stamp to crashcart file
|
|
520
|
+
*/
|
|
521
|
+
function applyStamp(inputPath, outputPath, stamp) {
|
|
522
|
+
// Get the original CrashCart size (excludes any existing stamp)
|
|
523
|
+
const originalSize = getOriginalCrashCartSize(inputPath);
|
|
524
|
+
|
|
525
|
+
// Read only the original CrashCart content
|
|
526
|
+
const fd = fs.openSync(inputPath, 'r');
|
|
527
|
+
const data = Buffer.alloc(originalSize);
|
|
528
|
+
fs.readSync(fd, data, 0, originalSize, 0);
|
|
529
|
+
fs.closeSync(fd);
|
|
530
|
+
|
|
531
|
+
// Serialize the stamp
|
|
532
|
+
const stampJson = JSON.stringify(stamp);
|
|
533
|
+
const stampData = Buffer.from(stampJson, 'utf8');
|
|
534
|
+
|
|
535
|
+
// Build stamp section: [CRSTAMP\0][4-byte length][JSON]
|
|
536
|
+
const stampLength = Buffer.alloc(4);
|
|
537
|
+
stampLength.writeUInt32LE(stampData.length, 0);
|
|
538
|
+
|
|
539
|
+
const stampSection = Buffer.concat([
|
|
540
|
+
STAMP_MAGIC,
|
|
541
|
+
stampLength,
|
|
542
|
+
stampData,
|
|
543
|
+
]);
|
|
544
|
+
|
|
545
|
+
// Append stamp to original CrashCart
|
|
546
|
+
const outputData = Buffer.concat([data, stampSection]);
|
|
547
|
+
|
|
548
|
+
// Write output
|
|
549
|
+
fs.writeFileSync(outputPath, outputData);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Display stamp info
|
|
554
|
+
*/
|
|
555
|
+
function displayStampInfo(stamp) {
|
|
556
|
+
const tierColors = {
|
|
557
|
+
free: '\x1b[33m', // Yellow
|
|
558
|
+
indie: '\x1b[36m', // Cyan
|
|
559
|
+
studio: '\x1b[35m', // Magenta
|
|
560
|
+
};
|
|
561
|
+
const reset = '\x1b[0m';
|
|
562
|
+
const green = '\x1b[32m';
|
|
563
|
+
const red = '\x1b[31m';
|
|
564
|
+
const dim = '\x1b[2m';
|
|
565
|
+
|
|
566
|
+
console.log(`\n${'─'.repeat(60)}`);
|
|
567
|
+
console.log(`License Stamp Applied`);
|
|
568
|
+
console.log(`${'─'.repeat(60)}`);
|
|
569
|
+
console.log(` Game: ${stamp.gameName}`);
|
|
570
|
+
console.log(` Licensee: ${stamp.licensee}`);
|
|
571
|
+
console.log(` Tier: ${tierColors[stamp.tier] || ''}${stamp.tier.toUpperCase()}${reset}`);
|
|
572
|
+
console.log(` Commercial: ${stamp.features.commercialUse ? green + 'Yes' : red + 'No'}${reset}`);
|
|
573
|
+
console.log(` Splash: ${stamp.features.removeSplash ? green + 'Removable' : stamp.features.skippableSplash ? '2s (skippable)' : '3s (required)'}${reset}`);
|
|
574
|
+
console.log(` Build ID: ${stamp.buildId}`);
|
|
575
|
+
console.log(` Checksum: ${dim}${stamp.gameChecksum.substring(0, 16)}...${reset}`);
|
|
576
|
+
console.log(` Built: ${new Date(stamp.builtAt).toLocaleString()}`);
|
|
577
|
+
console.log(`${'─'.repeat(60)}\n`);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Display upgrade info for free users
|
|
582
|
+
*/
|
|
583
|
+
function displayUpgradeInfo(arcadeUrl) {
|
|
584
|
+
console.log(`
|
|
585
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
586
|
+
│ UPGRADE YOUR LICENSE │
|
|
587
|
+
├─────────────────────────────────────────────────────────────┤
|
|
588
|
+
│ │
|
|
589
|
+
│ Your game has been stamped with a FREE tier license. │
|
|
590
|
+
│ This means: │
|
|
591
|
+
│ • Non-commercial use only │
|
|
592
|
+
│ • 3-second splash screen required │
|
|
593
|
+
│ │
|
|
594
|
+
│ Upgrade to unlock: │
|
|
595
|
+
│ │
|
|
596
|
+
│ PREMIUM ($4.99/mo) │
|
|
597
|
+
│ ✓ Commercial use allowed │
|
|
598
|
+
│ ✓ 2-second skippable splash │
|
|
599
|
+
│ ✓ Sell your games │
|
|
600
|
+
│ │
|
|
601
|
+
│ MULTIPLAYER ($9.99/mo) │
|
|
602
|
+
│ ✓ Everything in Premium │
|
|
603
|
+
│ ✓ Removable splash screen │
|
|
604
|
+
│ ✓ Publish CrashNet multiplayer games │
|
|
605
|
+
│ │
|
|
606
|
+
│ Visit: ${arcadeUrl}/pricing
|
|
607
|
+
│ │
|
|
608
|
+
└─────────────────────────────────────────────────────────────┘
|
|
609
|
+
`);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Main entry point
|
|
615
|
+
*/
|
|
616
|
+
async function main() {
|
|
617
|
+
const options = parseArgs();
|
|
618
|
+
|
|
619
|
+
if (options.version) {
|
|
620
|
+
console.log(`crashcart-stamp v${VERSION}`);
|
|
621
|
+
process.exit(0);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (options.help) {
|
|
625
|
+
showHelp();
|
|
626
|
+
process.exit(0);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (!options.inputFile) {
|
|
630
|
+
console.error('Error: No input file specified\n');
|
|
631
|
+
showHelp();
|
|
632
|
+
process.exit(1);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Verify input file exists
|
|
636
|
+
if (!fs.existsSync(options.inputFile)) {
|
|
637
|
+
console.error(`Error: File not found: ${options.inputFile}`);
|
|
638
|
+
process.exit(1);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Set default output file
|
|
642
|
+
if (!options.outputFile) {
|
|
643
|
+
const inputBase = options.inputFile.replace(/\.crashcart$/i, '');
|
|
644
|
+
options.outputFile = `${inputBase}.stamped.crashcart`;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Resolve to absolute paths for comparison
|
|
648
|
+
const inputPath = fs.realpathSync(options.inputFile);
|
|
649
|
+
const outputPath = fs.existsSync(options.outputFile)
|
|
650
|
+
? fs.realpathSync(options.outputFile)
|
|
651
|
+
: options.outputFile;
|
|
652
|
+
|
|
653
|
+
// Never overwrite the input file
|
|
654
|
+
if (inputPath === outputPath) {
|
|
655
|
+
console.error(`Error: Output file cannot be the same as input file.`);
|
|
656
|
+
console.error(`Use -o to specify a different output path.\n`);
|
|
657
|
+
process.exit(1);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
console.log(`\n🎮 CrashCart License Stamping Tool v${VERSION}\n`);
|
|
661
|
+
console.log(`Input: ${options.inputFile}`);
|
|
662
|
+
console.log(`Output: ${options.outputFile} (original file unchanged)`);
|
|
663
|
+
|
|
664
|
+
try {
|
|
665
|
+
// Read and validate crashcart
|
|
666
|
+
console.log(`\nReading CrashCart header...`);
|
|
667
|
+
const header = readCrashCartHeader(options.inputFile);
|
|
668
|
+
console.log(` Version: ${header.version}`);
|
|
669
|
+
console.log(` Files: ${header.fileCount}`);
|
|
670
|
+
console.log(` Author Key Hash: ${header.authorKeyHash.substring(0, 16)}...`);
|
|
671
|
+
|
|
672
|
+
// Check for existing stamp
|
|
673
|
+
if (hasExistingStamp(options.inputFile)) {
|
|
674
|
+
console.log(`\n⚠️ This CrashCart already has a license stamp.`);
|
|
675
|
+
console.log(` The existing stamp will be replaced.\n`);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Compute checksum of the CrashCart (binds stamp to this specific build)
|
|
679
|
+
console.log(`\nComputing file checksum...`);
|
|
680
|
+
const checksum = computeChecksum(options.inputFile);
|
|
681
|
+
console.log(` SHA-256: ${checksum.substring(0, 16)}...`);
|
|
682
|
+
|
|
683
|
+
// Read manifest to get game info
|
|
684
|
+
console.log(`\nReading manifest...`);
|
|
685
|
+
let manifest;
|
|
686
|
+
try {
|
|
687
|
+
manifest = readManifest(options.inputFile);
|
|
688
|
+
} catch (err) {
|
|
689
|
+
if (err.message === 'ENCRYPTED') {
|
|
690
|
+
console.log(` CrashCart is encrypted. Please provide the game secret.`);
|
|
691
|
+
const secret = await promptForSecret();
|
|
692
|
+
if (!secret) {
|
|
693
|
+
console.error('Error: Game secret is required for encrypted CrashCarts');
|
|
694
|
+
process.exit(1);
|
|
695
|
+
}
|
|
696
|
+
manifest = readManifest(options.inputFile, secret);
|
|
697
|
+
} else {
|
|
698
|
+
throw err;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const gameId = manifest.gameId;
|
|
703
|
+
const gameName = manifest.name || manifest.gameId;
|
|
704
|
+
console.log(` Game: ${gameName} (${gameId})`);
|
|
705
|
+
|
|
706
|
+
if (!gameId) {
|
|
707
|
+
console.error('Error: Could not read game ID from manifest');
|
|
708
|
+
process.exit(1);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Authenticate
|
|
712
|
+
const token = await authenticate(options.arcadeUrl);
|
|
713
|
+
console.log(`\n✓ Authenticated successfully`);
|
|
714
|
+
|
|
715
|
+
// Fetch license stamp (includes checksum)
|
|
716
|
+
console.log(`\nFetching license stamp for "${gameName}"...`);
|
|
717
|
+
const stamp = await fetchLicenseStamp(options.arcadeUrl, token, gameId, gameName, checksum);
|
|
718
|
+
|
|
719
|
+
// Apply stamp
|
|
720
|
+
console.log(`Applying stamp to CrashCart...`);
|
|
721
|
+
applyStamp(options.inputFile, options.outputFile, stamp);
|
|
722
|
+
|
|
723
|
+
// Display results
|
|
724
|
+
displayStampInfo(stamp);
|
|
725
|
+
|
|
726
|
+
// Show upgrade info for free tier
|
|
727
|
+
if (stamp.tier === 'free') {
|
|
728
|
+
displayUpgradeInfo(options.arcadeUrl);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
console.log(`✓ Successfully stamped: ${options.outputFile}\n`);
|
|
732
|
+
|
|
733
|
+
} catch (error) {
|
|
734
|
+
console.error(`\n❌ Error: ${error.message}\n`);
|
|
735
|
+
process.exit(1);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crashcontinuum/crashcart-stamp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "License stamping tool for CrashCart game bundles",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"crashcart-stamp": "./bin/crashcart-stamp.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"keywords": [
|
|
14
|
+
"crashcart",
|
|
15
|
+
"crashbasic",
|
|
16
|
+
"crash-basic",
|
|
17
|
+
"game",
|
|
18
|
+
"license",
|
|
19
|
+
"stamp"
|
|
20
|
+
],
|
|
21
|
+
"author": {
|
|
22
|
+
"name": "Crash Continuum LLC",
|
|
23
|
+
"url": "https://crashcontinuum.com"
|
|
24
|
+
},
|
|
25
|
+
"license": "UNLICENSED",
|
|
26
|
+
"homepage": "https://arcade.crashbasic.com",
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18.0.0"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
}
|
|
33
|
+
}
|