Huge refactor, submission system addition & security improvements. +Implementation of moderation cmds.
This commit is contained in:
194
docs/AUTH_FLOW.md
Normal file
194
docs/AUTH_FLOW.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# 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:
|
||||
|
||||
```rust
|
||||
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
|
||||
|
||||
1. A client requests a protected endpoint (e.g., `GET /api/content/:cxid` or `GET /api/content/:cxid/file/:file_idx`) with the password in the query string:
|
||||
```
|
||||
GET /api/content/abc123?sc=MySecretPassword
|
||||
```
|
||||
2. The handler calls `password_from_request(...)`.
|
||||
3. Inside `password_from_request`, the `sc` query parameter is checked **first**:
|
||||
- If `sc` is present and the content has a `password_hash`, the server verifies the supplied password against the stored Argon2 hash.
|
||||
- If verification succeeds, `password_from_request` returns `Some(AuthSource::QueryParam)`.
|
||||
4. The handler proceeds with the request.
|
||||
5. On the response path, the handler calls `add_auth_cookie(...)`.
|
||||
6. Because the auth source is `QueryParam`, `add_auth_cookie` generates a new HMAC-signed `cgcx_pw` cookie and attaches it via a `Set-Cookie` header.
|
||||
7. 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
|
||||
```rust
|
||||
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:
|
||||
|
||||
```http
|
||||
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](#cookie-format-hmac-signed) 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
|
||||
|
||||
1. The browser automatically sends the `cgcx_pw` cookie on every request to the same origin.
|
||||
2. When `password_from_request` is called:
|
||||
- It first checks for `sc` query param auth.
|
||||
- If that fails or is absent, it scans **all** `Cookie` headers (there may be multiple).
|
||||
- For each cookie header, it splits on `;` and looks for a part starting with `cgcx_pw=`.
|
||||
- When found, it calls `verify_cookie(cxid, &part[8..], cookie_secret)`.
|
||||
- If `verify_cookie` returns `true`, `password_from_request` returns `Some(AuthSource::Cookie)`.
|
||||
3. Because the auth source is `Cookie`, `add_auth_cookie` does **not** add a new `Set-Cookie` header.
|
||||
|
||||
---
|
||||
|
||||
## 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`)
|
||||
|
||||
```rust
|
||||
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`)
|
||||
|
||||
```rust
|
||||
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:
|
||||
|
||||
```rust
|
||||
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 valid `cgcx_pw` cookie.
|
||||
- **Timing safety:** Verification uses `subtle::ConstantTimeEq` to 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
|
||||
```
|
||||
Reference in New Issue
Block a user