From bae942c2102c25b74b09ae3dff2c6555f29195aa Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Mon, 22 Jun 2026 21:18:03 -0700 Subject: [PATCH 1/2] Add per-session ephemeral X25519 key exchange Each connection now establishes a fresh ephemeral X25519 keypair, signed with the shared Ed25519 key and exchanged in the TCP handshake (new Security-Ephemeral-Key and Security-Ephemeral-Sig headers). The session key is derived from the ephemeral Diffie-Hellman output bound to a transcript of both ephemeral public keys and the shared identity, giving a unique key per session and forward secrecy, and proving each peer holds the private key rather than merely knowing the public one. Adds LSLSecurity::reset() so the process-global security singleton can be returned to a clean state between tests, and C++ unit tests covering keypair freshness, derivation symmetry, per-session key uniqueness, and ephemeral-key signature verification. --- liblsl/include/lsl_security.h | 51 ++++++++++++++---- liblsl/src/data_receiver.cpp | 68 +++++++++++++++++++++--- liblsl/src/lsl_security.cpp | 88 ++++++++++++++++++++++++------- liblsl/src/lsl_security.h | 3 ++ liblsl/src/tcp_server.cpp | 72 ++++++++++++++++++++++---- liblsl/testing/int/security.cpp | 92 +++++++++++++++++++++++++++++++++ 6 files changed, 328 insertions(+), 46 deletions(-) diff --git a/liblsl/include/lsl_security.h b/liblsl/include/lsl_security.h index d762dcb..7d88073 100644 --- a/liblsl/include/lsl_security.h +++ b/liblsl/include/lsl_security.h @@ -177,6 +177,17 @@ class LSL_SECURITY_API LSLSecurity { */ SecurityResult load_credentials(); + /** + * @brief Clear loaded credentials and disable security + * + * Returns the singleton to the initialized-but-unconfigured state (security + * off, no credentials, key material zeroed); the libsodium initialization is + * left intact. Primarily for test isolation, since this is a process-global + * singleton: a test that loads credentials must reset afterwards so it does + * not leak an enabled-security state into subsequent tests. + */ + void reset(); + /** * @brief Check if private key is encrypted and locked * @return true if key is encrypted and requires unlock() @@ -291,19 +302,41 @@ class LSL_SECURITY_API LSLSecurity { // === Session Key Derivation === /** - * @brief Derive a session key from peer's public key - * @param peer_public_key Peer's Ed25519 public key + * @brief Generate a fresh ephemeral X25519 keypair for one session + * @param[out] eph_public 32-byte ephemeral X25519 public key + * @param[out] eph_secret 32-byte ephemeral X25519 secret key + * @return SUCCESS if generated + * + * A new keypair is generated for every connection. The caller must + * secure_zero() the secret once the session key has been derived. This is + * what provides forward secrecy: once the ephemeral secret is discarded, a + * later compromise of the long-term keypair cannot reconstruct past session + * keys. + */ + SecurityResult generate_ephemeral_keypair( + std::array& eph_public, + std::array& eph_secret); + + /** + * @brief Derive a per-session key from an authenticated ephemeral X25519 exchange + * @param own_eph_secret Our ephemeral X25519 secret key + * @param peer_eph_public Peer's ephemeral X25519 public key * @param[out] session_key Derived 32-byte session key - * @param is_initiator true if we initiated the connection * @return SUCCESS if key derived * - * Uses X25519 key agreement with HKDF to derive a symmetric session key. - * The is_initiator flag ensures both parties derive the same key. + * session_key = BLAKE2b(X25519(own_eph_secret, peer_eph_public) || + * EPH_CONTEXT || sort(own_eph_public, peer_eph_public) || static_public_key). + * Both ends sort the two ephemeral public keys, so initiator and responder + * derive the same key without exchanging role information. The caller MUST + * verify the peer's ephemeral-key signature with verify() before calling + * this, so that only a holder of the shared long-term key can take part. + * The ephemeral exchange makes the session key unique per connection and + * provides forward secrecy. */ - SecurityResult derive_session_key( - const std::array& peer_public_key, - std::array& session_key, - bool is_initiator); + SecurityResult derive_session_key_ephemeral( + const std::array& own_eph_secret, + const std::array& peer_eph_public, + std::array& session_key); // === Encryption/Decryption === diff --git a/liblsl/src/data_receiver.cpp b/liblsl/src/data_receiver.cpp index 5047d95..c323a88 100644 --- a/liblsl/src/data_receiver.cpp +++ b/liblsl/src/data_receiver.cpp @@ -202,11 +202,36 @@ void data_receiver::data_thread() { // Send security headers if security is enabled locally auto& sec = security::LSLSecurity::instance(); bool local_security_enabled = sec.is_enabled(); + // Per-session ephemeral keypair; the secret is held until the + // server's response arrives so the session key can be derived, + // then zeroed. Provides unique per-session keys and forward secrecy. + std::array client_eph_pub{}, client_eph_sec{}; + // Zero the ephemeral secret on every exit from this scope, + // including exceptions thrown anywhere in the remainder of the + // handshake, so it never survives stack unwinding. + struct EphSecZeroizer { + std::array& s; + ~EphSecZeroizer() { security::secure_zero(s.data(), s.size()); } + } client_eph_sec_zeroizer{client_eph_sec}; server_stream << "Security-Enabled: " << (local_security_enabled ? "true" : "false") << "\r\n"; if (local_security_enabled) { const auto& pk = sec.get_public_key(); server_stream << "Security-Public-Key: " << security::base64_encode(pk.data(), pk.size()) << "\r\n"; + // Generate our ephemeral key and sign it with the shared key so + // the outlet can verify we hold the private key, not just the public one. + std::array client_eph_sig{}; + if (sec.generate_ephemeral_keypair(client_eph_pub, client_eph_sec) != + security::SecurityResult::SUCCESS || + sec.sign(client_eph_pub.data(), client_eph_pub.size(), client_eph_sig) != + security::SecurityResult::SUCCESS) { + throw std::runtime_error( + "Failed to generate ephemeral key for secure handshake."); + } + server_stream << "Security-Ephemeral-Key: " + << security::base64_encode(client_eph_pub.data(), client_eph_pub.size()) << "\r\n"; + server_stream << "Security-Ephemeral-Sig: " + << security::base64_encode(client_eph_sig.data(), client_eph_sig.size()) << "\r\n"; } #endif server_stream << "\r\n" << std::flush; @@ -237,6 +262,8 @@ void data_receiver::data_thread() { #ifdef LSL_SECURITY_ENABLED bool server_security_enabled = false; std::string server_security_public_key; + std::string server_ephemeral_key; + std::string server_ephemeral_sig; #endif while (server_stream.getline(buf, sizeof(buf)) && (buf[0] != '\r')) { std::string hdrline(buf); @@ -280,6 +307,10 @@ void data_receiver::data_thread() { server_security_enabled = lsl::from_string(rest); if (type == "security-public-key") server_security_public_key = trim(original_hdrline.substr(colon + 1)); + if (type == "security-ephemeral-key") + server_ephemeral_key = trim(original_hdrline.substr(colon + 1)); + if (type == "security-ephemeral-sig") + server_ephemeral_sig = trim(original_hdrline.substr(colon + 1)); #endif } } @@ -328,18 +359,39 @@ void data_receiver::data_thread() { throw std::runtime_error("Public key mismatch - outlet not authorized"); } - // Create session state and derive session key + // The outlet must present an ephemeral public key and a signature + // over it produced with the shared long-term key (proving it holds + // the private key, and supplying the per-session randomness). + std::vector server_eph_pub_v, server_eph_sig_v; + if (server_ephemeral_key.empty() || server_ephemeral_sig.empty() || + !security::base64_decode(server_ephemeral_key, server_eph_pub_v) || + !security::base64_decode(server_ephemeral_sig, server_eph_sig_v) || + server_eph_pub_v.size() != 32 || + server_eph_sig_v.size() != security::SIGNATURE_SIZE) { + security::secure_zero(client_eph_sec.data(), client_eph_sec.size()); + throw std::runtime_error("Outlet did not supply a valid ephemeral key."); + } + + std::array server_sig_arr; + std::copy(server_eph_sig_v.begin(), server_eph_sig_v.end(), server_sig_arr.begin()); + if (sec.verify(server_eph_pub_v.data(), server_eph_pub_v.size(), + server_sig_arr, our_pk) != security::SecurityResult::SUCCESS) { + security::secure_zero(client_eph_sec.data(), client_eph_sec.size()); + throw std::runtime_error("Outlet ephemeral key signature invalid."); + } + + // Create session state and derive the per-session key from the + // ephemeral Diffie-Hellman exchange (unique per session, PFS). session_state_ = std::make_unique(); std::copy(decoded_key.begin(), decoded_key.end(), session_state_->peer_public_key.begin()); + session_state_->is_initiator = true; // client initiates - // Client is the initiator - session_state_->is_initiator = true; - - auto result = sec.derive_session_key( - session_state_->peer_public_key, - session_state_->session_key, - session_state_->is_initiator); + std::array server_eph_pub; + std::copy(server_eph_pub_v.begin(), server_eph_pub_v.end(), server_eph_pub.begin()); + auto result = sec.derive_session_key_ephemeral( + client_eph_sec, server_eph_pub, session_state_->session_key); + security::secure_zero(client_eph_sec.data(), client_eph_sec.size()); if (result != security::SecurityResult::SUCCESS) { throw std::runtime_error( diff --git a/liblsl/src/lsl_security.cpp b/liblsl/src/lsl_security.cpp index bedd471..51c5e67 100644 --- a/liblsl/src/lsl_security.cpp +++ b/liblsl/src/lsl_security.cpp @@ -557,6 +557,23 @@ SecurityResult LSLSecurity::load_credentials() { return SecurityResult::CONFIG_NOT_FOUND; } +void LSLSecurity::reset() { + // Return to the initialized-but-unconfigured state without touching the + // libsodium initialization. Used for test isolation in this process-global + // singleton so a credential-loading test does not enable security for later + // tests. + enabled_ = false; + credentials_loaded_ = false; + key_locked_ = false; + has_encrypted_key_ = false; + secure_zero(secret_key_.data(), secret_key_.size()); + secure_zero(x25519_secret_key_.data(), x25519_secret_key_.size()); + public_key_.fill(0); + secret_key_.fill(0); + x25519_public_key_.fill(0); + x25519_secret_key_.fill(0); +} + SecurityResult LSLSecurity::convert_ed25519_to_x25519() { // Convert Ed25519 public key to X25519 if (crypto_sign_ed25519_pk_to_curve25519( @@ -765,47 +782,78 @@ uint32_t LSLSecurity::get_session_key_lifetime() const { return session_key_lifetime_; } -SecurityResult LSLSecurity::derive_session_key( - const std::array& peer_public_key, - std::array& session_key, - bool is_initiator) { +SecurityResult LSLSecurity::generate_ephemeral_keypair( + std::array& eph_public, + std::array& eph_secret) { + + if (!initialized_) { + return SecurityResult::NOT_INITIALIZED; + } + + // Fresh X25519 keypair for this connection only. + if (crypto_box_keypair(eph_public.data(), eph_secret.data()) != 0) { + return SecurityResult::KEY_GENERATION_FAILED; + } + + return SecurityResult::SUCCESS; +} + +SecurityResult LSLSecurity::derive_session_key_ephemeral( + const std::array& own_eph_secret, + const std::array& peer_eph_public, + std::array& session_key) { if (!initialized_ || !credentials_loaded_) { return SecurityResult::NOT_INITIALIZED; } - // Convert peer's Ed25519 public key to X25519 - std::array peer_x25519; - if (crypto_sign_ed25519_pk_to_curve25519(peer_x25519.data(), peer_public_key.data()) != 0) { + // Ephemeral X25519 agreement: shared = X25519(own_eph_secret, peer_eph_public). + // Because both ephemeral keys are random and fresh per connection, this shared + // secret is unique per session and is forgotten once the secrets are zeroed. + std::array shared_secret; + if (crypto_scalarmult(shared_secret.data(), own_eph_secret.data(), + peer_eph_public.data()) != 0) { + return SecurityResult::INVALID_KEY; + } + // Reject a degenerate all-zero shared secret (peer sent a low-order point). + if (sodium_is_zero(shared_secret.data(), shared_secret.size())) { + secure_zero(shared_secret.data(), shared_secret.size()); return SecurityResult::INVALID_KEY; } - // X25519 key agreement - std::array shared_secret; - if (crypto_scalarmult(shared_secret.data(), x25519_secret_key_.data(), peer_x25519.data()) != 0) { + // Recompute our ephemeral public from the secret so the transcript can bind + // both ephemeral public keys without the caller having to pass it back in. + std::array own_eph_public; + if (crypto_scalarmult_base(own_eph_public.data(), own_eph_secret.data()) != 0) { + secure_zero(shared_secret.data(), shared_secret.size()); return SecurityResult::INVALID_KEY; } - // Derive session key from shared secret and both public keys crypto_generichash_state state; crypto_generichash_init(&state, nullptr, 0, SESSION_KEY_SIZE); crypto_generichash_update(&state, shared_secret.data(), shared_secret.size()); - crypto_generichash_update(&state, (const uint8_t*)HKDF_CONTEXT, sizeof(HKDF_CONTEXT) - 1); + crypto_generichash_update(&state, (const uint8_t*)EPH_CONTEXT, sizeof(EPH_CONTEXT) - 1); - // Order public keys consistently (smaller first) so both parties derive same key - if (memcmp(public_key_.data(), peer_public_key.data(), PUBLIC_KEY_SIZE) < 0) { - crypto_generichash_update(&state, public_key_.data(), PUBLIC_KEY_SIZE); - crypto_generichash_update(&state, peer_public_key.data(), PUBLIC_KEY_SIZE); + // Order the ephemeral public keys consistently (smaller first) so initiator + // and responder derive the same key without exchanging role information. + if (memcmp(own_eph_public.data(), peer_eph_public.data(), 32) < 0) { + crypto_generichash_update(&state, own_eph_public.data(), 32); + crypto_generichash_update(&state, peer_eph_public.data(), 32); } else { - crypto_generichash_update(&state, peer_public_key.data(), PUBLIC_KEY_SIZE); - crypto_generichash_update(&state, public_key_.data(), PUBLIC_KEY_SIZE); + crypto_generichash_update(&state, peer_eph_public.data(), 32); + crypto_generichash_update(&state, own_eph_public.data(), 32); } + // Bind the session key to the shared long-term identity (group membership). + crypto_generichash_update(&state, public_key_.data(), PUBLIC_KEY_SIZE); + crypto_generichash_final(&state, session_key.data(), SESSION_KEY_SIZE); - // Zero shared secret + // Zero all working material: shared_secret and the hash state hold (or are + // derived from) the shared secret; own_eph_public is public but cleared too. secure_zero(shared_secret.data(), shared_secret.size()); - secure_zero(peer_x25519.data(), peer_x25519.size()); + secure_zero(own_eph_public.data(), own_eph_public.size()); + sodium_memzero(&state, sizeof(state)); return SecurityResult::SUCCESS; } diff --git a/liblsl/src/lsl_security.h b/liblsl/src/lsl_security.h index 3abf2a6..34096c5 100644 --- a/liblsl/src/lsl_security.h +++ b/liblsl/src/lsl_security.h @@ -21,6 +21,9 @@ namespace security { // Internal constants constexpr size_t HKDF_CONTEXT_SIZE = 8; constexpr char HKDF_CONTEXT[] = "lsl-sess"; +// Domain-separation context for the ephemeral-exchange session key, kept +// distinct from HKDF_CONTEXT so the two derivations can never collide. +constexpr char EPH_CONTEXT[] = "lsl-esk1"; constexpr uint64_t SESSION_KEY_SUBKEY_ID = 1; // Nonce management for replay prevention diff --git a/liblsl/src/tcp_server.cpp b/liblsl/src/tcp_server.cpp index b6aac90..1a9c578 100644 --- a/liblsl/src/tcp_server.cpp +++ b/liblsl/src/tcp_server.cpp @@ -430,6 +430,11 @@ void client_session::handle_read_feedparams( // Security negotiation variables bool client_security_enabled = false; std::string client_security_public_key; + std::string client_ephemeral_key; + std::string client_ephemeral_sig; + // Our ephemeral public key and its signature, sent back in the response. + std::string server_ephemeral_key_b64; + std::string server_ephemeral_sig_b64; #endif // read feed parameters @@ -465,6 +470,10 @@ void client_session::handle_read_feedparams( client_security_enabled = from_string(rest); if (type == "security-public-key") client_security_public_key = trim(original_hdrline.substr(colon + 1)); + if (type == "security-ephemeral-key") + client_ephemeral_key = trim(original_hdrline.substr(colon + 1)); + if (type == "security-ephemeral-sig") + client_ephemeral_sig = trim(original_hdrline.substr(colon + 1)); #endif } else { DLOG_F(WARNING, "%p Request line '%s' contained no key-value pair", this, @@ -567,18 +576,52 @@ void client_session::handle_read_feedparams( return; } - // Create session state and derive session key + // The client must present an ephemeral public key and a signature + // over it produced with the shared long-term key. This both + // supplies the per-session randomness and proves the client holds + // the private key (not merely the public key). + std::vector client_eph_pub, client_eph_sig; + if (client_ephemeral_key.empty() || client_ephemeral_sig.empty() || + !security::base64_decode(client_ephemeral_key, client_eph_pub) || + !security::base64_decode(client_ephemeral_sig, client_eph_sig) || + client_eph_pub.size() != 32 || + client_eph_sig.size() != security::SIGNATURE_SIZE) { + send_status_message("LSL/" + std::to_string(cfg_proto_version) + + " 400 Security-Ephemeral-Key/Sig header required"); + LOG_F(WARNING, "%p Client did not supply a valid ephemeral key", this); + return; + } + + std::array client_sig_arr; + std::copy(client_eph_sig.begin(), client_eph_sig.end(), client_sig_arr.begin()); + if (sec.verify(client_eph_pub.data(), client_eph_pub.size(), client_sig_arr, + our_pk) != security::SecurityResult::SUCCESS) { + send_status_message("LSL/" + std::to_string(cfg_proto_version) + + " 403 Ephemeral key signature invalid"); + LOG_F(WARNING, "%p Client ephemeral key signature failed verification", this); + return; + } + + // Create session state session_state_ = std::make_unique(); std::copy(decoded_key.begin(), decoded_key.end(), session_state_->peer_public_key.begin()); - - // Server is never the initiator (client connects to server) - session_state_->is_initiator = false; - - auto result = sec.derive_session_key( - session_state_->peer_public_key, - session_state_->session_key, - session_state_->is_initiator); + session_state_->is_initiator = false; // server: client connects to us + + // Generate our ephemeral keypair, sign it, and derive the session key + // from the ephemeral Diffie-Hellman exchange (unique per session, PFS). + std::array server_eph_pub, server_eph_sec; + std::array server_eph_sig; + std::array peer_eph_pub; + std::copy(client_eph_pub.begin(), client_eph_pub.end(), peer_eph_pub.begin()); + + auto result = sec.generate_ephemeral_keypair(server_eph_pub, server_eph_sec); + if (result == security::SecurityResult::SUCCESS) + result = sec.sign(server_eph_pub.data(), server_eph_pub.size(), server_eph_sig); + if (result == security::SecurityResult::SUCCESS) + result = sec.derive_session_key_ephemeral( + server_eph_sec, peer_eph_pub, session_state_->session_key); + security::secure_zero(server_eph_sec.data(), server_eph_sec.size()); if (result != security::SecurityResult::SUCCESS) { send_status_message("LSL/" + std::to_string(cfg_proto_version) + @@ -590,6 +633,11 @@ void client_session::handle_read_feedparams( return; } + server_ephemeral_key_b64 = + security::base64_encode(server_eph_pub.data(), server_eph_pub.size()); + server_ephemeral_sig_b64 = + security::base64_encode(server_eph_sig.data(), server_eph_sig.size()); + session_state_->key_established = std::chrono::steady_clock::now(); session_state_->authenticated = true; @@ -615,6 +663,12 @@ void client_session::handle_read_feedparams( const auto& pk = sec.get_public_key(); response_stream << "Security-Public-Key: " << security::base64_encode(pk.data(), pk.size()) << "\r\n"; + if (security_enabled_) { + response_stream << "Security-Ephemeral-Key: " + << server_ephemeral_key_b64 << "\r\n"; + response_stream << "Security-Ephemeral-Sig: " + << server_ephemeral_sig_b64 << "\r\n"; + } } #endif response_stream << "\r\n" << std::flush; diff --git a/liblsl/testing/int/security.cpp b/liblsl/testing/int/security.cpp index 2959ecd..06b0e2f 100644 --- a/liblsl/testing/int/security.cpp +++ b/liblsl/testing/int/security.cpp @@ -325,6 +325,98 @@ TEST_CASE("Session key derivation", "[security][keyexchange]") { } } +TEST_CASE("Ephemeral session key exchange", "[security][keyexchange][ephemeral]") { + auto& sec = LSLSecurity::instance(); + sec.initialize(); + + // Provision a shared long-term keypair in a temp config and load it, so the + // ephemeral derivation (which binds to the static public key) has credentials. + std::string test_dir = "/tmp/lsl_eph_test_" + + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()); + std::string config_path = test_dir + "/lsl_api.cfg"; + test_mkdir_p(test_dir); + REQUIRE(sec.generate_and_save_keypair(config_path, true, "") == SecurityResult::SUCCESS); + setenv("LSLAPICFG", config_path.c_str(), 1); + REQUIRE(sec.load_credentials() == SecurityResult::SUCCESS); + + // One authenticated ephemeral exchange between an initiator and a responder + // that share the loaded keypair; returns the (equal) session keys derived by + // both ends. This mirrors exactly what the TCP handshake does. + auto run_exchange = [&](std::array& key_init, + std::array& key_resp) { + std::array ip, is, rp, rs; + REQUIRE(sec.generate_ephemeral_keypair(ip, is) == SecurityResult::SUCCESS); + REQUIRE(sec.generate_ephemeral_keypair(rp, rs) == SecurityResult::SUCCESS); + // Mirror the handshake: each side signs its ephemeral public key and the + // peer verifies that signature with the shared key before deriving. + std::array sig_i, sig_r; + REQUIRE(sec.sign(ip.data(), ip.size(), sig_i) == SecurityResult::SUCCESS); + REQUIRE(sec.sign(rp.data(), rp.size(), sig_r) == SecurityResult::SUCCESS); + REQUIRE(sec.verify(rp.data(), rp.size(), sig_r, sec.get_public_key()) == + SecurityResult::SUCCESS); + REQUIRE(sec.verify(ip.data(), ip.size(), sig_i, sec.get_public_key()) == + SecurityResult::SUCCESS); + REQUIRE(sec.derive_session_key_ephemeral(is, rp, key_init) == SecurityResult::SUCCESS); + REQUIRE(sec.derive_session_key_ephemeral(rs, ip, key_resp) == SecurityResult::SUCCESS); + }; + + SECTION("ephemeral keypairs are fresh on each call") { + std::array p1, s1, p2, s2; + REQUIRE(sec.generate_ephemeral_keypair(p1, s1) == SecurityResult::SUCCESS); + REQUIRE(sec.generate_ephemeral_keypair(p2, s2) == SecurityResult::SUCCESS); + CHECK(p1 != p2); + CHECK(s1 != s2); + } + + SECTION("initiator and responder derive the same session key") { + std::array ki, kr; + run_exchange(ki, kr); + CHECK(ki == kr); + } + + SECTION("independent sessions derive different keys") { + std::array k1a, k1b, k2a, k2b; + run_exchange(k1a, k1b); + run_exchange(k2a, k2b); + CHECK(k1a != k2a); // the property the constant-key derivation violated + } + + SECTION("identical plaintext at nonce 1 yields different ciphertext across sessions") { + // The headline regression: with the old constant session key, two sessions + // encrypting the same plaintext at nonce 1 produced byte-identical ciphertext + // (catastrophic keystream/nonce reuse). With per-session ephemeral keys they differ. + std::array k1, k1r, k2, k2r; + run_exchange(k1, k1r); + run_exchange(k2, k2r); + const std::vector plain = {1, 2, 3, 4, 5, 6, 7, 8}; + std::vector c1(plain.size() + AUTH_TAG_SIZE), c2(plain.size() + AUTH_TAG_SIZE); + std::copy(plain.begin(), plain.end(), c1.begin()); + std::copy(plain.begin(), plain.end(), c2.begin()); + size_t l1 = 0, l2 = 0; + REQUIRE(sec.encrypt(c1.data(), plain.size(), 1, k1, l1) == SecurityResult::SUCCESS); + REQUIRE(sec.encrypt(c2.data(), plain.size(), 1, k2, l2) == SecurityResult::SUCCESS); + CHECK(c1 != c2); + } + + SECTION("ephemeral-key signature verifies and rejects tampering") { + std::array ep, es; + REQUIRE(sec.generate_ephemeral_keypair(ep, es) == SecurityResult::SUCCESS); + std::array sig; + REQUIRE(sec.sign(ep.data(), ep.size(), sig) == SecurityResult::SUCCESS); + CHECK(sec.verify(ep.data(), ep.size(), sig, sec.get_public_key()) == SecurityResult::SUCCESS); + auto tampered = ep; + tampered[0] ^= 0x01; + CHECK(sec.verify(tampered.data(), tampered.size(), sig, sec.get_public_key()) != + SecurityResult::SUCCESS); + } + + unsetenv("LSLAPICFG"); + test_rm_rf(test_dir); + // This test loaded credentials into the process-global singleton, enabling + // security; reset it so later non-security tests see a clean state. + sec.reset(); +} + TEST_CASE("Base64 encoding/decoding", "[security][base64]") { SECTION("encode empty data") { std::string result = base64_encode(nullptr, 0); From 3adfb16681fe017903c3d6cc2336486e958ac5c7 Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Mon, 22 Jun 2026 21:23:05 -0700 Subject: [PATCH 2/2] Use portable env set/unset in security test (MSVC) --- liblsl/testing/int/security.cpp | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/liblsl/testing/int/security.cpp b/liblsl/testing/int/security.cpp index 06b0e2f..d0874f4 100644 --- a/liblsl/testing/int/security.cpp +++ b/liblsl/testing/int/security.cpp @@ -45,6 +45,22 @@ using namespace lsl::security; +// Portable environment set/unset (MSVC lacks the POSIX setenv/unsetenv). +static void test_setenv(const char* key, const char* value) { +#ifdef _WIN32 + _putenv_s(key, value); +#else + setenv(key, value, 1); +#endif +} +static void test_unsetenv(const char* key) { +#ifdef _WIN32 + _putenv_s(key, ""); +#else + unsetenv(key); +#endif +} + // Helper functions for test file/directory operations static void test_mkdir_p(const std::string& path) { #ifdef _WIN32 @@ -336,7 +352,7 @@ TEST_CASE("Ephemeral session key exchange", "[security][keyexchange][ephemeral]" std::string config_path = test_dir + "/lsl_api.cfg"; test_mkdir_p(test_dir); REQUIRE(sec.generate_and_save_keypair(config_path, true, "") == SecurityResult::SUCCESS); - setenv("LSLAPICFG", config_path.c_str(), 1); + test_setenv("LSLAPICFG", config_path.c_str()); REQUIRE(sec.load_credentials() == SecurityResult::SUCCESS); // One authenticated ephemeral exchange between an initiator and a responder @@ -410,7 +426,7 @@ TEST_CASE("Ephemeral session key exchange", "[security][keyexchange][ephemeral]" SecurityResult::SUCCESS); } - unsetenv("LSLAPICFG"); + test_unsetenv("LSLAPICFG"); test_rm_rf(test_dir); // This test loaded credentials into the process-global singleton, enabling // security; reset it so later non-security tests see a clean state.