commit af2bb123a789f5f9807737a45752a2a5ec051e2b Author: Ian Burgwin Date: Fri Sep 6 14:22:13 2019 -0700 initial commit diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..80948cd --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2019 Ian Burgwin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1ffbf38 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# custom-install +Experimental script to automate the process of a manual title install for Nintendo 3DS. Originally created late June 2019. + +## Summary +1. Dump boot9.bin and movable.sed from a 3DS system. +2. Install pycryptodomex: + * Windows: `py -3 -m pip install --user --upgrade pycryptodomex` + * macOS/Linux: `python3 -m pip install --user --upgrade pycryptodomex` +2. Run `custom-install.py` with boot9.bin, movable.sed, path to the SD root, and CIA files to install (see Usage section). +3. Use custom-install-finalize on the 3DS system to finish the install. + +## Setup +movable.sed is required and can be provided with `-m` or `--movable`. + +boot9 is needed: +* `-b` or `--boot9` argument (if set) +* `BOOT9_PATH` environment variable (if set) +* `%APPDATA%\3ds\boot9.bin` (Windows-specific) +* `~/Library/Application Support/3ds/boot9.bin` (macOS-specific) +* `~/.3ds/boot9.bin` +* `~/3ds/boot9.bin` + +A [SeedDB](https://github.com/ihaveamac/3DS-rom-tools/wiki/SeedDB-list) is needed for newer games (2015+) that use seeds. +SeedDB is checked in order of: +* `--seeddb` argument (if set) +* `SEEDDB_PATH` environment variable (if set) +* `%APPDATA%\3ds\seeddb.bin` (Windows-specific) +* `~/Library/Application Support/3ds/seeddb.bin` (macOS-specific) +* `~/.3ds/seeddb.bin` +* `~/3ds/seeddb.bin` + +## Usage +Use `-h` to view arguments. + +Examples: +``` +py -3 custom-install.py -b boot9.bin -m movable.sed --sd E:\ file.cia file2.cia +python3 custom-install.py -b boot9.bin -m movable.sed --sd /Volumes/GM9SD file.cia file2.cia +python3 custom-install.py -b boot9.bin -m movable.sed --sd /media/GM9SD file.cia file2.cia +``` + +## License/Credits +`pyctr/` is from [ninfs `d994c78`](https://github.com/ihaveamac/ninfs/tree/d994c78acf5ff3840df1ef5a6aabdc12ca98e806/ninfs/pyctr). + +[save3ds by wwylele](https://github.com/wwylele/save3ds) is used to interact with the Title Database (details in `bin/README`). + +Thanks to @BpyH64 for [researching how to generate the cmacs](https://github.com/d0k3/GodMode9/issues/340#issuecomment-487916606). diff --git a/bin/Cargo.lock b/bin/Cargo.lock new file mode 100644 index 0000000..a036e1e --- /dev/null +++ b/bin/Cargo.lock @@ -0,0 +1,515 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +[[package]] +name = "aes" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "aes-soft 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)", + "aesni 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)", + "block-cipher-trait 0.6.2 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "aes-soft" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "block-cipher-trait 0.6.2 (registry+https://github.com/rust-lang/crates.io-index)", + "byteorder 1.3.2 (registry+https://github.com/rust-lang/crates.io-index)", + "opaque-debug 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "aesni" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "block-cipher-trait 0.6.2 (registry+https://github.com/rust-lang/crates.io-index)", + "opaque-debug 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "autocfg" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "bitflags" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "block-buffer" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "block-padding 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)", + "byte-tools 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)", + "byteorder 1.3.2 (registry+https://github.com/rust-lang/crates.io-index)", + "generic-array 0.12.3 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "block-cipher-trait" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "generic-array 0.12.3 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "block-padding" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "byte-tools 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "byte-tools" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "byte_struct" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "byte_struct_derive 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "byte_struct_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "proc-macro2 0.4.30 (registry+https://github.com/rust-lang/crates.io-index)", + "quote 0.6.13 (registry+https://github.com/rust-lang/crates.io-index)", + "syn 0.15.39 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "byteorder" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "cfg-if" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "cloudabi" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "bitflags 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "cmac" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "block-cipher-trait 0.6.2 (registry+https://github.com/rust-lang/crates.io-index)", + "crypto-mac 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", + "dbl 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "crypto-mac" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "generic-array 0.12.3 (registry+https://github.com/rust-lang/crates.io-index)", + "subtle 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "dbl" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "generic-array 0.12.3 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "digest" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "generic-array 0.12.3 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "fake-simd" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "fuchsia-cprng" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "fuse" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "libc 0.2.60 (registry+https://github.com/rust-lang/crates.io-index)", + "log 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)", + "pkg-config 0.3.14 (registry+https://github.com/rust-lang/crates.io-index)", + "thread-scoped 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)", + "time 0.1.42 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "generic-array" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "typenum 1.10.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "getopts" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "unicode-width 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "hashbrown" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "byteorder 1.3.2 (registry+https://github.com/rust-lang/crates.io-index)", + "scopeguard 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "libc" +version = "0.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "libsave3ds" +version = "0.1.0" +dependencies = [ + "aes 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)", + "byte_struct 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)", + "cmac 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", + "lru 0.1.15 (registry+https://github.com/rust-lang/crates.io-index)", + "rand 0.6.5 (registry+https://github.com/rust-lang/crates.io-index)", + "sha2 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "log" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "log 0.4.7 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "log" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "cfg-if 0.1.9 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "lru" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "hashbrown 0.1.8 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "opaque-debug" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "pkg-config" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "proc-macro2" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "unicode-xid 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "quote" +version = "0.6.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "proc-macro2 0.4.30 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "rand" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "autocfg 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)", + "libc 0.2.60 (registry+https://github.com/rust-lang/crates.io-index)", + "rand_chacha 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)", + "rand_core 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "rand_hc 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", + "rand_isaac 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)", + "rand_jitter 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)", + "rand_os 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)", + "rand_pcg 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", + "rand_xorshift 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.7 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "rand_chacha" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "autocfg 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)", + "rand_core 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "rand_core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "rand_core 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "rand_core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "rand_hc" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "rand_core 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "rand_isaac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "rand_core 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "rand_jitter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "libc 0.2.60 (registry+https://github.com/rust-lang/crates.io-index)", + "rand_core 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.7 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "rand_os" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "cloudabi 0.0.3 (registry+https://github.com/rust-lang/crates.io-index)", + "fuchsia-cprng 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)", + "libc 0.2.60 (registry+https://github.com/rust-lang/crates.io-index)", + "rand_core 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "rdrand 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.7 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "rand_pcg" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "autocfg 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)", + "rand_core 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "rand_xorshift" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "rand_core 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "rdrand" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "rand_core 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "redox_syscall" +version = "0.1.56" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "save3ds_fuse" +version = "0.1.0" +dependencies = [ + "fuse 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)", + "getopts 0.2.19 (registry+https://github.com/rust-lang/crates.io-index)", + "libc 0.2.60 (registry+https://github.com/rust-lang/crates.io-index)", + "libsave3ds 0.1.0", + "time 0.1.42 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "scopeguard" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "sha2" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "block-buffer 0.7.3 (registry+https://github.com/rust-lang/crates.io-index)", + "digest 0.8.1 (registry+https://github.com/rust-lang/crates.io-index)", + "fake-simd 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", + "opaque-debug 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "subtle" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "syn" +version = "0.15.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "proc-macro2 0.4.30 (registry+https://github.com/rust-lang/crates.io-index)", + "quote 0.6.13 (registry+https://github.com/rust-lang/crates.io-index)", + "unicode-xid 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "thread-scoped" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "time" +version = "0.1.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "libc 0.2.60 (registry+https://github.com/rust-lang/crates.io-index)", + "redox_syscall 0.1.56 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.7 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "typenum" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "unicode-width" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "unicode-xid" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "winapi" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "winapi-i686-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi-x86_64-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[metadata] +"checksum aes 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)" = "54eb1d8fe354e5fc611daf4f2ea97dd45a765f4f1e4512306ec183ae2e8f20c9" +"checksum aes-soft 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)" = "cfd7e7ae3f9a1fb5c03b389fc6bb9a51400d0c13053f0dca698c832bfd893a0d" +"checksum aesni 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)" = "2f70a6b5f971e473091ab7cfb5ffac6cde81666c4556751d8d5620ead8abf100" +"checksum autocfg 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)" = "0e49efa51329a5fd37e7c79db4621af617cd4e3e5bc224939808d076077077bf" +"checksum bitflags 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "3d155346769a6855b86399e9bc3814ab343cd3d62c7e985113d46a0ec3c281fd" +"checksum block-buffer 0.7.3 (registry+https://github.com/rust-lang/crates.io-index)" = "c0940dc441f31689269e10ac70eb1002a3a1d3ad1390e030043662eb7fe4688b" +"checksum block-cipher-trait 0.6.2 (registry+https://github.com/rust-lang/crates.io-index)" = "1c924d49bd09e7c06003acda26cd9742e796e34282ec6c1189404dee0c1f4774" +"checksum block-padding 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)" = "6d4dc3af3ee2e12f3e5d224e5e1e3d73668abbeb69e566d361f7d5563a4fdf09" +"checksum byte-tools 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)" = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7" +"checksum byte_struct 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)" = "a11c85e60b49d9b5d16630266fc72d75172bd4af43e77cf390eec8ba9752a70d" +"checksum byte_struct_derive 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)" = "7fb6eccde50afec044557d1f1b8776168b7040255390eefffb39fcfd1ab40b2e" +"checksum byteorder 1.3.2 (registry+https://github.com/rust-lang/crates.io-index)" = "a7c3dd8985a7111efc5c80b44e23ecdd8c007de8ade3b96595387e812b957cf5" +"checksum cfg-if 0.1.9 (registry+https://github.com/rust-lang/crates.io-index)" = "b486ce3ccf7ffd79fdeb678eac06a9e6c09fc88d33836340becb8fffe87c5e33" +"checksum cloudabi 0.0.3 (registry+https://github.com/rust-lang/crates.io-index)" = "ddfc5b9aa5d4507acaf872de71051dfd0e309860e88966e1051e462a077aac4f" +"checksum cmac 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "6f4a435124bcc292eba031f1f725d7abacdaf13cbf9f935450e8c45aa9e96cad" +"checksum crypto-mac 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)" = "4434400df11d95d556bac068ddfedd482915eb18fe8bea89bc80b6e4b1c179e5" +"checksum dbl 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "28dc203b75decc900220c4d9838e738d08413e663c26826ba92b669bed1d0795" +"checksum digest 0.8.1 (registry+https://github.com/rust-lang/crates.io-index)" = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5" +"checksum fake-simd 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed" +"checksum fuchsia-cprng 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" +"checksum fuse 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)" = "80e57070510966bfef93662a81cb8aa2b1c7db0964354fa9921434f04b9e8660" +"checksum generic-array 0.12.3 (registry+https://github.com/rust-lang/crates.io-index)" = "c68f0274ae0e023facc3c97b2e00f076be70e254bc851d972503b328db79b2ec" +"checksum getopts 0.2.19 (registry+https://github.com/rust-lang/crates.io-index)" = "72327b15c228bfe31f1390f93dd5e9279587f0463836393c9df719ce62a3e450" +"checksum hashbrown 0.1.8 (registry+https://github.com/rust-lang/crates.io-index)" = "3bae29b6653b3412c2e71e9d486db9f9df5d701941d86683005efb9f2d28e3da" +"checksum libc 0.2.60 (registry+https://github.com/rust-lang/crates.io-index)" = "d44e80633f007889c7eff624b709ab43c92d708caad982295768a7b13ca3b5eb" +"checksum log 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)" = "e19e8d5c34a3e0e2223db8e060f9e8264aeeb5c5fc64a4ee9965c062211c024b" +"checksum log 0.4.7 (registry+https://github.com/rust-lang/crates.io-index)" = "c275b6ad54070ac2d665eef9197db647b32239c9d244bfb6f041a766d00da5b3" +"checksum lru 0.1.15 (registry+https://github.com/rust-lang/crates.io-index)" = "276235bb6b60773280b44b65e93815de82da5b6279ef175004fca03f4d06770a" +"checksum opaque-debug 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "93f5bb2e8e8dec81642920ccff6b61f1eb94fa3020c5a325c9851ff604152409" +"checksum pkg-config 0.3.14 (registry+https://github.com/rust-lang/crates.io-index)" = "676e8eb2b1b4c9043511a9b7bea0915320d7e502b0a079fb03f9635a5252b18c" +"checksum proc-macro2 0.4.30 (registry+https://github.com/rust-lang/crates.io-index)" = "cf3d2011ab5c909338f7887f4fc896d35932e29146c12c8d01da6b22a80ba759" +"checksum quote 0.6.13 (registry+https://github.com/rust-lang/crates.io-index)" = "6ce23b6b870e8f94f81fb0a363d65d86675884b34a09043c81e5562f11c1f8e1" +"checksum rand 0.6.5 (registry+https://github.com/rust-lang/crates.io-index)" = "6d71dacdc3c88c1fde3885a3be3fbab9f35724e6ce99467f7d9c5026132184ca" +"checksum rand_chacha 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "556d3a1ca6600bfcbab7c7c91ccb085ac7fbbcd70e008a98742e7847f4f7bcef" +"checksum rand_core 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)" = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" +"checksum rand_core 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "d0e7a549d590831370895ab7ba4ea0c1b6b011d106b5ff2da6eee112615e6dc0" +"checksum rand_hc 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "7b40677c7be09ae76218dc623efbf7b18e34bced3f38883af07bb75630a21bc4" +"checksum rand_isaac 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "ded997c9d5f13925be2a6fd7e66bf1872597f759fd9dd93513dd7e92e5a5ee08" +"checksum rand_jitter 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)" = "1166d5c91dc97b88d1decc3285bb0a99ed84b05cfd0bc2341bdf2d43fc41e39b" +"checksum rand_os 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)" = "7b75f676a1e053fc562eafbb47838d67c84801e38fc1ba459e8f180deabd5071" +"checksum rand_pcg 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "abf9b09b01790cfe0364f52bf32995ea3c39f4d2dd011eac241d2914146d0b44" +"checksum rand_xorshift 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "cbf7e9e623549b0e21f6e97cf8ecf247c1a8fd2e8a992ae265314300b2455d5c" +"checksum rdrand 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2" +"checksum redox_syscall 0.1.56 (registry+https://github.com/rust-lang/crates.io-index)" = "2439c63f3f6139d1b57529d16bc3b8bb855230c8efcc5d3a896c8bea7c3b1e84" +"checksum scopeguard 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)" = "94258f53601af11e6a49f722422f6e3425c52b06245a5cf9bc09908b174f5e27" +"checksum sha2 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)" = "7b4d8bfd0e469f417657573d8451fb33d16cfe0989359b93baf3a1ffc639543d" +"checksum subtle 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "2d67a5a62ba6e01cb2192ff309324cb4875d0c451d55fe2319433abe7a05a8ee" +"checksum syn 0.15.39 (registry+https://github.com/rust-lang/crates.io-index)" = "b4d960b829a55e56db167e861ddb43602c003c7be0bee1d345021703fac2fb7c" +"checksum thread-scoped 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)" = "bcbb6aa301e5d3b0b5ef639c9a9c7e2f1c944f177b460c04dc24c69b1fa2bd99" +"checksum time 0.1.42 (registry+https://github.com/rust-lang/crates.io-index)" = "db8dcfca086c1143c9270ac42a2bbd8a7ee477b78ac8e45b19abfb0cbede4b6f" +"checksum typenum 1.10.0 (registry+https://github.com/rust-lang/crates.io-index)" = "612d636f949607bdf9b123b4a6f6d966dedf3ff669f7f045890d3a4a73948169" +"checksum unicode-width 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)" = "882386231c45df4700b275c7ff55b6f3698780a650026380e72dabe76fa46526" +"checksum unicode-xid 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "fc72304796d0818e357ead4e000d19c9c174ab23dc11093ac919054d20a6a7fc" +"checksum winapi 0.3.7 (registry+https://github.com/rust-lang/crates.io-index)" = "f10e386af2b13e47c89e7236a7a14a086791a2b88ebad6df9bf42040195cf770" +"checksum winapi-i686-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +"checksum winapi-x86_64-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" diff --git a/bin/README b/bin/README new file mode 100644 index 0000000..ddd945b --- /dev/null +++ b/bin/README @@ -0,0 +1,4 @@ +save3ds_fuse for win32 and darwin built with commit a9bd1468751200ccd2d5285dd91482847d6a22ed +in repository https://github.com/wwylele/save3ds + +linux binary must be provided by the user. diff --git a/bin/darwin/save3ds_fuse b/bin/darwin/save3ds_fuse new file mode 100755 index 0000000..b08eec4 Binary files /dev/null and b/bin/darwin/save3ds_fuse differ diff --git a/bin/linux/put_save3ds_fuse_here b/bin/linux/put_save3ds_fuse_here new file mode 100644 index 0000000..e69de29 diff --git a/bin/win32/save3ds_fuse.exe b/bin/win32/save3ds_fuse.exe new file mode 100755 index 0000000..ecd0c28 Binary files /dev/null and b/bin/win32/save3ds_fuse.exe differ diff --git a/custom-install.py b/custom-install.py new file mode 100755 index 0000000..d65afce --- /dev/null +++ b/custom-install.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 + +# This file is a part of custom-install.py. +# +# Copyright (c) 2019 Ian Burgwin +# This file is licensed under The MIT License (MIT). +# You can find the full license text in LICENSE.md in the root of this project. + +from argparse import ArgumentParser +from os import makedirs, scandir +from os.path import dirname, join +from random import randint +from hashlib import sha256 +from sys import exit, platform +from tempfile import TemporaryDirectory +from typing import BinaryIO +import subprocess + +from pyctr.crypto import CryptoEngine, Keyslot +from pyctr.types.cia import CIAReader, CIASection +from pyctr.types.ncch import NCCHSection +from pyctr.util import roundup + +# used to run the save3ds_fuse binary next to the script +script_dir: str = dirname(__file__) + +# missing contents are replaced with 0xFFFFFFFF in the cmd file +CMD_MISSING = b'\xff\xff\xff\xff' + +# the size of each file and directory in a title's contents are rounded up to this +TITLE_ALIGN_SIZE = 0x8000 + +# size to read at a time when copying files +READ_SIZE = 0x200000 + + +def copy_with_progress(src: BinaryIO, dst: BinaryIO, size: int, path: str): + left = size + cipher = crypto.create_ctr_cipher(Keyslot.SD, crypto.sd_path_to_iv(path)) + while left > 0: + to_read = min(READ_SIZE, left) + data = cipher.encrypt(src.read(READ_SIZE)) + dst.write(data) + left -= to_read + total_read = size - left + print(f' {(total_read / size) * 100:>5.1f}% {total_read / 1048576:>.1f} MiB / {size / 1048576:.1f} MiB', + end='\r', flush=True) + print() + + +parser = ArgumentParser(description='Manually install a CIA to the SD card for a Nintendo 3DS system.') +parser.add_argument('cia', help='CIA files', nargs='+') +parser.add_argument('-m', '--movable', help='movable.sed file', required=True) +parser.add_argument('-b', '--boot9', help='boot9 file') +parser.add_argument('--sd', help='path to SD root') +parser.add_argument('--skip-contents', help="don't add contents, only add title info entry", action='store_true') + +args = parser.parse_args() + +# set up the crypto engine to encrypt contents as they are written to the SD +crypto = CryptoEngine(boot9=args.boot9) +crypto.setup_sd_key_from_file(args.movable) + +# try to find the path to the SD card contents +print('Finding path to install to...') +sd_path = join(args.sd, 'Nintendo 3DS', crypto.id0.hex()) +id1s = [] +for d in scandir(sd_path): + if d.is_dir() and len(d.name) == 32: + try: + id1_tmp = bytes.fromhex(d.name) + except ValueError: + continue + else: + id1s.append(d.name) + +# check the amount of id1 directories +if len(id1s) > 1: + exit(f'There are multiple id1 directories for id0 {crypto.id0.hex()}, please remove extra directories') +elif len(id1s) == 0: + exit(f'Could not find a suitable id1 directory for id0 {crypto.id0.hex()}') + +sd_path = join(sd_path, id1s[0]) + +title_info_entries = {} +# for use with a finalize program on the 3DS +finalize_entries = [] + +for c in args.cia: + # parse the cia + print('Reading CIA...') + cia = CIAReader(c) + tid_parts = (cia.tmd.title_id[0:8], cia.tmd.title_id[8:16]) + + try: + print(f'Installing {cia.contents[0].exefs.icon.get_app_title().short_desc}...') + except: + print('Installing...') + + # this includes the sizes for all the files that would be in the title, plus each directory + # except the separate directories for DLC contents, which don't count towards the size. + # five "1"s are here for the tidlow and content directories, the cmd file and its directory, and the tmd file + sizes = [1] * 5 + + if cia.tmd.save_size: + # one for the data directory, one for the 00000001.sav file + sizes.extend((1, cia.tmd.save_size)) + + for record in cia.content_info: + sizes.append(record.size) + + # this calculates the size to put in the Title Info Entry + title_size = sum(roundup(x, TITLE_ALIGN_SIZE) for x in sizes) + + # checks if this is dlc, which has some differences + is_dlc = tid_parts[0] == '0004008c' + + # this checks if it has a manual (index 1) and is not DLC + has_manual = (not is_dlc) and (1 in cia.contents) + + # this gets the extdata id from the extheader, stored in the storage info area + try: + with cia.contents[0].open_raw_section(NCCHSection.ExtendedHeader) as e: + e.seek(0x200 + 0x30) + extdata_id = e.read(8) + except KeyError: + # not an executable title + extdata_id = b'\0' * 8 + + # cmd content id, starts with 1 for non-dlc contents + cmd_id = len(cia.content_info) if is_dlc else 1 + cmd_filename = f'{cmd_id:08x}.cmd' + + # get the title root where all the contents will be + title_root = join(sd_path, 'title', *tid_parts) + content_root = join(title_root, 'content') + # generate the path used for the IV + title_root_cmd = f'/title/{"/".join(tid_parts)}' + content_root_cmd = title_root_cmd + '/content' + + makedirs(join(content_root, 'cmd'), exist_ok=True) + if cia.tmd.save_size: + makedirs(join(title_root, 'data'), exist_ok=True) + if is_dlc: + # create the separate directories for every 256 contents + for x in range(((len(cia.content_info) - 1) // 256) + 1): + makedirs(join(content_root, f'{x:08x}')) + + # maybe this will be changed in the future + tmd_id = 0 + + tmd_filename = f'{tmd_id:08x}.tmd' + + if not args.skip_contents: + # write the tmd + enc_path = content_root_cmd + '/' + tmd_filename + print(f'Writing {enc_path}...') + with cia.open_raw_section(CIASection.TitleMetadata) as s, open(join(content_root, tmd_filename), 'wb') as o: + copy_with_progress(s, o, cia.sections[CIASection.TitleMetadata].size, enc_path) + + # write each content + for c in cia.content_info: + content_filename = c.id + '.app' + if is_dlc: + dir_index = format((c.cindex // 256), '08x') + enc_path = content_root_cmd + f'/{dir_index}/{content_filename}' + out_path = join(content_root, dir_index, content_filename) + else: + enc_path = content_root_cmd + '/' + content_filename + out_path = join(content_root, content_filename) + print(f'Writing {enc_path}...') + with cia.open_raw_section(c.cindex) as s, open(out_path, 'wb') as o: + copy_with_progress(s, o, c.size, enc_path) + + # generate a blank save + if cia.tmd.save_size: + enc_path = title_root_cmd + '/data/00000001.sav' + out_path = join(title_root, 'data', '00000001.sav') + cipher = crypto.create_ctr_cipher(Keyslot.SD, crypto.sd_path_to_iv(enc_path)) + # in a new save, the first 0x20 are all 00s. the rest can be random + data = cipher.encrypt(b'\0' * 0x20) + print(f'Generating blank save at {enc_path}...') + with open(out_path, 'wb') as o: + o.write(data) + o.write(b'\0' * (cia.tmd.save_size - 0x20)) + + # generate and write cmd + enc_path = content_root_cmd + '/cmd/' + cmd_filename + out_path = join(content_root, 'cmd', cmd_filename) + print(f'Generating {enc_path}') + highest_index = 0 + content_ids = {} + + for record in cia.content_info: + highest_index = record.cindex + with cia.open_raw_section(record.cindex) as s: + s.seek(0x100) + cmac_data = s.read(0x100) + + id_bytes = bytes.fromhex(record.id)[::-1] + cmac_data += record.cindex.to_bytes(4, 'little') + id_bytes + + c = crypto.create_cmac_object(Keyslot.CMACSDNAND) + c.update(sha256(cmac_data).digest()) + content_ids[record.cindex] = (id_bytes, c.digest()) + + # add content IDs up to the last one + ids_by_index = [CMD_MISSING] * (highest_index + 1) + installed_ids = [] + cmacs = [] + for x in range(len(ids_by_index)): + try: + info = content_ids[x] + except KeyError: + cmacs.append(bytes.fromhex('4D495353494E4720434F4E54454E5421')) + else: + ids_by_index[x] = info[0] + cmacs.append(info[1]) + installed_ids.append(info[0]) + installed_ids.sort(key=lambda x: int.from_bytes(x, 'little')) + + final = (cmd_id.to_bytes(4, 'little') + + len(ids_by_index).to_bytes(4, 'little') + + len(installed_ids).to_bytes(4, 'little') + + (1).to_bytes(4, 'little')) + c = crypto.create_cmac_object(Keyslot.CMACSDNAND) + c.update(final) + final += c.digest() + + final += b''.join(ids_by_index) + final += b''.join(installed_ids) + final += b''.join(cmacs) + + cipher = crypto.create_ctr_cipher(Keyslot.SD, crypto.sd_path_to_iv(enc_path)) + print(f'Writing {enc_path}') + with open(out_path, 'wb') as o: + o.write(cipher.encrypt(final)) + + # this starts building the title info entry + title_info_entry_data = [ + # title size + title_size.to_bytes(8, 'little'), + # title type, seems to usually be 0x40 + 0x40.to_bytes(4, 'little'), + # title version + int(cia.tmd.title_version).to_bytes(2, 'little'), + # ncch version + cia.contents[0].version.to_bytes(2, 'little'), + # flags_0, only checking if there is a manual + (1 if has_manual else 0).to_bytes(4, 'little'), + # tmd content id, always starting with 0 + (0).to_bytes(4, 'little'), + # cmd content id + cmd_id.to_bytes(4, 'little'), + # flags_1, only checking save data + (1 if cia.tmd.save_size else 0).to_bytes(4, 'little'), + # extdataid low + extdata_id[0:4], + # reserved + b'\0' * 4, + # flags_2, only using a common value + 0x100000000.to_bytes(8, 'little'), + # product code + cia.contents[0].product_code.encode('ascii').ljust(0x10, b'\0'), + # reserved + b'\0' * 0x10, + # unknown + randint(0, 0xFFFFFFFF).to_bytes(4, 'little'), + # reserved + b'\0' * 0x2c + ] + + print(title_info_entry_data) + + title_info_entries[cia.tmd.title_id] = b''.join(title_info_entry_data) + + with cia.open_raw_section(CIASection.Ticket) as t: + ticket_data = t.read() + + finalize_entry_data = [ + # title id + bytes.fromhex(cia.tmd.title_id)[::-1], + # common key index + ticket_data[0x1F1:0x1F2], + # has seed + cia.contents[0].flags.uses_seed.to_bytes(1, 'little'), + # magic + b'TITLE\0', + # encrypted titlekey + ticket_data[0x1BF:0x1CF], + # seed, if needed + cia.contents[0].seed if cia.contents[0].flags.uses_seed else (b'\0' * 0x10) + ] + + finalize_entries.append(b''.join(finalize_entry_data)) + +with open(join(args.sd, 'cifinish.bin'), 'wb') as o: + # magic, version, title count + o.write(b'CIFINISH' + (1).to_bytes(4, 'little') + len(finalize_entries).to_bytes(4, 'little')) + + # add each entry to cifinish.bin + for entry in finalize_entries: + o.write(entry) + +with TemporaryDirectory(suffix='-custom-install') as tempdir: + # set up the common arguments for the two times we call save3ds_fuse + save3ds_fuse_common_args = [ + join(script_dir, 'bin', platform, 'save3ds_fuse'), + '-b', crypto.b9_path, + '-m', args.movable, + '--sd', args.sd, + '--db', 'sdtitle', + tempdir + ] + + # extract the title database to add our own entry to + print('Extracting Title Database...') + subprocess.run(save3ds_fuse_common_args + ['-x']) + + for title_id, entry in title_info_entries.items(): + # write the title info entry to the temp directory + with open(join(tempdir, title_id), 'wb') as o: + o.write(entry) + + # import the directory, now including our title + print('Importing into Title Database...') + subprocess.run(save3ds_fuse_common_args + ['-i']) diff --git a/finalize/Makefile b/finalize/Makefile new file mode 100644 index 0000000..ffa959d --- /dev/null +++ b/finalize/Makefile @@ -0,0 +1,259 @@ +#--------------------------------------------------------------------------------- +.SUFFIXES: +#--------------------------------------------------------------------------------- + +ifeq ($(strip $(DEVKITARM)),) +$(error "Please set DEVKITARM in your environment. export DEVKITARM=devkitARM") +endif + +TOPDIR ?= $(CURDIR) +include $(DEVKITARM)/3ds_rules + +#--------------------------------------------------------------------------------- +# TARGET is the name of the output +# BUILD is the directory where object files & intermediate files will be placed +# SOURCES is a list of directories containing source code +# DATA is a list of directories containing data files +# INCLUDES is a list of directories containing header files +# GRAPHICS is a list of directories containing graphics files +# GFXBUILD is the directory where converted graphics files will be placed +# If set to $(BUILD), it will statically link in the converted +# files as if they were data files. +# +# NO_SMDH: if set to anything, no SMDH file is generated. +# ROMFS is the directory which contains the RomFS, relative to the Makefile (Optional) +# APP_TITLE is the name of the app stored in the SMDH file (Optional) +# APP_DESCRIPTION is the description of the app stored in the SMDH file (Optional) +# APP_AUTHOR is the author of the app stored in the SMDH file (Optional) +# ICON is the filename of the icon (.png), relative to the project folder. +# If not set, it attempts to use one of the following (in this order): +# - .png +# - icon.png +# - /default_icon.png +#--------------------------------------------------------------------------------- +TARGET := custom-install-finalize +BUILD := build +SOURCES := source +DATA := data +INCLUDES := include +GRAPHICS := gfx +GFXBUILD := $(BUILD) +#ROMFS := romfs +#GFXBUILD := $(ROMFS)/gfx + +APP_TITLE := custom-install-finalize +APP_DESCRIPTION := Finalize installation from custom-install. +APP_AUTHOR := ihaveamac + +#--------------------------------------------------------------------------------- +# options for code generation +#--------------------------------------------------------------------------------- +ARCH := -march=armv6k -mtune=mpcore -mfloat-abi=hard -mtp=soft + +CFLAGS := -g -Wall -O2 -mword-relocations \ + -fomit-frame-pointer -ffunction-sections \ + $(ARCH) + +CFLAGS += $(INCLUDE) -DARM11 -D_3DS + +CXXFLAGS := $(CFLAGS) -fno-rtti -fno-exceptions -std=gnu++11 + +ASFLAGS := -g $(ARCH) +LDFLAGS = -specs=3dsx.specs -g $(ARCH) -Wl,-Map,$(notdir $*.map) + +LIBS := -lctru -lm + +#--------------------------------------------------------------------------------- +# list of directories containing libraries, this must be the top level containing +# include and lib +#--------------------------------------------------------------------------------- +LIBDIRS := $(CTRULIB) + + +#--------------------------------------------------------------------------------- +# no real need to edit anything past this point unless you need to add additional +# rules for different file extensions +#--------------------------------------------------------------------------------- +ifneq ($(BUILD),$(notdir $(CURDIR))) +#--------------------------------------------------------------------------------- + +export OUTPUT := $(CURDIR)/$(TARGET) +export TOPDIR := $(CURDIR) + +export VPATH := $(foreach dir,$(SOURCES),$(CURDIR)/$(dir)) \ + $(foreach dir,$(GRAPHICS),$(CURDIR)/$(dir)) \ + $(foreach dir,$(DATA),$(CURDIR)/$(dir)) + +export DEPSDIR := $(CURDIR)/$(BUILD) + +CFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.c))) +CPPFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.cpp))) +SFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.s))) +PICAFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.v.pica))) +SHLISTFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.shlist))) +GFXFILES := $(foreach dir,$(GRAPHICS),$(notdir $(wildcard $(dir)/*.t3s))) +BINFILES := $(foreach dir,$(DATA),$(notdir $(wildcard $(dir)/*.*))) + +#--------------------------------------------------------------------------------- +# use CXX for linking C++ projects, CC for standard C +#--------------------------------------------------------------------------------- +ifeq ($(strip $(CPPFILES)),) +#--------------------------------------------------------------------------------- + export LD := $(CC) +#--------------------------------------------------------------------------------- +else +#--------------------------------------------------------------------------------- + export LD := $(CXX) +#--------------------------------------------------------------------------------- +endif +#--------------------------------------------------------------------------------- + +#--------------------------------------------------------------------------------- +ifeq ($(GFXBUILD),$(BUILD)) +#--------------------------------------------------------------------------------- +export T3XFILES := $(GFXFILES:.t3s=.t3x) +#--------------------------------------------------------------------------------- +else +#--------------------------------------------------------------------------------- +export ROMFS_T3XFILES := $(patsubst %.t3s, $(GFXBUILD)/%.t3x, $(GFXFILES)) +export T3XHFILES := $(patsubst %.t3s, $(BUILD)/%.h, $(GFXFILES)) +#--------------------------------------------------------------------------------- +endif +#--------------------------------------------------------------------------------- + +export OFILES_SOURCES := $(CPPFILES:.cpp=.o) $(CFILES:.c=.o) $(SFILES:.s=.o) + +export OFILES_BIN := $(addsuffix .o,$(BINFILES)) \ + $(PICAFILES:.v.pica=.shbin.o) $(SHLISTFILES:.shlist=.shbin.o) \ + $(addsuffix .o,$(T3XFILES)) + +export OFILES := $(OFILES_BIN) $(OFILES_SOURCES) + +export HFILES := $(PICAFILES:.v.pica=_shbin.h) $(SHLISTFILES:.shlist=_shbin.h) \ + $(addsuffix .h,$(subst .,_,$(BINFILES))) \ + $(GFXFILES:.t3s=.h) + +export INCLUDE := $(foreach dir,$(INCLUDES),-I$(CURDIR)/$(dir)) \ + $(foreach dir,$(LIBDIRS),-I$(dir)/include) \ + -I$(CURDIR)/$(BUILD) + +export LIBPATHS := $(foreach dir,$(LIBDIRS),-L$(dir)/lib) + +export _3DSXDEPS := $(if $(NO_SMDH),,$(OUTPUT).smdh) + +ifeq ($(strip $(ICON)),) + icons := $(wildcard *.png) + ifneq (,$(findstring $(TARGET).png,$(icons))) + export APP_ICON := $(TOPDIR)/$(TARGET).png + else + ifneq (,$(findstring icon.png,$(icons))) + export APP_ICON := $(TOPDIR)/icon.png + endif + endif +else + export APP_ICON := $(TOPDIR)/$(ICON) +endif + +ifeq ($(strip $(NO_SMDH)),) + export _3DSXFLAGS += --smdh=$(CURDIR)/$(TARGET).smdh +endif + +ifneq ($(ROMFS),) + export _3DSXFLAGS += --romfs=$(CURDIR)/$(ROMFS) +endif + +.PHONY: all clean + +#--------------------------------------------------------------------------------- +all: $(BUILD) $(GFXBUILD) $(DEPSDIR) $(ROMFS_T3XFILES) $(T3XHFILES) + @$(MAKE) --no-print-directory -C $(BUILD) -f $(CURDIR)/Makefile + +$(BUILD): + @mkdir -p $@ + +ifneq ($(GFXBUILD),$(BUILD)) +$(GFXBUILD): + @mkdir -p $@ +endif + +ifneq ($(DEPSDIR),$(BUILD)) +$(DEPSDIR): + @mkdir -p $@ +endif + +#--------------------------------------------------------------------------------- +clean: + @echo clean ... + @rm -fr $(BUILD) $(TARGET).3dsx $(OUTPUT).smdh $(TARGET).elf $(GFXBUILD) + +#--------------------------------------------------------------------------------- +$(GFXBUILD)/%.t3x $(BUILD)/%.h : %.t3s +#--------------------------------------------------------------------------------- + @echo $(notdir $<) + @tex3ds -i $< -H $(BUILD)/$*.h -d $(DEPSDIR)/$*.d -o $(GFXBUILD)/$*.t3x + +#--------------------------------------------------------------------------------- +else + +#--------------------------------------------------------------------------------- +# main targets +#--------------------------------------------------------------------------------- +$(OUTPUT).3dsx : $(OUTPUT).elf $(_3DSXDEPS) + +$(OFILES_SOURCES) : $(HFILES) + +$(OUTPUT).elf : $(OFILES) + +#--------------------------------------------------------------------------------- +# you need a rule like this for each extension you use as binary data +#--------------------------------------------------------------------------------- +%.bin.o %_bin.h : %.bin +#--------------------------------------------------------------------------------- + @echo $(notdir $<) + @$(bin2o) + +#--------------------------------------------------------------------------------- +.PRECIOUS : %.t3x +#--------------------------------------------------------------------------------- +%.t3x.o %_t3x.h : %.t3x +#--------------------------------------------------------------------------------- + @echo $(notdir $<) + @$(bin2o) + +#--------------------------------------------------------------------------------- +# rules for assembling GPU shaders +#--------------------------------------------------------------------------------- +define shader-as + $(eval CURBIN := $*.shbin) + $(eval DEPSFILE := $(DEPSDIR)/$*.shbin.d) + echo "$(CURBIN).o: $< $1" > $(DEPSFILE) + echo "extern const u8" `(echo $(CURBIN) | sed -e 's/^\([0-9]\)/_\1/' | tr . _)`"_end[];" > `(echo $(CURBIN) | tr . _)`.h + echo "extern const u8" `(echo $(CURBIN) | sed -e 's/^\([0-9]\)/_\1/' | tr . _)`"[];" >> `(echo $(CURBIN) | tr . _)`.h + echo "extern const u32" `(echo $(CURBIN) | sed -e 's/^\([0-9]\)/_\1/' | tr . _)`_size";" >> `(echo $(CURBIN) | tr . _)`.h + picasso -o $(CURBIN) $1 + bin2s $(CURBIN) | $(AS) -o $*.shbin.o +endef + +%.shbin.o %_shbin.h : %.v.pica %.g.pica + @echo $(notdir $^) + @$(call shader-as,$^) + +%.shbin.o %_shbin.h : %.v.pica + @echo $(notdir $<) + @$(call shader-as,$<) + +%.shbin.o %_shbin.h : %.shlist + @echo $(notdir $<) + @$(call shader-as,$(foreach file,$(shell cat $<),$(dir $<)$(file))) + +#--------------------------------------------------------------------------------- +%.t3x %.h : %.t3s +#--------------------------------------------------------------------------------- + @echo $(notdir $<) + @tex3ds -i $< -H $*.h -d $*.d -o $*.t3x + +-include $(DEPSDIR)/*.d + +#--------------------------------------------------------------------------------------- +endif +#--------------------------------------------------------------------------------------- diff --git a/finalize/README.md b/finalize/README.md new file mode 100644 index 0000000..7023d96 --- /dev/null +++ b/finalize/README.md @@ -0,0 +1,2 @@ +# custom-install-finalize +Finishes the process after using custom-install. diff --git a/finalize/data/basetik.bin b/finalize/data/basetik.bin new file mode 100644 index 0000000..4360992 Binary files /dev/null and b/finalize/data/basetik.bin differ diff --git a/finalize/source/main.c b/finalize/source/main.c new file mode 100644 index 0000000..7066583 --- /dev/null +++ b/finalize/source/main.c @@ -0,0 +1,173 @@ +#include +#include +#include +#include <3ds.h> + +#include "basetik_bin.h" + +#define CIFINISH_PATH "/cifinish.bin" +#define REQUIRED_VERSION 1 + +// 0x10 +struct finish_db_header { + u8 magic[8]; + u32 version; + u32 title_count; +}; + +// 0x30 +struct finish_db_entry { + u64 title_id; + u8 common_key_index; + bool has_seed; + u8 magic[6]; // "TITLE" and a null byte + u8 title_key[0x10]; + u8 seed[0x10]; +}; + +// 0x350 +struct ticket_dumb { + u8 unused1[0x1BF]; + u8 title_key[0x10]; + u8 unused2[0xD]; + u64 title_id_be; + u8 unused3[0xD]; + u8 common_key_index; + u8 unused4[0x15E]; +} __attribute__((packed)); + +// from FBI: +// https://github.com/Steveice10/FBI/blob/6e3a28e4b674e0d7a6f234b0419c530b358957db/source/core/http.c#L440-L453 +static Result FSUSER_AddSeed(u64 titleId, const void* seed) { + u32 *cmdbuf = getThreadCommandBuffer(); + + cmdbuf[0] = 0x087A0180; + cmdbuf[1] = (u32) (titleId & 0xFFFFFFFF); + cmdbuf[2] = (u32) (titleId >> 32); + memcpy(&cmdbuf[3], seed, 16); + + Result ret = 0; + if(R_FAILED(ret = svcSendSyncRequest(*fsGetSessionHandle()))) return ret; + + ret = cmdbuf[1]; + return ret; +} + +void finalize_install(void) +{ + Result res; + Handle ticketHandle; + struct ticket_dumb ticket_buf; + FILE *fp; + + struct finish_db_header header; + struct finish_db_entry *entries; + + memcpy(&ticket_buf, basetik_bin, basetik_bin_size); + + printf("Reading %s...\n", CIFINISH_PATH); + fp = fopen(CIFINISH_PATH, "rb"); + if (!fp) + { + puts("Failed to open file."); + return; + } + + fread(&header, sizeof(struct finish_db_header), 1, fp); + + if (memcmp(header.magic, "CIFINISH", 8)) + { + printf("CIFINISH magic not found in %s.\n", CIFINISH_PATH); + fclose(fp); + return; + } + + entries = calloc(header.title_count, sizeof(struct finish_db_entry)); + fread(entries, sizeof(struct finish_db_entry), header.title_count, fp); + fclose(fp); + + for (int i = 0; i < header.title_count; ++i) + { + // this includes the null byte + if (memcmp(entries[i].magic, "TITLE", 6)) + { + puts("Couldn't find TITLE magic for entry, skipping."); + continue; + } + printf("Finalizing %016llx...\n", entries[i].title_id); + + ticket_buf.title_id_be = __builtin_bswap64(entries[i].title_id); + ticket_buf.common_key_index = entries[i].common_key_index; + memcpy(ticket_buf.title_key, entries[i].title_key, 0x10); + + res = AM_InstallTicketBegin(&ticketHandle); + if (R_FAILED(res)) + { + printf("Failed to begin ticket install: %08lx\n", res); + AM_InstallTicketAbort(ticketHandle); + free(entries); + return; + } + + res = FSFILE_Write(ticketHandle, NULL, 0, &ticket_buf, sizeof(struct ticket_dumb), 0); + if (R_FAILED(res)) + { + printf("Failed to write ticket: %08lx\n", res); + AM_InstallTicketAbort(ticketHandle); + free(entries); + return; + } + + res = AM_InstallTicketFinish(ticketHandle); + if (R_FAILED(res)) + { + printf("Failed to finish ticket install: %08lx\n", res); + AM_InstallTicketAbort(ticketHandle); + free(entries); + return; + } + + if (entries[i].has_seed) + { + res = FSUSER_AddSeed(entries[i].title_id, entries[i].seed); + if (R_FAILED(res)) + { + printf("Failed to install seed: %08lx\n", res); + continue; + } + } + } + + free(entries); +} + +int main(int argc, char* argv[]) +{ + amInit(); + sdmcInit(); + gfxInitDefault(); + consoleInit(GFX_TOP, NULL); + + puts("custom-install-finalize v1.0"); + + finalize_install(); + puts("\nPress START or B to exit."); + + // Main loop + while (aptMainLoop()) + { + gspWaitForVBlank(); + gfxSwapBuffers(); + hidScanInput(); + + // Your code goes here + u32 kDown = hidKeysDown(); + if (kDown & KEY_START || kDown & KEY_B) + break; // break in order to return to hbmenu + } + + gfxExit(); + sdmcExit(); + amExit(); + return 0; +} diff --git a/pyctr/README.md b/pyctr/README.md new file mode 100644 index 0000000..8ae2158 --- /dev/null +++ b/pyctr/README.md @@ -0,0 +1,7 @@ +# PyCTR + +Python library to parse several Nintendo 3DS files. + +The API is not stable and can significantly change at any point. (If anyone decides to use this, that is) + +This will eventually be a separate repo... diff --git a/pyctr/__init__.py b/pyctr/__init__.py new file mode 100644 index 0000000..bdd69fc --- /dev/null +++ b/pyctr/__init__.py @@ -0,0 +1,6 @@ +from .types.cia import * +from .types.exefs import * +from .types.ncch import * +from .types.romfs import * +from .types.smdh import * +from .types.tmd import * diff --git a/pyctr/common.py b/pyctr/common.py new file mode 100644 index 0000000..b189fab --- /dev/null +++ b/pyctr/common.py @@ -0,0 +1,82 @@ +# This file is a part of ninfs. +# +# Copyright (c) 2017-2019 Ian Burgwin +# This file is licensed under The MIT License (MIT). +# You can find the full license text in LICENSE.md in the root of this project. + +from functools import wraps +from io import BufferedIOBase +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # this is a lazy way to make type checkers stop complaining + from typing import BinaryIO + BufferedIOBase = BinaryIO + + +class PyCTRError(Exception): + """Common base class for all PyCTR errors.""" + + +def _raise_if_closed(method): + @wraps(method) + def decorator(self: '_ReaderOpenFileBase', *args, **kwargs): + if self._reader.closed: + self.closed = True + if self.closed: + raise ValueError('I/O operation on closed file') + return method(self, *args, **kwargs) + return decorator + + +class _ReaderOpenFileBase(BufferedIOBase): + """Base class for all open files for Reader classes.""" + + _seek = 0 + _info = None + closed = False + + def __init__(self, reader, path): + self._reader = reader + self._path = path + + def __repr__(self): + return f'<{type(self).__name__} path={self._path!r} info={self._info!r} reader={self._reader!r}>' + + @_raise_if_closed + def read(self, size: int = -1) -> bytes: + if size == -1: + size = self._info.size - self._seek + data = self._reader.get_data(self._info, self._seek, size) + self._seek += len(data) + return data + + read1 = read # probably make this act like read1 should, but this for now enables some other things to work + + @_raise_if_closed + def seek(self, seek: int, whence: int = 0) -> int: + if whence == 0: + if seek < 0: + raise ValueError(f'negative seek value {seek}') + self._seek = min(seek, self._info.size) + elif whence == 1: + self._seek = max(self._seek + seek, 0) + elif whence == 2: + self._seek = max(self._info.size + seek, 0) + return self._seek + + @_raise_if_closed + def tell(self) -> int: + return self._seek + + @_raise_if_closed + def readable(self) -> bool: + return True + + @_raise_if_closed + def writable(self) -> bool: + return False + + @_raise_if_closed + def seekable(self) -> bool: + return True diff --git a/pyctr/crypto.py b/pyctr/crypto.py new file mode 100644 index 0000000..2e80505 --- /dev/null +++ b/pyctr/crypto.py @@ -0,0 +1,598 @@ +# This file is a part of ninfs. +# +# Copyright (c) 2017-2019 Ian Burgwin +# This file is licensed under The MIT License (MIT). +# You can find the full license text in LICENSE.md in the root of this project. + +from enum import IntEnum +from functools import wraps +from hashlib import sha256 +from os import environ +from os.path import getsize, join as pjoin +from struct import pack, unpack +from typing import TYPE_CHECKING + +from Cryptodome.Cipher import AES +from Cryptodome.Hash import CMAC +from Cryptodome.Util import Counter + +from .common import PyCTRError +from .util import config_dirs, readbe, readle + +if TYPE_CHECKING: + # noinspection PyProtectedMember + from Cryptodome.Cipher._mode_cbc import CbcMode + # noinspection PyProtectedMember + from Cryptodome.Cipher._mode_ctr import CtrMode + # noinspection PyProtectedMember + from Cryptodome.Cipher._mode_ecb import EcbMode + from Cryptodome.Hash.CMAC import CMAC as CMACObject + from typing import Dict, List, Union + +__all__ = ['CryptoError', 'OTPLengthError', 'CorruptBootromError', 'KeyslotMissingError', 'TicketLengthError', + 'BootromNotFoundError', 'CorruptOTPError', 'Keyslot', 'CryptoEngine'] + + +class CryptoError(PyCTRError): + """Generic exception for cryptography operations.""" + + +class OTPLengthError(CryptoError): + """OTP is the wrong length.""" + + +class CorruptOTPError(CryptoError): + """OTP hash does not match.""" + + +class KeyslotMissingError(CryptoError): + """Normal key is not set up for the keyslot.""" + + +class BadMovableSedError(CryptoError): + """movable.sed provided is invalid.""" + + +class TicketLengthError(CryptoError): + """Ticket is too small.""" + def __init__(self, length): + super().__init__(length) + + def __str__(self): + return f'0x350 expected, {self.args[0]:#x} given' + + +# wonder if I'm doing this right... +class BootromNotFoundError(CryptoError): + """ARM9 bootROM was not found. Main argument is a tuple of checked paths.""" + + +class CorruptBootromError(CryptoError): + """ARM9 bootROM hash does not match.""" + + +class Keyslot(IntEnum): + TWLNAND = 0x03 + CTRNANDOld = 0x04 + CTRNANDNew = 0x05 + FIRM = 0x06 + AGB = 0x07 + + CMACNANDDB = 0x0B + + NCCH93 = 0x18 + CMACCardSaveNew = 0x19 + CardSaveNew = 0x1A + NCCH96 = 0x1B + + CMACAGB = 0x24 + NCCH70 = 0x25 + + NCCH = 0x2C + UDSLocalWAN = 0x2D + StreetPass = 0x2E + Save60 = 0x2F + CMACSDNAND = 0x30 + + CMACCardSave = 0x33 + SD = 0x34 + + CardSave = 0x37 + BOSS = 0x38 + DownloadPlay = 0x39 + + DSiWareExport = 0x3A + + CommonKey = 0x3D + + # anything after 0x3F is custom to PyCTR + DecryptedTitlekey = 0x40 + + +BOOT9_PROT_HASH = '7331f7edece3dd33f2ab4bd0b3a5d607229fd19212c10b734cedcaf78c1a7b98' + +DEV_COMMON_KEY_0 = bytes.fromhex('55A3F872BDC80C555A654381139E153B') + +common_key_y = ( + # eShop + 0xD07B337F9CA4385932A2E25723232EB9, + # System + 0x0C767230F0998F1C46828202FAACBE4C, + # Unknown + 0xC475CB3AB8C788BB575E12A10907B8A4, + # Unknown + 0xE486EEE3D0C09C902F6686D4C06F649F, + # Unknown + 0xED31BA9C04B067506C4497A35B7804FC, + # Unknown + 0x5E66998AB4E8931606850FD7A16DD755 +) + +base_key_x = { + # New3DS 9.3 NCCH + 0x18: (0x82E9C9BEBFB8BDB875ECC0A07D474374, 0x304BF1468372EE64115EBD4093D84276), + # New3DS 9.6 NCCH + 0x1B: (0x45AD04953992C7C893724A9A7BCE6182, 0x6C8B2944A0726035F941DFC018524FB6), + # 7x NCCH + 0x25: (0xCEE7D8AB30C00DAE850EF5E382AC5AF3, 0x81907A4B6F1B47323A677974CE4AD71B), +} + +# global values to be copied to new CryptoEngine instances after the first one +_b9_key_x: 'Dict[int, int]' = {} +_b9_key_y: 'Dict[int, int]' = {} +_b9_key_normal: 'Dict[int, bytes]' = {} +_b9_extdata_otp: bytes = None +_b9_extdata_keygen: bytes = None +_b9_path: str = None +_otp_key: bytes = None +_otp_iv: bytes = None + +b9_paths: 'List[str]' = [] +for p in config_dirs: + b9_paths.append(pjoin(p, 'boot9.bin')) + b9_paths.append(pjoin(p, 'boot9_prot.bin')) +try: + b9_paths.insert(0, environ['BOOT9_PATH']) +except KeyError: + pass + + +def _requires_bootrom(method): + @wraps(method) + def wrapper(self, *args, **kwargs): + if not self.b9_keys_set: + raise KeyslotMissingError('bootrom is required to set up keys, see setup_keys_from_boot9') + return method(self, *args, **kwargs) + return wrapper + + +# used from http://www.falatic.com/index.php/108/python-and-bitwise-rotation +# converted to def because pycodestyle complained to me +def rol(val: int, r_bits: int, max_bits: int) -> int: + return (val << r_bits % max_bits) & (2 ** max_bits - 1) |\ + ((val & (2 ** max_bits - 1)) >> (max_bits - (r_bits % max_bits))) + + +class _TWLCryptoWrapper: + def __init__(self, cipher: 'CbcMode'): + self._cipher = cipher + + def encrypt(self, data: bytes) -> bytes: + data_len = len(data) + data_rev = bytearray(data_len) + for i in range(0, data_len, 0x10): + data_rev[i:i + 0x10] = data[i:i + 0x10][::-1] + + data_out = bytearray(self._cipher.encrypt(bytes(data_rev))) + + for i in range(0, data_len, 0x10): + data_out[i:i + 0x10] = data_out[i:i + 0x10][::-1] + return bytes(data_out[0:data_len]) + + decrypt = encrypt + + +class CryptoEngine: + """Class for 3DS crypto operations, including encryption and key generation.""" + + b9_keys_set: bool = False + b9_path: str = None + + _b9_extdata_otp: bytes = None + _b9_extdata_keygen: bytes = None + + _otp_key: bytes = None + _otp_iv: bytes = None + + _id0: bytes = None + + def __init__(self, boot9: str = None, dev: int = 0, setup_b9_keys: bool = True): + self.key_x: Dict[int, int] = {} + self.key_y: Dict[int, int] = {0x03: 0xE1A00005202DDD1DBD4DC4D30AB9DC76, + 0x05: 0x4D804F4E9990194613A204AC584460BE} + self.key_normal: Dict[int, bytes] = {} + + self.dev = dev + + for keyslot, keys in base_key_x.items(): + self.key_x[keyslot] = keys[dev] + + if setup_b9_keys: + self.setup_keys_from_boot9_file(boot9) + + @property + @_requires_bootrom + def b9_extdata_otp(self) -> bytes: + return self._b9_extdata_otp + + @property + @_requires_bootrom + def b9_extdata_keygen(self) -> bytes: + return self._b9_extdata_keygen + + @property + @_requires_bootrom + def otp_key(self) -> bytes: + return self._otp_key + + @property + @_requires_bootrom + def otp_iv(self) -> bytes: + return self._otp_iv + + @property + def id0(self) -> bytes: + if not self._id0: + raise KeyslotMissingError('load a movable.sed with setup_sd_key') + return self._id0 + + def create_cbc_cipher(self, keyslot: int, iv: bytes) -> 'CbcMode': + """Create AES-CBC cipher with the given keyslot.""" + try: + key = self.key_normal[keyslot] + except KeyError: + raise KeyslotMissingError(f'normal key for keyslot 0x{keyslot:02x} is not set up') + + return AES.new(key, AES.MODE_CBC, iv) + + def create_ctr_cipher(self, keyslot: int, ctr: int) -> 'Union[CtrMode, _TWLCryptoWrapper]': + """ + Create AES-CTR cipher with the given keyslot. + + Normal and DSi crypto will be automatically chosen depending on keyslot. + """ + try: + key = self.key_normal[keyslot] + except KeyError: + raise KeyslotMissingError(f'normal key for keyslot 0x{keyslot:02x} is not set up') + + cipher = AES.new(key, AES.MODE_CTR, counter=Counter.new(128, initial_value=ctr)) + + if keyslot < 0x04: + return _TWLCryptoWrapper(cipher) + else: + return cipher + + def create_ecb_cipher(self, keyslot: int) -> 'EcbMode': + """Create AES-ECB cipher with the given keyslot.""" + try: + key = self.key_normal[keyslot] + except KeyError: + raise KeyslotMissingError(f'normal key for keyslot 0x{keyslot:02x} is not set up') + + return AES.new(key, AES.MODE_ECB) + + def create_cmac_object(self, keyslot: int) -> 'CMACObject': + """Create a CMAC object with the given keyslot.""" + try: + key = self.key_normal[keyslot] + except KeyError: + raise KeyslotMissingError(f'normal key for keyslot 0x{keyslot:02x} is not set up') + + return CMAC.new(key, ciphermod=AES) + + @staticmethod + def sd_path_to_iv(path: str) -> int: + # ensure the path is lowercase + path = path.lower() + + # SD Save Data Backup does a copy of the raw, encrypted file from the game's data directory + # so we need to handle this and fake the path + if path.startswith('/backup') and len(path) > 28: + tid_upper = path[12:20] + tid_lower = path[20:28] + path = f'/title/{tid_upper}/{tid_lower}/data' + path[28:] + + path_hash = sha256(path.encode('utf-16le') + b'\0\0').digest() + hash_p1 = readbe(path_hash[0:16]) + hash_p2 = readbe(path_hash[16:32]) + return hash_p1 ^ hash_p2 + + def load_from_ticket(self, ticket: bytes): + """Load a titlekey from a ticket and set keyslot 0x40 to the decrypted titlekey.""" + ticket_len = len(ticket) + # TODO: probably support other sig types which would be different lengths + # unlikely to happen in practice, but I would still like to + if ticket_len < 0x2AC: + raise TicketLengthError(ticket_len) + + titlekey_enc = ticket[0x1BF:0x1CF] + title_id = ticket[0x1DC:0x1E4] + common_key_index = ticket[0x1F1] + + if self.dev and common_key_index == 0: + self.set_normal_key(0x3D, DEV_COMMON_KEY_0) + else: + self.set_keyslot('y', 0x3D, common_key_y[common_key_index]) + + cipher = self.create_cbc_cipher(0x3D, title_id + (b'\0' * 8)) + self.set_normal_key(0x40, cipher.decrypt(titlekey_enc)) + + def set_keyslot(self, xy: str, keyslot: int, key: 'Union[int, bytes]'): + """Sets a keyslot to the specified key.""" + to_use = None + if xy == 'x': + to_use = self.key_x + elif xy == 'y': + to_use = self.key_y + if isinstance(key, bytes): + key = int.from_bytes(key, 'big' if keyslot > 0x03 else 'little') + to_use[keyslot] = key + try: + self.key_normal[keyslot] = self.keygen(keyslot) + except KeyError: + pass + + def set_normal_key(self, keyslot: int, key: bytes): + self.key_normal[keyslot] = key + + def keygen(self, keyslot: int) -> bytes: + """Generate a normal key based on the keyslot.""" + if keyslot < 0x04: + # DSi + return self.keygen_twl_manual(self.key_x[keyslot], self.key_y[keyslot]) + else: + # 3DS + return self.keygen_manual(self.key_x[keyslot], self.key_y[keyslot]) + + @staticmethod + def keygen_manual(key_x: int, key_y: int) -> bytes: + """Generate a normal key using the 3DS AES keyscrambler.""" + return rol((rol(key_x, 2, 128) ^ key_y) + 0x1FF9E9AAC5FE0408024591DC5D52768A, 87, 128).to_bytes(0x10, 'big') + + @staticmethod + def keygen_twl_manual(key_x: int, key_y: int) -> bytes: + """Generate a normal key using the DSi AES keyscrambler.""" + # usually would convert to LE bytes in the end then flip with [::-1], but those just cancel out + return rol((key_x ^ key_y) + 0xFFFEFB4E295902582A680F5F1A4F3E79, 42, 128).to_bytes(0x10, 'big') + + def _copy_global_keys(self): + self.key_x.update(_b9_key_x) + self.key_y.update(_b9_key_y) + self.key_normal.update(_b9_key_normal) + self._otp_key = _otp_key + self._otp_iv = _otp_iv + self._b9_extdata_otp = _b9_extdata_otp + self._b9_extdata_keygen = _b9_extdata_keygen + + self.b9_keys_set = True + + def setup_keys_from_boot9(self, b9: bytes): + """Set up certain keys from an ARM9 bootROM dump.""" + global _otp_key, _otp_iv, _b9_extdata_otp, _b9_extdata_keygen + if self.b9_keys_set: + return + + if _b9_key_x: + self._copy_global_keys() + return + + b9_len = len(b9) + if b9_len != 0x8000: + raise CorruptBootromError(f'wrong length: {b9_len}') + + b9_hash_digest: str = sha256(b9).hexdigest() + if b9_hash_digest != BOOT9_PROT_HASH: + raise CorruptBootromError(f'expected: {BOOT9_PROT_HASH}; returned: {b9_hash_digest}') + + keyblob_offset = 0x5860 + otp_key_offset = 0x56E0 + if self.dev: + keyblob_offset += 0x400 + otp_key_offset += 0x20 + + _otp_key = b9[otp_key_offset:otp_key_offset + 0x10] + _otp_iv = b9[otp_key_offset + 0x10:otp_key_offset + 0x20] + + keyblob: bytes = b9[keyblob_offset:keyblob_offset + 0x400] + + _b9_extdata_keygen = keyblob[0:0x200] + _b9_extdata_otp = keyblob[0:0x24] + + # Original NCCH key, UDS local-WLAN CCMP key, StreetPass key, 6.0 save key + _b9_key_x[0x2C] = _b9_key_x[0x2D] = _b9_key_x[0x2E] = _b9_key_x[0x2F] = readbe(keyblob[0x170:0x180]) + + # SD/NAND AES-CMAC key, APT wrap key, Unknown, Gamecard savedata AES-CMAC + _b9_key_x[0x30] = _b9_key_x[0x31] = _b9_key_x[0x32] = _b9_key_x[0x33] = readbe(keyblob[0x180:0x190]) + + # SD key (loaded from movable.sed), movable.sed key, Unknown (used by friends module), + # Gamecard savedata actual key + _b9_key_x[0x34] = _b9_key_x[0x35] = _b9_key_x[0x36] = _b9_key_x[0x37] = readbe(keyblob[0x190:0x1A0]) + + # BOSS key, Download Play key + actual NFC key for generating retail amiibo keys, CTR-CARD hardware-crypto seed + # decryption key + _b9_key_x[0x38] = _b9_key_x[0x39] = _b9_key_x[0x3A] = _b9_key_x[0x3B] = readbe(keyblob[0x1A0:0x1B0]) + + # Unused + _b9_key_x[0x3C] = readbe(keyblob[0x1B0:0x1C0]) + + # Common key (titlekey crypto) + _b9_key_x[0x3D] = readbe(keyblob[0x1C0:0x1D0]) + + # Unused + _b9_key_x[0x3E] = readbe(keyblob[0x1D0:0x1E0]) + + # NAND partition keys + _b9_key_y[0x04] = readbe(keyblob[0x1F0:0x200]) + # correct 0x05 KeyY not set by boot9. + _b9_key_y[0x06] = readbe(keyblob[0x210:0x220]) + _b9_key_y[0x07] = readbe(keyblob[0x220:0x230]) + + # Unused, Unused, DSiWare export key, NAND dbs/movable.sed AES-CMAC key + _b9_key_y[0x08] = readbe(keyblob[0x230:0x240]) + _b9_key_y[0x09] = readbe(keyblob[0x240:0x250]) + _b9_key_y[0x0A] = readbe(keyblob[0x250:0x260]) + _b9_key_y[0x0B] = readbe(keyblob[0x260:0x270]) + + _b9_key_normal[0x0D] = keyblob[0x270:0x280] + + self._copy_global_keys() + + def setup_keys_from_boot9_file(self, path: str = None): + """Set up certain keys from an ARM9 bootROM file.""" + global _b9_path + if self.b9_keys_set: + return + + if _b9_key_x: + self.b9_path = _b9_path + self._copy_global_keys() + return + + paths = (path,) if path else b9_paths + + for p in paths: + try: + b9_size = getsize(p) + if b9_size in {0x8000, 0x10000}: + with open(p, 'rb') as f: + if b9_size == 0x10000: + f.seek(0x8000) + self.setup_keys_from_boot9(f.read(0x8000)) + _b9_path = p + self.b9_path = p + return + except FileNotFoundError: + continue + + # if keys are not set... + raise BootromNotFoundError(paths) + + @_requires_bootrom + def setup_keys_from_otp(self, otp: bytes): + """Set up console-unique keys from an OTP dump. Encrypted and decrypted are supported.""" + otp_len = len(otp) + if otp_len != 0x100: + raise OTPLengthError(otp_len) + + cipher_otp = AES.new(self.otp_key, AES.MODE_CBC, self.otp_iv) + if otp[0:4] == b'\x0f\xb0\xad\xde': + # decrypted otp + otp_enc: bytes = cipher_otp.encrypt(otp) + otp_dec = otp + else: + # encrypted otp + otp_enc = otp + otp_dec: bytes = cipher_otp.decrypt(otp) + + otp_hash: bytes = otp_dec[0xE0:0x100] + otp_hash_digest: bytes = sha256(otp_dec[0:0xE0]).digest() + if otp_hash_digest != otp_hash: + raise CorruptOTPError(f'expected: {otp_hash.hex()}; result: {otp_hash_digest.hex()}') + + otp_keysect_hash: bytes = sha256(otp_enc[0:0x90]).digest() + + self.set_keyslot('x', 0x11, otp_keysect_hash[0:0x10]) + self.set_keyslot('y', 0x11, otp_keysect_hash[0:0x10]) + + # most otp code from https://github.com/Stary2001/3ds_tools/blob/master/three_ds/aesengine.py + + twl_cid_lo, twl_cid_hi = readle(otp_dec[0x08:0xC]), readle(otp_dec[0xC:0x10]) + twl_cid_lo ^= 0xB358A6AF + twl_cid_lo |= 0x80000000 + twl_cid_hi ^= 0x08C267B7 + twl_cid_lo = twl_cid_lo.to_bytes(4, 'little') + twl_cid_hi = twl_cid_hi.to_bytes(4, 'little') + self.set_keyslot('x', 0x03, twl_cid_lo + b'NINTENDO' + twl_cid_hi) + + console_key_xy: bytes = sha256(otp_dec[0x90:0xAC] + self.b9_extdata_otp).digest() + self.set_keyslot('x', 0x3F, console_key_xy[0:0x10]) + self.set_keyslot('y', 0x3F, console_key_xy[0x10:0x20]) + + extdata_off = 0 + + def gen(n: int) -> bytes: + nonlocal extdata_off + extdata_off += 36 + iv = self.b9_extdata_keygen[extdata_off:extdata_off+16] + extdata_off += 16 + + data = self.create_cbc_cipher(0x3F, iv).encrypt(self.b9_extdata_keygen[extdata_off:extdata_off + 64]) + + extdata_off += n + return data + + a = gen(64) + for i in range(0x4, 0x8): + self.set_keyslot('x', i, a[0:16]) + + for i in range(0x8, 0xc): + self.set_keyslot('x', i, a[16:32]) + + for i in range(0xc, 0x10): + self.set_keyslot('x', i, a[32:48]) + + self.set_keyslot('x', 0x10, a[48:64]) + + b = gen(16) + off = 0 + for i in range(0x14, 0x18): + self.set_keyslot('x', i, b[off:off + 16]) + off += 16 + + c = gen(64) + for i in range(0x18, 0x1c): + self.set_keyslot('x', i, c[0:16]) + + for i in range(0x1c, 0x20): + self.set_keyslot('x', i, c[16:32]) + + for i in range(0x20, 0x24): + self.set_keyslot('x', i, c[32:48]) + + self.set_keyslot('x', 0x24, c[48:64]) + + d = gen(16) + off = 0 + + for i in range(0x28, 0x2c): + self.set_keyslot('x', i, d[off:off + 16]) + off += 16 + + @_requires_bootrom + def setup_keys_from_otp_file(self, path: str): + """Set up console-unique keys from an OTP file. Encrypted and decrypted are supported.""" + with open(path, 'rb') as f: + self.setup_keys_from_otp(f.read(0x100)) + + def setup_sd_key(self, data: bytes): + """Set up the SD key from movable.sed. Must be 0x10 (only key), 0x120 (no cmac), or 0x140 (with cmac).""" + if len(data) == 0x10: + key = data + elif len(data) in {0x120, 0x140}: + key = data[0x110:0x120] + else: + raise BadMovableSedError(f'invalid length ({len(data):#x}') + + self.set_keyslot('y', Keyslot.SD, key) + self.set_keyslot('y', Keyslot.CMACSDNAND, key) + self.set_keyslot('y', Keyslot.DSiWareExport, key) + + key_hash = sha256(key).digest()[0:16] + hash_parts = unpack('IIII', *hash_parts) + + def setup_sd_key_from_file(self, path: str): + """Set up the SD key from a movable.sed file.""" + with open(path, 'rb') as f: + self.setup_sd_key(f.read(0x140)) diff --git a/pyctr/types/__init__.py b/pyctr/types/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyctr/types/base/title.py b/pyctr/types/base/title.py new file mode 100644 index 0000000..be62d5e --- /dev/null +++ b/pyctr/types/base/title.py @@ -0,0 +1,12 @@ +# This file is a part of ninfs. +# +# Copyright (c) 2017-2019 Ian Burgwin +# This file is licensed under The MIT License (MIT). +# You can find the full license text in LICENSE.md in the root of this project. + + +class TitleReaderBase: + + closed = False + + diff --git a/pyctr/types/cia.py b/pyctr/types/cia.py new file mode 100644 index 0000000..c3a11a2 --- /dev/null +++ b/pyctr/types/cia.py @@ -0,0 +1,237 @@ +# This file is a part of ninfs. +# +# Copyright (c) 2017-2019 Ian Burgwin +# This file is licensed under The MIT License (MIT). +# You can find the full license text in LICENSE.md in the root of this project. + +from enum import IntEnum +from io import BytesIO +from threading import Lock +from typing import TYPE_CHECKING, NamedTuple + +from ..common import PyCTRError, _ReaderOpenFileBase +from ..crypto import CryptoEngine, Keyslot +from ..types.ncch import NCCHReader +from ..types.tmd import TitleMetadataReader +from ..util import readle, roundup + +if TYPE_CHECKING: + from typing import BinaryIO, Dict, Optional, Union + +ALIGN_SIZE = 64 + + +class CIAError(PyCTRError): + """Generic error for CIA operations.""" + + +class InvalidCIAError(CIAError): + """Invalid CIA header exception.""" + + +class CIASection(IntEnum): + # these values as negative, as positive ones are used for contents + ArchiveHeader = -4 + CertificateChain = -3 + Ticket = -2 + TitleMetadata = -1 + Meta = -5 + + +class CIARegion(NamedTuple): + section: 'Union[int, CIASection]' + offset: int + size: int + iv: bytes # only used for encrypted sections + + +class _CIASectionFile(_ReaderOpenFileBase): + """Provides a raw CIA section as a file-like object.""" + + def __init__(self, reader: 'CIAReader', path: 'CIASection'): + super().__init__(reader, path) + self._info = reader.sections[path] + + +class CIAReader: + """Class for the 3DS CIA container.""" + + closed = False + + def __init__(self, fp: 'Union[str, BinaryIO]', *, case_insensitive: bool = True, crypto: CryptoEngine = None, + dev: bool = False, seeddb: str = None, load_contents: bool = True): + if isinstance(fp, str): + fp = open(fp, 'rb') + + if crypto: + self._crypto = crypto + else: + self._crypto = CryptoEngine(dev=dev) + + # store the starting offset so the CIA can be read from any point in the base file + self._start = fp.tell() + self._fp = fp + # store case-insensitivity for RomFSReader + self._case_insensitive = case_insensitive + # threading lock + self._lock = Lock() + + header = fp.read(0x20) + + archive_header_size = readle(header[0x0:0x4]) + if archive_header_size != 0x2020: + raise InvalidCIAError('Archive Header Size is not 0x2020') + # in practice, the certificate chain is the same for all retail titles + cert_chain_size = readle(header[0x8:0xC]) + # the ticket size usually never changes from 0x350 + # there is one ticket (without an associated title) that is smaller though + ticket_size = readle(header[0xC:0x10]) + # tmd contains info about the contents of the title + tmd_size = readle(header[0x10:0x14]) + # meta contains info such as the SMDH and Title ID dependency list + meta_size = readle(header[0x14:0x18]) + # content size is the total size of the contents + # I'm not sure what happens yet if one of the contents is not aligned to 0x40 bytes. + content_size = readle(header[0x18:0x20]) + # the content index determines what contents are in the CIA + # this is not stored as int, so it's faster to parse(?) + content_index = fp.read(archive_header_size - 0x20) + + active_contents = set() + for idx, b in enumerate(content_index): + offset = idx * 8 + curr = b + for x in range(7, -1, -1): + if curr & 1: + active_contents.add(x + offset) + curr >>= 1 + + # the header only stores sizes; offsets need to be calculated. + # the sections are aligned to 64(0x40) bytes. for example, if something is 0x78, + # it will take up 0x80, with the remaining 0x8 being padding. + cert_chain_offset = roundup(archive_header_size, ALIGN_SIZE) + ticket_offset = cert_chain_offset + roundup(cert_chain_size, ALIGN_SIZE) + tmd_offset = ticket_offset + roundup(ticket_size, ALIGN_SIZE) + content_offset = tmd_offset + roundup(tmd_size, ALIGN_SIZE) + meta_offset = content_offset + roundup(content_size, ALIGN_SIZE) + + # lazy method to get the total size + self.total_size = meta_offset + meta_size + + # this contains the location of each section, as well as the IV of encrypted ones + self.sections: Dict[Union[int, CIASection], CIARegion] = {} + + def add_region(section: 'Union[int, CIASection]', offset: int, size: int, iv: 'Optional[bytes]'): + region = CIARegion(section=section, offset=offset, size=size, iv=iv) + self.sections[section] = region + + # add each part of the header + add_region(CIASection.ArchiveHeader, 0, archive_header_size, None) + add_region(CIASection.CertificateChain, cert_chain_offset, cert_chain_size, None) + add_region(CIASection.Ticket, ticket_offset, ticket_size, None) + add_region(CIASection.TitleMetadata, tmd_offset, tmd_size, None) + if meta_size: + add_region(CIASection.Meta, meta_offset, meta_size, None) + + # this will load the titlekey to decrypt the contents + self._fp.seek(self._start + ticket_offset) + ticket = self._fp.read(ticket_size) + self._crypto.load_from_ticket(ticket) + + # the tmd describes the contents: ID, index, size, and hash + self._fp.seek(self._start + tmd_offset) + tmd_data = self._fp.read(tmd_size) + self.tmd = TitleMetadataReader.load(BytesIO(tmd_data)) + + active_contents_tmd = set() + self.content_info = [] + + # this does a first check to make sure there are no missing contents that are marked active in content_index + for record in self.tmd.chunk_records: + if record.cindex in active_contents: + active_contents_tmd.add(record.cindex) + self.content_info.append(record) + + # if the result of this is not an empty set, it means there are contents enabled in content_index + # that are not in the tmd, which is bad + if active_contents ^ active_contents_tmd: + raise InvalidCIAError('Missing active contents in the TMD') + + self.contents = {} + + # this goes through the contents and figures out their regions, then creates an NCCHReader + curr_offset = content_offset + for record in self.content_info: + iv = None + if record.type.encrypted: + iv = record.cindex.to_bytes(2, 'big') + (b'\0' * 14) + add_region(record.cindex, curr_offset, record.size, iv) + if load_contents: + # check if the content is a Nintendo DS ROM (SRL) first + is_srl = record.cindex == 0 and self.tmd.title_id[3:5] == '48' + if not is_srl: + content_fp = self.open_raw_section(record.cindex) + self.contents[record.cindex] = NCCHReader(content_fp, case_insensitive=case_insensitive, + dev=dev, seeddb=seeddb) + + curr_offset += record.size + + def close(self): + self.closed = True + try: + self._fp.close() + except AttributeError: + pass + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + __del__ = close + + def __repr__(self): + info = [('title_id', self.tmd.title_id)] + try: + info.append(('title_name', repr(self.contents[0].exefs.icon.get_app_title().short_desc))) + except KeyError: + info.append(('title_name', 'unknown')) + info.append(('content_count', len(self.contents))) + info_final = " ".join(x + ": " + str(y) for x, y in info) + return f'<{type(self).__name__} {info_final}>' + + def open_raw_section(self, section: 'CIASection'): + """Open a raw CIA section for reading.""" + return _CIASectionFile(self, section) + + def get_data(self, region: 'CIARegion', offset: int, size: int) -> bytes: + if offset + size > region.size: + # prevent reading past the region + size = region.size - offset + + with self._lock: + if region.iv: + real_size = size + # if encrypted, the block needs to be decrypted first + # CBC requires a full block (0x10 in this case). and the previous + # block is used as the IV. so that's quite a bit to read if the + # application requires just a few bytes. + # thanks Stary2001 for help with random-access crypto + before = offset % 16 + if size % 16 != 0: + size = size + 16 - size % 16 + if offset - before == 0: + iv = region.iv + else: + self._fp.seek(self._start + region.offset + offset - before - 0x10) + iv = self._fp.read(0x10) + # read to block size + self._fp.seek(self._start + region.offset + offset - before) + # adding x10 to the size fixes some kind of decryption bug I think. this needs more testing. + return self._crypto.create_cbc_cipher(Keyslot.DecryptedTitlekey, + iv).decrypt(self._fp.read(size + 0x10))[before:real_size + before] + else: + # no encryption + self._fp.seek(self._start + region.offset + offset) + return self._fp.read(size) diff --git a/pyctr/types/exefs.py b/pyctr/types/exefs.py new file mode 100644 index 0000000..0e0a89f --- /dev/null +++ b/pyctr/types/exefs.py @@ -0,0 +1,308 @@ +# This file is a part of ninfs. +# +# Copyright (c) 2017-2019 Ian Burgwin +# This file is licensed under The MIT License (MIT). +# You can find the full license text in LICENSE.md in the root of this project. + +from hashlib import sha256 +from threading import Lock +from typing import TYPE_CHECKING, NamedTuple + +from ..common import PyCTRError, _ReaderOpenFileBase +from ..util import readle +from ..types.smdh import SMDH, InvalidSMDHError + +if TYPE_CHECKING: + from typing import BinaryIO, Dict, Union + +__all__ = ['EXEFS_EMPTY_ENTRY', 'EXEFS_ENTRY_SIZE', 'EXEFS_ENTRY_COUNT', 'EXEFS_HEADER_SIZE', 'ExeFSError', + 'ExeFSFileNotFoundError', 'InvalidExeFSError', 'ExeFSNameError', 'BadOffsetError', 'CodeDecompressionError', + 'decompress_code', 'ExeFSReader'] + +EXEFS_ENTRY_SIZE = 0x10 +EXEFS_ENTRY_COUNT = 10 +EXEFS_EMPTY_ENTRY = b'\0' * EXEFS_ENTRY_SIZE +EXEFS_HEADER_SIZE = 0x200 + +CODE_DECOMPRESSED_NAME = '.code-decompressed' + + +class ExeFSError(PyCTRError): + """Generic exception for ExeFS operations.""" + + +class ExeFSFileNotFoundError(ExeFSError): + """File not found in the ExeFS.""" + + +class InvalidExeFSError(ExeFSError): + """Invalid ExeFS header.""" + + +class ExeFSNameError(InvalidExeFSError): + """Name could not be decoded, likely making the file not a valid ExeFS.""" + + def __str__(self): + return f'could not decode from ascii: {self.args[0]!r}' + + +class BadOffsetError(InvalidExeFSError): + """Offset is not a multiple of 0x200. This kind of ExeFS will not work on a 3DS.""" + + def __str__(self): + return f'offset is not a multiple of 0x200: {self.args[0]:#x}' + + +class CodeDecompressionError(ExeFSError): + """Exception when attempting to decompress ExeFS .code.""" + + +# lazy check +CODE_MAX_SIZE = 0x2300000 + + +def decompress_code(code: bytes) -> bytes: + # remade from C code, this could probably be done better + # https://github.com/d0k3/GodMode9/blob/689f6f7cf4280bf15885cbbf848d8dce81def36b/arm9/source/game/codelzss.c#L25-L93 + off_size_comp = int.from_bytes(code[-8:-4], 'little') + add_size = int.from_bytes(code[-4:], 'little') + comp_start = 0 + code_len = len(code) + + code_comp_size = off_size_comp & 0xFFFFFF + code_comp_end = code_comp_size - ((off_size_comp >> 24) % 0xFF) + code_dec_size = code_len + add_size + + if code_len < 8: + raise CodeDecompressionError('code_len < 8') + if code_len > CODE_MAX_SIZE: + raise CodeDecompressionError('code_len > CODE_MAX_SIZE') + + if code_comp_size <= code_len: + comp_start = code_len - code_comp_size + + if code_comp_end < 0: + raise CodeDecompressionError('code_comp_end < 0') + if code_dec_size > CODE_MAX_SIZE: + raise CodeDecompressionError('code_dec_size > CODE_MAX_SIZE') + + dec = bytearray(code) + dec.extend(b'\0' * add_size) + + data_end = comp_start + code_dec_size + ptr_in = comp_start + code_comp_end + ptr_out = code_dec_size + + while ptr_in > comp_start and ptr_out > comp_start: + if ptr_out < ptr_in: + raise CodeDecompressionError('ptr_out < ptr_in') + + ptr_in -= 1 + ctrl_byte = dec[ptr_in] + for i in range(7, -1, -1): + if ptr_in <= comp_start or ptr_out <= comp_start: + break + + if (ctrl_byte >> i) & 1: + ptr_in -= 2 + seg_code = int.from_bytes(dec[ptr_in:ptr_in + 2], 'little') + if ptr_in < comp_start: + raise CodeDecompressionError('ptr_in < comp_start') + seg_off = (seg_code & 0x0FFF) + 2 + seg_len = ((seg_code >> 12) & 0xF) + 3 + + if ptr_out - seg_len < comp_start: + raise CodeDecompressionError('ptr_out - seg_len < comp_start') + if ptr_out + seg_off >= data_end: + raise CodeDecompressionError('ptr_out + seg_off >= data_end') + + c = 0 + while c < seg_len: + byte = dec[ptr_out + seg_off] + ptr_out -= 1 + dec[ptr_out] = byte + c += 1 + else: + if ptr_out == comp_start: + raise CodeDecompressionError('ptr_out == comp_start') + if ptr_in == comp_start: + raise CodeDecompressionError('ptr_in == comp_start') + + ptr_out -= 1 + ptr_in -= 1 + dec[ptr_out] = dec[ptr_in] + + if ptr_in != comp_start: + raise CodeDecompressionError('ptr_in != comp_start') + if ptr_out != comp_start: + raise CodeDecompressionError('ptr_out != comp_start') + + return bytes(dec) + + +class ExeFSEntry(NamedTuple): + name: str + offset: int + size: int + hash: bytes + + +def _normalize_path(p: str): + """Fix a given path to work with ExeFS filenames.""" + if p.startswith('/'): + p = p[1:] + # while it is technically possible for an ExeFS entry to contain ".bin", + # this would not happen in practice. + # even so, normalization can be disabled by passing normalize=False to + # ExeFSReader.open + if p.lower().endswith('.bin'): + p = p[:4] + return p + + +class _ExeFSOpenFile(_ReaderOpenFileBase): + """Class for open ExeFS file entries.""" + + def __init__(self, reader: 'ExeFSReader', path: str): + super().__init__(reader, path) + try: + self._info = reader.entries[self._path] + except KeyError: + raise ExeFSFileNotFoundError(self._path) + + +class ExeFSReader: + """ + Class to read the 3DS ExeFS container. + + http://3dbrew.org/wiki/ExeFS + """ + + closed = False + _code_dec = None + icon: 'SMDH' = None + + def __init__(self, fp: 'Union[str, BinaryIO]', *, _load_icon: bool = True): + if isinstance(fp, str): + fp = open(fp, 'rb') + + # storing the starting offset lets it work from anywhere in the file + self._start = fp.tell() + self._fp = fp + self._lock = Lock() + + self.entries: 'Dict[str, ExeFSEntry]' = {} + + header = fp.read(EXEFS_HEADER_SIZE) + + # ExeFS entries can fit up to 10 names. hashes are stored in reverse order + # (e.g. the first entry would have the hash at the very end - 0x1E0) + for entry_n, hash_n in zip(range(0, EXEFS_ENTRY_COUNT * EXEFS_ENTRY_SIZE, EXEFS_ENTRY_SIZE), + range(0x1E0, 0xA0, -0x20)): + entry_raw = header[entry_n:entry_n + 0x10] + entry_hash = header[hash_n:hash_n + 0x20] + if entry_raw == EXEFS_EMPTY_ENTRY: + continue + + try: + # ascii is used since only a-z would be used in practice + name = entry_raw[0:8].rstrip(b'\0').decode('ascii') + except UnicodeDecodeError: + raise ExeFSNameError(entry_raw[0:8]) + + entry = ExeFSEntry(name=name, + offset=readle(entry_raw[8:12]), + size=readle(entry_raw[12:16]), + hash=entry_hash) + + # the 3DS fails to parse an ExeFS with an offset that isn't a multiple of 0x200 + # so we should do the same here + if entry.offset % 0x200: + raise BadOffsetError(entry.offset) + + self.entries[name] = entry + + # this sometimes needs to be loaded outside, since reading it here may cause encryption problems + # when the NCCH has not fully initialized yet and needs to figure out what ExeFS regions need + # to be decrypted with the Original NCCH key + if _load_icon: + self._load_icon() + + def _load_icon(self): + try: + with self.open('icon') as f: + self.icon = SMDH.load(f) + except InvalidSMDHError: + pass + + def __len__(self) -> int: + """Return the amount of entries in the ExeFS.""" + return len(self.entries) + + def close(self): + self.closed = True + try: + self._fp.close() + except AttributeError: + pass + + __del__ = close + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + def open(self, path: str, *, normalize: bool = True): + """Open a file in the ExeFS for reading.""" + if normalize: + # remove beginning "/" and ending ".bin" + path = _normalize_path(path) + return _ExeFSOpenFile(self, path) + + def get_data(self, info: ExeFSEntry, offset: int, size: int) -> bytes: + if offset + size > info.size: + size = info.size - offset + with self._lock: + if info.offset == -1: + # return the decompressed code instead + return self._code_dec[offset:offset + size] + else: + # data for ExeFS entries start relative to the end of the header + self._fp.seek(self._start + EXEFS_HEADER_SIZE + info.offset + offset) + return self._fp.read(size) + + def decompress_code(self) -> bool: + """ + Decompress '.code' in the container. The result will be available as '.code-decompressed'. + + The return value is if '.code' was actually decompressed. + """ + with self.open('.code') as f: + code = f.read() + + # if it's already decompressed, this would return the code unmodified + code_dec = decompress_code(code) + + decompressed = code_dec != code + + if decompressed: + code_dec_hash = sha256(code_dec) + entry = ExeFSEntry(name=CODE_DECOMPRESSED_NAME, + offset=-1, + size=len(code_dec), + hash=code_dec_hash.digest()) + self._code_dec = code_dec + else: + # if the code was already decompressed, don't store a second copy in memory + code_entry = self.entries['.code'] + entry = ExeFSEntry(name=CODE_DECOMPRESSED_NAME, + offset=code_entry.offset, + size=code_entry.size, + hash=code_entry.hash) + + self.entries[CODE_DECOMPRESSED_NAME] = entry + + # returns if the code was actually decompressed or not + return decompressed diff --git a/pyctr/types/extheader.py b/pyctr/types/extheader.py new file mode 100644 index 0000000..ceea160 --- /dev/null +++ b/pyctr/types/extheader.py @@ -0,0 +1,9 @@ +# This file is a part of ninfs. +# +# Copyright (c) 2017-2019 Ian Burgwin +# This file is licensed under The MIT License (MIT). +# You can find the full license text in LICENSE.md in the root of this project. + + +class ExtendedHeaderReader: + def __init__(self): diff --git a/pyctr/types/ncch.py b/pyctr/types/ncch.py new file mode 100644 index 0000000..92b6f56 --- /dev/null +++ b/pyctr/types/ncch.py @@ -0,0 +1,521 @@ +# This file is a part of ninfs. +# +# Copyright (c) 2017-2019 Ian Burgwin +# This file is licensed under The MIT License (MIT). +# You can find the full license text in LICENSE.md in the root of this project. + +from hashlib import sha256 +from enum import IntEnum +from math import ceil +from os import environ +from os.path import join as pjoin +from threading import Lock +from typing import TYPE_CHECKING, NamedTuple + +from .exefs import ExeFSReader, EXEFS_HEADER_SIZE +from .romfs import RomFSReader +from ..common import PyCTRError, _ReaderOpenFileBase +from ..util import config_dirs, readle, roundup +from ..crypto import CryptoEngine, Keyslot + +if TYPE_CHECKING: + from typing import BinaryIO, Dict, List, Optional, Tuple, Union + +__all__ = ['NCCH_MEDIA_UNIT', 'NO_ENCRYPTION', 'EXEFS_NORMAL_CRYPTO_FILES', 'FIXED_SYSTEM_KEY', 'NCCHError', + 'InvalidNCCHError', 'NCCHSeedError', 'MissingSeedError', 'SeedDBNotFoundError', 'get_seed', + 'extra_cryptoflags', 'NCCHSection', 'NCCHRegion', 'NCCHFlags', 'NCCHReader'] + + +class NCCHError(PyCTRError): + """Generic exception for NCCH operations.""" + + +class InvalidNCCHError(NCCHError): + """Invalid NCCH header exception.""" + + +class NCCHSeedError(NCCHError): + """NCCH seed is not set up, or attempted to set up seed when seed crypto is not used.""" + + +class MissingSeedError(NCCHSeedError): + """Seed could not be found.""" + + +class SeedDBNotFoundError(NCCHSeedError): + """SeedDB was not found. Main argument is a tuple of checked paths.""" + + +def get_seed(f: 'BinaryIO', program_id: int) -> bytes: + """Get a seed in a seeddb.bin from an I/O stream.""" + # convert the Program ID to little-endian bytes, as the TID is stored in seeddb.bin this way + tid_bytes = program_id.to_bytes(0x8, 'little') + f.seek(0) + # get the amount of seeds + seed_count = readle(f.read(4)) + f.seek(0x10) + for _ in range(seed_count): + entry = f.read(0x20) + if entry[0:8] == tid_bytes: + return entry[0x8:0x18] + raise NCCHSeedError(f'missing seed for {program_id:016X} from seeddb.bin') + + +seeddb_paths = [pjoin(x, 'seeddb.bin') for x in config_dirs] +try: + # try to insert the path in the SEEDDB_PATH environment variable + seeddb_paths.insert(0, environ['SEEDDB_PATH']) +except KeyError: + pass + +# NCCH sections are stored in media units +# for example, ExeFS may be stored in 13 media units, which is 0x1A00 bytes (13 * 0x200) +NCCH_MEDIA_UNIT = 0x200 +# depending on the crypto_method flag, a different keyslot may be used for RomFS and parts of ExeFS. +extra_cryptoflags = {0x00: Keyslot.NCCH, 0x01: Keyslot.NCCH70, 0x0A: Keyslot.NCCH93, 0x0B: Keyslot.NCCH96} + +# if fixed_crypto_key is enabled, the normal key is normally all zeros. +# however is (program_id & (0x10 << 32)) is true, this key is used instead. +FIXED_SYSTEM_KEY = 0x527CE630A9CA305F3696F3CDE954194B + + +# this is IntEnum to make generating the IV easier +class NCCHSection(IntEnum): + ExtendedHeader = 1 + ExeFS = 2 + RomFS = 3 + + # no crypto + Header = 4 + Logo = 5 + Plain = 6 + + # special + FullDecrypted = 7 + Raw = 8 + + +# these sections don't use encryption at all +NO_ENCRYPTION = {NCCHSection.Header, NCCHSection.Logo, NCCHSection.Plain, NCCHSection.Raw} +# the contents of these files in the ExeFS, plus the header, will always use the Original NCCH keyslot +# therefore these regions need to be stored to check what keyslot is used to decrypt +EXEFS_NORMAL_CRYPTO_FILES = {'icon', 'banner'} + + +class NCCHRegion(NamedTuple): + section: 'NCCHSection' + offset: int + size: int + end: int # this is just offset + size, stored to avoid re-calculation later on + # not all sections will actually use this (see NCCHSection), so some have a useless value + iv: int + + +class NCCHFlags(NamedTuple): + # determines the extra keyslot used for RomFS and parts of ExeFS + crypto_method: int + # if this is a CXI (CTR Executable Image) or CFA (CTR File Archive) + # in the raw flags, "Data" has to be set for it to be a CFA, while "Executable" is unset. + executable: bool + # if the content is encrypted using a fixed normal key. + fixed_crypto_key: bool + # if RomFS is to be ignored + no_romfs: bool + # if the NCCH has no encryption + no_crypto: bool + # if a seed must be loaded to load RomFS and parts of ExeFS + uses_seed: bool + + +class _NCCHSectionFile(_ReaderOpenFileBase): + """Provides a raw, decrypted NCCH section as a file-like object.""" + + def __init__(self, reader: 'NCCHReader', path: 'NCCHSection'): + super().__init__(reader, path) + self._info = reader.sections[path] + + +class NCCHReader: + """Class for 3DS NCCH container.""" + + seed_set_up = False + seed: 'Optional[bytes]' = None + # this is the KeyY when generated using the seed + _seeded_key_y = None + closed = False + + # this lists the ranges of the ExeFS to decrypt with Original NCCH (see load_sections) + _exefs_keyslot_normal_range: 'List[Tuple[int, int]]' + exefs: 'Optional[ExeFSReader]' = None + romfs: 'Optional[RomFSReader]' = None + + def __init__(self, fp: 'Union[str, BinaryIO]', *, case_insensitive: bool = True, crypto: CryptoEngine = None, + dev: bool = False, seeddb: str = None, load_sections: bool = True): + if isinstance(fp, str): + fp = open(fp, 'rb') + + if crypto: + self._crypto = crypto + else: + self._crypto = CryptoEngine(dev=dev) + + # store the starting offset so the NCCH can be read from any point in the base file + self._start = fp.tell() + self._fp = fp + # store case-insensitivity for RomFSReader + self._case_insensitive = case_insensitive + # threaing lock + self._lock = Lock() + + header = fp.read(0x200) + + # load the Key Y from the first 0x10 of the signature + self._key_y = header[0x0:0x10] + # store the ncch version + self.version = readle(header[0x112:0x114]) + # get the total size of the NCCH container, and store it in bytes + self.content_size = readle(header[0x104:0x108]) * NCCH_MEDIA_UNIT + # get the Partition ID, which is used in the encryption + # this is generally different for each content in a title, except for DLC + self.partition_id = readle(header[0x108:0x110]) + # load the seed verify field, which is part of an sha256 hash to verify if + # a seed is correct for this title + self._seed_verify = header[0x114:0x118] + # load the Product Code store it as a unicode string + self.product_code = header[0x150:0x160].decode('ascii').strip('\0') + # load the Program ID + # this is the Title ID, and + self.program_id = readle(header[0x118:0x120]) + # load the extheader size, but this code only uses it to determine if it exists + extheader_size = readle(header[0x180:0x184]) + + # each section is stored with the section ID, then the region information (offset, size, IV) + self.sections: 'Dict[NCCHSection, NCCHRegion]' = {} + # same as above, but includes non-existant regions too, for the full-decrypted handler + self._all_sections: 'Dict[NCCHSection, NCCHRegion]' = {} + + def add_region(section: 'NCCHSection', starting_unit: int, units: int): + offset = starting_unit * NCCH_MEDIA_UNIT + size = units * NCCH_MEDIA_UNIT + region = NCCHRegion(section=section, + offset=offset, + size=size, + end=offset + size, + iv=self.partition_id << 64 | (section << 56)) + self._all_sections[section] = region + if units != 0: # only add existing regions + self.sections[section] = region + + # add the header as the first region + add_region(NCCHSection.Header, 0, 1) + + # add the full decrypted content, which when read, simulates a fully decrypted NCCH container + add_region(NCCHSection.FullDecrypted, 0, self.content_size // NCCH_MEDIA_UNIT) + # add the full raw content + add_region(NCCHSection.Raw, 0, self.content_size // NCCH_MEDIA_UNIT) + + # only care about the exheader if it's the expected size + if extheader_size == 0x400: + add_region(NCCHSection.ExtendedHeader, 1, 4) + else: + add_region(NCCHSection.ExtendedHeader, 0, 0) + + # add the remaining NCCH regions + # some of these may not exist, and won't be added if units (second value) is 0 + add_region(NCCHSection.Logo, readle(header[0x198:0x19C]), readle(header[0x19C:0x1A0])) + add_region(NCCHSection.Plain, readle(header[0x190:0x194]), readle(header[0x194:0x198])) + add_region(NCCHSection.ExeFS, readle(header[0x1A0:0x1A4]), readle(header[0x1A4:0x1A8])) + add_region(NCCHSection.RomFS, readle(header[0x1B0:0x1B4]), readle(header[0x1B4:0x1B8])) + + # parse flags + flags_raw = header[0x188:0x190] + self.flags = NCCHFlags(crypto_method=flags_raw[3], executable=bool(flags_raw[5] & 0x2), + fixed_crypto_key=bool(flags_raw[7] & 0x1), no_romfs=bool(flags_raw[7] & 0x2), + no_crypto=bool(flags_raw[7] & 0x4), uses_seed=bool(flags_raw[7] & 0x20)) + + # load the original (non-seeded) KeyY into the Original NCCH slot + self._crypto.set_keyslot('y', Keyslot.NCCH, self.get_key_y(original=True)) + + # load the seed if needed + if self.flags.uses_seed: + self.load_seed_from_seeddb(seeddb) + + # load the (seeded, if needed) key into the extra keyslot + self._crypto.set_keyslot('y', self.extra_keyslot, self.get_key_y()) + + # load the sections using their specific readers + if load_sections: + self.load_sections() + + def close(self): + self.closed = True + try: + self._fp.close() + except AttributeError: + pass + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + __del__ = close + + def load_sections(self): + """Load the sections of the NCCH (Extended Header, ExeFS, and RomFS).""" + + # try to load the ExeFS + try: + self._fp.seek(self._start + self.sections[NCCHSection.ExeFS].offset) + except KeyError: + pass # no ExeFS + else: + # this is to generate what regions should be decrypted with the Original NCCH keyslot + # technically, it's not actually 0x200 chunks or units. the actual space of the file + # is encrypted with the different key. for example, if .code is 0x300 bytes, that + # means the first 0x300 are encrypted with the NCCH 7.x key, and the remaining + # 0x100 uses Original NCCH. however this would be quite a pain to implement properly + # with random access, so I only work with 0x200 chunks here. after all, the space + # after the file is effectively unused. it makes no difference, except for + # perfectionists who want it perfectly decrypted. GodMode9 does it properly I think, + # if that is what you want. or you can fix the empty space yourself with a hex editor. + self._exefs_keyslot_normal_range = [(0, 0x200)] + exefs_fp = self.open_raw_section(NCCHSection.ExeFS) + # load the RomFS reader + self.exefs = ExeFSReader(exefs_fp, _load_icon=False) + + for entry in self.exefs.entries.values(): + if entry.name in EXEFS_NORMAL_CRYPTO_FILES: + # this will add the offset (relative to ExeFS start), with the size + # rounded up to 0x200 chunks + r = (entry.offset + EXEFS_HEADER_SIZE, + entry.offset + EXEFS_HEADER_SIZE + roundup(entry.size, NCCH_MEDIA_UNIT)) + self._exefs_keyslot_normal_range.append(r) + + self.exefs._load_icon() + + # try to load RomFS + if not self.flags.no_romfs: + try: + self._fp.seek(self._start + self.sections[NCCHSection.RomFS].offset) + except KeyError: + pass # no RomFS + else: + romfs_fp = self.open_raw_section(NCCHSection.RomFS) + # load the RomFS reader + self.romfs = RomFSReader(romfs_fp, case_insensitive=self._case_insensitive) + + def open_raw_section(self, section: 'NCCHSection'): + """Open a raw NCCH section for reading.""" + return _NCCHSectionFile(self, section) + + def get_key_y(self, original: bool = False) -> bytes: + if original or not self.flags.uses_seed: + return self._key_y + if self.flags.uses_seed and not self.seed_set_up: + raise MissingSeedError('NCCH uses seed crypto, but seed is not set up') + else: + return self._seeded_key_y + + @property + def extra_keyslot(self) -> int: + return extra_cryptoflags[self.flags.crypto_method] + + def check_for_extheader(self) -> bool: + return NCCHSection.ExtendedHeader in self.sections + + def setup_seed(self, seed: bytes): + if not self.flags.uses_seed: + raise NCCHSeedError('NCCH does not use seed crypto') + seed_verify_hash = sha256(seed + self.program_id.to_bytes(0x8, 'little')).digest() + if seed_verify_hash[0x0:0x4] != self._seed_verify: + raise NCCHSeedError('given seed does not match with seed verify hash in header') + self.seed = seed + self._seeded_key_y = sha256(self._key_y + seed).digest()[0:16] + self.seed_set_up = True + + def load_seed_from_seeddb(self, path: str = None): + if not self.flags.uses_seed: + raise NCCHSeedError('NCCH does not use seed crypto') + if path: + # if a path was provided, use only that + paths = (path,) + else: + # use the fixed set of paths + paths = seeddb_paths + for fn in paths: + try: + with open(fn, 'rb') as f: + # try to load the seed from the file + self.setup_seed(get_seed(f, self.program_id)) + return + except FileNotFoundError: + continue + + # if keys are not set... + raise InvalidNCCHError(paths) + + def get_data(self, section: 'Union[NCCHRegion, NCCHSection]', offset: int, size: int) -> bytes: + try: + region = self._all_sections[section] + except KeyError: + region = section + if offset + size > region.size: + # prevent reading past the region + size = region.size - offset + + # the full-decrypted handler is done outside of the thread lock + if region.section == NCCHSection.FullDecrypted: + before = offset % 0x200 + aligned_offset = offset - before + aligned_size = size + before + + def do_thing(al_offset: int, al_size: int, cut_start: int, cut_end: int): + # get the offset of the end of the last chunk + end = al_offset + (ceil(al_size / 0x200) * 0x200) + + # store the sections to read + # dict is ordered by default in CPython since 3.6.0, and part of the language spec since 3.7.0 + to_read: Dict[Tuple[NCCHSection, int], List[int]] = {} + + # get each section to a local variable for easier access + header = self._all_sections[NCCHSection.Header] + extheader = self._all_sections[NCCHSection.ExtendedHeader] + logo = self._all_sections[NCCHSection.Logo] + plain = self._all_sections[NCCHSection.Plain] + exefs = self._all_sections[NCCHSection.ExeFS] + romfs = self._all_sections[NCCHSection.RomFS] + + last_region = False + + # this is somewhat hardcoded for performance reasons. this may be optimized better later. + for chunk_offset in range(al_offset, end, 0x200): + # RomFS check first, since it might be faster + if romfs.offset <= chunk_offset < romfs.end: + region = (NCCHSection.RomFS, 0) + curr_offset = romfs.offset + + # ExeFS check second, since it might be faster + elif exefs.offset <= chunk_offset < exefs.end: + region = (NCCHSection.ExeFS, 0) + curr_offset = exefs.offset + + elif header.offset <= chunk_offset < header.end: + region = (NCCHSection.Header, 0) + curr_offset = header.offset + + elif extheader.offset <= chunk_offset < extheader.end: + region = (NCCHSection.ExtendedHeader, 0) + curr_offset = extheader.offset + + elif logo.offset <= chunk_offset < logo.end: + region = (NCCHSection.Logo, 0) + curr_offset = logo.offset + + elif plain.offset <= chunk_offset < plain.end: + region = (NCCHSection.Plain, 0) + curr_offset = plain.offset + + else: + region = (NCCHSection.Raw, chunk_offset) + curr_offset = 0 + + if region not in to_read: + to_read[region] = [chunk_offset - curr_offset, 0] + to_read[region][1] += 0x200 + last_region = region + + is_start = True + for region, info in to_read.items(): + new_data = self.get_data(region[0], info[0], info[1]) + if region[0] == NCCHSection.Header: + # fix crypto flags + ncch_array = bytearray(new_data) + ncch_array[0x18B] = 0 + ncch_array[0x18F] = 4 + new_data = bytes(ncch_array) + if is_start: + new_data = new_data[cut_start:] + is_start = False + if region == last_region and cut_end != 0x200: + new_data = new_data[:-cut_end] + + yield new_data + + return b''.join(do_thing(aligned_offset, aligned_size, before, 0x200 - ((size + before) % 0x200))) + + with self._lock: + # check if decryption is really needed + if self.flags.no_crypto or region.section in NO_ENCRYPTION: + self._fp.seek(self._start + region.offset + offset) + return self._fp.read(size) + + # thanks Stary2001 for help with random-access crypto + + # if the region is ExeFS and extra crypto is being used, special handling is required + # because different parts use different encryption methods + if region.section == NCCHSection.ExeFS and self.flags.crypto_method != 0x00: + # get the amount to cut off at the beginning + before = offset % 0x200 + + # get the offset of the starting chunk + aligned_offset = offset - before + + # get the real offset of the starting chunk + aligned_real_offset = self._start + region.offset + aligned_offset + + # get the aligned total size of the requested size + aligned_size = size + before + self._fp.seek(aligned_real_offset) + + def do_thing(al_offset: int, al_size: int, cut_start: int, cut_end: int): + # get the offset of the end of the last chunk + end = al_offset + (ceil(al_size / 0x200) * 0x200) + + # get the offset to the last chunk + last_chunk_offset = end - 0x200 + + # noinspection PyTypeChecker + for chunk in range(al_offset, end, 0x200): + # generate the IV for this chunk + iv = region.iv + (chunk >> 4) + + # get the extra keyslot + keyslot = self.extra_keyslot + + for r in self._exefs_keyslot_normal_range: + if r[0] <= self._fp.tell() - region.offset < r[1]: + # if the chunk is within the "normal keyslot" ranges, + # use the Original NCCH keyslot instead + keyslot = Keyslot.NCCH + + # decrypt the data + out = self._crypto.create_ctr_cipher(keyslot, iv).decrypt(self._fp.read(0x200)) + if chunk == al_offset: + # cut off the beginning if it's the first chunk + out = out[cut_start:] + if chunk == last_chunk_offset and cut_end != 0x200: + # cut off the end of it's the last chunk + out = out[:-cut_end] + yield out + + # join all the chunks into one bytes result and return it + return b''.join(do_thing(aligned_offset, aligned_size, before, 0x200 - ((size + before) % 0x200))) + else: + # seek to the real offset of the section + the requested offset + self._fp.seek(self._start + region.offset + offset) + data = self._fp.read(size) + + # choose the extra keyslot only for RomFS here + # ExeFS needs special handling if a newer keyslot is used, therefore it's not checked here + keyslot = self.extra_keyslot if region.section == NCCHSection.RomFS else Keyslot.NCCH + + # get the amount of padding required at the beginning + before = offset % 16 + + # pad the beginning of the data if needed (the ending part doesn't need padding) + data = (b'\0' * before) + data + + # decrypt the data, then cut off the padding + return self._crypto.create_ctr_cipher(keyslot, region.iv + (offset >> 4)).decrypt(data)[before:] diff --git a/pyctr/types/romfs.py b/pyctr/types/romfs.py new file mode 100644 index 0000000..6d25125 --- /dev/null +++ b/pyctr/types/romfs.py @@ -0,0 +1,246 @@ +# This file is a part of ninfs. +# +# Copyright (c) 2017-2019 Ian Burgwin +# This file is licensed under The MIT License (MIT). +# You can find the full license text in LICENSE.md in the root of this project. + +from io import TextIOWrapper +from threading import Lock +from typing import overload, TYPE_CHECKING, NamedTuple + +from ..common import PyCTRError, _ReaderOpenFileBase +from ..util import readle, roundup + +if TYPE_CHECKING: + from typing import BinaryIO, Optional, Tuple, Union + +__all__ = ['IVFC_HEADER_SIZE', 'IVFC_ROMFS_MAGIC_NUM', 'ROMFS_LV3_HEADER_SIZE', 'RomFSError', 'InvalidIVFCError', + 'InvalidRomFSHeaderError', 'RomFSEntryError', 'RomFSFileNotFoundError', 'RomFSReader'] + +IVFC_HEADER_SIZE = 0x5C +IVFC_ROMFS_MAGIC_NUM = 0x10000 +ROMFS_LV3_HEADER_SIZE = 0x28 + + +class RomFSError(PyCTRError): + """Generic exception for RomFS operations.""" + + +class InvalidIVFCError(RomFSError): + """Invalid IVFC header exception.""" + + +class InvalidRomFSHeaderError(RomFSError): + """Invalid RomFS Level 3 header.""" + + +class RomFSEntryError(RomFSError): + """Error with RomFS Directory or File entry.""" + + +class RomFSFileNotFoundError(RomFSEntryError): + """Invalid file path in RomFS Level 3.""" + + +class RomFSIsADirectoryError(RomFSEntryError): + """Attempted to open a directory as a file.""" + + +class RomFSRegion(NamedTuple): + offset: int + size: int + + +class RomFSDirectoryEntry(NamedTuple): + name: str + type: str + contents: 'Tuple[str, ...]' + + +class RomFSFileEntry(NamedTuple): + name: str + type: str + offset: int + size: int + + +class _RomFSOpenFile(_ReaderOpenFileBase): + """Class for open RomFS file entries.""" + + def __init__(self, reader: 'RomFSReader', path: str): + super().__init__(reader, path) + self._info: RomFSFileEntry = reader.get_info_from_path(path) + if not isinstance(self._info, RomFSFileEntry): + raise RomFSIsADirectoryError(path) + + +class RomFSReader: + """ + Class for 3DS RomFS Level 3 partition. + + https://www.3dbrew.org/wiki/RomFS + """ + + closed = False + lv3_offset = 0 + data_offset = 0 + + def __init__(self, fp: 'Union[str, BinaryIO]', case_insensitive: bool = False): + if isinstance(fp, str): + fp = open(fp, 'rb') + + self._start = fp.tell() + self._fp = fp + self.case_insensitive = case_insensitive + self._lock = Lock() + + lv3_offset = fp.tell() + magic = fp.read(4) + + # detect ivfc and get the lv3 offset + if magic == b'IVFC': + ivfc = magic + fp.read(0x54) # IVFC_HEADER_SIZE - 4 + ivfc_magic_num = readle(ivfc[0x4:0x8]) + if ivfc_magic_num != IVFC_ROMFS_MAGIC_NUM: + raise InvalidIVFCError(f'IVFC magic number is invalid ' + f'({ivfc_magic_num:#X} instead of {IVFC_ROMFS_MAGIC_NUM:#X})') + master_hash_size = readle(ivfc[0x8:0xC]) + lv3_block_size = readle(ivfc[0x4C:0x50]) + lv3_hash_block_size = 1 << lv3_block_size + lv3_offset += roundup(0x60 + master_hash_size, lv3_hash_block_size) + fp.seek(self._start + lv3_offset) + magic = fp.read(4) + self.lv3_offset = lv3_offset + + lv3_header = magic + fp.read(0x24) # ROMFS_LV3_HEADER_SIZE - 4 + + # get offsets and sizes from lv3 header + lv3_header_size = readle(magic) + lv3_dirhash = RomFSRegion(offset=readle(lv3_header[0x4:0x8]), size=readle(lv3_header[0x8:0xC])) + lv3_dirmeta = RomFSRegion(offset=readle(lv3_header[0xC:0x10]), size=readle(lv3_header[0x10:0x14])) + lv3_filehash = RomFSRegion(offset=readle(lv3_header[0x14:0x18]), size=readle(lv3_header[0x18:0x1C])) + lv3_filemeta = RomFSRegion(offset=readle(lv3_header[0x1C:0x20]), size=readle(lv3_header[0x20:0x24])) + lv3_filedata_offset = readle(lv3_header[0x24:0x28]) + self.data_offset = lv3_offset + lv3_filedata_offset + + # verify lv3 header + if lv3_header_size != ROMFS_LV3_HEADER_SIZE: + raise InvalidRomFSHeaderError('Length in RomFS Lv3 header is not 0x28') + if lv3_dirhash.offset < lv3_header_size: + raise InvalidRomFSHeaderError('Directory Hash offset is before the end of the Lv3 header') + if lv3_dirmeta.offset < lv3_dirhash.offset + lv3_dirhash.size: + raise InvalidRomFSHeaderError('Directory Metadata offset is before the end of the Directory Hash region') + if lv3_filehash.offset < lv3_dirmeta.offset + lv3_dirmeta.size: + raise InvalidRomFSHeaderError('File Hash offset is before the end of the Directory Metadata region') + if lv3_filemeta.offset < lv3_filehash.offset + lv3_filehash.size: + raise InvalidRomFSHeaderError('File Metadata offset is before the end of the File Hash region') + if lv3_filedata_offset < lv3_filemeta.offset + lv3_filemeta.size: + raise InvalidRomFSHeaderError('File Data offset is before the end of the File Metadata region') + + # get entries from dirmeta and filemeta + def iterate_dir(out: dict, raw: bytes, current_path: str): + first_child_dir = readle(raw[0x8:0xC]) + first_file = readle(raw[0xC:0x10]) + + out['type'] = 'dir' + out['contents'] = {} + + # iterate through all child directories + if first_child_dir != 0xFFFFFFFF: + fp.seek(self._start + lv3_offset + lv3_dirmeta.offset + first_child_dir) + while True: + child_dir_meta = fp.read(0x18) + next_sibling_dir = readle(child_dir_meta[0x4:0x8]) + child_dir_name = fp.read(readle(child_dir_meta[0x14:0x18])).decode('utf-16le') + child_dir_name_meta = child_dir_name.lower() if case_insensitive else child_dir_name + if child_dir_name_meta in out['contents']: + print(f'WARNING: Dirname collision! {current_path}{child_dir_name}') + out['contents'][child_dir_name_meta] = {'name': child_dir_name} + + iterate_dir(out['contents'][child_dir_name_meta], child_dir_meta, + f'{current_path}{child_dir_name}/') + if next_sibling_dir == 0xFFFFFFFF: + break + fp.seek(self._start + lv3_offset + lv3_dirmeta.offset + next_sibling_dir) + + if first_file != 0xFFFFFFFF: + fp.seek(self._start + lv3_offset + lv3_filemeta.offset + first_file) + while True: + child_file_meta = fp.read(0x20) + next_sibling_file = readle(child_file_meta[0x4:0x8]) + child_file_offset = readle(child_file_meta[0x8:0x10]) + child_file_size = readle(child_file_meta[0x10:0x18]) + child_file_name = fp.read(readle(child_file_meta[0x1C:0x20])).decode('utf-16le') + child_file_name_meta = child_file_name.lower() if self.case_insensitive else child_file_name + if child_file_name_meta in out['contents']: + print(f'WARNING: Filename collision! {current_path}{child_file_name}') + out['contents'][child_file_name_meta] = {'name': child_file_name, 'type': 'file', + 'offset': child_file_offset, 'size': child_file_size} + + self.total_size += child_file_size + if next_sibling_file == 0xFFFFFFFF: + break + fp.seek(self._start + lv3_offset + lv3_filemeta.offset + next_sibling_file) + + self._tree_root = {'name': 'ROOT'} + self.total_size = 0 + fp.seek(self._start + lv3_offset + lv3_dirmeta.offset) + iterate_dir(self._tree_root, fp.read(0x18), '/') + + def close(self): + self.closed = True + try: + self._fp.close() + except AttributeError: + pass + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + @overload + def open(self, path: str, encoding: str, errors: 'Optional[str]' = None, + newline: 'Optional[str]' = None) -> TextIOWrapper: ... + + @overload + def open(self, path: str, encoding: None = None, errors: 'Optional[str]' = None, + newline: 'Optional[str]' = None) -> _RomFSOpenFile: ... + + def open(self, path, encoding=None, errors=None, newline=None): + """Open a file in the RomFS for reading.""" + f = _RomFSOpenFile(self, path) + if encoding is not None: + f = TextIOWrapper(f, encoding, errors, newline) + return f + + __del__ = close + + def get_info_from_path(self, path: str) -> 'Union[RomFSDirectoryEntry, RomFSFileEntry]': + """Get a directory or file entry""" + curr = self._tree_root + if self.case_insensitive: + path = path.lower() + if path[0] == '/': + path = path[1:] + for part in path.split('/'): + if part == '': + break + try: + # noinspection PyTypeChecker + curr = curr['contents'][part] + except KeyError: + raise RomFSFileNotFoundError(path) + if curr['type'] == 'dir': + contents = (k['name'] for k in curr['contents'].values()) + return RomFSDirectoryEntry(name=curr['name'], type='dir', contents=(*contents,)) + elif curr['type'] == 'file': + return RomFSFileEntry(name=curr['name'], type='file', offset=curr['offset'], size=curr['size']) + + def get_data(self, info: RomFSFileEntry, offset: int, size: int) -> bytes: + if offset + size > info.size: + size = info.size - offset + with self._lock: + self._fp.seek(self._start + self.data_offset + info.offset + offset) + return self._fp.read(size) diff --git a/pyctr/types/smdh.py b/pyctr/types/smdh.py new file mode 100644 index 0000000..bd3b739 --- /dev/null +++ b/pyctr/types/smdh.py @@ -0,0 +1,111 @@ +# This file is a part of ninfs. +# +# Copyright (c) 2017-2019 Ian Burgwin +# This file is licensed under The MIT License (MIT). +# You can find the full license text in LICENSE.md in the root of this project. + +from types import MappingProxyType +from typing import TYPE_CHECKING, NamedTuple + +from ..common import PyCTRError + +if TYPE_CHECKING: + from typing import BinaryIO, Dict, Mapping, Optional, Tuple, Union + +SMDH_SIZE = 0x36C0 + +region_names = ( + 'Japanese', + 'English', + 'French', + 'German', + 'Italian', + 'Spanish', + 'Simplified Chinese', + 'Korean', + 'Dutch', + 'Portuguese', + 'Russian', + 'Traditional Chinese', +) + +# the order of the SMDH names to check. the difference here is that English is put before Japanese. +_region_order_check = ( + 'English', + 'Japanese', + 'French', + 'German', + 'Italian', + 'Spanish', + 'Simplified Chinese', + 'Korean', + 'Dutch', + 'Portuguese', + 'Russian', + 'Traditional Chinese', +) + + +class SMDHError(PyCTRError): + """Generic exception for SMDH operations.""" + + +class InvalidSMDHError(SMDHError): + """Invalid SMDH contents.""" + + +class AppTitle(NamedTuple): + short_desc: str + long_desc: str + publisher: str + + +class SMDH: + """ + Class for 3DS SMDH. Icon data is currently not supported. + + https://www.3dbrew.org/wiki/SMDH + """ + + # TODO: support other settings + + def __init__(self, names: 'Dict[str, AppTitle]'): + self.names: Mapping[str, AppTitle] = MappingProxyType({n: names.get(n, None) for n in region_names}) + + def __repr__(self): + return f'<{type(self).__name__} title: {self.get_app_title().short_desc}>' + + def get_app_title(self, language: 'Union[str, Tuple[str, ...]]' = _region_order_check) -> 'Optional[AppTitle]': + if isinstance(language, str): + language = (language,) + + for l in language: + apptitle = self.names[l] + if apptitle: + return apptitle + + # if, for some reason, it fails to return... + return AppTitle('unknown', 'unknown', 'unknown') + + @classmethod + def load(cls, fp: 'BinaryIO') -> 'SMDH': + """Load an SMDH from a file-like object.""" + smdh = fp.read(SMDH_SIZE) + if len(smdh) != SMDH_SIZE: + raise InvalidSMDHError(f'invalid size (expected: {SMDH_SIZE:#6x}, got: {len(smdh):#6x}') + if smdh[0:4] != b'SMDH': + raise InvalidSMDHError('SMDH magic not found') + + app_structs = smdh[8:0x2008] + names: Dict[str, AppTitle] = {} + # due to region_names only being 12 elements, this will only process 12. the other 4 are unused. + for app_title, region in zip((app_structs[x:x + 0x200] for x in range(0, 0x2000, 0x200)), region_names): + names[region] = AppTitle(app_title[0:0x80].decode('utf-16le').strip('\0'), + app_title[0x80:0x180].decode('utf-16le').strip('\0'), + app_title[0x180:0x200].decode('utf-16le').strip('\0')) + return cls(names) + + @classmethod + def from_file(cls, fn: str) -> 'SMDH': + with open(fn, 'rb') as f: + return cls.load(f) diff --git a/pyctr/types/tmd.py b/pyctr/types/tmd.py new file mode 100644 index 0000000..0da6fa1 --- /dev/null +++ b/pyctr/types/tmd.py @@ -0,0 +1,316 @@ +# This file is a part of ninfs. +# +# Copyright (c) 2017-2019 Ian Burgwin +# This file is licensed under The MIT License (MIT). +# You can find the full license text in LICENSE.md in the root of this project. + +from hashlib import sha256 +from struct import pack +from typing import TYPE_CHECKING, NamedTuple + +from ..common import PyCTRError +from ..util import readbe, readle + +if TYPE_CHECKING: + from typing import BinaryIO, Iterable + +__all__ = ['CHUNK_RECORD_SIZE', 'TitleMetadataError', 'InvalidSignatureTypeError', 'InvalidHashError', + 'ContentInfoRecord', 'ContentChunkRecord', 'ContentTypeFlags', 'TitleVersion', 'TitleMetadataReader'] + +CHUNK_RECORD_SIZE = 0x30 + +# sig-type: (sig-size, padding) +signature_types = { + # RSA_4096 SHA1 (unused on 3DS) + 0x00010000: (0x200, 0x3C), + # RSA_2048 SHA1 (unused on 3DS) + 0x00010001: (0x100, 0x3C), + # Elliptic Curve with SHA1 (unused on 3DS) + 0x00010002: (0x3C, 0x40), + # RSA_4096 SHA256 + 0x00010003: (0x200, 0x3C), + # RSA_2048 SHA256 + 0x00010004: (0x100, 0x3C), + # ECDSA with SHA256 + 0x00010005: (0x3C, 0x40), +} + +BLANK_SIG_PAIR = (0x00010004, b'\xFF' * signature_types[0x00010004][0]) + + +class TitleMetadataError(PyCTRError): + """Generic exception for TitleMetadata operations.""" + + +class InvalidTMDError(TitleMetadataError): + """Title Metadata is invalid.""" + + +class InvalidSignatureTypeError(InvalidTMDError): + """Invalid signature type was used.""" + + def __init__(self, sig_type): + super().__init__(sig_type) + + def __str__(self): + return f'{self.args[0]:#010x}' + + +class InvalidHashError(InvalidTMDError): + """Hash mismatch in the Title Metadata.""" + + +class InvalidInfoRecordError(InvalidHashError): + """Hash mismatch in the Content Info Records.""" + + def __init__(self, info_record): + super().__init__(info_record) + + def __str__(self): + return f'Invalid info record: {self.args[0]}' + + +class UnusualInfoRecordError(InvalidTMDError): + """Encountered Content Info Record that attempts to hash a Content Chunk Record that has already been hashed.""" + + def __init__(self, info_record, chunk_record): + super().__init__(info_record, chunk_record) + + def __str__(self): + return f'Attempted to hash twice: {self.args[0]}, {self.args[1]}' + + +class ContentTypeFlags(NamedTuple): + encrypted: bool + disc: bool + cfm: bool + optional: bool + shared: bool + + def __index__(self) -> int: + return self.encrypted | (self.disc << 1) | (self.cfm << 2) | (self.optional << 14) | (self.shared << 15) + + __int__ = __index__ + + def __format__(self, format_spec: str) -> str: + return self.__int__().__format__(format_spec) + + @classmethod + def from_int(cls, flags: int) -> 'ContentTypeFlags': + # noinspection PyArgumentList + return cls(bool(flags & 1), bool(flags & 2), bool(flags & 4), bool(flags & 0x4000), bool(flags & 0x8000)) + + +class ContentInfoRecord(NamedTuple): + index_offset: int + command_count: int + hash: bytes + + def __bytes__(self) -> bytes: + return b''.join((self.index_offset.to_bytes(2, 'big'), self.command_count.to_bytes(2, 'big'), self.hash)) + + +class ContentChunkRecord(NamedTuple): + id: str + cindex: int + type: ContentTypeFlags + size: int + hash: bytes + + def __bytes__(self) -> bytes: + return b''.join((bytes.fromhex(self.id), self.cindex.to_bytes(2, 'big'), int(self.type).to_bytes(2, 'big'), + self.size.to_bytes(8, 'big'), self.hash)) + + +class TitleVersion(NamedTuple): + major: int + minor: int + micro: int + + def __str__(self) -> str: + return f'{self.major}.{self.minor}.{self.micro}' + + def __index__(self) -> int: + return (self.major << 10) | (self.minor << 4) | self.micro + + __int__ = __index__ + + def __format__(self, format_spec: str) -> str: + return self.__int__().__format__(format_spec) + + @classmethod + def from_int(cls, ver: int) -> 'TitleVersion': + # noinspection PyArgumentList + return cls((ver >> 10) & 0x3F, (ver >> 4) & 0x3F, ver & 0xF) + + +class TitleMetadataReader: + """ + Class for 3DS Title Metadata. + + https://www.3dbrew.org/wiki/Title_metadata + """ + + __slots__ = ('title_id', 'save_size', 'srl_save_size', 'title_version', 'info_records', + 'chunk_records', 'content_count', 'signature', '_u_issuer', '_u_version', '_u_ca_crl_version', + '_u_signer_crl_version', '_u_reserved1', '_u_system_version', '_u_title_type', '_u_group_id', + '_u_reserved2', '_u_srl_flag', '_u_reserved3', '_u_access_rights', '_u_boot_count', '_u_padding') + + # arguments prefixed with _u_ are values unused by the 3DS and/or are only kept around to generate the final tmd + def __init__(self, *, title_id: str, save_size: int, srl_save_size: int, title_version: TitleVersion, + info_records: 'Iterable[ContentInfoRecord]', chunk_records: 'Iterable[ContentChunkRecord]', + signature=BLANK_SIG_PAIR, _u_issuer='Root-CA00000003-CP0000000b', _u_version=1, _u_ca_crl_version=0, + _u_signer_crl_version=0, _u_reserved1=0, _u_system_version=b'\0' * 8, _u_title_type=b'\0\0\0@', + _u_group_id=b'\0\0', _u_reserved2=b'\0\0\0\0', _u_srl_flag=0, _u_reserved3=b'\0' * 0x31, + _u_access_rights=b'\0' * 4, _u_boot_count=b'\0\0', _u_padding=b'\0\0'): + # TODO: add checks + self.title_id = title_id.lower() + self.save_size = save_size + self.srl_save_size = srl_save_size + self.title_version = title_version + self.info_records = tuple(info_records) + self.chunk_records = tuple(chunk_records) + self.content_count = len(self.chunk_records) + self.signature = signature # TODO: store this differently + + # unused values + self._u_issuer = _u_issuer + self._u_version = _u_version + self._u_ca_crl_version = _u_ca_crl_version + self._u_signer_crl_version = _u_signer_crl_version + self._u_reserved1 = _u_reserved1 + self._u_system_version = _u_system_version + self._u_title_type = _u_title_type + self._u_group_id = _u_group_id + self._u_reserved2 = _u_reserved2 + self._u_srl_flag = _u_srl_flag + self._u_reserved3 = _u_reserved3 + self._u_access_rights = _u_access_rights + self._u_boot_count = _u_boot_count + self._u_padding = _u_padding + + def __hash__(self) -> int: + return hash((self.title_id, self.save_size, self.srl_save_size, self.title_version, + self.info_records, self.chunk_records)) + + def __repr__(self) -> str: + return (f'') + + def __bytes__(self) -> bytes: + sig_data = pack(f'>I {signature_types[self.signature[0]][0]}s {signature_types[self.signature[0]][1]}x', + self.signature[0], self.signature[1]) + + info_records = b''.join(bytes(x) for x in self.info_records).ljust(0x900, b'\0') + + header = pack('>64s b b b b 8s 8s 4s 2s I I 4s b 49s 4s H H 2s 2s 32s', self._u_issuer.encode('ascii'), + self._u_version, self._u_ca_crl_version, self._u_signer_crl_version, self._u_reserved1, + self._u_system_version, bytes.fromhex(self.title_id), self._u_title_type, self._u_group_id, + self.save_size, self.srl_save_size, self._u_reserved2, self._u_srl_flag, self._u_reserved3, + self._u_access_rights, self.title_version, self.content_count, self._u_boot_count, + self._u_padding, sha256(info_records).digest()) + + chunk_records = b''.join(bytes(x) for x in self.chunk_records) + + return sig_data + header + info_records + chunk_records + + @classmethod + def load(cls, fp: 'BinaryIO', verify_hashes: bool = True) -> 'TitleMetadataReader': + """Load a tmd from a file-like object.""" + sig_type = readbe(fp.read(4)) + try: + sig_size, sig_padding = signature_types[sig_type] + except KeyError: + raise InvalidSignatureTypeError(sig_type) + + signature = fp.read(sig_size) + try: + fp.seek(sig_padding, 1) + except Exception: + # most streams are probably seekable, but for some that aren't... + fp.read(sig_padding) + + header = fp.read(0xC4) + if len(header) != 0xC4: + raise InvalidTMDError('Header length is not 0xC4') + + # only values that actually have a use are loaded here. (currently) + # several fields in were left in from the Wii tmd and have no function on 3DS. + title_id = header[0x4C:0x54].hex() + save_size = readle(header[0x5A:0x5E]) + srl_save_size = readle(header[0x5E:0x62]) + title_version = TitleVersion.from_int(readbe(header[0x9C:0x9E])) + content_count = readbe(header[0x9E:0xA0]) + + content_info_records_hash = header[0xA4:0xC4] + + content_info_records_raw = fp.read(0x900) + if len(content_info_records_raw) != 0x900: + raise InvalidTMDError('Content info records length is not 0x900') + + if verify_hashes: + real_hash = sha256(content_info_records_raw) + if content_info_records_hash != real_hash.digest(): + raise InvalidHashError('Content Info Records hash is invalid') + + content_chunk_records_raw = fp.read(content_count * CHUNK_RECORD_SIZE) + + chunk_records = [] + for cr_raw in (content_chunk_records_raw[i:i + CHUNK_RECORD_SIZE] for i in + range(0, content_count * CHUNK_RECORD_SIZE, CHUNK_RECORD_SIZE)): + chunk_records.append(ContentChunkRecord(id=cr_raw[0:4].hex(), + cindex=readbe(cr_raw[4:6]), + type=ContentTypeFlags.from_int(readbe(cr_raw[6:8])), + size=readbe(cr_raw[8:16]), + hash=cr_raw[16:48])) + + info_records = [] + for ir_raw in (content_info_records_raw[i:i + 0x24] for i in range(0, 0x900, 0x24)): + if ir_raw != b'\0' * 0x24: + info_records.append(ContentInfoRecord(index_offset=readbe(ir_raw[0:2]), + command_count=readbe(ir_raw[2:4]), + hash=ir_raw[4:36])) + + if verify_hashes: + chunk_records_hashed = set() + for ir in info_records: + to_hash = [] + for cr in chunk_records[ir.index_offset:ir.index_offset + ir.command_count]: + if cr in chunk_records_hashed: + raise InvalidTMDError('attempting to hash chunk record twice') + + chunk_records_hashed.add(cr) + to_hash.append(cr) + + hashed = sha256(b''.join(bytes(x) for x in to_hash)) + if hashed.digest() != ir.hash: + raise InvalidInfoRecordError(ir) + + # unused vales are loaded only for use when re-building the binary tmd + u_issuer = header[0:0x40].decode('ascii').rstrip('\0') + u_version = header[0x40] + u_ca_crl_version = header[0x41] + u_signer_crl_version = header[0x42] + u_reserved1 = header[0x43] + u_system_version = header[0x44:0x4C] + u_title_type = header[0x54:0x58] + u_group_id = header[0x58:0x5A] + u_reserved2 = header[0x62:0x66] + u_srl_flag = header[0x66] # is this one used for anything? + u_reserved3 = header[0x67:0x98] + u_access_rights = header[0x98:0x9C] + u_boot_count = header[0xA0:0xA2] + u_padding = header[0xA2:0xA4] + + return cls(title_id=title_id, save_size=save_size, srl_save_size=srl_save_size, title_version=title_version, + info_records=info_records, chunk_records=chunk_records, signature=(sig_type, signature), + _u_issuer=u_issuer, _u_version=u_version, _u_ca_crl_version=u_ca_crl_version, + _u_signer_crl_version=u_signer_crl_version, _u_reserved1=u_reserved1, + _u_system_version=u_system_version, _u_title_type=u_title_type, _u_group_id=u_group_id, + _u_reserved2=u_reserved2, _u_srl_flag=u_srl_flag, _u_reserved3=u_reserved3, + _u_access_rights=u_access_rights, _u_boot_count=u_boot_count, _u_padding=u_padding) + + @classmethod + def from_file(cls, fn: str, *, verify_hashes: bool = True) -> 'TitleMetadataReader': + with open(fn, 'rb') as f: + return cls.load(f, verify_hashes=verify_hashes) diff --git a/pyctr/util.py b/pyctr/util.py new file mode 100644 index 0000000..1206d8b --- /dev/null +++ b/pyctr/util.py @@ -0,0 +1,41 @@ +# This file is a part of ninfs. +# +# Copyright (c) 2017-2019 Ian Burgwin +# This file is licensed under The MIT License (MIT). +# You can find the full license text in LICENSE.md in the root of this project. + +import os +from math import ceil +from sys import platform +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import List + +__all__ = ['windows', 'macos', 'readle', 'readbe', 'roundup', 'config_dirs'] + +windows = platform in {'win32', 'cygwin'} +macos = platform == 'darwin' + + +def readle(b: bytes) -> int: + """Convert little-endian bytes to an int.""" + return int.from_bytes(b, 'little') + + +def readbe(b: bytes) -> int: + """Convert big-endian bytes to an int.""" + return int.from_bytes(b, 'big') + + +def roundup(offset: int, alignment: int) -> int: + """Round up a number to a provided alignment.""" + return int(ceil(offset / alignment) * alignment) + + +_home = os.path.expanduser('~') +config_dirs: 'List[str]' = [os.path.join(_home, '.3ds'), os.path.join(_home, '3ds')] +if windows: + config_dirs.insert(0, os.path.join(os.environ.get('APPDATA'), '3ds')) +elif macos: + config_dirs.insert(0, os.path.join(_home, 'Library', 'Application Support', '3ds'))