From 37df0098069f01df6d97929d27837abed3f82d1f Mon Sep 17 00:00:00 2001 From: Arian Kordi Date: Sun, 4 May 2025 18:27:47 -0400 Subject: [PATCH] Refactor QR decryption routines to use sjcl. Remove asmcrypto.js dependency. May or may not break depending on the build of sjcl and/or minification options. It remains to be seen. --- package.json | 3 +- src/lib/constants.ts | 20 +++++-- src/lib/qr-codes.test.ts | 11 ++++ src/lib/qr-codes.ts | 102 ++++++++++++++++++++++++++-------- src/lib/tomodachi-life-mii.ts | 24 ++++++-- tsconfig.json | 3 +- 6 files changed, 128 insertions(+), 35 deletions(-) diff --git a/package.json b/package.json index 92cf684..250f45b 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "@bprogress/next": "^3.2.12", "@hello-pangea/dnd": "^18.0.1", "@prisma/client": "^6.7.0", - "@trafficlunar/asmcrypto.js": "^1.0.2", + "@types/sjcl": "^1.0.34", "bit-buffer": "^0.2.5", "canvas-confetti": "^1.9.3", "dayjs": "^1.11.13", @@ -34,6 +34,7 @@ "react-dropzone": "^14.3.8", "react-webcam": "^7.2.0", "sharp": "^0.34.1", + "sjcl-with-all": "1.0.8", "swr": "^2.3.3", "zod": "^3.24.3" }, diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 7eadb9e..d2744cb 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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]); -export const TOMODACHI_LIFE_DECRYPTION_KEY = new Uint8Array([ - 0x30, 0x81, 0x9f, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, -]); +import type { BitArray } from "sjcl"; + +/** 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]); diff --git a/src/lib/qr-codes.test.ts b/src/lib/qr-codes.test.ts index 1936b6c..016ee71 100644 --- a/src/lib/qr-codes.test.ts +++ b/src/lib/qr-codes.test.ts @@ -30,6 +30,8 @@ const zeroes372 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 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); @@ -53,11 +55,20 @@ describe("convertQrCode", () => { 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"); }); /* diff --git a/src/lib/qr-codes.ts b/src/lib/qr-codes.ts index 18cb0a3..3be79f7 100644 --- a/src/lib/qr-codes.ts +++ b/src/lib/qr-codes.ts @@ -1,47 +1,101 @@ 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 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 } { - // Decrypt the Mii part of the QR code - // (Credits to kazuki-4ys) - const nonce = bytes.subarray(0, 8); - const content = bytes.subarray(8, 0x70); + // Decrypt 96 byte 3DS/Wii U format Mii data from the QR code. + // References (Credits: jaames, kazuki-4ys): + // - https://gist.github.com/jaames/96ce8daa11b61b758b6b0227b55f9f78 + // - https://github.com/kazuki-4ys/kazuki-4ys.github.io/blob/148dc339974f8b7515bfdc1395ec1fc9becb68ab/web_apps/MiiInfoEditorCTR/encode.js#L57 - const nonceWithZeros = new Uint8Array(12); - nonceWithZeros.set(nonce, 0); - - let decrypted: Uint8Array = 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"); + // Check that the private _ctrMode function is defined. + 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."); } - const result = new Uint8Array(96); - result.set(decrypted.subarray(0, 12), 0); - result.set(nonce, 12); - result.set(decrypted.subarray(12), 20); + // Verify that the length is not smaller than expected. + if (bytes.length < MII_QR_ENCRYPTED_LENGTH) { + throw new Error(`Mii QR code has wrong size (got ${bytes.length}, expected ${MII_QR_ENCRYPTED_LENGTH} or longer)`); + } - // Check if QR code is valid (after decryption) - if (result.length !== 0x60 || (result[0x16] !== 0 && result[0x17] !== 0)) throw new Error("QR code is not a valid Mii QR code"); + const nonce = bytes.subarray(0, 8); // Extract the AES-CCM nonce. + 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 { - const buffer = Buffer.from(result); - const mii = new Mii(buffer); + decryptedBits = sjclCcmCtrMode(cipher, dataWithoutTag, nonceBits, [], tlen, 3); // hardcoding 3 as "L" / length + } 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); + // Apply hair dye fields. if (tomodachiLifeMii.hairDyeEnabled) { mii.hairColor = tomodachiLifeMii.studioHairColor; mii.eyebrowColor = tomodachiLifeMii.studioHairColor; mii.facialHairColor = tomodachiLifeMii.studioHairColor; } - // Censor potential inappropriate words + // Censor potential inappropriate words. tomodachiLifeMii.firstName = profanity.censor(tomodachiLifeMii.firstName); tomodachiLifeMii.lastName = profanity.censor(tomodachiLifeMii.lastName); tomodachiLifeMii.islandName = profanity.censor(tomodachiLifeMii.islandName); diff --git a/src/lib/tomodachi-life-mii.ts b/src/lib/tomodachi-life-mii.ts index 4d794ca..6a20ff2 100644 --- a/src/lib/tomodachi-life-mii.ts +++ b/src/lib/tomodachi-life-mii.ts @@ -1,12 +1,17 @@ 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 +// Reference: https://github.com/ariankordi/nwf-mii-cemu-toy/blob/9906440c1dafbe3f40ac8b95e10a22ebd85b441e/assets/data-conversion.js#L282 // (Credits to kat21) 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, ]; +// 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) export default class TomodachiLifeMii { firstName: string; @@ -16,7 +21,7 @@ export default class TomodachiLifeMii { hairDye: number; 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 studioHairColor: number; @@ -63,9 +68,18 @@ export default class TomodachiLifeMii { } static fromBytes(bytes: Uint8Array): TomodachiLifeMii { - const iv = bytes.slice(0x70, 128); - const encryptedExtraData = bytes.slice(128, -4); - const decryptedExtraData = AES_CTR.decrypt(encryptedExtraData, TOMODACHI_LIFE_DECRYPTION_KEY, iv); + const iv = bytes.subarray(0x70, 128); + const encryptedExtraData = bytes.subarray(128, -4); + + 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); return new TomodachiLifeMii(data.buffer); diff --git a/tsconfig.json b/tsconfig.json index c133409..57f13d4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,7 +19,8 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "sjcl-with-all": ["./node_modules/@types/sjcl"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],