├── pyctr ├── cmd │ ├── __init__.py │ ├── __main__.py │ └── checkenv.py ├── type │ ├── __init__.py │ ├── save │ │ ├── __init__.py │ │ ├── partdesc │ │ │ ├── __init__.py │ │ │ ├── common.py │ │ │ ├── difi.py │ │ │ └── dpfs.py │ │ ├── common.py │ │ ├── cmac.py │ │ ├── diff.py │ │ ├── partition.py │ │ └── disa.py │ ├── config │ │ ├── __init__.py │ │ └── blocks.py │ ├── base │ │ ├── __init__.py │ │ └── typereader.py │ ├── sdfs.py │ ├── sdtitle.py │ ├── cci.py │ ├── sd.py │ └── cdn.py ├── crypto │ ├── __init__.py │ └── seeddb.py ├── __init__.py ├── util.py └── common.py ├── .gitconfig ├── tests ├── romfs-test-dir │ ├── testdir │ │ └── emptyfile.bin │ ├── utf8.txt │ └── utf16.txt ├── 48x48.png ├── fixtures │ ├── icon.bin │ └── romfs.bin ├── README.md ├── test_smdh.py └── test_romfs.py ├── docs ├── requirements.txt ├── pyctr.common.rst ├── pyctr.type.cci.rst ├── pyctr.type.cdn.rst ├── pyctr.type.cia.rst ├── pyctr.type.tmd.rst ├── pyctr.type.ncch.rst ├── pyctr.type.sdtitle.rst ├── pyctr.type.save.cmac.rst ├── pyctr.type.save.diff.rst ├── pyctr.type.save.disa.rst ├── pyctr.type.config.save.rst ├── pyctr.type.save.common.rst ├── pyctr.type.config.blocks.rst ├── pyctr.type.save.partition.rst ├── pyctr.type.base.typereader.rst ├── pyctr.type.save.partdesc.difi.rst ├── pyctr.type.save.partdesc.dpfs.rst ├── pyctr.type.save.partdesc.ivfc.rst ├── pyctr.type.save.partdesc.common.rst ├── pyctr.type.base.rst ├── pyctr.crypto.rst ├── pyctr.type.config.rst ├── pyctr.rst ├── pyctr.type.save.partdesc.rst ├── pyctr.type.save.rst ├── pyctr.type.smdh.rst ├── Makefile ├── pyctr.type.rst ├── pyctr.util.rst ├── make.bat ├── pyctr.type.exefs.rst ├── pyctr.crypto.seeddb.rst ├── pyctr.fileio.rst ├── pyctr.type.romfs.rst ├── index.rst ├── conf.py ├── pyctr.type.sdfs.rst ├── example-nand.rst ├── example-cia.rst ├── pyctr.crypto.engine.rst ├── pyctr.type.sd.rst └── pyctr.type.nand.rst ├── .github └── FUNDING.yml ├── nix ├── pyfatfs-fix-deps.patch └── pyfatfs.nix ├── shell.nix ├── default.nix ├── .readthedocs.yml ├── flake.lock ├── CONTRIBUTING.md ├── package.nix ├── LICENSE ├── pyproject.toml ├── flake.nix ├── README.md ├── example ├── get-version-from-nand.py └── read-cia.py ├── .editorconfig └── .gitignore /pyctr/cmd/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pyctr/type/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pyctr/type/save/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitconfig: -------------------------------------------------------------------------------- 1 | *.bat text eol=crlf 2 | -------------------------------------------------------------------------------- /tests/romfs-test-dir/testdir/emptyfile.bin: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/romfs-test-dir/utf8.txt: -------------------------------------------------------------------------------- 1 | UTF-8 test: 2 | ニンテンドー3DS -------------------------------------------------------------------------------- /tests/48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihaveamac/pyctr/HEAD/tests/48x48.png -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | pycryptodomex 2 | Pillow 3 | fs 4 | pyfatfs 5 | sphinx-rtd-theme 6 | -------------------------------------------------------------------------------- /tests/fixtures/icon.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihaveamac/pyctr/HEAD/tests/fixtures/icon.bin -------------------------------------------------------------------------------- /tests/fixtures/romfs.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihaveamac/pyctr/HEAD/tests/fixtures/romfs.bin -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: ihaveahax 2 | patreon: ihaveahax 3 | custom: ["https://paypal.me/ihaveamac"] 4 | -------------------------------------------------------------------------------- /tests/romfs-test-dir/utf16.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihaveamac/pyctr/HEAD/tests/romfs-test-dir/utf16.txt -------------------------------------------------------------------------------- /pyctr/type/config/__init__.py: -------------------------------------------------------------------------------- 1 | from .blocks import ConfigSaveBlockParser 2 | from .save import ConfigSaveReader 3 | -------------------------------------------------------------------------------- /docs/pyctr.common.rst: -------------------------------------------------------------------------------- 1 | pyctr.common module 2 | =================== 3 | 4 | .. automodule:: pyctr.common 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/pyctr.type.cci.rst: -------------------------------------------------------------------------------- 1 | pyctr.type.cci module 2 | ===================== 3 | 4 | .. automodule:: pyctr.type.cci 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/pyctr.type.cdn.rst: -------------------------------------------------------------------------------- 1 | pyctr.type.cdn module 2 | ===================== 3 | 4 | .. automodule:: pyctr.type.cdn 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/pyctr.type.cia.rst: -------------------------------------------------------------------------------- 1 | pyctr.type.cia module 2 | ===================== 3 | 4 | .. automodule:: pyctr.type.cia 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/pyctr.type.tmd.rst: -------------------------------------------------------------------------------- 1 | pyctr.type.tmd module 2 | ===================== 3 | 4 | .. automodule:: pyctr.type.tmd 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/pyctr.type.ncch.rst: -------------------------------------------------------------------------------- 1 | pyctr.type.ncch module 2 | ====================== 3 | 4 | .. automodule:: pyctr.type.ncch 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/pyctr.type.sdtitle.rst: -------------------------------------------------------------------------------- 1 | pyctr.type.sdtitle module 2 | ========================= 3 | 4 | .. automodule:: pyctr.type.sdtitle 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/pyctr.type.save.cmac.rst: -------------------------------------------------------------------------------- 1 | pyctr.type.save.cmac module 2 | =========================== 3 | 4 | .. automodule:: pyctr.type.save.cmac 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/pyctr.type.save.diff.rst: -------------------------------------------------------------------------------- 1 | pyctr.type.save.diff module 2 | =========================== 3 | 4 | .. automodule:: pyctr.type.save.diff 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/pyctr.type.save.disa.rst: -------------------------------------------------------------------------------- 1 | pyctr.type.save.disa module 2 | =========================== 3 | 4 | .. automodule:: pyctr.type.save.disa 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/pyctr.type.config.save.rst: -------------------------------------------------------------------------------- 1 | pyctr.type.config.save module 2 | ============================= 3 | 4 | .. automodule:: pyctr.type.config.save 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/pyctr.type.save.common.rst: -------------------------------------------------------------------------------- 1 | pyctr.type.save.common module 2 | ============================= 3 | 4 | .. automodule:: pyctr.type.save.common 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/pyctr.type.config.blocks.rst: -------------------------------------------------------------------------------- 1 | pyctr.type.config.blocks module 2 | =============================== 3 | 4 | .. automodule:: pyctr.type.config.blocks 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/pyctr.type.save.partition.rst: -------------------------------------------------------------------------------- 1 | pyctr.type.save.partition module 2 | ================================ 3 | 4 | .. automodule:: pyctr.type.save.partition 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/pyctr.type.base.typereader.rst: -------------------------------------------------------------------------------- 1 | pyctr.type.base.typereader module 2 | ================================= 3 | 4 | .. automodule:: pyctr.type.base.typereader 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/pyctr.type.save.partdesc.difi.rst: -------------------------------------------------------------------------------- 1 | pyctr.type.save.partdesc.difi module 2 | ==================================== 3 | 4 | .. automodule:: pyctr.type.save.partdesc.difi 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/pyctr.type.save.partdesc.dpfs.rst: -------------------------------------------------------------------------------- 1 | pyctr.type.save.partdesc.dpfs module 2 | ==================================== 3 | 4 | .. automodule:: pyctr.type.save.partdesc.dpfs 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/pyctr.type.save.partdesc.ivfc.rst: -------------------------------------------------------------------------------- 1 | pyctr.type.save.partdesc.ivfc module 2 | ==================================== 3 | 4 | .. automodule:: pyctr.type.save.partdesc.ivfc 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/pyctr.type.save.partdesc.common.rst: -------------------------------------------------------------------------------- 1 | pyctr.type.save.partdesc.common module 2 | ====================================== 3 | 4 | .. automodule:: pyctr.type.save.partdesc.common 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /pyctr/crypto/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is a part of pyctr. 2 | # 3 | # Copyright (c) 2017-2023 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE in the root of this project. 6 | 7 | from .engine import * 8 | from .seeddb import * 9 | -------------------------------------------------------------------------------- /docs/pyctr.type.base.rst: -------------------------------------------------------------------------------- 1 | pyctr.type.base package 2 | ======================= 3 | 4 | Submodules 5 | ---------- 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | 10 | pyctr.type.base.typereader 11 | 12 | Module contents 13 | --------------- 14 | 15 | .. automodule:: pyctr.type.base 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | -------------------------------------------------------------------------------- /pyctr/type/base/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is a part of pyctr. 2 | # 3 | # Copyright (c) 2017-2023 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE in the root of this project. 6 | 7 | from .typereader import TypeReaderBase, TypeReaderCryptoBase, raise_if_closed, ReaderError, ReaderClosedError 8 | -------------------------------------------------------------------------------- /docs/pyctr.crypto.rst: -------------------------------------------------------------------------------- 1 | pyctr.crypto package 2 | ==================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | 10 | pyctr.crypto.engine 11 | pyctr.crypto.seeddb 12 | 13 | Module contents 14 | --------------- 15 | 16 | .. automodule:: pyctr.crypto 17 | :members: 18 | :undoc-members: 19 | :show-inheritance: 20 | -------------------------------------------------------------------------------- /nix/pyfatfs-fix-deps.patch: -------------------------------------------------------------------------------- 1 | diff --git a/pyproject.toml b/pyproject.toml 2 | index 9649c10..68c93d9 100644 3 | --- a/pyproject.toml 4 | +++ b/pyproject.toml 5 | @@ -1,5 +1,5 @@ 6 | [build-system] 7 | -requires = ["setuptools ~= 67.8", "setuptools_scm[toml] ~= 7.1"] 8 | +requires = ["setuptools", "setuptools_scm[toml]"] 9 | build-backend = "setuptools.build_meta" 10 | 11 | [project] 12 | -------------------------------------------------------------------------------- /docs/pyctr.type.config.rst: -------------------------------------------------------------------------------- 1 | pyctr.type.config package 2 | ========================= 3 | 4 | Submodules 5 | ---------- 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | 10 | pyctr.type.config.blocks 11 | pyctr.type.config.save 12 | 13 | Module contents 14 | --------------- 15 | 16 | .. automodule:: pyctr.type.config 17 | :members: 18 | :undoc-members: 19 | :show-inheritance: 20 | -------------------------------------------------------------------------------- /pyctr/type/save/partdesc/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is a part of pyctr. 2 | # 3 | # Copyright (c) 2017-2023 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE in the root of this project. 6 | 7 | from typing import TYPE_CHECKING 8 | 9 | from .difi import DIFI 10 | from .ivfc import IVFC 11 | from .dpfs import DPFS, DPFSLevel1, DPFSLevel2, DPFSLevel3, DPFSLevel3FileIO 12 | -------------------------------------------------------------------------------- /docs/pyctr.rst: -------------------------------------------------------------------------------- 1 | pyctr package 2 | ============= 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | 10 | pyctr.crypto 11 | pyctr.type 12 | 13 | Submodules 14 | ---------- 15 | 16 | .. toctree:: 17 | :maxdepth: 4 18 | 19 | pyctr.common 20 | pyctr.fileio 21 | pyctr.util 22 | 23 | Module contents 24 | --------------- 25 | 26 | .. automodule:: pyctr 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | -------------------------------------------------------------------------------- /docs/pyctr.type.save.partdesc.rst: -------------------------------------------------------------------------------- 1 | pyctr.type.save.partdesc package 2 | ================================ 3 | 4 | Submodules 5 | ---------- 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | 10 | pyctr.type.save.partdesc.common 11 | pyctr.type.save.partdesc.difi 12 | pyctr.type.save.partdesc.dpfs 13 | pyctr.type.save.partdesc.ivfc 14 | 15 | Module contents 16 | --------------- 17 | 18 | .. automodule:: pyctr.type.save.partdesc 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import {}, withPyctr ? false }: 2 | 3 | let 4 | pyctrPkgs = import ./default.nix { inherit pkgs; }; 5 | pyctr = pyctrPkgs.pyctr; 6 | pythonPackages = pkgs.python3Packages; 7 | in pkgs.mkShell { 8 | name = "pyctr-dev-shell"; 9 | 10 | packages = pyctr.propagatedBuildInputs ++ [ 11 | pythonPackages.pytest 12 | ] ++ (pkgs.lib.optional withPyctr pyctr); 13 | 14 | shellHook = '' 15 | # pytest seems to have issues without doing this 16 | PYTHONPATH=$PWD:$PYTHONPATH 17 | ''; 18 | } 19 | -------------------------------------------------------------------------------- /docs/pyctr.type.save.rst: -------------------------------------------------------------------------------- 1 | pyctr.type.save package 2 | ======================= 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | 10 | pyctr.type.save.partdesc 11 | 12 | Submodules 13 | ---------- 14 | 15 | .. toctree:: 16 | :maxdepth: 4 17 | 18 | pyctr.type.save.cmac 19 | pyctr.type.save.common 20 | pyctr.type.save.diff 21 | pyctr.type.save.disa 22 | pyctr.type.save.partition 23 | 24 | Module contents 25 | --------------- 26 | 27 | .. automodule:: pyctr.type.save 28 | :members: 29 | :undoc-members: 30 | :show-inheritance: 31 | -------------------------------------------------------------------------------- /pyctr/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is a part of pyctr. 2 | # 3 | # Copyright (c) 2017-2023 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE in the root of this project. 6 | 7 | from collections import namedtuple 8 | 9 | __author__ = 'ihaveamac' 10 | __copyright__ = 'Copyright (c) 2017-2023 Ian Burgwin' 11 | __license__ = 'MIT' 12 | 13 | VersionInfo = namedtuple('VersionInfo', 'major minor micro releaselevel serial') 14 | version_info = VersionInfo(major=0, minor=8, micro=0, releaselevel='dev', serial=0) 15 | __version__ = '0.8.0.dev0' 16 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import {} }: 2 | 3 | rec { 4 | pyfatfs = pkgs.python3Packages.callPackage ./nix/pyfatfs.nix { }; 5 | pyctr = pkgs.python3Packages.callPackage ./package.nix { pyfatfs = pyfatfs; }; 6 | # mainly useful for things like pycharm 7 | python-environment = pkgs.python3Packages.python.buildEnv.override { 8 | extraLibs = pyctr.propagatedBuildInputs ++ (with pkgs.python3Packages; [ pytest ]); 9 | ignoreCollisions = true; 10 | }; 11 | tester = pkgs.writeShellScriptBin "pyctr-tester" '' 12 | PYTHONPATH=$PWD:$PYTHONPATH ${python-environment}/bin/pytest ./tests 13 | ''; 14 | } 15 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.11" 13 | 14 | # Build documentation in the docs/ directory with Sphinx 15 | sphinx: 16 | configuration: docs/conf.py 17 | 18 | # We recommend specifying your dependencies to enable reproducible builds: 19 | # https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 20 | python: 21 | install: 22 | - requirements: docs/requirements.txt 23 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1763464769, 6 | "narHash": "sha256-AJHrsT7VoeQzErpBRlLJM1SODcaayp0joAoEA35yiwM=", 7 | "owner": "NixOS", 8 | "repo": "nixpkgs", 9 | "rev": "6f374686605df381de8541c072038472a5ea2e2d", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "NixOS", 14 | "ref": "nixpkgs-unstable", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs" 22 | } 23 | } 24 | }, 25 | "root": "root", 26 | "version": 7 27 | } 28 | -------------------------------------------------------------------------------- /docs/pyctr.type.smdh.rst: -------------------------------------------------------------------------------- 1 | :mod:`smdh` - SMDH icons 2 | ======================== 3 | 4 | .. py:module:: pyctr.type.smdh 5 | :synopsis: Parse SMDH icon data 6 | 7 | The :mod:`smdh` module enables reading SMDH icons, including converting the graphical icon data to a standard format, reading application titles, and settings. 8 | 9 | This module can use Pillow if it is installed, to provide icon data as :class:`PIL.Image.Image` objects. 10 | 11 | SMDH objects 12 | ------------ 13 | 14 | .. autoclass:: SMDH 15 | :members: 16 | :undoc-members: 17 | :show-inheritance: 18 | 19 | Exceptions 20 | ---------- 21 | 22 | .. autoexception:: SMDHError 23 | .. autoexception:: InvalidSMDHError 24 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /nix/pyfatfs.nix: -------------------------------------------------------------------------------- 1 | { lib, buildPythonPackage, pythonOlder, fetchPypi, fs, pip, setuptools, setuptools-scm }: 2 | 3 | buildPythonPackage rec { 4 | pname = "pyfatfs"; 5 | version = "1.1.0"; 6 | format = "pyproject"; 7 | 8 | disabled = pythonOlder "3.6"; 9 | 10 | src = fetchPypi { 11 | inherit pname version; 12 | hash = "sha256-lyXM0KTaHAnCc1irvxDwjAQ6yEIQr1doA+CH9RorMOA="; 13 | }; 14 | 15 | doCheck = false; 16 | 17 | patches = [ ./pyfatfs-fix-deps.patch ]; 18 | 19 | buildInputs = [ setuptools setuptools-scm ]; 20 | 21 | propagatedBuildInputs = [ 22 | fs 23 | setuptools 24 | setuptools-scm 25 | #pip 26 | ]; 27 | 28 | pythonImportsCheck = [ 29 | "pyfatfs" 30 | ]; 31 | } 32 | -------------------------------------------------------------------------------- /docs/pyctr.type.rst: -------------------------------------------------------------------------------- 1 | pyctr.type package 2 | ================== 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | 10 | pyctr.type.base 11 | pyctr.type.config 12 | pyctr.type.save 13 | 14 | Submodules 15 | ---------- 16 | 17 | .. toctree:: 18 | :maxdepth: 4 19 | 20 | pyctr.type.cci 21 | pyctr.type.cdn 22 | pyctr.type.cia 23 | pyctr.type.exefs 24 | pyctr.type.nand 25 | pyctr.type.ncch 26 | pyctr.type.romfs 27 | pyctr.type.sd 28 | pyctr.type.sdfs 29 | pyctr.type.sdtitle 30 | pyctr.type.smdh 31 | pyctr.type.tmd 32 | 33 | Module contents 34 | --------------- 35 | 36 | .. automodule:: pyctr.type 37 | :members: 38 | :undoc-members: 39 | :show-inheritance: 40 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## This is my personal project 2 | 3 | I make this project in my free time whenever I feel like it. I make no promises about reading issues or pull requests on a timely basis, or that I will fix certain issues or merge pull requests (soon or ever). 4 | 5 | If you are making a significant addition and you intend for it to be implemented in my repository, you should talk to me first, because putting it in my repo means I have to maintain it. Please keep in mind the above paragraph. Maybe keep your own fork if you need something. 6 | 7 | ## No AI-generated content 8 | 9 | Absolutely NO content generated by "artificial intelligence" for **__any reason whatsoever__** (including issues, pull requests, and code). You're wasting my time and yours. If you try to and I find it, I will delete it and likely block you. 10 | -------------------------------------------------------------------------------- /package.nix: -------------------------------------------------------------------------------- 1 | { 2 | lib, 3 | buildPythonPackage, 4 | pythonOlder, 5 | pycryptodomex, 6 | pillow, 7 | fs, 8 | pyfatfs, 9 | }: 10 | 11 | buildPythonPackage { 12 | pname = "pyctr"; 13 | version = "0.8-beta"; 14 | pyproject = true; 15 | 16 | disabled = pythonOlder "3.8"; 17 | 18 | src = builtins.path { 19 | path = ./.; 20 | name = "pyctr"; 21 | filter = 22 | path: type: 23 | !(builtins.elem (baseNameOf path) [ 24 | "build" 25 | "dist" 26 | "localtest" 27 | "__pycache__" 28 | "v" 29 | ".git" 30 | "_build" 31 | "pyctr.egg-info" 32 | ]); 33 | }; 34 | 35 | propagatedBuildInputs = [ 36 | pycryptodomex 37 | fs 38 | pyfatfs 39 | pillow 40 | ]; 41 | 42 | pythonImportsCheck = [ 43 | "pyctr" 44 | ]; 45 | } 46 | -------------------------------------------------------------------------------- /docs/pyctr.util.rst: -------------------------------------------------------------------------------- 1 | :mod:`util` - Utility functions 2 | =============================== 3 | 4 | .. module:: pyctr.util 5 | :synopsis: Utility functions for PyCTR 6 | 7 | The :mod:`util` module contains extra useful functions. 8 | 9 | .. autofunction:: readle 10 | .. autofunction:: readbe 11 | .. autofunction:: roundup 12 | 13 | .. py:data:: windows 14 | :type: bool 15 | 16 | If the current platform is Windows. 17 | 18 | .. py:data:: macos 19 | :type: bool 20 | 21 | If the current platform is macOS. 22 | 23 | .. py:data:: config_dirs 24 | :type: List[str] 25 | 26 | Data directories that should contain the ARM9 bootROM (boot9.bin), SeedDB (seeddb.bin), and other files. 27 | 28 | This includes ``~/.3ds`` and ``~/3ds``. On Windows this also includes ``%APPDATA%\3ds``. On macOS this also includes ``~/Library/Application Support/3ds``. 29 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.https://www.sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/pyctr.type.exefs.rst: -------------------------------------------------------------------------------- 1 | :mod:`exefs` - ExeFS reader 2 | =========================== 3 | 4 | .. py:module:: pyctr.type.exefs 5 | :synopsis: Read data from a Nintendo 3DS application ExeFS 6 | 7 | The :mod:`exefs` module enables reading application executable filesystems. 8 | 9 | ExeFSReader objects 10 | ------------------- 11 | 12 | .. autoclass:: ExeFSReader 13 | :members: open, decompress_code, icon, entries 14 | :undoc-members: 15 | :show-inheritance: 16 | 17 | Data classes 18 | ------------ 19 | 20 | .. autoclass:: ExeFSEntry 21 | 22 | Functions 23 | --------- 24 | 25 | .. autofunction:: decompress_code 26 | 27 | Decompress the given code. This is called by :meth:`ExeFSReader.decompress_code`, and you should probably use that instead if you are loading the code from an ExeFS. 28 | 29 | Exceptions 30 | ---------- 31 | 32 | .. autoexception:: ExeFSError 33 | .. autoexception:: ExeFSFileNotFoundError 34 | .. autoexception:: InvalidExeFSError 35 | .. autoexception:: ExeFSNameError 36 | .. autoexception:: BadOffsetError 37 | .. autoexception:: CodeDecompressionError 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-2023 Ian Burgwin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "pyctr" 7 | description = "Python library to parse several Nintendo 3DS files" 8 | authors = [ 9 | { name = "Ian Burgwin", email = "ian@ianburgwin.net" }, 10 | ] 11 | readme = "README.md" 12 | license = {text = "MIT"} 13 | dynamic = ["version"] 14 | requires-python = ">= 3.8" 15 | classifiers = [ 16 | "License :: OSI Approved :: MIT License", 17 | "Programming Language :: Python :: 3", 18 | "Programming Language :: Python :: 3.8", 19 | "Programming Language :: Python :: 3.9", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Programming Language :: Python :: 3.13", 24 | ] 25 | dependencies = [ 26 | "pycryptodomex>=3.9,<4", 27 | "fs>=2.4.0,<3.0.0", 28 | "pyfatfs>=1.0.5,<2", 29 | ] 30 | 31 | [project.optional-dependencies] 32 | images = ["Pillow>=8.2"] 33 | 34 | [project.scripts] 35 | pyctrcmd ="pyctr.cmd.__main__:main" 36 | 37 | [tool.setuptools.dynamic] 38 | version = {attr = "pyctr.__version__"} 39 | 40 | [tool.setuptools.packages] 41 | find = {namespaces = false} 42 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "pyctr"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 6 | }; 7 | 8 | outputs = 9 | inputs@{ self, nixpkgs }: 10 | let 11 | systems = [ 12 | "x86_64-linux" 13 | "i686-linux" 14 | "x86_64-darwin" 15 | "aarch64-darwin" 16 | "aarch64-linux" 17 | "armv6l-linux" 18 | "armv7l-linux" 19 | ]; 20 | forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f system); 21 | in 22 | { 23 | legacyPackages = forAllSystems ( 24 | system: 25 | (import ./default.nix { 26 | pkgs = import nixpkgs { inherit system; }; 27 | }) 28 | // { 29 | default = self.legacyPackages.${system}.pyctr; 30 | } 31 | ); 32 | packages = forAllSystems ( 33 | system: nixpkgs.lib.filterAttrs (_: v: nixpkgs.lib.isDerivation v) self.legacyPackages.${system} 34 | ); 35 | 36 | devShells = forAllSystems ( 37 | system: 38 | let 39 | pkgs = import nixpkgs { inherit system; }; 40 | in 41 | { 42 | default = pkgs.callPackage ./shell.nix { }; 43 | withPyctr = pkgs.callPackage ./shell.nix { withPyctr = true; }; 44 | } 45 | ); 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PyCTR 2 | Python library to interact with Nintendo 3DS files. 3 | 4 | The API is not yet stable. If you decide to use this, you should stick to a specific version on pypi, or store a copy locally, until it is stable. 5 | 6 | Documentation is being updated over time and is published on [Read the Docs](https://pyctr.readthedocs.io/en/latest/). Most classes and functions have docstrings. 7 | 8 | Support is provided on [GitHub Discussions](https://github.com/ihaveamac/pyctr/discussions) or Discord ([info](https://ihaveahax.net/view/Discord), [invite link](https://discord.gg/YVuFUrs)). 9 | 10 | ## Supported types 11 | * Application metadata and containers 12 | * CDN contents ("tmd" next to other contents) 13 | * CTR Cart Image (".3ds", ".cci") 14 | * CTR Importable Archive (".cia") 15 | * NCCH (".cxi", ".cfa", ".ncch", ".app") 16 | * Title Metadata ("*.tmd") 17 | * SMDH icon ("*.smdh", "icon.bin") 18 | * Application contents 19 | * Executable Filesystem (".exefs", "exefs.bin") 20 | * Read-only Filesystem (".romfs", "romfs.bin") 21 | * User files 22 | * NAND ("nand.bin") 23 | * SD card filesystem ("Nintendo 3DS" directory) 24 | * DISA (save) and DIFF (extdata) containers 25 | * NOT the Inner Fat yet! This is for the wrappers around them. 26 | 27 | ## License 28 | `pyctr` is under the MIT license. 29 | -------------------------------------------------------------------------------- /pyctr/util.py: -------------------------------------------------------------------------------- 1 | # This file is a part of pyctr. 2 | # 3 | # Copyright (c) 2017-2023 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE in the root of this project. 6 | 7 | import os 8 | from math import ceil 9 | from sys import platform 10 | from typing import TYPE_CHECKING 11 | 12 | if TYPE_CHECKING: 13 | from typing import List 14 | 15 | __all__ = ['windows', 'macos', 'readle', 'readbe', 'roundup', 'config_dirs'] 16 | 17 | windows = platform == 'win32' 18 | macos = platform == 'darwin' 19 | 20 | 21 | def readle(b: bytes) -> int: 22 | """Convert little-endian bytes to an int.""" 23 | return int.from_bytes(b, 'little') 24 | 25 | 26 | def readbe(b: bytes) -> int: 27 | """Convert big-endian bytes to an int.""" 28 | return int.from_bytes(b, 'big') 29 | 30 | 31 | def roundup(offset: int, alignment: int) -> int: 32 | """Round up a number to a provided alignment.""" 33 | return int(ceil(offset / alignment) * alignment) 34 | 35 | 36 | _home = os.path.expanduser('~') 37 | config_dirs: 'List[str]' = [os.path.join(_home, '.3ds'), os.path.join(_home, '3ds')] 38 | if windows: 39 | config_dirs.insert(0, os.path.join(os.environ.get('APPDATA'), '3ds')) 40 | elif macos: 41 | config_dirs.insert(0, os.path.join(_home, 'Library', 'Application Support', '3ds')) 42 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | ## Testing file sources 2 | 3 | ### romfs.bin 4 | ```bash 5 | 3dstool -cvtf romfs romfs.bin --romfs-dir romfs-test-dir 6 | ``` 7 | 8 | ### icon.bin 9 | ```bash 10 | bannertool makesmdh -i 48x48.png -o icon.bin \ 11 | -js "japanese short title" -jl "japanese long description" -jp "j publisher" \ 12 | -es "english short title" -el "english long description" -ep "e publisher" \ 13 | -fs "french short title" -fl "french long description" -fp "f publisher" \ 14 | -gs "german short title" -gl "german long description" -gp "g publisher" \ 15 | -is "italian short title" -il "italian long description" -ip "i publisher" \ 16 | -ss "spanish short title" -sl "spanish long description" -sp "s publisher" \ 17 | -scs "simplifiedchinese short title" -scl "simplifiedchinese long description" -scp "sc publisher" \ 18 | -ks "korean short title" -kl "korean long description" -kp "k publisher" \ 19 | -ds "dutch short title" -dl "dutch long description" -dp "d publisher" \ 20 | -ps "portuguese short title" -pl "portuguese long description" -pp "p publisher" \ 21 | -rs "russian short title" -rl "russian long description" -rp "r publisher" \ 22 | -tcs "traditionalchinese short title" -tcl "traditionalchinese long description" -tcp "tc publisher" \ 23 | -f visible,autoboot,allow3d,savedata,new3ds -r northamerica,japan,china -ev 1.2 \ 24 | -cer 10 -er 20 -ur 30 -pgr 40 -ppr 50 -pbr 60 -cr 70 -gr 80 -cgr 90 25 | ``` 26 | -------------------------------------------------------------------------------- /pyctr/cmd/__main__.py: -------------------------------------------------------------------------------- 1 | # This file is a part of pyctr. 2 | # 3 | # Copyright (c) 2017-2023 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE in the root of this project. 6 | 7 | from argparse import ArgumentParser 8 | from sys import argv, version as pyver 9 | from typing import TYPE_CHECKING 10 | 11 | from .. import __version__ 12 | from .checkenv import main as checkenv_main 13 | 14 | if TYPE_CHECKING: 15 | from typing import Optional 16 | from argparse import Namespace 17 | 18 | 19 | def print_version(detail: int): 20 | if detail == 1: 21 | print('pyctr ' + __version__) 22 | elif detail >= 2: 23 | pyver_short = pyver.split()[0] 24 | print('pyctr ' + __version__ + ' running on Python ' + pyver_short) 25 | 26 | 27 | def create_argparser(prog): 28 | p = ArgumentParser(prog=prog, description='Interact with Nintendo 3DS files') 29 | 30 | p.add_argument('--version', '-V', action='count', help='Print version') 31 | 32 | subparsers = p.add_subparsers(metavar='command') 33 | checkenv_sp = subparsers.add_parser('checkenv', help='check pyctr environment', description='check pyctr environment') 34 | checkenv_sp.set_defaults(func=checkenv_main) 35 | 36 | return p 37 | 38 | 39 | def main(args: 'Optional[list[str]]' = None): 40 | if not args: 41 | args = argv[1:] 42 | 43 | p = create_argparser('pyctr.cmd') 44 | a = p.parse_args(args=args) 45 | 46 | if a.version: 47 | print_version(a.version) 48 | return 49 | 50 | if 'func' not in a: 51 | p.print_help() 52 | return 53 | 54 | a.func(p, a) 55 | 56 | 57 | if __name__ == '__main__': 58 | main() 59 | -------------------------------------------------------------------------------- /pyctr/type/save/partdesc/common.py: -------------------------------------------------------------------------------- 1 | # This file is a part of pyctr. 2 | # 3 | # Copyright (c) 2017-2023 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE in the root of this project. 6 | 7 | from functools import wraps 8 | from typing import TYPE_CHECKING, NamedTuple 9 | 10 | from ....common import PyCTRError 11 | from ....util import readle, roundup 12 | 13 | if TYPE_CHECKING: 14 | from typing import BinaryIO 15 | 16 | 17 | class PartitionDescriptorError(PyCTRError): 18 | """Generic error for operations related to DIFI, IVFC, or DPFS.""" 19 | 20 | 21 | class InvalidHeaderError(PartitionDescriptorError): 22 | """The header is invalid.""" 23 | 24 | 25 | class InvalidHeaderLengthError(InvalidHeaderError): 26 | """Length of the header is invalid.""" 27 | 28 | 29 | class LevelData(NamedTuple): 30 | """Level data used by IVFC and DPFS.""" 31 | 32 | offset: int 33 | """Offset of the level.""" 34 | size: int 35 | """Size of the final level.""" 36 | block_size_log2: int 37 | """Block size in log2.""" 38 | block_size: int 39 | """Actual block size.""" 40 | 41 | 42 | def get_block_range(offset: int, size: int, block_size: int): 43 | starting_block = (roundup(offset - block_size + 1, block_size)) // block_size 44 | 45 | ending_block = max(((roundup(offset + size, block_size)) // block_size) - 1, starting_block) 46 | 47 | return starting_block, ending_block 48 | 49 | 50 | def read_le_u32_array(data: bytes): 51 | """Yields each little-endian u32 in a block of data.""" 52 | for o in range(0, len(data), 4): 53 | yield readle(data[o:o+4]) 54 | 55 | 56 | def _raise_if_level_closed(method): 57 | @wraps(method) 58 | def decorator(self: 'BinaryIO', *args, **kwargs): 59 | if self.closed: 60 | raise ValueError('I/O operation on closed file') 61 | return method(self, *args, **kwargs) 62 | return decorator 63 | -------------------------------------------------------------------------------- /docs/pyctr.crypto.seeddb.rst: -------------------------------------------------------------------------------- 1 | :mod:`seeddb` - SeedDB management 2 | ================================= 3 | 4 | .. py:module:: pyctr.crypto.seeddb 5 | :synopsis: Manage title encryption seeds 6 | 7 | The :mod:`seeddb` module handles seeds used for title encryption. This applies to digital games released after early 2015. Seeds were used to enable pre-purchasing and downloading titles before release without providing access to the actual contents before the release date. 8 | 9 | When a :class:`~pyctr.crypto.engine.CryptoEngine` object is initialized, by default it will attempt to load a ``seeddb.bin`` using the paths defined in :attr:`seeddb_paths`. 10 | 11 | File format 12 | ----------- 13 | 14 | The SeedDB file consists of a seed count, then each entry has a Title ID and its associated seed. 15 | 16 | .. list-table:: seeddb.bin file format 17 | :header-rows: 1 18 | 19 | * - Offset 20 | - Size 21 | - Data 22 | * - 0x0 23 | - 0x4 24 | - Entry count in little endian (C) 25 | * - 0x4 26 | - 0xC 27 | - Padding 28 | * - 0x10 29 | - (0x20 * C) 30 | - Entries 31 | 32 | .. list-table:: Entry 33 | :header-rows: 1 34 | 35 | * - Offset 36 | - Size 37 | - Data 38 | * - 0x0 39 | - 0x8 40 | - Title ID in little endian 41 | * - 0x8 42 | - 0x10 43 | - Seed 44 | * - 0x18 45 | - 0x8 46 | - Padding 47 | 48 | Functions 49 | --------- 50 | 51 | .. autofunction:: load_seeddb 52 | 53 | .. autofunction:: get_seed 54 | 55 | .. autofunction:: add_seed 56 | 57 | .. autofunction:: get_all_seeds 58 | 59 | .. autofunction:: save_seeddb 60 | 61 | Data 62 | ---- 63 | 64 | .. py:data:: seeddb_paths 65 | :type: Dict[int, bytes] 66 | 67 | The list of paths that :meth:`load_seeddb` will try to load from automatically. By default this is every path in :attr:`pyctr.util.config_dirs` with ``seeddb.bin``. If the environment variable ``SEEDDB_PATH`` is set, its value is put at the beginning of the list. 68 | -------------------------------------------------------------------------------- /docs/pyctr.fileio.rst: -------------------------------------------------------------------------------- 1 | :mod:`fileio` - Special files 2 | ============================= 3 | 4 | .. py:module:: pyctr.fileio 5 | :synopsis: Provides special file objects 6 | 7 | This module contains some special file objects. 8 | 9 | Classes 10 | ------- 11 | 12 | .. py:class:: pyctr.fileio.SubsectionIO(file, offset, size) 13 | 14 | Provides read-write access to a subsection of a file. Data written after the end is discarded. 15 | 16 | This class is thread-safe with other :class:`SubsectionIO` objects. A thread lock is stored for each base file, meaning two :class:`SubsectionIO` objects on one base file will use locks to prevent issues, while two with different base files can operate independently. 17 | 18 | However this cannot protect against threaded access from somewhere else. If another function seeks, reads, or writes data to the base file at the same time, it could interfere and read or write the wrong data. 19 | 20 | Available methods: ``close``, ``read``, ``seek``, ``tell``, ``write``, ``readable``, ``writable``, ``seekable``, ``flush``. 21 | 22 | :param file: Base file. 23 | :type file: :term:`binary file` 24 | :param offset: Offset of the section. 25 | :type offset: int 26 | :param size: Size of the section. 27 | :type size: int 28 | 29 | .. py:class:: pyctr.fileio.SplitFileMerger(files, read_only=True, closefds=False) 30 | 31 | Provides access to multiple file objects as one large file. 32 | 33 | This is not thread-safe with other :class:`SplitFileMerger` objects. 34 | 35 | .. note:: 36 | 37 | Writing is not implemented yet. 38 | 39 | Available methods: ``close``, ``read``, ``seek``, ``tell``, ``write``, ``readable``, ``writable``, ``seekable``. 40 | 41 | :param files: A list of tuples with binary files and size. 42 | :type files: Iterable[Tuple[(:term:`binary file`), int]] 43 | :param read_only: Disable writing. 44 | :type read_only: bool 45 | :param closefds: Close all file objects when this is closed. 46 | :type closefds: bool 47 | -------------------------------------------------------------------------------- /docs/pyctr.type.romfs.rst: -------------------------------------------------------------------------------- 1 | :mod:`romfs` - RomFS reader 2 | =========================== 3 | 4 | .. py:module:: pyctr.type.romfs 5 | :synopsis: Read data from a Nintendo 3DS application RomFS 6 | 7 | The :mod:`romfs` module enables reading application read-only filesystems. 8 | 9 | RomFSReader objects 10 | ------------------- 11 | 12 | .. autoclass:: RomFSReader 13 | :members: get_info_from_path 14 | :undoc-members: 15 | :show-inheritance: 16 | 17 | .. py:method:: open(path, mode='r', buffering=-1, encoding=None, errors=None, newline='', **options) 18 | 19 | Open a file for reading. 20 | 21 | .. warning:: 22 | 23 | By default, for compatibility reasons, this function works differently than the normal FS open method. 24 | Files are opened in binary mode by default and ``mode`` accepts an encoding. 25 | This can be toggled off when creating the RomFSReader by passing ``open_compatibility_mode=False``. 26 | This compatibility layer will be removed in a future release. 27 | 28 | :param path: Path to a file. 29 | :type path: str 30 | :param buffering: Buffering policy (-1 to use default buffering, 0 to disable buffering, 1 to select line buffering, of any positive integer to indicate a buffer size). 31 | :type buffering: int 32 | :param encoding: Encoding for text files (defaults to ``utf-8``) 33 | :type encoding: str 34 | :param errors: What to do with unicode decode errors (see ``codecs`` module for more information). 35 | :type errors: Optional[str] 36 | :param newline: Newline parameter. 37 | :type newline: str 38 | :return: A file-like object. 39 | :rtype: SubsectionIO 40 | 41 | Data classes 42 | ------------ 43 | 44 | .. autoclass:: RomFSDirectoryEntry 45 | .. autoclass:: RomFSFileEntry 46 | 47 | Exceptions 48 | ---------- 49 | 50 | .. autoexception:: RomFSError 51 | .. autoexception:: InvalidIVFCError 52 | .. autoexception:: InvalidRomFSHeaderError 53 | .. autoexception:: RomFSEntryError 54 | .. autoexception:: RomFSFileNotFoundError 55 | .. autoexception:: RomFSIsADirectoryError 56 | -------------------------------------------------------------------------------- /pyctr/type/save/partdesc/difi.py: -------------------------------------------------------------------------------- 1 | # This file is a part of pyctr. 2 | # 3 | # Copyright (c) 2017-2023 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE in the root of this project. 6 | 7 | from typing import NamedTuple 8 | 9 | from ....util import readle 10 | from .common import InvalidHeaderError, InvalidHeaderLengthError 11 | 12 | 13 | class DIFI(NamedTuple): 14 | ivfc_offset: int 15 | ivfc_size: int 16 | 17 | dpfs_offset: int 18 | dpfs_size: int 19 | 20 | part_hash_offset: int 21 | part_hash_size: int 22 | 23 | enable_external_ivfc_lv4: bool 24 | 25 | dpfs_tree_lv1_selector: int 26 | 27 | external_ivfc_lv4_offset: int 28 | 29 | @classmethod 30 | def from_bytes(cls, data: bytes): 31 | magic = data[0:8] 32 | if magic != b'DIFI\0\0\1\0': 33 | raise InvalidHeaderError(f'DIFI expected, got {data!r}') 34 | 35 | if len(data) != 0x44: 36 | raise InvalidHeaderLengthError(f'DIFI expected length 0x44, got {len(data):#x}') 37 | 38 | # noinspection PyArgumentList 39 | return cls(ivfc_offset=readle(data[0x8:0x10]), 40 | ivfc_size=readle(data[0x10:0x18]), 41 | dpfs_offset=readle(data[0x18:0x20]), 42 | dpfs_size=readle(data[0x20:0x28]), 43 | part_hash_offset=readle(data[0x28:0x30]), 44 | part_hash_size=readle(data[0x30:0x38]), 45 | enable_external_ivfc_lv4=bool(data[0x38]), 46 | dpfs_tree_lv1_selector=data[0x39], 47 | external_ivfc_lv4_offset=readle(data[0x3C:0x44])) 48 | 49 | def to_bytes(self): 50 | parts = [b'DIFI\0\0\1\0', 51 | self.ivfc_offset.to_bytes(8, 'little'), self.ivfc_size.to_bytes(8, 'little'), 52 | self.dpfs_offset.to_bytes(8, 'little'), self.dpfs_size.to_bytes(8, 'little'), 53 | self.part_hash_offset.to_bytes(8, 'little'), self.part_hash_size.to_bytes(8, 'little'), 54 | self.enable_external_ivfc_lv4.to_bytes(1, 'little'), self.dpfs_tree_lv1_selector.to_bytes(1, 'little'), 55 | b'\0\0', self.external_ivfc_lv4_offset.to_bytes(8, 'little')] 56 | 57 | return b''.join(parts) 58 | -------------------------------------------------------------------------------- /pyctr/cmd/checkenv.py: -------------------------------------------------------------------------------- 1 | # This file is a part of pyctr. 2 | # 3 | # Copyright (c) 2017-2023 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full licese text in LICENSE in the root of this project. 6 | 7 | from hashlib import sha256 8 | from os import environ 9 | from os.path import join, isfile 10 | from typing import TYPE_CHECKING 11 | 12 | from ..crypto.engine import BOOT9_PROT_HASH, b9_paths 13 | from ..crypto.seeddb import seeddb_paths 14 | from ..util import config_dirs 15 | 16 | if TYPE_CHECKING: 17 | from argparse import ArgumentParser, Namespace 18 | 19 | 20 | def find_boot9(): 21 | results = {} 22 | for p in b9_paths: 23 | result = {'type': 'unknown', 'valid': False} 24 | try: 25 | with open(p, 'rb') as f: 26 | data = f.read(0x10000) 27 | except FileNotFoundError: 28 | pass 29 | else: 30 | if len(data) == 0x10000: 31 | # trim full boot9 to just prot 32 | data = data[0x8000:] 33 | result['type'] = 'full' 34 | elif len(data) == 0x8000: 35 | result['type'] = 'prot' 36 | b9_sha = sha256(data) 37 | if b9_sha.hexdigest() == BOOT9_PROT_HASH: 38 | result['valid'] = True 39 | results[p] = result 40 | 41 | return results 42 | 43 | 44 | def find_seeddb(): 45 | found = [] 46 | for p in seeddb_paths: 47 | if isfile(p): 48 | found.append(p) 49 | 50 | return found 51 | 52 | 53 | def main(parser: 'ArgumentParser', args: 'Namespace'): 54 | b9_results = find_boot9() 55 | if b9_results: 56 | print('boot9 status:') 57 | for path, result in b9_results.items(): 58 | if result['valid'] is not None: 59 | print(f' - {path}: type: {result["type"]}, valid: {result["valid"]}') 60 | else: 61 | print('boot9 not found. Put it in one of these paths:') 62 | for p in b9_paths: 63 | print(' -', p) 64 | print(' - BOOT9_PATH (environment variable)') 65 | 66 | seeddb_results = find_seeddb() 67 | if seeddb_results: 68 | print('seeddb status:') 69 | for path in seeddb_results: 70 | print(' -', path) 71 | else: 72 | print('seeddb not found. Put it in one of these paths:') 73 | for p in seeddb_paths: 74 | print(' -', p) 75 | print(' - SEEDDB_PATH (environment variable)') 76 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. pyctr documentation master file, created by 2 | sphinx-quickstart on Wed Mar 16 02:56:34 2022. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to pyctr's documentation! 7 | ================================= 8 | 9 | PyCTR is a Python library to interact with Nintendo 3DS files. 10 | 11 | It can read data from different kinds of files: 12 | 13 | * CDN contents - :mod:`pyctr.type.cdn` 14 | * Game card dumps (CTR Cart Image/CCI) - :mod:`pyctr.type.cci` 15 | * CIA files (CTR Importable Archive) - :mod:`pyctr.type.cia` 16 | * ExeFS containers - :mod:`pyctr.type.exefs` 17 | * RomFS containers - :mod:`pyctr.type.romfs` 18 | * NCCH containers - :mod:`pyctr.type.ncch` 19 | * NAND backups - :mod:`pyctr.type.nand` 20 | * SD card files inside "Nintendo 3DS" - :mod:`pyctr.type.sd` 21 | * SD card titles - :mod:`pyctr.type.sdtitle` 22 | * SMDH icons - :mod:`pyctr.type.smdh` 23 | * Title Metadata files (TMD) - :mod:`pyctr.type.tmd` 24 | 25 | It can emulate cryptography features of the 3DS: 26 | 27 | * AES key engine and key scrambler - :mod:`pyctr.crypto.engine` 28 | * Seed database (SeedDB) - :mod:`pyctr.crypto.seeddb` 29 | 30 | Install 31 | ======= 32 | 33 | PyCTR requires Python 3.8 or later. 34 | 35 | It can be installed with ``pip``: 36 | 37 | .. code-block:: console 38 | 39 | $ pip install pyctr 40 | 41 | .. toctree:: 42 | :maxdepth: 1 43 | :caption: Getting started 44 | 45 | example-cia 46 | example-nand 47 | 48 | .. toctree:: 49 | :maxdepth: 1 50 | :caption: App containers 51 | 52 | pyctr.type.cdn 53 | pyctr.type.cia 54 | pyctr.type.cci 55 | pyctr.type.ncch 56 | pyctr.type.sdtitle 57 | 58 | .. toctree:: 59 | :maxdepth: 1 60 | :caption: App data 61 | 62 | pyctr.type.exefs 63 | pyctr.type.romfs 64 | 65 | .. toctree:: 66 | :maxdepth: 1 67 | :caption: App metadata 68 | 69 | pyctr.type.smdh 70 | pyctr.type.tmd 71 | 72 | .. toctree:: 73 | :maxdepth: 1 74 | :caption: Console data 75 | 76 | pyctr.type.nand 77 | pyctr.type.sdfs 78 | pyctr.type.sd 79 | 80 | .. toctree:: 81 | :maxdepth: 1 82 | :caption: Encryption 83 | 84 | pyctr.crypto.engine 85 | pyctr.crypto.seeddb 86 | 87 | .. toctree:: 88 | :maxdepth: 1 89 | :caption: Extras 90 | 91 | pyctr.util 92 | pyctr.fileio 93 | 94 | 95 | Indices and tables 96 | ================== 97 | 98 | * :ref:`genindex` 99 | * :ref:`modindex` 100 | * :ref:`search` 101 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | import os 17 | import sys 18 | sys.path.insert(0, os.path.abspath('..')) 19 | 20 | 21 | # -- Project information ----------------------------------------------------- 22 | 23 | project = 'pyctr' 24 | copyright = '2017-2023, ihaveahax' 25 | author = 'ihaveahax' 26 | 27 | # The full version, including alpha/beta/rc tags 28 | #release = '0.6.0' 29 | # noinspection PyUnresolvedReferences 30 | from pyctr import __version__ as release 31 | 32 | 33 | # -- General configuration --------------------------------------------------- 34 | 35 | # Add any Sphinx extension module names here, as strings. They can be 36 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 37 | # ones. 38 | extensions = [ 39 | 'sphinx.ext.autodoc', 40 | 'sphinx.ext.viewcode', 41 | 'sphinx.ext.intersphinx' 42 | ] 43 | 44 | # Add any paths that contain templates here, relative to this directory. 45 | templates_path = ['_templates'] 46 | 47 | # List of patterns, relative to source directory, that match files and 48 | # directories to ignore when looking for source files. 49 | # This pattern also affects html_static_path and html_extra_path. 50 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 51 | 52 | intersphinx_mapping = { 53 | 'python': ('https://docs.python.org/3', None), 54 | 'pyfatfs': ('https://pyfatfs.readthedocs.io/en/stable', None), 55 | 'fs': ('https://docs.pyfilesystem.org/en/stable', None), 56 | 'pycryptodome': ('https://www.pycryptodome.org', None), 57 | 'Pillow': ('https://pillow.readthedocs.io/en/stable', None), 58 | } 59 | 60 | autodoc_typehints = 'description' 61 | autodoc_member_order = 'bysource' 62 | 63 | 64 | # -- Options for HTML output ------------------------------------------------- 65 | 66 | # The theme to use for HTML and HTML Help pages. See the documentation for 67 | # a list of builtin themes. 68 | # 69 | html_theme = 'sphinx_rtd_theme' 70 | 71 | # Add any paths that contain custom static files (such as style sheets) here, 72 | # relative to this directory. They are copied after the builtin static files, 73 | # so a file named "default.css" will overwrite the builtin "default.css". 74 | html_static_path = ['_static'] 75 | -------------------------------------------------------------------------------- /tests/test_smdh.py: -------------------------------------------------------------------------------- 1 | # This file is a part of pyctr. 2 | # 3 | # Copyright (c) 2017-2023 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE in the root of this project. 6 | 7 | from os.path import dirname, join, realpath 8 | 9 | import pytest 10 | 11 | from pyctr.type import smdh 12 | 13 | 14 | def get_file_path(*parts: str): 15 | return join(dirname(realpath(__file__)), *parts) 16 | 17 | 18 | def open_smdh(): 19 | return smdh.SMDH.from_file(get_file_path('fixtures', 'icon.bin')) 20 | 21 | 22 | def test_no_file(): 23 | with pytest.raises(FileNotFoundError): 24 | smdh.SMDH.from_file('nonexistant.bin') 25 | 26 | 27 | smdh_params = ( 28 | ('Japanese', 'japanese short title', 'japanese long description', 'j publisher'), 29 | ('English', 'english short title', 'english long description', 'e publisher'), 30 | ('French', 'french short title', 'french long description', 'f publisher'), 31 | ('German', 'german short title', 'german long description', 'g publisher'), 32 | ('Italian', 'italian short title', 'italian long description', 'i publisher'), 33 | ('Spanish', 'spanish short title', 'spanish long description', 's publisher'), 34 | ('Simplified Chinese', 'simplifiedchinese short title', 'simplifiedchinese long description', 'sc publisher'), 35 | ('Korean', 'korean short title', 'korean long description', 'k publisher'), 36 | ('Dutch', 'dutch short title', 'dutch long description', 'd publisher'), 37 | ('Portuguese', 'portuguese short title', 'portuguese long description', 'p publisher'), 38 | ('Russian', 'russian short title', 'russian long description', 'r publisher'), 39 | ('Traditional Chinese', 'traditionalchinese short title', 'traditionalchinese long description', 'tc publisher'), 40 | ) 41 | 42 | 43 | @pytest.mark.parametrize('language,short,long,pub', smdh_params) 44 | def test_read_description(language, short, long, pub): 45 | reader = open_smdh() 46 | title = reader.get_app_title(language) 47 | assert isinstance(title, smdh.AppTitle) 48 | assert title.short_desc == short 49 | assert title.long_desc == long 50 | assert title.publisher == pub 51 | 52 | 53 | def test_flags(): 54 | reader = open_smdh() 55 | flags_expected = smdh.SMDHFlags(Visible=True, AutoBoot=True, Allow3D=True, SaveData=True, New3DS=True, 56 | RequireEULA=False, AutoSave=False, ExtendedBanner=False, RatingRequired=False, 57 | RecordUsage=False, NoSaveBackups=False) 58 | assert reader.flags == flags_expected 59 | 60 | 61 | def test_incorrect_header(): 62 | with pytest.raises(smdh.InvalidSMDHError) as excinfo: 63 | smdh.SMDH.from_file(get_file_path('fixtures', 'romfs.bin')) 64 | 65 | assert 'SMDH magic not found' in str(excinfo.value) 66 | -------------------------------------------------------------------------------- /pyctr/type/config/blocks.py: -------------------------------------------------------------------------------- 1 | # This file is a part of pyctr. 2 | # 3 | # Copyright (c) 2017-2023 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE in the root of this project. 6 | 7 | from enum import IntEnum 8 | from typing import TYPE_CHECKING 9 | 10 | from .save import BlockIDNotFoundError, ConfigSaveReader, KNOWN_BLOCKS 11 | 12 | if TYPE_CHECKING: 13 | from typing import BinaryIO, Union, Optional 14 | 15 | from fs.base import FS 16 | 17 | from ...common import FilePath 18 | 19 | 20 | class SystemModel(IntEnum): 21 | Old3DS = 0 22 | Old3DSXL = 1 23 | New3DS = 2 24 | Old2DS = 3 25 | New3DSXL = 4 26 | New2DSXL = 5 27 | 28 | 29 | class ConfigSaveBlockParser: 30 | """ 31 | Parses config blocks to provide easy access to information in the config save. 32 | 33 | https://www.3dbrew.org/wiki/Config_Savegame 34 | """ 35 | 36 | def __init__(self, save: 'ConfigSaveReader'): 37 | self.save = save 38 | 39 | @property 40 | def username(self) -> str: 41 | """ 42 | Profile username. Can be up to 10 characters long. 43 | 44 | Block ID: 0x000A0000 45 | """ 46 | 47 | username_raw = self.save.get_block(0x000A0000) 48 | username = username_raw.data.decode('utf-16le') 49 | # sometimes there seems to be garbage after the null terminator 50 | # so we can't just do a trim 51 | null_term_pos = username.find('\0') 52 | if null_term_pos >= 0: 53 | username = username[:null_term_pos] 54 | 55 | return username 56 | 57 | @username.setter 58 | def username(self, value: str): 59 | username_raw = value.encode('utf-16le').ljust(KNOWN_BLOCKS[0x000A0000]["size"], b'\0') 60 | self.save.set_block(0x000A0000, username_raw) 61 | 62 | @property 63 | def user_time_offset(self) -> int: 64 | """ 65 | The offset to the Raw RTC in milliseconds. 66 | 67 | Block ID: 0x00030001 68 | """ 69 | time_offset_raw = self.save.get_block(0x00030001) 70 | return int.from_bytes(time_offset_raw.data, 'little') 71 | 72 | @user_time_offset.setter 73 | def user_time_offset(self, value: int): 74 | time_offset_raw = value.to_bytes(KNOWN_BLOCKS[0x00030001]["size"], "little") 75 | self.save.set_block(0x00030001, time_offset_raw) 76 | 77 | @property 78 | def system_model(self) -> SystemModel: 79 | """ 80 | System model. 81 | 82 | Block ID: 0x000F0004 83 | """ 84 | 85 | system_model_raw = self.save.get_block(0x000F0004) 86 | return SystemModel(system_model_raw.data[0]) 87 | 88 | @system_model.setter 89 | def system_model(self, value: 'Union[int, SystemModel]'): 90 | # this field is actually 4 bytes 91 | # just in case, we'll preserve the next 3 bytes (their use is unknown) 92 | try: 93 | system_model_raw = bytearray(self.save.get_block(0x000F0004).data) 94 | except BlockIDNotFoundError: 95 | system_model_raw = bytearray(4) 96 | system_model_raw[0] = value 97 | self.save.set_block(0x000F0004, system_model_raw) 98 | 99 | @classmethod 100 | def load(cls, fp: 'BinaryIO'): 101 | return cls(ConfigSaveReader.load(fp)) 102 | 103 | @classmethod 104 | def from_file(cls, fn: 'FilePath', *, fs: 'Optional[FS]'): 105 | return cls(ConfigSaveReader.from_file(fn, fs=fs)) 106 | -------------------------------------------------------------------------------- /example/get-version-from-nand.py: -------------------------------------------------------------------------------- 1 | # This example demonstrates how to get the version (specifically, CVer and NVer) from a NAND backup. 2 | # No example file is provided here, you need to get your own NAND dump. 3 | 4 | # Import argv so we can specify our own NAND file as an argument. 5 | from sys import argv 6 | 7 | # Import the reader class for NAND files. 8 | from pyctr.type.nand import NAND 9 | 10 | # Import exceptions that may be raised when trying to search for tmds. 11 | from fs.errors import ResourceNotFound 12 | 13 | # Import the reader class for SD titles. Despite the name, this also works for titles on the NAND. 14 | from pyctr.type.sdtitle import SDTitleReader 15 | 16 | # This is all for type hints. This is to make it easier to understand what objects are being passed around. 17 | from typing import TYPE_CHECKING 18 | if TYPE_CHECKING: 19 | from typing import BinaryIO 20 | from fs.base import FS 21 | 22 | 23 | # This function will try to search multiple folders for a file that ends in *.tmd and get the first one. 24 | # This does not use glob because that is a lot slower than searching the directories ourselves. 25 | # Potential edge cases that you can solve: 26 | # * What if none is found? 27 | # * What if multiple tmds are found? This can happen if an update is pre-downloaded but not applied. 28 | def find_tmd(fs: 'FS', tid_high: 'str', tid_lows: 'list[str]'): 29 | for low in tid_lows: 30 | path = f'/title/{tid_high}/{low}/content' 31 | try: 32 | filelist = fs.scandir(path) 33 | except ResourceNotFound: 34 | continue 35 | else: 36 | for f in filelist: 37 | if f.name.endswith('.tmd'): 38 | return path + '/' + f.name 39 | else: 40 | return None 41 | 42 | 43 | # This reads version.bin, found in both CVer and NVer RomFS. 44 | # Both have the same format, but CVer has 4 values we care about, while NVer has 2 (first 2 we can ignore). 45 | # Version number is in order of build, minor, major. 46 | # To make this convenient for our code, we will reverse it to the expected order. 47 | def read_versionbin(fp: 'BinaryIO'): 48 | data = fp.read(5) 49 | return data[2], data[1], data[0], chr(data[4]) 50 | 51 | 52 | # Open the NAND for reading. 53 | print('Opening', argv[1]) 54 | with NAND(argv[1]) as nand: 55 | 56 | # Open the FAT16 filesystem in CTR NAND. 57 | print('Opening CTR NAND FAT16') 58 | ctrfat = nand.open_ctr_fat() 59 | 60 | # Try to find the CVer tmd file. 61 | # CVer is different for each region (6 possible titles). 62 | print('Attempting to find CVer tmd...') 63 | cver_lows = ['00017102', '00017202', '00017302', '00017402', '00017502', '00017602'] 64 | cver_tmd = find_tmd(ctrfat, '000400db', cver_lows) 65 | 66 | # Try to find the NVer tmd file. 67 | # NVer is different for each region and Old / New 3DS (10 possible titles). 68 | print('Attempting to find NVer tmd...') 69 | nver_lows_old = ['00016102', '00016202', '00016302', '00016402', '00016502', '00016602'] 70 | nver_lows_new = ['20016102', '20016202', '20016302', '20016502'] 71 | nver_tmd = find_tmd(ctrfat, '000400db', nver_lows_old + nver_lows_new) 72 | 73 | print('CVer tmd:', cver_tmd) 74 | print('NVer tmd:', nver_tmd) 75 | 76 | with SDTitleReader(cver_tmd, fs=ctrfat) as cver: 77 | with cver.contents[0].romfs.open('version.bin', 'rb') as f: 78 | cver_info = read_versionbin(f) 79 | 80 | with SDTitleReader(nver_tmd, fs=ctrfat) as nver: 81 | with nver.contents[0].romfs.open('version.bin', 'rb') as f: 82 | nver_info = read_versionbin(f) 83 | 84 | print('{0[0]}.{0[1]}.{0[2]}-{1[0]}{1[3]}'.format(cver_info, nver_info)) 85 | -------------------------------------------------------------------------------- /example/read-cia.py: -------------------------------------------------------------------------------- 1 | # This example demonstrates how to extract various kinds of data from a CTR Importable Archive (CIA) file. 2 | # The file used in this example is Checkpoint 3.7.4. The CIA file can be obtained here: 3 | # https://github.com/FlagBrew/Checkpoint/releases/tag/v3.7.4 4 | 5 | # Import the reader class for CIA files. 6 | from pyctr.type.cia import CIAReader, CIASection 7 | 8 | # Import the json library to parse an example json file. 9 | import json 10 | 11 | # Open the file for reading. This will parse the ticket, title metadata, and contents including NCCH, ExeFS, and RomFS. 12 | # If the file is encrypted and the appropriate keys are supplied, decryption is done on the fly. 13 | with CIAReader('Checkpoint.cia') as cia: 14 | 15 | # cia.tmd is a TitleMetadataReader object which parses Title metadata (TMD) data. 16 | # TMD format information: https://www.3dbrew.org/wiki/Title_metadata 17 | 18 | # This will print out the Title ID from the TMD. This is a 16-character hex string. 19 | # In the case of Checkpoint this will appear as '000400000bcfff00'. 20 | print('Title ID:', cia.tmd.title_id) 21 | 22 | # The title version is stored in a TitleVersion object. 23 | # This will display it as major.minor.micro. 24 | # This version of Checkpoint has the title version 3.7.4. 25 | print('Title Version:', '{0.major}.{0.minor}.{0.micro}'.format(cia.tmd.title_version)) 26 | 27 | # cia.contents is a dict with key as Content ID and value as an NCCHReader (if this is not a TWL/DSi title). 28 | # NCCH format information: https://www.3dbrew.org/wiki/NCCH 29 | 30 | # This title only has one content with an ID of 0, the application. This contains the executable code and 31 | # filesystem used by the title. 32 | # Other common sections for executable titles include 1 for manual and 2 for dlpchild. 33 | app = cia.contents[CIASection.Application] 34 | 35 | # app.exefs is an ExeFSReader object which parses Executable Filesystem (ExeFS) data. 36 | # ExeFS format information: https://www.3dbrew.org/wiki/ExeFS 37 | 38 | # app.exefs.icon is an SMDH object which contains information such as the application title and icon. 39 | # This exists if there is a valid icon file in the ExeFS. 40 | # SMDH format information: https://www.3dbrew.org/wiki/SMDH 41 | 42 | # This will get the English information from the SMDH and return it as an AppTitle object. 43 | app_title = app.exefs.icon.get_app_title('English') 44 | 45 | # This will print the short description (the applicaton name) for the application. 46 | # With the example CIA this would be "Checkpoint". 47 | print('Application Title:', app_title.short_desc) 48 | 49 | # This will print the long description for the application. 50 | # With the example CIA this would be "Fast and simple save manager". 51 | print('Application Description:', app_title.long_desc) 52 | 53 | # This will print the publisher for the application. 54 | # With the example CIA this would be "Bernardo Giordano, FlagBrew". 55 | print('Application Publisher:', app_title.publisher) 56 | 57 | # app.romfs is an RomFSReader object which accesses files in the Read-Only Filesystem (RomFS). 58 | # RomFS format information: https://www.3dbrew.org/wiki/RomFS 59 | 60 | # This will get information about the path '/' which is a directory. 61 | # This would return a RomFSDirectoryEntry. 62 | # In this example the directory contents are printed out, which will be "gfx, cheats, config.json, PKSM.smdh". 63 | print('Contents in the root:', ', '.join(app.romfs.get_info_from_path('/').contents)) 64 | 65 | # This will get information about the path '/config.json' which is a file. 66 | # This would return a RomFSFileEntry. 67 | # In this example the size is shown, which is 183 bytes. 68 | print('Size of /config.json in bytes:', app.romfs.get_info_from_path('/config.json').size) 69 | 70 | # This will parse the json file and print a value from it. 71 | # This demonstrates how to open files in the RomFS. The result is either a SubsectionIO object for binary, or 72 | # one wrapped with io.TextIOWrapper for text. 73 | # By default, files open in binary mode. Specifying the encoding argument will open in text mode. 74 | with app.romfs.open('/config.json', encoding='utf-8') as f: 75 | config = json.load(f) 76 | 77 | # Print the config version, which is 3 for this title version. 78 | print('Config version:', config['version']) 79 | -------------------------------------------------------------------------------- /pyctr/type/save/common.py: -------------------------------------------------------------------------------- 1 | # This file is a part of pyctr. 2 | # 3 | # Copyright (c) 2017-2023 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE in the root of this project. 6 | 7 | from threading import Lock 8 | from typing import TYPE_CHECKING 9 | 10 | from ...common import PyCTRError 11 | from ...fileio import SubsectionIO 12 | from ..base import TypeReaderCryptoBase 13 | from .partition import Partition, load_partdesc 14 | 15 | if TYPE_CHECKING: 16 | from typing import Dict, Literal, Optional 17 | 18 | from ...common import FilePath, FilePathOrObject 19 | from ...crypto import CryptoEngine 20 | from .cmac import CMACTypeBase 21 | 22 | ReadWriteBinaryFileModes = Literal['rb', 'br', 'rb+', 'br+', 'b+r', 'r+b', '+rb', '+br'] 23 | 24 | 25 | class PartitionContainerError(PyCTRError): 26 | """Generic error for partition container operations.""" 27 | 28 | 29 | class InvalidPartitionContainerError(PartitionContainerError): 30 | """There is an error with the header, such as a missing magic.""" 31 | 32 | 33 | class CorruptPartitionError(PartitionContainerError): 34 | """A hash somewhere in the header is incorrect.""" 35 | 36 | 37 | class PartitionContainerBase(TypeReaderCryptoBase): 38 | """ 39 | Base class for the DISA and DIFF classes. 40 | 41 | This object is not to be manually created. Please use the :class:`~.DISA` or :class:`~.DIFF` classes. 42 | 43 | :param file: A file path or a file-like object with the DISA or DIFF data. 44 | :param mode: Mode to open the file with, passed to `open`. Only used if a file path was given. 45 | :param closefd: Close the underlying file object when closed. Defaults to `True` for file paths, and `False` for 46 | file-like objects. 47 | :param crypto: A custom :class:`~.CryptoEngine` object to be used. Defaults to None, which causes a new one to 48 | be created. 49 | :param dev: Use devunit keys. 50 | :param cmac_base: A :class:`~.CMACTypeBase` object that describes how to update the CMAC. 51 | :param sd_key_file: Path to a movable.sed file to load the SD KeyY from. 52 | :param sd_key: SD KeyY to use. Has priority over `sd_key_file` if both are specified. 53 | """ 54 | 55 | partitions: 'Dict[int, Partition]' 56 | """Partitions of the file. Only 0 exists for DIFF, while 0 and 1 can exist with DISA.""" 57 | 58 | _header: bytes 59 | """Raw header for CMAC generation.""" 60 | 61 | def __init__(self, file: 'FilePathOrObject', mode: 'ReadWriteBinaryFileModes' = 'rb', *, 62 | fs: 'Optional[FS]' = None, closefd: 'Optional[bool]' = None, crypto: 'CryptoEngine' = None, 63 | dev: bool = False, cmac_base: 'CMACTypeBase' = None, sd_key_file: 'FilePath' = None, 64 | sd_key: bytes = None): 65 | super().__init__(file, fs=fs, closefd=closefd, mode=mode, crypto=crypto, dev=dev) 66 | 67 | self.cmac = self._file.read(0x10) 68 | 69 | if sd_key: 70 | self._crypto.setup_sd_key(sd_key) 71 | elif sd_key_file: 72 | self._crypto.setup_sd_key_from_file(sd_key_file) 73 | 74 | self._cmac_base = cmac_base 75 | if self._cmac_base: 76 | self._cmac_base.set_crypto(self._crypto) 77 | 78 | self._lock = Lock() 79 | 80 | self.partitions = {} 81 | 82 | def _load_partition(self, index: int, partdesc: bytes, partition_offset: int, partition_size: int): 83 | subfile = SubsectionIO(self._file, partition_offset, partition_size) 84 | 85 | difi, ivfc, dpfs, master_hash = load_partdesc(partdesc) 86 | 87 | def callback(new_partdesc: bytes): 88 | return self._update_hashes(index, new_partdesc) 89 | 90 | partition = Partition(subfile, difi, ivfc, dpfs, master_hash, update_partdesc_callback=callback, 91 | partdesc_size=len(partdesc)) 92 | 93 | self.partitions[index] = partition 94 | 95 | def _update_hashes(self, index: int, partdesc: bytes): 96 | """Dummy function since DISA and DIFF should be defining this.""" 97 | raise NotImplementedError 98 | 99 | def _update_cmac(self): 100 | """Update the CMAC of the file. Both DISA and DIFF put this at offset 0.""" 101 | if self._cmac_base: 102 | self.cmac = self._cmac_base.generate_cmac(self._header) 103 | self._seek(0) 104 | self._file.write(self.cmac) 105 | -------------------------------------------------------------------------------- /pyctr/type/save/cmac.py: -------------------------------------------------------------------------------- 1 | # This file is a part of pyctr. 2 | # 3 | # Copyright (c) 2017-2023 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE in the root of this project. 6 | 7 | from hashlib import sha256 8 | from typing import TYPE_CHECKING 9 | 10 | from ...common import PyCTRError 11 | from ...crypto import Keyslot 12 | 13 | if TYPE_CHECKING: 14 | from typing import List 15 | 16 | from ...crypto import CryptoEngine 17 | 18 | 19 | class CMACError(PyCTRError): 20 | """Generic error for CMAC operations.""" 21 | 22 | 23 | class InvalidDataError(CMACError): 24 | """Not all the data was provided in the correct form.""" 25 | 26 | 27 | def disa_to_sav0_digest(disa: bytes): 28 | if len(disa) != 0x100: 29 | raise InvalidDataError(f'DISA header is not 0x100 bytes, got {hex(len(disa))}') 30 | 31 | if disa[0:4] != b'DISA': 32 | raise InvalidDataError(f'DISA magic not found, got {disa[0:4]}') 33 | 34 | cmac_data = [b'CTR-SAV0', disa] 35 | return sha256(b''.join(cmac_data)).digest() 36 | 37 | 38 | class CMACTypeBase: 39 | """ 40 | Base class for AES-CMAC types. 41 | """ 42 | 43 | def __init__(self, magic: bytes, keyslot: 'Keyslot', *, crypto: 'CryptoEngine' = None): 44 | self.magic = magic 45 | self.keyslot = keyslot 46 | self.crypto = crypto 47 | 48 | def set_crypto(self, crypto: 'CryptoEngine'): 49 | if not self.crypto: 50 | self.crypto = crypto 51 | 52 | def generate_cmac(self, header: bytes): 53 | raise NotImplementedError 54 | 55 | def _gen_cmac_internal(self, data: 'List[bytes]'): 56 | all_data = [self.magic] + data 57 | cipher = self.crypto.create_cmac_object(self.keyslot) 58 | cipher.update(sha256(b''.join(all_data)).digest()) 59 | return cipher.digest() 60 | 61 | 62 | class CTR_NOR0(CMACTypeBase): 63 | """ 64 | Used for gamecard saves. 65 | 66 | This isn't well tested since I don't have much experience with gamecard saves. 67 | """ 68 | 69 | def __init__(self, new3ds: bool = False, *, crypto: 'CryptoEngine' = None): 70 | super().__init__(b'CTR-NOR0', Keyslot.CMACCardSaveNew if new3ds else Keyslot.CMACCardSave, crypto=crypto) 71 | 72 | def generate_cmac(self, disa: bytes): 73 | return self._gen_cmac_internal([disa_to_sav0_digest(disa)]) 74 | 75 | 76 | class CTR_SIGN(CMACTypeBase): 77 | """Used for SD savegames.""" 78 | 79 | def __init__(self, title_id: bytes, *, crypto: 'CryptoEngine' = None): 80 | super().__init__(b'CTR-SIGN', Keyslot.CMACSDNAND, crypto=crypto) 81 | self.title_id = title_id 82 | 83 | def generate_cmac(self, disa: bytes): 84 | return self._gen_cmac_internal([self.title_id, disa_to_sav0_digest(disa)]) 85 | 86 | 87 | class CTR_SYS0(CMACTypeBase): 88 | """Used for system savedata.""" 89 | 90 | def __init__(self, save_id: bytes, *, crypto: 'CryptoEngine' = None): 91 | super().__init__(b'CTR-SYS0', Keyslot.CMACSDNAND, crypto=crypto) 92 | self.save_id = save_id 93 | 94 | def generate_cmac(self, disa: bytes): 95 | return self._gen_cmac_internal([self.save_id, disa]) 96 | 97 | 98 | class CTR_EXT0(CMACTypeBase): 99 | """Used for extdata.""" 100 | 101 | def __init__(self, extdata_id: bytes, is_quota: bool, device_file_name_id: int = 0, 102 | device_directory_name_id: int = 0, *, crypto: 'CryptoEngine' = None): 103 | super().__init__(b'CTR-EXT0', Keyslot.CMACSDNAND, crypto=crypto) 104 | self.extdata_id = extdata_id 105 | self.is_quota = is_quota.to_bytes(4, 'little') 106 | self.device_file_name_id = device_file_name_id.to_bytes(4, 'little') 107 | self.device_directory_name_id = device_directory_name_id.to_bytes(4, 'little') 108 | 109 | def generate_cmac(self, diff: bytes): 110 | return self._gen_cmac_internal([self.extdata_id, self.is_quota, self.device_file_name_id, 111 | self.device_directory_name_id, diff]) 112 | 113 | 114 | class CTR_9DB0(CMACTypeBase): 115 | """Used for title databases.""" 116 | 117 | def __init__(self, database_id: int, is_nand: bool, *, crypto: 'CryptoEngine' = None): 118 | super().__init__(b'CTR-9DB0', Keyslot.CMACNANDDB if is_nand else Keyslot.CMACSDNAND, crypto=crypto) 119 | self.database_id = database_id.to_bytes(4, 'little') 120 | 121 | def generate_cmac(self, diff: bytes): 122 | return self._gen_cmac_internal([self.database_id, diff]) 123 | -------------------------------------------------------------------------------- /pyctr/type/save/diff.py: -------------------------------------------------------------------------------- 1 | # This file is a part of pyctr. 2 | # 3 | # Copyright (c) 2017-2023 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE in the root of this project. 6 | 7 | from hashlib import sha256 8 | from typing import TYPE_CHECKING 9 | 10 | from ...util import readle 11 | from .common import PartitionContainerBase, CorruptPartitionError, InvalidPartitionContainerError 12 | 13 | if TYPE_CHECKING: 14 | from typing import Dict, Optional 15 | 16 | from fs.base import FS 17 | 18 | from ...crypto import CryptoEngine 19 | from .cmac import CMACTypeBase 20 | from .common import ReadWriteBinaryFileModes, Partition 21 | from ...common import FilePath, FilePathOrObject 22 | 23 | 24 | class DIFF(PartitionContainerBase): 25 | """ 26 | Reads and writes to DIFF files. 27 | 28 | :param file: A file path or a file-like object with the DIFF data. 29 | :param mode: Mode to open the file with, passed to `open`. Only used if a file path was given. 30 | :param closefd: Close the underlying file object when closed. Defaults to `True` for file paths, and `False` for 31 | file-like objects. 32 | :param crypto: A custom :class:`~.CryptoEngine` object to be used. Defaults to None, which causes a new one to 33 | be created. 34 | :param dev: Use devunit keys. 35 | :param cmac_base: A :class:`~.CMACTypeBase` object that describes how to update the CMAC. 36 | :param sd_key_file: Path to a movable.sed file to load the SD KeyY from. 37 | :param sd_key: SD KeyY to use. Has priority over `sd_key_file` if both are specified. 38 | """ 39 | 40 | partitions: 'Dict[int, Partition]' 41 | """Partitions of the file. DIFF only has one, so there would only be a single `0` key.""" 42 | 43 | def __init__(self, file: 'FilePathOrObject', mode: 'ReadWriteBinaryFileModes' = 'rb', *, 44 | fs: 'Optional[FS]' = None, closefd: 'Optional[bool]' = None, crypto: 'CryptoEngine' = None, 45 | dev: bool = False, cmac_base: 'CMACTypeBase' = None, sd_key_file: 'FilePath' = None, 46 | sd_key: bytes = None): 47 | super().__init__(file, fs=fs, closefd=closefd, crypto=crypto, dev=dev, mode=mode, cmac_base=cmac_base, 48 | sd_key_file=sd_key_file, sd_key=sd_key) 49 | 50 | self._file.seek(0xF0, 1) 51 | self._header = self._file.read(0x100) 52 | 53 | magic = self._header[0:8] 54 | if magic != b'DIFF\0\0\3\0': 55 | raise InvalidPartitionContainerError(f'DISA magic expected, got {magic}') 56 | 57 | secondary_partdesc_offset = readle(self._header[0x8:0x10]) 58 | primary_partdesc_offset = readle(self._header[0x10:0x18]) 59 | partdesc_size = readle(self._header[0x18:0x20]) 60 | 61 | partition_a_offset = readle(self._header[0x20:0x28]) 62 | partition_a_size = readle(self._header[0x28:0x30]) 63 | 64 | active_partdesc = readle(self._header[0x30:0x34]) 65 | 66 | active_partdesc_hash = self._header[0x34:0x54] 67 | 68 | if active_partdesc == 0: 69 | self._partdesc_offset = primary_partdesc_offset 70 | else: 71 | self._partdesc_offset = secondary_partdesc_offset 72 | 73 | self.unique_identifier = readle(self._header[0x54:0x5C]) 74 | 75 | self._seek(self._partdesc_offset) 76 | partdesc = self._file.read(partdesc_size) 77 | if sha256(partdesc).digest() != active_partdesc_hash: 78 | raise CorruptPartitionError('Active partition table is corrupt') 79 | 80 | self._load_partition(0, partdesc, partition_a_offset, partition_a_size) 81 | 82 | def _update_hashes(self, partition: int, partdesc: bytes): 83 | """ 84 | Update master hashes, partition descriptor hash, and CMAC. 85 | 86 | :param partition: Unused for DIFF. This exists for consistency with DISA. 87 | :param partdesc: Partition descriptor in bytes. 88 | """ 89 | 90 | if self._file.writable(): 91 | with self._lock: 92 | self._seek(self._partdesc_offset) 93 | self._file.write(partdesc) 94 | 95 | partdesc_hash = sha256(partdesc) 96 | 97 | header_ba = bytearray(self._header) 98 | header_ba[0x34:0x54] = partdesc_hash.digest() 99 | self._header = bytes(header_ba) 100 | 101 | self._seek(0x100) 102 | self._file.write(self._header) 103 | 104 | self._update_cmac() 105 | -------------------------------------------------------------------------------- /pyctr/type/save/partition.py: -------------------------------------------------------------------------------- 1 | # This file is a part of pyctr. 2 | # 3 | # Copyright (c) 2017-2023 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE in the root of this project. 6 | 7 | from typing import TYPE_CHECKING 8 | 9 | from ...fileio import SubsectionIO 10 | from .partdesc.difi import DIFI 11 | from .partdesc.dpfs import DPFS, DPFSLevel1, DPFSLevel2, DPFSLevel3, DPFSLevel3FileIO 12 | from .partdesc.ivfc import IVFC, IVFCHashTree 13 | 14 | if TYPE_CHECKING: 15 | from typing import BinaryIO, Callable, List 16 | 17 | 18 | def load_partdesc(partdesc: bytes): 19 | """ 20 | Load a partition descriptor. 21 | 22 | :param partdesc: The partition descriptor. The first 0x44 must be a DIFI header. The rest is determined by the DIFI. 23 | """ 24 | 25 | difi = DIFI.from_bytes(partdesc[0:0x44]) 26 | ivfc = IVFC.from_bytes(partdesc[difi.ivfc_offset:difi.ivfc_offset + difi.ivfc_size]) 27 | dpfs = DPFS.from_bytes(partdesc[difi.dpfs_offset:difi.dpfs_offset + difi.dpfs_size]) 28 | base_master_hash = partdesc[difi.part_hash_offset:difi.part_hash_offset + difi.part_hash_size] 29 | master_hashes: List[bytes] = [base_master_hash[x:x + 0x20] for x in range(0, difi.part_hash_size, 0x20)] 30 | return difi, ivfc, dpfs, master_hashes 31 | 32 | 33 | def partdesc_to_bytes(difi, ivfc, dpfs, master_hashes, size): 34 | partdesc = bytearray(size) 35 | 36 | difi_bytes = difi.to_bytes() 37 | ivfc_bytes = ivfc.to_bytes() 38 | dpfs_bytes = dpfs.to_bytes() 39 | master_hashes_bytes = b''.join(master_hashes) 40 | 41 | partdesc[0:len(difi_bytes)] = difi_bytes 42 | partdesc[difi.ivfc_offset:difi.ivfc_offset + len(ivfc_bytes)] = ivfc_bytes 43 | partdesc[difi.dpfs_offset:difi.dpfs_offset + len(dpfs_bytes)] = dpfs_bytes 44 | partdesc[difi.part_hash_offset:difi.part_hash_offset + len(master_hashes_bytes)] = master_hashes_bytes 45 | 46 | return bytes(partdesc) 47 | 48 | 49 | class Partition: 50 | """ 51 | Reads a partition found within DISA and DIFF files. 52 | 53 | :param fp: A file-like object with the partition data. 54 | :param difi: DIFI header from the partition descriptor. 55 | :param ivfc: IVFC descriptor from the partition descriptor. 56 | :param dpfs: DPFS descriptor from the partition descriptor. 57 | :param master_hashes: A list of SHA-256 hashes over IVFC Level 1. 58 | """ 59 | 60 | def __init__(self, fp: 'BinaryIO', difi: 'DIFI', ivfc: 'IVFC', dpfs: 'DPFS', master_hashes: 'List[bytes]', *, 61 | update_partdesc_callback: 'Callable[[bytes], None]' = None, partdesc_size: int = None): 62 | self._fp = fp 63 | self.difi = difi 64 | self.ivfc = ivfc 65 | self.dpfs = dpfs 66 | self.master_hashes = master_hashes 67 | 68 | if update_partdesc_callback: 69 | self._update_partdesc_callback = update_partdesc_callback 70 | else: 71 | self._update_partdesc_callback = lambda x: None 72 | 73 | if partdesc_size: 74 | self._partdesc_size = partdesc_size 75 | else: 76 | # if partdesc size isn't specified, let's assume what it usually is 77 | # the numbers are the sizes for DIFI, IVFC, and DPFS. 78 | self._partdesc_size = 0x44 + 0x78 + 0x40 + (0x20 * len(self.master_hashes)) 79 | 80 | self._fp.seek(dpfs.lv1.offset) 81 | dpfs_lv1 = DPFSLevel1(self._fp.read(dpfs.lv1.size * 2), tree_selector=difi.dpfs_tree_lv1_selector) 82 | self._fp.seek(dpfs.lv2.offset) 83 | dpfs_lv2 = DPFSLevel2(self._fp.read(dpfs.lv2.size * 2), dpfs.lv2.block_size, dpfs_lv1) 84 | dpfs_lv3_base_file = SubsectionIO(self._fp, dpfs.lv3.offset, dpfs.lv3.size * 2) 85 | dpfs_lv3 = DPFSLevel3(dpfs_lv3_base_file, dpfs.lv3.size, dpfs.lv3.block_size, dpfs_lv2) 86 | 87 | self.dpfs_lv3_file = DPFSLevel3FileIO(dpfs_lv3) 88 | 89 | if difi.enable_external_ivfc_lv4: 90 | lv4_fp = SubsectionIO(self._fp, difi.external_ivfc_lv4_offset, ivfc.lv4.size) 91 | else: 92 | lv4_fp = None 93 | self.ivfc_hash_tree = IVFCHashTree(self.dpfs_lv3_file, self.ivfc, self.master_hashes, lv4_fp=lv4_fp, 94 | update_master_hashes_callback=self._update_hashes) 95 | 96 | def _update_hashes(self, master_hashes: 'List[bytes]'): 97 | self.master_hashes = master_hashes 98 | 99 | partdesc = partdesc_to_bytes(self.difi, self.ivfc, self.dpfs, self.master_hashes, self._partdesc_size) 100 | self._update_partdesc_callback(partdesc) 101 | -------------------------------------------------------------------------------- /docs/pyctr.type.sdfs.rst: -------------------------------------------------------------------------------- 1 | :mod:`sdfs` - SD card filesystem 2 | ================================ 3 | 4 | .. py:module:: pyctr.type.sdfs 5 | :synopsis: Read and write Nintendo 3DS SD card encrypted digital content 6 | 7 | The :mod:`sd` module enables reading and writing of Nintendo 3DS SD card encrypted digital content. This is the "Nintendo 3DS" folder on an SD card and includes application data, save data, and extdata. 8 | 9 | Directory hierarchy 10 | ------------------- 11 | 12 | * Nintendo 3DS 13 | 14 | * 15 | 16 | * 17 | 18 | * backup 19 | 20 | * dbs 21 | 22 | * extdata 23 | 24 | * title 25 | 26 | * Nintendo DSiWare 27 | 28 | Getting started 29 | --------------- 30 | 31 | There are two or three steps to get access to the filesystem inside id1. First you create an :class:`SDRoot` object pointing at a "Nintendo 3DS" folder. Then, if you wish, you can select an id1 directory to use. Then use :meth:`~SDRoot.open_id1` to open the filesystem it and receive an :class:`SDFS` object. 32 | 33 | .. code-block:: python 34 | 35 | from pyctr.type.sdfs import SDRoot, SDFS 36 | 37 | root = SDRoot('/Volumes/GM9SD/Nintendo 3DS', 38 | sd_key_file='movable.sed') 39 | # at this point check root.id1s if you wish, and then pass it to open_id1 40 | # or don't, and it will select the first one it finds 41 | fs = root.open_id1() 42 | with fs.open('/dbs/title.db') as f: 43 | f.read() 44 | 45 | You can also use the :class:`SDRoot` to open titles. It also accepts an id1 or will use the first one by default. 46 | 47 | .. code-block:: python 48 | 49 | title = sd.open_title('0004000000169800') 50 | with title.contents[0].romfs.open('/file.bin') as f: 51 | f.read() 52 | 53 | .. note:: 54 | Files cannot be moved or renamed because the encryption depends on the complete filepath from the start of the ID1 folder. If a file must be moved or renamed, it must be copied to the new location. 55 | 56 | SDRoot objects 57 | -------------- 58 | 59 | .. py:class:: SDRoot(path, *, crypto=None, dev=False, sd_key_file=None, sd_key=None) 60 | 61 | Opens an ID0 folder inside a "Nintendo 3DS" folder. 62 | 63 | :param path: Path to the Nintendo 3DS folder. 64 | :param crypto: A custom :class:`crypto.CryptoEngine` object to be used. Defaults to None, which causes a new one to 65 | be created. 66 | :param dev: Use devunit keys. 67 | :param sd_key_file: Path to a movable.sed file to load the SD KeyY from. 68 | :param sd_key: SD KeyY to use. Has priority over `sd_key_file` if both are specified. 69 | 70 | .. py:method:: open_id1(id1=None) 71 | 72 | Opens the filesystem inside an ID1 directory. 73 | 74 | if no ID1 is specified, the first one in :attr:`id1s` is used. 75 | 76 | :param id1: ID1 directory to use. 77 | :type id1: Optional[str] 78 | :return: SD filesystem. 79 | :rtype: SDFS 80 | :raises fs.errors.ResourceNotFound: If the ID1 directory doesn't exist. 81 | 82 | .. py:method:: open_title(title_id, *, case_insensitive=False, seed=None, load_contents=True) 83 | 84 | Open a title's contents for reading. 85 | 86 | In the case where a title's directory has multiple tmd files, the one with the smallest number in the filename is used. 87 | 88 | :param title_id: Title ID to open. 89 | :type title_id: str 90 | :param case_insensitive: Use case-insensitive paths for the RomFS of each NCCH container. 91 | :type case_insensitive: bool 92 | :param seed: Seed to use. This is a quick way to add a seed using :func:`~.seeddb.add_seed`. 93 | :type seed: bytes 94 | :param load_contents: Load each partition with :class:`~.NCCHReader`. 95 | :type load_contents: bool 96 | :rtype: ~pyctr.type.sdtitle.SDTitleReader 97 | :raises MissingTitleError: If the title could not be found. 98 | 99 | SDFS objects 100 | ------------ 101 | 102 | These are created by :class:`SDRoot` and usually shouldn't be created manually. 103 | 104 | These inherit :class:`fs.base.FS` and so generally the same methods work. 105 | 106 | .. py:class:: SDFS(parent_fs, path, *, crypto) 107 | 108 | Enables access to an SD card filesystem inside Nintendo 3DS/id0/id1. 109 | 110 | Currently, files inside the "Nintendo 3DS" directory cannot be read. 111 | 112 | :param parent_fs: The filesystem containing the contents of "Nintendo 3DS". 113 | :type parent_fs: ~fs.base.FS 114 | :param path: The path to the id1 folder. 115 | :type path: str 116 | :param crypto: The :class:`~pyctr.crypto.engine.CryptoEngine` object to be used. 117 | :type crypto: ~pyctr.crypto.engine.CryptoEngine 118 | -------------------------------------------------------------------------------- /docs/example-nand.rst: -------------------------------------------------------------------------------- 1 | Example: Read and write NAND partitions 2 | ======================================= 3 | 4 | In this example we will take a dumped Nintendo 3DS NAND image and extract data from the partitions. 5 | 6 | `Use GodMode9 to dump the NAND and essential files from your console. `_ No examples files are provided. 7 | 8 | .. note:: 9 | This is only an introduction to the NAND class assuming a valid NAND backup is being used. There are plenty of ways for this to go wrong in practice, from a corrupt TWL MBR, to no embedded essentials backup or an outdated one (such as lacking ``hwcal0`` and ``hwcal1`` or containing un-updated files like ``movable``). 10 | 11 | (soon) The full :class:`~.NAND` module documentation helps you to figure out how to handle these cases. 12 | 13 | First we need to import :class:`~.NAND`, and use it to open our NAND backup. In this case we will assume it has the GodMode9 essentials backup embedded. 14 | 15 | When opening a NAND backup file without any additional arguments, it is read-only by default. 16 | 17 | .. code-block:: python 18 | 19 | >>> from pyctr.type.nand import NAND 20 | >>> nand = NAND('nand.bin') 21 | 22 | With this we now immediately have access to decrypted versions of every NCSD partition and the GodMode9 bonus partition if it exists. We can also access the essentials backup with the :attr:`essential ` attribute, an :class:`~.ExeFSReader` object. 23 | 24 | Most of the time when interacting with a NAND backup, we'll want to access CTR NAND. There is a convenience function that will immediately open the CTR NAND FAT32 partition and enable access to the filesystem, :meth:`~pyctr.type.nand.NAND.open_ctr_fat`. This returns a :external+pyfatfs:class:`PyFatBytesIOFS `, provided by `pyfatfs `_. 25 | 26 | .. code-block:: python 27 | 28 | >>> ctrfat = nand.open_ctr_fat() 29 | 30 | With that let's list the files and open one. 31 | 32 | .. code-block:: python 33 | 34 | >>> ctrfat.listdir('/') 35 | ['data', 'ro', 'rw', 'ticket', 'title', 'tmp', 'fixdata', 'dbs', 'private', '__journal.nn_'] 36 | >>> ctrfat.listdir('/private') 37 | ['movable.sed'] 38 | >>> msed_file = ctrfat.open('/private/movable.sed', 'rb') 39 | >>> msed_file.seek(0x110) 40 | >>> msed = msed_file.read(0x10) 41 | >>> print('movable.sed key:', msed.hex()) 42 | movable.sed key: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 43 | 44 | Once done, let's remember to close all the files properly. 45 | 46 | .. code-block:: python 47 | 48 | >>> msed_file.close() 49 | >>> ctrnand.close() 50 | >>> nand.close() 51 | 52 | Writing 53 | ------- 54 | 55 | We've done enough reading. Let's write some files to the NAND now. 56 | 57 | To do this, the second argument for :class:`~.NAND` should be given ``'rb+'``. 58 | 59 | .. code-block:: python 60 | 61 | >>> from pyctr.type.nand import NAND, NANDSection 62 | >>> from pyfatfs.PyFatFS import PyFatBytesIOFS 63 | >>> nand = NAND('nand.bin', 'rb+') 64 | >>> ctrfat = nand.open_ctr_fat() 65 | 66 | With the NAND in read-write mode we can open files for writing now. 67 | 68 | .. code-block:: python 69 | 70 | >>> myfile = ctrfat.open('/myfile.txt', 'wb') 71 | >>> myfile.write(b'my contents') 72 | >>> myfile.close() 73 | 74 | Context managers 75 | ---------------- 76 | 77 | Files can all be opened using context managers as well. 78 | 79 | .. code-block:: python 80 | 81 | with NAND('nand.bin') as nand: 82 | with nand.open_ctr_fat() as ctrfat: 83 | with ctrfat.open('/myfile.txt', 'rb') as f: 84 | print(f.read()) 85 | 86 | Next steps 87 | ---------- 88 | 89 | Now that you know the basics of reading and writing to a NAND backup, you may want to check out these methods: 90 | 91 | * :meth:`~pyctr.type.nand.NAND.open_ctr_partition` - opens the raw CTR NAND partition 92 | * :meth:`~pyctr.type.nand.NAND.open_twl_partition` - opens a raw TWL NAND partition 93 | * :meth:`~pyctr.type.nand.NAND.open_bonus_partition` - opens the raw bonus partition created by GodMode9 94 | * :meth:`~pyctr.type.nand.NAND.open_ctr_fat` - opens the FAT16 filesystem in the CTR NAND partition 95 | * :meth:`~pyctr.type.nand.NAND.open_twl_fat` - opens the FAT12/FAT16 filesystem in a TWL NAND partition 96 | * :meth:`~pyctr.type.nand.NAND.open_bonus_fat` - opens the FAT32 filesystem in the GodMode9 bonus partition 97 | * :meth:`~pyctr.type.nand.NAND.open_raw_section` - opens a raw NCSD section 98 | * :meth:`~pyctr.type.nand.NAND.raise_if_ctr_failed` - raise an error if CTR partitions are inaccessible 99 | * :meth:`~pyctr.type.nand.NAND.raise_if_twl_failed` - raise an error if TWL partitions are inaccessible 100 | -------------------------------------------------------------------------------- /docs/example-cia.rst: -------------------------------------------------------------------------------- 1 | Example: Read contents from a CIA 2 | ================================= 3 | 4 | In this example we will take a homebrew CIA file and extract some data from it. The example in this case will be Checkpoint 3.7.4. `Download the example title here. `_ 5 | 6 | First we need to import :class:`~.CIAReader` and :class:`~.CIASection`. The former does the actual reading, the latter is an enum that can be used to access the contents. We will also import :py:mod:`json` to read a JSON file inside the RomFS. 7 | 8 | .. code-block:: python 9 | 10 | >>> import json 11 | >>> from pyctr.type.cia import CIAReader, CIASection 12 | 13 | Now we can open the file by creating a :class:`~.CIAReader` object. 14 | 15 | .. code-block:: python 16 | 17 | >>> cia = CIAReader('Checkpoint.cia') 18 | 19 | This will grant immediate access to all the contents inside, including the tmd, ticket, and NCCH contents. It also loads the data within the NCCH contents, such as the RomFS. 20 | 21 | We can now check the Title ID by accessing it through the :attr:`tmd ` attribute: 22 | 23 | .. code-block:: python 24 | 25 | >>> print('Title ID:', cia.tmd.title_id) 26 | Title ID: 000400000bcfff00 27 | 28 | Let's also print the Title Version, using the :attr:`title_version ` attribute. 29 | 30 | .. code-block:: python 31 | 32 | >>> print('Title Version:', '{0.major}.{0.minor}.{0.micro}'.format(cia.tmd.title_version)) 33 | Title Version: 3.7.4 34 | 35 | Now let's access the executable content using the :attr:`contents ` attribute. Here we'll use :attr:`CIASection.Application ` to read the first (and only) content. 36 | 37 | .. code-block:: python 38 | 39 | >>> app = cia.contents[CIASection.Application] 40 | >>> app 41 | 42 | 43 | This has given us an :class:`~.NCCHReader` object. 44 | 45 | Let's get the application's SMDH, which will give us access to the name and publisher shown on the HOME Menu. We'll get it through the ExeFS and get an :class:`SMDH ` object. 46 | 47 | .. code-block:: python 48 | 49 | >>> app_title = app.exefs.icon.get_app_title('English') 50 | >>> app_title 51 | AppTitle(short_desc='Checkpoint', long_desc='Fast and simple save manager', publisher='Bernardo Giordano, FlagBrew') 52 | >>> print('Application Title:', app_title.short_desc) 53 | Application Title: Checkpoint 54 | >>> print('Application Description:', app_title.long_desc) 55 | Application Description: Fast and simple save manager 56 | >>> print('Application Publisher:', app_title.publisher) 57 | Application Publisher: Bernardo Giordano, FlagBrew 58 | 59 | Next, we will list the contents of the RomFS. The :class:`~.NCCHReader` has a :attr:`romfs ` attribute that will give us a :class:`RomFSReader ` object. 60 | 61 | Using :func:`get_info_from_path ` we will list the contents at the root. 62 | 63 | .. code-block:: python 64 | 65 | >>> print('Contents in the root:', ', '.join(app.romfs.get_info_from_path('/').contents)) 66 | Contents in the root: gfx, cheats, config.json, PKSM.smdh 67 | 68 | Using the same method, we can get information about a specific file. 69 | 70 | .. code-block:: python 71 | 72 | >>> print('Size of /config.json in bytes:', app.romfs.get_info_from_path('/config.json').size) 73 | Size of /config.json in bytes: 183 74 | 75 | Finally, we can open the file and parse the JSON inside. We'll pass an ``encoding`` argument so that we get an :py:class:`io.TextIOWrapper` object. Then we use :py:func:`json.load` and print a value from it. 76 | 77 | .. code-block:: python 78 | 79 | >>> f = app.romfs.open('/config.json', encoding='utf-8') 80 | >>> f 81 | <_io.TextIOWrapper encoding='utf-8'> 82 | >>> config = json.load(f) 83 | >>> f.close() 84 | >>> config 85 | {'filter': [], 'favorites': [], 'additional_save_folders': {}, 'additional_extdata_folders': {}, 'nand_saves': False, 'scan_cart': False, 'version': 3} 86 | >>> print('Config version:', config['version']) 87 | Config version: 3 88 | 89 | When you're done, make sure to close the :class:`~.CIAReader`. You should also close any open files based on the CIA. 90 | 91 | .. code-block:: python 92 | 93 | >>> cia.close() 94 | 95 | You can also use :class:`~.CIAReader` in the form of a context manager. 96 | 97 | .. code-block:: python 98 | 99 | with CIAReader('Checkpoint.cia') as cia: 100 | with cia.contents[CIASection.Application].romfs.open('/config.json') as f: 101 | config = json.load(f) 102 | print('Config version:', config['version']) 103 | -------------------------------------------------------------------------------- /pyctr/common.py: -------------------------------------------------------------------------------- 1 | # This file is a part of pyctr. 2 | # 3 | # Copyright (c) 2017-2023 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE in the root of this project. 6 | 7 | from functools import wraps 8 | from io import RawIOBase 9 | from os import PathLike, fsdecode 10 | from os.path import dirname as os_dirname 11 | from typing import TYPE_CHECKING 12 | 13 | from fs import open_fs 14 | from fs.base import FS 15 | from fs.path import dirname as fs_dirname 16 | 17 | if TYPE_CHECKING: 18 | # this is a lazy way to make type checkers stop complaining 19 | from typing import BinaryIO, IO, Union, Optional, Tuple 20 | 21 | RawIOBase = BinaryIO 22 | 23 | FilePath = Union[PathLike, str, bytes] 24 | FilePathOrObject = Union[FilePath, BinaryIO] 25 | DirPathOrFS = Union[PathLike, str, bytes, FS] 26 | 27 | 28 | class PyCTRError(Exception): 29 | """Common base class for all PyCTR errors.""" 30 | 31 | 32 | def get_fs_file_object( 33 | path: 'FilePathOrObject', 34 | fs: 'Optional[FS]' = None, 35 | *, 36 | mode: str = 'rb' 37 | ) -> 'Tuple[IO, bool]': 38 | if isinstance(path, (PathLike, str, bytes)): 39 | """ 40 | Opens a file on the given filesystem. This can be given a simple OS path, a path and a filesystem, or an 41 | olready opened file object. 42 | 43 | :param path: A path to a file. 44 | :param fs: A filesystem or an FS URL. 45 | :return: A file-like object and True if the file is newly opened. 46 | """ 47 | if fs: 48 | # fs can be an FS object or an FS URL 49 | if not isinstance(fs, FS): 50 | fs = open_fs(fs) 51 | return fs.open(path, mode), True 52 | else: 53 | # no fs means assuming OS, and no real need to bother going through OSFS for this one 54 | return open(path, mode), True 55 | else: 56 | # it's already an opened file object, so just return that 57 | return path, False 58 | 59 | 60 | def _raise_if_file_closed(method): 61 | """ 62 | Wraps a method that raises an exception if the reader file object is closed. 63 | 64 | :param method: The method to call if the file is not closed. 65 | :return: The wrapper method. 66 | """ 67 | @wraps(method) 68 | def decorator(self: '_ReaderOpenFileBase', *args, **kwargs): 69 | if self._reader.closed: 70 | self.closed = True 71 | if self.closed: 72 | raise ValueError('I/O operation on closed file') 73 | return method(self, *args, **kwargs) 74 | return decorator 75 | 76 | 77 | def _raise_if_file_closed_generic(method): 78 | """ 79 | Wraps a method that raises an exception if the file object is closed. This works on any file-like object, not just 80 | ones for Reader open files. 81 | 82 | :param method: The method to call if the file is not closed. 83 | :return: The wrapper method. 84 | """ 85 | @wraps(method) 86 | def decorator(self: 'IO', *args, **kwargs): 87 | if self.closed: 88 | raise ValueError('I/O operation on closed file') 89 | return method(self, *args, **kwargs) 90 | return decorator 91 | 92 | 93 | if TYPE_CHECKING: 94 | # get pycharm to stop complaining 95 | def _raise_if_file_closed(method): 96 | return method 97 | 98 | def _raise_if_file_closed_generic(method): 99 | return method 100 | 101 | 102 | class _ReaderOpenFileBase(RawIOBase): 103 | """Base class for all open files for Reader classes.""" 104 | 105 | _seek = 0 106 | _info = None 107 | closed = False 108 | 109 | def __init__(self, reader, path): 110 | self._reader = reader 111 | self._path = path 112 | 113 | def __repr__(self): 114 | return f'<{type(self).__name__} path={self._path!r} info={self._info!r} reader={self._reader!r}>' 115 | 116 | @_raise_if_file_closed 117 | def read(self, size: int = -1) -> bytes: 118 | if size == -1: 119 | size = self._info.size - self._seek 120 | data = self._reader.get_data(self._info, self._seek, size) 121 | self._seek += len(data) 122 | return data 123 | 124 | @_raise_if_file_closed 125 | def seek(self, seek: int, whence: int = 0) -> int: 126 | if whence == 0: 127 | if seek < 0: 128 | raise ValueError(f'negative seek value {seek}') 129 | self._seek = min(seek, self._info.size) 130 | elif whence == 1: 131 | self._seek = max(self._seek + seek, 0) 132 | elif whence == 2: 133 | self._seek = max(self._info.size + seek, 0) 134 | return self._seek 135 | 136 | @_raise_if_file_closed 137 | def tell(self) -> int: 138 | return self._seek 139 | 140 | @_raise_if_file_closed 141 | def readable(self) -> bool: 142 | return True 143 | 144 | @_raise_if_file_closed 145 | def writable(self) -> bool: 146 | return False 147 | 148 | @_raise_if_file_closed 149 | def seekable(self) -> bool: 150 | return True 151 | -------------------------------------------------------------------------------- /docs/pyctr.crypto.engine.rst: -------------------------------------------------------------------------------- 1 | :mod:`engine` - AES engine tools 2 | ================================ 3 | 4 | .. py:module:: pyctr.crypto.engine 5 | :synopsis: Perform cryptographic operations operations with Nintendo 3DS data 6 | 7 | The :mod:`engine` module provides tools to perform cryptographic operations on Nintendo 3DS data, including emulating keyslots and the key scrambler. 8 | 9 | .. warning:: 10 | 11 | This page is incomplete. 12 | 13 | AES engine 14 | ---------- 15 | 16 | The 3DS uses keyslots in an attempt to obscure the encryption keys used. Each slot consists of X, Y, and normal keys. 17 | 18 | Often, one slot contains a fixed key (often X), while the other is a key unique to something such as a game or console (often Y). When key Y is set, both keys are put into a key scrambler, and the result is stored as a normal key. The AES engine would then use the result for encryption. A normal key can also be set directly. 19 | 20 | The AES engine only has keyslots 0x0 to 0x3F (0 to 63). Keyslots 0x0 to 0x3 are for DSi-mode software, and use the DSi key scrambler. This module uses keyslots above 0x3F for internal use. 21 | 22 | CryptoEngine objects 23 | -------------------- 24 | 25 | .. py:class:: CryptoEngine(boot9=None, dev=False, setup_b9_keys=True) 26 | 27 | Emulates the AES engine, including keyslots and the key scrambler. 28 | 29 | :param boot9: Path to a dump of the protected region of the ARM9 BootROM. Defaults to None, which causes it to search a predefined list of paths. 30 | :type boot9: FilePathOrObject 31 | :param dev: Use devunit keys. 32 | :type dev: bool 33 | :param setup_b9_keys: Automatically load keys from boot9. This calls :meth:`setup_boot9_keys`. 34 | :type setup_b9_keys: bool 35 | 36 | .. py:method:: create_cbc_cipher(keyslot, iv) 37 | 38 | Create an AES-CBC cipher. 39 | 40 | :param keyslot: Keyslot to use. 41 | :type keyslot: Keyslot 42 | :param iv: Initialization vector. 43 | :type iv: bytes 44 | :return: An AES-CBC cipher object. 45 | :rtype: CbcMode 46 | 47 | .. py:method:: create_ctr_cipher(keyslot, ctr) 48 | 49 | Create an AES-CTR cipher. 50 | 51 | :param keyslot: Keyslot to use. 52 | :type keyslot: Keyslot 53 | :param ctr: Counter to start with. 54 | :type ctr: int 55 | :return: An AES-CTR cipher object. 56 | :rtype: CtrMode | _TWLCryptoWrapper 57 | 58 | .. py:method:: create_ecb_cipher(keyslot) 59 | 60 | Create an AES-ECB cipher. 61 | 62 | :param keyslot: Keyslot to use. 63 | :type keyslot: Keyslot 64 | :return: An AES-ECB cipher object. 65 | :rtype: EcbMode 66 | 67 | .. py:method:: create_cmac_object(keyslot) 68 | 69 | Create a CMAC object. 70 | 71 | :param keyslot: Keyslot to use. 72 | :type keyslot: Keyslot 73 | :return: A CMAC object. 74 | :rtype: CMAC 75 | 76 | .. py:method:: create_ctr_io(keyslot, fh, ctr, closefd=False) 77 | 78 | Create an AES-CTR read-write file object with the given keyslot. 79 | 80 | :param keyslot: Keyslot to use. 81 | :type keyslot: Keyslot 82 | :param fh: File-like object to wrap. 83 | :type fh: BinaryIO 84 | :param ctr: Counter to start with. 85 | :type ctr: int 86 | :param closefd: Close underlying file object when closed. 87 | :type closefd: bool 88 | :return: A file-like object that does decryption and encryption on the fly. 89 | :rtype: CTRFileIO 90 | 91 | .. py:method:: create_cbc_io(keyslot, fh, iv, closefd=False) 92 | 93 | Create an AES-CBC read-write file object with the given keyslot. 94 | 95 | :param keyslot: Keyslot to use. 96 | :type keyslot: Keyslot 97 | :param fh: File-like object to wrap. 98 | :type fh: BinaryIO 99 | :param iv: Initialization vector. 100 | :type iv: bytes 101 | :param closefd: Close underlying file object when closed. 102 | :type closefd: bool 103 | :return: A file-like object that does decryption and encryption on the fly. 104 | :rtype: CBCFileIO 105 | 106 | .. py:method:: set_keyslot(xy, keyslot, key, *, update_normal_key=True) 107 | 108 | Sets a keyslot. 109 | 110 | :param xy: X or Y. 111 | :type xy: Literal['x', 'y'] 112 | :param keyslot: Keyslot to set. 113 | :type keyslot: Keyslot 114 | :param key: Key to set it to. If provided as an integer, it is converted to little- or big-endian depending on if the keyslot is <=0x03. 115 | :type key: bytes | int 116 | :param update_normal_key: Update the normal key based on the value of X and Y. 117 | :type update_normal_key: bool 118 | 119 | .. py:method:: set_normal_key(keyslot, key) 120 | 121 | Set a keyslot's normal key. 122 | 123 | :param keyslot: Keyslot to set. 124 | :type keyslot: Keyslot 125 | :param key: Key to set it to. 126 | :type key: bytes 127 | 128 | .. py:method:: update_normal_keys() 129 | 130 | Refresh normal keys. This is only required if :meth:`set_keyslot` was called with `update_normal_key=False`. 131 | -------------------------------------------------------------------------------- /docs/pyctr.type.sd.rst: -------------------------------------------------------------------------------- 1 | :mod:`sd` - SD card contents 2 | ============================ 3 | 4 | .. py:module:: pyctr.type.sd 5 | :synopsis: Read and write Nintendo 3DS SD card encrypted digital content 6 | 7 | The :mod:`sd` module enables reading and writing of Nintendo 3DS SD card encrypted digital content. This is the "Nintendo 3DS" folder on an SD card and includes application data, save data, and extdata. 8 | 9 | .. deprecated:: 0.8.0 10 | Replaced with :mod:`~pyctr.type.sdfs`. 11 | 12 | Directory hierarchy 13 | ------------------- 14 | 15 | * Nintendo 3DS 16 | 17 | * 18 | 19 | * 20 | 21 | * backup 22 | 23 | * dbs 24 | 25 | * extdata 26 | 27 | * title 28 | 29 | * Nintendo DSiWare 30 | 31 | .. note:: 32 | Files cannot be moved or renamed because the encryption depends on the complete filepath from the start of the ID1 folder. If a file must be moved or renamed, it must be copied to the new location. 33 | 34 | SDFilesystem objects 35 | -------------------- 36 | 37 | .. py:class:: SDFilesystem(path, *, crypto=None, dev=False, sd_key_file=None, sd_key=None) 38 | 39 | Read and write encrypted SD card contents in the "Nintendo 3DS" directory. 40 | 41 | All methods related to files and directories happen relative to the root of the ID1 folder. Each have an optional ``id1`` parameter to specify a specific ID1 directory. If left unspecified, the value of :attr:`current_id1` is used. 42 | 43 | :param path: Path to the Nintendo 3DS folder. 44 | :type path: str 45 | :param crypto: A custom crypto object to be used. Defaults to None, which causes a new one to be created. 46 | :type crypto: ~pyctr.crypto.engine.CryptoEngine 47 | :param sd_key_file: Path to a movable.sed file to load the SD KeyY from. 48 | :type sd_key_file: :term:`path-like object` or :term:`binary file` 49 | :param sd_key: SD KeyY to use. Has priority over `sd_key_file` if both are specified. 50 | :type sd_key: bytes 51 | :raises MissingMovableSedError: If movable.sed is not provided. 52 | :raises MissingID0Error: If the ID0 could not be found in "Nintendo 3DS". 53 | :raises MissingID1Error: If there are no ID1 directories inside ID0. 54 | 55 | .. py:method:: open_title(title_id, *, case_insensitive=False, seed=None, load_contents=True, id1=None) 56 | 57 | Open a title's contents for reading. 58 | 59 | In the case where a title's directory has multiple tmd files, the first one returned by :meth:`listdir` is used. 60 | 61 | :param title_id: Title ID to open. 62 | :type title_id: str 63 | :param case_insensitive: Use case-insensitive paths for the RomFS of each NCCH container. 64 | :type case_insensitive: bool 65 | :param seed: Seed to use. This is a quick way to add a seed using :func:`~.seeddb.add_seed`. 66 | :type seed: bytes 67 | :param load_contents: Load each partition with :class:`~.NCCHReader`. 68 | :type load_contents: bool 69 | :rtype: ~pyctr.type.sdtitle.SDTitleReader 70 | :raises MissingTitleError: If the title could not be found. 71 | 72 | .. py:method:: open(path, mode='rb', *, id1=None) 73 | 74 | Opens a file in the SD filesystem for reading or writing. Unix and Windows style paths are accepted. 75 | 76 | This does not support reading or writing files in the "Nintendo DSiWare" directory, which use a very different encryption method. Attempting will raise :exc:`NotImplementedError`. 77 | 78 | :param path: File path. 79 | :type path: :term:`path-like object` 80 | :param mode: Mode to open the file with. Binary mode is always used. 81 | :type mode: str 82 | :rtype: ~pyctr.crypto.engine.CTRFileIO 83 | 84 | .. py:method:: listdir(path, id1=None) 85 | 86 | Returns a list of files in the directory. 87 | 88 | :param path: Directory path. 89 | :type path: :term:`path-like object` 90 | :rtype: List[str] 91 | 92 | .. py:method:: isfile(path, id1=None) 93 | 94 | Checks if the path points to a file. 95 | 96 | :param path: Path to check. 97 | :type path: :term:`path-like object` 98 | :rtype: bool 99 | 100 | .. py:method:: isdir(path, id1=None) 101 | 102 | Checks if the path points to a directory. 103 | 104 | :param path: Path to check. 105 | :type path: :term:`path-like object` 106 | :rtype: bool 107 | 108 | .. py:attribute:: id1s 109 | :type: List[str] 110 | 111 | A list of ID1 directories found in the ID0 directory. 112 | 113 | .. py:attribute:: current_id1 114 | :type: str 115 | 116 | The ID1 used as the default when none is specified to a method's ``id1`` argument, initially set to the first value in :attr:`id1s`. 117 | 118 | .. note:: 119 | 120 | If there is more than one ID1, the default value is whichever happens to be returned by the OS first. This could be different from what is actually used on someone's console. 121 | 122 | Exceptions 123 | ---------- 124 | 125 | .. autoexception:: SDFilesystemError 126 | .. autoexception:: MissingMovableSedError 127 | .. autoexception:: MissingID0Error 128 | .. autoexception:: MissingID1Error 129 | .. autoexception:: MissingTitleError 130 | -------------------------------------------------------------------------------- /pyctr/crypto/seeddb.py: -------------------------------------------------------------------------------- 1 | # This file is a part of pyctr. 2 | # 3 | # Copyright (c) 2017-2023 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE in the root of this project. 6 | 7 | from os import PathLike, environ 8 | from os.path import join 9 | from types import MappingProxyType 10 | from typing import TYPE_CHECKING 11 | 12 | from ..common import PyCTRError 13 | from ..util import config_dirs, readle 14 | 15 | if TYPE_CHECKING: 16 | from typing import BinaryIO, Dict, Union 17 | from ..common import FilePathOrObject 18 | 19 | __all__ = ['SeedDBError', 'InvalidProgramIDError', 'InvalidSeedError', 'MissingSeedError', 'load_seeddb', 'get_seed', 20 | 'add_seed', 'get_all_seeds', 'save_seeddb'] 21 | 22 | SEED_ENTRY_PADDING = (b'\0' * 8) 23 | 24 | 25 | class SeedDBError(PyCTRError): 26 | """Generic exception for seed operations.""" 27 | 28 | 29 | class InvalidProgramIDError(SeedDBError): 30 | """Program ID is not in a valid format.""" 31 | 32 | 33 | class InvalidSeedError(SeedDBError): 34 | """The provided seed is not in a valid format.""" 35 | 36 | 37 | class MissingSeedError(SeedDBError): 38 | """Seed not found in the database.""" 39 | 40 | 41 | _seeds: 'Dict[int, bytes]' = {} 42 | _loaded_from_default_paths = False 43 | 44 | seeddb_paths = [join(x, 'seeddb.bin') for x in config_dirs] 45 | try: 46 | # try to insert the path in the SEEDDB_PATH environment variable 47 | seeddb_paths.insert(0, environ['SEEDDB_PATH']) 48 | except KeyError: 49 | pass 50 | 51 | 52 | def _load_seeds_from_file_object(fh: 'BinaryIO'): 53 | seed_count = readle(fh.read(4)) 54 | fh.seek(0x10) 55 | for _ in range(seed_count): 56 | entry = fh.read(0x20) 57 | title_id = readle(entry[0:8]) 58 | _seeds[title_id] = entry[0x8:0x18] 59 | 60 | 61 | def _normalize_program_id(program_id: 'Union[int, str, bytes]') -> int: 62 | if not isinstance(program_id, (int, str, bytes)): 63 | raise InvalidProgramIDError('not an int, str, or bytes') 64 | 65 | if isinstance(program_id, str): 66 | program_id = int(program_id, 16) 67 | elif isinstance(program_id, bytes): 68 | program_id = int.from_bytes(program_id, 'little') 69 | 70 | return program_id 71 | 72 | 73 | def load_seeddb(fp: 'FilePathOrObject' = None): 74 | """ 75 | Load a seeddb file. 76 | 77 | :param fp: A file path or file-like object with the seeddb data. 78 | """ 79 | global _loaded_from_default_paths 80 | if fp: 81 | if isinstance(fp, (PathLike, str, bytes)): 82 | fp = open(fp, 'rb') 83 | _load_seeds_from_file_object(fp) 84 | elif not _loaded_from_default_paths: 85 | for path in seeddb_paths: 86 | try: 87 | with open(path, 'rb') as fh: 88 | _load_seeds_from_file_object(fh) 89 | except FileNotFoundError: 90 | pass 91 | 92 | _loaded_from_default_paths = True 93 | 94 | 95 | def get_seed(program_id: 'Union[int, str, bytes]', *, load_if_required: bool = True): 96 | """ 97 | Get a seed for a Program ID. 98 | 99 | :param program_id: The Program ID to search for. If `bytes` is provided, the value must be little-endian. 100 | :param load_if_required: Automatically load using :func:`load_seeddb` if the requested Program ID is not already 101 | available. 102 | """ 103 | program_id = _normalize_program_id(program_id) 104 | 105 | try: 106 | return _seeds[program_id] 107 | except KeyError: 108 | if _loaded_from_default_paths or not load_if_required: 109 | raise MissingSeedError(f'{program_id:016x}') 110 | else: 111 | if load_if_required: 112 | load_seeddb() 113 | return get_seed(program_id, load_if_required=False) 114 | 115 | 116 | def add_seed(program_id: 'Union[int, str, bytes]', seed: 'Union[bytes, str]'): 117 | """ 118 | Adds a seed to the database. 119 | 120 | :param program_id: The Program ID associated with the seed. If `bytes` is provided, the value must be little-endian. 121 | :param seed: The seed to add. 122 | """ 123 | program_id = _normalize_program_id(program_id) 124 | 125 | if isinstance(seed, str): 126 | try: 127 | seed = bytes.fromhex(seed) 128 | except ValueError: 129 | raise InvalidSeedError('seed is not in hex') 130 | 131 | if len(seed) != 16: 132 | raise InvalidSeedError(f'expected 16 bytes, got {len(seed)}') 133 | 134 | _seeds[program_id] = seed 135 | 136 | 137 | def get_all_seeds(): 138 | """ 139 | Gets all the loaded seeds. 140 | 141 | :return: A read-only view of the seed database. 142 | """ 143 | return MappingProxyType(_seeds) 144 | 145 | 146 | def save_seeddb(fp: 'FilePathOrObject'): 147 | """ 148 | Save the seed database to a seeddb file. 149 | 150 | :param fp: A file path or file-like object to write the seeddb data to. 151 | """ 152 | if isinstance(fp, (PathLike, str, bytes)): 153 | fp = open(fp, 'wb') 154 | 155 | fp.write(len(_seeds).to_bytes(4, 'little') + (b'\0' * 12)) 156 | 157 | for program_id, seed in _seeds.items(): 158 | fp.write(program_id.to_bytes(8, 'little') + seed + SEED_ENTRY_PADDING) 159 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | indent_size = 4 4 | indent_style = space 5 | insert_final_newline = true 6 | max_line_length = 120 7 | tab_width = 4 8 | ij_continuation_indent_size = 8 9 | ij_formatter_off_tag = @formatter:off 10 | ij_formatter_on_tag = @formatter:on 11 | ij_formatter_tags_enabled = true 12 | ij_smart_tabs = false 13 | ij_visual_guides = 14 | ij_wrap_on_typing = false 15 | 16 | [.editorconfig] 17 | ij_editorconfig_align_group_field_declarations = false 18 | ij_editorconfig_space_after_colon = false 19 | ij_editorconfig_space_after_comma = true 20 | ij_editorconfig_space_before_colon = false 21 | ij_editorconfig_space_before_comma = false 22 | ij_editorconfig_spaces_around_assignment_operators = true 23 | 24 | [{*.bash,*.sh,*.zsh}] 25 | end_of_line = lf 26 | indent_size = 2 27 | tab_width = 2 28 | ij_shell_binary_ops_start_line = false 29 | ij_shell_keep_column_alignment_padding = false 30 | ij_shell_minify_program = false 31 | ij_shell_redirect_followed_by_space = false 32 | ij_shell_switch_cases_indented = false 33 | ij_shell_use_unix_line_separator = true 34 | 35 | [*.bat] 36 | end_of_line = crlf 37 | indent_size = 2 38 | tab_width = 2 39 | 40 | [{*.markdown,*.md}] 41 | ij_markdown_force_one_space_after_blockquote_symbol = true 42 | ij_markdown_force_one_space_after_header_symbol = true 43 | ij_markdown_force_one_space_after_list_bullet = true 44 | ij_markdown_force_one_space_between_words = true 45 | ij_markdown_format_tables = true 46 | ij_markdown_insert_quote_arrows_on_wrap = true 47 | ij_markdown_keep_indents_on_empty_lines = false 48 | ij_markdown_keep_line_breaks_inside_text_blocks = true 49 | ij_markdown_max_lines_around_block_elements = 1 50 | ij_markdown_max_lines_around_header = 1 51 | ij_markdown_max_lines_between_paragraphs = 1 52 | ij_markdown_min_lines_around_block_elements = 1 53 | ij_markdown_min_lines_around_header = 1 54 | ij_markdown_min_lines_between_paragraphs = 1 55 | ij_markdown_wrap_text_if_long = true 56 | ij_markdown_wrap_text_inside_blockquotes = true 57 | 58 | [{*.py,*.pyw,*.spec}] 59 | ij_python_align_collections_and_comprehensions = true 60 | ij_python_align_multiline_imports = true 61 | ij_python_align_multiline_parameters = true 62 | ij_python_align_multiline_parameters_in_calls = true 63 | ij_python_blank_line_at_file_end = true 64 | ij_python_blank_lines_after_imports = 1 65 | ij_python_blank_lines_after_local_imports = 0 66 | ij_python_blank_lines_around_class = 1 67 | ij_python_blank_lines_around_method = 1 68 | ij_python_blank_lines_around_top_level_classes_functions = 2 69 | ij_python_blank_lines_before_first_method = 0 70 | ij_python_call_parameters_new_line_after_left_paren = false 71 | ij_python_call_parameters_right_paren_on_new_line = false 72 | ij_python_call_parameters_wrap = normal 73 | ij_python_dict_alignment = 0 74 | ij_python_dict_new_line_after_left_brace = false 75 | ij_python_dict_new_line_before_right_brace = false 76 | ij_python_dict_wrapping = 1 77 | ij_python_from_import_new_line_after_left_parenthesis = false 78 | ij_python_from_import_new_line_before_right_parenthesis = false 79 | ij_python_from_import_parentheses_force_if_multiline = false 80 | ij_python_from_import_trailing_comma_if_multiline = false 81 | ij_python_from_import_wrapping = 1 82 | ij_python_hang_closing_brackets = false 83 | ij_python_keep_blank_lines_in_code = 1 84 | ij_python_keep_blank_lines_in_declarations = 1 85 | ij_python_keep_indents_on_empty_lines = false 86 | ij_python_keep_line_breaks = true 87 | ij_python_method_parameters_new_line_after_left_paren = false 88 | ij_python_method_parameters_right_paren_on_new_line = false 89 | ij_python_method_parameters_wrap = normal 90 | ij_python_new_line_after_colon = false 91 | ij_python_new_line_after_colon_multi_clause = true 92 | ij_python_optimize_imports_always_split_from_imports = false 93 | ij_python_optimize_imports_case_insensitive_order = false 94 | ij_python_optimize_imports_join_from_imports_with_same_source = false 95 | ij_python_optimize_imports_sort_by_type_first = true 96 | ij_python_optimize_imports_sort_imports = true 97 | ij_python_optimize_imports_sort_names_in_from_imports = false 98 | ij_python_space_after_comma = true 99 | ij_python_space_after_number_sign = true 100 | ij_python_space_after_py_colon = true 101 | ij_python_space_before_backslash = true 102 | ij_python_space_before_comma = false 103 | ij_python_space_before_for_semicolon = false 104 | ij_python_space_before_lbracket = false 105 | ij_python_space_before_method_call_parentheses = false 106 | ij_python_space_before_method_parentheses = false 107 | ij_python_space_before_number_sign = true 108 | ij_python_space_before_py_colon = false 109 | ij_python_space_within_empty_method_call_parentheses = false 110 | ij_python_space_within_empty_method_parentheses = false 111 | ij_python_spaces_around_additive_operators = true 112 | ij_python_spaces_around_assignment_operators = true 113 | ij_python_spaces_around_bitwise_operators = true 114 | ij_python_spaces_around_eq_in_keyword_argument = false 115 | ij_python_spaces_around_eq_in_named_parameter = false 116 | ij_python_spaces_around_equality_operators = true 117 | ij_python_spaces_around_multiplicative_operators = true 118 | ij_python_spaces_around_power_operator = true 119 | ij_python_spaces_around_relational_operators = true 120 | ij_python_spaces_around_shift_operators = true 121 | ij_python_spaces_within_braces = false 122 | ij_python_spaces_within_brackets = false 123 | ij_python_spaces_within_method_call_parentheses = false 124 | ij_python_spaces_within_method_parentheses = false 125 | ij_python_use_continuation_indent_for_arguments = false 126 | ij_python_use_continuation_indent_for_collection_and_comprehensions = false 127 | ij_python_use_continuation_indent_for_parameters = true 128 | ij_python_wrap_long_lines = false 129 | 130 | [{*.toml,Cargo.lock,Cargo.toml.orig,Gopkg.lock,Pipfile,poetry.lock}] 131 | ij_toml_keep_indents_on_empty_lines = false 132 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.cia 2 | localtest/ 3 | *.local/ 4 | docs/_build/ 5 | 6 | # Created by .ignore support plugin (hsz.mobi) 7 | ### JetBrains template 8 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 9 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 10 | 11 | # User-specific stuff 12 | .idea/**/workspace.xml 13 | .idea/**/tasks.xml 14 | .idea/**/usage.statistics.xml 15 | .idea/**/dictionaries 16 | .idea/**/shelf 17 | 18 | # Generated files 19 | .idea/**/contentModel.xml 20 | 21 | # Sensitive or high-churn files 22 | .idea/**/dataSources/ 23 | .idea/**/dataSources.ids 24 | .idea/**/dataSources.local.xml 25 | .idea/**/sqlDataSources.xml 26 | .idea/**/dynamic.xml 27 | .idea/**/uiDesigner.xml 28 | .idea/**/dbnavigator.xml 29 | 30 | # Gradle 31 | .idea/**/gradle.xml 32 | .idea/**/libraries 33 | 34 | # Gradle and Maven with auto-import 35 | # When using Gradle or Maven with auto-import, you should exclude module files, 36 | # since they will be recreated, and may cause churn. Uncomment if using 37 | # auto-import. 38 | # .idea/artifacts 39 | # .idea/compiler.xml 40 | # .idea/modules.xml 41 | # .idea/*.iml 42 | # .idea/modules 43 | # *.iml 44 | # *.ipr 45 | 46 | # CMake 47 | cmake-build-*/ 48 | 49 | # Mongo Explorer plugin 50 | .idea/**/mongoSettings.xml 51 | 52 | # File-based project format 53 | *.iws 54 | 55 | # IntelliJ 56 | out/ 57 | 58 | # mpeltonen/sbt-idea plugin 59 | .idea_modules/ 60 | 61 | # JIRA plugin 62 | atlassian-ide-plugin.xml 63 | 64 | # Cursive Clojure plugin 65 | .idea/replstate.xml 66 | 67 | # Crashlytics plugin (for Android Studio and IntelliJ) 68 | com_crashlytics_export_strings.xml 69 | crashlytics.properties 70 | crashlytics-build.properties 71 | fabric.properties 72 | 73 | # Editor-based Rest Client 74 | .idea/httpRequests 75 | 76 | # Android studio 3.1+ serialized cache file 77 | .idea/caches/build_file_checksums.ser 78 | 79 | ### macOS template 80 | # General 81 | .DS_Store 82 | .AppleDouble 83 | .LSOverride 84 | 85 | # Icon must end with two \r 86 | Icon 87 | 88 | # Thumbnails 89 | ._* 90 | 91 | # Files that might appear in the root of a volume 92 | .DocumentRevisions-V100 93 | .fseventsd 94 | .Spotlight-V100 95 | .TemporaryItems 96 | .Trashes 97 | .VolumeIcon.icns 98 | .com.apple.timemachine.donotpresent 99 | 100 | # Directories potentially created on remote AFP share 101 | .AppleDB 102 | .AppleDesktop 103 | Network Trash Folder 104 | Temporary Items 105 | .apdisk 106 | 107 | ### Linux template 108 | *~ 109 | 110 | # temporary files which can be created if a process still has a handle open of a deleted file 111 | .fuse_hidden* 112 | 113 | # KDE directory preferences 114 | .directory 115 | 116 | # Linux trash folder which might appear on any partition or disk 117 | .Trash-* 118 | 119 | # .nfs files are created when an open file is removed but is still being accessed 120 | .nfs* 121 | 122 | ### Windows template 123 | # Windows thumbnail cache files 124 | Thumbs.db 125 | Thumbs.db:encryptable 126 | ehthumbs.db 127 | ehthumbs_vista.db 128 | 129 | # Dump file 130 | *.stackdump 131 | 132 | # Folder config file 133 | [Dd]esktop.ini 134 | 135 | # Recycle Bin used on file shares 136 | $RECYCLE.BIN/ 137 | 138 | # Windows Installer files 139 | *.cab 140 | *.msi 141 | *.msix 142 | *.msm 143 | *.msp 144 | 145 | # Windows shortcuts 146 | *.lnk 147 | 148 | ### Python template 149 | # Byte-compiled / optimized / DLL files 150 | __pycache__/ 151 | *.py[cod] 152 | *$py.class 153 | 154 | # C extensions 155 | *.so 156 | 157 | # Distribution / packaging 158 | .Python 159 | build/ 160 | develop-eggs/ 161 | dist/ 162 | downloads/ 163 | eggs/ 164 | .eggs/ 165 | lib/ 166 | lib64/ 167 | parts/ 168 | sdist/ 169 | var/ 170 | wheels/ 171 | pip-wheel-metadata/ 172 | share/python-wheels/ 173 | *.egg-info/ 174 | .installed.cfg 175 | *.egg 176 | MANIFEST 177 | 178 | # PyInstaller 179 | # Usually these files are written by a python script from a template 180 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 181 | *.manifest 182 | *.spec 183 | 184 | # Installer logs 185 | pip-log.txt 186 | pip-delete-this-directory.txt 187 | 188 | # Unit test / coverage reports 189 | htmlcov/ 190 | .tox/ 191 | .nox/ 192 | .coverage 193 | .coverage.* 194 | .cache 195 | nosetests.xml 196 | coverage.xml 197 | *.cover 198 | *.py,cover 199 | .hypothesis/ 200 | .pytest_cache/ 201 | 202 | # Translations 203 | *.mo 204 | *.pot 205 | 206 | # Django stuff: 207 | *.log 208 | local_settings.py 209 | db.sqlite3 210 | db.sqlite3-journal 211 | 212 | # Flask stuff: 213 | instance/ 214 | .webassets-cache 215 | 216 | # Scrapy stuff: 217 | .scrapy 218 | 219 | # Sphinx documentation 220 | docs/_build/ 221 | 222 | # PyBuilder 223 | target/ 224 | 225 | # Jupyter Notebook 226 | .ipynb_checkpoints 227 | 228 | # IPython 229 | profile_default/ 230 | ipython_config.py 231 | 232 | # pyenv 233 | .python-version 234 | 235 | # pipenv 236 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 237 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 238 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 239 | # install all needed dependencies. 240 | #Pipfile.lock 241 | 242 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 243 | __pypackages__/ 244 | 245 | # Celery stuff 246 | celerybeat-schedule 247 | celerybeat.pid 248 | 249 | # SageMath parsed files 250 | *.sage.py 251 | 252 | # Environments 253 | .env 254 | .venv 255 | env/ 256 | venv/ 257 | ENV/ 258 | env.bak/ 259 | venv.bak/ 260 | 261 | # Spyder project settings 262 | .spyderproject 263 | .spyproject 264 | 265 | # Rope project settings 266 | .ropeproject 267 | 268 | # mkdocs documentation 269 | /site 270 | 271 | # mypy 272 | .mypy_cache/ 273 | .dmypy.json 274 | dmypy.json 275 | 276 | # Pyre type checker 277 | .pyre/ 278 | 279 | # Nix 280 | result 281 | result-* 282 | -------------------------------------------------------------------------------- /pyctr/type/save/disa.py: -------------------------------------------------------------------------------- 1 | # This file is a part of pyctr. 2 | # 3 | # Copyright (c) 2017-2023 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE in the root of this project. 6 | 7 | from hashlib import sha256 8 | from typing import TYPE_CHECKING 9 | 10 | from ...util import readle 11 | from .common import PartitionContainerBase, CorruptPartitionError, InvalidPartitionContainerError 12 | 13 | if TYPE_CHECKING: 14 | from typing import Dict, Optional 15 | 16 | from fs.base import FS 17 | 18 | from ...crypto import CryptoEngine 19 | from .cmac import CMACTypeBase 20 | from .common import ReadWriteBinaryFileModes, Partition 21 | from ...common import FilePath, FilePathOrObject 22 | 23 | 24 | class UnformattedSaveError(InvalidPartitionContainerError): 25 | """ 26 | The archive appears to be unformatted. The difference here is that the first 0x20 bytes are all null bytes, 27 | the rest is garbage. 28 | """ 29 | 30 | 31 | class DISA(PartitionContainerBase): 32 | """ 33 | Reads and writes to DISA files. 34 | 35 | :param file: A file path or a file-like object with the DIFF data. 36 | :param mode: Mode to open the file with, passed to `open`. Only used if a file path was given. 37 | :param closefd: Close the underlying file object when closed. Defaults to `True` for file paths, and `False` for 38 | file-like objects. 39 | :param crypto: A custom :class:`~.CryptoEngine` object to be used. Defaults to None, which causes a new one to 40 | be created. 41 | :param dev: Use devunit keys. 42 | :param cmac_base: A :class:`~.CMACTypeBase` object that describes how to update the CMAC. 43 | :param sd_key_file: Path to a movable.sed file to load the SD KeyY from. 44 | :param sd_key: SD KeyY to use. Has priority over `sd_key_file` if both are specified. 45 | """ 46 | 47 | partitions: 'Dict[int, Partition]' 48 | """Partitions of the file. DISA can have one or two, so there is always `0` but there can be `1` as well.""" 49 | 50 | def __init__(self, file: 'FilePathOrObject', mode: 'ReadWriteBinaryFileModes' = 'rb', *, 51 | fs: 'Optional[FS]' = None, closefd: 'Optional[bool]' = None, crypto: 'CryptoEngine' = None, 52 | dev: bool = False, cmac_base: 'CMACTypeBase' = None, sd_key_file: 'FilePath' = None, 53 | sd_key: bytes = None): 54 | super().__init__(file, fs=fs, closefd=closefd, crypto=crypto, dev=dev, mode=mode, cmac_base=cmac_base, 55 | sd_key_file=sd_key_file, sd_key=sd_key) 56 | 57 | self._file.seek(0xF0, 1) 58 | self._header = self._file.read(0x100) 59 | 60 | magic = self._header[0:8] 61 | if magic != b'DISA\0\0\4\0': 62 | if self._header[0:0x20] == (b'\0' * 0x20): 63 | raise UnformattedSaveError('decryption may have worked but the save may have not been formatted') 64 | else: 65 | raise InvalidPartitionContainerError(f'DISA magic expected, got {magic}') 66 | 67 | partition_count = readle(self._header[0x8:0xC]) 68 | 69 | secondary_parttable_offset = readle(self._header[0x10:0x18]) 70 | primary_parttable_offset = readle(self._header[0x18:0x20]) 71 | parttable_size = readle(self._header[0x20:0x28]) 72 | 73 | self._partdesc_a_offset = readle(self._header[0x28:0x30]) 74 | self._partdesc_a_size = readle(self._header[0x30:0x38]) 75 | self._partdesc_b_offset = readle(self._header[0x38:0x40]) 76 | self._partdesc_b_size = readle(self._header[0x40:0x48]) 77 | 78 | partition_a_offset = readle(self._header[0x48:0x50]) 79 | partition_a_size = readle(self._header[0x50:0x58]) 80 | partition_b_offset = readle(self._header[0x58:0x60]) 81 | partition_b_size = readle(self._header[0x60:0x68]) 82 | 83 | active_parttable = self._header[0x68] 84 | 85 | active_parttable_hash = self._header[0x6C:0x8C] 86 | 87 | if active_parttable == 0: 88 | self._parttable_offset = primary_parttable_offset 89 | else: 90 | self._parttable_offset = secondary_parttable_offset 91 | 92 | self.unique_identifier = readle(self._header[0x54:0x5C]) 93 | 94 | self._seek(self._parttable_offset) 95 | parttable = self._file.read(parttable_size) 96 | if sha256(parttable).digest() != active_parttable_hash: 97 | raise CorruptPartitionError('Active partition table is corrupt') 98 | 99 | partdesc_a = parttable[self._partdesc_a_offset:self._partdesc_a_offset + self._partdesc_a_size] 100 | self._load_partition(0, partdesc_a, partition_a_offset, partition_a_size) 101 | 102 | if partition_count == 2: 103 | partdesc_b = parttable[self._partdesc_b_offset:self._partdesc_b_offset + self._partdesc_b_size] 104 | self._load_partition(1, partdesc_b, partition_b_offset, partition_b_size) 105 | 106 | def _update_hashes(self, partition: int, partdesc: bytes): 107 | """ 108 | Update master hashes, partition descriptor hash, and CMAC. 109 | 110 | :param partition: Unused for DIFF. This exists for consistency with DISA. 111 | :param partdesc: Partition descriptor in bytes. 112 | """ 113 | 114 | if partition == 0: 115 | partdesc_offset = self._partdesc_a_offset 116 | else: 117 | partdesc_offset = self._partdesc_b_offset 118 | 119 | if self._file.writable(): 120 | with self._lock: 121 | self._seek(self._parttable_offset + partdesc_offset) 122 | self._file.write(partdesc) 123 | 124 | partdesc_hash = sha256(partdesc) 125 | 126 | header_ba = bytearray(self._header) 127 | header_ba[0x6C:0x8C] = partdesc_hash.digest() 128 | self._header = bytes(header_ba) 129 | 130 | self._seek(0x100) 131 | self._file.write(self._header) 132 | 133 | self._update_cmac() 134 | -------------------------------------------------------------------------------- /pyctr/type/base/typereader.py: -------------------------------------------------------------------------------- 1 | # This file is a part of pyctr. 2 | # 3 | # Copyright (c) 2017-2023 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE in the root of this project. 6 | 7 | from functools import wraps 8 | from os import PathLike 9 | from typing import TYPE_CHECKING 10 | from weakref import WeakSet 11 | 12 | from ...common import PyCTRError, get_fs_file_object 13 | from ...crypto import CryptoEngine 14 | 15 | if TYPE_CHECKING: 16 | from typing import BinaryIO, Optional, Set 17 | from ...common import FilePathOrObject 18 | 19 | from fs.base import FS 20 | 21 | __all__ = ['raise_if_closed', 'ReaderError', 'ReaderClosedError', 'TypeReaderBase', 'TypeReaderCryptoBase'] 22 | 23 | 24 | def raise_if_closed(method): 25 | """ 26 | Wraps a method that raises an exception if the reader object is closed. 27 | 28 | :param method: The method to call if the file is not closed. 29 | :return: The wrapper method. 30 | """ 31 | @wraps(method) 32 | def decorator(self: 'TypeReaderBase', *args, **kwargs): 33 | if self.closed: 34 | raise ValueError('I/O operation on closed file') 35 | return method(self, *args, **kwargs) 36 | return decorator 37 | 38 | 39 | class ReaderError(PyCTRError): 40 | """Generic error for TypeReaderBase operations.""" 41 | 42 | 43 | class ReaderClosedError(ReaderError): 44 | """The reader object is closed.""" 45 | 46 | 47 | class TypeReaderBase: 48 | """ 49 | Base class for all reader classes. 50 | 51 | This handles types that are based in a single file. Therefore not every class will use this, such as SDFilesystem. 52 | 53 | :param file: A file path or a file-like object with the type's data. 54 | :param closefd: Close the underlying file object when closed. Defaults to `True` for file paths, and `False` for 55 | file-like objects. 56 | :param mode: Mode to open the file with, passed to `open`. This is set by type readers internally. Only used if 57 | a file path was given. 58 | """ 59 | 60 | __slots__ = ('_closefd', '_file', '_open_files', '_start', 'closed') 61 | 62 | closed: bool 63 | """`True` if the reader is closed.""" 64 | 65 | def __init__(self, file: 'FilePathOrObject', *, fs: 'Optional[FS]' = None, closefd: 'Optional[bool]' = None, 66 | mode: str = 'rb'): 67 | self.closed = False 68 | 69 | # Store a set of opened files based on this reader. 70 | # This is a WeakSet so these references aren't kept around when all other parts of the code have deleted it. 71 | # All of the files here get closed when the reader is closed. 72 | # The noinspection line is because some type checkers (PyCharm at least) don't recognize WeakSet as being a set, 73 | # even though it's similar. 74 | # noinspection PyTypeChecker 75 | self._open_files: Set[BinaryIO] = WeakSet() 76 | 77 | fileobj, newly_opened = get_fs_file_object(file, fs, mode=mode) 78 | 79 | if closefd is None: 80 | closefd = newly_opened 81 | 82 | self._closefd = closefd 83 | 84 | # Store the file in a private attribute. 85 | # noinspection PyTypeChecker 86 | self._file: BinaryIO = fileobj 87 | 88 | # Store the starting offset of the file. 89 | self._start = fileobj.tell() 90 | 91 | def __enter__(self): 92 | return self 93 | 94 | def __exit__(self, exc_type, exc_val, exc_tb): 95 | self.close() 96 | 97 | @property 98 | def _closed(self): 99 | # for PyFilesystem2 which checks _closed 100 | return self.closed 101 | 102 | def close(self): 103 | """Close the reader. If closefd is `True`, the underlying file is also closed.""" 104 | if not self.closed: 105 | self.closed = True 106 | try: 107 | if self._closefd: 108 | try: 109 | self._file.close() 110 | except AttributeError: 111 | pass 112 | except AttributeError: 113 | # closefd may not have been set yet 114 | pass 115 | 116 | for f in self._open_files: 117 | f.close() 118 | 119 | # frozenset can't be modified, so even if I made a mistake this prevents opening files on a closed reader 120 | self._open_files = frozenset() 121 | 122 | # sometimes close is overridden, so this can't just be `__del__ = close` or it will not call the intended one 123 | def __del__(self): 124 | self.close() 125 | 126 | def _seek(self, offset: int = 0, whence: int = 0): 127 | """Seek to an offset in the underlying file, relative to the starting offset.""" 128 | return self._file.seek(self._start + offset, whence) 129 | 130 | 131 | class TypeReaderCryptoBase(TypeReaderBase): 132 | """ 133 | Base class for reader classes that use a :class:`~.CryptoEngine` object.. 134 | 135 | :param file: A file path or a file-like object with the type's data. 136 | :param closefd: Close the underlying file object when closed. Defaults to `True` for file paths, and `False` for 137 | file-like objects. 138 | :param crypto: A custom :class:`crypto.CryptoEngine` object to be used. Defaults to None, which causes a new one to 139 | be created. This typically only works directly on the type, not any subtypes that might be created (e.g. 140 | :class:`~.CIAReader` creates :class:`~.NCCHReader`). 141 | :param dev: Use devunit keys. 142 | :param mode: Mode to open the file with, passed to `open`. This is set by type readers internally. Only used if 143 | a file path was given. 144 | """ 145 | 146 | __slots__ = ('_crypto',) 147 | 148 | def __init__(self, file: 'FilePathOrObject', *, fs: 'Optional[FS]' = None, closefd: bool = None, mode: str = 'rb', 149 | crypto: 'CryptoEngine' = None, dev: bool = False): 150 | super().__init__(file, fs=fs, closefd=closefd, mode=mode) 151 | 152 | if crypto: 153 | self._crypto = crypto 154 | else: 155 | self._crypto = CryptoEngine(dev=dev) 156 | -------------------------------------------------------------------------------- /pyctr/type/sdfs.py: -------------------------------------------------------------------------------- 1 | # This file is a part of pyctr. 2 | # 3 | # Copyright (c) 2017-2023 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE in the root of this project. 6 | 7 | """Module for interacting with encrypted SD card contents under the "Nintendo 3DS" directory.""" 8 | 9 | from os import fsdecode 10 | from typing import TYPE_CHECKING 11 | 12 | from fs import open_fs 13 | from fs.base import FS 14 | from fs.subfs import SubFS 15 | from fs.path import abspath, normpath, join 16 | 17 | from ..common import PyCTRError 18 | from ..crypto import CryptoEngine, KeyslotMissingError, Keyslot 19 | from .sdtitle import SDTitleReader 20 | 21 | if TYPE_CHECKING: 22 | from os import PathLike 23 | from typing import BinaryIO, Mapping, Optional 24 | from ..common import FilePath, DirPathOrFS 25 | 26 | # noinspection PyProtectedMember 27 | from ..crypto import CTRFileIO 28 | 29 | 30 | class SDFilesystemError(PyCTRError): 31 | """Generic exception for SD filesystem operations.""" 32 | 33 | 34 | class MissingMovableSedError(SDFilesystemError): 35 | """movable.sed key is not set up.""" 36 | 37 | 38 | class MissingID0Error(SDFilesystemError): 39 | """ID0 directory could not be found.""" 40 | 41 | 42 | class MissingID1Error(SDFilesystemError): 43 | """No ID1 directories exist in the ID0 directory.""" 44 | 45 | 46 | class MissingTitleError(SDFilesystemError): 47 | """The requested Title ID could not be found.""" 48 | 49 | 50 | class SDRoot: 51 | """ 52 | Opens an ID0 folder inside a "Nintendo 3DS" folder. 53 | 54 | :param path: Path to the Nintendo 3DS folder. 55 | :param crypto: A custom :class:`crypto.CryptoEngine` object to be used. Defaults to None, which causes a new one to 56 | be created. 57 | :param sd_key_file: Path to a movable.sed file to load the SD KeyY from. 58 | :param sd_key: SD KeyY to use. Has priority over `sd_key_file` if both are specified. 59 | :ivar id1s: A list of ID1 directories found in the ID0 directory. 60 | """ 61 | 62 | def __init__(self, path: 'DirPathOrFS', *, crypto: CryptoEngine = None, dev: bool = False, 63 | sd_key_file: 'FilePath' = None, sd_key: bytes = None): 64 | if crypto: 65 | self._crypto = crypto 66 | else: 67 | self._crypto = CryptoEngine(dev=dev) 68 | 69 | if sd_key: 70 | self._crypto.setup_sd_key(sd_key) 71 | elif sd_key_file: 72 | self._crypto.setup_sd_key_from_file(sd_key_file) 73 | 74 | try: 75 | self.id0 = self._crypto.id0.hex() 76 | except KeyslotMissingError: 77 | raise MissingMovableSedError('set up key with sd_key_file or sd_key') 78 | 79 | if isinstance(path, FS): 80 | self.fs = path 81 | else: 82 | self.fs = open_fs(fsdecode(path)) 83 | 84 | self.id1s = [] 85 | for id1 in self.fs.scandir(self.id0): 86 | try: 87 | # check if it decodes to hex 88 | bytes.fromhex(id1.name) 89 | except ValueError: 90 | pass 91 | else: 92 | if len(id1.name) == 32: 93 | self.id1s.append(id1.name) 94 | 95 | if len(self.id1s) == 0: 96 | raise MissingID1Error('could not find any ID1 directories in ' + self._crypto.id0.hex()) 97 | 98 | def open_id1(self, /, id1: 'Optional[str]' = None): 99 | if not id1: 100 | id1 = self.id1s[0] 101 | return self.fs.opendir(self.id0 + '/' + id1, lambda p, f: SDFS(p, f, crypto=self._crypto)) 102 | 103 | def open_title(self, /, title_id: str, *, case_insensitive: bool = False, seed: bytes = None, 104 | load_contents: bool = True, id1: 'Optional[str]' = None): 105 | fs = self.open_id1(id1) 106 | title_id = title_id.lower() 107 | sd_path = f'/title/{title_id[0:8]}/{title_id[8:16]}/content' 108 | 109 | tmds = [] 110 | for f in fs.listdir(sd_path): 111 | if f.endswith('.tmd'): 112 | tmds.append(f) 113 | 114 | if not tmds: 115 | raise MissingTitleError(title_id) 116 | 117 | # In case there is an in-progress download here, we choose the tmd with the smaller number, 118 | # so we can get the active title 119 | tmds.sort(key=lambda x: int(x[0:8])) 120 | 121 | return SDTitleReader(join(sd_path, tmds[0]), case_insensitive=case_insensitive, fs=fs, 122 | dev=self._crypto.dev, seed=seed, load_contents=load_contents) 123 | 124 | 125 | class SDFS(SubFS): 126 | """ 127 | Enables access to an SD card filesystem inside Nintendo 3DS/id0/id1. 128 | 129 | :param path: Path to the Nintendo 3DS folder. 130 | :param crypto: A custom :class:`crypto.CryptoEngine` object to be used. Defaults to None, which causes a new one to 131 | be created. 132 | :param sd_key_file: Path to a movable.sed file to load the SD KeyY from. 133 | :param sd_key: SD KeyY to use. Has priority over `sd_key_file` if both are specified. 134 | :ivar id1s: A list of ID1 directories found in the ID0 directory. 135 | :ivar current_id1: The ID1 directory used as the default when none is specified, initially set to the first value 136 | in id1s. 137 | """ 138 | 139 | __slots__ = ('_base_path', '_crypto', '_id0_path', 'current_id1', 'id1s') 140 | 141 | def __init__(self, parent_fs: 'FS', path: str, *, crypto: CryptoEngine): 142 | super().__init__(parent_fs, path) 143 | self._crypto = crypto 144 | 145 | def __enter__(self): 146 | return self 147 | 148 | def __exit__(self, exc_type, exc_val, exc_tb): 149 | pass 150 | 151 | def openbin(self, path: str, mode: str = 'r', buffering: int = -1, **options) -> 'BinaryIO': 152 | """ 153 | Opens a file in the SD filesystem, allowing decrypted access. 154 | 155 | Currently, files under "Nintendo DSiWare" cannot be opened. 156 | 157 | :param path: Path relative to the ID1 directory. 158 | :param mode: Mode to open the file with. 159 | :param buffering: Buffering policy (-1 to use default buffering, 0 to disable buffering, 160 | 1 to select line buffering, of any positive integer to indicate a buffer size). 161 | :return: A file-like object which decrypts and encrypts on the fly. 162 | :rtype: CTRFileIO 163 | """ 164 | # The way DSiWare exports are encrypted makes it annoying to do crypto on the fly. 165 | # A different method would have to be used to support them. 166 | if 'Nintendo DSiWare' in path: 167 | raise NotImplementedError('files under "Nintendo DSiWare" currently cannot be opened with this method') 168 | 169 | fh = super().openbin(path, mode, buffering, **options) 170 | return self._crypto.create_ctr_io(Keyslot.SD, fh, self._crypto.sd_path_to_iv(normpath(abspath(path))), 171 | closefd=True) 172 | 173 | def open( 174 | self, 175 | path, 176 | mode: str = 'rb', 177 | buffering: int = -1, 178 | encoding: 'Optional[str]' = None, 179 | errors: 'Optional[str]' = None, 180 | newline: str = '', 181 | **options 182 | ) -> 'BinaryIO': 183 | if 'b' not in mode: 184 | mode += 'b' 185 | if 't' in mode: 186 | raise NotImplementedError('text mode is not supported') 187 | # noinspection PyTypeChecker 188 | return self.openbin(path, mode, buffering, **options) 189 | 190 | def getmeta(self, namespace: str = 'standard') -> 'Mapping[str, object]': 191 | meta = dict(super().getmeta(namespace)) 192 | meta['supports_rename'] = False 193 | return meta 194 | -------------------------------------------------------------------------------- /pyctr/type/sdtitle.py: -------------------------------------------------------------------------------- 1 | # This file is a part of pyctr. 2 | # 3 | # Copyright (c) 2017-2023 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE in the root of this project. 6 | 7 | from enum import IntEnum 8 | from os import fsdecode 9 | from pathlib import Path, PurePosixPath 10 | from typing import TYPE_CHECKING 11 | from weakref import WeakSet 12 | 13 | from fs import open_fs 14 | from fs.base import FS 15 | from fs.osfs import OSFS 16 | from fs.path import dirname as fs_dirname, join as fs_join, basename as fs_basename 17 | 18 | from ..common import PyCTRError 19 | from ..crypto import add_seed 20 | from .ncch import NCCHReader 21 | from .tmd import TitleMetadataReader 22 | 23 | if TYPE_CHECKING: 24 | from pathlib import PurePath 25 | from typing import BinaryIO, Dict, List, Set, Union 26 | 27 | from ..common import FilePath 28 | from .ncch import NCCHReader 29 | from .sd import SDFilesystem 30 | from .tmd import ContentChunkRecord 31 | 32 | 33 | class SDTitleError(PyCTRError): 34 | """Generic error for SD Title operations.""" 35 | 36 | 37 | class SDTitleSection(IntEnum): 38 | TitleMetadata = -1 39 | """Contains information about all the possible contents.""" 40 | Application = 0 41 | """Main application CXI.""" 42 | Manual = 1 43 | """Manual CFA. It has a RomFS with a single "Manual.bcma" file inside.""" 44 | DownloadPlayChild = 2 45 | """ 46 | Download Play Child CFA. It has a RomFS with CIA files that are sent to other Nintendo 3DS systems using 47 | Download Play. Most games only contain one. 48 | """ 49 | 50 | 51 | class SDTitleReader: 52 | """ 53 | Reads the contents of files installed on the SD card inside "Nintendo 3DS". 54 | 55 | By default, this only works with contents that do not use SD encryption (i.e. tmd and contents are plaintext). To 56 | read contents currently encrypted on an SD card, :class:`~.SDFilesystem` is needed, and provides a method to easily 57 | open a title's contents. (NYI) 58 | 59 | Only NCCH contents are supported. SRL (DSiWare) contents are currently ignored. 60 | 61 | :param file: A path to a tmd file. All the contents should be in the same directory. 62 | :param fs: An :meth:`FS` object or an `FS URL `. 63 | :param case_insensitive: Use case-insensitive paths for the RomFS of each NCCH container. 64 | :param dev: Use devunit keys. 65 | :param seed: Seed to use. This is a quick way to add a seed using :func:`~.seeddb.add_seed`. 66 | :param load_contents: Load each partition with :class:`~.NCCHReader`. 67 | :param sdfs: :class:`~.SDFilesystem` object to use, if opening contents that are currently encrypted. Usually this 68 | should not be set directly, instead open the title through :class:`~.SDFilesystem`. 69 | :param sd_id1: ID1 to use if opened through :class:`~.SDFilesystem`. 70 | """ 71 | 72 | __slots__ = ( 73 | '_base_files', '_open_files', 'available_sections', 'closed', 'content_info', 'contents', 'sd_id1', 'sdfs', 74 | 'tmd', 'fs' 75 | ) 76 | 77 | available_sections: 'List[Union[SDTitleSection, int]]' 78 | """A list of sections available, including contents, ticket, and title metadata.""" 79 | 80 | closed: bool 81 | """`True` if the reader is closed.""" 82 | 83 | contents: 'Dict[int, NCCHReader]' 84 | """A `dict` of :class:`~.NCCHReader` objects for each active NCCH content.""" 85 | 86 | content_info: 'List[ContentChunkRecord]' 87 | """ 88 | A list of :class:`~.ContentChunkRecord` objects for each content found in the directory at the time of object 89 | initialization. 90 | """ 91 | 92 | tmd: TitleMetadataReader 93 | """The :class:`~.TitleMetadataReader` object with information from the TMD section.""" 94 | 95 | def __init__(self, file: 'FilePath', *, fs: 'FS' = None, case_insensitive: bool = False, dev: bool = False, 96 | seed: bytes = None, load_contents: bool = True, sdfs: 'SDFilesystem' = None, sd_id1: str = None): 97 | self.closed = False 98 | 99 | self.sdfs = sdfs 100 | self.sd_id1 = sd_id1 101 | 102 | self.contents = {} 103 | self.content_info = [] 104 | 105 | # {section: filepath} 106 | self._base_files: Dict[Union[SDTitleSection, int], PurePath] = {} 107 | 108 | # opened files to close if the SDTitleReader is closed 109 | # noinspection PyTypeChecker 110 | self._open_files: Set[BinaryIO] = WeakSet() 111 | 112 | # public method to see what sections can be accessed 113 | self.available_sections = [] 114 | 115 | if self.sdfs: 116 | # custom FS is not supported here (this section is deprecated anyway) 117 | file = PurePosixPath(file) 118 | title_root = fsdecode(file.parent) 119 | fs = OSFS(title_root) 120 | file = file.name 121 | else: 122 | if fs: 123 | if not isinstance(fs, FS): 124 | fs = open_fs(fs) 125 | title_root = fs_dirname(file) 126 | file = fs_basename(file) 127 | else: 128 | # the path being absolute makes Path.parent work reliably 129 | file = Path(file).absolute() 130 | fs = OSFS(fsdecode(file.parent)) 131 | title_root = '/' 132 | file = file.name 133 | self.fs = fs 134 | 135 | def add_file(section: 'Union[SDTitleSection, int]', path: str): 136 | self._base_files[section] = path 137 | self.available_sections.append(section) 138 | 139 | add_file(SDTitleSection.TitleMetadata, fs_join(title_root, file)) 140 | 141 | with self.open_raw_section(SDTitleSection.TitleMetadata) as tmd: 142 | self.tmd = TitleMetadataReader.load(tmd) 143 | 144 | if seed: 145 | add_seed(self.tmd.title_id, seed) 146 | 147 | for record in self.tmd.chunk_records: 148 | # check if the content is a Nintendo DS ROM (SRL) 149 | is_srl = record.cindex == 0 and self.tmd.title_id[3:5] == '48' 150 | 151 | # this should ideally never be uppercase in practice 152 | # since the console stores these as lowercase 153 | content_file = fs_join(title_root, record.id + '.app') 154 | if self.sdfs: 155 | if not self.sdfs.isfile(str(content_file), id1=self.sd_id1): 156 | # can't find the file, so continue to the next record 157 | continue 158 | else: 159 | if not fs.isfile(content_file): 160 | continue 161 | 162 | self.content_info.append(record) 163 | add_file(record.cindex, content_file) 164 | 165 | # this needs to check how many files are being opened 166 | if load_contents and not is_srl: 167 | decrypted_file = self.open_raw_section(record.cindex) 168 | self.contents[record.cindex] = NCCHReader(decrypted_file, case_insensitive=case_insensitive, dev=dev) 169 | 170 | def __enter__(self): 171 | return self 172 | 173 | def __exit__(self, exc_type, exc_val, exc_tb): 174 | self.close() 175 | 176 | def close(self): 177 | """Close the reader.""" 178 | if not self.closed: 179 | self.closed = True 180 | for cindex, content in self.contents.items(): 181 | content.close() 182 | for f in self._open_files: 183 | f.close() 184 | 185 | self.contents = {} 186 | # frozenset can't be modified, so even if I made a mistake this prevents opening files on a closed reader 187 | self._open_files = frozenset() 188 | 189 | __del__ = close 190 | 191 | def __repr__(self): 192 | info = [('title_id', self.tmd.title_id)] 193 | try: 194 | info.append(('title_name', repr(self.contents[0].exefs.icon.get_app_title().short_desc))) 195 | except KeyError: 196 | info.append(('title_name', 'unknown')) 197 | info.append(('content_count', len(self.contents))) 198 | info_final = " ".join(x + ": " + str(y) for x, y in info) 199 | return f'<{type(self).__name__} {info_final}>' 200 | 201 | def open_raw_section(self, section: 'Union[SDTitleSection, int]') -> 'BinaryIO': 202 | """ 203 | Open a raw content for reading. 204 | 205 | :param section: The content to open. 206 | :return: A file-like object that reads from the content. 207 | :rtype: io.BufferedIOBase | CTRFileIO 208 | """ 209 | filepath = self._base_files[section] 210 | if self.sdfs: 211 | f = self.sdfs.open(str(filepath), 'rb', id1=self.sd_id1) 212 | else: 213 | f = self.fs.open(filepath, 'rb') 214 | self._open_files.add(f) 215 | return f 216 | -------------------------------------------------------------------------------- /tests/test_romfs.py: -------------------------------------------------------------------------------- 1 | # This file is a part of pyctr. 2 | # 3 | # Copyright (c) 2017-2023 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE in the root of this project. 6 | 7 | from io import BytesIO 8 | from os.path import dirname, join, realpath 9 | 10 | from hashlib import sha256 11 | 12 | import pytest 13 | 14 | from pyctr.type import romfs 15 | from fs.info import Info 16 | from fs.enums import ResourceType 17 | 18 | 19 | def get_file_path(*parts: str): 20 | return join(dirname(realpath(__file__)), *parts) 21 | 22 | 23 | def get_romfs_path(): 24 | return get_file_path('fixtures', 'romfs.bin') 25 | 26 | 27 | def load_romfs_into_bytesio(): 28 | with open(get_romfs_path(), 'rb') as raw: 29 | mem = BytesIO(raw.read()) 30 | return mem 31 | 32 | 33 | def open_romfs(case_insensitive=False, closefd=None, open_compatibility_mode=True): 34 | return romfs.RomFSReader(get_romfs_path(), 35 | case_insensitive=case_insensitive, 36 | closefd=closefd, 37 | open_compatibility_mode=open_compatibility_mode) 38 | 39 | 40 | def test_no_file(): 41 | with pytest.raises(FileNotFoundError): 42 | romfs.RomFSReader('nope.bin') 43 | 44 | 45 | def test_read_file(): 46 | with open_romfs() as reader: 47 | with reader.open('/utf16.txt', 'rb') as f: 48 | data = f.read() 49 | filehash = sha256(data) 50 | assert filehash.hexdigest() == '1ac2ddff4940809ea36a3e82e9f28bc2f5733275c1baa6ce9f5e434b3a7eab5b' 51 | 52 | 53 | def test_read_text_file(): 54 | with open_romfs() as reader: 55 | with reader.open('/utf16.txt', encoding='utf-16') as f: 56 | text = f.read() 57 | assert text == 'UTF-16 text!\nFor testing!' 58 | 59 | 60 | def test_read_past_file(): 61 | with open_romfs() as reader: 62 | with reader.open('/utf16.txt', 'rb') as f: 63 | # This file is 0x34 (52) bytes, this should hopefully not read more than that. 64 | data = f.read(0x40) 65 | assert len(data) == 0x34 66 | 67 | 68 | def test_get_file_info(): 69 | with open_romfs() as reader: 70 | info = reader.getinfo('/utf16.txt') 71 | assert isinstance(info, Info) 72 | assert info.name == 'utf16.txt' 73 | assert info.type == ResourceType.file 74 | assert info.size == 52 75 | assert info.get('rawfs', 'offset') == 0 76 | 77 | 78 | def test_get_dir_info(): 79 | with open_romfs() as reader: 80 | info = reader.getinfo('/') 81 | assert isinstance(info, Info) 82 | assert info.name == 'ROOT' 83 | assert info.type == ResourceType.directory 84 | 85 | 86 | def test_listdir(): 87 | with open_romfs() as reader: 88 | contents = reader.listdir('./') 89 | assert contents == ['testdir', 'utf16.txt', 'utf8.txt'] 90 | 91 | 92 | def test_get_nonroot_dir_info(): 93 | with open_romfs() as reader: 94 | info = reader.getinfo('/testdir') 95 | assert isinstance(info, Info) 96 | assert info.name == 'testdir' 97 | assert info.type == ResourceType.directory 98 | 99 | 100 | def test_nonroot_listdir(): 101 | with open_romfs() as reader: 102 | contents = reader.listdir('/testdir') 103 | assert contents == ['emptyfile.bin'] 104 | 105 | 106 | def test_missing_file(): 107 | with open_romfs(open_compatibility_mode=False) as reader: 108 | with pytest.raises(romfs.RomFSFileNotFoundError): 109 | reader.open('/nonexistant.bin') 110 | 111 | 112 | def test_get_missing_file_info(): 113 | with open_romfs() as reader: 114 | with pytest.raises(romfs.RomFSFileNotFoundError): 115 | reader.getinfo('/nonexistant.bin') 116 | 117 | 118 | def test_open_on_directory(): 119 | with open_romfs(open_compatibility_mode=False) as reader: 120 | with pytest.raises(romfs.RomFSIsADirectoryError): 121 | reader.open('/testdir') 122 | 123 | 124 | def test_case_insensitive(): 125 | with open_romfs(case_insensitive=True) as reader: 126 | assert reader.getinfo('/TESTDIR/EMPTYFILE.BIN').name == 'emptyfile.bin' 127 | 128 | 129 | def test_closefd_false(): 130 | reader = open_romfs(closefd=False) 131 | assert reader._file.closed is False 132 | reader.close() 133 | assert reader.closed is True 134 | assert reader._file.closed is False 135 | 136 | 137 | def test_no_open_compatibility_mode(): 138 | with open_romfs(open_compatibility_mode=False) as reader: 139 | # fs defaults to utf-8 so let's see if this opens and reads at that successfully 140 | with reader.open('/utf8.txt') as f: 141 | data = f.read() 142 | assert data == 'UTF-8 test:\nニンテンドー3DS' 143 | 144 | 145 | def test_deprecated_read_file(): 146 | with open_romfs() as reader: 147 | with pytest.warns(DeprecationWarning): 148 | with reader.open('/utf16.txt') as f: 149 | data = f.read() 150 | filehash = sha256(data) 151 | assert filehash.hexdigest() == '1ac2ddff4940809ea36a3e82e9f28bc2f5733275c1baa6ce9f5e434b3a7eab5b' 152 | 153 | 154 | def test_deprecated_read_past_file(): 155 | with open_romfs() as reader: 156 | with pytest.warns(DeprecationWarning): 157 | with reader.open('/utf16.txt') as f: 158 | # This file is 0x34 (52) bytes, this should hopefully not read more than that. 159 | data = f.read(0x40) 160 | assert len(data) == 0x34 161 | 162 | 163 | def test_deprecated_get_file_info(): 164 | with open_romfs() as reader: 165 | with pytest.warns(DeprecationWarning): 166 | info = reader.get_info_from_path('/utf16.txt') 167 | assert isinstance(info, romfs.RomFSFileEntry) 168 | assert info.name == 'utf16.txt' 169 | assert info.type == 'file' 170 | assert info.offset == 0 171 | assert info.size == 52 172 | 173 | 174 | def test_deprecated_get_dir_info(): 175 | with open_romfs() as reader: 176 | with pytest.warns(DeprecationWarning): 177 | info = reader.get_info_from_path('/') 178 | assert isinstance(info, romfs.RomFSDirectoryEntry) 179 | assert info.name == 'ROOT' 180 | assert info.type == 'dir' 181 | assert info.contents == ('testdir', 'utf16.txt', 'utf8.txt') 182 | 183 | 184 | def test_deprecated_get_nonroot_dir_info(): 185 | with open_romfs() as reader: 186 | with pytest.warns(DeprecationWarning): 187 | info = reader.get_info_from_path('/testdir') 188 | assert isinstance(info, romfs.RomFSDirectoryEntry) 189 | assert info.name == 'testdir' 190 | assert info.type == 'dir' 191 | assert info.contents == ('emptyfile.bin',) 192 | 193 | 194 | def test_deprecated_get_missing_file_info(): 195 | with open_romfs() as reader: 196 | with pytest.warns(DeprecationWarning), pytest.raises(romfs.RomFSFileNotFoundError): 197 | reader.get_info_from_path('/nonexistant.bin') 198 | 199 | 200 | romfs_corrupt_params = ( 201 | (0x4, b'ABCD', romfs.InvalidIVFCError, 'IVFC magic number is invalid (0X44434241 instead of 0X10000)'), 202 | (0x1000, b'FFFF', romfs.InvalidRomFSHeaderError, 'Length in RomFS Lv3 header is not 0x28'), 203 | (0x1004, b'\0\0\0\0', romfs.InvalidRomFSHeaderError, 'Directory Hash offset is before the end of the Lv3 header'), 204 | (0x1008, b'\xff\0\0\0', romfs.InvalidRomFSHeaderError, 205 | 'Directory Metadata offset is before the end of the Directory Hash region'), 206 | (0x100C, b'\0\0\0\0', romfs.InvalidRomFSHeaderError, 207 | 'Directory Metadata offset is before the end of the Directory Hash region'), 208 | (0x1010, b'\xff\0\0\0', romfs.InvalidRomFSHeaderError, 209 | 'File Hash offset is before the end of the Directory Metadata region'), 210 | (0x1014, b'\0\0\0\0', romfs.InvalidRomFSHeaderError, 211 | 'File Hash offset is before the end of the Directory Metadata region'), 212 | (0x1018, b'\xff\0\0\0', romfs.InvalidRomFSHeaderError, 213 | 'File Metadata offset is before the end of the File Hash region'), 214 | (0x101C, b'\0\0\0\0', romfs.InvalidRomFSHeaderError, 215 | 'File Metadata offset is before the end of the File Hash region'), 216 | (0x1020, b'\xff\0\0\0', romfs.InvalidRomFSHeaderError, 217 | 'File Data offset is before the end of the File Metadata region'), 218 | (0x1024, b'\0\0\0\0', romfs.InvalidRomFSHeaderError, 219 | 'File Data offset is before the end of the File Metadata region'), 220 | ) 221 | 222 | 223 | @pytest.mark.parametrize('seek,data,exc,excstring', romfs_corrupt_params) 224 | def test_corrupt_romfs_file(seek, data, exc, excstring): 225 | with load_romfs_into_bytesio() as mem: 226 | mem.seek(seek) 227 | mem.write(data) 228 | mem.seek(0) 229 | 230 | with pytest.raises(exc) as excinfo: 231 | romfs.RomFSReader(mem) 232 | 233 | assert excstring == str(excinfo.value) 234 | -------------------------------------------------------------------------------- /pyctr/type/cci.py: -------------------------------------------------------------------------------- 1 | # This file is a part of pyctr. 2 | # 3 | # Copyright (c) 2017-2023 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE in the root of this project. 6 | 7 | """Module for interacting with CTR Cart Image (CCI) files.""" 8 | 9 | from enum import Enum, IntEnum, auto 10 | from typing import TYPE_CHECKING, NamedTuple 11 | 12 | from ..common import PyCTRError 13 | from ..fileio import SubsectionIO 14 | from ..type.ncch import NCCHReader 15 | from ..util import readle 16 | from .base import TypeReaderBase 17 | 18 | if TYPE_CHECKING: 19 | from typing import Dict 20 | from ..common import FilePathOrObject 21 | 22 | CCI_MEDIA_UNIT = 0x200 23 | 24 | 25 | class CCIError(PyCTRError): 26 | """Generic error for CCI operations.""" 27 | 28 | 29 | class InvalidCCIError(CCIError): 30 | """Invalid CCI header exception.""" 31 | 32 | 33 | class CCISection(IntEnum): 34 | """Partition indexes in a CCI.""" 35 | Header = -3 36 | """Header of the CCI file.""" 37 | CardInfo = -2 38 | """Card Info Header. https://www.3dbrew.org/wiki/NCSD#Card_Info_Header""" 39 | DevInfo = -1 40 | """ 41 | Development Card Info Header. Some flashcarts use this for "private headers" which are unique to each produced 42 | game card. 43 | """ 44 | Application = 0 45 | """Main application CXI.""" 46 | Manual = 1 47 | """Manual CFA. It has a RomFS with a single "Manual.bcma" file inside.""" 48 | DownloadPlayChild = 2 49 | """ 50 | Download Play Child CFA. It has a RomFS with CIA files that are sent to other Nintendo 3DS systems using 51 | Download Play. Most games only contain one. 52 | """ 53 | Unk3 = 3 54 | """Never seems to be used in practice.""" 55 | Unk4 = 4 56 | """Never seems to be used in practice.""" 57 | Unk5 = 5 58 | """Never seems to be used in practice.""" 59 | UpdateNew3DS = 6 60 | """ 61 | Update CFA for New Nintendo 3DS systems. It has a RomFS with a "SNAKE" directory, then contains the same as 62 | :attr:`UpdateOld3DS`. Any Title IDs in "cup_list" that are not in this partition are loaded from 63 | :attr:`UpdateOld3DS`. 64 | """ 65 | UpdateOld3DS = 7 66 | """ 67 | Update CFA for Old Nintendo 3DS systems. It has a RomFS with a "cup_list" file that is 0x800 bytes and is a list of 68 | Title IDs in the update. The rest are CIA files with matching Title ID filenames. 69 | """ 70 | 71 | 72 | class CCIRegion(NamedTuple): 73 | section: 'CCISection' 74 | offset: int 75 | size: int 76 | 77 | 78 | class CCICartRegion(Enum): 79 | USA = 'USA' 80 | EUR = 'EUR' 81 | JPN = 'JPN' 82 | CHN = 'CHN' 83 | KOR = 'KOR' 84 | TWN = 'TWN' 85 | Unknown = 'Unknown' 86 | 87 | 88 | class CCIReader(TypeReaderBase): 89 | """ 90 | Reads the contents of CCI files, usually dumps from Nintendo 3DS game cards. 91 | 92 | A CCI file can contain 8 partitions; in practice, only 0, 1, 2, 6 and 7 seem to be used. 93 | 94 | Note that a custom :class:`~.CryptoEngine` object cannot be given, as it can only store keys for a single 95 | :class:`~.NCCHReader`. To use a custom one, set `load_contents` to `False`, then load each section manually 96 | with `open_raw_section`. 97 | 98 | :param file: A file path or a file-like object with the CCI data. 99 | :param case_insensitive: Use case-insensitive paths for the RomFS of each NCCH container. 100 | :param dev: Use devunit keys. 101 | :param load_contents: Load each partition with :class:`~.NCCHReader`. 102 | :param assume_decrypted: Assume each NCCH content is decrypted. Needed if the image was decrypted without fixing 103 | the NCCH flags. 104 | """ 105 | 106 | __slots__ = ('_case_insensitive', 'cart_region', 'contents', 'image_size', 'media_id', 'sections') 107 | 108 | cart_region: CCICartRegion 109 | """Region that the game card is for. This is detected by checking the files in the Update RomFS.""" 110 | 111 | image_size: int 112 | """Image size in bytes. This does not always match the file size on disk.""" 113 | 114 | sections: 'Dict[CCISection, CCIRegion]' 115 | """A list of :class:`CCIRegion` objects containing the offset and size of each partition.""" 116 | 117 | contents: 'Dict[CCISection, NCCHReader]' 118 | """A list of :class:`~.NCCHReader` objects for each partition.""" 119 | 120 | media_id: str 121 | """Same as the Title ID of the application.""" 122 | 123 | def __init__(self, file: 'FilePathOrObject', *, fs: 'Optional[FS]' = None, closefd: bool = None, 124 | case_insensitive: bool = True, dev: bool = False, load_contents: bool = True, 125 | assume_decrypted: bool = False): 126 | super().__init__(file, fs=fs, closefd=closefd) 127 | 128 | # store case-insensitivity for RomFSReader 129 | self._case_insensitive = case_insensitive 130 | 131 | # this contains the location of each section 132 | self.sections = {} 133 | 134 | # this contains loaded sections 135 | self.contents = {} 136 | 137 | # ignore the signature, we don't need it 138 | self._file.seek(0x100, 1) 139 | header = self._file.read(0x100) 140 | if header[0:4] != b'NCSD': 141 | raise InvalidCCIError('NCSD magic not found') 142 | 143 | # make sure the Media ID is not 00, which is used for the NAND header 144 | self.media_id = header[0x8:0x10][::-1].hex() 145 | if self.media_id == '00' * 8: 146 | raise InvalidCCIError('Not a CCI, this is a NAND') 147 | 148 | self.image_size = readle(header[4:8]) * CCI_MEDIA_UNIT 149 | 150 | def add_region(section: 'CCISection', offset: int, size: int): 151 | region = CCIRegion(section=section, offset=offset, size=size) 152 | self.sections[section] = region 153 | 154 | # add each part of the header 155 | add_region(CCISection.Header, 0, 0x200) 156 | add_region(CCISection.CardInfo, 0x200, 0x1000) 157 | add_region(CCISection.DevInfo, 0x1200, 0x300) 158 | 159 | # use a CCISection value for section keys 160 | partition_sections = [x for x in CCISection if x >= 0] 161 | 162 | part_raw = header[0x20:0x60] 163 | 164 | # the first content always starts at 0x4000 but this code makes no assumptions about it 165 | for idx, info_offset in enumerate(range(0, 0x40, 0x8)): 166 | part_info = part_raw[info_offset:info_offset + 8] 167 | part_offset = readle(part_info[0:4]) * CCI_MEDIA_UNIT 168 | part_size = readle(part_info[4:8]) * CCI_MEDIA_UNIT 169 | if part_offset: 170 | section_id = partition_sections[idx] 171 | add_region(section_id, part_offset, part_size) 172 | 173 | if load_contents: 174 | content_fp = self.open_raw_section(section_id) 175 | self.contents[section_id] = NCCHReader(content_fp, case_insensitive=case_insensitive, dev=dev, 176 | assume_decrypted=assume_decrypted) 177 | 178 | self.cart_region = CCICartRegion.Unknown 179 | try: 180 | update_romfs = self.contents[CCISection.UpdateOld3DS].romfs 181 | version_cias = { # CVer 182 | '000400db00017102.cia': CCICartRegion.EUR, 183 | '000400db00017202.cia': CCICartRegion.JPN, 184 | '000400db00017302.cia': CCICartRegion.USA, 185 | '000400db00017402.cia': CCICartRegion.CHN, 186 | '000400db00017502.cia': CCICartRegion.KOR, 187 | '000400db00017602.cia': CCICartRegion.TWN, 188 | } 189 | update_contents = update_romfs.get_info_from_path('/').contents 190 | for cia_name, region in version_cias.items(): 191 | if cia_name in update_contents: 192 | self.cart_region = region 193 | break 194 | except KeyError: 195 | pass 196 | 197 | def __repr__(self): 198 | info = [('media_id', self.media_id)] 199 | try: 200 | info.append(('title_name', 201 | repr(self.contents[CCISection.Application].exefs.icon.get_app_title().short_desc))) 202 | except KeyError: 203 | info.append(('title_name', 'unknown')) 204 | info.append(('partition_count', len(self.contents))) 205 | info_final = " ".join(x + ": " + str(y) for x, y in info) 206 | return f'<{type(self).__name__} {info_final}>' 207 | 208 | def open_raw_section(self, section: 'CCISection'): 209 | """ 210 | Open a raw CCI section for reading. 211 | 212 | :param section: The section to open. 213 | :return: A file-like object that reads from the section. 214 | :rtype: SubsectionIO 215 | """ 216 | region = self.sections[section] 217 | f = SubsectionIO(self._file, self._start + region.offset, region.size) 218 | self._open_files.add(f) 219 | return f 220 | -------------------------------------------------------------------------------- /pyctr/type/sd.py: -------------------------------------------------------------------------------- 1 | # This file is a part of pyctr. 2 | # 3 | # Copyright (c) 2017-2023 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE in the root of this project. 6 | 7 | """ 8 | Module for interacting with encrypted SD card contents under the "Nintendo 3DS" directory. 9 | 10 | .. deprecated:: 0.8.0 11 | Replaced with :mod:`~pyctr.type.sdfs`. 12 | """ 13 | 14 | from os import fsdecode 15 | from os.path import isfile, isdir 16 | from pathlib import Path 17 | from typing import TYPE_CHECKING 18 | from warnings import warn 19 | 20 | from ..common import PyCTRError 21 | from ..crypto import CryptoEngine, KeyslotMissingError, Keyslot 22 | from .sdtitle import SDTitleReader 23 | 24 | if TYPE_CHECKING: 25 | from os import PathLike 26 | from typing import BinaryIO, List, Union 27 | from ..common import FilePath 28 | 29 | # noinspection PyProtectedMember 30 | from ..crypto import CTRFileIO 31 | 32 | 33 | warn('pyctr.type.sd is deprecated, use pyctr.type.sdfs instead', 34 | DeprecationWarning) 35 | 36 | 37 | class SDFilesystemError(PyCTRError): 38 | """Generic exception for SD filesystem operations.""" 39 | 40 | 41 | class MissingMovableSedError(SDFilesystemError): 42 | """movable.sed key is not set up.""" 43 | 44 | 45 | class MissingID0Error(SDFilesystemError): 46 | """ID0 directory could not be found.""" 47 | 48 | 49 | class MissingID1Error(SDFilesystemError): 50 | """No ID1 directories exist in the ID0 directory.""" 51 | 52 | 53 | class MissingTitleError(SDFilesystemError): 54 | """The requested Title ID could not be found.""" 55 | 56 | 57 | def normalize_sd_path(path: 'Union[PathLike, str]'): 58 | return str(path).lstrip('/').lstrip('\\') 59 | 60 | 61 | class SDFilesystem: 62 | """ 63 | Allows access to encrypted SD card contents under the "Nintendo 3DS" directory. 64 | 65 | :param path: Path to the Nintendo 3DS folder. 66 | :param crypto: A custom :class:`crypto.CryptoEngine` object to be used. Defaults to None, which causes a new one to 67 | be created. 68 | :param sd_key_file: Path to a movable.sed file to load the SD KeyY from. 69 | :param sd_key: SD KeyY to use. Has priority over `sd_key_file` if both are specified. 70 | :ivar id1s: A list of ID1 directories found in the ID0 directory. 71 | :ivar current_id1: The ID1 directory used as the default when none is specified, initially set to the first value 72 | in id1s. 73 | """ 74 | 75 | __slots__ = ('_base_path', '_crypto', '_id0_path', 'current_id1', 'id1s') 76 | 77 | def __init__(self, path: 'FilePath', *, crypto: CryptoEngine = None, dev: bool = False, 78 | sd_key_file: 'FilePath' = None, sd_key: bytes = None): 79 | if crypto: 80 | self._crypto = crypto 81 | else: 82 | self._crypto = CryptoEngine(dev=dev) 83 | 84 | if sd_key: 85 | self._crypto.setup_sd_key(sd_key) 86 | elif sd_key_file: 87 | self._crypto.setup_sd_key_from_file(sd_key_file) 88 | 89 | self._base_path = Path(fsdecode(path)).absolute() 90 | 91 | try: 92 | self._id0_path = self._base_path / self._crypto.id0.hex() 93 | except KeyslotMissingError: 94 | raise MissingMovableSedError('set up key with sd_key_file or sd_key') 95 | 96 | if not self._id0_path.is_dir(): 97 | raise MissingID0Error(self._crypto.id0.hex()) 98 | 99 | self.id1s = [] 100 | for id1 in self._id0_path.iterdir(): 101 | try: 102 | # check if it decodes to hex 103 | bytes.fromhex(id1.name) 104 | except ValueError: 105 | pass 106 | else: 107 | if len(id1.name) == 32: 108 | self.id1s.append(id1.name) 109 | 110 | if len(self.id1s) == 0: 111 | raise MissingID1Error('could not find any ID1 directories in ' + self._crypto.id0.hex()) 112 | 113 | self.current_id1 = self.id1s[0] 114 | 115 | def __enter__(self): 116 | return self 117 | 118 | def __exit__(self, exc_type, exc_val, exc_tb): 119 | pass 120 | 121 | def _get_real_path(self, path: str, id1: str = None): 122 | if not id1: 123 | id1 = self.current_id1 124 | return self._id0_path / id1 / path 125 | 126 | def open(self, path: 'Union[PathLike, str]', mode: str = 'rb', *, id1: str = None) -> 'CTRFileIO': 127 | """ 128 | Opens a file in the SD filesystem, allowing decrypted access. 129 | 130 | Currently, files under "Nintendo DSiWare" cannot be opened. 131 | 132 | :param path: Path relative to the ID1 directory. 133 | :param mode: Mode to open the file with. Binary mode is always used. 134 | :param id1: ID1 directory to use. Defaults to current_id1. 135 | :return: A file-like object which decrypts and encrypts on the fly. 136 | :rtype: CTRFileIO 137 | """ 138 | # The way DSiWare exports are encrypted makes it annoying to do crypto on the fly. 139 | # A different method would have to be used to support them. 140 | if 'Nintendo DSiWare' in path: 141 | raise NotImplementedError('files under "Nintendo DSiWare" currently cannot be opened with this method') 142 | 143 | if not id1: 144 | id1 = self.id1s[0] 145 | 146 | if 'b' not in mode: 147 | # force binary mode, since the 3DS does not use text files here 148 | mode += 'b' 149 | 150 | path = normalize_sd_path(path) 151 | real_path = self._get_real_path(path, id1) 152 | 153 | # since we're forcing opening in binary mode, we can assume this will be BinaryIO 154 | # noinspection PyTypeChecker 155 | fh: BinaryIO = real_path.open(mode) 156 | return self._crypto.create_ctr_io(Keyslot.SD, fh, self._crypto.sd_path_to_iv('/' + path)) 157 | 158 | def listdir(self, path: 'Union[PathLike, str]', id1: str = None) -> 'List[str]': 159 | """ 160 | Returns a list of files in the directory. 161 | 162 | :param path: Directory to list the contents of. 163 | :param id1: ID1 directory to use. Defaults to current_id1. 164 | :return: A list of files in the directory. 165 | :rtype: list 166 | """ 167 | real_path = self._get_real_path(normalize_sd_path(path), id1) 168 | return list(x.name for x in real_path.iterdir()) 169 | 170 | def isfile(self, path: 'Union[PathLike, str]', id1: str = None) -> bool: 171 | """ 172 | Checks if the path points to a file. 173 | 174 | :param path: Path to check. 175 | :param id1: ID1 directory to use. Defaults to current_id1. 176 | :return: `True` if the file exists, `False` otherwise. 177 | :rtype: bool 178 | """ 179 | real_path = self._get_real_path(normalize_sd_path(path), id1) 180 | return isfile(real_path) 181 | 182 | def isdir(self, path: 'Union[PathLike, str]', id1: str = None) -> bool: 183 | """ 184 | Checks if the path points to a directory. 185 | 186 | :param path: Path to check. 187 | :param id1: ID1 directory to use. Defaults to current_id1. 188 | :return: `True` if the file exists, `False` otherwise. 189 | :rtype: bool 190 | """ 191 | real_path = self._get_real_path(normalize_sd_path(path), id1) 192 | return isdir(real_path) 193 | 194 | def open_title(self, title_id: str, *, case_insensitive: bool = False, seed: bytes = None, 195 | load_contents: bool = True, id1: str = None) -> SDTitleReader: 196 | """ 197 | Open a title's contents for reading. 198 | 199 | In the case where a title's directory has multiple tmd files, the first one returned by :meth:`listdir` is 200 | used. 201 | 202 | :param title_id: Title ID to open. 203 | :param case_insensitive: Use case-insensitive paths for the RomFS of each NCCH container. 204 | :param seed: Seed to use. This is a quick way to add a seed using :func:`~.seeddb.add_seed`. 205 | :param load_contents: Load each partition with :class:`~.NCCHReader`. 206 | :param id1: ID1 directory to use. Defaults to current_id1. 207 | :return: Opened title contents. 208 | :rtype: SDTitleReader 209 | """ 210 | id1 = id1 or self.current_id1 211 | title_id = title_id.lower() 212 | sd_path = f'/title/{title_id[0:8]}/{title_id[8:16]}/content' 213 | 214 | # not sure why PyCharm thinks this variable is unused? 215 | # noinspection PyUnusedLocal 216 | tmd_path = None 217 | for f in self.listdir(sd_path): 218 | if f.endswith('.tmd'): 219 | tmd_path = sd_path + '/' + f 220 | break 221 | else: 222 | raise MissingTitleError(title_id) 223 | 224 | return SDTitleReader(tmd_path, case_insensitive=case_insensitive, dev=self._crypto.dev, seed=seed, 225 | load_contents=load_contents, sdfs=self, sd_id1=id1) 226 | -------------------------------------------------------------------------------- /docs/pyctr.type.nand.rst: -------------------------------------------------------------------------------- 1 | :mod:`nand` - NAND images 2 | ========================= 3 | 4 | .. py:module:: pyctr.type.nand 5 | :synopsis: Read and write Nintendo 3DS NAND images 6 | 7 | The :mod:`nand` module enables reading and writing of Nintendo 3DS NAND images. 8 | 9 | A basic overview of reading and writing to a NAND image is available here: :doc:`example-nand`. 10 | 11 | Getting started 12 | --------------- 13 | 14 | Here's a quick example to get inside CTRNAND and read files from within it using :mod:`~pyfatfs.PyFatBytesIOFS`: 15 | 16 | .. code-block:: python 17 | 18 | from pyctr.type.nand import NAND 19 | from pyfatfs.PyFatFS import PyFatBytesIOFS 20 | 21 | with NAND('nand.bin') as nand: 22 | with PyFatBytesIOFS(fp=nand.open_ctr_partition()) as ctrfat: 23 | with ctrfat.open('/private/movable.sed', 'rb') as msed: 24 | msed.read() 25 | 26 | A second example trying to load SecureInfo. This one is tricky because some consoles use ``SecureInfo_A`` and some use ``SecureInfo_B``, so we have to try both. 27 | 28 | .. code-block:: python 29 | 30 | from pyctr.type.nand import NAND 31 | from pyfatfs.PyFatFS import PyFatBytesIOFS 32 | from fs.errors import ResourceNotFound 33 | 34 | with NAND('nand.bin') as nand: 35 | with PyFatBytesIOFS(fp=nand.open_ctr_partition()) as ctrfat: 36 | for l in 'AB': 37 | path = '/rw/sys/SecureInfo_' + l 38 | if ctrfat.exists(path): 39 | with ctrfat.open(path, 'rb') as f: 40 | f.read() 41 | break 42 | 43 | Required files 44 | -------------- 45 | 46 | In most cases users will have a NAND backup with an essentials backup embedded. However there are plenty of cases where this may not occur, so you may need to provide support files in other ways. 47 | 48 | The only hard requirement is an OTP. This is found in the essentials backup, but otherwise can be provided as a file, file-like object, and a bytestring. 49 | 50 | NAND CID is also useful to have but is not required for most consoles. This also is loaded from the essentials backup if found, otherwise it can be provided like OTP. If it's not found anywhere, PyCTR will attempt to generate the Counter for both CTR and TWL. The Counter for TWL will not be generated if the TWL MBR is corrupt. 51 | 52 | In either case the load priority is first file, then bytestring, then essentials backup. 53 | 54 | An external ``essential.exefs`` file must manually be loaded with :class:`~.ExeFSReader` and then the individual ``otp`` and ``nand_cid`` read and provided to the :mod:`NAND` initializer. 55 | 56 | Dealing with corruption 57 | ----------------------- 58 | 59 | There are cases where the NAND is corrupt but you still want to read it. 60 | 61 | One of the most common kinds of corruption is an invalid TWL MBR. This happens if the NCSD header is replaced with one of another console. This applies mostly to very old pre-sighax NAND backups. If the TWL MBR cannot be decrypted and parsed, but a NAND CID was loaded, PyCTR will use the default partition information. Otherwise, TWL information will be inaccessible. 62 | 63 | NAND objects 64 | ------------ 65 | 66 | .. autoclass:: NAND 67 | 68 | .. automethod:: open_ctr_partition 69 | .. automethod:: open_ctr_fat 70 | .. automethod:: open_twl_partition 71 | .. automethod:: open_twl_fat 72 | .. py:method:: open_raw_section(section) 73 | 74 | Opens a raw NCSD section for reading and writing with on-the-fly decryption. 75 | 76 | You should use :class:`NANDSection` to get a specific type of partition. Unless you need to interact with the physical location of partitions, using partition indexes could break for users who have moved them around. 77 | 78 | .. note:: 79 | 80 | If you are looking to read from TWL NAND or CTR NAND, you may be looking for :meth:`open_twl_partition` 81 | or :meth:`open_ctr_partition` instead to open the raw MBR partition. This will return NCSD partitions, 82 | which for TWL NAND and CTR NAND, include the MBR. 83 | 84 | :param section: The section to open. Numbers 0 to 7 are specific NCSD partitions. Negative numbers are special 85 | sections defined by PyCTR. 86 | :type section: Union[NANDSection, int] 87 | :return: A file-like object. 88 | :rtype: SubsectionIO 89 | 90 | .. automethod:: open_bonus_partition 91 | .. automethod:: open_bonus_fat 92 | .. automethod:: raise_if_ctr_failed 93 | .. automethod:: raise_if_twl_failed 94 | 95 | .. py:attribute:: essential 96 | 97 | The embedded GodMode9 essentials backup. 98 | 99 | This usually contains these files: 100 | 101 | * ``frndseed`` 102 | * ``hwcal0`` 103 | * ``hwcal1`` 104 | * ``movable`` 105 | * ``nand_cid`` 106 | * ``nand_hdr`` 107 | * ``otp`` 108 | * ``secinfo`` 109 | 110 | :type: ExeFSReader 111 | 112 | .. py:attribute:: ctr_partitions 113 | 114 | The list of partitions in the CTR MBR. Always only one in practice, referred to as CTR NAND. 115 | 116 | :type: List[Tuple[int, int]] 117 | 118 | .. py:attribute:: twl_partitions 119 | 120 | The list of partitions in the TWL MBR. First one is TWL NAND and second is TWL Photo. 121 | 122 | :type: List[Tuple[int, int]] 123 | 124 | .. automethod:: close 125 | 126 | NAND sections 127 | ------------- 128 | 129 | .. py:class:: NANDSection 130 | 131 | This defines the location of partitions in a NAND. 132 | 133 | All the enums here are negative numbers to refer to different types of partitions rather than physical locations when used with :meth:`NAND.open_raw_section`, because while 99% of users will never alter the partitions, it is still possible to do so and this module will handle those use cases. 134 | 135 | .. autoattribute:: Header 136 | .. autoattribute:: TWLMBR 137 | .. autoattribute:: TWLNAND 138 | 139 | .. note:: 140 | 141 | Don't write to the first 0x1BE, this is where the NCSD header is on the raw NAND. Future versions of pyctr may silently discard writes to this region. 142 | 143 | If writing to the TWL MBR region (0x1BE-0x200), the NCSD header signature may be invalidated. Use the sighax signature to keep a "valid" header. Also keep a backup of the original NCSD header (this may already be in the essentials backup). 144 | 145 | .. autoattribute:: AGBSAVE 146 | .. autoattribute:: FIRM0 147 | .. autoattribute:: FIRM1 148 | .. autoattribute:: CTRNAND 149 | 150 | Special sections 151 | ~~~~~~~~~~~~~~~~ 152 | 153 | These are not actual sections of the NAND/NCSD but are included for convenience. 154 | 155 | .. autoattribute:: Sector0x96 156 | 157 | .. note:: 158 | 159 | Reading this decrypted with :meth:`~NAND.open_raw_section` is not yet supported. Decrypt it manually if you need access to it. 160 | 161 | .. autoattribute:: GM9BonusVolume 162 | .. autoattribute:: MinSize 163 | 164 | Exceptions 165 | ---------- 166 | 167 | .. autoexception:: NANDError 168 | .. autoexception:: InvalidNANDError 169 | .. autoexception:: MissingOTPError 170 | 171 | Custom NCSD interaction 172 | ----------------------- 173 | 174 | These are for those who want to manually interact with the NCSD information. 175 | 176 | .. autoclass:: NANDNCSDHeader 177 | 178 | This contains all the information in the NCSD header. This is also used for virtual sections in PyCTR. 179 | 180 | .. autoattribute:: signature 181 | .. py:attribute:: image_size 182 | :type: int 183 | 184 | Claimed image size. This does not actually line up with the raw image size in sectors, but is useful to determine Old 3DS vs New 3DS. 185 | 186 | .. autoattribute:: actual_image_size 187 | .. py:attribute:: partition_table 188 | :type: Dict[Union[int, NANDSection], NCSDPartitionInfo] 189 | 190 | Partition information. :class:`NANDSection` keys (negative ints) are for partition and section types, while positive int keys are for physical locations. This means that, for example, :attr:`NANDSection.TWLMBR` and ``0`` contain the same partition info. 191 | 192 | .. autoattribute:: twl_mbr_encrypted 193 | .. autoattribute:: unknown 194 | 195 | .. py:classmethod:: load(fp) 196 | 197 | Load a NAND header from a file-like object. This will also seek to :attr:`actual_image_size` to determine if there is a GodMode9 bonus drive. 198 | 199 | :param fp: The file-like object to read from. Must be seekable. 200 | :type fp: typing.BinaryIO 201 | 202 | .. automethod:: from_bytes 203 | 204 | .. py:class:: NCSDPartitionInfo 205 | 206 | Information for a single partition. 207 | 208 | .. py:attribute:: fs_type 209 | :type: Union[PartitionFSType, int] 210 | 211 | Type of filesystem. 212 | 213 | .. py:attribute:: encryption_type 214 | :type: Union[PartitionEncryptionType, int] 215 | 216 | Type of encryption used for the partition. 217 | 218 | .. py:attribute:: offset 219 | :type: int 220 | 221 | Offset of the partition in bytes. 222 | 223 | .. py:attribute:: size 224 | :type: int 225 | 226 | Size of the partition in bytes. 227 | 228 | Enums 229 | ~~~~~ 230 | 231 | .. autoclass:: PartitionFSType 232 | :members: 233 | :undoc-members: 234 | 235 | .. autoclass:: PartitionEncryptionType 236 | :members: 237 | :undoc-members: 238 | -------------------------------------------------------------------------------- /pyctr/type/save/partdesc/dpfs.py: -------------------------------------------------------------------------------- 1 | # This file is a part of pyctr. 2 | # 3 | # Copyright (c) 2017-2023 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE in the root of this project. 6 | 7 | from io import RawIOBase 8 | from threading import Lock 9 | from typing import TYPE_CHECKING, NamedTuple 10 | 11 | from ....util import readle 12 | from .common import (PartitionDescriptorError, InvalidHeaderError, InvalidHeaderLengthError, LevelData, 13 | get_block_range, read_le_u32_array, _raise_if_level_closed) 14 | 15 | if TYPE_CHECKING: 16 | from typing import BinaryIO, List 17 | 18 | # trick type checkers 19 | RawIOBase = BinaryIO 20 | 21 | 22 | class DPFSReadOnlyError(PartitionDescriptorError): 23 | """The DPFS level is read-only.""" 24 | 25 | 26 | class DPFS(NamedTuple): 27 | lv1: 'LevelData' 28 | lv2: 'LevelData' 29 | lv3: 'LevelData' 30 | 31 | @classmethod 32 | def from_bytes(cls, data: bytes): 33 | magic = data[0:8] 34 | if magic != b'DPFS\0\0\1\0': 35 | raise InvalidHeaderError(f'DPFS expected, got {data!r}') 36 | 37 | if len(data) != 0x50: 38 | raise InvalidHeaderLengthError(f'DPFS expected length 0x50, got {hex(len(data))}') 39 | 40 | levels = {} 41 | for lvl in range(1, 4): 42 | offs = 0x8 + ((lvl - 1) * 0x18) 43 | block_size_log2 = readle(data[offs+0x10:offs+0x14]) 44 | level_data = LevelData(offset=readle(data[offs:offs+0x8]), 45 | size=readle(data[offs+0x8:offs+0x10]), 46 | block_size_log2=block_size_log2, 47 | block_size=1 << block_size_log2) 48 | 49 | levels[f'lv{lvl}'] = level_data 50 | 51 | # noinspection PyArgumentList 52 | return cls(**levels) 53 | 54 | def to_bytes(self): 55 | parts = [b'DPFS\0\0\1\0'] 56 | 57 | for lvl in range(1, 4): 58 | level_data = getattr(self, f'lv{lvl}') 59 | parts.append(level_data.offset.to_bytes(8, 'little')) 60 | parts.append(level_data.size.to_bytes(8, 'little')) 61 | parts.append(level_data.block_size_log2.to_bytes(4, 'little')) 62 | parts.append(b'\0\0\0\0') # padding 63 | 64 | return b''.join(parts) 65 | 66 | 67 | class DPFSLevelChunkBase: 68 | u32_list: 'List[int]' 69 | 70 | def get_active_bit(self, bit: int): 71 | # get the index of the list to read, then get the appropriate u32 72 | current_u32 = self.u32_list[bit >> 5] 73 | 74 | bit_offset = (31 - (bit % 32)) 75 | 76 | return bool((current_u32 >> bit_offset) & 1) 77 | 78 | def get_all_active_bits(self): 79 | for u32 in self.u32_list: 80 | for curr in range(31, -1, -1): 81 | yield u32 >> curr & 1 82 | 83 | 84 | class DPFSLevel1(DPFSLevelChunkBase): 85 | """ 86 | Reads the contents of DPFS Level 1. In the DPFS tree, this contains bits that determine which blocks are active in 87 | DPFS Level 2. 88 | 89 | :param data: 90 | :param tree_selector: 91 | """ 92 | 93 | def __init__(self, data: bytes, tree_selector: int): 94 | orig_data_len = len(data) 95 | self.tree_selector = tree_selector 96 | 97 | if self.tree_selector: 98 | active_data = data[orig_data_len // 2:] 99 | else: 100 | active_data = data[:orig_data_len // 2] 101 | 102 | self.u32_list = list(read_le_u32_array(active_data)) 103 | 104 | 105 | class DPFSLevel2(DPFSLevelChunkBase): 106 | """ 107 | Reads the contents of DPFS Level 2. In the DPFS tree, this contains bits that determine which blocks are active in 108 | DPFS Level 3. 109 | 110 | :param data: The DPFS Level 2 data. 111 | :param block_size_log2: Block size in log2. 112 | :param lv1: Level 1 to read active bits from. 113 | """ 114 | 115 | def __init__(self, data: bytes, block_size: int, lv1: 'DPFSLevel1'): 116 | self.lv1 = lv1 117 | orig_data_len = len(data) 118 | data_len_half = orig_data_len // 2 119 | 120 | self.u32_list = [] 121 | 122 | for active_chunk, offs in zip(lv1.get_all_active_bits(), range(0, data_len_half, block_size)): 123 | chunk_offset = data_len_half if active_chunk else 0 124 | 125 | block_data = data[offs + chunk_offset:offs + chunk_offset + block_size] 126 | 127 | for u32 in read_le_u32_array(block_data): 128 | self.u32_list.append(u32) 129 | 130 | 131 | class DPFSLevel3FileIO(RawIOBase): 132 | def __init__(self, lv3: 'DPFSLevel3'): 133 | self._lv3 = lv3 134 | self._seek = 0 135 | self._lock = Lock() 136 | 137 | @_raise_if_level_closed 138 | def read(self, size: int = -1) -> bytes: 139 | if size == -1: 140 | size = self._lv3.size - self._seek 141 | 142 | with self._lock: 143 | data = b''.join(self._lv3.get_data(self._seek, size)) 144 | self._seek += len(data) 145 | return data 146 | 147 | @_raise_if_level_closed 148 | def seek(self, offset: int, whence: int = 0) -> int: 149 | if whence == 0: 150 | if offset < 0: 151 | raise ValueError(f'negative seek value {offset}') 152 | self._seek = min(offset, self._lv3.size) 153 | elif whence == 1: 154 | self._seek = max(self._seek + offset, 0) 155 | elif whence == 2: 156 | self._seek = max(self._lv3.size + offset, 0) 157 | return self._seek 158 | 159 | @_raise_if_level_closed 160 | def write(self, data: bytes) -> int: 161 | with self._lock: 162 | written = self._lv3.write_data(self._seek, data) 163 | self._seek += written 164 | return written 165 | 166 | @_raise_if_level_closed 167 | def tell(self) -> int: 168 | return self._seek 169 | 170 | @_raise_if_level_closed 171 | def readable(self) -> bool: 172 | return True 173 | 174 | @_raise_if_level_closed 175 | def writable(self) -> bool: 176 | # noinspection PyProtectedMember 177 | return self._lv3._fp.writable() 178 | 179 | @_raise_if_level_closed 180 | def seekable(self) -> bool: 181 | return True 182 | 183 | 184 | class DPFSLevel3: 185 | """ 186 | Reads the contents of DPFS Level 3. In the DPFS tree, this contains the actual data. 187 | 188 | :param fp: A file-like object with the DPFS Level 3 data. 189 | :param size: Size of the level. This should be the size of the final data, meaning the actual data read would be 190 | twice this size to account for the two chunks. 191 | :param block_size: Block size. 192 | :param lv2: Level 2 to read active bits from. 193 | """ 194 | 195 | def __init__(self, fp: 'BinaryIO', size: int, block_size: int, lv2: 'DPFSLevel2'): 196 | self._fp = fp 197 | self._start = self._fp.tell() 198 | self._block_size = block_size 199 | self.lv2 = lv2 200 | 201 | self._lock = Lock() 202 | 203 | self.size = size 204 | 205 | def get_data(self, offset: int, size: int): 206 | if offset + size > self.size: 207 | size = self.size - offset 208 | starting_block, ending_block = get_block_range(offset, size, self._block_size) 209 | 210 | blocks = [] 211 | 212 | with self._lock: 213 | for block in range(starting_block, ending_block + 1): 214 | if self.lv2.get_active_bit(block): 215 | chunk_offset = self.size 216 | else: 217 | chunk_offset = 0 218 | 219 | self._fp.seek(chunk_offset + (block * self._block_size)) 220 | data = self._fp.read(self._block_size) 221 | blocks.append(data) 222 | 223 | first_block_offset = offset % self._block_size 224 | if starting_block == ending_block: 225 | last_block_size = size % self._block_size 226 | else: 227 | last_block_size = (first_block_offset + size) % self._block_size 228 | 229 | if not last_block_size: 230 | last_block_size = self._block_size 231 | 232 | blocks[0] = blocks[0][first_block_offset:] 233 | blocks[-1] = blocks[-1][:last_block_size] 234 | 235 | yield from blocks 236 | 237 | def write_data(self, offset: int, data: bytes): 238 | bs = self._block_size 239 | if self._fp.writable(): 240 | if offset + len(data) > self.size: 241 | data = data[:self.size - offset] 242 | orig_data_len = len(data) 243 | starting_block, ending_block = get_block_range(offset, orig_data_len, bs) 244 | first_block_offset = offset % bs 245 | 246 | data = (b'\0' * first_block_offset) + data 247 | 248 | data_blocks = [] 249 | for x in range(0, len(data), bs): 250 | data_blocks.append(data[x:x + bs]) 251 | 252 | data_blocks[0] = data_blocks[0][first_block_offset:] 253 | 254 | total_written = 0 255 | with self._lock: 256 | for block, data_block in enumerate(data_blocks, starting_block): 257 | if self.lv2.get_active_bit(block): 258 | chunk_offset = self.size 259 | else: 260 | chunk_offset = 0 261 | 262 | self._fp.seek(chunk_offset + (block * bs) + first_block_offset) 263 | total_written += self._fp.write(data_block) 264 | 265 | # for laziness 266 | first_block_offset = 0 267 | 268 | return total_written 269 | 270 | else: 271 | raise DPFSReadOnlyError('DPFS level 3 was opened on a read-only file') 272 | -------------------------------------------------------------------------------- /pyctr/type/cdn.py: -------------------------------------------------------------------------------- 1 | # This file is a part of pyctr. 2 | # 3 | # Copyright (c) 2017-2023 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE in the root of this project. 6 | 7 | """Module for interacting with contents in CDN layout.""" 8 | 9 | from enum import IntEnum 10 | from os import fsdecode 11 | from pathlib import Path 12 | from typing import TYPE_CHECKING, NamedTuple 13 | from weakref import WeakSet 14 | 15 | from fs import open_fs 16 | from fs.base import FS 17 | from fs.osfs import OSFS 18 | from fs.path import dirname as fs_dirname, join as fs_join, basename as fs_basename 19 | 20 | from ..common import PyCTRError 21 | from ..crypto import CryptoEngine, Keyslot, add_seed 22 | from .ncch import NCCHReader 23 | from .tmd import TitleMetadataReader 24 | 25 | if TYPE_CHECKING: 26 | from os import PathLike 27 | from typing import BinaryIO, Dict, List, Optional, Set, Tuple, Union 28 | from ..common import FilePath 29 | from ..crypto import CBCFileIO 30 | from .tmd import ContentChunkRecord 31 | 32 | 33 | class CDNError(PyCTRError): 34 | """Generic error for CDN operations.""" 35 | 36 | 37 | class CDNSection(IntEnum): 38 | Ticket = -2 39 | """ 40 | Contains the title key used to decrypt the contents, as well as a content index describing which contents are 41 | enabled (mostly used for DLC). 42 | """ 43 | TitleMetadata = -1 44 | """Contains information about all the possible contents.""" 45 | Application = 0 46 | """Main application CXI.""" 47 | Manual = 1 48 | """Manual CFA. It has a RomFS with a single "Manual.bcma" file inside.""" 49 | DownloadPlayChild = 2 50 | """ 51 | Download Play Child CFA. It has a RomFS with CIA files that are sent to other Nintendo 3DS systems using 52 | Download Play. Most games only contain one. 53 | """ 54 | 55 | 56 | class CDNRegion(NamedTuple): 57 | section: 'Union[int, CDNSection]' 58 | """Index of the section.""" 59 | iv: bytes 60 | """Initialization vector. Only used for encrypted contents.""" 61 | 62 | 63 | class CDNReader: 64 | """ 65 | Reads the contents of files in a CDN file layout. 66 | 67 | Only NCCH contents are supported. SRL (DSiWare) contents are currently ignored. 68 | 69 | Note that a custom :class:`~.CryptoEngine` object is only used for encryption on the CDN contents. Each 70 | :class:`~.NCCHReader` must use their own object, as it can only store keys for a single NCCH container. To 71 | use a custom one, set `load_contents` to `False`, then load each section manually with `open_raw_section`. 72 | 73 | :param file: A path to a tmd file. All the contents should be in the same directory. 74 | :param case_insensitive: Use case-insensitive paths for the RomFS of each NCCH container. 75 | :param crypto: A custom :class:`~.CryptoEngine` object to be used. Defaults to None, which causes a new one to 76 | be created. This is only used to decrypt the CIA, not the NCCH contents. 77 | :param dev: Use devunit keys. 78 | :param seed: Seed to use. This is a quick way to add a seed using :func:`~.seeddb.add_seed`. 79 | :param titlekey: Encrypted titlekey to use. Used over the ticket file if specified. 80 | :param decrypted_titlekey: Decrypted titlekey to use. Used over the encrypted titlekey or ticket if specified. 81 | :param common_key_index: Common key index to decrypt the titlekey with. Only used if `titlekey` is specified. 82 | Defaults to 0 for an eShop application. 83 | :param load_contents: Load each partition with :class:`~.NCCHReader`. 84 | """ 85 | 86 | __slots__ = ( 87 | '_base_files', '_crypto', '_open_files', 'available_sections', 'closed', 'content_info', 'contents', 'tmd', 'fs' 88 | ) 89 | 90 | available_sections: 'List[Union[CDNSection, int]]' 91 | """A list of sections available, including contents, ticket, and title metadata.""" 92 | 93 | closed: bool 94 | """`True` if the reader is closed.""" 95 | 96 | contents: 'Dict[int, NCCHReader]' 97 | """A `dict` of :class:`~.NCCHReader` objects for each active NCCH content.""" 98 | 99 | content_info: 'List[ContentChunkRecord]' 100 | """ 101 | A list of :class:`~.ContentChunkRecord` objects for each content found in the directory at the time of object 102 | initialization. 103 | """ 104 | 105 | tmd: TitleMetadataReader 106 | """The :class:`~.TitleMetadataReader` object with information from the TMD section.""" 107 | 108 | def __init__(self, file: 'FilePath', *, fs: 'Optional[FS]' = None, case_insensitive: bool = False, 109 | crypto: 'CryptoEngine' = None, dev: bool = False, seed: bytes = None, titlekey: bytes = None, 110 | decrypted_titlekey: bytes = None, common_key_index: int = 0, load_contents: bool = True): 111 | if crypto: 112 | self._crypto = crypto 113 | else: 114 | self._crypto = CryptoEngine(dev=dev) 115 | 116 | self.closed = False 117 | 118 | if fs: 119 | if not isinstance(fs, FS): 120 | fs = open_fs(fs) 121 | title_root = fs_dirname(file) 122 | file = fs_basename(file) 123 | else: 124 | # the path being absolute makes Path.parent work reliably 125 | file = Path(file).absolute() 126 | fs = OSFS(fsdecode(file.parent)) 127 | title_root = '/' 128 | file = file.name 129 | self.fs = fs 130 | 131 | # {section: (filepath, iv)} 132 | self._base_files: Dict[Union[CDNSection, int], Tuple[str, bytes]] = {} 133 | 134 | # opened files to close if the CDNReader is closed 135 | # noinspection PyTypeChecker 136 | self._open_files: Set[BinaryIO] = WeakSet() 137 | 138 | # public method to see what sections can be accessed 139 | self.available_sections = [] 140 | 141 | self.contents = {} 142 | self.content_info = [] 143 | 144 | def add_file(section: 'Union[CDNSection, int]', path: 'Union[PathLike, str]', iv: 'Optional[bytes]'): 145 | self._base_files[section] = (path, iv) 146 | self.available_sections.append(section) 147 | 148 | add_file(CDNSection.TitleMetadata, fs_join(title_root, file), None) 149 | 150 | with self.open_raw_section(CDNSection.TitleMetadata) as tmd: 151 | self.tmd = TitleMetadataReader.load(tmd) 152 | 153 | if seed: 154 | add_seed(self.tmd.title_id, seed) 155 | 156 | if decrypted_titlekey: 157 | self._crypto.set_normal_key(Keyslot.DecryptedTitlekey, decrypted_titlekey) 158 | elif titlekey: 159 | self._crypto.load_encrypted_titlekey(titlekey, common_key_index, self.tmd.title_id) 160 | else: 161 | ticket_file = fs_join(title_root, 'cetk') 162 | add_file(CDNSection.Ticket, ticket_file, None) 163 | with self.open_raw_section(CDNSection.Ticket) as ticket: 164 | self._crypto.load_from_ticket(ticket.read(0x2AC)) 165 | 166 | for record in self.tmd.chunk_records: 167 | iv = None 168 | if record.type.encrypted: 169 | iv = record.cindex.to_bytes(2, 'big') + (b'\0' * 14) 170 | # check if the content is a Nintendo DS ROM (SRL) 171 | is_srl = record.cindex == 0 and self.tmd.title_id[3:5] == '48' 172 | 173 | # allow both lowercase and uppercase contents 174 | content_lower = fs_join(title_root, record.id) 175 | content_upper = fs_join(title_root, record.id.upper()) 176 | if fs.isfile(content_lower): 177 | content_file = content_lower 178 | elif fs.isfile(content_upper): 179 | content_file = content_upper 180 | else: 181 | # can't find the file, so continue to the next record 182 | continue 183 | 184 | self.content_info.append(record) 185 | add_file(record.cindex, content_file, iv) 186 | 187 | # this needs to check how many files are being opened 188 | if load_contents and not is_srl: 189 | decrypted_file = self.open_raw_section(record.cindex) 190 | self.contents[record.cindex] = NCCHReader(decrypted_file, case_insensitive=case_insensitive, dev=dev, 191 | crypto=self._crypto.clone()) 192 | 193 | def __enter__(self): 194 | return self 195 | 196 | def __exit__(self, exc_type, exc_val, exc_tb): 197 | self.close() 198 | 199 | def close(self): 200 | """Close the reader.""" 201 | if not self.closed: 202 | self.closed = True 203 | for cindex, content in self.contents.items(): 204 | content.close() 205 | for f in self._open_files: 206 | f.close() 207 | 208 | self.contents = {} 209 | # frozenset can't be modified, so even if I made a mistake this prevents opening files on a closed reader 210 | self._open_files = frozenset() 211 | 212 | __del__ = close 213 | 214 | def __repr__(self): 215 | info = [('title_id', self.tmd.title_id)] 216 | try: 217 | info.append(('title_name', repr(self.contents[0].exefs.icon.get_app_title().short_desc))) 218 | except KeyError: 219 | info.append(('title_name', 'unknown')) 220 | info.append(('content_count', len(self.contents))) 221 | info_final = " ".join(x + ": " + str(y) for x, y in info) 222 | return f'<{type(self).__name__} {info_final}>' 223 | 224 | def open_raw_section(self, section: 'Union[int, CDNSection]') -> 'BinaryIO': 225 | """ 226 | Open a raw CDN content for reading with on-the-fly decryption. 227 | 228 | :param section: The content to open. 229 | :return: A file-like object that reads from the content. 230 | :rtype: io.BufferedIOBase | CBCFileIO 231 | """ 232 | filepath, iv = self._base_files[section] 233 | f = self.fs.open(filepath, 'rb') 234 | if iv: # if encrypted 235 | f = self._crypto.create_cbc_io(Keyslot.DecryptedTitlekey, f, iv, closefd=True) 236 | self._open_files.add(f) 237 | return f 238 | 239 | --------------------------------------------------------------------------------