// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later #include #include #include #include #include #include #include #include #include #include #include "common/common_types.h" #include "common/logging.h" #include "common/zstd_compression.h" #include "core/crypto/aes_util.h" #include "core/crypto/key_manager.h" #include "core/crypto/ncz_decompressor.h" #include "core/file_sys/vfs/vfs.h" namespace Core::Crypto { namespace { constexpr u64 HeaderSize = 0x4000; constexpr std::size_t SectionWire = 0x40; constexpr std::size_t BlockFixed = 0x18; constexpr std::size_t Chunk = 0x100000; // Upper bound on NCZ section entries (64 bytes each, so the table is at most 4 MiB). A base NCA // has a handful of sections, but nsz stores an update's patch (BKTR) data one entry per // subsection, which is thousands of them. constexpr u64 MaxSectionCount = 0x10000; constexpr std::array SectionsMagic{'N', 'C', 'Z', 'S', 'E', 'C', 'T', 'N'}; constexpr std::array BlockMagic{'N', 'C', 'Z', 'B', 'L', 'O', 'C', 'K'}; struct Section { s64 offset, size; u64 crypto_type; Key128 key, counter; bool Ctr() const { return crypto_type == 3 || crypto_type == 4; } }; struct BlockHeader { u32 exponent; u64 decompressed_size; std::vector sizes; }; u64 Le(const u8* p, std::size_t n) { u64 v = 0; for (std::size_t i = n; i-- > 0;) v = (v << 8) | p[i]; return v; } // Mirrors nsz AESCTR.seek(): upper 8 bytes = stored counter, lower 8 = absolute NCA offset / 16. void SetIv(Key128& iv, const Key128& base, u64 off) { iv = base; u64 blk = off >> 4; for (std::size_t i = 0; i < 8; ++i) { iv[15 - i] = static_cast(blk & 0xFF); blk >>= 8; } } // Parse header + section table + optional block header. Returns payload_start. std::optional, std::optional>> ParseHeader( const FileSys::VirtualFile& f, u64& payload_start) { const u64 fsize = f->GetSize(); if (fsize < HeaderSize + 16) return std::nullopt; const auto head = f->ReadBytes(16, HeaderSize); if (head.size() != 16 || !std::equal(head.begin(), head.begin() + 8, SectionsMagic.begin())) return std::nullopt; const u64 count = Le(head.data() + 8, 8); if (count == 0 || count > MaxSectionCount || count * SectionWire > fsize - HeaderSize - 16) return std::nullopt; const auto table = f->ReadBytes(count * SectionWire, HeaderSize + 16); if (table.size() != count * SectionWire) return std::nullopt; std::vector
sections; sections.reserve(count + 1); for (u64 i = 0; i < count; ++i) { const u8* e = table.data() + i * SectionWire; Section s{}; s.offset = static_cast(Le(e, 8)); s.size = static_cast(Le(e + 8, 8)); s.crypto_type = Le(e + 16, 8); std::memcpy(s.key.data(), e + 32, 16); std::memcpy(s.counter.data(), e + 48, 16); sections.push_back(s); } const u64 block_start = HeaderSize + 16 + count * SectionWire; const auto fixed = f->ReadBytes(BlockFixed, block_start); std::optional bh; if (fixed.size() == BlockFixed && std::equal(fixed.begin(), fixed.begin() + 8, BlockMagic.begin())) { BlockHeader b{}; b.exponent = fixed[11]; const s32 n = static_cast(Le(fixed.data() + 12, 4)); if (n <= 0) return std::nullopt; b.decompressed_size = Le(fixed.data() + 16, 8); const auto sizes = f->ReadBytes(static_cast(n) * 4, block_start + BlockFixed); if (sizes.size() != static_cast(n) * 4) return std::nullopt; b.sizes.resize(n); for (s32 i = 0; i < n; ++i) b.sizes[i] = static_cast(Le(sizes.data() + i * 4, 4)); payload_start = block_start + BlockFixed + static_cast(n) * 4; bh = std::move(b); } else { payload_start = block_start; } if (!sections.empty() && sections[0].offset > static_cast(HeaderSize)) { Section fake{}; fake.offset = static_cast(HeaderSize); fake.size = sections[0].offset - static_cast(HeaderSize); sections.insert(sections.begin(), fake); } return std::make_pair(std::move(sections), std::move(bh)); } // Sequential reader over independently-compressed blocks, one block cached (like nsz_rs). class BlockReader { public: BlockReader(FileSys::VirtualFile f, const BlockHeader& bh, u64 payload_start) : source{std::move(f)}, block_size{1ULL << bh.exponent}, sizes{bh.sizes}, decompressed_size{bh.decompressed_size} { if (bh.exponent < 14 || bh.exponent > 32 || sizes.empty()) throw std::runtime_error("bad NCZBLOCK header"); u64 off = payload_start; offsets.reserve(sizes.size()); for (u32 s : sizes) { offsets.push_back(off); off += s; } } // Fills `out` from `position`; returns bytes written. std::size_t ReadAt(u64 position, std::span out) { std::size_t done = 0; while (done < out.size() && position < decompressed_size) { const u64 id = position / block_size; if (id >= sizes.size()) break; if (cache_id != id && !Load(id)) break; const u64 in = position - id * block_size; const std::size_t n = static_cast( std::min(out.size() - done, cache.size() - in)); std::memcpy(out.data() + done, cache.data() + in, n); done += n; position += n; } return done; } private: bool Load(u64 id) { u64 want = block_size; if (id + 1 == sizes.size()) { const u64 rem = decompressed_size % block_size; if (rem > 0) want = rem; // avoid the 0-size bug from the original nsz } try { cache.resize(static_cast(want)); } catch (const std::bad_alloc&) { return false; } const u32 csize = sizes[id]; if (csize < want) { const auto comp = source->ReadBytes(csize, offsets[id]); if (comp.size() != csize || Common::Compression::DecompressDataZSTD(comp, cache) != want) return false; } else if (source->Read(cache.data(), cache.size(), offsets[id]) != cache.size()) { return false; } cache_id = id; return true; } FileSys::VirtualFile source; u64 block_size; std::vector offsets; std::vector sizes; u64 decompressed_size; u64 cache_id = ~0ULL; std::vector cache; }; class BlockNczFile final : public FileSys::VfsFile { public: BlockNczFile(std::string name_, std::vector hdr_, std::vector
sections_, std::unique_ptr body_) : name{std::move(name_)}, hdr{std::move(hdr_)}, sections{std::move(sections_)}, body{std::move(body_)} { nca_size = HeaderSize; for (const auto& s : sections) nca_size = std::max(nca_size, static_cast(s.offset + s.size)); ciphers.reserve(sections.size()); for (const auto& s : sections) ciphers.push_back(s.Ctr() ? std::make_unique>(s.key, Mode::CTR) : nullptr); } std::string GetName() const override { return name; } std::size_t GetSize() const override { return static_cast(nca_size); } bool Resize(std::size_t) override { return false; } FileSys::VirtualDir GetContainingDirectory() const override { return nullptr; } bool IsWritable() const override { return false; } bool IsReadable() const override { return true; } std::size_t Read(u8* data, std::size_t len, std::size_t off) const override { if (off >= nca_size) return 0; len = static_cast(std::min(len, nca_size - off)); const u64 end = off + len; u64 pos = off; if (pos < HeaderSize) { const u64 n = std::min(end, HeaderSize) - pos; std::memcpy(data, hdr.data() + pos, static_cast(n)); pos += n; } std::scoped_lock lock{mutex}; for (std::size_t i = 0; i < sections.size() && pos < end; ++i) { const auto& s = sections[i]; const u64 begin = std::max(static_cast(s.offset), HeaderSize); const u64 stop = std::max(static_cast(s.offset + s.size), begin); if (stop <= pos) continue; if (pos < begin) { const u64 g = std::min(end, begin); std::memset(data + (pos - off), 0, static_cast(g - pos)); pos = g; if (pos >= end) break; } const u64 pe = std::min(end, stop); const std::span piece(data + (pos - off), static_cast(pe - pos)); if (body->ReadAt(pos - HeaderSize, piece) != piece.size()) return static_cast(pos - off); if (ciphers[i]) { Key128 iv{}; SetIv(iv, s.counter, pos); ciphers[i]->SetIV(iv); ciphers[i]->Transcode(piece.data(), piece.size(), piece.data(), Op::Encrypt); } pos = pe; } return len; } std::size_t Write(const u8*, std::size_t, std::size_t) override { return 0; } bool Rename(std::string_view) override { return false; } private: const std::string name; const std::vector hdr; const std::vector
sections; const std::unique_ptr body; std::vector>> ciphers; mutable std::mutex mutex; u64 nca_size{}; }; // Solid NCZ: one zstd stream over the whole body. Decoded incrementally into memory, up to the // requested offset, so sequential install reads are O(n) instead of O(n^2). class SolidNczFile final : public FileSys::VfsFile { public: SolidNczFile(std::string name_, FileSys::VirtualFile source_, std::vector
sections_, u64 payload_start_) : name{std::move(name_)}, source{std::move(source_)}, sections{std::move(sections_)}, payload_start{payload_start_} { const auto raw_header = source->ReadBytes(HeaderSize, 0); data.assign(raw_header.begin(), raw_header.end()); frontier = HeaderSize; } ~SolidNczFile() override { if (stream != nullptr) ZSTD_freeDStream(stream); } std::string GetName() const override { return name; } std::size_t GetSize() const override { u64 size = HeaderSize; for (const auto& s : sections) size = std::max(size, static_cast(s.offset + s.size)); return static_cast(size); } bool Resize(std::size_t) override { return false; } FileSys::VirtualDir GetContainingDirectory() const override { return nullptr; } bool IsWritable() const override { return false; } bool IsReadable() const override { return true; } std::size_t Read(u8* out, std::size_t len, std::size_t off) const override { std::scoped_lock lock{mutex}; const u64 nca_size = GetSize(); if (off >= nca_size) return 0; len = static_cast(std::min(len, nca_size - off)); const u64 needed = off + len; if (needed > frontier && !DecodeTo(needed)) return 0; std::memcpy(out, data.data() + off, len); return len; } std::size_t Write(const u8*, std::size_t, std::size_t) override { return 0; } bool Rename(std::string_view) override { return false; } private: bool DecodeTo(u64 needed) const { if (stream == nullptr) { stream = ZSTD_createDStream(); if (stream == nullptr) return false; ZSTD_DCtx_setParameter(stream, ZSTD_d_windowLogMax, 31); read_pos = payload_start; } const u64 file_size = source->GetSize(); std::vector out_buf(Chunk); std::size_t index = 0; for (; index < sections.size(); ++index) { const auto& s = sections[index]; const u64 begin = std::max(static_cast(s.offset), HeaderSize); const u64 end = std::max(static_cast(s.offset) + static_cast(s.size), begin); if (end > frontier) break; } for (; index < sections.size() && frontier < needed; ++index) { const auto& s = sections[index]; const u64 begin = std::max(static_cast(s.offset), HeaderSize); const u64 end = std::max(static_cast(s.offset) + static_cast(s.size), begin); if (end <= frontier) continue; u64 pos = std::max(begin, frontier); std::optional> cipher; if (s.Ctr()) cipher.emplace(s.key, Mode::CTR); while (pos < end && frontier < needed) { const u64 want = std::min(end - pos, out_buf.size()); std::size_t produced = 0; while (produced < want) { if (in.pos >= in.size) { const u64 remaining = file_size - read_pos; const std::size_t chunk = static_cast( std::min(Chunk, remaining)); if (chunk == 0) { LOG_ERROR(Crypto, "Solid NCZ: truncated"); return false; } const auto bytes = source->ReadBytes(chunk, read_pos); if (bytes.empty()) { LOG_ERROR(Crypto, "Solid NCZ: truncated"); return false; } read_pos += bytes.size(); in_buf.assign(bytes.begin(), bytes.end()); in.src = in_buf.data(); in.size = in_buf.size(); in.pos = 0; } ZSTD_outBuffer outb{out_buf.data() + produced, want - produced, 0}; const std::size_t res = ZSTD_decompressStream(stream, &outb, &in); if (ZSTD_isError(res)) { LOG_ERROR(Crypto, "Solid NCZ: zstd error"); return false; } produced += outb.pos; } if (cipher) { Key128 iv{}; SetIv(iv, s.counter, pos); cipher->SetIV(iv); cipher->Transcode(out_buf.data(), produced, out_buf.data(), Op::Encrypt); } data.insert(data.end(), out_buf.begin(), out_buf.begin() + produced); pos += produced; frontier = pos; } } return frontier >= needed; } const std::string name; const FileSys::VirtualFile source; const std::vector
sections; const u64 payload_start; mutable std::mutex mutex; mutable std::vector data; mutable ZSTD_DStream* stream = nullptr; mutable u64 read_pos = 0; mutable u64 frontier = 0; mutable std::vector in_buf; mutable ZSTD_inBuffer in{nullptr, 0, 0}; }; // Shared opener: block path always; solid only when allow_solid is true. FileSys::VirtualFile OpenNcz(const FileSys::VirtualFile& f, std::string_view name, bool allow_solid) { if (!f) return nullptr; u64 ps = 0; auto parsed = ParseHeader(f, ps); if (!parsed) return nullptr; auto& [sections, block] = *parsed; if (block) { auto hdr = f->ReadBytes(HeaderSize, 0); if (hdr.size() != HeaderSize) return nullptr; std::unique_ptr body; try { body = std::make_unique(f, *block, ps); } catch (const std::exception& e) { LOG_ERROR(Crypto, "Failed to open NCZBLOCK: {}", e.what()); return nullptr; } return std::make_shared(std::string{name}, std::move(hdr), std::move(sections), std::move(body)); } if (allow_solid) { return std::make_shared(std::string{name}, f, std::move(sections), ps); } LOG_DEBUG(Crypto, "Solid NCZ {} skipped (only NCZBLOCK is supported for play)", name); return nullptr; } } // Anonymous namespace bool IsBlockCompressedNCZ(const FileSys::VirtualFile& f) { if (!f) return false; u64 ps = 0; const auto p = ParseHeader(f, ps); return p && p->second.has_value(); } FileSys::VirtualFile DecompressNCZ(const FileSys::VirtualFile& f, std::string_view name) { return OpenNcz(f, name, /*allow_solid=*/false); } FileSys::VirtualFile DecompressNCZForInstall(const FileSys::VirtualFile& f, std::string_view name) { return OpenNcz(f, name, /*allow_solid=*/true); } } // namespace Core::Crypto