feat: split admin login and client api keys

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-11 21:05:47 +08:00
co-authored by Codex
parent ffec8b5ad7
commit 24d579944e
9 changed files with 144 additions and 6 deletions
+1 -1
View File
@@ -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<AppServices>` 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 | | | |
@@ -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
+20
View File
@@ -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<String> {
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<bool> {
let parsed = PasswordHash::new(hash)?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok())
}
+72
View File
@@ -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<Sha256>;
#[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<Self> {
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::<String>();
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<bool> {
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}"),
}
}
}
+19
View File
@@ -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<T> = Result<T, AuthError>;
impl From<argon2::password_hash::Error> for AuthError {
fn from(value: argon2::password_hash::Error) -> Self {
Self::PasswordHash(value.to_string())
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod admin_session;
pub mod api_key;
pub mod error;
+1
View File
@@ -1,3 +1,4 @@
pub mod auth;
pub mod config;
pub mod error;
pub mod state;
+8
View File
@@ -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());
}
+15
View File
@@ -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());
}