Mercurial
view mrjunejune/auth_api.c @ 279:b3b547563ec7
Add Google connector service and agent wiki
Implement the C/Seobeo Google Drive and Gmail connector with encrypted OAuth storage, Zenbu authentication, browser testing, AI tool discovery, chunked HTTP decoding, and Bazel coverage. Consolidate repository guidance into progressive wiki documentation and enforce arena-first allocation for new first-party C code.
Co-authored-by: Copilot <[email protected]>
Copilot-Session: 84c338fd-0939-4bb3-b7f3-1062eb213e5d
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Mon, 17 Aug 2026 22:22:36 -0700 |
| parents | 04fee26ecce0 |
| children |
line wrap: on
line source
#include "mrjunejune/auth_api.h" #include "mrjunejune/template_renderer.h" #include "auth/auth_crypto.h" #include "auth/auth_http.h" #include "auth/auth_store.h" #include "seobeo/seobeo.h" #include "dowa/dowa.h" #include <openssl/hmac.h> #include <openssl/evp.h> #include <openssl/rand.h> #include <openssl/crypto.h> #include <arpa/inet.h> #include <pthread.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <strings.h> #include <time.h> /* ------------------------------------------------------------------ */ /* Constants */ /* ------------------------------------------------------------------ */ #define BODY_MAX_BYTES 4096 #define COOKIE_VALUE_MAX 512 #define RATE_TABLE_SIZE 512 /* must be power of 2 */ #define RATE_PROBE_LIMIT 16 #define RATE_LIMIT_MAX_FAILURES 5 #define RATE_LIMIT_WINDOW_SECS (15 * 60) #define SESSION_COOKIE_MAX 512 #define GUEST_COOKIE_MAX (AUTH_CRYPTO_GUEST_COOKIE_SIZE + 256) #define RATE_KEY_MAX 65 /* * Fixed precomputed scrypt hash of the constant string "dummy-zenbu-timing". * Used only for timing-attack mitigation on nonexistent-username lookups. * Never used as an account credential; salt+hash are intentionally public. */ #define AUTH_DUMMY_PASSWORD_HASH \ "zenbu-scrypt$v=1$N=32768$r=8$p=1$" \ "c4ff27cc756429b17991b80e4c2c5f77$" \ "34e208b3a51796ebd3b39da1039611b1c7ffeb04090b07ef2bf4968ce8131ce3" /* ------------------------------------------------------------------ */ /* Module state */ /* ------------------------------------------------------------------ */ static Auth_Store *g_auth_store = NULL; static uint8 g_cookie_secret[AUTH_CRYPTO_COOKIE_SECRET_MAX_BYTES]; static size_t g_cookie_secret_length = 0; static char g_trusted_proxy_ip[AUTH_CRYPTO_IP_MAX_BYTES]; static boolean g_has_trusted_proxy = FALSE; static int64 g_session_idle_ttl = AUTH_API_SESSION_IDLE_TTL_DEFAULT; static int64 g_session_abs_ttl = AUTH_API_SESSION_ABS_TTL_DEFAULT; static int64 g_guest_ttl = AUTH_API_GUEST_TTL_DEFAULT; static boolean g_dev_insecure_cookie = FALSE; static Auth_Guest_Transfer_Hook g_transfer_hook = NULL; static void *g_transfer_hook_ctx = NULL; static Auth_API_Guest_Quota_Cb g_guest_quota_cb = NULL; #ifdef AUTH_API_TEST_HOOKS static Auth_API_Test_Login_Pre_Create_Hook g_login_pre_create_hook = NULL; static void *g_login_pre_create_context = NULL; #endif /* ------------------------------------------------------------------ */ /* Rate limiter (collision-safe open-addressing with LRU eviction) */ /* ------------------------------------------------------------------ */ typedef struct { char key[RATE_KEY_MAX]; /* HMAC hex binding; empty if slot unused */ uint32 count; int64 window_start; } Auth_Rate_Entry; static Auth_Rate_Entry g_rate_table[RATE_TABLE_SIZE]; static pthread_mutex_t g_rate_mutex = PTHREAD_MUTEX_INITIALIZER; /* ------------------------------------------------------------------ */ /* Internal helpers */ /* ------------------------------------------------------------------ */ static int64 auth_now(void) { return (int64)time(NULL); } /* * Generate a UUID v4 using RAND_bytes. * buf must be at least 37 bytes. */ static boolean auth_uuid4(char *buf, size_t capacity) { if (capacity < 37) return FALSE; uint8 rnd[16]; if (RAND_bytes(rnd, sizeof(rnd)) != 1) return FALSE; /* RFC 4122 version 4 */ rnd[6] = (rnd[6] & 0x0f) | 0x40; rnd[8] = (rnd[8] & 0x3f) | 0x80; snprintf(buf, capacity, "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-" "%02x%02x%02x%02x%02x%02x", rnd[0], rnd[1], rnd[2], rnd[3], rnd[4], rnd[5], rnd[6], rnd[7], rnd[8], rnd[9], rnd[10], rnd[11], rnd[12], rnd[13], rnd[14], rnd[15]); OPENSSL_cleanse(rnd, sizeof(rnd)); return TRUE; } static void auth_hex_encode( const uint8 *src, size_t src_len, char *dst, size_t dst_capacity) { static const char kHex[] = "0123456789abcdef"; size_t i = 0, o = 0; while (i < src_len && o + 2 < dst_capacity) { dst[o++] = kHex[(src[i] >> 4) & 0xf]; dst[o++] = kHex[src[i] & 0xf]; i++; } if (o < dst_capacity) dst[o] = '\0'; } /* * Derive a CSRF token deterministically from a session binding. * binding: session token_digest (user) or guest_id (guest). * Output: base64url-encoded HMAC-SHA256, AUTH_CRYPTO_TOKEN_SIZE bytes. */ static boolean auth_derive_csrf( const char *binding, char *csrf_out, size_t csrf_capacity) { return Auth_HTTP_Derive_CSRF( g_cookie_secret, g_cookie_secret_length, binding, csrf_out, csrf_capacity); } /* * Build the rate-limit key: HMAC(secret, peer_digest + ":" + norm_user). * Output: 64-char hex string. */ static boolean auth_rate_key( const char *peer_binding_digest, const char *normalized_username, char *key_out) { char input[AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE + 1 + AUTH_STORE_USERNAME_MAX + 1]; int n = snprintf(input, sizeof(input), "%s:%s", peer_binding_digest, normalized_username); if (n < 0 || (size_t)n >= sizeof(input)) return FALSE; uint8 digest[32]; uint32 digest_len = 32; if (!HMAC(EVP_sha256(), g_cookie_secret, (int)g_cookie_secret_length, (const uint8 *)input, (size_t)n, digest, &digest_len)) { OPENSSL_cleanse(input, sizeof(input)); OPENSSL_cleanse(digest, sizeof(digest)); return FALSE; } OPENSSL_cleanse(input, sizeof(input)); auth_hex_encode(digest, 32, key_out, RATE_KEY_MAX); OPENSSL_cleanse(digest, sizeof(digest)); return TRUE; } static uint32 auth_rate_index(const char *key) { /* Use the first 8 hex chars of the HMAC key as a uint32 hash seed. */ uint32 h = 0; for (int i = 0; i < 8 && key[i] != '\0'; i++) { char c = key[i]; uint32 nibble = (c >= '0' && c <= '9') ? (uint32)(c - '0') : (c >= 'a' && c <= 'f') ? (uint32)(c - 'a' + 10) : 0; h = (h << 4) | nibble; } return h & (RATE_TABLE_SIZE - 1); } /* * Find or insert an entry for key in the open-addressing rate table. * Probes up to RATE_PROBE_LIMIT slots from the hash index. * On a full probe window, evicts the oldest entry by window_start. * Must be called with g_rate_mutex held. * Returns a pointer to the entry on success, NULL if internal error. */ static Auth_Rate_Entry *auth_rate_find_or_insert(const char *key, int64 now) { uint32 start = auth_rate_index(key); Auth_Rate_Entry *evict_candidate = NULL; int64 evict_time = INT64_MAX; for (uint32 i = 0; i < (uint32)RATE_PROBE_LIMIT; i++) { uint32 idx = (start + i) & (RATE_TABLE_SIZE - 1); Auth_Rate_Entry *e = &g_rate_table[idx]; /* Exact match */ if (e->key[0] != '\0' && memcmp(e->key, key, RATE_KEY_MAX) == 0) return e; /* Empty slot — claim it */ if (e->key[0] == '\0') { memcpy(e->key, key, RATE_KEY_MAX); e->count = 0; e->window_start = now; return e; } /* Expired entry — reuse it immediately */ if (now - e->window_start >= RATE_LIMIT_WINDOW_SECS) { memcpy(e->key, key, RATE_KEY_MAX); e->count = 0; e->window_start = now; return e; } /* Track the oldest live entry for eviction */ if (e->window_start < evict_time) { evict_time = e->window_start; evict_candidate = e; } } /* All probe slots occupied by live, non-matching entries — evict oldest */ if (evict_candidate) { memcpy(evict_candidate->key, key, RATE_KEY_MAX); evict_candidate->count = 0; evict_candidate->window_start = now; return evict_candidate; } return NULL; } /* * Returns TRUE if the caller should be rate-limited (too many failures). * Caller must still call auth_rate_record_failure on a failed attempt. */ static boolean auth_rate_check(const char *key) { pthread_mutex_lock(&g_rate_mutex); int64 now = auth_now(); Auth_Rate_Entry *entry = auth_rate_find_or_insert(key, now); boolean limited = FALSE; if (entry && now - entry->window_start < RATE_LIMIT_WINDOW_SECS) limited = (entry->count >= RATE_LIMIT_MAX_FAILURES); pthread_mutex_unlock(&g_rate_mutex); return limited; } static void auth_rate_record_failure(const char *key) { pthread_mutex_lock(&g_rate_mutex); int64 now = auth_now(); Auth_Rate_Entry *entry = auth_rate_find_or_insert(key, now); if (entry) { if (now - entry->window_start >= RATE_LIMIT_WINDOW_SECS) { /* Window expired; start fresh */ entry->count = 1; entry->window_start = now; } else { entry->count++; } } pthread_mutex_unlock(&g_rate_mutex); } static void auth_rate_reset(const char *key) { pthread_mutex_lock(&g_rate_mutex); uint32 start = auth_rate_index(key); for (uint32 i = 0; i < (uint32)RATE_PROBE_LIMIT; i++) { uint32 idx = (start + i) & (RATE_TABLE_SIZE - 1); Auth_Rate_Entry *e = &g_rate_table[idx]; if (e->key[0] != '\0' && memcmp(e->key, key, RATE_KEY_MAX) == 0) { memset(e, 0, sizeof(*e)); break; } } pthread_mutex_unlock(&g_rate_mutex); } /* ------------------------------------------------------------------ */ /* Request helpers */ /* ------------------------------------------------------------------ */ static const char *auth_req_value( Seobeo_Request_Entry *p_req, const char *key) { void *p = Dowa_HashMap_Get_Ptr(p_req, (char *)key); return p ? ((Seobeo_Request_Entry *)p)->value : NULL; } static boolean auth_extract_secret_field( Dowa_JSON_Entry *obj, const char *key, char *out_buf, size_t max_len) { char *arena_ptr = Dowa_JSON_Get_String(obj, key); if (!arena_ptr || arena_ptr[0] == '\0') return FALSE; size_t field_len = strlen(arena_ptr); if (field_len > max_len) { OPENSSL_cleanse(arena_ptr, field_len); return FALSE; } memcpy(out_buf, arena_ptr, field_len); out_buf[field_len] = '\0'; OPENSSL_cleanse(arena_ptr, field_len); return TRUE; } /* * Parse a named cookie from the Cookie header. * Returns TRUE and fills value_out on success. */ static boolean auth_parse_cookie( const char *cookie_header, const char *name, char *value_out, size_t capacity) { return Auth_HTTP_Parse_Cookie( cookie_header, name, value_out, capacity); } /* * Resolve effective peer IP. * If Remote-Addr matches configured trusted proxy, accept X-Real-IP. * Never logs raw IPs. */ static boolean auth_peer_ip( Seobeo_Request_Entry *p_req, char *ip_out, size_t capacity) { const char *direct = auth_req_value(p_req, "Remote-Addr"); if (!direct || direct[0] == '\0') return FALSE; if (g_has_trusted_proxy && strcmp(direct, g_trusted_proxy_ip) == 0) { const char *forwarded = auth_req_value(p_req, "X-Real-IP"); if (forwarded && forwarded[0] != '\0' && strlen(forwarded) < capacity) { strncpy(ip_out, forwarded, capacity - 1); ip_out[capacity - 1] = '\0'; return TRUE; } } if (strlen(direct) >= capacity) return FALSE; strncpy(ip_out, direct, capacity - 1); ip_out[capacity - 1] = '\0'; return TRUE; } static boolean auth_same_origin(Seobeo_Request_Entry *p_req) { return Auth_HTTP_Same_Origin(p_req); } /* ------------------------------------------------------------------ */ /* Response builders */ /* ------------------------------------------------------------------ */ /* * Build a Set-Cookie directive string. * expires_max_age = 0 means no Max-Age (persistent); < 0 means Max-Age=0 * (clear the cookie). */ static boolean auth_build_cookie_directive( const char *name, const char *value, int32 max_age, boolean http_only, char *out, size_t capacity) { int n; if (max_age < 0) { n = snprintf(out, capacity, "%s=; Path=/; %sSameSite=Lax; Max-Age=0%s", name, http_only ? "HttpOnly; " : "", g_dev_insecure_cookie ? "" : "; Secure"); } else if (max_age == 0) { n = snprintf(out, capacity, "%s=%s; Path=/; %sSameSite=Lax%s", name, value, http_only ? "HttpOnly; " : "", g_dev_insecure_cookie ? "" : "; Secure"); } else { n = snprintf(out, capacity, "%s=%s; Path=/; %sSameSite=Lax; Max-Age=%d%s", name, value, http_only ? "HttpOnly; " : "", max_age, g_dev_insecure_cookie ? "" : "; Secure"); } return n > 0 && (size_t)n < capacity; } static Seobeo_Request_Entry *auth_json_response( Dowa_Arena *p_arena, const char *status, const char *body) { Seobeo_Request_Entry *resp = NULL; Dowa_HashMap_Push_Arena(resp, "status", (char *)status, p_arena); Dowa_HashMap_Push_Arena( resp, "content-type", "application/json; charset=utf-8", p_arena); Dowa_HashMap_Push_Arena(resp, "cache-control", "no-store", p_arena); Dowa_HashMap_Push_Arena(resp, "pragma", "no-cache", p_arena); Dowa_HashMap_Push_Arena(resp, "x-content-type-options", "nosniff", p_arena); Dowa_HashMap_Push_Arena(resp, "body", (char *)body, p_arena); return resp; } static Seobeo_Request_Entry *auth_json_response_with_cookies( Dowa_Arena *p_arena, const char *status, const char *body, const char *cookie1, /* value for "Set-Cookie"; NULL to skip */ const char *cookie2) /* value for "set-cookie"; NULL to skip */ { Seobeo_Request_Entry *resp = auth_json_response(p_arena, status, body); if (cookie1) Dowa_HashMap_Push_Arena(resp, "Set-Cookie", (char *)cookie1, p_arena); if (cookie2) Dowa_HashMap_Push_Arena(resp, "set-cookie", (char *)cookie2, p_arena); return resp; } static Seobeo_Request_Entry *auth_error( Dowa_Arena *p_arena, const char *status, const char *code, const char *message) { char body[512]; snprintf(body, sizeof(body), "{\"error\":{\"code\":\"%s\",\"message\":\"%s\"}}", code, message); char *body_copy = Dowa_Arena_Allocate(p_arena, strlen(body) + 1); if (body_copy) strcpy(body_copy, body); return auth_json_response(p_arena, status, body_copy ? body_copy : "{}"); } /* ------------------------------------------------------------------ */ /* Principal resolution */ /* ------------------------------------------------------------------ */ boolean Auth_API_Resolve_Principal( Seobeo_Request_Entry *p_request, Auth_Principal *p_principal, Dowa_Arena *p_arena, char *new_guest_cookie_out, size_t new_guest_cookie_capacity) { if (!g_auth_store || !p_principal) return FALSE; memset(p_principal, 0, sizeof(*p_principal)); if (new_guest_cookie_out && new_guest_cookie_capacity > 0) new_guest_cookie_out[0] = '\0'; const char *cookie_header = auth_req_value(p_request, "Cookie"); int64 now = auth_now(); /* --- Try authenticated session first --- */ Auth_HTTP_Authenticated_User authenticated_user; Auth_HTTP_Resolve_Result user_result = Auth_HTTP_Resolve_Authenticated_User( p_request, g_auth_store, g_cookie_secret, g_cookie_secret_length, now, g_session_idle_ttl, &authenticated_user); if (user_result == AUTH_HTTP_RESOLVE_ERROR) return FALSE; if (user_result == AUTH_HTTP_RESOLVE_OK) { p_principal->kind = AUTH_PRINCIPAL_USER; strncpy(p_principal->user_id, authenticated_user.user.id, sizeof(p_principal->user_id) - 1); strncpy(p_principal->username, authenticated_user.user.username, sizeof(p_principal->username) - 1); strncpy(p_principal->role, authenticated_user.user.role, sizeof(p_principal->role) - 1); p_principal->must_change_password = authenticated_user.user.must_change_password; strncpy(p_principal->_binding, authenticated_user.token_digest, sizeof(p_principal->_binding) - 1); strncpy(p_principal->csrf_token, authenticated_user.csrf_token, sizeof(p_principal->csrf_token) - 1); OPENSSL_cleanse(&authenticated_user, sizeof(authenticated_user)); return TRUE; } /* --- Try guest cookie --- */ char peer_ip[AUTH_CRYPTO_IP_MAX_BYTES] = {0}; boolean have_ip = auth_peer_ip(p_request, peer_ip, sizeof(peer_ip)); char ip_binding[AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE] = {0}; if (have_ip && Auth_Crypto_IP_Binding_Digest( g_cookie_secret, g_cookie_secret_length, peer_ip, ip_binding, sizeof(ip_binding)) != AUTH_CRYPTO_OK) { OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); return FALSE; } char guest_cookie_val[COOKIE_VALUE_MAX] = {0}; boolean guest_valid = FALSE; Auth_Crypto_Guest_Cookie guest_parsed; memset(&guest_parsed, 0, sizeof(guest_parsed)); if (cookie_header && auth_parse_cookie(cookie_header, AUTH_API_GUEST_COOKIE_NAME, guest_cookie_val, sizeof(guest_cookie_val)) && guest_cookie_val[0] != '\0' && have_ip) { Auth_Crypto_Result cr = Auth_Crypto_Guest_Cookie_Verify( g_cookie_secret, g_cookie_secret_length, guest_cookie_val, (uint64)now, ip_binding, &guest_parsed); guest_valid = (cr == AUTH_CRYPTO_OK); } if (guest_valid) { Auth_Guest_Identity_Record identity; Auth_Store_Result result = Auth_Store_Find_Guest_Identity( g_auth_store, guest_parsed.guest_uuid, now, &identity); if (result == AUTH_STORE_OK) { Auth_Store_Upsert_Guest_Identity( g_auth_store, guest_parsed.guest_uuid, ip_binding, now + g_guest_ttl, &identity); p_principal->kind = AUTH_PRINCIPAL_GUEST; strncpy(p_principal->guest_id, guest_parsed.guest_uuid, sizeof(p_principal->guest_id) - 1); strncpy(p_principal->_binding, guest_parsed.guest_uuid, sizeof(p_principal->_binding) - 1); if (!auth_derive_csrf(guest_parsed.guest_uuid, p_principal->csrf_token, sizeof(p_principal->csrf_token))) { OPENSSL_cleanse(guest_cookie_val, sizeof(guest_cookie_val)); OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); memset(p_principal, 0, sizeof(*p_principal)); return FALSE; } OPENSSL_cleanse(guest_cookie_val, sizeof(guest_cookie_val)); OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); return TRUE; } } OPENSSL_cleanse(guest_cookie_val, sizeof(guest_cookie_val)); /* --- Create new guest identity --- */ char guest_uuid[AUTH_CRYPTO_GUEST_UUID_SIZE]; if (!auth_uuid4(guest_uuid, sizeof(guest_uuid))) { OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); return FALSE; } int64 guest_expires = now + g_guest_ttl; Auth_Guest_Identity_Record new_identity; if (Auth_Store_Upsert_Guest_Identity( g_auth_store, guest_uuid, ip_binding, guest_expires, &new_identity) != AUTH_STORE_OK) { OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); return FALSE; } p_principal->kind = AUTH_PRINCIPAL_GUEST; strncpy(p_principal->guest_id, guest_uuid, sizeof(p_principal->guest_id) - 1); strncpy(p_principal->_binding, guest_uuid, sizeof(p_principal->_binding) - 1); if (!auth_derive_csrf(guest_uuid, p_principal->csrf_token, sizeof(p_principal->csrf_token))) { OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); memset(p_principal, 0, sizeof(*p_principal)); return FALSE; } /* Build new guest cookie for the response */ if (new_guest_cookie_out && new_guest_cookie_capacity > 0 && have_ip) { char signed_cookie[AUTH_CRYPTO_GUEST_COOKIE_SIZE]; if (Auth_Crypto_Guest_Cookie_Create( g_cookie_secret, g_cookie_secret_length, guest_uuid, (uint64)guest_expires, ip_binding, signed_cookie, sizeof(signed_cookie)) == AUTH_CRYPTO_OK) { auth_build_cookie_directive( AUTH_API_GUEST_COOKIE_NAME, signed_cookie, (int32)g_guest_ttl, TRUE, new_guest_cookie_out, new_guest_cookie_capacity); OPENSSL_cleanse(signed_cookie, sizeof(signed_cookie)); } } OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); return TRUE; } boolean Auth_API_Resolve_Existing_Principal( Seobeo_Request_Entry *p_request, Auth_Principal *p_principal, Dowa_Arena *p_arena, boolean *p_found) { if (!g_auth_store || !p_principal || !p_found) return FALSE; memset(p_principal, 0, sizeof(*p_principal)); *p_found = FALSE; const char *cookie_header = auth_req_value(p_request, "Cookie"); int64 now = auth_now(); /* --- Try authenticated session first --- */ Auth_HTTP_Authenticated_User authenticated_user; Auth_HTTP_Resolve_Result user_result = Auth_HTTP_Resolve_Authenticated_User( p_request, g_auth_store, g_cookie_secret, g_cookie_secret_length, now, g_session_idle_ttl, &authenticated_user); if (user_result == AUTH_HTTP_RESOLVE_ERROR) return FALSE; if (user_result == AUTH_HTTP_RESOLVE_OK) { p_principal->kind = AUTH_PRINCIPAL_USER; strncpy(p_principal->user_id, authenticated_user.user.id, sizeof(p_principal->user_id) - 1); strncpy(p_principal->username, authenticated_user.user.username, sizeof(p_principal->username) - 1); strncpy(p_principal->role, authenticated_user.user.role, sizeof(p_principal->role) - 1); p_principal->must_change_password = authenticated_user.user.must_change_password; strncpy(p_principal->_binding, authenticated_user.token_digest, sizeof(p_principal->_binding) - 1); strncpy(p_principal->csrf_token, authenticated_user.csrf_token, sizeof(p_principal->csrf_token) - 1); OPENSSL_cleanse(&authenticated_user, sizeof(authenticated_user)); *p_found = TRUE; return TRUE; } /* --- Try existing guest cookie (no new guest created) --- */ char peer_ip[AUTH_CRYPTO_IP_MAX_BYTES] = {0}; boolean have_ip = auth_peer_ip(p_request, peer_ip, sizeof(peer_ip)); char ip_binding[AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE] = {0}; if (have_ip && Auth_Crypto_IP_Binding_Digest( g_cookie_secret, g_cookie_secret_length, peer_ip, ip_binding, sizeof(ip_binding)) != AUTH_CRYPTO_OK) { OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); return FALSE; } char guest_cookie_val[COOKIE_VALUE_MAX] = {0}; Auth_Crypto_Guest_Cookie guest_parsed; memset(&guest_parsed, 0, sizeof(guest_parsed)); if (cookie_header && auth_parse_cookie(cookie_header, AUTH_API_GUEST_COOKIE_NAME, guest_cookie_val, sizeof(guest_cookie_val)) && guest_cookie_val[0] != '\0' && have_ip) { Auth_Crypto_Result cr = Auth_Crypto_Guest_Cookie_Verify( g_cookie_secret, g_cookie_secret_length, guest_cookie_val, (uint64)now, ip_binding, &guest_parsed); if (cr == AUTH_CRYPTO_OK) { Auth_Guest_Identity_Record identity; Auth_Store_Result result = Auth_Store_Find_Guest_Identity( g_auth_store, guest_parsed.guest_uuid, now, &identity); if (result == AUTH_STORE_OK) { Auth_Store_Upsert_Guest_Identity( g_auth_store, guest_parsed.guest_uuid, ip_binding, now + g_guest_ttl, &identity); p_principal->kind = AUTH_PRINCIPAL_GUEST; strncpy(p_principal->guest_id, guest_parsed.guest_uuid, sizeof(p_principal->guest_id) - 1); strncpy(p_principal->_binding, guest_parsed.guest_uuid, sizeof(p_principal->_binding) - 1); if (!auth_derive_csrf(guest_parsed.guest_uuid, p_principal->csrf_token, sizeof(p_principal->csrf_token))) { OPENSSL_cleanse(guest_cookie_val, sizeof(guest_cookie_val)); OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); memset(p_principal, 0, sizeof(*p_principal)); return FALSE; } OPENSSL_cleanse(guest_cookie_val, sizeof(guest_cookie_val)); OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); *p_found = TRUE; return TRUE; } } } OPENSSL_cleanse(guest_cookie_val, sizeof(guest_cookie_val)); OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); /* No existing identity found — caller should return 401. * We do NOT create a guest row or generate a Set-Cookie directive. */ *p_found = FALSE; return TRUE; } /* ------------------------------------------------------------------ */ /* CSRF verification helper */ /* ------------------------------------------------------------------ */ /* * Verify a CSRF token provided by the client against the binding for * the current session/guest. Comparison is by SHA-256 digest equality * to avoid timing-oracle attacks on the base64url token directly. */ static boolean auth_verify_csrf( const char *provided_token, const char *binding) { return Auth_HTTP_Verify_CSRF_Token( g_cookie_secret, g_cookie_secret_length, binding, provided_token); } /* ------------------------------------------------------------------ */ /* Public: Auth_API_Verify_CSRF */ /* ------------------------------------------------------------------ */ boolean Auth_API_Verify_CSRF( Seobeo_Request_Entry *p_request, const Auth_Principal *p_principal) { if (!p_principal) return FALSE; return Auth_HTTP_Verify_CSRF( p_request, g_cookie_secret, g_cookie_secret_length, p_principal->_binding); } /* ------------------------------------------------------------------ */ /* Route: GET /api/auth/session */ /* ------------------------------------------------------------------ */ static Seobeo_Request_Entry *auth_session_handler( Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena) { if (!g_auth_store) return auth_error(p_arena, "503", "service_unavailable", "Auth not initialised"); Auth_Principal principal; char new_guest_cookie[GUEST_COOKIE_MAX] = {0}; if (!Auth_API_Resolve_Principal(p_req, &principal, p_arena, new_guest_cookie, sizeof(new_guest_cookie))) return auth_error(p_arena, "500", "internal_error", "Session error"); char body[2048]; if (principal.kind == AUTH_PRINCIPAL_USER) { char *safe_username = Dowa_JSON_Escape_String(principal.username, 0, p_arena); char *safe_role = Dowa_JSON_Escape_String(principal.role, 0, p_arena); char *safe_csrf = Dowa_JSON_Escape_String(principal.csrf_token, 0, p_arena); if (!safe_username || !safe_role || !safe_csrf) return auth_error(p_arena, "500", "internal_error", "Encode error"); snprintf(body, sizeof(body), "{\"kind\":\"user\",\"username\":\"%s\",\"role\":\"%s\"," "\"mustChangePassword\":%s,\"csrfToken\":\"%s\"," "\"quota\":null}", safe_username, safe_role, principal.must_change_password ? "true" : "false", safe_csrf); } else { char *safe_csrf = Dowa_JSON_Escape_String(principal.csrf_token, 0, p_arena); if (!safe_csrf) return auth_error(p_arena, "500", "internal_error", "Encode error"); /* Ask conversation layer for quota JSON (null if not registered). */ char quota_json[512] = "null"; if (g_guest_quota_cb) g_guest_quota_cb(principal.guest_id, auth_now(), quota_json, sizeof(quota_json)); snprintf(body, sizeof(body), "{\"kind\":\"guest\",\"csrfToken\":\"%s\",\"quota\":%s}", safe_csrf, quota_json); } char *body_copy = Dowa_Arena_Allocate(p_arena, strlen(body) + 1); if (!body_copy) return auth_error(p_arena, "500", "internal_error", "OOM"); strcpy(body_copy, body); const char *cookie1 = (new_guest_cookie[0] != '\0') ? new_guest_cookie : NULL; return auth_json_response_with_cookies( p_arena, "200", body_copy, cookie1, NULL); } /* ------------------------------------------------------------------ */ /* Route: POST /api/auth/login */ /* ------------------------------------------------------------------ */ static Seobeo_Request_Entry *auth_login_handler( Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena) { if (!g_auth_store) return auth_error(p_arena, "503", "service_unavailable", "Auth not initialised"); if (!auth_same_origin(p_req)) return auth_error(p_arena, "403", "forbidden", "Origin mismatch"); /* --- Parse body --- */ const char *body_str = auth_req_value(p_req, "Body"); if (!body_str) return auth_error(p_arena, "400", "bad_request", "Invalid body"); size_t body_len = strlen(body_str); if (body_len > BODY_MAX_BYTES) { OPENSSL_cleanse((char *)body_str, body_len); return auth_error(p_arena, "400", "bad_request", "Invalid body"); } Dowa_JSON_Value jv = Dowa_JSON_Parse(body_str, (int32)body_len, p_arena); OPENSSL_cleanse((char *)body_str, body_len); if (jv.type != DOWA_JSON_OBJECT) return auth_error(p_arena, "400", "bad_request", "Expected JSON object"); Dowa_JSON_Entry *obj = (Dowa_JSON_Entry *)jv.object_val; char *username_raw = Dowa_JSON_Get_String(obj, "username"); char *csrf_provided = Dowa_JSON_Get_String(obj, "csrfToken"); char password_buf[AUTH_CRYPTO_PASSWORD_MAX_BYTES + 1]; memset(password_buf, 0, sizeof(password_buf)); boolean have_password = auth_extract_secret_field( obj, "password", password_buf, AUTH_CRYPTO_PASSWORD_MAX_BYTES); char *password_raw = password_buf; if (!username_raw || !have_password || !csrf_provided || username_raw[0] == '\0' || csrf_provided[0] == '\0') { OPENSSL_cleanse(password_buf, sizeof(password_buf)); return auth_error(p_arena, "400", "bad_request", "Missing fields"); } /* --- Password length bounds --- */ size_t pw_len = strlen(password_raw); if (pw_len < 12 || pw_len > AUTH_CRYPTO_PASSWORD_MAX_BYTES) { OPENSSL_cleanse(password_raw, pw_len); return auth_error(p_arena, "401", "invalid_credentials", "Invalid credentials"); } /* --- Resolve existing principal for CSRF binding --- */ Auth_Principal principal; char ignored_cookie[GUEST_COOKIE_MAX] = {0}; if (!Auth_API_Resolve_Principal(p_req, &principal, p_arena, ignored_cookie, sizeof(ignored_cookie))) { OPENSSL_cleanse(password_raw, pw_len); return auth_error(p_arena, "500", "internal_error", "Session error"); } /* --- CSRF check --- */ if (!auth_verify_csrf(csrf_provided, principal._binding)) { OPENSSL_cleanse(password_raw, pw_len); return auth_error(p_arena, "403", "csrf_invalid", "CSRF token invalid"); } /* --- Peer IP and rate-limit key --- */ char peer_ip[AUTH_CRYPTO_IP_MAX_BYTES] = {0}; boolean have_ip = auth_peer_ip(p_req, peer_ip, sizeof(peer_ip)); char ip_binding[AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE] = {0}; if (have_ip && Auth_Crypto_IP_Binding_Digest( g_cookie_secret, g_cookie_secret_length, peer_ip, ip_binding, sizeof(ip_binding)) != AUTH_CRYPTO_OK) { OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); OPENSSL_cleanse(password_raw, pw_len); return auth_error(p_arena, "500", "internal_error", "Binding error"); } OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); /* Normalize username */ char norm_username[AUTH_STORE_USERNAME_MAX + 1] = {0}; if (!Auth_Store_Normalize_Username( username_raw, norm_username, sizeof(norm_username))) { OPENSSL_cleanse(password_raw, pw_len); return auth_error(p_arena, "401", "invalid_credentials", "Invalid credentials"); } /* Rate limit check */ char rate_key[RATE_KEY_MAX] = {0}; boolean have_rate_key = auth_rate_key(ip_binding, norm_username, rate_key); if (have_rate_key && auth_rate_check(rate_key)) { OPENSSL_cleanse(password_raw, pw_len); OPENSSL_cleanse(norm_username, sizeof(norm_username)); return auth_error(p_arena, "429", "too_many_requests", "Too many login attempts"); } /* --- Fetch user record --- */ Auth_User_Auth_Record auth_record; memset(&auth_record, 0, sizeof(auth_record)); Auth_Store_Result find_result = Auth_Store_Find_User_By_Username( g_auth_store, norm_username, &auth_record); if (find_result != AUTH_STORE_OK) { /* * User not found; run exactly one scrypt verification against a fixed * precomputed hash to consume constant time, then return a generic error. * AUTH_DUMMY_PASSWORD_HASH is a valid zenbu-scrypt hash of a known * constant string — it is never an account credential. */ Auth_Crypto_Password_Verify(password_raw, AUTH_DUMMY_PASSWORD_HASH); OPENSSL_cleanse(password_raw, pw_len); OPENSSL_cleanse(auth_record.password_hash, sizeof(auth_record.password_hash)); if (have_rate_key) auth_rate_record_failure(rate_key); return auth_error(p_arena, "401", "invalid_credentials", "Invalid credentials"); } if (strcmp(auth_record.user.status, "active") != 0) { Auth_Crypto_Password_Verify(password_raw, auth_record.password_hash); OPENSSL_cleanse(password_raw, pw_len); OPENSSL_cleanse(auth_record.password_hash, sizeof(auth_record.password_hash)); if (have_rate_key) auth_rate_record_failure(rate_key); return auth_error(p_arena, "401", "invalid_credentials", "Invalid credentials"); } /* --- Verify password --- */ Auth_Crypto_Result verify = Auth_Crypto_Password_Verify(password_raw, auth_record.password_hash); OPENSSL_cleanse(password_raw, pw_len); if (verify != AUTH_CRYPTO_OK) { OPENSSL_cleanse(auth_record.password_hash, sizeof(auth_record.password_hash)); if (have_rate_key) auth_rate_record_failure(rate_key); return auth_error(p_arena, "401", "invalid_credentials", "Invalid credentials"); } /* Successful authentication: reset rate limit */ if (have_rate_key) auth_rate_reset(rate_key); /* --- Create new session (prevents session fixation) --- */ char new_token[AUTH_CRYPTO_TOKEN_SIZE]; if (Auth_Crypto_Token_Generate(new_token, sizeof(new_token)) != AUTH_CRYPTO_OK) { OPENSSL_cleanse(auth_record.password_hash, sizeof(auth_record.password_hash)); return auth_error(p_arena, "500", "internal_error", "Token error"); } char new_token_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE]; if (Auth_Crypto_Token_Digest(new_token, new_token_digest, sizeof(new_token_digest)) != AUTH_CRYPTO_OK) { OPENSSL_cleanse(auth_record.password_hash, sizeof(auth_record.password_hash)); OPENSSL_cleanse(new_token, sizeof(new_token)); return auth_error(p_arena, "500", "internal_error", "Digest error"); } /* CSRF for this new session */ char new_csrf[AUTH_CRYPTO_TOKEN_SIZE]; if (!auth_derive_csrf(new_token_digest, new_csrf, sizeof(new_csrf))) { OPENSSL_cleanse(auth_record.password_hash, sizeof(auth_record.password_hash)); OPENSSL_cleanse(new_token, sizeof(new_token)); OPENSSL_cleanse(new_token_digest, sizeof(new_token_digest)); return auth_error(p_arena, "500", "internal_error", "CSRF error"); } char csrf_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE]; if (Auth_Crypto_Token_Digest(new_csrf, csrf_digest, sizeof(csrf_digest)) != AUTH_CRYPTO_OK) { OPENSSL_cleanse(auth_record.password_hash, sizeof(auth_record.password_hash)); OPENSSL_cleanse(new_token, sizeof(new_token)); OPENSSL_cleanse(new_token_digest, sizeof(new_token_digest)); OPENSSL_cleanse(new_csrf, sizeof(new_csrf)); return auth_error(p_arena, "500", "internal_error", "CSRF digest error"); } int64 now = auth_now(); Auth_Session_Record session; #ifdef AUTH_API_TEST_HOOKS if (g_login_pre_create_hook) g_login_pre_create_hook(g_login_pre_create_context); #endif Auth_Store_Result create_result = Auth_Store_Create_Session_CAS( g_auth_store, auth_record.user.id, auth_record.password_hash, new_token_digest, csrf_digest, g_session_idle_ttl, g_session_abs_ttl, now, &session); OPENSSL_cleanse(auth_record.password_hash, sizeof(auth_record.password_hash)); OPENSSL_cleanse(csrf_digest, sizeof(csrf_digest)); OPENSSL_cleanse(new_token_digest, sizeof(new_token_digest)); if (create_result != AUTH_STORE_OK) { OPENSSL_cleanse(new_token, sizeof(new_token)); OPENSSL_cleanse(new_csrf, sizeof(new_csrf)); if (create_result == AUTH_STORE_STALE_PASSWORD) return auth_error(p_arena, "401", "invalid_credentials", "Invalid credentials"); return auth_error(p_arena, "500", "internal_error", "Session create failed"); } /* --- Call guest transfer hook (before clearing guest state) --- */ if (g_transfer_hook && principal.kind == AUTH_PRINCIPAL_GUEST && principal.guest_id[0] != '\0') { boolean transferred = g_transfer_hook( principal.guest_id, auth_record.user.id, g_transfer_hook_ctx); if (!transferred) { /* Transfer failed — revoke the new session and return 500. * Guest cookie/data are preserved (no clear_guest in response). */ char rev_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE]; if (Auth_Crypto_Token_Digest(new_token, rev_digest, sizeof(rev_digest)) == AUTH_CRYPTO_OK) { Auth_Store_Result revoke_result = Auth_Store_Revoke_Session(g_auth_store, rev_digest); if (revoke_result == AUTH_STORE_ERROR) Seobeo_Log(SEOBEO_ERROR, "[AUTH] Failed to revoke session after transfer failure\n"); OPENSSL_cleanse(rev_digest, sizeof(rev_digest)); } OPENSSL_cleanse(new_token, sizeof(new_token)); OPENSSL_cleanse(new_csrf, sizeof(new_csrf)); return auth_error(p_arena, "500", "transfer_failed", "Resource transfer failed"); } } /* --- Build response --- */ char *safe_username = Dowa_JSON_Escape_String(auth_record.user.username, 0, p_arena); char *safe_role = Dowa_JSON_Escape_String(auth_record.user.role, 0, p_arena); char *safe_csrf = Dowa_JSON_Escape_String(new_csrf, 0, p_arena); OPENSSL_cleanse(new_csrf, sizeof(new_csrf)); if (!safe_username || !safe_role || !safe_csrf) { OPENSSL_cleanse(new_token, sizeof(new_token)); return auth_error(p_arena, "500", "internal_error", "Encode error"); } char body_buf[512]; snprintf(body_buf, sizeof(body_buf), "{\"kind\":\"user\",\"username\":\"%s\",\"role\":\"%s\"," "\"mustChangePassword\":%s,\"csrfToken\":\"%s\"," "\"quota\":null}", safe_username, safe_role, auth_record.user.must_change_password ? "true" : "false", safe_csrf); char *body_copy = Dowa_Arena_Allocate(p_arena, strlen(body_buf) + 1); if (!body_copy) { OPENSSL_cleanse(new_token, sizeof(new_token)); return auth_error(p_arena, "500", "internal_error", "OOM"); } strcpy(body_copy, body_buf); /* Session cookie */ char session_cookie[SESSION_COOKIE_MAX]; auth_build_cookie_directive( AUTH_API_SESSION_COOKIE_NAME, new_token, 0, TRUE, session_cookie, sizeof(session_cookie)); OPENSSL_cleanse(new_token, sizeof(new_token)); char *session_cookie_copy = Dowa_Arena_Allocate(p_arena, strlen(session_cookie) + 1); if (session_cookie_copy) strcpy(session_cookie_copy, session_cookie); OPENSSL_cleanse(session_cookie, sizeof(session_cookie)); /* Clear guest cookie */ char clear_guest[256]; auth_build_cookie_directive( AUTH_API_GUEST_COOKIE_NAME, "", -1, TRUE, clear_guest, sizeof(clear_guest)); char *clear_guest_copy = Dowa_Arena_Allocate(p_arena, strlen(clear_guest) + 1); if (clear_guest_copy) strcpy(clear_guest_copy, clear_guest); return auth_json_response_with_cookies( p_arena, "200", body_copy, session_cookie_copy, clear_guest_copy); } /* ------------------------------------------------------------------ */ /* Route: POST /api/auth/logout */ /* ------------------------------------------------------------------ */ static Seobeo_Request_Entry *auth_logout_handler( Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena) { if (!g_auth_store) return auth_error(p_arena, "503", "service_unavailable", "Auth not initialised"); if (!auth_same_origin(p_req)) return auth_error(p_arena, "403", "forbidden", "Origin mismatch"); /* --- Resolve current principal for CSRF binding --- */ Auth_Principal principal; char ignored_cookie[GUEST_COOKIE_MAX] = {0}; if (!Auth_API_Resolve_Principal(p_req, &principal, p_arena, ignored_cookie, sizeof(ignored_cookie))) return auth_error(p_arena, "500", "internal_error", "Session error"); /* --- CSRF check --- */ const char *body_str = auth_req_value(p_req, "Body"); char csrf_provided[AUTH_CRYPTO_TOKEN_SIZE] = {0}; if (body_str && strlen(body_str) <= BODY_MAX_BYTES) { Dowa_JSON_Value jv = Dowa_JSON_Parse(body_str, (int32)strlen(body_str), p_arena); if (jv.type == DOWA_JSON_OBJECT) { char *t = Dowa_JSON_Get_String((Dowa_JSON_Entry *)jv.object_val, "csrfToken"); if (t) strncpy(csrf_provided, t, sizeof(csrf_provided) - 1); } } /* Also accept CSRF from X-CSRF-Token header */ if (csrf_provided[0] == '\0') { const char *hdr = auth_req_value(p_req, "X-CSRF-Token"); if (hdr) strncpy(csrf_provided, hdr, sizeof(csrf_provided) - 1); } if (!auth_verify_csrf(csrf_provided, principal._binding)) return auth_error(p_arena, "403", "csrf_invalid", "CSRF token invalid"); /* --- Revoke session if authenticated --- */ const char *cookie_hdr = auth_req_value(p_req, "Cookie"); char session_token[COOKIE_VALUE_MAX] = {0}; if (cookie_hdr && auth_parse_cookie(cookie_hdr, AUTH_API_SESSION_COOKIE_NAME, session_token, sizeof(session_token)) && session_token[0] != '\0') { char token_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE] = {0}; Auth_Crypto_Result digest_result = Auth_Crypto_Token_Digest( session_token, token_digest, sizeof(token_digest)); if (digest_result == AUTH_CRYPTO_OK) { Auth_Store_Result revoke_result = Auth_Store_Revoke_Session(g_auth_store, token_digest); OPENSSL_cleanse(token_digest, sizeof(token_digest)); if (revoke_result == AUTH_STORE_ERROR) { OPENSSL_cleanse(session_token, sizeof(session_token)); return auth_error(p_arena, "500", "internal_error", "Logout failed"); } } else { OPENSSL_cleanse(token_digest, sizeof(token_digest)); OPENSSL_cleanse(session_token, sizeof(session_token)); return auth_error(p_arena, "500", "internal_error", "Logout failed"); } OPENSSL_cleanse(session_token, sizeof(session_token)); } /* --- Create fresh guest identity --- */ char peer_ip[AUTH_CRYPTO_IP_MAX_BYTES] = {0}; boolean have_ip = auth_peer_ip(p_req, peer_ip, sizeof(peer_ip)); char ip_binding[AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE] = {0}; if (have_ip && Auth_Crypto_IP_Binding_Digest( g_cookie_secret, g_cookie_secret_length, peer_ip, ip_binding, sizeof(ip_binding)) != AUTH_CRYPTO_OK) { OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); return auth_error(p_arena, "500", "internal_error", "Binding error"); } OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); char guest_uuid[AUTH_CRYPTO_GUEST_UUID_SIZE] = {0}; if (!auth_uuid4(guest_uuid, sizeof(guest_uuid))) return auth_error(p_arena, "500", "internal_error", "UUID error"); int64 now = auth_now(); int64 guest_expires = now + g_guest_ttl; Auth_Guest_Identity_Record new_identity; Auth_Store_Upsert_Guest_Identity( g_auth_store, guest_uuid, ip_binding, guest_expires, &new_identity); char new_csrf[AUTH_CRYPTO_TOKEN_SIZE] = {0}; if (!auth_derive_csrf(guest_uuid, new_csrf, sizeof(new_csrf))) return auth_error(p_arena, "500", "internal_error", "CSRF error"); char signed_cookie[AUTH_CRYPTO_GUEST_COOKIE_SIZE] = {0}; char new_guest_cookie[GUEST_COOKIE_MAX] = {0}; if (have_ip && Auth_Crypto_Guest_Cookie_Create( g_cookie_secret, g_cookie_secret_length, guest_uuid, (uint64)guest_expires, ip_binding, signed_cookie, sizeof(signed_cookie)) == AUTH_CRYPTO_OK) { auth_build_cookie_directive( AUTH_API_GUEST_COOKIE_NAME, signed_cookie, (int32)g_guest_ttl, TRUE, new_guest_cookie, sizeof(new_guest_cookie)); OPENSSL_cleanse(signed_cookie, sizeof(signed_cookie)); } /* --- Build response --- */ char *safe_csrf = Dowa_JSON_Escape_String(new_csrf, 0, p_arena); OPENSSL_cleanse(new_csrf, sizeof(new_csrf)); if (!safe_csrf) return auth_error(p_arena, "500", "internal_error", "Encode error"); char body_buf[256]; snprintf(body_buf, sizeof(body_buf), "{\"kind\":\"guest\",\"csrfToken\":\"%s\",\"quota\":null}", safe_csrf); char *body_copy = Dowa_Arena_Allocate(p_arena, strlen(body_buf) + 1); if (!body_copy) return auth_error(p_arena, "500", "internal_error", "OOM"); strcpy(body_copy, body_buf); /* Clear session cookie */ char clear_session[256]; auth_build_cookie_directive( AUTH_API_SESSION_COOKIE_NAME, "", -1, TRUE, clear_session, sizeof(clear_session)); char *clear_session_copy = Dowa_Arena_Allocate(p_arena, strlen(clear_session) + 1); if (!clear_session_copy) return auth_error(p_arena, "500", "internal_error", "OOM"); strcpy(clear_session_copy, clear_session); char *new_guest_copy = NULL; if (new_guest_cookie[0] != '\0') { new_guest_copy = Dowa_Arena_Allocate(p_arena, strlen(new_guest_cookie) + 1); if (!new_guest_copy) return auth_error(p_arena, "500", "internal_error", "OOM"); strcpy(new_guest_copy, new_guest_cookie); } return auth_json_response_with_cookies( p_arena, "200", body_copy, clear_session_copy, new_guest_copy); } /* ------------------------------------------------------------------ */ /* Route: POST /api/auth/password */ /* ------------------------------------------------------------------ */ static Seobeo_Request_Entry *auth_password_handler( Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena) { if (!g_auth_store) return auth_error(p_arena, "503", "service_unavailable", "Auth not initialised"); if (!auth_same_origin(p_req)) return auth_error(p_arena, "403", "forbidden", "Origin mismatch"); /* --- Resolve principal — must be authenticated user --- */ Auth_Principal principal; char ignored_cookie[GUEST_COOKIE_MAX] = {0}; if (!Auth_API_Resolve_Principal(p_req, &principal, p_arena, ignored_cookie, sizeof(ignored_cookie))) return auth_error(p_arena, "500", "internal_error", "Session error"); if (principal.kind != AUTH_PRINCIPAL_USER) return auth_error(p_arena, "401", "unauthenticated", "Authentication required"); /* --- Parse body --- */ const char *body_str = auth_req_value(p_req, "Body"); if (!body_str) return auth_error(p_arena, "400", "bad_request", "Invalid body"); size_t body_len = strlen(body_str); if (body_len > BODY_MAX_BYTES) { OPENSSL_cleanse((char *)body_str, body_len); return auth_error(p_arena, "400", "bad_request", "Invalid body"); } Dowa_JSON_Value jv = Dowa_JSON_Parse(body_str, (int32)body_len, p_arena); OPENSSL_cleanse((char *)body_str, body_len); if (jv.type != DOWA_JSON_OBJECT) return auth_error(p_arena, "400", "bad_request", "Expected JSON object"); Dowa_JSON_Entry *obj = (Dowa_JSON_Entry *)jv.object_val; char *csrf_provided = Dowa_JSON_Get_String(obj, "csrfToken"); char current_pw_buf[AUTH_CRYPTO_PASSWORD_MAX_BYTES + 1]; char new_pw_buf[AUTH_CRYPTO_PASSWORD_MAX_BYTES + 1]; memset(current_pw_buf, 0, sizeof(current_pw_buf)); memset(new_pw_buf, 0, sizeof(new_pw_buf)); boolean have_current = auth_extract_secret_field( obj, "currentPassword", current_pw_buf, AUTH_CRYPTO_PASSWORD_MAX_BYTES); boolean have_new = auth_extract_secret_field( obj, "newPassword", new_pw_buf, AUTH_CRYPTO_PASSWORD_MAX_BYTES); char *current_pw_raw = current_pw_buf; char *new_pw_raw = new_pw_buf; /* Also accept CSRF from header */ if (!csrf_provided || csrf_provided[0] == '\0') csrf_provided = (char *)auth_req_value(p_req, "X-CSRF-Token"); if (!have_current || !have_new || !csrf_provided) { OPENSSL_cleanse(current_pw_buf, sizeof(current_pw_buf)); OPENSSL_cleanse(new_pw_buf, sizeof(new_pw_buf)); return auth_error(p_arena, "400", "bad_request", "Missing fields"); } /* --- CSRF check --- */ if (!auth_verify_csrf(csrf_provided, principal._binding)) { OPENSSL_cleanse(current_pw_buf, sizeof(current_pw_buf)); OPENSSL_cleanse(new_pw_buf, sizeof(new_pw_buf)); return auth_error(p_arena, "403", "csrf_invalid", "CSRF token invalid"); } /* --- Password policy --- */ size_t new_pw_len = strlen(new_pw_raw); size_t cur_pw_len = strlen(current_pw_raw); if (new_pw_len < 12 || new_pw_len > AUTH_CRYPTO_PASSWORD_MAX_BYTES || cur_pw_len < 1 || cur_pw_len > AUTH_CRYPTO_PASSWORD_MAX_BYTES) { OPENSSL_cleanse(current_pw_raw, cur_pw_len); OPENSSL_cleanse(new_pw_raw, new_pw_len); return auth_error(p_arena, "400", "password_policy", "New password must be at least 12 characters"); } /* --- Fetch user with password hash --- */ char norm_username[AUTH_STORE_USERNAME_MAX + 1]; if (!Auth_Store_Normalize_Username( principal.username, norm_username, sizeof(norm_username))) { OPENSSL_cleanse(current_pw_raw, cur_pw_len); OPENSSL_cleanse(new_pw_raw, new_pw_len); return auth_error(p_arena, "500", "internal_error", "Username error"); } Auth_User_Auth_Record auth_record; memset(&auth_record, 0, sizeof(auth_record)); if (Auth_Store_Find_User_By_Username( g_auth_store, norm_username, &auth_record) != AUTH_STORE_OK) { OPENSSL_cleanse(current_pw_raw, cur_pw_len); OPENSSL_cleanse(new_pw_raw, new_pw_len); OPENSSL_cleanse(auth_record.password_hash, sizeof(auth_record.password_hash)); return auth_error(p_arena, "401", "invalid_credentials", "Invalid credentials"); } /* --- Verify current password --- */ Auth_Crypto_Result verify = Auth_Crypto_Password_Verify(current_pw_raw, auth_record.password_hash); OPENSSL_cleanse(current_pw_raw, cur_pw_len); if (verify != AUTH_CRYPTO_OK) { OPENSSL_cleanse(auth_record.password_hash, sizeof(auth_record.password_hash)); OPENSSL_cleanse(new_pw_raw, new_pw_len); return auth_error(p_arena, "401", "invalid_credentials", "Invalid credentials"); } /* --- Hash new password --- */ char new_encoded_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE]; Auth_Crypto_Result hash_result = Auth_Crypto_Password_Hash( new_pw_raw, new_encoded_hash, sizeof(new_encoded_hash)); OPENSSL_cleanse(new_pw_raw, new_pw_len); if (hash_result != AUTH_CRYPTO_OK) { OPENSSL_cleanse(auth_record.password_hash, sizeof(auth_record.password_hash)); OPENSSL_cleanse(new_encoded_hash, sizeof(new_encoded_hash)); return auth_error(p_arena, "500", "internal_error", "Hash error"); } /* Generate the replacement session before entering the transaction. */ char new_token[AUTH_CRYPTO_TOKEN_SIZE]; if (Auth_Crypto_Token_Generate(new_token, sizeof(new_token)) != AUTH_CRYPTO_OK) { OPENSSL_cleanse(auth_record.password_hash, sizeof(auth_record.password_hash)); OPENSSL_cleanse(new_encoded_hash, sizeof(new_encoded_hash)); return auth_error(p_arena, "500", "internal_error", "Token error"); } char new_token_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE]; if (Auth_Crypto_Token_Digest(new_token, new_token_digest, sizeof(new_token_digest)) != AUTH_CRYPTO_OK) { OPENSSL_cleanse(auth_record.password_hash, sizeof(auth_record.password_hash)); OPENSSL_cleanse(new_encoded_hash, sizeof(new_encoded_hash)); OPENSSL_cleanse(new_token, sizeof(new_token)); return auth_error(p_arena, "500", "internal_error", "Digest error"); } char new_csrf[AUTH_CRYPTO_TOKEN_SIZE]; if (!auth_derive_csrf(new_token_digest, new_csrf, sizeof(new_csrf))) { OPENSSL_cleanse(auth_record.password_hash, sizeof(auth_record.password_hash)); OPENSSL_cleanse(new_encoded_hash, sizeof(new_encoded_hash)); OPENSSL_cleanse(new_token, sizeof(new_token)); OPENSSL_cleanse(new_token_digest, sizeof(new_token_digest)); return auth_error(p_arena, "500", "internal_error", "CSRF error"); } char new_csrf_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE]; if (Auth_Crypto_Token_Digest(new_csrf, new_csrf_digest, sizeof(new_csrf_digest)) != AUTH_CRYPTO_OK) { OPENSSL_cleanse(auth_record.password_hash, sizeof(auth_record.password_hash)); OPENSSL_cleanse(new_encoded_hash, sizeof(new_encoded_hash)); OPENSSL_cleanse(new_token, sizeof(new_token)); OPENSSL_cleanse(new_token_digest, sizeof(new_token_digest)); OPENSSL_cleanse(new_csrf, sizeof(new_csrf)); return auth_error(p_arena, "500", "internal_error", "CSRF digest error"); } int64 now = auth_now(); Auth_Session_Record new_session; Auth_Store_Result password_result = Auth_Store_Self_Change_Password( g_auth_store, principal.user_id, auth_record.password_hash, new_encoded_hash, new_token_digest, new_csrf_digest, g_session_idle_ttl, g_session_abs_ttl, now, &new_session); OPENSSL_cleanse(auth_record.password_hash, sizeof(auth_record.password_hash)); OPENSSL_cleanse(new_encoded_hash, sizeof(new_encoded_hash)); OPENSSL_cleanse(new_csrf_digest, sizeof(new_csrf_digest)); OPENSSL_cleanse(new_token_digest, sizeof(new_token_digest)); if (password_result != AUTH_STORE_OK) { OPENSSL_cleanse(new_token, sizeof(new_token)); OPENSSL_cleanse(new_csrf, sizeof(new_csrf)); return auth_error(p_arena, "500", "internal_error", "Password update failed"); } /* --- Build response --- */ char *safe_username = Dowa_JSON_Escape_String(principal.username, 0, p_arena); char *safe_role = Dowa_JSON_Escape_String(principal.role, 0, p_arena); char *safe_csrf = Dowa_JSON_Escape_String(new_csrf, 0, p_arena); OPENSSL_cleanse(new_csrf, sizeof(new_csrf)); if (!safe_username || !safe_role || !safe_csrf) { OPENSSL_cleanse(new_token, sizeof(new_token)); return auth_error(p_arena, "500", "internal_error", "Encode error"); } char body_buf[512]; snprintf(body_buf, sizeof(body_buf), "{\"kind\":\"user\",\"username\":\"%s\",\"role\":\"%s\"," "\"mustChangePassword\":false,\"csrfToken\":\"%s\"," "\"quota\":null}", safe_username, safe_role, safe_csrf); char *body_copy = Dowa_Arena_Allocate(p_arena, strlen(body_buf) + 1); if (!body_copy) { OPENSSL_cleanse(new_token, sizeof(new_token)); return auth_error(p_arena, "500", "internal_error", "OOM"); } strcpy(body_copy, body_buf); /* Updated session cookie */ char session_cookie[SESSION_COOKIE_MAX]; auth_build_cookie_directive( AUTH_API_SESSION_COOKIE_NAME, new_token, 0, TRUE, session_cookie, sizeof(session_cookie)); OPENSSL_cleanse(new_token, sizeof(new_token)); char *session_cookie_copy = Dowa_Arena_Allocate(p_arena, strlen(session_cookie) + 1); if (session_cookie_copy) strcpy(session_cookie_copy, session_cookie); OPENSSL_cleanse(session_cookie, sizeof(session_cookie)); return auth_json_response_with_cookies( p_arena, "200", body_copy, session_cookie_copy, NULL); } /* ------------------------------------------------------------------ */ /* Route: GET /account/password */ /* ------------------------------------------------------------------ */ static Seobeo_Request_Entry *auth_password_page_handler( Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena) { (void)p_req; char *body = Dowa_Arena_Allocate(p_arena, 128 * 1024); if (!body || !Mjj_Template_Render_File(body, 128 * 1024, "/account/password.html", p_arena)) { Seobeo_Request_Entry *resp = NULL; Dowa_HashMap_Push_Arena(resp, "status", "500", p_arena); Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain; charset=utf-8", p_arena); Dowa_HashMap_Push_Arena(resp, "cache-control", "no-store", p_arena); Dowa_HashMap_Push_Arena(resp, "body", "Internal Server Error", p_arena); return resp; } Seobeo_Request_Entry *resp = NULL; Dowa_HashMap_Push_Arena(resp, "body", body, p_arena); Dowa_HashMap_Push_Arena( resp, "content-type", "text/html; charset=utf-8", p_arena); Dowa_HashMap_Push_Arena(resp, "cache-control", "no-store", p_arena); Dowa_HashMap_Push_Arena(resp, "pragma", "no-cache", p_arena); Dowa_HashMap_Push_Arena(resp, "x-content-type-options", "nosniff", p_arena); Dowa_HashMap_Push_Arena(resp, "referrer-policy", "no-referrer", p_arena); Dowa_HashMap_Push_Arena( resp, "content-security-policy", "frame-ancestors 'none'", p_arena); return resp; } /* ------------------------------------------------------------------ */ /* Public API */ /* ------------------------------------------------------------------ */ boolean Auth_API_Is_Forced_Password_Change_Only(const char *http_path) { if (!http_path) return FALSE; return strcmp(http_path, AUTH_API_PATH_SESSION) == 0 || strcmp(http_path, AUTH_API_PATH_LOGIN) == 0 || strcmp(http_path, AUTH_API_PATH_LOGOUT) == 0 || strcmp(http_path, AUTH_API_PATH_PASSWORD) == 0 || strcmp(http_path, AUTH_API_PATH_PASSWORD_PAGE) == 0; } void Auth_API_Register_Guest_Transfer_Hook( Auth_Guest_Transfer_Hook hook, void *context) { g_transfer_hook = hook; g_transfer_hook_ctx = context; } void Auth_API_Register_Guest_Quota_Cb(Auth_API_Guest_Quota_Cb cb) { g_guest_quota_cb = cb; } boolean Auth_API_Init( const char *database_path, const uint8 *cookie_secret, size_t cookie_secret_length, const char *bootstrap_username, const char *bootstrap_password_hash, const char *trusted_proxy_ip, int64 session_idle_ttl_secs, int64 session_absolute_ttl_secs, int64 guest_ttl_secs, boolean dev_insecure_cookie) { /* Fail closed: cookie secret required */ if (!cookie_secret || cookie_secret_length < AUTH_CRYPTO_COOKIE_SECRET_MIN_BYTES || cookie_secret_length > AUTH_CRYPTO_COOKIE_SECRET_MAX_BYTES) { Seobeo_Log(SEOBEO_ERROR, "[AUTH] Init failed: cookie secret missing or invalid length\n"); return FALSE; } if (!database_path || database_path[0] == '\0') { Seobeo_Log(SEOBEO_ERROR, "[AUTH] Init failed: database path required\n"); return FALSE; } g_auth_store = Auth_Store_Create(database_path); if (!g_auth_store) { Seobeo_Log(SEOBEO_ERROR, "[AUTH] Failed to open auth store\n"); return FALSE; } memcpy(g_cookie_secret, cookie_secret, cookie_secret_length); g_cookie_secret_length = cookie_secret_length; if (trusted_proxy_ip && trusted_proxy_ip[0] != '\0') { /* Canonicalize via inet_pton/inet_ntop; reject invalid addresses. */ struct in_addr addr4; struct in6_addr addr6; char canonical[AUTH_CRYPTO_IP_MAX_BYTES]; canonical[0] = '\0'; if (inet_pton(AF_INET, trusted_proxy_ip, &addr4) == 1) { if (!inet_ntop(AF_INET, &addr4, canonical, sizeof(canonical))) { Seobeo_Log(SEOBEO_ERROR, "[AUTH] Init failed: trusted proxy IPv4 canonicalization\n"); Auth_Store_Destroy(g_auth_store); g_auth_store = NULL; return FALSE; } } else if (inet_pton(AF_INET6, trusted_proxy_ip, &addr6) == 1) { if (!inet_ntop(AF_INET6, &addr6, canonical, sizeof(canonical))) { Seobeo_Log(SEOBEO_ERROR, "[AUTH] Init failed: trusted proxy IPv6 canonicalization\n"); Auth_Store_Destroy(g_auth_store); g_auth_store = NULL; return FALSE; } } else { Seobeo_Log(SEOBEO_ERROR, "[AUTH] Init failed: trusted proxy is not a valid IP address\n"); Auth_Store_Destroy(g_auth_store); g_auth_store = NULL; return FALSE; } strncpy(g_trusted_proxy_ip, canonical, sizeof(g_trusted_proxy_ip) - 1); g_trusted_proxy_ip[sizeof(g_trusted_proxy_ip) - 1] = '\0'; g_has_trusted_proxy = TRUE; } if (session_idle_ttl_secs > 0) g_session_idle_ttl = session_idle_ttl_secs; if (session_absolute_ttl_secs > 0) g_session_abs_ttl = session_absolute_ttl_secs; if (guest_ttl_secs > 0) g_guest_ttl = guest_ttl_secs; g_dev_insecure_cookie = dev_insecure_cookie; /* Bootstrap admin — fail closed on any store error. */ if (bootstrap_username && bootstrap_username[0] != '\0' && bootstrap_password_hash && bootstrap_password_hash[0] != '\0') { Auth_Store_Bootstrap_Result bootstrap_result; char bootstrap_id[37]; Auth_Store_Result r = Auth_Store_Bootstrap_Admin( g_auth_store, bootstrap_username, bootstrap_password_hash, &bootstrap_result, bootstrap_id); if (r == AUTH_STORE_OK && bootstrap_result == AUTH_STORE_BOOTSTRAP_CREATED) { Seobeo_Log(SEOBEO_INFO, "[AUTH] Bootstrap admin created\n"); } else if (r == AUTH_STORE_OK && bootstrap_result == AUTH_STORE_BOOTSTRAP_ALREADY_PRESENT) { Seobeo_Log(SEOBEO_INFO, "[AUTH] Bootstrap admin already present\n"); } else { Seobeo_Log(SEOBEO_ERROR, "[AUTH] Bootstrap admin failed: store error %d — refusing to start\n", r); OPENSSL_cleanse(g_cookie_secret, sizeof(g_cookie_secret)); g_cookie_secret_length = 0; g_has_trusted_proxy = FALSE; Auth_Store_Destroy(g_auth_store); g_auth_store = NULL; return FALSE; } } Seobeo_Log(SEOBEO_INFO, "[AUTH] Initialised (dev_insecure=%s)\n", dev_insecure_cookie ? "yes" : "no"); return TRUE; } void Auth_API_Destroy(void) { if (g_auth_store) { Auth_Store_Destroy(g_auth_store); g_auth_store = NULL; } OPENSSL_cleanse(g_cookie_secret, sizeof(g_cookie_secret)); g_cookie_secret_length = 0; g_has_trusted_proxy = FALSE; g_transfer_hook = NULL; g_transfer_hook_ctx = NULL; g_guest_quota_cb = NULL; #ifdef AUTH_API_TEST_HOOKS g_login_pre_create_hook = NULL; g_login_pre_create_context = NULL; #endif } void Auth_API_Register_Routes(void) { Seobeo_Router_Register("GET", "/api/auth/session", auth_session_handler); Seobeo_Router_Register("POST", "/api/auth/login", auth_login_handler); Seobeo_Router_Register("POST", "/api/auth/logout", auth_logout_handler); Seobeo_Router_Register("POST", "/api/auth/password", auth_password_handler); Seobeo_Router_Register("GET", "/account/password", auth_password_page_handler); } Auth_Store *Auth_API_Get_Store(void) { return g_auth_store; } /* ------------------------------------------------------------------ */ /* Test hooks (compiled in only for test builds) */ /* ------------------------------------------------------------------ */ #ifdef AUTH_API_TEST_HOOKS void Auth_API_Test_Set_Login_Pre_Create_Hook( Auth_API_Test_Login_Pre_Create_Hook hook, void *p_context) { g_login_pre_create_hook = hook; g_login_pre_create_context = p_context; } Seobeo_Request_Entry *Auth_API_Test_Session_Handler( Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena) { return auth_session_handler(p_req, p_arena); } Seobeo_Request_Entry *Auth_API_Test_Login_Handler( Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena) { return auth_login_handler(p_req, p_arena); } Seobeo_Request_Entry *Auth_API_Test_Logout_Handler( Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena) { return auth_logout_handler(p_req, p_arena); } Seobeo_Request_Entry *Auth_API_Test_Password_Handler( Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena) { return auth_password_handler(p_req, p_arena); } #endif /* AUTH_API_TEST_HOOKS */