Initial secure packer CLI

This commit is contained in:
server
2026-04-14 11:12:29 +02:00
commit 59262f1691
14 changed files with 1458 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
/build/
/.idea/
/.vscode/
*.tmp
*.swp
keys/
out/

48
CMakeLists.txt Normal file
View File

@@ -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)

80
README.md Normal file
View File

@@ -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).

View File

@@ -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.

65
docs/format.md Normal file
View File

@@ -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

339
src/archive.cpp Normal file
View File

@@ -0,0 +1,339 @@
#include "archive.h"
#include <algorithm>
#include <cstring>
#include <fstream>
#include <unordered_set>
#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<std::uint8_t, kAeadKeySize> 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<char>(std::tolower(ch));
});
return out;
}
std::vector<std::filesystem::path> collect_files(const std::filesystem::path& root)
{
std::vector<std::filesystem::path> 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<std::uint8_t> serialize_manifest(const std::vector<ManifestEntry>& entries)
{
std::vector<std::uint8_t> bytes;
const ManifestFixedHeader fixed {
static_cast<std::uint32_t>(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<std::uint16_t>(entry.path.size());
pod.compression = static_cast<std::uint8_t>(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<ManifestEntry> parse_manifest(const std::vector<std::uint8_t>& bytes)
{
std::size_t offset = 0;
const auto fixed = read_pod<ManifestFixedHeader>(bytes, offset);
std::vector<ManifestEntry> entries;
entries.reserve(fixed.entry_count);
for (std::uint32_t i = 0; i < fixed.entry_count; ++i)
{
const auto pod = read_pod<ManifestEntryFixed>(bytes, offset);
if (offset + pod.path_size > bytes.size())
{
fail("Manifest path data exceeds buffer");
}
ManifestEntry entry;
entry.path.assign(reinterpret_cast<const char*>(bytes.data() + offset), pod.path_size);
offset += pod.path_size;
entry.compression = static_cast<Compression>(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<ManifestEntry> manifest_entries;
std::vector<std::uint8_t> 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<const char*>(&header), sizeof(header));
if (!payload_bytes.empty())
{
output.write(reinterpret_cast<const char*>(payload_bytes.data()), static_cast<std::streamsize>(payload_bytes.size()));
}
if (!manifest_bytes.empty())
{
output.write(reinterpret_cast<const char*>(manifest_bytes.data()), static_cast<std::streamsize>(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<std::ptrdiff_t>(archive.header.manifest_offset),
archive.file_bytes.begin() + static_cast<std::ptrdiff_t>(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<std::string> 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<std::uint8_t, kAeadKeySize> {})
{
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<std::uint8_t> extract_entry(
const LoadedArchive& archive,
const ManifestEntry& entry,
const std::array<std::uint8_t, kAeadKeySize>& master_key)
{
const std::size_t begin = sizeof(ArchiveHeader) + static_cast<std::size_t>(entry.data_offset);
const std::size_t end = begin + static_cast<std::size_t>(entry.stored_size);
const std::vector<std::uint8_t> ciphertext(
archive.file_bytes.begin() + static_cast<std::ptrdiff_t>(begin),
archive.file_bytes.begin() + static_cast<std::ptrdiff_t>(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<std::size_t>(entry.original_size));
default:
fail("Unsupported compression mode");
}
}
void extract_all(
const LoadedArchive& archive,
const std::filesystem::path& output_dir,
const std::array<std::uint8_t, kAeadKeySize>& 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

120
src/archive.h Normal file
View File

@@ -0,0 +1,120 @@
#pragma once
#include <array>
#include <cstdint>
#include <filesystem>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
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<std::uint8_t, kAeadNonceSize> nonce {};
std::array<std::uint8_t, kHashSize> plaintext_hash {};
};
struct LoadedArchive
{
ArchiveHeader header {};
std::vector<std::uint8_t> manifest_bytes;
std::vector<ManifestEntry> entries;
std::vector<std::uint8_t> file_bytes;
};
struct KeyMaterial
{
std::array<std::uint8_t, kAeadKeySize> master_key {};
std::optional<std::vector<std::uint8_t>> signing_secret_key;
std::optional<std::vector<std::uint8_t>> 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<std::filesystem::path> 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<std::uint8_t> extract_entry(
const LoadedArchive& archive,
const ManifestEntry& entry,
const std::array<std::uint8_t, kAeadKeySize>& master_key);
void extract_all(
const LoadedArchive& archive,
const std::filesystem::path& output_dir,
const std::array<std::uint8_t, kAeadKeySize>& master_key);
std::vector<std::uint8_t> serialize_manifest(const std::vector<ManifestEntry>& entries);
std::vector<ManifestEntry> parse_manifest(const std::vector<std::uint8_t>& bytes);
} // namespace m2pack

312
src/cli.cpp Normal file
View File

@@ -0,0 +1,312 @@
#include "cli.h"
#include <array>
#include <cstring>
#include <filesystem>
#include <iostream>
#include <map>
#include <sstream>
#include <sodium.h>
#include "archive.h"
#include "crypto.h"
#include "util.h"
namespace m2pack
{
namespace
{
struct ParsedArgs
{
std::string command;
std::map<std::string, std::string> options;
bool json = false;
};
ParsedArgs parse_args(int argc, char** argv)
{
if (argc < 2)
{
fail("Usage: m2pack <command> [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<std::string> 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<std::uint8_t, kAeadKeySize> 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<std::uint8_t, kAeadKeySize> key {};
::memcpy(key.data(), bytes.data(), key.size());
return key;
}
void print_usage()
{
std::cout
<< "m2pack commands:\n"
<< " keygen --out-dir <dir> [--json]\n"
<< " build --input <dir> --output <file> --key <hex-file> --sign-secret-key <hex-file> [--json]\n"
<< " list --archive <file> [--json]\n"
<< " verify --archive <file> [--public-key <hex-file>] [--key <hex-file>] [--json]\n"
<< " extract --archive <file> --output <dir> --key <hex-file> [--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<unsigned char, crypto_sign_PUBLICKEYBYTES> public_key {};
std::array<unsigned char, crypto_sign_SECRETKEYBYTES> 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<std::uint8_t>(master_hex.begin(), master_hex.end()));
write_file((out_dir / "signing.pub").string(),
std::vector<std::uint8_t>(public_hex.begin(), public_hex.end()));
write_file((out_dir / "signing.key").string(),
std::vector<std::uint8_t>(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

8
src/cli.h Normal file
View File

@@ -0,0 +1,8 @@
#pragma once
namespace m2pack
{
int run_cli(int argc, char** argv);
}

237
src/crypto.cpp Normal file
View File

@@ -0,0 +1,237 @@
#include "crypto.h"
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <zstd.h>
#include <sodium.h>
#include "util.h"
namespace m2pack
{
namespace
{
std::uint8_t from_hex_digit(const char ch)
{
if (ch >= '0' && ch <= '9')
{
return static_cast<std::uint8_t>(ch - '0');
}
if (ch >= 'a' && ch <= 'f')
{
return static_cast<std::uint8_t>(10 + ch - 'a');
}
if (ch >= 'A' && ch <= 'F')
{
return static_cast<std::uint8_t>(10 + ch - 'A');
}
fail("Invalid hex digit");
}
} // namespace
void crypto_init()
{
if (sodium_init() < 0)
{
fail("libsodium initialization failed");
}
}
std::array<std::uint8_t, kHashSize> hash_bytes(const std::vector<std::uint8_t>& bytes)
{
std::array<std::uint8_t, kHashSize> out {};
crypto_generichash(out.data(), out.size(), bytes.data(), bytes.size(), nullptr, 0);
return out;
}
std::array<std::uint8_t, kHashSize> hash_string(std::string_view text)
{
std::array<std::uint8_t, kHashSize> out {};
crypto_generichash(out.data(), out.size(),
reinterpret_cast<const unsigned char*>(text.data()), text.size(),
nullptr, 0);
return out;
}
std::vector<std::uint8_t> random_bytes(std::size_t size)
{
std::vector<std::uint8_t> out(size);
randombytes_buf(out.data(), out.size());
return out;
}
std::array<std::uint8_t, kAeadNonceSize> random_nonce()
{
std::array<std::uint8_t, kAeadNonceSize> nonce {};
randombytes_buf(nonce.data(), nonce.size());
return nonce;
}
std::vector<std::uint8_t> 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<std::uint8_t> bytes(text.size() / 2);
for (std::size_t i = 0; i < bytes.size(); ++i)
{
bytes[i] = static_cast<std::uint8_t>((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<std::uint8_t>& data)
{
return to_hex(data.data(), data.size());
}
std::vector<std::uint8_t> compress_zstd(const std::vector<std::uint8_t>& input)
{
const auto bound = ZSTD_compressBound(input.size());
std::vector<std::uint8_t> 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<std::uint8_t> decompress_zstd(const std::vector<std::uint8_t>& input, std::size_t output_size)
{
std::vector<std::uint8_t> 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<std::uint8_t> encrypt_payload(
const std::vector<std::uint8_t>& plaintext,
const std::array<std::uint8_t, kAeadKeySize>& key,
const std::array<std::uint8_t, kAeadNonceSize>& nonce,
std::string_view associated_data)
{
std::vector<std::uint8_t> 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<const unsigned char*>(associated_data.data()),
associated_data.size(),
nullptr,
nonce.data(),
key.data()) != 0)
{
fail("Encryption failed");
}
ciphertext.resize(static_cast<std::size_t>(written));
return ciphertext;
}
std::vector<std::uint8_t> decrypt_payload(
const std::vector<std::uint8_t>& ciphertext,
const std::array<std::uint8_t, kAeadKeySize>& key,
const std::array<std::uint8_t, kAeadNonceSize>& nonce,
std::string_view associated_data)
{
std::vector<std::uint8_t> plaintext(ciphertext.size());
unsigned long long written = 0;
if (crypto_aead_xchacha20poly1305_ietf_decrypt(
plaintext.data(),
&written,
nullptr,
ciphertext.data(),
ciphertext.size(),
reinterpret_cast<const unsigned char*>(associated_data.data()),
associated_data.size(),
nonce.data(),
key.data()) != 0)
{
fail("Payload authentication failed");
}
plaintext.resize(static_cast<std::size_t>(written));
return plaintext;
}
std::vector<std::uint8_t> sign_detached(
const std::vector<std::uint8_t>& data,
const std::vector<std::uint8_t>& secret_key)
{
if (secret_key.size() != crypto_sign_SECRETKEYBYTES)
{
fail("Signing secret key has invalid size");
}
std::vector<std::uint8_t> 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<std::size_t>(sig_len));
return signature;
}
bool verify_detached(
const std::vector<std::uint8_t>& data,
const std::uint8_t* signature,
const std::vector<std::uint8_t>& 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

50
src/crypto.h Normal file
View File

@@ -0,0 +1,50 @@
#pragma once
#include <array>
#include <cstdint>
#include <string>
#include <string_view>
#include <vector>
#include "archive.h"
namespace m2pack
{
void crypto_init();
std::array<std::uint8_t, kHashSize> hash_bytes(const std::vector<std::uint8_t>& bytes);
std::array<std::uint8_t, kHashSize> hash_string(std::string_view text);
std::vector<std::uint8_t> random_bytes(std::size_t size);
std::array<std::uint8_t, kAeadNonceSize> random_nonce();
std::vector<std::uint8_t> 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<std::uint8_t>& data);
std::vector<std::uint8_t> compress_zstd(const std::vector<std::uint8_t>& input);
std::vector<std::uint8_t> decompress_zstd(const std::vector<std::uint8_t>& input, std::size_t output_size);
std::vector<std::uint8_t> encrypt_payload(
const std::vector<std::uint8_t>& plaintext,
const std::array<std::uint8_t, kAeadKeySize>& key,
const std::array<std::uint8_t, kAeadNonceSize>& nonce,
std::string_view associated_data);
std::vector<std::uint8_t> decrypt_payload(
const std::vector<std::uint8_t>& ciphertext,
const std::array<std::uint8_t, kAeadKeySize>& key,
const std::array<std::uint8_t, kAeadNonceSize>& nonce,
std::string_view associated_data);
std::vector<std::uint8_t> sign_detached(
const std::vector<std::uint8_t>& data,
const std::vector<std::uint8_t>& secret_key);
bool verify_detached(
const std::vector<std::uint8_t>& data,
const std::uint8_t* signature,
const std::vector<std::uint8_t>& public_key);
} // namespace m2pack

17
src/main.cpp Normal file
View File

@@ -0,0 +1,17 @@
#include <exception>
#include <iostream>
#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;
}
}

89
src/util.cpp Normal file
View File

@@ -0,0 +1,89 @@
#include "util.h"
#include <cstring>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <stdexcept>
namespace m2pack
{
[[noreturn]] void fail(const std::string& message)
{
throw std::runtime_error(message);
}
std::vector<std::uint8_t> 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<std::size_t>(input.tellg());
input.seekg(0, std::ios::beg);
std::vector<std::uint8_t> bytes(size);
if (!bytes.empty())
{
input.read(reinterpret_cast<char*>(bytes.data()), static_cast<std::streamsize>(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<std::uint8_t>& 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<const char*>(data.data()), static_cast<std::streamsize>(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<std::uint8_t>& data)
{
std::cout.write(reinterpret_cast<const char*>(data.data()), static_cast<std::streamsize>(data.size()));
}
} // namespace m2pack

39
src/util.h Normal file
View File

@@ -0,0 +1,39 @@
#pragma once
#include <cstring>
#include <cstdint>
#include <string>
#include <string_view>
#include <vector>
namespace m2pack
{
[[noreturn]] void fail(const std::string& message);
std::vector<std::uint8_t> read_file(const std::string& path);
void write_file(const std::string& path, const std::vector<std::uint8_t>& data);
std::string json_escape(std::string_view value);
void write_stdout(const std::vector<std::uint8_t>& data);
template <typename T>
void append_pod(std::vector<std::uint8_t>& out, const T& value)
{
const auto* ptr = reinterpret_cast<const std::uint8_t*>(&value);
out.insert(out.end(), ptr, ptr + sizeof(T));
}
template <typename T>
T read_pod(const std::vector<std::uint8_t>& 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