@tootallnate/aes-xts 0.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/.turbo/turbo-build.log +4 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +211 -0
- package/dist/index.js.map +1 -0
- package/package.json +17 -0
- package/src/index.ts +298 -0
- package/test/index.test.ts +114 -0
- package/tsconfig.json +14 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AES-128-XTS encryption/decryption using Web Crypto.
|
|
3
|
+
*
|
|
4
|
+
* Supports Nintendo's big-endian tweak format, where the sector number
|
|
5
|
+
* is stored as a big-endian 128-bit value (opposite of the IEEE P1619
|
|
6
|
+
* standard which uses little-endian).
|
|
7
|
+
*
|
|
8
|
+
* Implementation uses Web Crypto's AES-CBC with a zero IV to perform
|
|
9
|
+
* single-block AES-ECB operations, then builds the XTS mode on top.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Encrypt data using AES-128-XTS with Nintendo's big-endian tweak.
|
|
13
|
+
*
|
|
14
|
+
* @param key - 32-byte key (first 16 bytes = data key K1, last 16 bytes = tweak key K2)
|
|
15
|
+
* @param data - Data to encrypt (must be a multiple of `sectorSize`)
|
|
16
|
+
* @param sectorSize - Size of each sector in bytes (typically 0x200 for NCA headers)
|
|
17
|
+
* @param startSector - Starting sector number (default: 0)
|
|
18
|
+
* @param crypto - Optional Crypto implementation (defaults to globalThis.crypto)
|
|
19
|
+
* @returns Encrypted data
|
|
20
|
+
*/
|
|
21
|
+
export declare function encrypt(key: ArrayBuffer | Uint8Array, data: ArrayBuffer | Uint8Array, sectorSize: number, startSector?: number, crypto?: Crypto): Promise<ArrayBuffer>;
|
|
22
|
+
/**
|
|
23
|
+
* Decrypt data using AES-128-XTS with Nintendo's big-endian tweak.
|
|
24
|
+
*
|
|
25
|
+
* @param key - 32-byte key (first 16 bytes = data key K1, last 16 bytes = tweak key K2)
|
|
26
|
+
* @param data - Data to decrypt (must be a multiple of `sectorSize`)
|
|
27
|
+
* @param sectorSize - Size of each sector in bytes (typically 0x200 for NCA headers)
|
|
28
|
+
* @param startSector - Starting sector number (default: 0)
|
|
29
|
+
* @param crypto - Optional Crypto implementation (defaults to globalThis.crypto)
|
|
30
|
+
* @returns Decrypted data
|
|
31
|
+
*/
|
|
32
|
+
export declare function decrypt(key: ArrayBuffer | Uint8Array, data: ArrayBuffer | Uint8Array, sectorSize: number, startSector?: number, crypto?: Crypto): Promise<ArrayBuffer>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AES-128-XTS encryption/decryption using Web Crypto.
|
|
3
|
+
*
|
|
4
|
+
* Supports Nintendo's big-endian tweak format, where the sector number
|
|
5
|
+
* is stored as a big-endian 128-bit value (opposite of the IEEE P1619
|
|
6
|
+
* standard which uses little-endian).
|
|
7
|
+
*
|
|
8
|
+
* Implementation uses Web Crypto's AES-CBC with a zero IV to perform
|
|
9
|
+
* single-block AES-ECB operations, then builds the XTS mode on top.
|
|
10
|
+
*/
|
|
11
|
+
const BLOCK_SIZE = 16;
|
|
12
|
+
const ZERO_IV = new Uint8Array(BLOCK_SIZE);
|
|
13
|
+
/**
|
|
14
|
+
* Import a raw AES-128 key for use with AES-CBC (used to emulate ECB).
|
|
15
|
+
*/
|
|
16
|
+
async function importKey(rawKey, crypto = globalThis.crypto) {
|
|
17
|
+
return crypto.subtle.importKey('raw', rawKey, { name: 'AES-CBC' }, false, [
|
|
18
|
+
'encrypt',
|
|
19
|
+
'decrypt',
|
|
20
|
+
]);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Encrypt a single 16-byte block using AES-ECB (emulated via AES-CBC with zero IV).
|
|
24
|
+
* AES-CBC with a zero IV and a single block is equivalent to AES-ECB for that block.
|
|
25
|
+
* Web Crypto's AES-CBC adds PKCS7 padding, so the output is 32 bytes — we take only the first 16.
|
|
26
|
+
*/
|
|
27
|
+
async function ecbEncryptBlock(key, block, crypto = globalThis.crypto) {
|
|
28
|
+
const result = await crypto.subtle.encrypt({ name: 'AES-CBC', iv: ZERO_IV }, key, block);
|
|
29
|
+
return new Uint8Array(result, 0, BLOCK_SIZE);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Decrypt a single 16-byte block using AES-ECB (emulated via AES-CBC with zero IV).
|
|
33
|
+
*
|
|
34
|
+
* Strategy: Construct a 2-block CBC ciphertext [C0, C1] such that when decrypted
|
|
35
|
+
* with IV=0, the first plaintext block P0 = AES-ECB-Decrypt(C0) and the second
|
|
36
|
+
* block P1 has valid PKCS7 padding (16 bytes of 0x10).
|
|
37
|
+
*
|
|
38
|
+
* In CBC decrypt: P0 = AES-Decrypt(C0) XOR IV = AES-Decrypt(C0) (since IV=0)
|
|
39
|
+
* P1 = AES-Decrypt(C1) XOR C0
|
|
40
|
+
*
|
|
41
|
+
* For P1 to be valid PKCS7 padding: we need AES-Decrypt(C1) = padding XOR C0
|
|
42
|
+
* So C1 = AES-Encrypt(padding XOR C0) = AES-Encrypt(padding XOR block)
|
|
43
|
+
*/
|
|
44
|
+
async function ecbDecryptBlock(key, block, crypto = globalThis.crypto) {
|
|
45
|
+
// PKCS7 padding for a full block: 16 bytes of 0x10
|
|
46
|
+
const padding = new Uint8Array(BLOCK_SIZE);
|
|
47
|
+
padding.fill(0x10);
|
|
48
|
+
// XOR padding with the ciphertext block (C0)
|
|
49
|
+
const paddingXorBlock = new Uint8Array(BLOCK_SIZE);
|
|
50
|
+
for (let i = 0; i < BLOCK_SIZE; i++) {
|
|
51
|
+
paddingXorBlock[i] = padding[i] ^ block[i];
|
|
52
|
+
}
|
|
53
|
+
// C1 = AES-ECB-Encrypt(padding XOR block)
|
|
54
|
+
const c1 = await ecbEncryptBlock(key, paddingXorBlock, crypto);
|
|
55
|
+
// Construct [C0=block, C1] and CBC-decrypt with IV=0
|
|
56
|
+
const cbcCiphertext = new Uint8Array(BLOCK_SIZE * 2);
|
|
57
|
+
cbcCiphertext.set(block, 0);
|
|
58
|
+
cbcCiphertext.set(c1, BLOCK_SIZE);
|
|
59
|
+
const result = await crypto.subtle.decrypt({ name: 'AES-CBC', iv: ZERO_IV }, key, cbcCiphertext);
|
|
60
|
+
return new Uint8Array(result, 0, BLOCK_SIZE);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Multiply a value in GF(2^128) by the primitive element alpha (x).
|
|
64
|
+
* This is the standard XTS doubling operation.
|
|
65
|
+
*
|
|
66
|
+
* The multiplication is performed on the 16-byte value interpreted as a
|
|
67
|
+
* little-endian polynomial in GF(2^128) with the irreducible polynomial
|
|
68
|
+
* x^128 + x^7 + x^2 + x + 1 (0x87 reduction).
|
|
69
|
+
*
|
|
70
|
+
* Operates in-place on the provided buffer.
|
|
71
|
+
*/
|
|
72
|
+
function gf128Mul(block) {
|
|
73
|
+
let carry = 0;
|
|
74
|
+
for (let i = 0; i < BLOCK_SIZE; i++) {
|
|
75
|
+
const nextCarry = (block[i] >> 7) & 1;
|
|
76
|
+
block[i] = ((block[i] << 1) | carry) & 0xff;
|
|
77
|
+
carry = nextCarry;
|
|
78
|
+
}
|
|
79
|
+
// If there was a carry out, XOR with the reduction polynomial
|
|
80
|
+
if (carry) {
|
|
81
|
+
block[0] ^= 0x87;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* XOR two 16-byte blocks, storing the result in `dst`.
|
|
86
|
+
*/
|
|
87
|
+
function xorBlocks(dst, a, b) {
|
|
88
|
+
for (let i = 0; i < BLOCK_SIZE; i++) {
|
|
89
|
+
dst[i] = a[i] ^ b[i];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Generate the Nintendo big-endian tweak value for a given sector number.
|
|
94
|
+
*
|
|
95
|
+
* Nintendo stores the sector number as a big-endian 128-bit value,
|
|
96
|
+
* which is the opposite of the IEEE P1619 standard (little-endian).
|
|
97
|
+
*
|
|
98
|
+
* This matches the C implementation:
|
|
99
|
+
* ```c
|
|
100
|
+
* static void get_tweak(unsigned char *tweak, size_t sector) {
|
|
101
|
+
* for (int i = 0xF; i >= 0; i--) {
|
|
102
|
+
* tweak[i] = (unsigned char)(sector & 0xFF);
|
|
103
|
+
* sector >>= 8;
|
|
104
|
+
* }
|
|
105
|
+
* }
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
function getNintendoTweak(sector) {
|
|
109
|
+
const tweak = new Uint8Array(BLOCK_SIZE);
|
|
110
|
+
let s = sector;
|
|
111
|
+
for (let i = 0xf; i >= 0; i--) {
|
|
112
|
+
tweak[i] = s & 0xff;
|
|
113
|
+
s = Math.floor(s / 256); // Avoid issues with bitwise ops on large numbers
|
|
114
|
+
}
|
|
115
|
+
return tweak;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Encrypt data using AES-128-XTS with Nintendo's big-endian tweak.
|
|
119
|
+
*
|
|
120
|
+
* @param key - 32-byte key (first 16 bytes = data key K1, last 16 bytes = tweak key K2)
|
|
121
|
+
* @param data - Data to encrypt (must be a multiple of `sectorSize`)
|
|
122
|
+
* @param sectorSize - Size of each sector in bytes (typically 0x200 for NCA headers)
|
|
123
|
+
* @param startSector - Starting sector number (default: 0)
|
|
124
|
+
* @param crypto - Optional Crypto implementation (defaults to globalThis.crypto)
|
|
125
|
+
* @returns Encrypted data
|
|
126
|
+
*/
|
|
127
|
+
export async function encrypt(key, data, sectorSize, startSector = 0, crypto = globalThis.crypto) {
|
|
128
|
+
const keyBytes = new Uint8Array(key);
|
|
129
|
+
if (keyBytes.length !== 32) {
|
|
130
|
+
throw new Error(`AES-XTS key must be 32 bytes, got ${keyBytes.length}`);
|
|
131
|
+
}
|
|
132
|
+
const dataBytes = new Uint8Array(data);
|
|
133
|
+
if (dataBytes.length % sectorSize !== 0) {
|
|
134
|
+
throw new Error('Data length must be a multiple of sector size');
|
|
135
|
+
}
|
|
136
|
+
if (sectorSize % BLOCK_SIZE !== 0) {
|
|
137
|
+
throw new Error('Sector size must be a multiple of 16');
|
|
138
|
+
}
|
|
139
|
+
const k1 = await importKey(keyBytes.subarray(0, 16), crypto);
|
|
140
|
+
const k2 = await importKey(keyBytes.subarray(16, 32), crypto);
|
|
141
|
+
const output = new Uint8Array(dataBytes.length);
|
|
142
|
+
const blocksPerSector = sectorSize / BLOCK_SIZE;
|
|
143
|
+
const tempBlock = new Uint8Array(BLOCK_SIZE);
|
|
144
|
+
for (let sectorOffset = 0; sectorOffset < dataBytes.length; sectorOffset += sectorSize) {
|
|
145
|
+
const sector = startSector + sectorOffset / sectorSize;
|
|
146
|
+
// Compute the initial tweak: T = AES-ECB-Encrypt(K2, tweak_value)
|
|
147
|
+
const tweakValue = getNintendoTweak(sector);
|
|
148
|
+
const T = await ecbEncryptBlock(k2, tweakValue, crypto);
|
|
149
|
+
for (let j = 0; j < blocksPerSector; j++) {
|
|
150
|
+
const blockOffset = sectorOffset + j * BLOCK_SIZE;
|
|
151
|
+
const plainBlock = dataBytes.subarray(blockOffset, blockOffset + BLOCK_SIZE);
|
|
152
|
+
// PP = P XOR T
|
|
153
|
+
xorBlocks(tempBlock, plainBlock, T);
|
|
154
|
+
// CC = AES-ECB-Encrypt(K1, PP)
|
|
155
|
+
const encrypted = await ecbEncryptBlock(k1, tempBlock, crypto);
|
|
156
|
+
// C = CC XOR T
|
|
157
|
+
xorBlocks(output.subarray(blockOffset, blockOffset + BLOCK_SIZE), encrypted, T);
|
|
158
|
+
// T = T * alpha in GF(2^128)
|
|
159
|
+
gf128Mul(T);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return output.buffer;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Decrypt data using AES-128-XTS with Nintendo's big-endian tweak.
|
|
166
|
+
*
|
|
167
|
+
* @param key - 32-byte key (first 16 bytes = data key K1, last 16 bytes = tweak key K2)
|
|
168
|
+
* @param data - Data to decrypt (must be a multiple of `sectorSize`)
|
|
169
|
+
* @param sectorSize - Size of each sector in bytes (typically 0x200 for NCA headers)
|
|
170
|
+
* @param startSector - Starting sector number (default: 0)
|
|
171
|
+
* @param crypto - Optional Crypto implementation (defaults to globalThis.crypto)
|
|
172
|
+
* @returns Decrypted data
|
|
173
|
+
*/
|
|
174
|
+
export async function decrypt(key, data, sectorSize, startSector = 0, crypto = globalThis.crypto) {
|
|
175
|
+
const keyBytes = new Uint8Array(key);
|
|
176
|
+
if (keyBytes.length !== 32) {
|
|
177
|
+
throw new Error(`AES-XTS key must be 32 bytes, got ${keyBytes.length}`);
|
|
178
|
+
}
|
|
179
|
+
const dataBytes = new Uint8Array(data);
|
|
180
|
+
if (dataBytes.length % sectorSize !== 0) {
|
|
181
|
+
throw new Error('Data length must be a multiple of sector size');
|
|
182
|
+
}
|
|
183
|
+
if (sectorSize % BLOCK_SIZE !== 0) {
|
|
184
|
+
throw new Error('Sector size must be a multiple of 16');
|
|
185
|
+
}
|
|
186
|
+
const k1 = await importKey(keyBytes.subarray(0, 16), crypto);
|
|
187
|
+
const k2 = await importKey(keyBytes.subarray(16, 32), crypto);
|
|
188
|
+
const output = new Uint8Array(dataBytes.length);
|
|
189
|
+
const blocksPerSector = sectorSize / BLOCK_SIZE;
|
|
190
|
+
const tempBlock = new Uint8Array(BLOCK_SIZE);
|
|
191
|
+
for (let sectorOffset = 0; sectorOffset < dataBytes.length; sectorOffset += sectorSize) {
|
|
192
|
+
const sector = startSector + sectorOffset / sectorSize;
|
|
193
|
+
// Compute the initial tweak: T = AES-ECB-Encrypt(K2, tweak_value)
|
|
194
|
+
const tweakValue = getNintendoTweak(sector);
|
|
195
|
+
const T = await ecbEncryptBlock(k2, tweakValue, crypto);
|
|
196
|
+
for (let j = 0; j < blocksPerSector; j++) {
|
|
197
|
+
const blockOffset = sectorOffset + j * BLOCK_SIZE;
|
|
198
|
+
const cipherBlock = dataBytes.subarray(blockOffset, blockOffset + BLOCK_SIZE);
|
|
199
|
+
// CC = C XOR T
|
|
200
|
+
xorBlocks(tempBlock, cipherBlock, T);
|
|
201
|
+
// PP = AES-ECB-Decrypt(K1, CC)
|
|
202
|
+
const decrypted = await ecbDecryptBlock(k1, tempBlock, crypto);
|
|
203
|
+
// P = PP XOR T
|
|
204
|
+
xorBlocks(output.subarray(blockOffset, blockOffset + BLOCK_SIZE), decrypted, T);
|
|
205
|
+
// T = T * alpha in GF(2^128)
|
|
206
|
+
gf128Mul(T);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return output.buffer;
|
|
210
|
+
}
|
|
211
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tootallnate/aes-xts",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "AES-128-XTS encryption/decryption using Web Crypto, with support for Nintendo's big-endian tweak",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"test": "vitest run"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [],
|
|
12
|
+
"author": "Nathan Rajlich <n@n8.io>",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"typescript": "^5.3.3"
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AES-128-XTS encryption/decryption using Web Crypto.
|
|
3
|
+
*
|
|
4
|
+
* Supports Nintendo's big-endian tweak format, where the sector number
|
|
5
|
+
* is stored as a big-endian 128-bit value (opposite of the IEEE P1619
|
|
6
|
+
* standard which uses little-endian).
|
|
7
|
+
*
|
|
8
|
+
* Implementation uses Web Crypto's AES-CBC with a zero IV to perform
|
|
9
|
+
* single-block AES-ECB operations, then builds the XTS mode on top.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const BLOCK_SIZE = 16;
|
|
13
|
+
const ZERO_IV = new Uint8Array(BLOCK_SIZE);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Import a raw AES-128 key for use with AES-CBC (used to emulate ECB).
|
|
17
|
+
*/
|
|
18
|
+
async function importKey(
|
|
19
|
+
rawKey: ArrayBuffer | Uint8Array,
|
|
20
|
+
crypto: Crypto = globalThis.crypto
|
|
21
|
+
): Promise<CryptoKey> {
|
|
22
|
+
return crypto.subtle.importKey('raw', rawKey, { name: 'AES-CBC' }, false, [
|
|
23
|
+
'encrypt',
|
|
24
|
+
'decrypt',
|
|
25
|
+
]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Encrypt a single 16-byte block using AES-ECB (emulated via AES-CBC with zero IV).
|
|
30
|
+
* AES-CBC with a zero IV and a single block is equivalent to AES-ECB for that block.
|
|
31
|
+
* Web Crypto's AES-CBC adds PKCS7 padding, so the output is 32 bytes — we take only the first 16.
|
|
32
|
+
*/
|
|
33
|
+
async function ecbEncryptBlock(
|
|
34
|
+
key: CryptoKey,
|
|
35
|
+
block: Uint8Array,
|
|
36
|
+
crypto: Crypto = globalThis.crypto
|
|
37
|
+
): Promise<Uint8Array> {
|
|
38
|
+
const result = await crypto.subtle.encrypt(
|
|
39
|
+
{ name: 'AES-CBC', iv: ZERO_IV },
|
|
40
|
+
key,
|
|
41
|
+
block
|
|
42
|
+
);
|
|
43
|
+
return new Uint8Array(result, 0, BLOCK_SIZE);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Decrypt a single 16-byte block using AES-ECB (emulated via AES-CBC with zero IV).
|
|
48
|
+
*
|
|
49
|
+
* Strategy: Construct a 2-block CBC ciphertext [C0, C1] such that when decrypted
|
|
50
|
+
* with IV=0, the first plaintext block P0 = AES-ECB-Decrypt(C0) and the second
|
|
51
|
+
* block P1 has valid PKCS7 padding (16 bytes of 0x10).
|
|
52
|
+
*
|
|
53
|
+
* In CBC decrypt: P0 = AES-Decrypt(C0) XOR IV = AES-Decrypt(C0) (since IV=0)
|
|
54
|
+
* P1 = AES-Decrypt(C1) XOR C0
|
|
55
|
+
*
|
|
56
|
+
* For P1 to be valid PKCS7 padding: we need AES-Decrypt(C1) = padding XOR C0
|
|
57
|
+
* So C1 = AES-Encrypt(padding XOR C0) = AES-Encrypt(padding XOR block)
|
|
58
|
+
*/
|
|
59
|
+
async function ecbDecryptBlock(
|
|
60
|
+
key: CryptoKey,
|
|
61
|
+
block: Uint8Array,
|
|
62
|
+
crypto: Crypto = globalThis.crypto
|
|
63
|
+
): Promise<Uint8Array> {
|
|
64
|
+
// PKCS7 padding for a full block: 16 bytes of 0x10
|
|
65
|
+
const padding = new Uint8Array(BLOCK_SIZE);
|
|
66
|
+
padding.fill(0x10);
|
|
67
|
+
|
|
68
|
+
// XOR padding with the ciphertext block (C0)
|
|
69
|
+
const paddingXorBlock = new Uint8Array(BLOCK_SIZE);
|
|
70
|
+
for (let i = 0; i < BLOCK_SIZE; i++) {
|
|
71
|
+
paddingXorBlock[i] = padding[i] ^ block[i];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// C1 = AES-ECB-Encrypt(padding XOR block)
|
|
75
|
+
const c1 = await ecbEncryptBlock(key, paddingXorBlock, crypto);
|
|
76
|
+
|
|
77
|
+
// Construct [C0=block, C1] and CBC-decrypt with IV=0
|
|
78
|
+
const cbcCiphertext = new Uint8Array(BLOCK_SIZE * 2);
|
|
79
|
+
cbcCiphertext.set(block, 0);
|
|
80
|
+
cbcCiphertext.set(c1, BLOCK_SIZE);
|
|
81
|
+
|
|
82
|
+
const result = await crypto.subtle.decrypt(
|
|
83
|
+
{ name: 'AES-CBC', iv: ZERO_IV },
|
|
84
|
+
key,
|
|
85
|
+
cbcCiphertext
|
|
86
|
+
);
|
|
87
|
+
return new Uint8Array(result, 0, BLOCK_SIZE);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Multiply a value in GF(2^128) by the primitive element alpha (x).
|
|
92
|
+
* This is the standard XTS doubling operation.
|
|
93
|
+
*
|
|
94
|
+
* The multiplication is performed on the 16-byte value interpreted as a
|
|
95
|
+
* little-endian polynomial in GF(2^128) with the irreducible polynomial
|
|
96
|
+
* x^128 + x^7 + x^2 + x + 1 (0x87 reduction).
|
|
97
|
+
*
|
|
98
|
+
* Operates in-place on the provided buffer.
|
|
99
|
+
*/
|
|
100
|
+
function gf128Mul(block: Uint8Array): void {
|
|
101
|
+
let carry = 0;
|
|
102
|
+
for (let i = 0; i < BLOCK_SIZE; i++) {
|
|
103
|
+
const nextCarry = (block[i] >> 7) & 1;
|
|
104
|
+
block[i] = ((block[i] << 1) | carry) & 0xff;
|
|
105
|
+
carry = nextCarry;
|
|
106
|
+
}
|
|
107
|
+
// If there was a carry out, XOR with the reduction polynomial
|
|
108
|
+
if (carry) {
|
|
109
|
+
block[0] ^= 0x87;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* XOR two 16-byte blocks, storing the result in `dst`.
|
|
115
|
+
*/
|
|
116
|
+
function xorBlocks(dst: Uint8Array, a: Uint8Array, b: Uint8Array): void {
|
|
117
|
+
for (let i = 0; i < BLOCK_SIZE; i++) {
|
|
118
|
+
dst[i] = a[i] ^ b[i];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Generate the Nintendo big-endian tweak value for a given sector number.
|
|
124
|
+
*
|
|
125
|
+
* Nintendo stores the sector number as a big-endian 128-bit value,
|
|
126
|
+
* which is the opposite of the IEEE P1619 standard (little-endian).
|
|
127
|
+
*
|
|
128
|
+
* This matches the C implementation:
|
|
129
|
+
* ```c
|
|
130
|
+
* static void get_tweak(unsigned char *tweak, size_t sector) {
|
|
131
|
+
* for (int i = 0xF; i >= 0; i--) {
|
|
132
|
+
* tweak[i] = (unsigned char)(sector & 0xFF);
|
|
133
|
+
* sector >>= 8;
|
|
134
|
+
* }
|
|
135
|
+
* }
|
|
136
|
+
* ```
|
|
137
|
+
*/
|
|
138
|
+
function getNintendoTweak(sector: number): Uint8Array {
|
|
139
|
+
const tweak = new Uint8Array(BLOCK_SIZE);
|
|
140
|
+
let s = sector;
|
|
141
|
+
for (let i = 0xf; i >= 0; i--) {
|
|
142
|
+
tweak[i] = s & 0xff;
|
|
143
|
+
s = Math.floor(s / 256); // Avoid issues with bitwise ops on large numbers
|
|
144
|
+
}
|
|
145
|
+
return tweak;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Encrypt data using AES-128-XTS with Nintendo's big-endian tweak.
|
|
150
|
+
*
|
|
151
|
+
* @param key - 32-byte key (first 16 bytes = data key K1, last 16 bytes = tweak key K2)
|
|
152
|
+
* @param data - Data to encrypt (must be a multiple of `sectorSize`)
|
|
153
|
+
* @param sectorSize - Size of each sector in bytes (typically 0x200 for NCA headers)
|
|
154
|
+
* @param startSector - Starting sector number (default: 0)
|
|
155
|
+
* @param crypto - Optional Crypto implementation (defaults to globalThis.crypto)
|
|
156
|
+
* @returns Encrypted data
|
|
157
|
+
*/
|
|
158
|
+
export async function encrypt(
|
|
159
|
+
key: ArrayBuffer | Uint8Array,
|
|
160
|
+
data: ArrayBuffer | Uint8Array,
|
|
161
|
+
sectorSize: number,
|
|
162
|
+
startSector = 0,
|
|
163
|
+
crypto: Crypto = globalThis.crypto
|
|
164
|
+
): Promise<ArrayBuffer> {
|
|
165
|
+
const keyBytes = new Uint8Array(key);
|
|
166
|
+
if (keyBytes.length !== 32) {
|
|
167
|
+
throw new Error(`AES-XTS key must be 32 bytes, got ${keyBytes.length}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const dataBytes = new Uint8Array(data);
|
|
171
|
+
if (dataBytes.length % sectorSize !== 0) {
|
|
172
|
+
throw new Error('Data length must be a multiple of sector size');
|
|
173
|
+
}
|
|
174
|
+
if (sectorSize % BLOCK_SIZE !== 0) {
|
|
175
|
+
throw new Error('Sector size must be a multiple of 16');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const k1 = await importKey(keyBytes.subarray(0, 16), crypto);
|
|
179
|
+
const k2 = await importKey(keyBytes.subarray(16, 32), crypto);
|
|
180
|
+
|
|
181
|
+
const output = new Uint8Array(dataBytes.length);
|
|
182
|
+
const blocksPerSector = sectorSize / BLOCK_SIZE;
|
|
183
|
+
const tempBlock = new Uint8Array(BLOCK_SIZE);
|
|
184
|
+
|
|
185
|
+
for (
|
|
186
|
+
let sectorOffset = 0;
|
|
187
|
+
sectorOffset < dataBytes.length;
|
|
188
|
+
sectorOffset += sectorSize
|
|
189
|
+
) {
|
|
190
|
+
const sector = startSector + sectorOffset / sectorSize;
|
|
191
|
+
|
|
192
|
+
// Compute the initial tweak: T = AES-ECB-Encrypt(K2, tweak_value)
|
|
193
|
+
const tweakValue = getNintendoTweak(sector);
|
|
194
|
+
const T = await ecbEncryptBlock(k2, tweakValue, crypto);
|
|
195
|
+
|
|
196
|
+
for (let j = 0; j < blocksPerSector; j++) {
|
|
197
|
+
const blockOffset = sectorOffset + j * BLOCK_SIZE;
|
|
198
|
+
const plainBlock = dataBytes.subarray(
|
|
199
|
+
blockOffset,
|
|
200
|
+
blockOffset + BLOCK_SIZE
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
// PP = P XOR T
|
|
204
|
+
xorBlocks(tempBlock, plainBlock, T);
|
|
205
|
+
|
|
206
|
+
// CC = AES-ECB-Encrypt(K1, PP)
|
|
207
|
+
const encrypted = await ecbEncryptBlock(k1, tempBlock, crypto);
|
|
208
|
+
|
|
209
|
+
// C = CC XOR T
|
|
210
|
+
xorBlocks(
|
|
211
|
+
output.subarray(blockOffset, blockOffset + BLOCK_SIZE),
|
|
212
|
+
encrypted,
|
|
213
|
+
T
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
// T = T * alpha in GF(2^128)
|
|
217
|
+
gf128Mul(T);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return output.buffer;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Decrypt data using AES-128-XTS with Nintendo's big-endian tweak.
|
|
226
|
+
*
|
|
227
|
+
* @param key - 32-byte key (first 16 bytes = data key K1, last 16 bytes = tweak key K2)
|
|
228
|
+
* @param data - Data to decrypt (must be a multiple of `sectorSize`)
|
|
229
|
+
* @param sectorSize - Size of each sector in bytes (typically 0x200 for NCA headers)
|
|
230
|
+
* @param startSector - Starting sector number (default: 0)
|
|
231
|
+
* @param crypto - Optional Crypto implementation (defaults to globalThis.crypto)
|
|
232
|
+
* @returns Decrypted data
|
|
233
|
+
*/
|
|
234
|
+
export async function decrypt(
|
|
235
|
+
key: ArrayBuffer | Uint8Array,
|
|
236
|
+
data: ArrayBuffer | Uint8Array,
|
|
237
|
+
sectorSize: number,
|
|
238
|
+
startSector = 0,
|
|
239
|
+
crypto: Crypto = globalThis.crypto
|
|
240
|
+
): Promise<ArrayBuffer> {
|
|
241
|
+
const keyBytes = new Uint8Array(key);
|
|
242
|
+
if (keyBytes.length !== 32) {
|
|
243
|
+
throw new Error(`AES-XTS key must be 32 bytes, got ${keyBytes.length}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const dataBytes = new Uint8Array(data);
|
|
247
|
+
if (dataBytes.length % sectorSize !== 0) {
|
|
248
|
+
throw new Error('Data length must be a multiple of sector size');
|
|
249
|
+
}
|
|
250
|
+
if (sectorSize % BLOCK_SIZE !== 0) {
|
|
251
|
+
throw new Error('Sector size must be a multiple of 16');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const k1 = await importKey(keyBytes.subarray(0, 16), crypto);
|
|
255
|
+
const k2 = await importKey(keyBytes.subarray(16, 32), crypto);
|
|
256
|
+
|
|
257
|
+
const output = new Uint8Array(dataBytes.length);
|
|
258
|
+
const blocksPerSector = sectorSize / BLOCK_SIZE;
|
|
259
|
+
const tempBlock = new Uint8Array(BLOCK_SIZE);
|
|
260
|
+
|
|
261
|
+
for (
|
|
262
|
+
let sectorOffset = 0;
|
|
263
|
+
sectorOffset < dataBytes.length;
|
|
264
|
+
sectorOffset += sectorSize
|
|
265
|
+
) {
|
|
266
|
+
const sector = startSector + sectorOffset / sectorSize;
|
|
267
|
+
|
|
268
|
+
// Compute the initial tweak: T = AES-ECB-Encrypt(K2, tweak_value)
|
|
269
|
+
const tweakValue = getNintendoTweak(sector);
|
|
270
|
+
const T = await ecbEncryptBlock(k2, tweakValue, crypto);
|
|
271
|
+
|
|
272
|
+
for (let j = 0; j < blocksPerSector; j++) {
|
|
273
|
+
const blockOffset = sectorOffset + j * BLOCK_SIZE;
|
|
274
|
+
const cipherBlock = dataBytes.subarray(
|
|
275
|
+
blockOffset,
|
|
276
|
+
blockOffset + BLOCK_SIZE
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
// CC = C XOR T
|
|
280
|
+
xorBlocks(tempBlock, cipherBlock, T);
|
|
281
|
+
|
|
282
|
+
// PP = AES-ECB-Decrypt(K1, CC)
|
|
283
|
+
const decrypted = await ecbDecryptBlock(k1, tempBlock, crypto);
|
|
284
|
+
|
|
285
|
+
// P = PP XOR T
|
|
286
|
+
xorBlocks(
|
|
287
|
+
output.subarray(blockOffset, blockOffset + BLOCK_SIZE),
|
|
288
|
+
decrypted,
|
|
289
|
+
T
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
// T = T * alpha in GF(2^128)
|
|
293
|
+
gf128Mul(T);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return output.buffer;
|
|
298
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { encrypt, decrypt } from '../src/index.js';
|
|
3
|
+
|
|
4
|
+
// Test vectors generated using Node.js crypto with identical key/tweak settings
|
|
5
|
+
const TEST_KEY = hexToBytes(
|
|
6
|
+
'00112233445566778899aabbccddeeffaabbccddeeff00112233445566778899'
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
const SECTOR_0_EXPECTED =
|
|
10
|
+
'7575d42fde6b2f7190ff26861970b889b0f7d93951047e4913017c4a6dd4a1cc';
|
|
11
|
+
|
|
12
|
+
const SECTOR_1_EXPECTED =
|
|
13
|
+
'd573fc38797f8affbe2bd3b104b0ef085667c568fed42c7773f8e936e780d1f5';
|
|
14
|
+
|
|
15
|
+
function hexToBytes(hex: string): Uint8Array {
|
|
16
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
17
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
18
|
+
bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
|
|
19
|
+
}
|
|
20
|
+
return bytes;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function bytesToHex(bytes: Uint8Array): string {
|
|
24
|
+
return Array.from(bytes)
|
|
25
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
26
|
+
.join('');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeTestData(size: number): Uint8Array {
|
|
30
|
+
const data = new Uint8Array(size);
|
|
31
|
+
for (let i = 0; i < size; i++) data[i] = i & 0xff;
|
|
32
|
+
return data;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('AES-128-XTS with Nintendo big-endian tweak', () => {
|
|
36
|
+
describe('encrypt', () => {
|
|
37
|
+
it('should encrypt a single sector (sector 0)', async () => {
|
|
38
|
+
const plain = makeTestData(512);
|
|
39
|
+
const ct = new Uint8Array(await encrypt(TEST_KEY, plain, 512, 0));
|
|
40
|
+
expect(bytesToHex(ct.subarray(0, 32))).toBe(SECTOR_0_EXPECTED);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should encrypt with correct sector 1 tweak', async () => {
|
|
44
|
+
const plain = makeTestData(512);
|
|
45
|
+
const ct = new Uint8Array(await encrypt(TEST_KEY, plain, 512, 1));
|
|
46
|
+
expect(bytesToHex(ct.subarray(0, 32))).toBe(SECTOR_1_EXPECTED);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should encrypt multiple sectors', async () => {
|
|
50
|
+
const plain = makeTestData(1024);
|
|
51
|
+
const ct = new Uint8Array(await encrypt(TEST_KEY, plain, 512, 0));
|
|
52
|
+
// First 512 bytes = sector 0 ciphertext
|
|
53
|
+
expect(bytesToHex(ct.subarray(0, 32))).toBe(SECTOR_0_EXPECTED);
|
|
54
|
+
// Second 512 bytes = sector 1 ciphertext
|
|
55
|
+
expect(bytesToHex(ct.subarray(512, 544))).toBe(SECTOR_1_EXPECTED);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('decrypt', () => {
|
|
60
|
+
it('should round-trip encrypt/decrypt', async () => {
|
|
61
|
+
const plain = makeTestData(512);
|
|
62
|
+
const ct = await encrypt(TEST_KEY, plain, 512, 0);
|
|
63
|
+
const decrypted = new Uint8Array(
|
|
64
|
+
await decrypt(TEST_KEY, ct, 512, 0)
|
|
65
|
+
);
|
|
66
|
+
expect(bytesToHex(decrypted)).toBe(bytesToHex(plain));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should round-trip multiple sectors', async () => {
|
|
70
|
+
const plain = makeTestData(1024);
|
|
71
|
+
const ct = await encrypt(TEST_KEY, plain, 512, 0);
|
|
72
|
+
const decrypted = new Uint8Array(
|
|
73
|
+
await decrypt(TEST_KEY, ct, 512, 0)
|
|
74
|
+
);
|
|
75
|
+
expect(bytesToHex(decrypted)).toBe(bytesToHex(plain));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should round-trip with non-zero start sector', async () => {
|
|
79
|
+
const plain = makeTestData(512);
|
|
80
|
+
const ct = await encrypt(TEST_KEY, plain, 512, 5);
|
|
81
|
+
const decrypted = new Uint8Array(
|
|
82
|
+
await decrypt(TEST_KEY, ct, 512, 5)
|
|
83
|
+
);
|
|
84
|
+
expect(bytesToHex(decrypted)).toBe(bytesToHex(plain));
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('NCA header size (0xC00 = 3072 bytes, sector size 0x200)', () => {
|
|
89
|
+
it('should encrypt/decrypt NCA-header-sized data', async () => {
|
|
90
|
+
const data = makeTestData(0xc00);
|
|
91
|
+
const ct = await encrypt(TEST_KEY, data, 0x200, 0);
|
|
92
|
+
const decrypted = new Uint8Array(
|
|
93
|
+
await decrypt(TEST_KEY, ct, 0x200, 0)
|
|
94
|
+
);
|
|
95
|
+
expect(bytesToHex(decrypted)).toBe(bytesToHex(data));
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('validation', () => {
|
|
100
|
+
it('should reject non-32-byte keys', async () => {
|
|
101
|
+
const plain = makeTestData(512);
|
|
102
|
+
await expect(
|
|
103
|
+
encrypt(new Uint8Array(16), plain, 512)
|
|
104
|
+
).rejects.toThrow('32 bytes');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should reject data not aligned to sector size', async () => {
|
|
108
|
+
const plain = makeTestData(500);
|
|
109
|
+
await expect(encrypt(TEST_KEY, plain, 512)).rejects.toThrow(
|
|
110
|
+
'multiple of sector size'
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "es2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"declaration": true,
|
|
8
|
+
"sourceMap": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"strict": true,
|
|
11
|
+
"skipLibCheck": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*.ts"]
|
|
14
|
+
}
|