Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 42 additions & 9 deletions liblsl/include/lsl_security.h
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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<uint8_t, 32>& eph_public,
std::array<uint8_t, 32>& 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<uint8_t, PUBLIC_KEY_SIZE>& peer_public_key,
std::array<uint8_t, SESSION_KEY_SIZE>& session_key,
bool is_initiator);
SecurityResult derive_session_key_ephemeral(
const std::array<uint8_t, 32>& own_eph_secret,
const std::array<uint8_t, 32>& peer_eph_public,
std::array<uint8_t, SESSION_KEY_SIZE>& session_key);

// === Encryption/Decryption ===

Expand Down
68 changes: 60 additions & 8 deletions liblsl/src/data_receiver.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<uint8_t, 32> 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<uint8_t, 32>& 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<uint8_t, security::SIGNATURE_SIZE> 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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -280,6 +307,10 @@ void data_receiver::data_thread() {
server_security_enabled = lsl::from_string<bool>(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
}
}
Expand Down Expand Up @@ -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<uint8_t> 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<uint8_t, security::SIGNATURE_SIZE> 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<security::SessionState>();
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<uint8_t, 32> 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(
Expand Down
88 changes: 68 additions & 20 deletions liblsl/src/lsl_security.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -765,47 +782,78 @@ uint32_t LSLSecurity::get_session_key_lifetime() const {
return session_key_lifetime_;
}

SecurityResult LSLSecurity::derive_session_key(
const std::array<uint8_t, PUBLIC_KEY_SIZE>& peer_public_key,
std::array<uint8_t, SESSION_KEY_SIZE>& session_key,
bool is_initiator) {
SecurityResult LSLSecurity::generate_ephemeral_keypair(
std::array<uint8_t, 32>& eph_public,
std::array<uint8_t, 32>& 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<uint8_t, 32>& own_eph_secret,
const std::array<uint8_t, 32>& peer_eph_public,
std::array<uint8_t, SESSION_KEY_SIZE>& session_key) {

if (!initialized_ || !credentials_loaded_) {
return SecurityResult::NOT_INITIALIZED;
}

// Convert peer's Ed25519 public key to X25519
std::array<uint8_t, 32> 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<uint8_t, crypto_scalarmult_BYTES> 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<uint8_t, crypto_scalarmult_BYTES> 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<uint8_t, 32> 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;
}
Expand Down
3 changes: 3 additions & 0 deletions liblsl/src/lsl_security.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading