diff --git a/CMakeLists.txt b/CMakeLists.txt index 416267e..7d0f7d1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,7 +6,7 @@ cmake_minimum_required(VERSION 3.16) # If MAJOR is 0, and MINOR > 0, Version is BETA project(ColumnLynx - VERSION 1.1.1 + VERSION 1.2.0 LANGUAGES CXX ) diff --git a/README.md b/README.md index d3ad9d7..7297c0a 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,22 @@ openssl pkey -in key.pem -pubout -outform DER | tail -c 32 | xxd -p -c 32 # Output example: 1c9d4f7a3b2e8a6d0f5c9b1e4d8a7f3c6e2b1a9d5f4c8e0a7b3d6c9f2e ``` -You can then set these keys accordingly in the **server_config** and **client_config** files. +Write the hex output into two separate files in the matching config directory: + +- `public.key` for the public key +- `private.key` for the private key seed + +The files should contain only the hex ASCII characters, optionally followed by a trailing newline. Hex parsing is case-insensitive. + +For example: + +```bash +printf '%s' '1c9d4f7a3b2e8a6d0f5c9b1e4d8a7f3c6e2b1a9d5f4c8e0a7b3d6c9f2e' > /etc/columnlynx/public.key +printf '%s' '9f3a2b6c0f8e4d1a7c3e9a4b5d2f8c6e1a9d0b7e3f4c2a8e6d5b1f0a3c4e' > /etc/columnlynx/private.key +chmod 600 /etc/columnlynx/private.key +``` + +On Unix-like systems, the software will warn if the private key file is too permissive and recommend tightening it with `chmod 600`. ### Server Setup (Linux Server ONLY) @@ -157,20 +172,19 @@ sudo nft add rule nat postroute ip saddr 10.10.0.0/24 oifname "eth0" masquerade "**server_config**" is a file that contains the server configuration, **one variable per line**. These are the current configuration available variables: -- **SERVER_PUBLIC_KEY** (Hex String): The public key to be used - Used for verification -- **SERVER_PRIVATE_KEY** (Hex String): The private key seed to be used - **NETWORK** (IPv4 Format): The network IPv4 to be used (Server Interface still needs to be configured manually) - **SUBNET_MASK** (Integer): The subnet mask to be used (ensure proper length, it will not be checked) **Example:** ``` -SERVER_PUBLIC_KEY=1c9d4f7a3b2e8a6d0f5c9b1e4d8a7f3c6e2b1a9d5f4c8e0a7b3d6c9f2e -SERVER_PRIVATE_KEY=9f3a2b6c0f8e4d1a7c3e9a4b5d2f8c6e1a9d0b7e3f4c2a8e6d5b1f0a3c4e NETWORK=10.10.0.0 SUBNET_MASK=24 ``` +The server keypair must now live in the same directory as `server_config`, stored in `public.key` and `private.key`. +`server_config` no longer stores key material. +
"**whitelisted_keys**" is a file that **public keys of clients that are allowed to connect to the server, one key per line**. @@ -184,17 +198,9 @@ SUBNET_MASK=24 ### Client -"**client_config**" is a file that contains the client configuration, **one variable per line**. These are the current configuration available variables: +"**client_config**" is a file that contains the client configuration, **one variable per line**. Key material is no longer stored here; if you do not have any client-only settings yet, this file can stay empty. -- **CLIENT_PUBLIC_KEY** (Hex String): The public key to be used - Used for verification -- **CLIENT_PRIVATE_KEY** (Hex String): The private key seed to be used - -**Example:** - -``` -CLIENT_PUBLIC_KEY=1c9d4f7a3b2e8a6d0f5c9b1e4d8a7f3c6e2b1a9d5f4c8e0a7b3d6c9f2e -CLIENT_PRIVATE_KEY=9f3a2b6c0f8e4d1a7c3e9a4b5d2f8c6e1a9d0b7e3f4c2a8e6d5b1f0a3c4e -``` +The client keypair must now live in the same directory as `client_config`, stored in `public.key` and `private.key`.
diff --git a/include/columnlynx/client/net/tcp/tcp_client.hpp b/include/columnlynx/client/net/tcp/tcp_client.hpp index d39b135..fa850f7 100644 --- a/include/columnlynx/client/net/tcp/tcp_client.hpp +++ b/include/columnlynx/client/net/tcp/tcp_client.hpp @@ -14,7 +14,6 @@ #include #include #include -#include #include #include #include @@ -27,48 +26,7 @@ namespace ColumnLynx::Net::TCP { public: TCPClient(asio::io_context& ioContext, const std::string& host, - const std::string& port) - : - mResolver(ioContext), - mSocket(ioContext), - mHost(host), - mPort(port), - mHeartbeatTimer(mSocket.get_executor()), - mLastHeartbeatReceived(std::chrono::steady_clock::now()), - mLastHeartbeatSent(std::chrono::steady_clock::now()) - { - // Get initial client config - std::string configPath = ClientSession::getInstance().getConfigPath(); - std::shared_ptr mLibSodiumWrapper = ClientSession::getInstance().getSodiumWrapper(); - - // Preload the config map - mRawClientConfig = Utils::getConfigMap(configPath + "client_config"); - - auto itPubkey = mRawClientConfig.find("CLIENT_PUBLIC_KEY"); - auto itPrivkey = mRawClientConfig.find("CLIENT_PRIVATE_KEY"); - - if (itPubkey != mRawClientConfig.end() && itPrivkey != mRawClientConfig.end()) { - Utils::log("Loading keypair from config file."); - - PublicKey pk; - PrivateSeed seed; - - std::copy_n(Utils::hexStringToBytes(itPrivkey->second).begin(), seed.size(), seed.begin()); // This is extremely stupid, but the C++ compiler has forced my hand (I would've just used to_array, but fucking asio decls) - std::copy_n(Utils::hexStringToBytes(itPubkey->second).begin(), pk.size(), pk.begin()); - - if (!mLibSodiumWrapper->recomputeKeys(seed, pk)) { - throw std::runtime_error("Failed to recompute keypair from config file values!"); - } - - Utils::debug("Newly-Loaded Public Key: " + Utils::bytesToHexString(mLibSodiumWrapper->getPublicKey(), 32)); - } else { - #if defined(DEBUG) - Utils::warn("No keypair found in config file! Using random key."); - #else - throw std::runtime_error("No keypair found in config file! Cannot start client without keys."); - #endif - } - } + const std::string& port); // Starts the TCP Client and initiaties the handshake void start(); @@ -106,6 +64,5 @@ namespace ColumnLynx::Net::TCP { int mMissedHeartbeats = 0; bool mIsHostDomain; Protocol::TunConfig mTunConfig; - std::unordered_map mRawClientConfig; }; } \ No newline at end of file diff --git a/include/columnlynx/common/utils.hpp b/include/columnlynx/common/utils.hpp index 5ad2605..626804a 100644 --- a/include/columnlynx/common/utils.hpp +++ b/include/columnlynx/common/utils.hpp @@ -79,4 +79,15 @@ namespace ColumnLynx::Utils { // Returns the config file in an unordered_map format. This purely reads the config file, you still need to parse it manually. std::unordered_map getConfigMap(std::string path, std::vector requiredKeys = {}); + + // Load a hex-encoded file, validate its byte length, and return the decoded bytes. + std::vector loadHexBytesFromFile(const std::string& path, size_t expectedBytes, const std::string& description = "key", bool warnOnInsecurePermissions = false); + + template + inline std::array loadHexArrayFromFile(const std::string& path, const std::string& description = "key", bool warnOnInsecurePermissions = false) { + auto bytes = loadHexBytesFromFile(path, N, description, warnOnInsecurePermissions); + std::array out{}; + std::copy_n(bytes.begin(), N, out.begin()); + return out; + } }; \ No newline at end of file diff --git a/src/client/main.cpp b/src/client/main.cpp index be27d56..1e99a1e 100644 --- a/src/client/main.cpp +++ b/src/client/main.cpp @@ -127,6 +127,28 @@ int main(int argc, char** argv) { initialState.virtualInterface = tun; std::shared_ptr sodiumWrapper = std::make_shared(); + const std::string clientPublicKeyPath = configPath + "public.key"; + const std::string clientPrivateKeyPath = configPath + "private.key"; + + namespace fs = std::filesystem; + bool clientKeyFilesPresent = fs::exists(clientPublicKeyPath) && fs::exists(clientPrivateKeyPath); + if (clientKeyFilesPresent) { + Utils::log("Loading client keypair from key files."); + + PublicKey pk = Utils::loadHexArrayFromFile(clientPublicKeyPath, "client public key"); + PrivateSeed seed = Utils::loadHexArrayFromFile(clientPrivateKeyPath, "client private key", true); + + if (!sodiumWrapper->recomputeKeys(seed, pk)) { + throw std::runtime_error("Failed to recompute client keypair from key files!"); + } + } else { +#if defined(DEBUG) + Utils::warn("No client keypair files found! Using random key."); +#else + throw std::runtime_error("No client keypair files found! Cannot start client without keys."); +#endif + } + debug("Public Key: " + Utils::bytesToHexString(sodiumWrapper->getPublicKey(), 32)); debug("Private Key: " + Utils::bytesToHexString(sodiumWrapper->getPrivateKey(), 64)); initialState.sodiumWrapper = sodiumWrapper; diff --git a/src/client/net/tcp/tcp_client.cpp b/src/client/net/tcp/tcp_client.cpp index 9766d86..ab74ef6 100644 --- a/src/client/net/tcp/tcp_client.cpp +++ b/src/client/net/tcp/tcp_client.cpp @@ -6,6 +6,20 @@ //#include namespace ColumnLynx::Net::TCP { + TCPClient::TCPClient(asio::io_context& ioContext, const std::string& host, const std::string& port) + : mResolver(ioContext), + mSocket(ioContext), + mHost(host), + mPort(port), + mHeartbeatTimer(mSocket.get_executor()), + mLastHeartbeatReceived(std::chrono::steady_clock::now()), + mLastHeartbeatSent(std::chrono::steady_clock::now()) + { + if (!ClientSession::getInstance().getSodiumWrapper()) { + throw std::runtime_error("ClientSession sodium wrapper is not initialized"); + } + } + void TCPClient::start() { auto self = shared_from_this(); mResolver.async_resolve(mHost, mPort, diff --git a/src/common/utils.cpp b/src/common/utils.cpp index 9ba8d40..d11784e 100644 --- a/src/common/utils.cpp +++ b/src/common/utils.cpp @@ -4,6 +4,7 @@ #include #include +#include namespace ColumnLynx::Utils { std::string unixMillisToISO8601(uint64_t unixMillis, bool local) { @@ -86,7 +87,7 @@ namespace ColumnLynx::Utils { } std::string getVersion() { - return "1.1.1"; + return "1.2.0"; } unsigned short serverPort() { @@ -251,4 +252,78 @@ namespace ColumnLynx::Utils { return config; } + + std::vector loadHexBytesFromFile(const std::string& path, size_t expectedBytes, const std::string& description, bool warnOnInsecurePermissions) { + namespace fs = std::filesystem; + std::error_code ec; + + fs::path p(path); + fs::path abs = fs::absolute(p, ec); + if (ec) { + throw std::runtime_error("loadHexBytesFromFile(): failed to resolve path: " + path + " - " + ec.message()); + } + + if (!fs::exists(abs, ec) || ec) { + throw std::runtime_error("loadHexBytesFromFile(): file does not exist: " + abs.string()); + } + + fs::path canon = fs::canonical(abs, ec); + if (ec) { + throw std::runtime_error("loadHexBytesFromFile(): failed to canonicalize path: " + abs.string()); + } + +#ifndef _WIN32 + if (warnOnInsecurePermissions) { + ec.clear(); + fs::file_status status = fs::status(canon, ec); + if (ec) { + warn("loadHexBytesFromFile(): failed to inspect permissions for " + canon.string() + " - " + ec.message()); + } else { + auto perms = status.permissions(); + if ((perms & (fs::perms::group_all | fs::perms::others_all)) != fs::perms::none) { + warn(description + " file permissions are too permissive: " + canon.string() + " (recommend chmod 600)"); + } + + if (!fs::is_regular_file(status)) { + warn(description + " path is not a regular file: " + canon.string()); + } + } + } +#endif + + std::ifstream file(canon); + if (!file.is_open()) { + throw std::runtime_error("Failed to open " + description + " file at path: " + canon.string()); + } + + std::string hex((std::istreambuf_iterator(file)), std::istreambuf_iterator()); + + auto trim = [](std::string& s) { + while (!s.empty() && std::isspace(static_cast(s.back()))) { + s.pop_back(); + } + + size_t start = 0; + while (start < s.size() && std::isspace(static_cast(s[start]))) { + ++start; + } + + if (start > 0) { + s.erase(0, start); + } + }; + + trim(hex); + + if (hex.empty()) { + throw std::runtime_error(description + " file is empty: " + canon.string()); + } + + std::vector bytes = hexStringToBytes(hex); + if (bytes.size() != expectedBytes) { + throw std::runtime_error(description + " file must contain exactly " + std::to_string(expectedBytes * 2) + " hex characters (" + std::to_string(expectedBytes) + " bytes), got " + std::to_string(bytes.size()) + " bytes"); + } + + return bytes; + } } \ No newline at end of file diff --git a/src/server/main.cpp b/src/server/main.cpp index 4f20de6..89f611d 100644 --- a/src/server/main.cpp +++ b/src/server/main.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -91,10 +92,9 @@ int main(int argc, char** argv) { serverState.configPath = configPath; #if defined(DEBUG) - std::unordered_map config = Utils::getConfigMap(configPath + "server_config", { "NETWORK", "SUBNET_MASK" }); + std::unordered_map config = Utils::getConfigMap(configPath + "server_config", { "NETWORK", "SUBNET_MASK" }); #else - // A production server should never use random keys. If the config file cannot be read or does not contain keys, the server will fail to start. - std::unordered_map config = Utils::getConfigMap(configPath + "server_config", { "NETWORK", "SUBNET_MASK", "SERVER_PUBLIC_KEY", "SERVER_PRIVATE_KEY" }); + std::unordered_map config = Utils::getConfigMap(configPath + "server_config", { "NETWORK", "SUBNET_MASK" }); #endif serverState.serverConfig = config; @@ -105,30 +105,28 @@ int main(int argc, char** argv) { // Store a reference to the tun in the serverState, it will increment and keep a safe reference (we love shared_ptrs) serverState.virtualInterface = tun; - // Generate a temporary keypair, replace with actual CA signed keys later (Note, these are stored in memory) std::shared_ptr sodiumWrapper = std::make_shared(); - auto itPubkey = config.find("SERVER_PUBLIC_KEY"); - auto itPrivkey = config.find("SERVER_PRIVATE_KEY"); + const std::string serverPublicKeyPath = configPath + "public.key"; + const std::string serverPrivateKeyPath = configPath + "private.key"; - if (itPubkey != config.end() && itPrivkey != config.end()) { - log("Loading keypair from config file."); + namespace fs = std::filesystem; + bool serverKeyFilesPresent = fs::exists(serverPublicKeyPath) && fs::exists(serverPrivateKeyPath); + if (serverKeyFilesPresent) { + log("Loading server keypair from key files."); - PublicKey pk; - PrivateSeed seed; - - std::copy_n(Utils::hexStringToBytes(itPrivkey->second).begin(), seed.size(), seed.begin()); - std::copy_n(Utils::hexStringToBytes(itPubkey->second).begin(), pk.size(), pk.begin()); + PublicKey pk = Utils::loadHexArrayFromFile(serverPublicKeyPath, "server public key"); + PrivateSeed seed = Utils::loadHexArrayFromFile(serverPrivateKeyPath, "server private key", true); if (!sodiumWrapper->recomputeKeys(seed, pk)) { - throw std::runtime_error("Failed to recompute keypair from config file values!"); + throw std::runtime_error("Failed to recompute keypair from key files!"); } } else { - #if defined(DEBUG) - warn("No keypair found in config file! Using random key."); - #else - throw std::runtime_error("No keypair found in config file! Cannot start server without keys."); - #endif +#if defined(DEBUG) + warn("No server keypair files found! Using random key."); +#else + throw std::runtime_error("No server keypair files found! Cannot start server without keys."); +#endif } log("Server public key: " + bytesToHexString(sodiumWrapper->getPublicKey(), crypto_sign_PUBLICKEYBYTES)); diff --git a/tests/test_key_file_loading.cpp b/tests/test_key_file_loading.cpp new file mode 100644 index 0000000..6929ba2 --- /dev/null +++ b/tests/test_key_file_loading.cpp @@ -0,0 +1,62 @@ +// Tests for hex key file loading helpers +#include +#include +#include +#include + +#include +#include + +int main() { + namespace fs = std::filesystem; + using namespace ColumnLynx::Utils; + + fs::path tempDir = fs::temp_directory_path() / "columnlynx_key_loader_test"; + fs::remove_all(tempDir); + fs::create_directories(tempDir); + + auto publicKeyPath = tempDir / "public.key"; + auto privateKeyPath = tempDir / "private.key"; + + { + std::ofstream pub(publicKeyPath); + pub << "00112233445566778899aabbccddeeff00112233445566778899AABBCCDDEEFF"; + } + + { + std::ofstream priv(privateKeyPath); + priv << "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100\n"; + } + + auto pk = loadHexArrayFromFile(publicKeyPath.string(), "public key"); + auto sk = loadHexArrayFromFile(privateKeyPath.string(), "private key", true); + + assert(pk[0] == 0x00 && pk[1] == 0x11 && pk.back() == 0xFF); + assert(sk[0] == 0xFF && sk[1] == 0xEE && sk.back() == 0x00); + + bool threwMissing = false; + try { + (void)loadHexArrayFromFile((tempDir / "missing.key").string(), "missing key"); + } catch (const std::exception&) { + threwMissing = true; + } + assert(threwMissing && "Missing key file should throw"); + + auto badLengthPath = tempDir / "bad-length.key"; + { + std::ofstream bad(badLengthPath); + bad << "abcd"; + } + + bool threwBadLength = false; + try { + (void)loadHexArrayFromFile(badLengthPath.string(), "bad length key"); + } catch (const std::exception&) { + threwBadLength = true; + } + assert(threwBadLength && "Wrong-length key file should throw"); + + std::cout << "Key file loader tests passed\n"; + fs::remove_all(tempDir); + return 0; +} \ No newline at end of file