/**
* Authentication utilities implementation
*/
#include "auth.h"
#include "config.h"
#include "lang.h"
#include "user_store.h"
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
namespace ragger {
namespace fs = std::filesystem;
// Base64url encoding (RFC 4648 section 5)
static std::string base64url_encode(const unsigned char* data, size_t len) {
static const char* base64_chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
std::string result;
result.reserve((len * 4 / 3) + 3);
for (size_t i = 0; i < len; ) {
uint32_t octet_a = i < len ? data[i++] : 0;
uint32_t octet_b = i < len ? data[i++] : 0;
uint32_t octet_c = i < len ? data[i++] : 0;
uint32_t triple = (octet_a << 16) + (octet_b << 8) + octet_c;
result.push_back(base64_chars[(triple >> 18) & 0x3F]);
result.push_back(base64_chars[(triple >> 12) & 0x3F]);
result.push_back(base64_chars[(triple >> 6) & 0x3F]);
result.push_back(base64_chars[triple & 0x3F]);
}
// Remove padding (base64url doesn't use padding)
size_t padding = (3 - (len % 3)) % 3;
if (padding > 0) {
result.resize(result.size() - padding);
}
return result;
}
std::string hash_token(const std::string& token) {
unsigned char hash[SHA256_DIGEST_LENGTH];
SHA256(reinterpret_cast(token.c_str()),
token.size(), hash);
// Convert to hex string
std::ostringstream oss;
for (int i = 0; i < SHA256_DIGEST_LENGTH; ++i) {
oss << std::hex << std::setw(2) << std::setfill('0')
<< static_cast(hash[i]);
}
return oss.str();
}
std::string token_path() {
return config().resolved_token_path();
}
std::string load_token() {
std::string path = token_path();
std::ifstream f(path);
if (!f.is_open()) return "";
std::string token;
std::getline(f, token);
// Trim whitespace
size_t start = token.find_first_not_of(" \t\r\n");
size_t end = token.find_last_not_of(" \t\r\n");
if (start == std::string::npos) return "";
return token.substr(start, end - start + 1);
}
std::string generate_token() {
unsigned char random_bytes[32];
// Read from /dev/urandom
FILE* f = fopen("/dev/urandom", "rb");
if (!f) {
throw std::runtime_error(lang::ERR_URANDOM_OPEN);
}
size_t read_count = fread(random_bytes, 1, 32, f);
fclose(f);
if (read_count != 32) {
throw std::runtime_error(lang::ERR_URANDOM_READ);
}
return base64url_encode(random_bytes, 32);
}
std::string ensure_token() {
std::string path = token_path();
// Check if token file exists
if (fs::exists(path)) {
std::string token = load_token();
if (!token.empty()) return token;
}
// Create parent directory
fs::create_directories(fs::path(path).parent_path());
// Generate new token
std::string token = generate_token();
// Write to file
std::ofstream f(path);
if (!f.is_open()) {
throw std::runtime_error(std::format(lang::ERR_TOKEN_WRITE, path));
}
f << token << std::endl;
f.close();
// Set permissions to 0640 (owner read/write + group read)
// Group read allows the daemon (_ragger) to read user tokens
chmod(path.c_str(), 0640);
return token;
}
// NOTE: The old time-seeded token rotation system (server.secret +
// token_for_minute + 24h verify window) has been removed. Tokens are now
// plain random values: generate_token() â hash_token() â stored in DB.
// The framework (users.token_hash, issue_token, verify_token) is kept for the
// planned labeled-token system (see GitHub issue: "Labeled API tokens").
// PBKDF2 password hashing
static const int PBKDF2_ITERATIONS = 600000;
static const int PBKDF2_SALT_LEN = 16;
static const int PBKDF2_KEY_LEN = 32;
static std::string bytes_to_hex(const unsigned char* data, size_t len) {
std::ostringstream oss;
for (size_t i = 0; i < len; ++i) {
oss << std::hex << std::setw(2) << std::setfill('0')
<< static_cast(data[i]);
}
return oss.str();
}
static std::vector hex_to_bytes(const std::string& hex) {
std::vector bytes;
for (size_t i = 0; i + 1 < hex.size(); i += 2) {
unsigned int byte;
std::sscanf(hex.c_str() + i, "%02x", &byte);
bytes.push_back(static_cast(byte));
}
return bytes;
}
std::string generate_random_token(int bytes) {
std::vector buf(bytes);
if (RAND_bytes(buf.data(), bytes) != 1) {
throw std::runtime_error(lang::ERR_RANDOM_BYTES);
}
// URL-safe base64-like: hex encoding (simple, always works)
return bytes_to_hex(buf.data(), bytes);
}
std::string hash_password(const std::string& password) {
unsigned char salt[PBKDF2_SALT_LEN];
if (RAND_bytes(salt, PBKDF2_SALT_LEN) != 1) {
throw std::runtime_error(lang::ERR_RANDOM_SALT);
}
unsigned char key[PBKDF2_KEY_LEN];
if (PKCS5_PBKDF2_HMAC(
password.c_str(), static_cast(password.size()),
salt, PBKDF2_SALT_LEN,
PBKDF2_ITERATIONS,
EVP_sha256(),
PBKDF2_KEY_LEN, key) != 1) {
throw std::runtime_error(lang::ERR_PBKDF2);
}
// Format: pbkdf2:iterations:salt_hex:key_hex
return "pbkdf2:" + std::to_string(PBKDF2_ITERATIONS) + ":"
+ bytes_to_hex(salt, PBKDF2_SALT_LEN) + ":"
+ bytes_to_hex(key, PBKDF2_KEY_LEN);
}
bool verify_password(const std::string& password, const std::string& stored_hash) {
// Parse "pbkdf2:iterations:salt_hex:key_hex"
if (stored_hash.substr(0, 7) != "pbkdf2:") return false;
size_t pos1 = 7;
size_t pos2 = stored_hash.find(':', pos1);
if (pos2 == std::string::npos) return false;
size_t pos3 = stored_hash.find(':', pos2 + 1);
if (pos3 == std::string::npos) return false;
int iterations = std::stoi(stored_hash.substr(pos1, pos2 - pos1));
auto salt = hex_to_bytes(stored_hash.substr(pos2 + 1, pos3 - pos2 - 1));
auto expected_key = hex_to_bytes(stored_hash.substr(pos3 + 1));
unsigned char key[PBKDF2_KEY_LEN];
if (PKCS5_PBKDF2_HMAC(
password.c_str(), static_cast(password.size()),
salt.data(), static_cast(salt.size()),
iterations,
EVP_sha256(),
PBKDF2_KEY_LEN, key) != 1) {
return false;
}
// Constant-time comparison
return CRYPTO_memcmp(key, expected_key.data(),
std::min(static_cast(PBKDF2_KEY_LEN), expected_key.size())) == 0;
}
// --- DB-backed API ---
void userdel(UserStore& db, const std::string& user) {
db.delete_user(user);
}
bool verify_password(UserStore& db, const std::string& user, const std::string& pw) {
auto stored = db.get_user_password(user);
if (!stored || stored->empty()) return false;
return verify_password(pw, *stored);
}
std::string issue_token(UserStore& db, const std::string& user) {
std::string token = generate_token();
std::string token_hash = hash_token(token);
db.update_user_token(user, token_hash);
return token;
}
AuthResult verify_token(UserStore& db, const std::string& token) {
std::string token_hash = hash_token(token);
auto user_info = db.get_user_by_token_hash(token_hash);
if (!user_info) return {};
return {true, user_info->username};
}
bool request_origin_allowed(std::string_view method,
std::string_view origin,
std::string_view host) {
// Safe methods never mutate state â CORS preflight (OPTIONS) included.
if (method == "GET" || method == "HEAD" || method == "OPTIONS")
return true;
// No Origin header: curl, the CLI, the Hermes plugin, MCP â none send
// one, and none are browser cross-site contexts.
if (origin.empty())
return true;
// Strip the scheme ("http://" / "https://") to get the authority.
std::string_view authority = origin;
if (auto sep = origin.find("://"); sep != std::string_view::npos)
authority = origin.substr(sep + 3);
if (authority.empty())
return false;
// Case-insensitive exact match against Host (scheme-less). Hosts are
// case-insensitive per RFC; ports must match too.
auto ieq = [](std::string_view a, std::string_view b) {
if (a.size() != b.size()) return false;
for (size_t i = 0; i < a.size(); ++i) {
unsigned char ca = static_cast(a[i]);
unsigned char cb = static_cast(b[i]);
if (std::tolower(ca) != std::tolower(cb)) return false;
}
return true;
};
return ieq(authority, host);
}
} // namespace ragger