nitro-patcher 0.3.0 → 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/src/nitro/twl.ts CHANGED
@@ -1,10 +1,13 @@
1
- import { hashDigest, hmacSha1, readFileSync, verifyRsaSha1 } from "#nitro-runtime";
1
+ import { hmacSha1, readFileSync, verifyRsaSha1 } from "#nitro-runtime";
2
2
 
3
3
  import type { BinaryWriter } from "./binary";
4
+ import { DEFAULT_MAX_SIZE } from "./binary";
4
5
  import type { Arm9Header } from "./blz";
5
6
  import { compressArm9, decompressArm9 } from "./blz";
6
7
  import type { ModcryptHeader } from "./crypto";
7
8
  import { aes128CtrCrypt, encryptSecureArea, modcryptKey } from "./crypto";
9
+ import { Header } from "./header";
10
+ import { CompleteTWL } from "./twl-complete";
8
11
 
9
12
  export interface TwlHeader extends ModcryptHeader {
10
13
  digest_sector_size: number;
@@ -108,9 +111,46 @@ export class TWL {
108
111
  readonly Header2Data: Buffer[] | null;
109
112
  readonly Overlays9Sha1Hmac: Buffer[];
110
113
  readonly twlEncrypted: boolean;
111
- DSi9Data: Buffer;
112
- DSi7Data: Buffer;
113
- constructor(header: TwlHeader, overlays: readonly OverlayRange[], original: Buffer) {
114
+ readonly complete: CompleteTWL | undefined;
115
+ private dsi9Data: Buffer = Buffer.alloc(0);
116
+ private dsi7Data: Buffer = Buffer.alloc(0);
117
+ get DSi9Data(): Buffer {
118
+ return this.complete?.DSi9Data ?? this.dsi9Data;
119
+ }
120
+ set DSi9Data(data: Buffer) {
121
+ if (this.complete) this.complete.DSi9Data = data;
122
+ else this.dsi9Data = data;
123
+ }
124
+ get DSi7Data(): Buffer {
125
+ return this.complete?.DSi7Data ?? this.dsi7Data;
126
+ }
127
+ set DSi7Data(data: Buffer) {
128
+ if (this.complete) this.complete.DSi7Data = data;
129
+ else this.dsi7Data = data;
130
+ }
131
+ constructor(
132
+ header: TwlHeader,
133
+ overlays: readonly OverlayRange[],
134
+ original: Buffer,
135
+ options: { dsiMode?: "legacy" | "complete"; maxOutputSize?: number } = {},
136
+ ) {
137
+ if (options.dsiMode === "complete") {
138
+ if (!(header instanceof Header)) throw new TypeError("Complete DSi handling requires a parsed Header");
139
+ this.complete = new CompleteTWL(
140
+ header,
141
+ overlays.map((overlay) => slice(original, overlay.offset, overlay.size, "Signed ARM9 overlay")),
142
+ original,
143
+ options.maxOutputSize ?? DEFAULT_MAX_SIZE,
144
+ );
145
+ this.firstDSiHashOffset = this.complete.firstDSiHashOffset;
146
+ this.dsiHashSize = this.complete.dsiHashSize;
147
+ this.Hashtable1Data = this.complete.Hashtable1Data;
148
+ this.Hashtable2Data = this.complete.Hashtable2Data;
149
+ this.Header2Data = this.complete.Header2Data;
150
+ this.Overlays9Sha1Hmac = this.complete.Overlays9Sha1Hmac;
151
+ this.twlEncrypted = this.complete.twlEncrypted;
152
+ return;
153
+ }
114
154
  validate(header);
115
155
  const h = header;
116
156
  this.firstDSiHashOffset = (h.digest_ntr_size / h.digest_sector_size) * 20;
@@ -167,9 +207,11 @@ export class TWL {
167
207
  }
168
208
  }
169
209
  importArm9iData(data: Buffer | string, offset = 0, size?: number): void {
210
+ if (this.complete) return this.complete.importArm9iData(data, offset, size);
170
211
  this.DSi9Data = this.importData(data, offset, size);
171
212
  }
172
213
  importArm7iData(data: Buffer | string, offset = 0, size?: number): void {
214
+ if (this.complete) return this.complete.importArm7iData(data, offset, size);
173
215
  this.DSi7Data = this.importData(data, offset, size);
174
216
  }
175
217
  private importData(data: Buffer | string, offset: number, size?: number): Buffer {
@@ -181,6 +223,7 @@ export class TWL {
181
223
  return out;
182
224
  }
183
225
  updateOverlays9Sha1Hmac(arm9Data: Buffer, header: Arm9Header, overlays: readonly Buffer[]): Buffer {
226
+ if (this.complete) return this.complete.updateOverlays9Sha1Hmac(arm9Data, header, overlays);
184
227
  if (overlays.length !== this.Overlays9Sha1Hmac.length)
185
228
  throw new Error("Changing ARM9 overlay count is unsupported");
186
229
  const hashes = overlays.map(hash);
@@ -203,6 +246,7 @@ export class TWL {
203
246
  return unpacked.compressed ? compressArm9(unpacked.data, header, arm9Data.length - hdrptr) : unpacked.data;
204
247
  }
205
248
  writeTo(writer: BinaryWriter, h: TwlHeader): Buffer {
249
+ if (this.complete) throw new Error("Complete DSi serialization requires NDSFile.toBuffer()");
206
250
  validate(h);
207
251
  // Fixed DSi regions cannot be shifted safely without rewriting the header.
208
252
  if (writer.position > h.sector_hashtable_start) {
@@ -266,32 +310,22 @@ export class TWL {
266
310
  }
267
311
  }
268
312
 
269
- /** Refresh the digest and keep valid RSA signatures; optional no$gba hash mask. */
313
+ /** Refresh the master digest while preserving the cartridge signature. */
270
314
  export const updateHeaderSignatures = (
271
315
  writer: BinaryWriter,
272
316
  header: SignedTwlHeader,
273
317
  originalHeader: Buffer,
274
- keepOriginal: boolean,
318
+ _keepOriginal = true,
275
319
  ): void => {
276
320
  const signedData = Buffer.from(slice(originalHeader, 0, 0xe00, "Signed header"));
277
321
  const digest = slice(header.hmac_digest_master, 0, 20, "Master digest");
278
322
  const signature = slice(header.rsa_signature, 0, 128, "RSA signature");
279
323
  digest.copy(signedData, 0x328);
280
- const valid = verifyRsaSha1(signedData, signature);
324
+ verifyRsaSha1(signedData, signature);
281
325
  const position = writer.position;
282
326
  writer.position = 0x328;
283
327
  writer.write(digest);
284
- if (!valid) {
285
- if (!keepOriginal) {
286
- const mask = Buffer.alloc(128, 0xff);
287
- mask[0] = 0;
288
- mask[1] = 1;
289
- mask[107] = 0;
290
- hashDigest("sha1", signedData).copy(mask, 108);
291
- header.rsa_signature = mask;
292
- }
293
- writer.position = 0xf80;
294
- writer.write(header.rsa_signature);
295
- }
328
+ writer.position = 0xf80;
329
+ writer.write(signature);
296
330
  writer.position = position;
297
331
  };
@@ -6,9 +6,10 @@ export { FileAllocationTable, FileNameTable, OverlayTable } from "./nitro/filesy
6
6
  export type { FatEntry, NitroFile, NitroFolder, OverlayItem } from "./nitro/filesystem";
7
7
  export { BinaryWriter } from "./nitro/binary";
8
8
  export { crc16, crc32 } from "./nitro/crc";
9
- export { aes128CtrCrypt, encryptSecureArea, modcryptKey } from "./nitro/crypto";
9
+ export { aes128CtrCrypt, decryptSecureArea, encryptSecureArea, modcryptKey } from "./nitro/crypto";
10
10
  export type { ModcryptHeader } from "./nitro/crypto";
11
11
  export { compressArm9, compressBLZ, decompressArm9, decompressBLZ } from "./nitro/blz";
12
12
  export type { Arm9Header } from "./nitro/blz";
13
13
  export { TWL, updateHeaderSignatures } from "./nitro/twl";
14
14
  export type { OverlayRange, SignedTwlHeader, TwlHeader } from "./nitro/twl";
15
+ export type { DsiCheck, DsiVerification } from "./nitro/twl-complete";
@@ -2,9 +2,11 @@
2
2
  import { hashDigest, readFile } from "#nitro-runtime";
3
3
 
4
4
  import { bytes } from "./nitro/binary";
5
+ import { OverlayTable } from "./nitro/filesystem";
5
6
  import { NDSFile } from "./nitro/nds-file";
7
+ import type { DsiVerification } from "./nitro/twl-complete";
6
8
  import { decodeXdelta } from "./patch/xdelta";
7
- import { extractZipEntry as extractEntry, readZip } from "./patch/zip";
9
+ import { extractZipEntry as extractEntry, readZip, readZipEntries } from "./patch/zip";
8
10
 
9
11
  export const PatchReturnValue = Object.freeze({
10
12
  SUCCESS: "SUCCESS",
@@ -19,6 +21,7 @@ export interface PatchResult {
19
21
  /** MD5 of the original input, before preprocessing. */
20
22
  inputMd5: string;
21
23
  outputMd5: string;
24
+ dsi?: DsiVerification;
22
25
  }
23
26
 
24
27
  export interface PatchOptions {
@@ -33,6 +36,7 @@ export interface PatchOptions {
33
36
  }
34
37
 
35
38
  export interface PatchMetadata {
39
+ formatVersion?: number;
36
40
  id?: string;
37
41
  author?: string;
38
42
  name?: string;
@@ -77,6 +81,13 @@ const readMetadata = (files: Map<string, Buffer>): PatchMetadata | null => {
77
81
 
78
82
  const value = metadata as Record<string, unknown>;
79
83
  const result: PatchMetadata = {};
84
+ if (value.formatVersion !== undefined) {
85
+ const version = value.formatVersion;
86
+ if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1)
87
+ throw new Error("metadata.json formatVersion must be a positive safe integer");
88
+ if (version !== 1 && version !== 2) throw new Error(`Unsupported XZP format version: ${version}`);
89
+ result.formatVersion = version;
90
+ }
80
91
  for (const field of ["id", "author", "name", "homepage", "version"] as const) {
81
92
  const fieldValue = value[field];
82
93
  if (fieldValue === undefined) continue;
@@ -146,10 +157,18 @@ export const readPatchInfoFile = async (patchPath: string, options: PatchOptions
146
157
  /** Apply an existing NitroPatcher ZIP package entirely in memory. */
147
158
  export const patchBuffer = (original: Uint8Array, patch: Uint8Array, options: PatchOptions = {}): PatchResult => {
148
159
  const input = bytes(original, "original ROM");
149
- const patches = readZip(patch, options);
160
+ const entries = readZipEntries(patch, options);
161
+ const patches = new Map([...entries].map(([name, entry]) => [name, entry.data]));
162
+ const formatVersion = readMetadata(patches)?.formatVersion ?? 1;
150
163
  const inputMd5 = md5(input);
151
164
  const preprocessing = patches.get(`preprocessing/${inputMd5}.xdelta`);
152
- const rom = new NDSFile(preprocessing ? decodeXdelta(input, preprocessing, options) : input, options);
165
+ const romOptions = { ...options, dsiMode: formatVersion === 2 ? ("complete" as const) : ("legacy" as const) };
166
+ const rom = new NDSFile(preprocessing ? decodeXdelta(input, preprocessing, options) : input, romOptions);
167
+ if (formatVersion === 2)
168
+ for (const name of ["arm9i.bin", "arm7i.bin"]) {
169
+ if ((patches.has(name) || patches.has(`xdelta/${name}`)) && !rom.hasFile(name))
170
+ throw new Error(`${name} requires a complete DSi-enhanced cartridge`);
171
+ }
153
172
  let matched = true;
154
173
  const md5File = patches.get("md5.txt");
155
174
  if (md5File) {
@@ -171,12 +190,54 @@ export const patchBuffer = (original: Uint8Array, patch: Uint8Array, options: Pa
171
190
  const replacement = delta ? decodeXdelta(rom.getFile(path), delta, options) : patches.get(normalized);
172
191
  if (replacement) rom.replaceFile(path, replacement);
173
192
  }
193
+ if (formatVersion === 2) {
194
+ for (const name of ["overarm9.bin", "overarm7.bin"])
195
+ if (!rom.hasFile(name)) {
196
+ const delta = patches.get(`xdelta/${name}`);
197
+ const replacement = delta ? decodeXdelta(Buffer.alloc(0), delta, options) : patches.get(name);
198
+ if (replacement) rom.replaceFile(name, replacement);
199
+ }
200
+ const candidates = new Map<string, { path: string; data: Buffer; delta: boolean }>();
201
+ for (const [name, entry] of entries) {
202
+ const delta = name.startsWith("xdelta/");
203
+ const target = delta ? name.slice(7) : name;
204
+ if ((!target.startsWith("data/") && !target.startsWith("overlay/")) || rom.hasFile(target)) continue;
205
+ if (!delta && entries.has(`xdelta/${target}`)) continue;
206
+ candidates.set(target, { path: delta ? entry.path.slice(7) : entry.path, data: entry.data, delta });
207
+ }
208
+ if (candidates.size && (patches.has("fnt.bin") || patches.has("xdelta/fnt.bin")))
209
+ throw new Error("Adding files conflicts with an fnt.bin replacement");
210
+ const overlayTables = new Map<string, OverlayTable>();
211
+ for (const name of ["overarm9.bin", "overarm7.bin"])
212
+ if (rom.hasFile(name)) {
213
+ const raw = rom.getFile(name);
214
+ overlayTables.set(name, new OverlayTable(raw, 0, raw.length, name === "overarm9.bin"));
215
+ }
216
+ const additions = [...candidates.values()].sort((a, b) => Buffer.compare(Buffer.from(a.path), Buffer.from(b.path)));
217
+ for (const entry of additions) {
218
+ const content = entry.delta ? decodeXdelta(Buffer.alloc(0), entry.data, options) : entry.data;
219
+ if (entry.path.toLowerCase().startsWith("overlay/")) {
220
+ const match = /^overlay\/overlay(7)?_(\d{4,})\.bin$/i.exec(entry.path);
221
+ const overlayId = match?.[2];
222
+ const arm9 = !match?.[1];
223
+ if (!overlayId || String(Number(overlayId)).padStart(4, "0") !== overlayId)
224
+ throw new Error(`Invalid overlay path: ${entry.path}`);
225
+ const name = arm9 ? "overarm9.bin" : "overarm7.bin";
226
+ const table = overlayTables.get(name);
227
+ if (!table) throw new Error(`New overlay requires ${name}: ${entry.path}`);
228
+ const metadata = table.entries.find((item) => item.overlayId === Number(overlayId));
229
+ if (!metadata) throw new Error(`New overlay is missing its table entry: ${entry.path}`);
230
+ rom.addOverlay(arm9, metadata, content);
231
+ } else rom.addFile(entry.path, content);
232
+ }
233
+ }
174
234
  const buffer = rom.toBuffer();
175
235
  return {
176
236
  buffer,
177
237
  returnValue: matched ? PatchReturnValue.SUCCESS : PatchReturnValue.MD5_MISMATCH,
178
238
  inputMd5,
179
239
  outputMd5: md5(buffer),
240
+ ...(formatVersion === 2 && rom.header.unitCode === 2 && { dsi: new NDSFile(buffer, romOptions).verifyDsi() }),
180
241
  };
181
242
  };
182
243
 
package/src/patch/zip.ts CHANGED
@@ -8,6 +8,11 @@ export interface ZipOptions {
8
8
  maxEntries?: number;
9
9
  }
10
10
 
11
+ export interface ZipEntry {
12
+ path: string;
13
+ data: Buffer;
14
+ }
15
+
11
16
  const fail = (message: string): never => {
12
17
  throw new Error(`ZIP: ${message}`);
13
18
  };
@@ -64,7 +69,7 @@ function filename(raw: Buffer, flags: number, fields: Map<number, Buffer>): stri
64
69
  * ZIP64, encryption, split archives and other compression methods fail explicitly.
65
70
  * Format reference: https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT
66
71
  */
67
- export const readZip = (input: Uint8Array, options: ZipOptions = {}): Map<string, Buffer> => {
72
+ export const readZipEntries = (input: Uint8Array, options: ZipOptions = {}): Map<string, ZipEntry> => {
68
73
  if (!(input instanceof Uint8Array)) throw new TypeError("ZIP input must be a Buffer or Uint8Array");
69
74
  const bytes = Buffer.from(input.buffer, input.byteOffset, input.byteLength);
70
75
  const maxSize = boundOption(options.maxUncompressedSize, 1024 * 1024 * 1024, "maxUncompressedSize");
@@ -96,7 +101,7 @@ export const readZip = (input: Uint8Array, options: ZipOptions = {}): Map<string
96
101
  checkRange(bytes, centralOffset, centralSize, eocd);
97
102
  if (centralOffset + centralSize !== eocd) fail("central directory size/offset mismatch");
98
103
  if (count > Math.floor(centralSize / 46)) fail("entry count exceeds central directory size");
99
- const result = new Map<string, Buffer>();
104
+ const result = new Map<string, ZipEntry>();
100
105
  const ranges: { start: number; end: number }[] = [];
101
106
  let offset = centralOffset,
102
107
  totalSize = 0;
@@ -124,7 +129,8 @@ export const readZip = (input: Uint8Array, options: ZipOptions = {}): Map<string
124
129
  checkRange(bytes, offset + 46, nameLength + extraLength + commentLength, eocd);
125
130
  const rawName = bytes.subarray(offset + 46, offset + 46 + nameLength);
126
131
  const fields = extras(bytes.subarray(offset + 46 + nameLength, offset + 46 + nameLength + extraLength));
127
- const name = filename(rawName, flags, fields).replaceAll("\\", "/").toLowerCase();
132
+ const path = filename(rawName, flags, fields).replaceAll("\\", "/");
133
+ const name = path.toLowerCase();
128
134
  offset += 46 + nameLength + extraLength + commentLength;
129
135
 
130
136
  checkRange(bytes, localOffset, 30, centralOffset);
@@ -177,7 +183,7 @@ export const readZip = (input: Uint8Array, options: ZipOptions = {}): Map<string
177
183
  }
178
184
  if (data.length !== size) fail(`uncompressed size mismatch for ${JSON.stringify(name)}`);
179
185
  if (crc32(data) !== checksum) fail(`CRC32 mismatch for ${JSON.stringify(name)}`);
180
- if (name && !name.endsWith("/")) result.set(name, data);
186
+ if (name && !name.endsWith("/")) result.set(name, { path, data });
181
187
  }
182
188
  if (offset !== eocd) fail("central directory entry count/length mismatch");
183
189
  ranges.sort((a, b) => a.start - b.start);
@@ -186,6 +192,10 @@ export const readZip = (input: Uint8Array, options: ZipOptions = {}): Map<string
186
192
  return result;
187
193
  };
188
194
 
195
+ /** Read file contents using normalized paths, with the final duplicate winning. */
196
+ export const readZip = (input: Uint8Array, options: ZipOptions = {}): Map<string, Buffer> =>
197
+ new Map([...readZipEntries(input, options)].map(([name, entry]) => [name, entry.data]));
198
+
189
199
  /** Extract one file using the same normalized, case-insensitive paths as readZip. */
190
200
  export const extractZipEntry = (input: Uint8Array, entryName: string, options: ZipOptions = {}): Buffer => {
191
201
  if (typeof entryName !== "string" || !entryName) throw new TypeError("ZIP entry name must be a non-empty string");
@@ -2,6 +2,7 @@ import { ecb } from "#browser-aes";
2
2
  import { md5, sha1 } from "#browser-hashes";
3
3
  import { hmac } from "#browser-hmac";
4
4
  import { Inflate } from "fflate";
5
+ export { verifyDsiSignature as verifyRsaSha1 } from "./rsa";
5
6
 
6
7
  export const hashDigest = (algorithm: "md5" | "sha1", data: Uint8Array): Buffer =>
7
8
  Buffer.from((algorithm === "md5" ? md5 : sha1)(data));
@@ -30,9 +31,8 @@ export const inflateRawSync = (data: Uint8Array, options: { maxOutputLength: num
30
31
  // These Node-only methods are not exposed by the browser public API. Keep a
31
32
  // clear error if a future internal call accidentally crosses that boundary.
32
33
  const nodeOnly = (): never => {
33
- throw new Error("Filesystem and RSA APIs are only available in the Node entry");
34
+ throw new Error("Filesystem APIs are only available in the Node entry");
34
35
  };
35
36
  export const readFile = nodeOnly;
36
37
  export const writeFile = nodeOnly;
37
38
  export const readFileSync = nodeOnly;
38
- export const verifyRsaSha1 = nodeOnly;
@@ -1,4 +1,5 @@
1
- import { createCipheriv, createHash, createHmac, createPublicKey, verify } from "node:crypto";
1
+ import { createCipheriv, createHash, createHmac } from "node:crypto";
2
+ export { verifyDsiSignature as verifyRsaSha1 } from "./rsa";
2
3
  export { readFile, writeFile } from "node:fs/promises";
3
4
  export { readFileSync } from "node:fs";
4
5
  export { inflateRawSync } from "node:zlib";
@@ -11,17 +12,3 @@ export const createAesBlockCipher = (key: Uint8Array): ((block: Uint8Array) => B
11
12
  cipher.setAutoPadding(false);
12
13
  return (block) => cipher.update(block);
13
14
  };
14
- export const verifyRsaSha1 = (data: Uint8Array, signature: Uint8Array): boolean => {
15
- const key = createPublicKey({
16
- key: {
17
- kty: "RSA",
18
- n: Buffer.from(
19
- "956F790DF08BB85A76AAEFA27FE874758BED9EDF9E9A670CD818BEB9B2885203B3FA11AEAA186513B5D6BB85A384D0D0EFB366CBC6051AAA86827AB74311F59C9BFC6C7079D5F17BD0819F522056738C721F40CF2361932590A3C5DC94CFD17A8CBC954A918AA858F4D804BAF7D3C1C4D7B8F077012FA170260B2C049056F3A5",
20
- "hex",
21
- ).toString("base64url"),
22
- e: "AQAB",
23
- },
24
- format: "jwk",
25
- });
26
- return verify("RSA-SHA1", data, key, signature);
27
- };
@@ -0,0 +1,14 @@
1
+ import forge from "node-forge";
2
+
3
+ import { hashDigest } from "#nitro-runtime";
4
+
5
+ const publicKey = forge.pki.rsa.setPublicKey(
6
+ new forge.jsbn.BigInteger(
7
+ "956F790DF08BB85A76AAEFA27FE874758BED9EDF9E9A670CD818BEB9B2885203B3FA11AEAA186513B5D6BB85A384D0D0EFB366CBC6051AAA86827AB74311F59C9BFC6C7079D5F17BD0819F522056738C721F40CF2361932590A3C5DC94CFD17A8CBC954A918AA858F4D804BAF7D3C1C4D7B8F077012FA170260B2C049056F3A5",
8
+ 16,
9
+ ),
10
+ new forge.jsbn.BigInteger("10001", 16),
11
+ );
12
+
13
+ export const verifyDsiSignature = (data: Uint8Array, signature: Uint8Array): boolean =>
14
+ publicKey.verify(hashDigest("sha1", data).toString("latin1"), Buffer.from(signature).toString("latin1"), "NONE");