commit 59262f1691d863ac9079efd71093ad0f0facdf88 Author: server Date: Tue Apr 14 11:12:29 2026 +0200 Initial secure packer CLI diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..259bc1c --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/build/ +/.idea/ +/.vscode/ +*.tmp +*.swp +keys/ +out/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..30eece5 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,48 @@ +cmake_minimum_required(VERSION 3.20) +project(m2pack-secure VERSION 0.1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +find_package(PkgConfig REQUIRED) +pkg_check_modules(LIBSODIUM REQUIRED libsodium) +find_path(ZSTD_INCLUDE_DIR NAMES zstd.h) +find_library(ZSTD_LIBRARY NAMES zstd libzstd) + +if(NOT ZSTD_INCLUDE_DIR) + set(ZSTD_INCLUDE_DIR "/home/mt2.jakubkadlec.dev/metin/repos/m2dev-client-src/vendor/zstd-1.5.7/lib") +endif() + +if(NOT ZSTD_LIBRARY) + find_library(ZSTD_LIBRARY NAMES zstd PATHS /usr/lib/x86_64-linux-gnu) +endif() + +if(NOT ZSTD_LIBRARY AND EXISTS "/usr/lib/x86_64-linux-gnu/libzstd.so.1") + set(ZSTD_LIBRARY "/usr/lib/x86_64-linux-gnu/libzstd.so.1") +endif() + +if(NOT EXISTS "${ZSTD_INCLUDE_DIR}/zstd.h" OR NOT ZSTD_LIBRARY) + message(FATAL_ERROR "zstd files not found") +endif() + +add_executable(m2pack + src/archive.cpp + src/cli.cpp + src/crypto.cpp + src/main.cpp + src/util.cpp +) + +target_include_directories(m2pack + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src + ${LIBSODIUM_INCLUDE_DIRS} + ${ZSTD_INCLUDE_DIR} +) + +target_compile_options(m2pack PRIVATE -Wall -Wextra -Wpedantic) +target_link_directories(m2pack PRIVATE ${LIBSODIUM_LIBRARY_DIRS}) +target_link_libraries(m2pack PRIVATE ${LIBSODIUM_LIBRARIES} ${ZSTD_LIBRARY}) + +install(TARGETS m2pack RUNTIME DESTINATION bin) diff --git a/README.md b/README.md new file mode 100644 index 0000000..f964bc1 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# m2pack-secure + +CLI-first archive builder for a modern Metin2 client pack pipeline. + +It is designed as a replacement for legacy EterPack tooling when you control the +client source and want a format that is easier to automate and harder to tamper +with. + +## Goals + +- CLI workflow first, no GUI dependency +- deterministic manifest layout for automation +- `zstd` compression +- `XChaCha20-Poly1305` authenticated encryption per file +- `Ed25519` signed manifest for tamper detection +- JSON output for AI agents and automation + +## Current commands + +- `m2pack keygen` +- `m2pack build` +- `m2pack list` +- `m2pack verify` +- `m2pack extract` + +## Build + +```bash +cmake -S . -B build +cmake --build build -j +``` + +## Quick start + +Generate a master content key and signing keypair: + +```bash +./build/m2pack keygen --out-dir keys --json +``` + +Build an archive from a client asset directory: + +```bash +./build/m2pack build \ + --input /path/to/client/root \ + --output out/client.m2p \ + --key keys/master.key \ + --sign-secret-key keys/signing.key \ + --json +``` + +Verify the archive: + +```bash +./build/m2pack verify \ + --archive out/client.m2p \ + --public-key keys/signing.pub \ + --key keys/master.key \ + --json +``` + +Extract: + +```bash +./build/m2pack extract \ + --archive out/client.m2p \ + --output out/unpacked \ + --key keys/master.key +``` + +## Format summary + +- Single archive file with a fixed header +- Binary manifest near the end of the file +- Signed manifest hash in the header +- Per-file random nonce +- Per-file AEAD ciphertext, authenticated with the relative file path + +See [docs/format.md](docs/format.md) and +[docs/client-integration.md](docs/client-integration.md). diff --git a/docs/client-integration.md b/docs/client-integration.md new file mode 100644 index 0000000..b917557 --- /dev/null +++ b/docs/client-integration.md @@ -0,0 +1,47 @@ +# Client integration + +Your current client already has a custom `PackLib` using `zstd` and +`XChaCha20`. This project should become the next iteration, not a parallel dead +tool. + +## Suggested migration + +1. Keep the current `PackManager` interface stable. +2. Add a new loader beside the current pack code, for example: + - `src/PackLib/M2PackArchive.h` + - `src/PackLib/M2PackArchive.cpp` +3. Route `CPackManager::AddPack()` by extension: + - legacy format for old `.pack` + - new format for `.m2p` +4. Embed only the signing public key in the client. +5. Resolve the content decryption key from: + - launcher-provided memory + - machine-bound cache + - or a derived release secret + +## Runtime validation + +Minimum validation: + +- verify header magic and version +- compare manifest hash +- verify manifest signature +- reject duplicate paths +- reject path traversal +- verify AEAD tag before decompression + +## Loader notes + +- Use memory-mapped I/O for the archive. +- Keep a path-to-entry map in lowercase normalized form. +- Decrypt per request, not by unpacking the full archive. +- Keep a small decompression scratch buffer pool if the client reads in parallel. + +## Release model + +- `master.key`: private content encryption key +- `signing.key`: private release signing key +- `signing.pub`: embedded client verifier key + +The signing key belongs in CI or a secured release box. The public key belongs +in the client binary or in a protected launcher manifest. diff --git a/docs/format.md b/docs/format.md new file mode 100644 index 0000000..52028cd --- /dev/null +++ b/docs/format.md @@ -0,0 +1,65 @@ +# Format + +`m2pack-secure` uses a single-file archive format optimized for runtime loading. + +## Header + +Fixed-size header at the start of the archive: + +- magic: `M2PACK2` +- format version +- manifest offset +- manifest size +- manifest hash: `BLAKE2b-256` +- manifest signature: `Ed25519` + +The header is intentionally tiny and stable so the loader can validate it before +touching asset payloads. + +## Manifest + +The manifest stores: + +- normalized relative path +- data offset +- original size +- stored size +- compression algorithm +- per-file nonce +- plaintext hash + +The manifest is signed as a whole. Runtime validation should always happen in +this order: + +1. read header +2. read manifest +3. compare manifest hash +4. verify manifest signature +5. resolve entry by path +6. decrypt and decompress asset +7. optionally hash plaintext for debug or strict mode + +## Data + +Each file payload is: + +1. compressed with `zstd` +2. encrypted with `XChaCha20-Poly1305` +3. stored in-place in the archive + +The file path is used as associated data so path tampering invalidates the +payload authentication tag. + +## Security model + +This format is designed to prevent trivial unpacking and silent tampering. It is +not a claim of absolute secrecy, because the client still contains the runtime +key path and loader logic. + +Recommended production posture: + +- do not hardcode the final master key as a trivial static array +- derive a session key in the launcher when possible +- keep the signing public key in the client +- keep the signing secret key only in CI or the release workstation +- rotate the master content key on format-breaking releases diff --git a/src/archive.cpp b/src/archive.cpp new file mode 100644 index 0000000..c0a113d --- /dev/null +++ b/src/archive.cpp @@ -0,0 +1,339 @@ +#include "archive.h" + +#include +#include +#include +#include + +#include "crypto.h" +#include "util.h" + +namespace m2pack +{ + +namespace +{ + +constexpr char kArchiveMagic[kMagicSize] = {'M', '2', 'P', 'A', 'C', 'K', '2', '\0'}; +constexpr std::uint32_t kArchiveVersion = 1; + +std::array require_master_key(const KeyMaterial& keys) +{ + return keys.master_key; +} + +} // namespace + +std::string normalize_path(const std::filesystem::path& root, const std::filesystem::path& file) +{ + std::string out = std::filesystem::relative(file, root).generic_string(); + std::replace(out.begin(), out.end(), '\\', '/'); + std::transform(out.begin(), out.end(), out.begin(), [](unsigned char ch) { + return static_cast(std::tolower(ch)); + }); + return out; +} + +std::vector collect_files(const std::filesystem::path& root) +{ + std::vector files; + for (const auto& entry : std::filesystem::recursive_directory_iterator(root)) + { + if (!entry.is_regular_file()) + { + continue; + } + files.push_back(entry.path()); + } + + std::sort(files.begin(), files.end()); + return files; +} + +std::vector serialize_manifest(const std::vector& entries) +{ + std::vector bytes; + const ManifestFixedHeader fixed { + static_cast(entries.size()), + 0, + }; + append_pod(bytes, fixed); + + for (const auto& entry : entries) + { + if (entry.path.size() > UINT16_MAX) + { + fail("Path too long for manifest entry: " + entry.path); + } + + ManifestEntryFixed pod {}; + pod.path_size = static_cast(entry.path.size()); + pod.compression = static_cast(entry.compression); + pod.flags = 0; + pod.data_offset = entry.data_offset; + pod.original_size = entry.original_size; + pod.stored_size = entry.stored_size; + std::memcpy(pod.nonce, entry.nonce.data(), entry.nonce.size()); + std::memcpy(pod.plaintext_hash, entry.plaintext_hash.data(), entry.plaintext_hash.size()); + append_pod(bytes, pod); + bytes.insert(bytes.end(), entry.path.begin(), entry.path.end()); + } + + return bytes; +} + +std::vector parse_manifest(const std::vector& bytes) +{ + std::size_t offset = 0; + const auto fixed = read_pod(bytes, offset); + + std::vector entries; + entries.reserve(fixed.entry_count); + + for (std::uint32_t i = 0; i < fixed.entry_count; ++i) + { + const auto pod = read_pod(bytes, offset); + if (offset + pod.path_size > bytes.size()) + { + fail("Manifest path data exceeds buffer"); + } + + ManifestEntry entry; + entry.path.assign(reinterpret_cast(bytes.data() + offset), pod.path_size); + offset += pod.path_size; + entry.compression = static_cast(pod.compression); + entry.data_offset = pod.data_offset; + entry.original_size = pod.original_size; + entry.stored_size = pod.stored_size; + std::memcpy(entry.nonce.data(), pod.nonce, entry.nonce.size()); + std::memcpy(entry.plaintext_hash.data(), pod.plaintext_hash, entry.plaintext_hash.size()); + entries.push_back(std::move(entry)); + } + + return entries; +} + +BuildResult build_archive( + const std::filesystem::path& input_dir, + const std::filesystem::path& output_file, + const KeyMaterial& keys) +{ + if (!std::filesystem::exists(input_dir) || !std::filesystem::is_directory(input_dir)) + { + fail("Input directory does not exist: " + input_dir.string()); + } + + if (!keys.signing_secret_key.has_value()) + { + fail("Build requires a signing secret key"); + } + + BuildResult result; + result.archive_path = output_file; + + std::vector manifest_entries; + std::vector payload_bytes; + + const auto files = collect_files(input_dir); + manifest_entries.reserve(files.size()); + + for (const auto& path : files) + { + const auto plain = read_file(path.string()); + const auto compressed = compress_zstd(plain); + ManifestEntry entry; + entry.path = normalize_path(input_dir, path); + entry.compression = Compression::Zstd; + entry.data_offset = payload_bytes.size(); + entry.original_size = plain.size(); + entry.nonce = random_nonce(); + entry.plaintext_hash = hash_bytes(plain); + + const auto encrypted = encrypt_payload(compressed, require_master_key(keys), entry.nonce, entry.path); + entry.stored_size = encrypted.size(); + + payload_bytes.insert(payload_bytes.end(), encrypted.begin(), encrypted.end()); + manifest_entries.push_back(entry); + + result.file_count += 1; + result.total_input_bytes += plain.size(); + result.total_stored_bytes += encrypted.size(); + } + + const auto manifest_bytes = serialize_manifest(manifest_entries); + const auto manifest_hash = hash_bytes(manifest_bytes); + const auto signature = sign_detached(manifest_bytes, *keys.signing_secret_key); + + ArchiveHeader header {}; + std::memcpy(header.magic, kArchiveMagic, sizeof(kArchiveMagic)); + header.version = kArchiveVersion; + header.flags = 0; + header.manifest_offset = sizeof(ArchiveHeader) + payload_bytes.size(); + header.manifest_size = manifest_bytes.size(); + std::memcpy(header.manifest_hash, manifest_hash.data(), manifest_hash.size()); + std::memcpy(header.manifest_signature, signature.data(), std::min(signature.size(), sizeof(header.manifest_signature))); + + std::filesystem::create_directories(output_file.parent_path()); + std::ofstream output(output_file, std::ios::binary); + if (!output) + { + fail("Failed to open archive for writing: " + output_file.string()); + } + + output.write(reinterpret_cast(&header), sizeof(header)); + if (!payload_bytes.empty()) + { + output.write(reinterpret_cast(payload_bytes.data()), static_cast(payload_bytes.size())); + } + if (!manifest_bytes.empty()) + { + output.write(reinterpret_cast(manifest_bytes.data()), static_cast(manifest_bytes.size())); + } + + if (!output.good()) + { + fail("Failed while writing archive: " + output_file.string()); + } + + return result; +} + +LoadedArchive load_archive(const std::filesystem::path& archive_path) +{ + LoadedArchive archive; + archive.file_bytes = read_file(archive_path.string()); + + if (archive.file_bytes.size() < sizeof(ArchiveHeader)) + { + fail("Archive too small"); + } + + std::memcpy(&archive.header, archive.file_bytes.data(), sizeof(ArchiveHeader)); + if (std::memcmp(archive.header.magic, kArchiveMagic, sizeof(kArchiveMagic)) != 0) + { + fail("Archive magic mismatch"); + } + if (archive.header.version != kArchiveVersion) + { + fail("Unsupported archive version"); + } + if (archive.header.manifest_offset + archive.header.manifest_size > archive.file_bytes.size()) + { + fail("Manifest range exceeds archive bounds"); + } + + archive.manifest_bytes.assign( + archive.file_bytes.begin() + static_cast(archive.header.manifest_offset), + archive.file_bytes.begin() + static_cast(archive.header.manifest_offset + archive.header.manifest_size)); + + archive.entries = parse_manifest(archive.manifest_bytes); + return archive; +} + +bool verify_archive( + const LoadedArchive& archive, + const KeyMaterial& keys, + std::string& error) +{ + const auto actual_hash = hash_bytes(archive.manifest_bytes); + if (std::memcmp(actual_hash.data(), archive.header.manifest_hash, actual_hash.size()) != 0) + { + error = "Manifest hash mismatch"; + return false; + } + + if (keys.signing_public_key.has_value() && + !verify_detached(archive.manifest_bytes, archive.header.manifest_signature, *keys.signing_public_key)) + { + error = "Manifest signature verification failed"; + return false; + } + + std::unordered_set seen_paths; + for (const auto& entry : archive.entries) + { + if (entry.path.empty() || entry.path.find("..") != std::string::npos || entry.path.front() == '/') + { + error = "Unsafe path in manifest: " + entry.path; + return false; + } + + if (!seen_paths.insert(entry.path).second) + { + error = "Duplicate path in manifest: " + entry.path; + return false; + } + + const std::uint64_t payload_begin = sizeof(ArchiveHeader); + const std::uint64_t payload_end = archive.header.manifest_offset; + const std::uint64_t begin = payload_begin + entry.data_offset; + const std::uint64_t end = begin + entry.stored_size; + if (begin > payload_end || end > payload_end || end < begin) + { + error = "Entry points outside payload section: " + entry.path; + return false; + } + } + + if (keys.master_key != std::array {}) + { + try + { + for (const auto& entry : archive.entries) + { + const auto plain = extract_entry(archive, entry, keys.master_key); + const auto hash = hash_bytes(plain); + if (hash != entry.plaintext_hash) + { + error = "Plaintext hash mismatch: " + entry.path; + return false; + } + } + } + catch (const std::exception& ex) + { + error = ex.what(); + return false; + } + } + + return true; +} + +std::vector extract_entry( + const LoadedArchive& archive, + const ManifestEntry& entry, + const std::array& master_key) +{ + const std::size_t begin = sizeof(ArchiveHeader) + static_cast(entry.data_offset); + const std::size_t end = begin + static_cast(entry.stored_size); + const std::vector ciphertext( + archive.file_bytes.begin() + static_cast(begin), + archive.file_bytes.begin() + static_cast(end)); + + const auto compressed = decrypt_payload(ciphertext, master_key, entry.nonce, entry.path); + switch (entry.compression) + { + case Compression::None: + return compressed; + case Compression::Zstd: + return decompress_zstd(compressed, static_cast(entry.original_size)); + default: + fail("Unsupported compression mode"); + } +} + +void extract_all( + const LoadedArchive& archive, + const std::filesystem::path& output_dir, + const std::array& master_key) +{ + for (const auto& entry : archive.entries) + { + const auto plain = extract_entry(archive, entry, master_key); + const auto path = output_dir / std::filesystem::path(entry.path); + write_file(path.string(), plain); + } +} + +} // namespace m2pack diff --git a/src/archive.h b/src/archive.h new file mode 100644 index 0000000..680a907 --- /dev/null +++ b/src/archive.h @@ -0,0 +1,120 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace m2pack +{ + +constexpr std::size_t kMagicSize = 8; +constexpr std::size_t kHashSize = 32; +constexpr std::size_t kSignatureSize = 64; +constexpr std::size_t kAeadKeySize = 32; +constexpr std::size_t kAeadNonceSize = 24; + +enum class Compression : std::uint8_t +{ + None = 0, + Zstd = 1, +}; + +#pragma pack(push, 1) +struct ArchiveHeader +{ + char magic[kMagicSize]; + std::uint32_t version; + std::uint32_t flags; + std::uint64_t manifest_offset; + std::uint64_t manifest_size; + std::uint8_t manifest_hash[kHashSize]; + std::uint8_t manifest_signature[kSignatureSize]; + std::uint8_t reserved[64]; +}; + +struct ManifestFixedHeader +{ + std::uint32_t entry_count; + std::uint32_t flags; +}; + +struct ManifestEntryFixed +{ + std::uint16_t path_size; + std::uint8_t compression; + std::uint8_t flags; + std::uint64_t data_offset; + std::uint64_t original_size; + std::uint64_t stored_size; + std::uint8_t nonce[kAeadNonceSize]; + std::uint8_t plaintext_hash[kHashSize]; +}; +#pragma pack(pop) + +struct ManifestEntry +{ + std::string path; + Compression compression = Compression::Zstd; + std::uint64_t data_offset = 0; + std::uint64_t original_size = 0; + std::uint64_t stored_size = 0; + std::array nonce {}; + std::array plaintext_hash {}; +}; + +struct LoadedArchive +{ + ArchiveHeader header {}; + std::vector manifest_bytes; + std::vector entries; + std::vector file_bytes; +}; + +struct KeyMaterial +{ + std::array master_key {}; + std::optional> signing_secret_key; + std::optional> signing_public_key; +}; + +struct BuildResult +{ + std::filesystem::path archive_path; + std::size_t file_count = 0; + std::uint64_t total_input_bytes = 0; + std::uint64_t total_stored_bytes = 0; +}; + +std::string normalize_path(const std::filesystem::path& root, const std::filesystem::path& file); +std::vector collect_files(const std::filesystem::path& root); + +BuildResult build_archive( + const std::filesystem::path& input_dir, + const std::filesystem::path& output_file, + const KeyMaterial& keys); + +LoadedArchive load_archive(const std::filesystem::path& archive_path); + +bool verify_archive( + const LoadedArchive& archive, + const KeyMaterial& keys, + std::string& error); + +std::vector extract_entry( + const LoadedArchive& archive, + const ManifestEntry& entry, + const std::array& master_key); + +void extract_all( + const LoadedArchive& archive, + const std::filesystem::path& output_dir, + const std::array& master_key); + +std::vector serialize_manifest(const std::vector& entries); +std::vector parse_manifest(const std::vector& bytes); + +} // namespace m2pack diff --git a/src/cli.cpp b/src/cli.cpp new file mode 100644 index 0000000..2182abd --- /dev/null +++ b/src/cli.cpp @@ -0,0 +1,312 @@ +#include "cli.h" + +#include +#include +#include +#include +#include +#include + +#include + +#include "archive.h" +#include "crypto.h" +#include "util.h" + +namespace m2pack +{ + +namespace +{ + +struct ParsedArgs +{ + std::string command; + std::map options; + bool json = false; +}; + +ParsedArgs parse_args(int argc, char** argv) +{ + if (argc < 2) + { + fail("Usage: m2pack [options]"); + } + + ParsedArgs parsed; + parsed.command = argv[1]; + + for (int i = 2; i < argc; ++i) + { + const std::string current = argv[i]; + if (current == "--json") + { + parsed.json = true; + continue; + } + + if (!current.starts_with("--")) + { + fail("Unexpected argument: " + current); + } + + if (i + 1 >= argc) + { + fail("Missing value for option: " + current); + } + + parsed.options[current.substr(2)] = argv[++i]; + } + + return parsed; +} + +std::string require_option(const ParsedArgs& args, const std::string& key) +{ + const auto it = args.options.find(key); + if (it == args.options.end()) + { + fail("Missing required option --" + key); + } + return it->second; +} + +std::optional optional_option(const ParsedArgs& args, const std::string& key) +{ + const auto it = args.options.find(key); + if (it == args.options.end()) + { + return std::nullopt; + } + return it->second; +} + +std::array load_master_key(const std::string& path) +{ + const auto bytes = load_hex_file(path); + if (bytes.size() != kAeadKeySize) + { + fail("Master key must be 32 bytes"); + } + + std::array key {}; + ::memcpy(key.data(), bytes.data(), key.size()); + return key; +} + +void print_usage() +{ + std::cout + << "m2pack commands:\n" + << " keygen --out-dir [--json]\n" + << " build --input --output --key --sign-secret-key [--json]\n" + << " list --archive [--json]\n" + << " verify --archive [--public-key ] [--key ] [--json]\n" + << " extract --archive --output --key [--json]\n"; +} + +void command_keygen(const ParsedArgs& args) +{ + const auto out_dir = std::filesystem::path(require_option(args, "out-dir")); + std::filesystem::create_directories(out_dir); + + std::array public_key {}; + std::array secret_key {}; + crypto_sign_keypair(public_key.data(), secret_key.data()); + + const auto master = random_bytes(kAeadKeySize); + + const auto master_hex = to_hex(master); + const auto public_hex = to_hex(public_key.data(), public_key.size()); + const auto secret_hex = to_hex(secret_key.data(), secret_key.size()); + + write_file((out_dir / "master.key").string(), + std::vector(master_hex.begin(), master_hex.end())); + write_file((out_dir / "signing.pub").string(), + std::vector(public_hex.begin(), public_hex.end())); + write_file((out_dir / "signing.key").string(), + std::vector(secret_hex.begin(), secret_hex.end())); + + if (args.json) + { + std::cout + << "{" + << "\"ok\":true," + << "\"master_key\":\"" << json_escape((out_dir / "master.key").string()) << "\"," + << "\"signing_public_key\":\"" << json_escape((out_dir / "signing.pub").string()) << "\"," + << "\"signing_secret_key\":\"" << json_escape((out_dir / "signing.key").string()) << "\"" + << "}\n"; + return; + } + + std::cout << "Generated keys in " << out_dir << "\n"; +} + +void command_build(const ParsedArgs& args) +{ + KeyMaterial keys; + keys.master_key = load_master_key(require_option(args, "key")); + keys.signing_secret_key = load_hex_file(require_option(args, "sign-secret-key")); + + const auto result = build_archive( + require_option(args, "input"), + require_option(args, "output"), + keys); + + if (args.json) + { + std::cout + << "{" + << "\"ok\":true," + << "\"archive\":\"" << json_escape(result.archive_path.string()) << "\"," + << "\"file_count\":" << result.file_count << "," + << "\"input_bytes\":" << result.total_input_bytes << "," + << "\"stored_bytes\":" << result.total_stored_bytes + << "}\n"; + return; + } + + std::cout + << "Built " << result.archive_path + << " with " << result.file_count << " files\n"; +} + +void command_list(const ParsedArgs& args) +{ + const auto archive = load_archive(require_option(args, "archive")); + + if (args.json) + { + std::cout << "{\"ok\":true,\"entries\":["; + for (std::size_t i = 0; i < archive.entries.size(); ++i) + { + const auto& entry = archive.entries[i]; + if (i != 0) + { + std::cout << ","; + } + std::cout + << "{" + << "\"path\":\"" << json_escape(entry.path) << "\"," + << "\"original_size\":" << entry.original_size << "," + << "\"stored_size\":" << entry.stored_size + << "}"; + } + std::cout << "]}\n"; + return; + } + + for (const auto& entry : archive.entries) + { + std::cout << entry.path << " " << entry.original_size << " -> " << entry.stored_size << "\n"; + } +} + +void command_verify(const ParsedArgs& args) +{ + KeyMaterial keys; + if (const auto key_path = optional_option(args, "key")) + { + keys.master_key = load_master_key(*key_path); + } + if (const auto pub_path = optional_option(args, "public-key")) + { + keys.signing_public_key = load_hex_file(*pub_path); + } + + const auto archive = load_archive(require_option(args, "archive")); + std::string error; + const bool ok = verify_archive(archive, keys, error); + + if (args.json) + { + std::cout + << "{" + << "\"ok\":" << (ok ? "true" : "false") << "," + << "\"entry_count\":" << archive.entries.size(); + if (!ok) + { + std::cout << ",\"error\":\"" << json_escape(error) << "\""; + } + std::cout << "}\n"; + return; + } + + if (!ok) + { + fail(error); + } + + std::cout << "Archive verification passed\n"; +} + +void command_extract(const ParsedArgs& args) +{ + KeyMaterial keys; + keys.master_key = load_master_key(require_option(args, "key")); + + const auto archive = load_archive(require_option(args, "archive")); + extract_all(archive, require_option(args, "output"), keys.master_key); + + if (args.json) + { + std::cout + << "{" + << "\"ok\":true," + << "\"entry_count\":" << archive.entries.size() + << "}\n"; + return; + } + + std::cout << "Extracted " << archive.entries.size() << " files\n"; +} + +} // namespace + +int run_cli(int argc, char** argv) +{ + crypto_init(); + + if (argc == 1) + { + print_usage(); + return 0; + } + + const auto args = parse_args(argc, argv); + + if (args.command == "keygen") + { + command_keygen(args); + return 0; + } + if (args.command == "build") + { + command_build(args); + return 0; + } + if (args.command == "list") + { + command_list(args); + return 0; + } + if (args.command == "verify") + { + command_verify(args); + return 0; + } + if (args.command == "extract") + { + command_extract(args); + return 0; + } + if (args.command == "help" || args.command == "--help" || args.command == "-h") + { + print_usage(); + return 0; + } + + fail("Unknown command: " + args.command); +} + +} // namespace m2pack diff --git a/src/cli.h b/src/cli.h new file mode 100644 index 0000000..2d79cf6 --- /dev/null +++ b/src/cli.h @@ -0,0 +1,8 @@ +#pragma once + +namespace m2pack +{ + +int run_cli(int argc, char** argv); + +} diff --git a/src/crypto.cpp b/src/crypto.cpp new file mode 100644 index 0000000..f1364af --- /dev/null +++ b/src/crypto.cpp @@ -0,0 +1,237 @@ +#include "crypto.h" + +#include +#include +#include +#include +#include + +#include + +#include "util.h" + +namespace m2pack +{ + +namespace +{ + +std::uint8_t from_hex_digit(const char ch) +{ + if (ch >= '0' && ch <= '9') + { + return static_cast(ch - '0'); + } + if (ch >= 'a' && ch <= 'f') + { + return static_cast(10 + ch - 'a'); + } + if (ch >= 'A' && ch <= 'F') + { + return static_cast(10 + ch - 'A'); + } + fail("Invalid hex digit"); +} + +} // namespace + +void crypto_init() +{ + if (sodium_init() < 0) + { + fail("libsodium initialization failed"); + } +} + +std::array hash_bytes(const std::vector& bytes) +{ + std::array out {}; + crypto_generichash(out.data(), out.size(), bytes.data(), bytes.size(), nullptr, 0); + return out; +} + +std::array hash_string(std::string_view text) +{ + std::array out {}; + crypto_generichash(out.data(), out.size(), + reinterpret_cast(text.data()), text.size(), + nullptr, 0); + return out; +} + +std::vector random_bytes(std::size_t size) +{ + std::vector out(size); + randombytes_buf(out.data(), out.size()); + return out; +} + +std::array random_nonce() +{ + std::array nonce {}; + randombytes_buf(nonce.data(), nonce.size()); + return nonce; +} + +std::vector load_hex_file(const std::string& path) +{ + std::ifstream input(path); + if (!input) + { + fail("Failed to open key file: " + path); + } + + std::stringstream buffer; + buffer << input.rdbuf(); + std::string text = buffer.str(); + + text.erase(std::remove_if(text.begin(), text.end(), [](unsigned char ch) { + return std::isspace(ch) != 0; + }), text.end()); + + if (text.size() % 2 != 0) + { + fail("Hex file has odd length: " + path); + } + + std::vector bytes(text.size() / 2); + for (std::size_t i = 0; i < bytes.size(); ++i) + { + bytes[i] = static_cast((from_hex_digit(text[i * 2]) << 4) | from_hex_digit(text[i * 2 + 1])); + } + + return bytes; +} + +std::string to_hex(const std::uint8_t* data, std::size_t size) +{ + static constexpr char kHex[] = "0123456789abcdef"; + + std::string out; + out.resize(size * 2); + for (std::size_t i = 0; i < size; ++i) + { + out[i * 2] = kHex[(data[i] >> 4) & 0x0f]; + out[i * 2 + 1] = kHex[data[i] & 0x0f]; + } + return out; +} + +std::string to_hex(const std::vector& data) +{ + return to_hex(data.data(), data.size()); +} + +std::vector compress_zstd(const std::vector& input) +{ + const auto bound = ZSTD_compressBound(input.size()); + std::vector out(bound); + const auto written = ZSTD_compress(out.data(), out.size(), input.data(), input.size(), 12); + if (ZSTD_isError(written)) + { + fail("ZSTD compression failed"); + } + out.resize(written); + return out; +} + +std::vector decompress_zstd(const std::vector& input, std::size_t output_size) +{ + std::vector out(output_size); + const auto written = ZSTD_decompress(out.data(), out.size(), input.data(), input.size()); + if (ZSTD_isError(written)) + { + fail("ZSTD decompression failed"); + } + if (written != output_size) + { + fail("ZSTD decompression size mismatch"); + } + return out; +} + +std::vector encrypt_payload( + const std::vector& plaintext, + const std::array& key, + const std::array& nonce, + std::string_view associated_data) +{ + std::vector ciphertext( + plaintext.size() + crypto_aead_xchacha20poly1305_ietf_ABYTES); + + unsigned long long written = 0; + if (crypto_aead_xchacha20poly1305_ietf_encrypt( + ciphertext.data(), + &written, + plaintext.data(), + plaintext.size(), + reinterpret_cast(associated_data.data()), + associated_data.size(), + nullptr, + nonce.data(), + key.data()) != 0) + { + fail("Encryption failed"); + } + + ciphertext.resize(static_cast(written)); + return ciphertext; +} + +std::vector decrypt_payload( + const std::vector& ciphertext, + const std::array& key, + const std::array& nonce, + std::string_view associated_data) +{ + std::vector plaintext(ciphertext.size()); + + unsigned long long written = 0; + if (crypto_aead_xchacha20poly1305_ietf_decrypt( + plaintext.data(), + &written, + nullptr, + ciphertext.data(), + ciphertext.size(), + reinterpret_cast(associated_data.data()), + associated_data.size(), + nonce.data(), + key.data()) != 0) + { + fail("Payload authentication failed"); + } + + plaintext.resize(static_cast(written)); + return plaintext; +} + +std::vector sign_detached( + const std::vector& data, + const std::vector& secret_key) +{ + if (secret_key.size() != crypto_sign_SECRETKEYBYTES) + { + fail("Signing secret key has invalid size"); + } + + std::vector signature(crypto_sign_BYTES); + unsigned long long sig_len = 0; + crypto_sign_detached(signature.data(), &sig_len, data.data(), data.size(), secret_key.data()); + signature.resize(static_cast(sig_len)); + return signature; +} + +bool verify_detached( + const std::vector& data, + const std::uint8_t* signature, + const std::vector& public_key) +{ + if (public_key.size() != crypto_sign_PUBLICKEYBYTES) + { + fail("Signing public key has invalid size"); + } + + return crypto_sign_verify_detached(signature, data.data(), data.size(), public_key.data()) == 0; +} + +} // namespace m2pack diff --git a/src/crypto.h b/src/crypto.h new file mode 100644 index 0000000..2e131e1 --- /dev/null +++ b/src/crypto.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "archive.h" + +namespace m2pack +{ + +void crypto_init(); + +std::array hash_bytes(const std::vector& bytes); +std::array hash_string(std::string_view text); + +std::vector random_bytes(std::size_t size); +std::array random_nonce(); + +std::vector load_hex_file(const std::string& path); +std::string to_hex(const std::uint8_t* data, std::size_t size); +std::string to_hex(const std::vector& data); + +std::vector compress_zstd(const std::vector& input); +std::vector decompress_zstd(const std::vector& input, std::size_t output_size); + +std::vector encrypt_payload( + const std::vector& plaintext, + const std::array& key, + const std::array& nonce, + std::string_view associated_data); + +std::vector decrypt_payload( + const std::vector& ciphertext, + const std::array& key, + const std::array& nonce, + std::string_view associated_data); + +std::vector sign_detached( + const std::vector& data, + const std::vector& secret_key); + +bool verify_detached( + const std::vector& data, + const std::uint8_t* signature, + const std::vector& public_key); + +} // namespace m2pack diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..b31f46f --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,17 @@ +#include +#include + +#include "cli.h" + +int main(int argc, char** argv) +{ + try + { + return m2pack::run_cli(argc, argv); + } + catch (const std::exception& ex) + { + std::cerr << "m2pack: " << ex.what() << "\n"; + return 1; + } +} diff --git a/src/util.cpp b/src/util.cpp new file mode 100644 index 0000000..9bfc072 --- /dev/null +++ b/src/util.cpp @@ -0,0 +1,89 @@ +#include "util.h" + +#include +#include +#include +#include +#include + +namespace m2pack +{ + +[[noreturn]] void fail(const std::string& message) +{ + throw std::runtime_error(message); +} + +std::vector read_file(const std::string& path) +{ + std::ifstream input(path, std::ios::binary); + if (!input) + { + fail("Failed to open file for reading: " + path); + } + + input.seekg(0, std::ios::end); + const auto size = static_cast(input.tellg()); + input.seekg(0, std::ios::beg); + + std::vector bytes(size); + if (!bytes.empty()) + { + input.read(reinterpret_cast(bytes.data()), static_cast(bytes.size())); + } + + if (!input.good() && !input.eof()) + { + fail("Failed to read file: " + path); + } + + return bytes; +} + +void write_file(const std::string& path, const std::vector& data) +{ + std::filesystem::create_directories(std::filesystem::path(path).parent_path()); + std::ofstream output(path, std::ios::binary); + if (!output) + { + fail("Failed to open file for writing: " + path); + } + + if (!data.empty()) + { + output.write(reinterpret_cast(data.data()), static_cast(data.size())); + } + + if (!output.good()) + { + fail("Failed to write file: " + path); + } +} + +std::string json_escape(std::string_view value) +{ + std::string out; + out.reserve(value.size() + 8); + + for (const char ch : value) + { + switch (ch) + { + case '\\': out += "\\\\"; break; + case '"': out += "\\\""; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: out += ch; break; + } + } + + return out; +} + +void write_stdout(const std::vector& data) +{ + std::cout.write(reinterpret_cast(data.data()), static_cast(data.size())); +} + +} // namespace m2pack diff --git a/src/util.h b/src/util.h new file mode 100644 index 0000000..e842e70 --- /dev/null +++ b/src/util.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace m2pack +{ + +[[noreturn]] void fail(const std::string& message); +std::vector read_file(const std::string& path); +void write_file(const std::string& path, const std::vector& data); +std::string json_escape(std::string_view value); +void write_stdout(const std::vector& data); + +template +void append_pod(std::vector& out, const T& value) +{ + const auto* ptr = reinterpret_cast(&value); + out.insert(out.end(), ptr, ptr + sizeof(T)); +} + +template +T read_pod(const std::vector& bytes, std::size_t& offset) +{ + if (offset + sizeof(T) > bytes.size()) + { + fail("Unexpected end of buffer while decoding archive data"); + } + + T value {}; + ::memcpy(&value, bytes.data() + offset, sizeof(T)); + offset += sizeof(T); + return value; +} + +} // namespace m2pack