initial commit

This commit is contained in:
Ian Burgwin
2019-09-06 14:22:13 -07:00
commit af2bb123a7
26 changed files with 3842 additions and 0 deletions

21
LICENSE.md Normal file
View File

@@ -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.

47
README.md Normal file
View File

@@ -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).

515
bin/Cargo.lock generated Normal file
View File

@@ -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"

4
bin/README Normal file
View File

@@ -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.

BIN
bin/darwin/save3ds_fuse Executable file

Binary file not shown.

View File

BIN
bin/win32/save3ds_fuse.exe Executable file

Binary file not shown.

327
custom-install.py Executable file
View File

@@ -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'])

259
finalize/Makefile Normal file
View File

@@ -0,0 +1,259 @@
#---------------------------------------------------------------------------------
.SUFFIXES:
#---------------------------------------------------------------------------------
ifeq ($(strip $(DEVKITARM)),)
$(error "Please set DEVKITARM in your environment. export DEVKITARM=<path to>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):
# - <Project name>.png
# - icon.png
# - <libctru folder>/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
#---------------------------------------------------------------------------------------

2
finalize/README.md Normal file
View File

@@ -0,0 +1,2 @@
# custom-install-finalize
Finishes the process after using custom-install.

BIN
finalize/data/basetik.bin Normal file

Binary file not shown.

173
finalize/source/main.c Normal file
View File

@@ -0,0 +1,173 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#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;
}

7
pyctr/README.md Normal file
View File

@@ -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...

6
pyctr/__init__.py Normal file
View File

@@ -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 *

82
pyctr/common.py Normal file
View File

@@ -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

598
pyctr/crypto.py Normal file
View File

@@ -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', key_hash)
self._id0 = pack('>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))

0
pyctr/types/__init__.py Normal file
View File

12
pyctr/types/base/title.py Normal file
View File

@@ -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

237
pyctr/types/cia.py Normal file
View File

@@ -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)

308
pyctr/types/exefs.py Normal file
View File

@@ -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

9
pyctr/types/extheader.py Normal file
View File

@@ -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):

521
pyctr/types/ncch.py Normal file
View File

@@ -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:]

246
pyctr/types/romfs.py Normal file
View File

@@ -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)

111
pyctr/types/smdh.py Normal file
View File

@@ -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)

316
pyctr/types/tmd.py Normal file
View File

@@ -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'<TitleMetadataReader title_id={self.title_id!r} title_version={self.title_version!r} '
f'content_count={self.content_count!r}>')
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)

41
pyctr/util.py Normal file
View File

@@ -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'))