7.1 KiB
Authentication Flow
cg.cx uses a simple password-and-cookie authentication model. There is no user account system; access is granted per-content-item by knowing its password.
AuthSource Enum
The server tracks how a request was authenticated using the AuthSource enum:
enum AuthSource {
Cookie, // Request presented a valid cgcx_pw cookie
QueryParam, // Request presented a correct ?sc=PASSWORD query param
}
This distinction matters because the server only issues a new cookie when auth succeeds via QueryParam. Cookie-based auth does not re-issue the cookie (the browser already has it).
How Direct Links with ?sc=PASSWORD Work
- A client requests a protected endpoint (e.g.,
GET /api/content/:cxidorGET /api/content/:cxid/file/:file_idx) with the password in the query string:GET /api/content/abc123?sc=MySecretPassword - The handler calls
password_from_request(...). - Inside
password_from_request, thescquery parameter is checked first:- If
scis present and the content has apassword_hash, the server verifies the supplied password against the stored Argon2 hash. - If verification succeeds,
password_from_requestreturnsSome(AuthSource::QueryParam).
- If
- The handler proceeds with the request.
- On the response path, the handler calls
add_auth_cookie(...). - Because the auth source is
QueryParam,add_auth_cookiegenerates a new HMAC-signedcgcx_pwcookie and attaches it via aSet-Cookieheader. - The client’s browser stores the cookie. Subsequent requests to the same domain automatically include it, so the
?sc=...parameter is no longer needed.
How the Server Validates Passwords (Argon2)
Password verification happens in two places: password_from_request and the verify_password endpoint.
Stored Hash Format
Password hashes are stored in the database as Argon2id hashes in the standard PHC string format (e.g., $argon2id$v=19$m=65536,t=3,p=4$...).
Verification
use argon2::{Argon2, PasswordHash, PasswordVerifier};
let parsed_hash = PasswordHash::new(&hash)?;
let valid = Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok();
- Uses the default Argon2 parameters.
- If parsing the stored hash fails, verification is treated as failed.
- If verification fails, the server returns
401 Unauthorized.
How Cookies Are Set After Successful Query-Param Auth
After a successful ?sc=... authentication, the response includes:
Set-Cookie: cgcx_pw=<value>; Max-Age=3600; SameSite=Strict; HttpOnly; Path=/
- Name:
cgcx_pw - Max-Age: 3600 seconds (1 hour)
- SameSite:
Strict - HttpOnly:
true(not accessible to JavaScript) - Path:
/
The cookie value is generated by make_cookie_value(cxid, cookie_secret) (see Cookie Format below).
The same cookie is also set by the POST /api/content/:cxid/verify-password endpoint after a successful JSON password verification.
How Subsequent Requests Use the Cookie
- The browser automatically sends the
cgcx_pwcookie on every request to the same origin. - When
password_from_requestis called:- It first checks for
scquery param auth. - If that fails or is absent, it scans all
Cookieheaders (there may be multiple). - For each cookie header, it splits on
;and looks for a part starting withcgcx_pw=. - When found, it calls
verify_cookie(cxid, &part[8..], cookie_secret). - If
verify_cookiereturnstrue,password_from_requestreturnsSome(AuthSource::Cookie).
- It first checks for
- Because the auth source is
Cookie,add_auth_cookiedoes not add a newSet-Cookieheader.
Cookie Format (HMAC-Signed)
The cookie value is a Base64-encoded string of the form:
base64(cxid + ":" + hmac_sha256(cxid, secret))
Building the Cookie (make_cookie_value)
fn make_cookie_value(cxid: &str, secret: &[u8]) -> String {
let mac = hmac_cookie(cxid, secret); // HMAC-SHA256 of cxid
let mut raw = Vec::with_capacity(cxid.len() + 1 + mac.len());
raw.extend_from_slice(cxid.as_bytes());
raw.push(b':');
raw.extend_from_slice(&mac);
base64::engine::general_purpose::STANDARD.encode(&raw)
}
Verifying the Cookie (verify_cookie)
fn verify_cookie(cxid: &str, cookie_value: &str, secret: &[u8]) -> bool {
let decoded = base64_decode(cookie_value)?;
let mut parts = decoded.splitn(2, |&b| b == b':');
let decoded_cxid = std::str::from_utf8(parts.next()?)?;
let mac_bytes = parts.next()?;
if decoded_cxid != cxid {
return false;
}
let expected = hmac_cookie(cxid, secret);
if mac_bytes.len() != expected.len() {
return false;
}
// Constant-time comparison to prevent timing attacks
mac_bytes.ct_eq(&expected).into()
}
Cookie Secret
The HMAC key (cookie_secret) is derived from the master encryption key at startup:
let cookie_secret = blake3::hash(master_key.as_bytes()).as_bytes().to_vec();
This means:
- The cookie secret is deterministic for a given master key.
- Cookies are bound to a specific server instance (or cluster sharing the same master key).
- Changing the master key invalidates all existing auth cookies.
Security Properties
- Binding: The HMAC includes the
cxid, so a cookie for one piece of content cannot be replayed against another. - Tamper resistance: Without the
cookie_secret, an attacker cannot forge a validcgcx_pwcookie. - Timing safety: Verification uses
subtle::ConstantTimeEqto avoid leaking information via timing side channels.
Summary Flow Diagram
Client Request
│
▼
┌─────────────────────────────────────────┐
│ Does the content have a password? │
└─────────────────────────────────────────┘
│
├─ No ──► Proceed (no auth needed)
│
└─ Yes
│
▼
┌─────────────────────────────────────────┐
│ Is ?sc=PASSWORD present and correct? │
│ └─ Argon2 verify against stored hash │
└─────────────────────────────────────────┘
│
├─ Yes ──► AuthSource::QueryParam
│ └─ Set cgcx_pw cookie on response
│
└─ No
│
▼
┌─────────────────────────────────────────┐
│ Is cgcx_pw cookie present and valid? │
│ └─ HMAC-SHA256 verify against secret │
└─────────────────────────────────────────┘
│
├─ Yes ──► AuthSource::Cookie
│ └─ Proceed without setting new cookie
│
└─ No ──► 401 Unauthorized