Skip to content

Commit e2d9eb0

Browse files
Merge pull request #3 from sccn/feature/ephemeral-session-keys
Add per-session ephemeral X25519 key exchange
2 parents 4d1be0e + 3adfb16 commit e2d9eb0

6 files changed

Lines changed: 344 additions & 46 deletions

File tree

liblsl/include/lsl_security.h

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,17 @@ class LSL_SECURITY_API LSLSecurity {
177177
*/
178178
SecurityResult load_credentials();
179179

180+
/**
181+
* @brief Clear loaded credentials and disable security
182+
*
183+
* Returns the singleton to the initialized-but-unconfigured state (security
184+
* off, no credentials, key material zeroed); the libsodium initialization is
185+
* left intact. Primarily for test isolation, since this is a process-global
186+
* singleton: a test that loads credentials must reset afterwards so it does
187+
* not leak an enabled-security state into subsequent tests.
188+
*/
189+
void reset();
190+
180191
/**
181192
* @brief Check if private key is encrypted and locked
182193
* @return true if key is encrypted and requires unlock()
@@ -291,19 +302,41 @@ class LSL_SECURITY_API LSLSecurity {
291302
// === Session Key Derivation ===
292303

293304
/**
294-
* @brief Derive a session key from peer's public key
295-
* @param peer_public_key Peer's Ed25519 public key
305+
* @brief Generate a fresh ephemeral X25519 keypair for one session
306+
* @param[out] eph_public 32-byte ephemeral X25519 public key
307+
* @param[out] eph_secret 32-byte ephemeral X25519 secret key
308+
* @return SUCCESS if generated
309+
*
310+
* A new keypair is generated for every connection. The caller must
311+
* secure_zero() the secret once the session key has been derived. This is
312+
* what provides forward secrecy: once the ephemeral secret is discarded, a
313+
* later compromise of the long-term keypair cannot reconstruct past session
314+
* keys.
315+
*/
316+
SecurityResult generate_ephemeral_keypair(
317+
std::array<uint8_t, 32>& eph_public,
318+
std::array<uint8_t, 32>& eph_secret);
319+
320+
/**
321+
* @brief Derive a per-session key from an authenticated ephemeral X25519 exchange
322+
* @param own_eph_secret Our ephemeral X25519 secret key
323+
* @param peer_eph_public Peer's ephemeral X25519 public key
296324
* @param[out] session_key Derived 32-byte session key
297-
* @param is_initiator true if we initiated the connection
298325
* @return SUCCESS if key derived
299326
*
300-
* Uses X25519 key agreement with HKDF to derive a symmetric session key.
301-
* The is_initiator flag ensures both parties derive the same key.
327+
* session_key = BLAKE2b(X25519(own_eph_secret, peer_eph_public) ||
328+
* EPH_CONTEXT || sort(own_eph_public, peer_eph_public) || static_public_key).
329+
* Both ends sort the two ephemeral public keys, so initiator and responder
330+
* derive the same key without exchanging role information. The caller MUST
331+
* verify the peer's ephemeral-key signature with verify() before calling
332+
* this, so that only a holder of the shared long-term key can take part.
333+
* The ephemeral exchange makes the session key unique per connection and
334+
* provides forward secrecy.
302335
*/
303-
SecurityResult derive_session_key(
304-
const std::array<uint8_t, PUBLIC_KEY_SIZE>& peer_public_key,
305-
std::array<uint8_t, SESSION_KEY_SIZE>& session_key,
306-
bool is_initiator);
336+
SecurityResult derive_session_key_ephemeral(
337+
const std::array<uint8_t, 32>& own_eph_secret,
338+
const std::array<uint8_t, 32>& peer_eph_public,
339+
std::array<uint8_t, SESSION_KEY_SIZE>& session_key);
307340

308341
// === Encryption/Decryption ===
309342

liblsl/src/data_receiver.cpp

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -202,11 +202,36 @@ void data_receiver::data_thread() {
202202
// Send security headers if security is enabled locally
203203
auto& sec = security::LSLSecurity::instance();
204204
bool local_security_enabled = sec.is_enabled();
205+
// Per-session ephemeral keypair; the secret is held until the
206+
// server's response arrives so the session key can be derived,
207+
// then zeroed. Provides unique per-session keys and forward secrecy.
208+
std::array<uint8_t, 32> client_eph_pub{}, client_eph_sec{};
209+
// Zero the ephemeral secret on every exit from this scope,
210+
// including exceptions thrown anywhere in the remainder of the
211+
// handshake, so it never survives stack unwinding.
212+
struct EphSecZeroizer {
213+
std::array<uint8_t, 32>& s;
214+
~EphSecZeroizer() { security::secure_zero(s.data(), s.size()); }
215+
} client_eph_sec_zeroizer{client_eph_sec};
205216
server_stream << "Security-Enabled: " << (local_security_enabled ? "true" : "false") << "\r\n";
206217
if (local_security_enabled) {
207218
const auto& pk = sec.get_public_key();
208219
server_stream << "Security-Public-Key: "
209220
<< security::base64_encode(pk.data(), pk.size()) << "\r\n";
221+
// Generate our ephemeral key and sign it with the shared key so
222+
// the outlet can verify we hold the private key, not just the public one.
223+
std::array<uint8_t, security::SIGNATURE_SIZE> client_eph_sig{};
224+
if (sec.generate_ephemeral_keypair(client_eph_pub, client_eph_sec) !=
225+
security::SecurityResult::SUCCESS ||
226+
sec.sign(client_eph_pub.data(), client_eph_pub.size(), client_eph_sig) !=
227+
security::SecurityResult::SUCCESS) {
228+
throw std::runtime_error(
229+
"Failed to generate ephemeral key for secure handshake.");
230+
}
231+
server_stream << "Security-Ephemeral-Key: "
232+
<< security::base64_encode(client_eph_pub.data(), client_eph_pub.size()) << "\r\n";
233+
server_stream << "Security-Ephemeral-Sig: "
234+
<< security::base64_encode(client_eph_sig.data(), client_eph_sig.size()) << "\r\n";
210235
}
211236
#endif
212237
server_stream << "\r\n" << std::flush;
@@ -237,6 +262,8 @@ void data_receiver::data_thread() {
237262
#ifdef LSL_SECURITY_ENABLED
238263
bool server_security_enabled = false;
239264
std::string server_security_public_key;
265+
std::string server_ephemeral_key;
266+
std::string server_ephemeral_sig;
240267
#endif
241268
while (server_stream.getline(buf, sizeof(buf)) && (buf[0] != '\r')) {
242269
std::string hdrline(buf);
@@ -280,6 +307,10 @@ void data_receiver::data_thread() {
280307
server_security_enabled = lsl::from_string<bool>(rest);
281308
if (type == "security-public-key")
282309
server_security_public_key = trim(original_hdrline.substr(colon + 1));
310+
if (type == "security-ephemeral-key")
311+
server_ephemeral_key = trim(original_hdrline.substr(colon + 1));
312+
if (type == "security-ephemeral-sig")
313+
server_ephemeral_sig = trim(original_hdrline.substr(colon + 1));
283314
#endif
284315
}
285316
}
@@ -328,18 +359,39 @@ void data_receiver::data_thread() {
328359
throw std::runtime_error("Public key mismatch - outlet not authorized");
329360
}
330361

331-
// Create session state and derive session key
362+
// The outlet must present an ephemeral public key and a signature
363+
// over it produced with the shared long-term key (proving it holds
364+
// the private key, and supplying the per-session randomness).
365+
std::vector<uint8_t> server_eph_pub_v, server_eph_sig_v;
366+
if (server_ephemeral_key.empty() || server_ephemeral_sig.empty() ||
367+
!security::base64_decode(server_ephemeral_key, server_eph_pub_v) ||
368+
!security::base64_decode(server_ephemeral_sig, server_eph_sig_v) ||
369+
server_eph_pub_v.size() != 32 ||
370+
server_eph_sig_v.size() != security::SIGNATURE_SIZE) {
371+
security::secure_zero(client_eph_sec.data(), client_eph_sec.size());
372+
throw std::runtime_error("Outlet did not supply a valid ephemeral key.");
373+
}
374+
375+
std::array<uint8_t, security::SIGNATURE_SIZE> server_sig_arr;
376+
std::copy(server_eph_sig_v.begin(), server_eph_sig_v.end(), server_sig_arr.begin());
377+
if (sec.verify(server_eph_pub_v.data(), server_eph_pub_v.size(),
378+
server_sig_arr, our_pk) != security::SecurityResult::SUCCESS) {
379+
security::secure_zero(client_eph_sec.data(), client_eph_sec.size());
380+
throw std::runtime_error("Outlet ephemeral key signature invalid.");
381+
}
382+
383+
// Create session state and derive the per-session key from the
384+
// ephemeral Diffie-Hellman exchange (unique per session, PFS).
332385
session_state_ = std::make_unique<security::SessionState>();
333386
std::copy(decoded_key.begin(), decoded_key.end(),
334387
session_state_->peer_public_key.begin());
388+
session_state_->is_initiator = true; // client initiates
335389

336-
// Client is the initiator
337-
session_state_->is_initiator = true;
338-
339-
auto result = sec.derive_session_key(
340-
session_state_->peer_public_key,
341-
session_state_->session_key,
342-
session_state_->is_initiator);
390+
std::array<uint8_t, 32> server_eph_pub;
391+
std::copy(server_eph_pub_v.begin(), server_eph_pub_v.end(), server_eph_pub.begin());
392+
auto result = sec.derive_session_key_ephemeral(
393+
client_eph_sec, server_eph_pub, session_state_->session_key);
394+
security::secure_zero(client_eph_sec.data(), client_eph_sec.size());
343395

344396
if (result != security::SecurityResult::SUCCESS) {
345397
throw std::runtime_error(

liblsl/src/lsl_security.cpp

Lines changed: 68 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,23 @@ SecurityResult LSLSecurity::load_credentials() {
557557
return SecurityResult::CONFIG_NOT_FOUND;
558558
}
559559

560+
void LSLSecurity::reset() {
561+
// Return to the initialized-but-unconfigured state without touching the
562+
// libsodium initialization. Used for test isolation in this process-global
563+
// singleton so a credential-loading test does not enable security for later
564+
// tests.
565+
enabled_ = false;
566+
credentials_loaded_ = false;
567+
key_locked_ = false;
568+
has_encrypted_key_ = false;
569+
secure_zero(secret_key_.data(), secret_key_.size());
570+
secure_zero(x25519_secret_key_.data(), x25519_secret_key_.size());
571+
public_key_.fill(0);
572+
secret_key_.fill(0);
573+
x25519_public_key_.fill(0);
574+
x25519_secret_key_.fill(0);
575+
}
576+
560577
SecurityResult LSLSecurity::convert_ed25519_to_x25519() {
561578
// Convert Ed25519 public key to X25519
562579
if (crypto_sign_ed25519_pk_to_curve25519(
@@ -765,47 +782,78 @@ uint32_t LSLSecurity::get_session_key_lifetime() const {
765782
return session_key_lifetime_;
766783
}
767784

768-
SecurityResult LSLSecurity::derive_session_key(
769-
const std::array<uint8_t, PUBLIC_KEY_SIZE>& peer_public_key,
770-
std::array<uint8_t, SESSION_KEY_SIZE>& session_key,
771-
bool is_initiator) {
785+
SecurityResult LSLSecurity::generate_ephemeral_keypair(
786+
std::array<uint8_t, 32>& eph_public,
787+
std::array<uint8_t, 32>& eph_secret) {
788+
789+
if (!initialized_) {
790+
return SecurityResult::NOT_INITIALIZED;
791+
}
792+
793+
// Fresh X25519 keypair for this connection only.
794+
if (crypto_box_keypair(eph_public.data(), eph_secret.data()) != 0) {
795+
return SecurityResult::KEY_GENERATION_FAILED;
796+
}
797+
798+
return SecurityResult::SUCCESS;
799+
}
800+
801+
SecurityResult LSLSecurity::derive_session_key_ephemeral(
802+
const std::array<uint8_t, 32>& own_eph_secret,
803+
const std::array<uint8_t, 32>& peer_eph_public,
804+
std::array<uint8_t, SESSION_KEY_SIZE>& session_key) {
772805

773806
if (!initialized_ || !credentials_loaded_) {
774807
return SecurityResult::NOT_INITIALIZED;
775808
}
776809

777-
// Convert peer's Ed25519 public key to X25519
778-
std::array<uint8_t, 32> peer_x25519;
779-
if (crypto_sign_ed25519_pk_to_curve25519(peer_x25519.data(), peer_public_key.data()) != 0) {
810+
// Ephemeral X25519 agreement: shared = X25519(own_eph_secret, peer_eph_public).
811+
// Because both ephemeral keys are random and fresh per connection, this shared
812+
// secret is unique per session and is forgotten once the secrets are zeroed.
813+
std::array<uint8_t, crypto_scalarmult_BYTES> shared_secret;
814+
if (crypto_scalarmult(shared_secret.data(), own_eph_secret.data(),
815+
peer_eph_public.data()) != 0) {
816+
return SecurityResult::INVALID_KEY;
817+
}
818+
// Reject a degenerate all-zero shared secret (peer sent a low-order point).
819+
if (sodium_is_zero(shared_secret.data(), shared_secret.size())) {
820+
secure_zero(shared_secret.data(), shared_secret.size());
780821
return SecurityResult::INVALID_KEY;
781822
}
782823

783-
// X25519 key agreement
784-
std::array<uint8_t, crypto_scalarmult_BYTES> shared_secret;
785-
if (crypto_scalarmult(shared_secret.data(), x25519_secret_key_.data(), peer_x25519.data()) != 0) {
824+
// Recompute our ephemeral public from the secret so the transcript can bind
825+
// both ephemeral public keys without the caller having to pass it back in.
826+
std::array<uint8_t, 32> own_eph_public;
827+
if (crypto_scalarmult_base(own_eph_public.data(), own_eph_secret.data()) != 0) {
828+
secure_zero(shared_secret.data(), shared_secret.size());
786829
return SecurityResult::INVALID_KEY;
787830
}
788831

789-
// Derive session key from shared secret and both public keys
790832
crypto_generichash_state state;
791833
crypto_generichash_init(&state, nullptr, 0, SESSION_KEY_SIZE);
792834
crypto_generichash_update(&state, shared_secret.data(), shared_secret.size());
793-
crypto_generichash_update(&state, (const uint8_t*)HKDF_CONTEXT, sizeof(HKDF_CONTEXT) - 1);
835+
crypto_generichash_update(&state, (const uint8_t*)EPH_CONTEXT, sizeof(EPH_CONTEXT) - 1);
794836

795-
// Order public keys consistently (smaller first) so both parties derive same key
796-
if (memcmp(public_key_.data(), peer_public_key.data(), PUBLIC_KEY_SIZE) < 0) {
797-
crypto_generichash_update(&state, public_key_.data(), PUBLIC_KEY_SIZE);
798-
crypto_generichash_update(&state, peer_public_key.data(), PUBLIC_KEY_SIZE);
837+
// Order the ephemeral public keys consistently (smaller first) so initiator
838+
// and responder derive the same key without exchanging role information.
839+
if (memcmp(own_eph_public.data(), peer_eph_public.data(), 32) < 0) {
840+
crypto_generichash_update(&state, own_eph_public.data(), 32);
841+
crypto_generichash_update(&state, peer_eph_public.data(), 32);
799842
} else {
800-
crypto_generichash_update(&state, peer_public_key.data(), PUBLIC_KEY_SIZE);
801-
crypto_generichash_update(&state, public_key_.data(), PUBLIC_KEY_SIZE);
843+
crypto_generichash_update(&state, peer_eph_public.data(), 32);
844+
crypto_generichash_update(&state, own_eph_public.data(), 32);
802845
}
803846

847+
// Bind the session key to the shared long-term identity (group membership).
848+
crypto_generichash_update(&state, public_key_.data(), PUBLIC_KEY_SIZE);
849+
804850
crypto_generichash_final(&state, session_key.data(), SESSION_KEY_SIZE);
805851

806-
// Zero shared secret
852+
// Zero all working material: shared_secret and the hash state hold (or are
853+
// derived from) the shared secret; own_eph_public is public but cleared too.
807854
secure_zero(shared_secret.data(), shared_secret.size());
808-
secure_zero(peer_x25519.data(), peer_x25519.size());
855+
secure_zero(own_eph_public.data(), own_eph_public.size());
856+
sodium_memzero(&state, sizeof(state));
809857

810858
return SecurityResult::SUCCESS;
811859
}

liblsl/src/lsl_security.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ namespace security {
2121
// Internal constants
2222
constexpr size_t HKDF_CONTEXT_SIZE = 8;
2323
constexpr char HKDF_CONTEXT[] = "lsl-sess";
24+
// Domain-separation context for the ephemeral-exchange session key, kept
25+
// distinct from HKDF_CONTEXT so the two derivations can never collide.
26+
constexpr char EPH_CONTEXT[] = "lsl-esk1";
2427
constexpr uint64_t SESSION_KEY_SUBKEY_ID = 1;
2528

2629
// Nonce management for replay prevention

0 commit comments

Comments
 (0)