Initial secure packer CLI
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/build/
|
||||||
|
/.idea/
|
||||||
|
/.vscode/
|
||||||
|
*.tmp
|
||||||
|
*.swp
|
||||||
|
keys/
|
||||||
|
out/
|
||||||
48
CMakeLists.txt
Normal file
48
CMakeLists.txt
Normal 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
80
README.md
Normal 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).
|
||||||
47
docs/client-integration.md
Normal file
47
docs/client-integration.md
Normal 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
65
docs/format.md
Normal 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
339
src/archive.cpp
Normal 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
120
src/archive.h
Normal 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
312
src/cli.cpp
Normal 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
8
src/cli.h
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
namespace m2pack
|
||||||
|
{
|
||||||
|
|
||||||
|
int run_cli(int argc, char** argv);
|
||||||
|
|
||||||
|
}
|
||||||
237
src/crypto.cpp
Normal file
237
src/crypto.cpp
Normal 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
50
src/crypto.h
Normal 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
17
src/main.cpp
Normal 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
89
src/util.cpp
Normal 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
39
src/util.h
Normal 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
|
||||||
Reference in New Issue
Block a user