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