();
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Ekona
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | _Ekona_ is a library part of the [_SceneGate_](https://github.com/SceneGate)
24 | framework that provides support for **DS and DSi file formats.**
25 |
26 | ## Supported formats
27 |
28 | - :video_game: DS cartridge:
29 | - :file_folder: Filesystem: read and write
30 | - :information_source: Header: read and write, including extended header
31 | - :framed_picture: Banner and icon: read and write.
32 | - :closed_lock_with_key: ARM9 secure area encryption and decryption (KEY1).
33 | - :video_game: DSi cartridge:
34 | - :file_folder: Filesystem: read and write `arm9i` and `arm7i` programs.
35 | - :information_source: Extended header: read and write
36 | - :framed_picture: Animated banner icons
37 | - :closed_lock_with_key: Modcrypt encryption and decryption
38 | - :lock_with_ink_pen: HMAC validation and generation when keys are provided.
39 | - :lock_with_ink_pen: Signature validation when keys are provided.
40 |
41 | ## Getting started
42 |
43 | Check-out the
44 | [getting started guide](https://scenegate.github.io/Ekona/docs/dev/tutorial.html)
45 | to start using _Ekona_ in no time! Below you can find an example that shows how
46 | to open a DS/DSi ROM file (cartridge dump).
47 |
48 | ```csharp
49 | // Create Yarhl node from a file (binary format).
50 | Node game = NodeFactory.FromFile("game.nds", FileOpenMode.Read);
51 |
52 | // Use the `Binary2NitroRom` converter to convert the binary format
53 | // into node containers (virtual file system tree with files and directories).
54 | game.TransformWith();
55 |
56 | // And it's done!
57 | // Now we can access to every game file. For instance, we can export one file
58 | Node items = Navigator.SearchNode(game, "data/Items.dat");
59 | items.Stream.WriteTo("dump/Items.dat");
60 | ```
61 |
62 | ## Usage
63 |
64 | The project provides the following .NET libraries (NuGet packages in nuget.org).
65 | The libraries works on supported versions of .NET: 6.0 and 8.0.
66 |
67 | - [](https://www.nuget.org/packages/SceneGate.Ekona)
68 | - `SceneGate.Ekona.Containers.Rom`: DS and DSi cartridge (ROM) format.
69 | - `SceneGate.Ekona.Security`: hash and encryption algorithms
70 |
71 | Preview releases can be found in this
72 | [Azure DevOps package repository](https://dev.azure.com/SceneGate/SceneGate/_packaging?_a=feed&feed=SceneGate-Preview).
73 | To use a preview release, create a file `nuget.config` in the same directory of
74 | your solution file (.sln) with the following content:
75 |
76 | ```xml
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 | ```
96 |
97 | ## Documentation
98 |
99 | You can get full details about how to use library from the
100 | [documentation](https://scenegate.github.io/Ekona/docs/dev/features/cartridge.html)
101 | website.
102 |
103 | Don't miss the
104 | [formats specifications](https://scenegate.github.io/Ekona/docs/specs/cartridge/cartridge.html)
105 | in case you need to do further research.
106 |
107 | And don't hesitate to ask questions in the
108 | [project Discussion site!](https://github.com/SceneGate/Ekona/discussions)
109 |
110 | ## Build
111 |
112 | The project requires to build .NET 8.0 SDK.
113 |
114 | To build, test and generate artifacts run:
115 |
116 | ```sh
117 | # Build and run tests
118 | dotnet run --project build/orchestrator
119 |
120 | # (Optional) Create bundles (nuget, zips, docs)
121 | dotnet run --project build/orchestrator -- --target=Bundle
122 | ```
123 |
124 | To build the documentation only, run:
125 |
126 | ```sh
127 | dotnet docfx docs/docfx.json --serve
128 | ```
129 |
130 | To run the performance test with memory and CPU traces:
131 |
132 | ```sh
133 | dotnet run --project src/Ekona.PerformanceTests/ -c Release -- -f "**" -m -p EP --maxWidth 60
134 | ```
135 |
136 | ## Special thanks
137 |
138 | The DS / DSi cartridge format was based on the amazing reverse engineering work
139 | of Martin Korth at [GBATek](https://problemkaputt.de/gbatek.htm). Its
140 | specifications of the hardware of the video controller and I/O ports was also a
141 | great help in additional reverse engineering.
142 |
--------------------------------------------------------------------------------
/src/Ekona.Tests/Security/NitroBlowfishTests.cs:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2022 SceneGate
2 |
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 |
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 |
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 | using System;
21 | using FluentAssertions;
22 | using NUnit.Framework;
23 | using SceneGate.Ekona.Security;
24 | using Yarhl.IO;
25 |
26 | namespace SceneGate.Ekona.Tests.Security;
27 |
28 | [TestFixture]
29 | public class NitroBlowfishTest
30 | {
31 | [Test]
32 | public void InitializationGuards()
33 | {
34 | byte[] keysValid = new byte[NitroBlowfish.KeyLength];
35 | byte[] keysInvalid = new byte[8];
36 |
37 | var blowfish = new NitroBlowfish();
38 | blowfish.Invoking(b => b.Initialize(null, 2, 8, keysValid)).Should().ThrowExactly();
39 | blowfish.Invoking(b => b.Initialize(string.Empty, 2, 8, keysValid)).Should().ThrowExactly();
40 | blowfish.Invoking(b => b.Initialize("AAA", 2, 8, keysValid)).Should().ThrowExactly();
41 | blowfish.Invoking(b => b.Initialize("AAAAA", 2, 8, keysValid)).Should().ThrowExactly();
42 | blowfish.Invoking(b => b.Initialize("AAAA", 0, 8, keysValid)).Should().ThrowExactly();
43 | blowfish.Invoking(b => b.Initialize("AAAA", 4, 8, keysValid)).Should().ThrowExactly();
44 | blowfish.Invoking(b => b.Initialize("AAAA", 2, 0, keysValid)).Should().ThrowExactly();
45 | blowfish.Invoking(b => b.Initialize("AAAA", 2, 16, keysValid)).Should().ThrowExactly();
46 | blowfish.Invoking(b => b.Initialize("AAAA", 2, 8, null)).Should().ThrowExactly();
47 | blowfish.Invoking(b => b.Initialize("AAAA", 2, 8, keysInvalid)).Should().ThrowExactly();
48 | }
49 |
50 | [Test]
51 | [TestCase(new uint[] { 0x01234567, 0x89ABCDEF }, "AAAA", 2, 8, new uint[] { 0xECD83DAE, 0xAB3EF361 })]
52 | public void Decrypt(uint[] data, string idCode, int level, int modulo, uint[] expected)
53 | {
54 | DsiKeyStore keys = TestDataBase.GetDsiKeyStore();
55 | var blowfish = new NitroBlowfish();
56 | blowfish.Initialize(idCode, level, modulo, keys.BlowfishDsKey);
57 |
58 | blowfish.Decrypt(ref data[0], ref data[1]);
59 | data.Should().BeEquivalentTo(expected);
60 | }
61 |
62 | [Test]
63 | [TestCase(new uint[] { 0xECD83DAE, 0xAB3EF361 }, "AAAA", 2, 8, new uint[] { 0x01234567, 0x89ABCDEF })]
64 | public void Encrypt(uint[] data, string idCode, int level, int modulo, uint[] expected)
65 | {
66 | DsiKeyStore keys = TestDataBase.GetDsiKeyStore();
67 | var blowfish = new NitroBlowfish();
68 | blowfish.Initialize(idCode, level, modulo, keys.BlowfishDsKey);
69 |
70 | blowfish.Encrypt(ref data[0], ref data[1]);
71 | data.Should().BeEquivalentTo(expected);
72 | }
73 |
74 | [Test]
75 | public void StreamEncDecryption()
76 | {
77 | using var stream = new DataStream();
78 | var inputWriter = new DataWriter(stream);
79 | inputWriter.Write(0xECD83DAE);
80 | inputWriter.Write(0xAB3EF361);
81 |
82 | DsiKeyStore keys = TestDataBase.GetDsiKeyStore();
83 | var blowfish = new NitroBlowfish();
84 | blowfish.Initialize("AAAA", 2, 8, keys.BlowfishDsKey);
85 |
86 | blowfish.Encrypt(stream);
87 |
88 | stream.Position = 0;
89 | var outputReader = new DataReader(stream);
90 | outputReader.ReadUInt32().Should().Be(0x01234567);
91 | outputReader.ReadUInt32().Should().Be(0x89ABCDEF);
92 |
93 | blowfish.Decrypt(stream);
94 |
95 | stream.Position = 0;
96 | outputReader.ReadUInt32().Should().Be(0xECD83DAE);
97 | outputReader.ReadUInt32().Should().Be(0xAB3EF361);
98 | }
99 |
100 | [Test]
101 | public void ArrayEncDecryption()
102 | {
103 | using var inputStream = new DataStream();
104 | var inputWriter = new DataWriter(inputStream);
105 | inputWriter.Write(0xECD83DAE);
106 | inputWriter.Write(0xAB3EF361);
107 | byte[] input = new byte[8];
108 | inputStream.Position = 0;
109 | inputStream.Read(input);
110 |
111 | DsiKeyStore keys = TestDataBase.GetDsiKeyStore();
112 | var blowfish = new NitroBlowfish();
113 | blowfish.Initialize("AAAA", 2, 8, keys.BlowfishDsKey);
114 |
115 | byte[] output = blowfish.Encrypt(input);
116 |
117 | using var outputStream = DataStreamFactory.FromArray(output);
118 | var outputReader = new DataReader(outputStream);
119 | outputReader.ReadUInt32().Should().Be(0x01234567);
120 | outputReader.ReadUInt32().Should().Be(0x89ABCDEF);
121 |
122 | byte[] finalEncryption = blowfish.Decrypt(output);
123 |
124 | finalEncryption.Should().BeEquivalentTo(input);
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/src/Ekona/Containers/Rom/Banner.cs:
--------------------------------------------------------------------------------
1 | // Copyright(c) 2021 SceneGate
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 | using System;
21 | using SceneGate.Ekona.Security;
22 | using Yarhl.FileFormat;
23 |
24 | namespace SceneGate.Ekona.Containers.Rom
25 | {
26 | ///
27 | /// Banner of a program.
28 | ///
29 | public class Banner : IFormat
30 | {
31 | private Version version;
32 |
33 | ///
34 | /// Gets or sets the version of the banner model.
35 | ///
36 | ///
37 | /// Only the following version are valid:
38 | ///
39 | /// - 0.1: Original
40 | /// - 0.2: Support Chinese title
41 | /// - 0.3: Support Chinese and Korean titles
42 | /// - 1.3: Support Chinese and Korean titles and animated DSi icon.
43 | ///
44 | ///
45 | /// Invalid version number.
46 | public Version Version {
47 | get => version;
48 | set {
49 | if (value.Minor is < 1 or > 3)
50 | throw new ArgumentOutOfRangeException(nameof(value), value.Minor, "Minor must be [1,3]");
51 | if (value.Major is < 0 or > 1)
52 | throw new ArgumentOutOfRangeException(nameof(value), value.Major, "Major must be [0, 1]");
53 | if (value.Major == 1 && value.Minor != 3)
54 | throw new ArgumentOutOfRangeException(nameof(value), value.Minor, "Minor must be 3 for major 1");
55 |
56 | version = value;
57 | }
58 | }
59 |
60 | ///
61 | /// Gets a value indicating whether the banner supports animated icons (version >= 1.3).
62 | ///
63 | public bool SupportAnimatedIcon => Version is { Major: > 1 } or { Major: 1, Minor: >= 3 };
64 |
65 | ///
66 | /// Gets or sets the CRC-16 checksum for the banner binary data of version 0.1.
67 | ///
68 | /// This field may be null if the model was not deserialized from binary data.
69 | public HashInfo ChecksumBase { get; set; }
70 |
71 | ///
72 | /// Gets or sets the CRC-16 checksum for the banner binary data of version 0.2
73 | /// that includes Chinese title.
74 | ///
75 | /// This field may be null if the model was not deserialized from binary data.
76 | public HashInfo ChecksumChinese { get; set; }
77 |
78 | ///
79 | /// Gets or sets the CRC-16 checksum for the banner binary data of version 0.3
80 | /// that includes Chinese and Korean titles.
81 | ///
82 | /// This field may be null if the model was not deserialized from binary data.
83 | public HashInfo ChecksumKorean { get; set; }
84 |
85 | ///
86 | /// Gets or sets the CRC-16 checksum for the animated DSi icon.
87 | ///
88 | /// This field may be null if the model was not deserialized from binary data.
89 | public HashInfo ChecksumAnimatedIcon { get; set; }
90 |
91 | ///
92 | /// Gets or sets the Japenese title.
93 | ///
94 | public string JapaneseTitle { get; set; }
95 |
96 | ///
97 | /// Gets or sets the English title.
98 | ///
99 | public string EnglishTitle { get; set; }
100 |
101 | ///
102 | /// Gets or sets the French title.
103 | ///
104 | public string FrenchTitle { get; set; }
105 |
106 | ///
107 | /// Gets or sets the German title.
108 | ///
109 | public string GermanTitle { get; set; }
110 |
111 | ///
112 | /// Gets or sets the Italian title.
113 | ///
114 | public string ItalianTitle { get; set; }
115 |
116 | ///
117 | /// Gets or sets the Spanish title.
118 | ///
119 | public string SpanishTitle { get; set; }
120 |
121 | ///
122 | /// Gets or sets the Chinese title.
123 | ///
124 | public string ChineseTitle { get; set; }
125 |
126 | ///
127 | /// Gets or sets the Korean title.
128 | ///
129 | public string KoreanTitle { get; set; }
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/src/Ekona/Containers/Rom/ArmEncodeFieldFinder.cs:
--------------------------------------------------------------------------------
1 | // Copyright(c) 2021 SceneGate
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 | using System;
21 | using System.IO;
22 | using Yarhl.IO;
23 |
24 | namespace SceneGate.Ekona.Containers.Rom
25 | {
26 | ///
27 | /// Find the constant with the decoded size of the ARM.
28 | ///
29 | public static class ArmEncodeFieldFinder
30 | {
31 | private const int SecureAreaSize = 0x4000;
32 |
33 | // Number of bytes from the header to the first instruction in DecoderOps
34 | private const int DecoderShift = 0x0C;
35 |
36 | // First ARM instructions of the BLZ decoder
37 | private static readonly uint[] DecoderOps = new uint[]
38 | {
39 | 0xE9100006, 0xE0802002, 0xE0403C21, 0xE3C114FF,
40 | 0xE0401001, 0xE1A04002,
41 | };
42 |
43 | ///
44 | /// Searchs the encoded size address.
45 | ///
46 | /// The ARM file to analyze.
47 | /// The information of the program.
48 | /// The encoded size address. 0 if not found.
49 | public static int SearchEncodedSizeAddress(IBinary arm, ProgramInfo info)
50 | {
51 | // Steps to find the ARM9 size address that we need to change
52 | // in order to fix the BLZ decoded error.
53 | // 1º Get ARM9 entry address.
54 | // 2º From that point and while we're in the secure zone,
55 | // search the decode_BLZ routine.
56 | // 3º Search previous BL (jump) instruction that call the decoder.
57 | // 4º Search instructions before it that loads R0 (parameter of decode_BLZ).
58 | DataReader reader = new DataReader(arm.Stream);
59 |
60 | // 1º
61 | uint entryAddress = info.Arm9EntryAddress - info.Arm9RamAddress;
62 |
63 | // 2º
64 | arm.Stream.Seek(entryAddress, SeekOrigin.Begin);
65 | uint decoderAddress = SearchDecoder(arm.Stream);
66 | if (decoderAddress == 0x00) {
67 | throw new FormatException("Invalid decoder address");
68 | }
69 |
70 | // 3º & 4º
71 | arm.Stream.Seek(entryAddress, SeekOrigin.Begin);
72 | uint baseOffset = SearchBaseOffset(arm.Stream, decoderAddress);
73 | if (baseOffset == 0x00) {
74 | throw new FormatException("Invalid base offset");
75 | }
76 |
77 | // Get relative address (not RAM address)
78 | arm.Stream.Seek(baseOffset, SeekOrigin.Begin);
79 | uint sizeAddress = reader.ReadUInt32() + 0x14; // Size is at 0x14 from that address
80 | sizeAddress -= info.Arm9RamAddress;
81 |
82 | return (int)sizeAddress;
83 | }
84 |
85 | private static uint SearchDecoder(DataStream stream)
86 | {
87 | DataReader reader = new DataReader(stream);
88 | long startPosition = stream.Position;
89 |
90 | uint decoderAddress = 0x00;
91 | while (stream.Position - startPosition < SecureAreaSize && decoderAddress == 0x00)
92 | {
93 | long loopPosition = stream.Position;
94 |
95 | // Compare instructions to see if it's the routing we want
96 | bool found = true;
97 | for (int i = 0; i < DecoderOps.Length && found; i++) {
98 | if (reader.ReadUInt32() != DecoderOps[i]) {
99 | found = false;
100 | }
101 | }
102 |
103 | if (found) {
104 | decoderAddress = (uint)loopPosition - DecoderShift; // Get start of routine
105 | } else {
106 | stream.Seek(loopPosition + 4, SeekOrigin.Begin); // Go to next instruction
107 | }
108 | }
109 |
110 | return decoderAddress;
111 | }
112 |
113 | private static uint SearchBaseOffset(DataStream stream, uint decoderAddress)
114 | {
115 | DataReader reader = new DataReader(stream);
116 | uint instr;
117 |
118 | // Search the instruction: BL DecoderAddress
119 | // Where DecoderAddress=(PC+8+nn*4)
120 | bool found = false;
121 | while (stream.Position < decoderAddress && !found)
122 | {
123 | instr = reader.ReadUInt32();
124 | if ((instr & 0xFF000000) == 0xEB000000) {
125 | uint shift = instr & 0x00FFFFFF;
126 | shift = 4 + (shift * 4);
127 |
128 | // Check if that jump goes to the correct routine
129 | if (stream.Position + shift == decoderAddress)
130 | found = true;
131 | }
132 | }
133 |
134 | // Search for the Load instruction, btw LDR R1=[PC+ZZ].
135 | // Usually two instruction before.
136 | stream.Seek(-0x0C, SeekOrigin.Current);
137 | uint baseOffset = 0x00;
138 | instr = reader.ReadUInt32();
139 | if ((instr & 0xFFFF0000) == 0xE59F0000) {
140 | baseOffset = (uint)stream.Position + (instr & 0xFFF) + 4;
141 | }
142 |
143 | // If not found... Should we continue looking above instructions?
144 | // I run a test with > 500 games and at the moment it is always there
145 | return baseOffset;
146 | }
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/src/Ekona.Tests/Containers/Rom/ProgramInfoTests.cs:
--------------------------------------------------------------------------------
1 | namespace SceneGate.Ekona.Tests.Containers.Rom;
2 | using System;
3 | using System.Collections.Generic;
4 | using System.Linq;
5 | using System.Text;
6 | using System.Threading.Tasks;
7 | using NUnit.Framework;
8 | using SceneGate.Ekona.Containers.Rom;
9 | using SceneGate.Ekona.Security;
10 |
11 | [TestFixture]
12 | internal class ProgramInfoTests
13 | {
14 | [Test]
15 | public void HasValidHashesTrueIfNull()
16 | {
17 | var info = new ProgramInfo();
18 |
19 | Assert.That(info.HasValidHashes(), Is.True);
20 | }
21 |
22 | [Test]
23 | public void HasValidHashesTrueIfGenerated()
24 | {
25 | var info = new ProgramInfo();
26 | _ = CreateDsHashes(info, HashStatus.Generated);
27 |
28 | Assert.That(info.HasValidHashes(), Is.True);
29 | }
30 |
31 | [Test]
32 | public void HasValidHashesThrowsIfNotValidated()
33 | {
34 | var info = new ProgramInfo();
35 | _ = CreateDsHashes(info, HashStatus.NotValidated);
36 |
37 | Assert.That(() => info.HasValidHashes(), Throws.InvalidOperationException);
38 | }
39 |
40 | [Test]
41 | public void HasValidHashesFalseIfAnyInvalid()
42 | {
43 | var info = new ProgramInfo();
44 | HashInfo[] hashInfos = CreateDsHashes(info, HashStatus.Invalid);
45 |
46 | foreach (HashInfo hashInfo in hashInfos) {
47 | Assert.That(info.HasValidHashes(), Is.False);
48 | hashInfo.Status = HashStatus.Valid;
49 | }
50 |
51 | Assert.That(info.HasValidHashes(), Is.True);
52 | }
53 |
54 | [Test]
55 | public void HasValidHashesTrueIfAllValid()
56 | {
57 | var info = new ProgramInfo();
58 | _ = CreateDsHashes(info, HashStatus.Valid);
59 |
60 | Assert.That(info.HasValidHashes(), Is.True);
61 | }
62 |
63 | [Test]
64 | public void HasValidHashesDsi()
65 | {
66 | var info = new ProgramInfo();
67 | HashInfo[] hashInfos = CreateDsiHashes(info, HashStatus.Invalid);
68 |
69 | foreach (HashInfo hashInfo in hashInfos) {
70 | Assert.That(info.HasValidHashes(), Is.False);
71 | hashInfo.Status = HashStatus.Valid;
72 | }
73 |
74 | Assert.That(info.HasValidHashes(), Is.False);
75 |
76 | info.DsiInfo.DigestHashesStatus = HashStatus.Valid;
77 | Assert.That(info.HasValidHashes(), Is.True);
78 | }
79 |
80 | [Test]
81 | public void HasValidHashesDsExtended()
82 | {
83 | var info = new ProgramInfo();
84 | HashInfo[] hashInfos = CreateDsExtendedHashes(info, HashStatus.Invalid);
85 |
86 | foreach (HashInfo hashInfo in hashInfos) {
87 | Assert.That(info.HasValidHashes(), Is.False);
88 | hashInfo.Status = HashStatus.Valid;
89 | }
90 |
91 | Assert.That(info.HasValidHashes(), Is.True);
92 | }
93 |
94 | [Test]
95 | public void HasSignatureForDsIsTrue()
96 | {
97 | var info = new ProgramInfo();
98 | info.UnitCode = DeviceUnitKind.DS;
99 |
100 | Assert.That(info.HasValidSignature(), Is.True);
101 |
102 | info.Signature = CreateHashInfo(HashStatus.Invalid);
103 | Assert.That(info.HasValidSignature(), Is.True);
104 | }
105 |
106 | [Test]
107 | public void HasSignatureForDsi()
108 | {
109 | var info = new ProgramInfo();
110 | info.UnitCode = DeviceUnitKind.DSiExclusive;
111 |
112 | // null signature
113 | Assert.That(info.HasValidSignature(), Is.True);
114 |
115 | info.Signature = CreateHashInfo(HashStatus.Generated);
116 | Assert.That(info.HasValidSignature(), Is.True);
117 |
118 | info.Signature.Status = HashStatus.Invalid;
119 | Assert.That(info.HasValidSignature(), Is.False);
120 |
121 | info.Signature.Status = HashStatus.Valid;
122 | Assert.That(info.HasValidSignature(), Is.True);
123 |
124 | info.Signature.Status = HashStatus.NotValidated;
125 | Assert.That(() => info.HasValidSignature(), Throws.InvalidOperationException);
126 | }
127 |
128 | private static HashInfo[] CreateDsHashes(ProgramInfo info, HashStatus status)
129 | {
130 | info.UnitCode = DeviceUnitKind.DS;
131 |
132 | #pragma warning disable S1121 // cool syntax for tests
133 | var hashInfos = new List {
134 | (info.ChecksumHeader = CreateHashInfo(status)),
135 | (info.ChecksumLogo = CreateHashInfo(status)),
136 | (info.ChecksumSecureArea = CreateHashInfo(status)),
137 | };
138 | #pragma warning restore S1121
139 |
140 | return hashInfos.ToArray();
141 | }
142 |
143 | private static HashInfo[] CreateDsExtendedHashes(ProgramInfo info, HashStatus status)
144 | {
145 | info.UnitCode = DeviceUnitKind.DS;
146 |
147 | info.ProgramFeatures = DsiRomFeatures.NitroProgramSigned | DsiRomFeatures.NitroBannerSigned;
148 |
149 | #pragma warning disable S1121 // cool syntax for tests
150 | var hashInfos = new List {
151 | (info.ChecksumHeader = CreateHashInfo(status)),
152 | (info.ChecksumLogo = CreateHashInfo(status)),
153 | (info.ChecksumSecureArea = CreateHashInfo(status)),
154 |
155 | (info.BannerMac = CreateHashInfo(status)),
156 |
157 | (info.NitroProgramMac = CreateHashInfo(status)),
158 | (info.NitroOverlaysMac = CreateHashInfo(status)),
159 | };
160 | #pragma warning restore S1121
161 |
162 | return hashInfos.ToArray();
163 | }
164 |
165 | private static HashInfo[] CreateDsiHashes(ProgramInfo info, HashStatus status)
166 | {
167 | info.UnitCode = DeviceUnitKind.DSiExclusive;
168 | info.DsiInfo ??= new DsiProgramInfo();
169 |
170 | info.DsiInfo.DigestHashesStatus = status;
171 |
172 | #pragma warning disable S1121 // cool syntax for tests
173 | var hashInfos = new List {
174 | (info.ChecksumHeader = CreateHashInfo(status)),
175 | (info.ChecksumLogo = CreateHashInfo(status)),
176 | (info.ChecksumSecureArea = CreateHashInfo(status)),
177 |
178 | (info.BannerMac = CreateHashInfo(status)),
179 |
180 | (info.DsiInfo.Arm9SecureMac = CreateHashInfo(status)),
181 | (info.DsiInfo.Arm7Mac = CreateHashInfo(status)),
182 | (info.DsiInfo.DigestMain = CreateHashInfo(status)),
183 | (info.DsiInfo.Arm9iMac = CreateHashInfo(status)),
184 | (info.DsiInfo.Arm7iMac = CreateHashInfo(status)),
185 | (info.DsiInfo.Arm9Mac = CreateHashInfo(status)),
186 | };
187 | #pragma warning restore S1121
188 |
189 | return hashInfos.ToArray();
190 | }
191 |
192 | private static HashInfo CreateHashInfo(HashStatus status)
193 | {
194 | return new HashInfo("TestAlgo", new byte[] { 0x42 }) {
195 | Status = status,
196 | };
197 | }
198 | }
199 |
--------------------------------------------------------------------------------
/src/Ekona.Tests/Containers/Rom/Binary2RomHeaderTests.cs:
--------------------------------------------------------------------------------
1 | // Copyright(c) 2021 SceneGate
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 | using System.Collections.Generic;
21 | using System.IO;
22 | using System.Linq;
23 | using FluentAssertions;
24 | using NUnit.Framework;
25 | using SceneGate.Ekona.Containers.Rom;
26 | using SceneGate.Ekona.Security;
27 | using YamlDotNet.Serialization;
28 | using YamlDotNet.Serialization.NamingConventions;
29 | using Yarhl.FileFormat;
30 | using Yarhl.FileSystem;
31 | using Yarhl.IO;
32 |
33 | namespace SceneGate.Ekona.Tests.Containers.Rom
34 | {
35 | [TestFixture]
36 | public class Binary2RomHeaderTests
37 | {
38 | public static IEnumerable GetFiles()
39 | {
40 | string basePath = Path.Combine(TestDataBase.RootFromOutputPath, "Containers");
41 | string listPath = Path.Combine(basePath, "header.txt");
42 | return TestDataBase.ReadTestListFile(listPath)
43 | .Select(line => line.Split(','))
44 | .Select(data => new TestCaseData(
45 | Path.Combine(basePath, data[0]),
46 | Path.Combine(basePath, data[1]))
47 | .SetName($"{{m}}({data[1]})"));
48 | }
49 |
50 | [TestCaseSource(nameof(GetFiles))]
51 | public void DeserializeRomHeaderMatchInfo(string infoPath, string headerPath)
52 | {
53 | TestDataBase.IgnoreIfFileDoesNotExist(infoPath);
54 | TestDataBase.IgnoreIfFileDoesNotExist(headerPath);
55 |
56 | string yaml = File.ReadAllText(infoPath);
57 | RomHeader expected = new DeserializerBuilder()
58 | .WithNamingConvention(UnderscoredNamingConvention.Instance)
59 | .Build()
60 | .Deserialize(yaml);
61 |
62 | using Node node = NodeFactory.FromFile(headerPath, FileOpenMode.Read);
63 | node.Invoking(n => n.TransformWith()).Should().NotThrow();
64 |
65 | node.GetFormatAs().Should().BeEquivalentTo(
66 | expected,
67 | opts => opts
68 | .Excluding(p => p.CopyrightLogo)
69 | .Excluding((FluentAssertions.Equivalency.IMemberInfo info) => info.Type == typeof(NitroProgramCodeParameters))
70 | .Excluding((FluentAssertions.Equivalency.IMemberInfo info) => info.Type == typeof(HashInfo)));
71 | }
72 |
73 | [TestCaseSource(nameof(GetFiles))]
74 | public void DeserializeRomHeaderWithoutKeysHasHashes(string infoPath, string headerPath)
75 | {
76 | TestDataBase.IgnoreIfFileDoesNotExist(infoPath);
77 | TestDataBase.IgnoreIfFileDoesNotExist(headerPath);
78 |
79 | using Node node = NodeFactory.FromFile(headerPath, FileOpenMode.Read);
80 | node.Invoking(n => n.TransformWith()).Should().NotThrow();
81 |
82 | ProgramInfo programInfo = node.GetFormatAs().ProgramInfo;
83 | programInfo.ChecksumSecureArea.Status.Should().Be(HashStatus.NotValidated);
84 | programInfo.ChecksumLogo.Status.Should().Be(HashStatus.Valid);
85 | programInfo.ChecksumHeader.Status.Should().Be(HashStatus.Valid);
86 |
87 | bool isDsi = programInfo.UnitCode != DeviceUnitKind.DS;
88 | if (isDsi || programInfo.ProgramFeatures.HasFlag(DsiRomFeatures.NitroBannerSigned)) {
89 | programInfo.BannerMac.Should().NotBeNull();
90 | programInfo.BannerMac.Status.Should().Be(HashStatus.NotValidated);
91 | }
92 |
93 | if (programInfo.ProgramFeatures.HasFlag(DsiRomFeatures.NitroProgramSigned)) {
94 | programInfo.NitroOverlaysMac.Should().NotBeNull();
95 | programInfo.NitroOverlaysMac.Status.Should().Be(HashStatus.NotValidated);
96 |
97 | programInfo.NitroProgramMac.Should().NotBeNull();
98 | programInfo.NitroProgramMac.Status.Should().Be(HashStatus.NotValidated);
99 | }
100 |
101 | if (isDsi || programInfo.ProgramFeatures.HasFlag(DsiRomFeatures.NitroProgramSigned)) {
102 | programInfo.Signature.Should().NotBeNull();
103 | programInfo.Signature.Status.Should().Be(HashStatus.NotValidated);
104 | }
105 |
106 | if (isDsi) {
107 | programInfo.DsiInfo.Arm9SecureMac.Status.Should().Be(HashStatus.NotValidated);
108 | programInfo.DsiInfo.Arm7Mac.Status.Should().Be(HashStatus.NotValidated);
109 | programInfo.DsiInfo.DigestMain.Status.Should().Be(HashStatus.NotValidated);
110 | programInfo.DsiInfo.Arm9iMac.Status.Should().Be(HashStatus.NotValidated);
111 | programInfo.DsiInfo.Arm7iMac.Status.Should().Be(HashStatus.NotValidated);
112 | programInfo.DsiInfo.Arm9Mac.Status.Should().Be(HashStatus.NotValidated);
113 | }
114 | }
115 |
116 | [TestCaseSource(nameof(GetFiles))]
117 | public void TwoWaysIdenticalRomHeaderStream(string infoPath, string headerPath)
118 | {
119 | TestDataBase.IgnoreIfFileDoesNotExist(headerPath);
120 |
121 | using Node node = NodeFactory.FromFile(headerPath, FileOpenMode.Read);
122 |
123 | RomHeader header = node.GetFormatAs().ConvertWith(new Binary2RomHeader());
124 | BinaryFormat generatedStream = header.ConvertWith(new RomHeader2Binary());
125 |
126 | var originalStream = new DataStream(node.Stream!, 0, header.SectionInfo.HeaderSize);
127 | generatedStream.Stream.Length.Should().Be(originalStream.Length);
128 | generatedStream.Stream.Compare(originalStream).Should().BeTrue();
129 | }
130 |
131 | [TestCaseSource(nameof(GetFiles))]
132 | public void ThreeWaysIdenticalRomHeaderObjects(string infoPath, string headerPath)
133 | {
134 | TestDataBase.IgnoreIfFileDoesNotExist(headerPath);
135 |
136 | using Node node = NodeFactory.FromFile(headerPath, FileOpenMode.Read);
137 |
138 | RomHeader originalHeader = node.GetFormatAs().ConvertWith(new Binary2RomHeader());
139 | using BinaryFormat generatedStream = originalHeader.ConvertWith(new RomHeader2Binary());
140 | RomHeader generatedHeader = generatedStream.ConvertWith(new Binary2RomHeader());
141 |
142 | generatedHeader.Should().BeEquivalentTo(
143 | originalHeader,
144 | opts => opts.Excluding((FluentAssertions.Equivalency.IMemberInfo info) => info.Type == typeof(HashInfo)));
145 | }
146 | }
147 | }
148 |
--------------------------------------------------------------------------------
/src/Ekona/Security/Modcrypt.cs:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2022 SceneGate
2 |
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 |
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 |
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 | using System;
21 | using System.Diagnostics.CodeAnalysis;
22 | using System.IO;
23 | using System.Numerics;
24 | using System.Security.Cryptography;
25 | using System.Text;
26 | using SceneGate.Ekona.Containers.Rom;
27 | using Yarhl.IO;
28 |
29 | namespace SceneGate.Ekona.Security;
30 |
31 | ///
32 | /// Twilight modcrypt encryption (AES-CTR).
33 | ///
34 | public class Modcrypt
35 | {
36 | private readonly Aes aes;
37 | private readonly byte[] counter;
38 | private readonly byte[] key;
39 |
40 | ///
41 | /// Initializes a new instance of the class.
42 | ///
43 | /// The initialization value.
44 | /// The key.
45 | public Modcrypt(byte[] iv, byte[] key)
46 | {
47 | aes = Aes.Create();
48 | aes.Mode = CipherMode.ECB;
49 | aes.Padding = PaddingMode.None;
50 |
51 | this.key = key;
52 |
53 | counter = new byte[16];
54 | Array.Copy(iv, counter, counter.Length);
55 | }
56 |
57 | ///
58 | /// Create a for a twilight program.
59 | ///
60 | /// Information of the program to initialize encryption.
61 | /// Modcrypt area 1 or 2.
62 | /// The initialized encryption.
63 | /// Area is not 1/2 or the program flags disable modcrypt.
64 | public static Modcrypt Create(ProgramInfo programInfo, int area)
65 | {
66 | ArgumentNullException.ThrowIfNull(programInfo);
67 | ArgumentNullException.ThrowIfNull(programInfo.DsiInfo);
68 | if (area is not 1 and not 2) {
69 | throw new ArgumentException("Area must be 1 or 2", nameof(area));
70 | }
71 |
72 | if (!programInfo.DsiCryptoFlags.HasFlag(DsiCryptoMode.Modcrypted)) {
73 | throw new ArgumentException("Program flags indicates modcrypt is disabled.");
74 | }
75 |
76 | bool useKeyDebug = programInfo.DsiCryptoFlags.HasFlag(DsiCryptoMode.ModcryptKeyDebug);
77 | bool devApp = programInfo.ProgramFeatures.HasFlag(DsiRomFeatures.DeveloperApp);
78 | if (useKeyDebug || devApp) {
79 | return CreateDebug(programInfo, area);
80 | }
81 |
82 | return CreateRetail(programInfo, area);
83 | }
84 |
85 | ///
86 | /// Encrypt or decrypt (same operation) the input into a new stream.
87 | ///
88 | /// The input data.
89 | /// The decrypted or encrypted data.
90 | [SuppressMessage("", "S3329", Justification = "Implement must match device.")]
91 | public DataStream Transform(Stream input)
92 | {
93 | var output = new DataStream();
94 |
95 | int blockSize = aes.BlockSize / 8;
96 | var zeroIv = new byte[blockSize];
97 | using ICryptoTransform encryptor = aes.CreateEncryptor(key, zeroIv);
98 |
99 | byte[] buffer = new byte[blockSize];
100 | byte[] xorMask = new byte[blockSize];
101 | while (input.Position < input.Length) {
102 | encryptor.TransformBlock(counter, 0, counter.Length, xorMask, 0);
103 | IncrementCounter();
104 |
105 | int read = input.Read(buffer);
106 | for (int i = 0; i < read; i++) {
107 | buffer[i] ^= xorMask[blockSize - 1 - i];
108 | }
109 |
110 | output.Write(buffer, 0, read);
111 | }
112 |
113 | return output;
114 | }
115 |
116 | [SuppressMessage("", "S1117", Justification = "Static methods can't access instance fields")]
117 | private static Modcrypt CreateDebug(ProgramInfo programInfo, int area)
118 | {
119 | byte[] iv = (area == 1)
120 | ? programInfo.DsiInfo.Arm9SecureMac.Hash[0..16]
121 | : programInfo.DsiInfo.Arm7Mac.Hash[0..16];
122 |
123 | // Debug KEY[0..F]: First 16 bytes of the header
124 | byte[] key = new byte[16];
125 | Array.Copy(Encoding.ASCII.GetBytes(programInfo.GameTitle), key, 12);
126 | Array.Copy(Encoding.ASCII.GetBytes(programInfo.GameCode), 0, key, 12, 4);
127 |
128 | Array.Reverse(iv);
129 | Array.Reverse(key);
130 | return new Modcrypt(iv, key);
131 | }
132 |
133 | [SuppressMessage("", "S1117", Justification = "Static methods can't access instance fields")]
134 | private static Modcrypt CreateRetail(ProgramInfo programInfo, int area)
135 | {
136 | byte[] iv = (area == 1)
137 | ? programInfo.DsiInfo.Arm9SecureMac.Hash[0..16]
138 | : programInfo.DsiInfo.Arm7Mac.Hash[0..16];
139 |
140 | // KEY_X[0..7] = Decimal obfuscation of the forbidden fruit.
141 | // KEY_X[8..B] = gamecode forwards
142 | // KEY_X[C..F] = gamecode backwards because originality has its limits.
143 | byte[] keyX = new byte[0x10];
144 | keyX[0] = 78;
145 | keyX[1] = 105;
146 | keyX[2] = 110;
147 | keyX[3] = 116;
148 | keyX[4] = 101;
149 | keyX[5] = 110;
150 | keyX[6] = 100;
151 | keyX[7] = 111;
152 | keyX[8] = (byte)programInfo.GameCode[0];
153 | keyX[9] = (byte)programInfo.GameCode[1];
154 | keyX[10] = (byte)programInfo.GameCode[2];
155 | keyX[11] = (byte)programInfo.GameCode[3];
156 | keyX[12] = (byte)programInfo.GameCode[3];
157 | keyX[13] = (byte)programInfo.GameCode[2];
158 | keyX[14] = (byte)programInfo.GameCode[1];
159 | keyX[15] = (byte)programInfo.GameCode[0];
160 |
161 | // KEY_Y[0..F]: First 16 bytes of the ARM9i SHA1-HMAC
162 | byte[] keyY = programInfo.DsiInfo.Arm9iMac.Hash[0..16];
163 |
164 | // Key = ((Key_X XOR Key_Y) + scrambler) ROL 42
165 | const ulong Mask42Bits = 0x3FF_FFFFFFFF;
166 | byte[] scrambler = { 0x79, 0x3E, 0x4F, 0x1A, 0x5F, 0x0F, 0x68, 0x2A, 0x58, 0x02, 0x59, 0x29, 0x4E, 0xFB, 0xFE, 0xFF };
167 | var bigX = new BigInteger(keyX, isUnsigned: true);
168 | var bigY = new BigInteger(keyY, isUnsigned: true);
169 | var bigScrambler = new BigInteger(scrambler, isUnsigned: true);
170 | var keyNum = ((bigX ^ bigY) + bigScrambler) << 42;
171 | keyNum = keyNum | ((keyNum >> 128) & Mask42Bits); // move to overflow 128-bits into lower part (ROL 42)
172 | byte[] key = keyNum.ToByteArray(isUnsigned: true)[0..16]; // Get first 128-bits (like an AND)
173 |
174 | // Reverse everything
175 | Array.Reverse(iv);
176 | Array.Reverse(key);
177 | return new Modcrypt(iv, key);
178 | }
179 |
180 | private void IncrementCounter()
181 | {
182 | // Increment the count as it were a big little endian number.
183 | for (var i = counter.Length - 1; i >= 0; i--)
184 | {
185 | counter[i]++;
186 |
187 | // If it's 0, then keep iterating as we overflew this byte.
188 | if (counter[i] != 0)
189 | {
190 | break;
191 | }
192 | }
193 | }
194 | }
195 |
--------------------------------------------------------------------------------