Merge pull request #3 from ariankordi/replace-asmcrypto-with-sjcl
Replace asmcrypto with sjcl, add tests for QR decryption (closes #2)
This commit is contained in:
commit
d2e336b700
6 changed files with 204 additions and 38 deletions
|
|
@ -8,7 +8,8 @@
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"postinstall": "prisma generate"
|
"postinstall": "prisma generate",
|
||||||
|
"test": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@2toad/profanity": "^3.1.1",
|
"@2toad/profanity": "^3.1.1",
|
||||||
|
|
@ -16,7 +17,7 @@
|
||||||
"@bprogress/next": "^3.2.12",
|
"@bprogress/next": "^3.2.12",
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"@prisma/client": "^6.7.0",
|
"@prisma/client": "^6.7.0",
|
||||||
"@trafficlunar/asmcrypto.js": "^1.0.2",
|
"@types/sjcl": "^1.0.34",
|
||||||
"bit-buffer": "^0.2.5",
|
"bit-buffer": "^0.2.5",
|
||||||
"canvas-confetti": "^1.9.3",
|
"canvas-confetti": "^1.9.3",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
|
|
@ -33,6 +34,7 @@
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
"react-webcam": "^7.2.0",
|
"react-webcam": "^7.2.0",
|
||||||
"sharp": "^0.34.1",
|
"sharp": "^0.34.1",
|
||||||
|
"sjcl-with-all": "1.0.8",
|
||||||
"swr": "^2.3.3",
|
"swr": "^2.3.3",
|
||||||
"zod": "^3.24.3"
|
"zod": "^3.24.3"
|
||||||
},
|
},
|
||||||
|
|
@ -48,6 +50,7 @@
|
||||||
"eslint-config-next": "15.2.4",
|
"eslint-config-next": "15.2.4",
|
||||||
"prisma": "^6.7.0",
|
"prisma": "^6.7.0",
|
||||||
"tailwindcss": "^4.1.5",
|
"tailwindcss": "^4.1.5",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3",
|
||||||
|
"vitest": "^3.1.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,16 @@
|
||||||
export const MII_DECRYPTION_KEY = new Uint8Array([0x59, 0xfc, 0x81, 0x7e, 0x64, 0x46, 0xea, 0x61, 0x90, 0x34, 0x7b, 0x20, 0xe9, 0xbd, 0xce, 0x52]);
|
import type { BitArray } from "sjcl";
|
||||||
export const TOMODACHI_LIFE_DECRYPTION_KEY = new Uint8Array([
|
|
||||||
0x30, 0x81, 0x9f, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01,
|
/** Length of an encrypted Mii QR code, without extra data. (CFLiWrappedMiiData) */
|
||||||
]);
|
export const MII_QR_ENCRYPTED_LENGTH = 112;
|
||||||
|
|
||||||
|
// Keys are in sjcl.BitArray format.
|
||||||
|
|
||||||
|
/** AES-CCM key for Mii QR codes. Original: 59FC817E6446EA6190347B20E9BDCE52 */
|
||||||
|
export const MII_DECRYPTION_KEY: BitArray = [1509720446, 1682369121, -1875608800, -373436846];
|
||||||
|
|
||||||
|
/** AES-CTR key for Tomodachi Life extra data at the end of Mii QR codes. Original: 30819F300D06092A864886F70D010101 */
|
||||||
|
export const TOMODACHI_LIFE_DECRYPTION_KEY: BitArray = [813801264, 218499370, -2042067209, 218169601];
|
||||||
|
|
||||||
|
// Keys as Uint8Array.
|
||||||
|
// export const MII_DECRYPTION_KEY = new Uint8Array([0x59, 0xfc, 0x81, 0x7e, 0x64, 0x46, 0xea, 0x61, 0x90, 0x34, 0x7b, 0x20, 0xe9, 0xbd, 0xce, 0x52]);
|
||||||
|
// export const TOMODACHI_LIFE_DECRYPTION_KEY = new Uint8Array([0x30, 0x81, 0x9f, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01]);
|
||||||
|
|
|
||||||
82
src/lib/qr-codes.test.ts
Normal file
82
src/lib/qr-codes.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { convertQrCode } from "./qr-codes";
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import Mii from "./mii.js/mii";
|
||||||
|
import TomodachiLifeMii from "./tomodachi-life-mii";
|
||||||
|
|
||||||
|
// List of encrypted QR code data to test against.
|
||||||
|
const validQrCodes = [
|
||||||
|
// Frieren (from step6.webp)
|
||||||
|
{
|
||||||
|
base64: "lymEx6TA4eLgfwTEceW+DbdYBTfBfPKM4VesJkq1qzoGGXnk/3OohvPPuGS8mmnzvpvyC36ge8+C2c4IIhJ0l7Lx9KKxnPEAuLBM1YtCnSowjVNHo3CmjE7D7lkDUBuhO3qKjAqWXfQthtqBqjTe4Hv95TKNVPimaxNXhAVZGSmOMh++0Z2N0TvIDpU/8kxLIsgntsQH4PNlaFcVF2HeOERXRdTm/TMFb6pozO57nJ9NKi5bxh1ClNArbpyTQgBe7cfvnNretFVNWzGJBWctZC7weecYIKyU3qbH5c4kogmj4WcfJhYsuOT3odvv2WBaGhchgR779Ztc0E76COxNEaJa6M7QDyHGw8XQfxCH3j4pMkhFOlPn/ObS3rNUADYUCY8d+Wt4fBbO7PTW7ppDnDaCyxwEIbglsMtkD/cIPIr4f0RPnpV7ZlOmrJ3HdIbmwXi6TqKTwqkHtBmBPvZVpXm4RJN8A6gF22Uc7NY8B3KMYY7Q",
|
||||||
|
expected: {
|
||||||
|
miiName: "Frieren",
|
||||||
|
firstName: "Frieren",
|
||||||
|
lastName: "the Slayer",
|
||||||
|
islandName: "Wuhu",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
base64: "ky1cf9hr9xotl250Y8cDOGqPd7t51NS8tNVrJMRAI2bfXr4LNirKvqu7ZrvC00vgz70NU8kQRR6H3uAnRaHupxjLbeYfU0s6CduruAEnXP8rZanCeSePQQH0NSL3QqiilT2WEt7nCAMvHwR9fT/LE/k6NBMDHqoK3zqzemr4OlQro0YWBeRJ501EawWan/k/rq2VSGTeLO09CsQD6AFHECtxF9+sSKyJxK1aiu7VhmOZLY6L9VKrlpvahQ1/vHVyYVpFJvc3bdHE8D94bBXkZ18mnXj++ST+j1Had4aki7oqTT83fgs7asRg3DRRZArw8PiKVmZWJ4ODRWR/LNvjIxb1FQqWk9I6S3DEo6AMuBRbXgj1H4YWrRuTkWlEpP2Y/P5+Mvfv5GbNQKYSKwpTYFOCTn13yQ1wtDbF4yG+Ro4Xf45cNBT6k3yqswrKt9bkP2wiULqYZR7McaD1SJ4whFFqcadjpLvbn8zQNFY0lOUTQMGI",
|
||||||
|
expected: {
|
||||||
|
miiName: "ミク",
|
||||||
|
firstName: "ミク",
|
||||||
|
lastName: "はつね",
|
||||||
|
islandName: "LOSTの",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const zeroes372 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||||
|
|
||||||
|
const length32 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||||
|
|
||||||
|
const badCrc = "ULENrXILeXZooDaJvwHMcFaBjGH7k5pj+DDPDV0irq66c01Fh82TIrZerZX2xiB+TbHBzYIaDHdryA9IbR1uH/slSalGCScmjDiL9zpOXlvh8z38NGkmjtL1fVVrshCcJHKeQApdbZU4S6h+wZd0LA==";
|
||||||
|
|
||||||
|
function base64ToUint8Array(base64: string): Uint8Array {
|
||||||
|
const binary = Buffer.from(base64, "base64");
|
||||||
|
return new Uint8Array(binary.buffer, binary.byteOffset, binary.byteLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("convertQrCode", () => {
|
||||||
|
it.each(validQrCodes.map((t, i) => [i, t]))(
|
||||||
|
"should convert valid QR #%i into Mii + TomodachiLifeMii",
|
||||||
|
(_, { base64, expected }) => {
|
||||||
|
const bytes = base64ToUint8Array(base64);
|
||||||
|
const { mii, tomodachiLifeMii } = convertQrCode(bytes);
|
||||||
|
|
||||||
|
expect(mii).toBeInstanceOf(Mii);
|
||||||
|
expect(tomodachiLifeMii).toBeInstanceOf(TomodachiLifeMii);
|
||||||
|
|
||||||
|
expect(mii.miiName).toBe(expected.miiName);
|
||||||
|
expect(tomodachiLifeMii.firstName).toBe(expected.firstName);
|
||||||
|
expect(tomodachiLifeMii.lastName).toBe(expected.lastName);
|
||||||
|
expect(tomodachiLifeMii.islandName).toBe(expected.islandName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw an error on zeroed out data", () => {
|
||||||
|
const bytes = base64ToUint8Array(zeroes372);
|
||||||
|
// Will decrypt wrongly, leading to the expected stream
|
||||||
|
// of zeroes in the decrypted data not being intact.
|
||||||
|
expect(() => convertQrCode(bytes)).toThrow("QR code is not a valid Mii QR code");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw an error on wrong length", () => {
|
||||||
|
const bytes = base64ToUint8Array(length32);
|
||||||
|
// Thrown at the beginning of the function.
|
||||||
|
expect(() => convertQrCode(bytes)).toThrow("Mii QR code has wrong size (got 32, expected 112 or longer)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw an error on data with bad CRC-16", () => {
|
||||||
|
const bytes = base64ToUint8Array(badCrc);
|
||||||
|
// Verified by new Mii() constructor from mii-js.
|
||||||
|
expect(() => convertQrCode(bytes)).toThrow("Mii data is not valid");
|
||||||
|
});
|
||||||
|
/*
|
||||||
|
it('should censor bad words in names', () => {
|
||||||
|
const qrWithSwears = // TODO TODO
|
||||||
|
const { tomodachiLifeMii } = convertQrCode(qrWithSwears);
|
||||||
|
|
||||||
|
expect(tomodachiLifeMii.firstName).not.toMatch(/INSERT_SWEARS_HERE/i);
|
||||||
|
});
|
||||||
|
*/
|
||||||
|
});
|
||||||
|
|
@ -1,47 +1,101 @@
|
||||||
import { profanity } from "@2toad/profanity";
|
import { profanity } from "@2toad/profanity";
|
||||||
import { AES_CCM } from "@trafficlunar/asmcrypto.js";
|
// import { AES_CCM } from "@trafficlunar/asmcrypto.js";
|
||||||
|
import sjcl from "sjcl-with-all";
|
||||||
|
|
||||||
import { MII_DECRYPTION_KEY } from "./constants";
|
import { MII_DECRYPTION_KEY, MII_QR_ENCRYPTED_LENGTH } from "./constants";
|
||||||
import Mii from "./mii.js/mii";
|
import Mii from "./mii.js/mii";
|
||||||
import TomodachiLifeMii from "./tomodachi-life-mii";
|
import TomodachiLifeMii from "./tomodachi-life-mii";
|
||||||
|
|
||||||
|
// AES-CCM encrypted Mii QR codes have some errata (https://www.3dbrew.org/wiki/AES_Registers#CCM_mode_pitfall)
|
||||||
|
// causing them to not be easily decryptable by asmcrypto
|
||||||
|
// or sjcl, they can't verify the nonce/tag.
|
||||||
|
|
||||||
|
// In order to use sjcl's decrypt function without
|
||||||
|
// verification, a private function needs to be called.
|
||||||
|
// This private function's name is originally "_ctrMode"
|
||||||
|
// but its name is minified across builds.
|
||||||
|
// In "sjcl-with-all" v1.0.8 from npm, the name is "u"
|
||||||
|
|
||||||
|
/** Private _ctrMode function defined here: {@link https://github.com/bitwiseshiftleft/sjcl/blob/85caa53c281eeeb502310013312c775d35fe0867/core/ccm.js#L194} */
|
||||||
|
const sjclCcmCtrMode: ((
|
||||||
|
prf: sjcl.SjclCipher, data: sjcl.BitArray, iv: sjcl.BitArray,
|
||||||
|
tag: sjcl.BitArray, tlen: number, L: number
|
||||||
|
) => { data: sjcl.BitArray; tag: sjcl.BitArray }) | undefined =
|
||||||
|
// @ts-expect-error -- Referencing a private function that is not in the types.
|
||||||
|
sjcl.mode.ccm.u; // NOTE: This may need to be changed with a different sjcl build. Read above
|
||||||
|
|
||||||
export function convertQrCode(bytes: Uint8Array): { mii: Mii; tomodachiLifeMii: TomodachiLifeMii } {
|
export function convertQrCode(bytes: Uint8Array): { mii: Mii; tomodachiLifeMii: TomodachiLifeMii } {
|
||||||
// Decrypt the Mii part of the QR code
|
// Decrypt 96 byte 3DS/Wii U format Mii data from the QR code.
|
||||||
// (Credits to kazuki-4ys)
|
// References (Credits: jaames, kazuki-4ys):
|
||||||
const nonce = bytes.subarray(0, 8);
|
// - https://gist.github.com/jaames/96ce8daa11b61b758b6b0227b55f9f78
|
||||||
const content = bytes.subarray(8, 0x70);
|
// - https://github.com/kazuki-4ys/kazuki-4ys.github.io/blob/148dc339974f8b7515bfdc1395ec1fc9becb68ab/web_apps/MiiInfoEditorCTR/encode.js#L57
|
||||||
|
|
||||||
const nonceWithZeros = new Uint8Array(12);
|
// Check that the private _ctrMode function is defined.
|
||||||
nonceWithZeros.set(nonce, 0);
|
if (!sjclCcmCtrMode) {
|
||||||
|
throw new Error("Private sjcl.mode.ccm._ctrMode function cannot be found. The build of sjcl expected may have changed. Read src/lib/qr-codes.ts for more details.");
|
||||||
let decrypted: Uint8Array<ArrayBufferLike> = new Uint8Array();
|
|
||||||
try {
|
|
||||||
decrypted = AES_CCM.decrypt(content, MII_DECRYPTION_KEY, nonceWithZeros, undefined, 16);
|
|
||||||
} catch {
|
|
||||||
throw new Error("Failed to decrypt QR code. It may be invalid or corrupted");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = new Uint8Array(96);
|
// Verify that the length is not smaller than expected.
|
||||||
result.set(decrypted.subarray(0, 12), 0);
|
if (bytes.length < MII_QR_ENCRYPTED_LENGTH) {
|
||||||
result.set(nonce, 12);
|
throw new Error(`Mii QR code has wrong size (got ${bytes.length}, expected ${MII_QR_ENCRYPTED_LENGTH} or longer)`);
|
||||||
result.set(decrypted.subarray(12), 20);
|
}
|
||||||
|
|
||||||
// Check if QR code is valid (after decryption)
|
const nonce = bytes.subarray(0, 8); // Extract the AES-CCM nonce.
|
||||||
if (result.length !== 0x60 || (result[0x16] !== 0 && result[0x17] !== 0)) throw new Error("QR code is not a valid Mii QR code");
|
const encryptedContent = bytes.subarray(8); // Extract the ciphertext.
|
||||||
|
|
||||||
// Convert to Mii classes
|
const cipher = new sjcl.cipher.aes(MII_DECRYPTION_KEY); // Construct new sjcl cipher.
|
||||||
|
|
||||||
|
// Convert encrypted content and nonce to sjcl.BitArray (toBits expects array)
|
||||||
|
const encryptedBits = sjcl.codec.bytes.toBits(Array.from(encryptedContent));
|
||||||
|
const nonceBits = sjcl.codec.bytes.toBits([...nonce, 0, 0, 0, 0]); // Pad to 12 bytes.
|
||||||
|
|
||||||
|
// Isolate the actual ciphertext from the tag and adjust IV.
|
||||||
|
// Copied from sjcl.mode.ccm.decrypt: https://github.com/bitwiseshiftleft/sjcl/blob/85caa53c281eeeb502310013312c775d35fe0867/core/ccm.js#L83
|
||||||
|
const tlen = 128; // Tag length in bits.
|
||||||
|
const dataWithoutTag = sjcl.bitArray.clamp(encryptedBits,
|
||||||
|
// remove tag from out, tag length = 128
|
||||||
|
sjcl.bitArray.bitLength(encryptedBits) - tlen);
|
||||||
|
|
||||||
|
let decryptedBits: { data: sjcl.BitArray };
|
||||||
try {
|
try {
|
||||||
const buffer = Buffer.from(result);
|
decryptedBits = sjclCcmCtrMode(cipher, dataWithoutTag, nonceBits, [], tlen, 3); // hardcoding 3 as "L" / length
|
||||||
const mii = new Mii(buffer);
|
} catch {
|
||||||
|
// Note that the function above is not likely to fail since it decrypts without verification.
|
||||||
|
throw new Error("Failed to decrypt QR code. It may be invalid or corrupted");
|
||||||
|
}
|
||||||
|
// NOTE: The CBC-MAC within the QR code is NOT verified here.
|
||||||
|
|
||||||
|
// Convert the decrypted bytes from sjcl format.
|
||||||
|
const decryptedBytes = sjcl.codec.bytes.fromBits(decryptedBits.data);
|
||||||
|
const decryptedSlice = new Uint8Array(decryptedBytes).subarray(0, 88);
|
||||||
|
|
||||||
|
// Create the final Mii StoreData from the decrypted bytes.
|
||||||
|
const result = new Uint8Array([
|
||||||
|
...decryptedSlice.subarray(0, 12), // First 12 decrypted bytes.
|
||||||
|
...nonce, // Original nonce from the encrypted bytes.
|
||||||
|
...decryptedSlice.subarray(12), // Then the rest of the decrypted bytes.
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Check if Mii data is valid by checking always zero "reserved_2" field.
|
||||||
|
if (result[0x16] !== 0 && result[0x17] !== 0) {
|
||||||
|
throw new Error("QR code is not a valid Mii QR code");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct mii-js Mii and TomodachiLifeMii classes.
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.from(result); // Convert to node Buffer.
|
||||||
|
const mii = new Mii(buffer); // Will verify the Mii data further.
|
||||||
|
// Decrypt Tomodachi Life Mii data from encrypted QR code bytes.
|
||||||
const tomodachiLifeMii = TomodachiLifeMii.fromBytes(bytes);
|
const tomodachiLifeMii = TomodachiLifeMii.fromBytes(bytes);
|
||||||
|
|
||||||
|
// Apply hair dye fields.
|
||||||
if (tomodachiLifeMii.hairDyeEnabled) {
|
if (tomodachiLifeMii.hairDyeEnabled) {
|
||||||
mii.hairColor = tomodachiLifeMii.studioHairColor;
|
mii.hairColor = tomodachiLifeMii.studioHairColor;
|
||||||
mii.eyebrowColor = tomodachiLifeMii.studioHairColor;
|
mii.eyebrowColor = tomodachiLifeMii.studioHairColor;
|
||||||
mii.facialHairColor = tomodachiLifeMii.studioHairColor;
|
mii.facialHairColor = tomodachiLifeMii.studioHairColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Censor potential inappropriate words
|
// Censor potential inappropriate words.
|
||||||
tomodachiLifeMii.firstName = profanity.censor(tomodachiLifeMii.firstName);
|
tomodachiLifeMii.firstName = profanity.censor(tomodachiLifeMii.firstName);
|
||||||
tomodachiLifeMii.lastName = profanity.censor(tomodachiLifeMii.lastName);
|
tomodachiLifeMii.lastName = profanity.censor(tomodachiLifeMii.lastName);
|
||||||
tomodachiLifeMii.islandName = profanity.censor(tomodachiLifeMii.islandName);
|
tomodachiLifeMii.islandName = profanity.censor(tomodachiLifeMii.islandName);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
import { TOMODACHI_LIFE_DECRYPTION_KEY } from "@/lib/constants";
|
import { TOMODACHI_LIFE_DECRYPTION_KEY } from "../lib/constants";
|
||||||
import { AES_CTR } from "@trafficlunar/asmcrypto.js";
|
import sjcl from "sjcl-with-all";
|
||||||
|
|
||||||
|
// @ts-expect-error - This is not in the types, but it's a function needed to enable CTR mode.
|
||||||
|
sjcl.beware["CTR mode is dangerous because it doesn't protect message integrity."]();
|
||||||
|
|
||||||
// Converts hair dye to studio color
|
// Converts hair dye to studio color
|
||||||
|
// Reference: https://github.com/ariankordi/nwf-mii-cemu-toy/blob/9906440c1dafbe3f40ac8b95e10a22ebd85b441e/assets/data-conversion.js#L282
|
||||||
// (Credits to kat21)
|
// (Credits to kat21)
|
||||||
const hairDyeConverter = [
|
const hairDyeConverter = [
|
||||||
55, 51, 50, 12, 16, 12, 67, 61, 51, 64, 69, 66, 65, 86, 85, 93, 92, 19, 20, 20, 15, 32, 35, 26, 38, 41, 43, 18, 95, 97, 97, 99,
|
55, 51, 50, 12, 16, 12, 67, 61, 51, 64, 69, 66, 65, 86, 85, 93, 92, 19, 20, 20, 15, 32, 35, 26, 38, 41, 43, 18, 95, 97, 97, 99,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Reference: https://github.com/ariankordi/nwf-mii-cemu-toy/blob/ffl-renderer-proto-integrate/assets/kaitai-structs/tomodachi_life_qr_code.ksy
|
||||||
// (Credits to ariankordi for the byte locations)
|
// (Credits to ariankordi for the byte locations)
|
||||||
export default class TomodachiLifeMii {
|
export default class TomodachiLifeMii {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
|
|
@ -16,7 +21,7 @@ export default class TomodachiLifeMii {
|
||||||
hairDye: number;
|
hairDye: number;
|
||||||
hairDyeEnabled: boolean;
|
hairDyeEnabled: boolean;
|
||||||
|
|
||||||
// There is more properties but I don't plan to add them *yet*
|
// There are more properties but I don't plan to add them *yet*
|
||||||
|
|
||||||
// Property below is not located in the bytes
|
// Property below is not located in the bytes
|
||||||
studioHairColor: number;
|
studioHairColor: number;
|
||||||
|
|
@ -63,9 +68,18 @@ export default class TomodachiLifeMii {
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromBytes(bytes: Uint8Array): TomodachiLifeMii {
|
static fromBytes(bytes: Uint8Array): TomodachiLifeMii {
|
||||||
const iv = bytes.slice(0x70, 128);
|
const iv = bytes.subarray(0x70, 128);
|
||||||
const encryptedExtraData = bytes.slice(128, -4);
|
const encryptedExtraData = bytes.subarray(128, -4);
|
||||||
const decryptedExtraData = AES_CTR.decrypt(encryptedExtraData, TOMODACHI_LIFE_DECRYPTION_KEY, iv);
|
|
||||||
|
const cipher = new sjcl.cipher.aes(TOMODACHI_LIFE_DECRYPTION_KEY);
|
||||||
|
|
||||||
|
// Convert nonce and encrypted content to sjcl.BitArray.
|
||||||
|
const encryptedBits = sjcl.codec.bytes.toBits(Array.from(encryptedExtraData));
|
||||||
|
const ivBits = sjcl.codec.bytes.toBits(Array.from(iv));
|
||||||
|
// Perform decryption.
|
||||||
|
const decryptedBits = sjcl.mode.ctr.decrypt(cipher, encryptedBits, ivBits);
|
||||||
|
// Convert from sjcl format.
|
||||||
|
const decryptedExtraData = sjcl.codec.bytes.fromBits(decryptedBits);
|
||||||
const data = new Uint8Array(decryptedExtraData);
|
const data = new Uint8Array(decryptedExtraData);
|
||||||
|
|
||||||
return new TomodachiLifeMii(data.buffer);
|
return new TomodachiLifeMii(data.buffer);
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"],
|
||||||
|
"sjcl-with-all": ["./node_modules/@types/sjcl"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue