mirror of
https://github.com/ihaveamac/custom-install.git
synced 2025-12-05 22:31:45 +00:00
initial commit
This commit is contained in:
21
LICENSE.md
Normal file
21
LICENSE.md
Normal 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
47
README.md
Normal 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
515
bin/Cargo.lock
generated
Normal 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
4
bin/README
Normal 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
BIN
bin/darwin/save3ds_fuse
Executable file
Binary file not shown.
0
bin/linux/put_save3ds_fuse_here
Normal file
0
bin/linux/put_save3ds_fuse_here
Normal file
BIN
bin/win32/save3ds_fuse.exe
Executable file
BIN
bin/win32/save3ds_fuse.exe
Executable file
Binary file not shown.
327
custom-install.py
Executable file
327
custom-install.py
Executable 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
259
finalize/Makefile
Normal 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
2
finalize/README.md
Normal file
@@ -0,0 +1,2 @@
|
||||
# custom-install-finalize
|
||||
Finishes the process after using custom-install.
|
||||
BIN
finalize/data/basetik.bin
Normal file
BIN
finalize/data/basetik.bin
Normal file
Binary file not shown.
173
finalize/source/main.c
Normal file
173
finalize/source/main.c
Normal 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
7
pyctr/README.md
Normal 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
6
pyctr/__init__.py
Normal 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
82
pyctr/common.py
Normal 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
598
pyctr/crypto.py
Normal 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
0
pyctr/types/__init__.py
Normal file
12
pyctr/types/base/title.py
Normal file
12
pyctr/types/base/title.py
Normal 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
237
pyctr/types/cia.py
Normal 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
308
pyctr/types/exefs.py
Normal 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
9
pyctr/types/extheader.py
Normal 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
521
pyctr/types/ncch.py
Normal 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
246
pyctr/types/romfs.py
Normal 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
111
pyctr/types/smdh.py
Normal 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
316
pyctr/types/tmd.py
Normal 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
41
pyctr/util.py
Normal 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'))
|
||||
Reference in New Issue
Block a user