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
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.14)
project(LinuxCamPAM VERSION 0.9.3.3 LANGUAGES C CXX)
project(LinuxCamPAM VERSION 0.9.4 LANGUAGES C CXX)

add_compile_definitions(LINUXCAMPAM_VERSION="${PROJECT_VERSION}")

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,9 @@ This project was inspired by and utilizes tools from the open source community:
- [ ] **Liveness Detection:**
- *Passive:* Frame variance analysis during enrollment (static images = spoof attempt).
- *Active:* Challenges like blink, smile, head turn to prevent video replay attacks.
- [ ] **Security Hardening:** Rate limiting, embedding integrity (HMAC), model file verification, and configurable logging.
- [x] **Rate Limiting:** Per-user lockout after failed auth attempts (configurable via `[Security]` in config.ini).
- [ ] **Security Hardening:** Embedding integrity (HMAC), model file verification, and configurable logging.
- [ ] **Adaptive Enrollment** *(needs investigation)*: Auto-update embeddings when face auth fails but password succeeds. Requires careful security analysis.
- [ ] **GUI Config Tool:** A simple GTK/Qt app for managing users and cameras.
- [ ] **Enterprise Features:**
- Embedding export/import with model version validation for cross-machine portability.
Expand Down
8 changes: 8 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
linuxcampam (0.9.4-1) unstable; urgency=medium

* Feat: Implement security rate-limiting and lockout mechanism to prevent brute-force attacks.
* Feat: Add `show-config` CLI command for runtime configuration inspection.
* Security: Add comprehensive regression tests for lockout logic.

-- Vladimir Orlinski <contact.github.submerge594@slmails.com> Sun, 05 Jan 2026 10:56:00 +0100

linuxcampam (0.9.3.3-1) unstable; urgency=medium

* Fix: Include AI models in Docker-based cross-arch builds (i386, riscv64).
Expand Down
7 changes: 4 additions & 3 deletions docs/SECURITY_ASSESSMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ The service implements strict validation on all inputs (specifically usernames)
### 4.2 DoS Protection

* **Timeout:** Authentication requests have a hard timeout (default 3s).
* **Socket:** The service uses a persistent connection model but handles requests sequentially to prevent resource exhaustion.
* **Rate Limiting:** Configurable lockout after N failed auth attempts (default: 5 failures → 5 minute lockout). See `[Security]` in config.ini.
* **Socket:** The service handles requests sequentially to prevent resource exhaustion.

---

Expand All @@ -115,7 +116,7 @@ The service implements strict validation on all inputs (specifically usernames)
2. **Model Bias:** The AI models (YuNet/SFace) may have varying accuracy across different demographics or lighting conditions.
3. **USB Camera Injection:** Linux generally trusts USB devices. A "Rubber Ducky" style device mimicking a webcam could inject frames.
* *Mitigation:* LinuxCamPAM checks specific device paths, but these can be spoofed if the attacker controls the USB stack.
4. **No Brute-Force Protection:** There is currently no rate limiting. A compromised root process could spam authentication attempts indefinitely.
4. ~~**No Brute-Force Protection:**~~ **Mitigated.** Rate limiting is now implemented (v0.9.4+). Configurable via `lockout_attempts` and `lockout_duration_sec`.
5. **Embedding Tampering:** If an attacker gains root access, they could modify user embedding files (`/etc/linuxcampam/users/*.json`) to inject their own face. No cryptographic integrity check (e.g., HMAC) is currently performed.
6. **Model Tampering:** Similarly, ONNX model files could be replaced with backdoored versions. No hash verification is performed on load.
7. **Information Leakage via Logging:** Usernames are currently logged to stdout/syslog, which could leak identity information in multi-user or shared environments.
Expand Down Expand Up @@ -165,7 +166,7 @@ The following security features are planned for future releases:

| Feature | Description |
| :--- | :--- |
| **Rate Limiting / Lockout** | Configurable lockout after N failed authentication attempts to mitigate brute-force attacks. |
| ~~**Rate Limiting / Lockout**~~ | ✅ **Implemented** (v0.9.4). Configurable via `[Security]` section. |
| **Embedding Integrity (HMAC)** | Cryptographic signature on user embedding files to detect tampering. |
| **Model Verification (SHA256)** | Hash verification of ONNX model files on load to prevent backdoored model injection. |
| **Configurable Logging** | Option to disable verbose logging in production to prevent username leakage via syslog. |
Expand Down
9 changes: 9 additions & 0 deletions src/cli/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ void print_help() {
<< " linuxcampam test [username] Test camera & auth\n"
<< " linuxcampam list <username> Show embedding labels\n"
<< " linuxcampam remove <user> --label <X> Remove specific embedding\n"
<< " linuxcampam show-config Show active config\n"
<< " linuxcampam help Show this help\n";
}

Expand Down Expand Up @@ -219,6 +220,14 @@ int main(int argc, char *argv[]) {
}
print_response(send_cmd("REMOVE_EMBEDDING " + user + " " + label));

} else if (op == "show-config") {
std::string cfg = send_cmd("GET_CONFIG");
if (!cfg.empty()) {
std::cout << cfg << std::endl;
} else {
std::cerr << "Error: Could not get config from service." << std::endl;
}

} else if (op == "version" || op == "--version" || op == "-v") {
#ifdef LINUXCAMPAM_VERSION
std::cout << "Client Version: " << LINUXCAMPAM_VERSION << std::endl;
Expand Down
66 changes: 65 additions & 1 deletion src/service/auth_engine.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,11 @@ bool AuthEngine::init(const std::string &config_path) {
std::string ka_str = get("Performance.model_keep_alive_sec", "0");
config.model_keep_alive_sec = std::stoi(ka_str);

// Security / Rate Limiting
config.lockout_attempts = std::stoi(get("Security.lockout_attempts", "5"));
config.lockout_duration_sec =
std::stoi(get("Security.lockout_duration_sec", "300"));

last_activity_ = std::chrono::steady_clock::now();

// 2. Initialize Models (Delegated)
Expand Down Expand Up @@ -487,6 +492,10 @@ bool AuthEngine::verifyUser(const std::string &username) {
<< username << std::endl;
return false;
}
if (isUserLockedOut(username)) {
Logger::log(LogLevel::WARN, "User " + username + " is locked out");
return false;
}
std::string user_file =
std::string(config.users_dir) + "/" + username + ".json";
if (!fs::exists(user_file))
Expand Down Expand Up @@ -631,7 +640,9 @@ bool AuthEngine::verifyUser(const std::string &username) {
if (config.policy == AuthPolicy::LENIENT_ANY)
return successes > 0;

return failures == 0;
bool success = (failures == 0);
recordAuthAttempt(username, success);
return success;
}

AuthResult AuthEngine::verifyUserWithDetails(const std::string &username) {
Expand All @@ -647,6 +658,10 @@ AuthResult AuthEngine::verifyUserWithDetails(const std::string &username) {
result.reason = "Invalid username";
return result;
}
if (isUserLockedOut(username)) {
result.reason = "User locked out";
return result;
}
std::string user_file =
std::string(config.users_dir) + "/" + username + ".json";
if (!fs::exists(user_file)) {
Expand Down Expand Up @@ -768,6 +783,7 @@ AuthResult AuthEngine::verifyUserWithDetails(const std::string &username) {
} else {
result.reason = "Authentication failed";
}
recordAuthAttempt(username, result.success);
return result;
}

Expand Down Expand Up @@ -1190,3 +1206,51 @@ bool AuthEngine::testCameraAndAuth() {

return any_ok;
}

bool AuthEngine::isUserLockedOut(const std::string &username) {
if (config.lockout_attempts <= 0)
return false;

std::lock_guard<std::mutex> lock(lockout_mutex_);
auto it = lockout_map_.find(username);
if (it == lockout_map_.end())
return false;

if (std::chrono::steady_clock::now() < it->second.lockout_until)
return true;

return false;
}

void AuthEngine::recordAuthAttempt(const std::string &username, bool success) {
if (config.lockout_attempts <= 0)
return;

std::lock_guard<std::mutex> lock(lockout_mutex_);
auto &state = lockout_map_[username];

if (success) {
state.failed_attempts = 0;
state.lockout_until = {};
} else {
state.failed_attempts++;
if (state.failed_attempts >= config.lockout_attempts) {
state.lockout_until = std::chrono::steady_clock::now() +
std::chrono::seconds(config.lockout_duration_sec);
Logger::log(LogLevel::WARN, username + " locked out");
}
}
}

std::string AuthEngine::getConfigString() const {
std::ostringstream ss;
ss << "threshold = " << config.threshold << "\n"
<< "detection_threshold = " << config.detection_threshold << "\n"
<< "timeout_ms = " << config.timeout_ms << "\n"
<< "max_embeddings = " << config.max_embeddings << "\n"
<< "model_keep_alive_sec = " << config.model_keep_alive_sec << "\n"
<< "lockout_attempts = " << config.lockout_attempts << "\n"
<< "lockout_duration_sec = " << config.lockout_duration_sec << "\n"
<< "cameras = " << active_cameras.size();
return ss.str();
}
19 changes: 19 additions & 0 deletions src/service/auth_engine.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
#include "constants.hpp"

#include <chrono>
#include <mutex>
#include <opencv2/dnn.hpp>
#include <opencv2/opencv.hpp>
#include <string>
#include <unordered_map>
#include <vector>

// Helper: Cosine similarity between two feature vectors.
Expand Down Expand Up @@ -51,6 +53,9 @@ class AuthEngine {
[[nodiscard]] bool removeEmbedding(const std::string &username,
const std::string &label);

// Config visibility
[[nodiscard]] std::string getConfigString() const;

private:
enum class AuthPolicy {
STRICT_ALL, // All cameras must match
Expand Down Expand Up @@ -103,6 +108,10 @@ class AuthEngine {
std::string users_dir = linuxcampam::USERS_DIR;
std::string models_dir = linuxcampam::MODELS_DIR;
std::string ir_emitter_path = linuxcampam::IR_EMITTER_PATH;

// Security / Rate Limiting
int lockout_attempts = 5; // Lock after N failures. 0 = disabled.
int lockout_duration_sec = 300; // Lockout duration (5 min default)
} config;

cv::Ptr<cv::FaceDetectorYN> detector;
Expand Down Expand Up @@ -134,4 +143,14 @@ class AuthEngine {
void unloadModels();

std::chrono::steady_clock::time_point last_activity_;

// Lockout state
struct LockoutState {
int failed_attempts = 0;
std::chrono::steady_clock::time_point lockout_until{};
};
std::unordered_map<std::string, LockoutState> lockout_map_;
mutable std::mutex lockout_mutex_;
bool isUserLockedOut(const std::string &username);
void recordAuthAttempt(const std::string &username, bool success);
};
2 changes: 2 additions & 0 deletions src/service/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ void handle_client(int client_fd, AuthEngine &engine) {
bool success = engine.removeEmbedding(user, label);
response = success ? "REMOVED" : "REMOVE_FAIL";
}
} else if (cmd == "GET_CONFIG") {
response = engine.getConfigString();
}
} catch (const std::exception &e) {
Logger::log(LogLevel::ERROR, "Exception handling " + cmd + ": " + e.what());
Expand Down
121 changes: 121 additions & 0 deletions tests/test_security.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -301,3 +301,124 @@ TEST(SecurityTest, SimilarityOverflowBehavior) {
EXPECT_FALSE(std::isnan(sim));
EXPECT_NEAR(sim, 1.0f, 0.001f);
}

// Rate limiting tests - mirrors auth_engine lockout logic

#include <chrono>
#include <thread>
#include <unordered_map>

namespace lockout_test {

struct LockoutState {
int failed_attempts = 0;
std::chrono::steady_clock::time_point lockout_until{};
};

struct Config {
int lockout_attempts = 5;
int lockout_duration_sec = 300;
};

std::unordered_map<std::string, LockoutState> lockout_map;
Config config;

void reset() {
lockout_map.clear();
config.lockout_attempts = 5;
config.lockout_duration_sec = 300;
}

bool isUserLockedOut(const std::string &username) {
if (config.lockout_attempts <= 0)
return false;
auto it = lockout_map.find(username);
if (it == lockout_map.end())
return false;
return std::chrono::steady_clock::now() < it->second.lockout_until;
}

void recordAuthAttempt(const std::string &username, bool success) {
if (config.lockout_attempts <= 0)
return;
auto &state = lockout_map[username];
if (success) {
state.failed_attempts = 0;
state.lockout_until = {};
} else {
state.failed_attempts++;
if (state.failed_attempts >= config.lockout_attempts) {
state.lockout_until = std::chrono::steady_clock::now() +
std::chrono::seconds(config.lockout_duration_sec);
}
}
}

} // namespace lockout_test

TEST(RateLimitingTest, LockoutAfterFailedAttempts) {
lockout_test::reset();
std::string user = "testuser";

// 4 failures should not trigger lockout
for (int i = 0; i < 4; i++) {
lockout_test::recordAuthAttempt(user, false);
EXPECT_FALSE(lockout_test::isUserLockedOut(user));
}

// 5th failure triggers lockout
lockout_test::recordAuthAttempt(user, false);
EXPECT_TRUE(lockout_test::isUserLockedOut(user));
}

TEST(RateLimitingTest, SuccessResetsCounter) {
lockout_test::reset();
std::string user = "testuser";

// 3 failures
for (int i = 0; i < 3; i++)
lockout_test::recordAuthAttempt(user, false);
EXPECT_FALSE(lockout_test::isUserLockedOut(user));

// Success resets
lockout_test::recordAuthAttempt(user, true);
EXPECT_EQ(lockout_test::lockout_map[user].failed_attempts, 0);

// 4 more failures still not locked out
for (int i = 0; i < 4; i++)
lockout_test::recordAuthAttempt(user, false);
EXPECT_FALSE(lockout_test::isUserLockedOut(user));
}

TEST(RateLimitingTest, DisabledWhenZero) {
lockout_test::reset();
lockout_test::config.lockout_attempts = 0; // Disable
std::string user = "testuser";

// Even 100 failures should not lock out
for (int i = 0; i < 100; i++)
lockout_test::recordAuthAttempt(user, false);
EXPECT_FALSE(lockout_test::isUserLockedOut(user));
}

TEST(RateLimitingTest, LockoutExpiration) {
lockout_test::reset();
lockout_test::config.lockout_duration_sec = 1; // 1 second for testing
std::string user = "testuser";

// Trigger lockout
for (int i = 0; i < 5; i++)
lockout_test::recordAuthAttempt(user, false);
EXPECT_TRUE(lockout_test::isUserLockedOut(user));

// Wait for expiration (1.1 seconds)
std::this_thread::sleep_for(std::chrono::milliseconds(1100));
EXPECT_FALSE(lockout_test::isUserLockedOut(user));
}

TEST(RateLimitingTest, ConfigParsing) {
// Test that default values are reasonable
lockout_test::reset();
EXPECT_EQ(lockout_test::config.lockout_attempts, 5);
EXPECT_EQ(lockout_test::config.lockout_duration_sec, 300);
}
Loading