From 24d579944e858c956bc43983c4d6d1effe0f7859 Mon Sep 17 00:00:00 2001 From: zyycn Date: Thu, 11 Jun 2026 21:05:47 +0800 Subject: [PATCH] feat: split admin login and client api keys Co-authored-by: Codex --- docs/implementation-status.md | 2 +- .../plans/2026-06-11-codex-proxy-rs.md | 10 +-- src/auth/admin_session.rs | 20 ++++++ src/auth/api_key.rs | 72 +++++++++++++++++++ src/auth/error.rs | 19 +++++ src/auth/mod.rs | 3 + src/lib.rs | 1 + tests/admin_auth_test.rs | 8 +++ tests/api_key_auth_test.rs | 15 ++++ 9 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 src/auth/admin_session.rs create mode 100644 src/auth/api_key.rs create mode 100644 src/auth/error.rs create mode 100644 src/auth/mod.rs create mode 100644 tests/admin_auth_test.rs create mode 100644 tests/api_key_auth_test.rs diff --git a/docs/implementation-status.md b/docs/implementation-status.md index b97783516..1b83be15a 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -8,7 +8,7 @@ Update this file before each feature commit. | API contract docs | Completed | initial scaffold commit | `cargo fmt --check`; `cargo test`; `cargo clippy --all-targets --all-features --locked -- -D warnings` | `/v1/*` OpenAI-compatible body, `/admin/*` frontend envelope, body codes, camelCase, and request ID policy are documented. | | Configuration | Completed | pending config commit | `cargo test default_config_keeps_only_codex_backend`; full `fmt/test/clippy` before commit | Defines Codex-only config, default YAML, and `Arc` state shell. | | SQLite storage | Completed | pending storage commit | `cargo test migrations_create_accounts_and_event_tables`; full `fmt/test/clippy` before commit | Creates SQLite WAL connector, migrations for accounts/API keys/sessions/cookies/fingerprints/events, and event/account indexes. | -| Admin auth and client API keys | Planned | | | | +| Admin auth and client API keys | Completed | pending auth commit | `cargo test client_api_key_has_proxy_prefix_and_verifies_against_hash`; `cargo test admin_password_hash_is_not_a_client_api_key`; full `fmt/test/clippy` before commit | Admin passwords use Argon2id, client API keys use `cpr_` prefix plus HMAC-SHA256 with server-side pepper. | | Secret encryption | Planned | | | | | Logging and pagination | Planned | | | | | TLS headers and fingerprint | Planned | | | | diff --git a/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md b/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md index d57253b06..423f29deb 100644 --- a/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md +++ b/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md @@ -1133,7 +1133,7 @@ git commit -m "feat: add sqlite storage schema" - Test: `/home/zyy/桌面/Codes/codex-proxy-rs/tests/admin_auth_test.rs` - Test: `/home/zyy/桌面/Codes/codex-proxy-rs/tests/api_key_auth_test.rs` -- [ ] **Step 1: Write failing tests for separate secret types** +- [x] **Step 1: Write failing tests for separate secret types** Create `/home/zyy/桌面/Codes/codex-proxy-rs/tests/api_key_auth_test.rs`: @@ -1164,7 +1164,7 @@ fn admin_password_hash_is_not_a_client_api_key() { } ``` -- [ ] **Step 2: Run tests and verify RED** +- [x] **Step 2: Run tests and verify RED** Run: @@ -1174,7 +1174,7 @@ cargo test client_api_key_has_proxy_prefix_and_verifies_against_hash admin_passw Expected: compile failure because auth modules do not exist. -- [ ] **Step 3: Implement hashing helpers** +- [x] **Step 3: Implement hashing helpers** Modify `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs`: @@ -1295,7 +1295,7 @@ impl ApiKeyHasher { } ``` -- [ ] **Step 4: Verify auth split tests** +- [x] **Step 4: Verify auth split tests** Run: @@ -1305,7 +1305,7 @@ cargo test client_api_key_has_proxy_prefix_and_verifies_against_hash admin_passw Expected: pass. -- [ ] **Step 5: Commit auth split** +- [x] **Step 5: Commit auth split** ```bash git add Cargo.toml src/auth src/lib.rs tests/admin_auth_test.rs tests/api_key_auth_test.rs diff --git a/src/auth/admin_session.rs b/src/auth/admin_session.rs new file mode 100644 index 000000000..01ba32358 --- /dev/null +++ b/src/auth/admin_session.rs @@ -0,0 +1,20 @@ +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, +}; + +use crate::auth::error::AuthResult; + +pub fn hash_admin_password(password: &str) -> AuthResult { + let salt = SaltString::generate(&mut OsRng); + Ok(Argon2::default() + .hash_password(password.as_bytes(), &salt)? + .to_string()) +} + +pub fn verify_admin_password(password: &str, hash: &str) -> AuthResult { + let parsed = PasswordHash::new(hash)?; + Ok(Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .is_ok()) +} diff --git a/src/auth/api_key.rs b/src/auth/api_key.rs new file mode 100644 index 000000000..886353891 --- /dev/null +++ b/src/auth/api_key.rs @@ -0,0 +1,72 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use hmac::{Hmac, KeyInit, Mac}; +use rand::Rng; +use sha2::Sha256; + +use crate::auth::error::{AuthError, AuthResult}; + +type HmacSha256 = Hmac; + +#[derive(Debug, Clone)] +pub struct GeneratedClientApiKey { + pub plaintext: String, + pub prefix: String, + pub key_hash: String, +} + +#[derive(Debug, Clone)] +pub struct ApiKeyHasher { + pepper: [u8; 32], +} + +impl ApiKeyHasher { + pub fn new(pepper: [u8; 32]) -> Self { + Self { pepper } + } + + pub fn try_from_slice(pepper: &[u8]) -> AuthResult { + let pepper: [u8; 32] = pepper + .try_into() + .map_err(|_| AuthError::InvalidPepperLength)?; + Ok(Self::new(pepper)) + } + + pub fn generate_client_api_key(&self, _name: &str) -> GeneratedClientApiKey { + let mut bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut bytes); + let plaintext = format!("cpr_{}", URL_SAFE_NO_PAD.encode(bytes)); + let prefix = plaintext.chars().take(12).collect::(); + let key_hash = self.hash_client_api_key(&plaintext); + GeneratedClientApiKey { + plaintext, + prefix, + key_hash, + } + } + + pub fn hash_client_api_key(&self, plaintext: &str) -> String { + let mut mac = self.new_mac(); + mac.update(plaintext.as_bytes()); + URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()) + } + + pub fn verify_client_api_key(&self, plaintext: &str, key_hash: &str) -> AuthResult { + let Some(suffix) = plaintext.strip_prefix("cpr_") else { + return Ok(false); + }; + + let decoded_suffix = URL_SAFE_NO_PAD.decode(suffix)?; + let canonical = format!("cpr_{}", URL_SAFE_NO_PAD.encode(decoded_suffix)); + let expected = URL_SAFE_NO_PAD.decode(key_hash)?; + let mut mac = self.new_mac(); + mac.update(canonical.as_bytes()); + Ok(mac.verify_slice(&expected).is_ok()) + } + + fn new_mac(&self) -> HmacSha256 { + match HmacSha256::new_from_slice(&self.pepper) { + Ok(mac) => mac, + Err(error) => unreachable!("HMAC accepts any key size: {error}"), + } + } +} diff --git a/src/auth/error.rs b/src/auth/error.rs new file mode 100644 index 000000000..9d2aff6a6 --- /dev/null +++ b/src/auth/error.rs @@ -0,0 +1,19 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AuthError { + #[error("password hash error: {0}")] + PasswordHash(String), + #[error("invalid api key encoding: {0}")] + ApiKeyEncoding(#[from] base64::DecodeError), + #[error("invalid api key pepper length")] + InvalidPepperLength, +} + +pub type AuthResult = Result; + +impl From for AuthError { + fn from(value: argon2::password_hash::Error) -> Self { + Self::PasswordHash(value.to_string()) + } +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 000000000..4db0d1d2c --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,3 @@ +pub mod admin_session; +pub mod api_key; +pub mod error; diff --git a/src/lib.rs b/src/lib.rs index 14c3e968f..3e9bb0e38 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod auth; pub mod config; pub mod error; pub mod state; diff --git a/tests/admin_auth_test.rs b/tests/admin_auth_test.rs new file mode 100644 index 000000000..713aeb90f --- /dev/null +++ b/tests/admin_auth_test.rs @@ -0,0 +1,8 @@ +use codex_proxy_rs::auth::admin_session::{hash_admin_password, verify_admin_password}; + +#[test] +fn admin_password_hash_is_not_a_client_api_key() { + let hash = hash_admin_password("correct horse battery staple").unwrap(); + assert!(verify_admin_password("correct horse battery staple", &hash).unwrap()); + assert!(!verify_admin_password("cpr_fake_client_key", &hash).unwrap()); +} diff --git a/tests/api_key_auth_test.rs b/tests/api_key_auth_test.rs new file mode 100644 index 000000000..089fd509e --- /dev/null +++ b/tests/api_key_auth_test.rs @@ -0,0 +1,15 @@ +use codex_proxy_rs::auth::api_key::ApiKeyHasher; + +#[test] +fn client_api_key_has_proxy_prefix_and_verifies_against_hash() { + let hasher = ApiKeyHasher::new([9u8; 32]); + let generated = hasher.generate_client_api_key("cursor"); + assert!(generated.plaintext.starts_with("cpr_")); + assert_eq!(generated.prefix.len(), 12); + assert!(hasher + .verify_client_api_key(&generated.plaintext, &generated.key_hash) + .unwrap()); + assert!(!hasher + .verify_client_api_key("wrong", &generated.key_hash) + .unwrap()); +}