diff --git a/Cargo.lock b/Cargo.lock index 5e6c89c..002c4f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -337,6 +337,7 @@ dependencies = [ "hex", "jsonwebtoken", "openssl", + "pem", "redis", "reqwest 0.13.2", "rustls", diff --git a/Cargo.toml b/Cargo.toml index e204aa2..e034881 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ futures = "0.3.31" hex = { version = "0.4.3", features = ["serde"] } jsonwebtoken = "10.3.0" openssl = { version = "0.10" } +pem = "3.0.6" redis = { version = "0.32.6", features = ["aio", "tokio-comp", "bb8"] } reqwest = { version = "0.13.2" } rustls = "0.23.35" diff --git a/server/Cargo.toml b/server/Cargo.toml index 1e49f38..c0d3a31 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -36,6 +36,7 @@ futures = { workspace = true } hex = { workspace = true } jsonwebtoken = { workspace = true, features = ["aws_lc_rs", "use_pem"] } openssl = { workspace = true, features = ["vendored"], optional = true } +pem = { workspace = true } redis = { workspace = true } reqwest = { workspace = true, features = [ "json", diff --git a/server/auth_server.dev.toml b/server/auth_server.dev.toml index 1fcf54a..e02aa61 100644 --- a/server/auth_server.dev.toml +++ b/server/auth_server.dev.toml @@ -36,6 +36,11 @@ admin_ui_path = "admin-ui/dist" # --------------------------------------------------------------------------- roles = ["SuperAdmin", "DomainAdmin", "CryptoOfficer", "Auditor", "User"] +# Pre-configure a fixed TOTP secret for the basic admin ("admin" / realm "_"). +# Use any Base32 TOTP secret you generate once, e.g.: +# python3 -c "import base64, os; print(base64.b32encode(os.urandom(20)).decode())" +# admin_totp_secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + # --------------------------------------------------------------------------- # TLS — uses the test EC P-256 certificates bundled with the source tree. # These are self-signed and must NOT be used in production. @@ -68,3 +73,10 @@ auto_init_schema = true realm_id = "dev-realm" admin_username = "realm-admin" admin_password = "change_me" + +# TOTP-enabled user for testing two-factor authentication. +# Use the secret below with any TOTP app (Google Authenticator, Authy, etc.). +# Login requires both the password and the current TOTP code. +totp_username = "totp-user" +totp_password = "change_me" +totp_secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" diff --git a/server/documentation/api_reference.md b/server/documentation/api_reference.md index 6dd9f04..509c643 100644 --- a/server/documentation/api_reference.md +++ b/server/documentation/api_reference.md @@ -40,7 +40,7 @@ Content-Type: text/plain --- -### `GET /public/jwks` +### `GET /.well-known/jwks.json` Returns the JSON Web Key Set containing the server's public key(s) for JWT signature verification. diff --git a/server/documentation/authentication_flows.md b/server/documentation/authentication_flows.md index aa09f05..1ef0a82 100644 --- a/server/documentation/authentication_flows.md +++ b/server/documentation/authentication_flows.md @@ -143,7 +143,7 @@ sequenceDiagram C->>API: GET /api/resource
Authorization: Bearer eyJhbG… note over API: JwtAuth middleware - API->>EA: GET /public/jwks (key discovery) + API->>EA: GET /.well-known/jwks.json (key discovery) EA-->>API: JWKS (public keys) note over API: Validates JWT signature + exp + aud API->>SS: upsert_session(jwt_claims) @@ -153,7 +153,7 @@ sequenceDiagram ### Key discovery -The server exposes a JWKS endpoint at `GET /public/jwks` which returns the RSA/EC public keys used to verify tokens. The JWT middleware caches this document and refreshes it on a configurable interval. +The server exposes a JWKS endpoint at `GET /.well-known/jwks.json` which returns the RSA/EC public keys used to verify tokens. The JWT middleware caches this document and refreshes it on a configurable interval. ### JWT requirements @@ -243,7 +243,7 @@ DELETE /sessions/realms/{realm_id} | `POST` | `/login?realm={realm}` | Authenticate and issue a session cookie | No (credentials in body) | | `GET` | `/whoami?realm={realm}` | Return the current session's claims | Session cookie | | `GET` | `/public/version` | Server version string | No | -| `GET` | `/public/jwks` | JSON Web Key Set for JWT verification | No | +| `GET` | `/.well-known/jwks.json` | JSON Web Key Set for JWT verification | No | ### Session management endpoints diff --git a/server/documentation/authorization_and_administration.md b/server/documentation/authorization_and_administration.md index 0c34e6f..6abdd2a 100644 --- a/server/documentation/authorization_and_administration.md +++ b/server/documentation/authorization_and_administration.md @@ -142,7 +142,7 @@ All `/realms/{realm}/userpass` endpoints require `can_administer_realm(realm)`. | Method | Endpoint | Anyone | |--------|---------|:------:| | `GET` | `/public/version` | ✅ | -| `GET` | `/public/jwks` | ✅ | +| `GET` | `/.well-known/jwks.json` | ✅ | | `POST` | `/login` | ✅ | | `GET` | `/whoami` | ✅ (no `AdminAuth`) | diff --git a/server/documentation/openapi.yaml b/server/documentation/openapi.yaml index 24c20b6..4a2d611 100644 --- a/server/documentation/openapi.yaml +++ b/server/documentation/openapi.yaml @@ -554,14 +554,14 @@ paths: example: version: 1.0.0 - /public/jwks: + /.well-known/jwks.json: get: tags: [Public] - summary: JSON Web Key Set (test builds only) + summary: JSON Web Key Set operationId: getJwks responses: '200': - description: JWKS document. + description: JWKS document containing the server's EC P-256 signing public key. content: application/json: schema: @@ -571,13 +571,23 @@ paths: type: array items: type: object + properties: + kty: { type: string } + crv: { type: string } + use: { type: string } + alg: { type: string } + kid: { type: string } + x: { type: string } + y: { type: string } example: keys: - kty: EC crv: P-256 + use: sig + alg: ES256 + kid: a3f1c2d4e5b6... x: f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU y: x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0 - use: sig /login: post: diff --git a/server/src/server/auth_server.rs b/server/src/server/auth_server.rs index 623430c..a547a5e 100644 --- a/server/src/server/auth_server.rs +++ b/server/src/server/auth_server.rs @@ -7,14 +7,14 @@ use crate::{ add_admin_to_realm, create_admin, create_realm, create_userpass, delete_admin, delete_expired_sessions, delete_realm, delete_sessions, delete_sessions_for_realm, delete_userpass, get_admin, get_realm, get_session, get_session_by_id, - get_sessions_for_clients, get_userpass, list_admins, list_all_userpass, list_realms, - list_userpass_by_realm, login, remove_admin_from_realm, roles_endpoint, totp_disable, - totp_generate, totp_verify, update_admin, update_realm, update_userpass, - upsert_session, version_endpoint, whoami, + get_sessions_for_clients, get_userpass, jwks_well_known, list_admins, + list_all_userpass, list_realms, list_userpass_by_realm, login, remove_admin_from_realm, + roles_endpoint, totp_disable, totp_generate, totp_verify, update_admin, update_realm, + update_userpass, upsert_session, version_endpoint, whoami, }, - parameters::{DatabaseBackend, DatabaseParams, DevSeedParams, ServerParams}, + parameters::{DatabaseBackend, DatabaseParams, ServerParams}, }, - session::{self, JwtTokenConfig}, + session::{self, JwksData, JwtTokenConfig}, }; use actix_cors::Cors; use actix_web::{ @@ -36,96 +36,6 @@ use crate::tls::openssl_config::{create_openssl_acceptor, extract_openssl_peer_c #[cfg(feature = "rustls")] use crate::tls::rustls_config::{extract_rustls_peer_certificate, rustls_server_config}; -/// Seeds a realm and a realm-scoped admin account for development use. -/// All operations are idempotent — nothing is overwritten if it already exists. -async fn seed_dev_realm_admin( - db: &dyn crate::database::Database, - seed: &DevSeedParams, -) -> AuthResult<()> { - use crate::database::hash_password_with_argon2; - use crate::models::{ADMIN_REALM, Admin, Realm, UserPass}; - use crate::{RealmAuthParams, UsernamePasswordParams}; - - // 1. Create the realm if it does not exist. - if db.get_realm(&seed.realm_id).await?.is_none() { - let realm = Realm { - id: seed.realm_id.clone(), - auth_params: RealmAuthParams { - username_password_params: Some(UsernamePasswordParams { - allow_expired_passwords: false, - }), - ..Default::default() - }, - session_max_age_seconds: 3600, - session_max_stale_age_seconds: 3600, - }; - db.create_realm(&realm).await.map_err(|e| { - crate::AuthError::Init(format!( - "dev_seed: failed to create realm '{}': {e}", - seed.realm_id - )) - })?; - info!("dev_seed: created realm '{}'", seed.realm_id); - } - - // 2. Create the credential in the admin realm if it does not exist. - if db - .get_userpass(ADMIN_REALM, &seed.admin_username) - .await? - .is_none() - { - let hashed = hash_password_with_argon2(&seed.admin_username, &seed.admin_password) - .map_err(|e| { - crate::AuthError::Init(format!( - "dev_seed: failed to hash password for '{}': {e}", - seed.admin_username - )) - })?; - let userpass = UserPass { - realm: ADMIN_REALM.to_string(), - username: seed.admin_username.clone(), - password: hashed, - change_password: true, - roles: Vec::new(), - }; - db.create_userpass(&userpass).await.map_err(|e| { - crate::AuthError::Init(format!( - "dev_seed: failed to create credential for '{}': {e}", - seed.admin_username - )) - })?; - info!("dev_seed: created credential for '{}'", seed.admin_username); - } - - // 3. Create the admin record if it does not exist. - if db.get_admin(&seed.admin_username).await?.is_none() { - let admin = Admin { - id: seed.admin_username.clone(), - realms: vec![seed.realm_id.clone()], - userpass: Some(seed.admin_username.clone()), - jwt: None, - fido2: None, - digital_credentials: None, - client_certificate: None, - totp_enabled: None, - totp_secret: None, - totp_auth_url: None, - }; - db.create_admin(&admin).await.map_err(|e| { - crate::AuthError::Init(format!( - "dev_seed: failed to create admin '{}': {e}", - seed.admin_username - )) - })?; - info!( - "dev_seed: created realm-admin '{}' for realm '{}'", - seed.admin_username, seed.realm_id - ); - } - - Ok(()) -} - /// Inner function to start the test server asynchronously. pub async fn start_auth_server( server_params: Arc, @@ -181,6 +91,7 @@ async fn prepare_auth_server( // If a dev_seed config is present, ensure the seeded realm and realm-admin exist. if let Some(seed) = ¶ms.dev_seed { + use crate::server::dev_seed::seed_dev_realm_admin; seed_dev_realm_admin(database.as_ref(), seed).await?; } @@ -215,6 +126,23 @@ async fn prepare_auth_server( decoding_key: params.get_jwt_decoding_key()?, }); + // Build the JWKS document once from the JWT signing public key. + // Reads the same PEM path that get_jwt_decoding_key() uses. + let jwks_pem_path = params + .session_jwt_params + .as_ref() + .map(|p| p.jwt_ec_public_key.clone()) + .unwrap_or_else(|| params.tls_params.server_certificate.clone()); + let jwks_pem = std::fs::read_to_string(&jwks_pem_path).map_err(|e| { + crate::AuthError::Init(format!( + "Failed to read JWT public key PEM for JWKS ({jwks_pem_path}): {e}" + )) + })?; + let jwks_data = Arc::new( + crate::session::build_jwks_from_pem(&jwks_pem) + .map_err(|e| crate::AuthError::Init(format!("Failed to build JWKS: {e}")))?, + ); + // Clone test server params for HttpServer closure let server_params = params.clone(); @@ -229,6 +157,7 @@ async fn prepare_auth_server( jwks_manager.clone(), default_username.clone(), jwt_token_config.clone(), + jwks_data.clone(), ) }); let http_server = http_server @@ -268,6 +197,7 @@ fn build_app( jwks_manager: Arc, default_username: Option, jwt_token_config: Arc, + jwks_data: Arc, ) -> App< impl ServiceFactory< ServiceRequest, @@ -285,8 +215,10 @@ fn build_app( .app_data(Data::new(database.clone())) .app_data(Data::new(session_store.clone())) .app_data(Data::new(jwt_token_config.clone())) + .app_data(Data::new(jwks_data.clone())) .app_data(PayloadConfig::new(1_000_000)) - .app_data(JsonConfig::default().limit(1_000_000)); + .app_data(JsonConfig::default().limit(1_000_000)) + .route("/.well-known/jwks.json", web::get().to(jwks_well_known)); #[cfg(test)] let app = { diff --git a/server/src/server/dev_seed.rs b/server/src/server/dev_seed.rs new file mode 100644 index 0000000..7363131 --- /dev/null +++ b/server/src/server/dev_seed.rs @@ -0,0 +1,189 @@ +use crate::{ + AuthResult, + database::{Database, hash_password_with_argon2}, + models::{ADMIN_REALM, Admin, Realm, UserPass}, + server::parameters::DevSeedParams, + {RealmAuthParams, UsernamePasswordParams}, +}; +use cosmian_logger::info; + +/// Seeds a realm and a realm-scoped admin account for development use. +/// All operations are idempotent — nothing is overwritten if it already exists. +pub(super) async fn seed_dev_realm_admin( + db: &dyn Database, + seed: &DevSeedParams, +) -> AuthResult<()> { + // 1. Create the realm if it does not exist. + if db.get_realm(&seed.realm_id).await?.is_none() { + let realm = Realm { + id: seed.realm_id.clone(), + auth_params: RealmAuthParams { + username_password_params: Some(UsernamePasswordParams { + allow_expired_passwords: false, + }), + ..Default::default() + }, + session_max_age_seconds: 3600, + session_max_stale_age_seconds: 3600, + }; + db.create_realm(&realm).await.map_err(|e| { + crate::AuthError::Init(format!( + "dev_seed: failed to create realm '{}': {e}", + seed.realm_id + )) + })?; + info!("dev_seed: created realm '{}'", seed.realm_id); + } + + // 2. Create the credential in the admin realm if it does not exist. + if db + .get_userpass(ADMIN_REALM, &seed.admin_username) + .await? + .is_none() + { + let hashed = hash_password_with_argon2(&seed.admin_username, &seed.admin_password) + .map_err(|e| { + crate::AuthError::Init(format!( + "dev_seed: failed to hash password for '{}': {e}", + seed.admin_username + )) + })?; + let userpass = UserPass { + realm: ADMIN_REALM.to_string(), + username: seed.admin_username.clone(), + password: hashed, + change_password: true, + roles: Vec::new(), + }; + db.create_userpass(&userpass).await.map_err(|e| { + crate::AuthError::Init(format!( + "dev_seed: failed to create credential for '{}': {e}", + seed.admin_username + )) + })?; + info!("dev_seed: created credential for '{}'", seed.admin_username); + } + + // 3. Create the admin record if it does not exist. + if db.get_admin(&seed.admin_username).await?.is_none() { + let admin = Admin { + id: seed.admin_username.clone(), + realms: vec![seed.realm_id.clone()], + userpass: Some(seed.admin_username.clone()), + jwt: None, + fido2: None, + digital_credentials: None, + client_certificate: None, + totp_enabled: None, + totp_secret: None, + totp_auth_url: None, + }; + db.create_admin(&admin).await.map_err(|e| { + crate::AuthError::Init(format!( + "dev_seed: failed to create admin '{}': {e}", + seed.admin_username + )) + })?; + info!( + "dev_seed: created realm-admin '{}' for realm '{}'", + seed.admin_username, seed.realm_id + ); + } + + // 4. Optionally create a TOTP-enabled user in the seeded realm. + if let (Some(totp_username), Some(totp_password)) = (&seed.totp_username, &seed.totp_password) { + if db + .get_userpass(&seed.realm_id, totp_username) + .await? + .is_none() + { + let hashed = hash_password_with_argon2(totp_username, totp_password).map_err(|e| { + crate::AuthError::Init(format!( + "dev_seed: failed to hash password for TOTP user '{}': {e}", + totp_username + )) + })?; + let userpass = UserPass { + realm: seed.realm_id.clone(), + username: totp_username.clone(), + password: hashed, + change_password: false, + roles: Vec::new(), + }; + db.create_userpass(&userpass).await.map_err(|e| { + crate::AuthError::Init(format!( + "dev_seed: failed to create TOTP user '{}': {e}", + totp_username + )) + })?; + info!( + "dev_seed: created TOTP user '{}' in realm '{}'", + totp_username, seed.realm_id + ); + } + + // Create the admin record for the TOTP user if it does not exist (required for TOTP fields in DB). + if db.get_admin(totp_username).await?.is_none() { + let admin = Admin { + id: totp_username.clone(), + realms: Vec::new(), // not an admin of any realms + userpass: Some(totp_username.clone()), + jwt: None, + fido2: None, + digital_credentials: None, + client_certificate: None, + totp_enabled: None, + totp_secret: None, + totp_auth_url: None, + }; + db.create_admin(&admin).await.map_err(|e| { + crate::AuthError::Init(format!( + "dev_seed: failed to create admin record for TOTP user '{}': {e}", + totp_username + )) + })?; + info!( + "dev_seed: created admin record for TOTP user '{}'", + totp_username + ); + } + + // Enable TOTP for the user (idempotent: skip if already enabled). + let already_enabled = db + .is_totp_enabled(&seed.realm_id, totp_username) + .await? + .unwrap_or(false); + if !already_enabled { + let secret = if let Some(s) = &seed.totp_secret { + s.clone() + } else { + let generated = crate::totp::Totps::new() + .map_err(|e| { + crate::AuthError::Init(format!( + "dev_seed: failed to generate TOTP secret: {e}" + )) + })? + .generate_secret(); + info!( + "dev_seed: generated TOTP secret for '{}': {}", + totp_username, generated + ); + generated + }; + db.enable_totp(&seed.realm_id, totp_username, &secret, &seed.realm_id) + .await + .map_err(|e| { + crate::AuthError::Init(format!( + "dev_seed: failed to enable TOTP for '{}': {e}", + totp_username + )) + })?; + info!( + "dev_seed: TOTP enabled for user '{}' in realm '{}'", + totp_username, seed.realm_id + ); + } + } + + Ok(()) +} diff --git a/server/src/server/endpoints/client_endpoints.rs b/server/src/server/endpoints/client_endpoints.rs index d2643a9..e6940db 100644 --- a/server/src/server/endpoints/client_endpoints.rs +++ b/server/src/server/endpoints/client_endpoints.rs @@ -1,6 +1,6 @@ use crate::models::ClientClaims; use crate::models::LoginRequest; -use crate::session::{self, issue_token, session_id_from_cookie_value}; +use crate::session::{self, JwksData, issue_token, session_id_from_cookie_value}; use crate::{AuthError, AuthenticatedClientScheme, server::Version}; use crate::{AuthenticationNextStep, AuthenticationResult, Realm, build_cookie}; use actix_web::HttpMessage; @@ -154,6 +154,14 @@ pub async fn roles_endpoint( Ok(HttpResponse::Ok().json(&server_params.roles)) } +/// Returns the JWKS (JSON Web Key Set) document containing the server's EC signing +/// public key. Served at `/.well-known/jwks.json` per OIDC / RFC 8414. +pub async fn jwks_well_known(jwks: Data>) -> Result { + Ok(HttpResponse::Ok() + .insert_header(("Cache-Control", "public, max-age=3600")) + .json(&jwks.0)) +} + /// Serve the raw OpenAPI 3.1 schema (YAML), embedded at compile time. #[cfg(feature = "swagger-ui")] pub async fn openapi_yaml_endpoint(_req: HttpRequest) -> Result { diff --git a/server/src/server/endpoints/mod.rs b/server/src/server/endpoints/mod.rs index a701382..c72eaa7 100644 --- a/server/src/server/endpoints/mod.rs +++ b/server/src/server/endpoints/mod.rs @@ -16,7 +16,7 @@ pub use realms_endpoints::{ }; mod client_endpoints; -pub use client_endpoints::{login, roles_endpoint, version_endpoint, whoami}; +pub use client_endpoints::{jwks_well_known, login, roles_endpoint, version_endpoint, whoami}; #[cfg(feature = "swagger-ui")] pub use client_endpoints::{openapi_yaml_endpoint, swagger_ui_endpoint}; diff --git a/server/src/server/mod.rs b/server/src/server/mod.rs index f6a6925..3d2649b 100644 --- a/server/src/server/mod.rs +++ b/server/src/server/mod.rs @@ -1,4 +1,5 @@ mod auth_server; +mod dev_seed; pub use auth_server::start_auth_server; pub mod endpoints; diff --git a/server/src/server/parameters/server_params.rs b/server/src/server/parameters/server_params.rs index 70dd483..1f3911c 100644 --- a/server/src/server/parameters/server_params.rs +++ b/server/src/server/parameters/server_params.rs @@ -60,6 +60,13 @@ pub struct DevSeedParams { pub admin_username: String, /// Plain-text password for the realm-admin account. pub admin_password: String, + /// Username for a TOTP-enabled regular user in the seeded realm (optional). + pub totp_username: Option, + /// Plain-text password for the TOTP-enabled user (optional). + pub totp_password: Option, + /// Fixed Base32 TOTP secret for the TOTP user (optional). + /// If omitted, a random secret is generated and logged at startup. + pub totp_secret: Option, } impl ServerParams { diff --git a/server/src/session/jwt.rs b/server/src/session/jwt.rs index 28da59b..cc04042 100644 --- a/server/src/session/jwt.rs +++ b/server/src/session/jwt.rs @@ -4,6 +4,152 @@ use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, deco use crate::{AuthError, AuthScheme, models::ClientClaims}; +/// Pre-built JWKS document served at `/.well-known/jwks.json`. +pub struct JwksData(pub serde_json::Value); + +/// Build a JWKS document from a PEM string (either an X.509 certificate or a +/// SubjectPublicKeyInfo / `BEGIN PUBLIC KEY` file). The resulting set contains a +/// single EC P-256 `sig` key with a deterministic `kid` derived from the +/// SHA-256 digest of the raw x‖y bytes. +/// +/// Supported PEM types: `BEGIN CERTIFICATE`, `BEGIN PUBLIC KEY`. +#[cfg(feature = "openssl")] +pub fn build_jwks_from_pem(pem: &str) -> crate::AuthResult { + use base64::Engine as _; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use sha2::{Digest, Sha256}; + + let xy = ec_xy_from_pem_openssl(pem)?; + + let kid = hex::encode(Sha256::digest(&xy)); + let jwk = serde_json::json!({ + "kty": "EC", + "crv": "P-256", + "use": "sig", + "alg": "ES256", + "kid": kid, + "x": URL_SAFE_NO_PAD.encode(&xy[0..32]), + "y": URL_SAFE_NO_PAD.encode(&xy[32..64]), + }); + Ok(JwksData(serde_json::json!({ "keys": [jwk] }))) +} + +#[cfg(feature = "openssl")] +fn ec_xy_from_pem_openssl(pem: &str) -> crate::AuthResult> { + if pem.contains("BEGIN CERTIFICATE") { + let der = pem_to_der_bytes(pem, "CERTIFICATE")?; + let cert = openssl::x509::X509::from_der(&der) + .map_err(|e| AuthError::Config(format!("Failed to parse X.509 certificate: {e}")))?; + let pkey = cert + .public_key() + .map_err(|e| AuthError::Config(format!("Failed to extract public key: {e}")))?; + ec_xy_from_pkey(&pkey) + } else { + let der = pem_to_der_bytes(pem, "PUBLIC KEY")?; + let pkey = openssl::pkey::PKey::public_key_from_der(&der) + .map_err(|e| AuthError::Config(format!("Failed to parse public key DER: {e}")))?; + ec_xy_from_pkey(&pkey) + } +} + +#[cfg(feature = "openssl")] +fn ec_xy_from_pkey( + pkey: &openssl::pkey::PKey, +) -> crate::AuthResult> { + let ec = pkey + .ec_key() + .map_err(|e| AuthError::Config(format!("JWT key is not an EC key: {e}")))?; + let group = ec.group(); + let point = ec.public_key(); + let mut ctx = openssl::bn::BigNumContext::new() + .map_err(|e| AuthError::Config(format!("Failed to create BigNum context: {e}")))?; + let bytes = point + .to_bytes( + group, + openssl::ec::PointConversionForm::UNCOMPRESSED, + &mut ctx, + ) + .map_err(|e| AuthError::Config(format!("Failed to export EC point: {e}")))?; + if bytes.len() != 65 || bytes[0] != 0x04 { + return Err(AuthError::Config(format!( + "Expected 65-byte uncompressed P-256 point, got {} bytes", + bytes.len() + ))); + } + Ok(bytes[1..].to_vec()) // 64 bytes: x‖y +} + +/// Build a JWKS document from a PEM string. +/// +/// This is the `rustls`-only (no OpenSSL) implementation. It parses the raw DER +/// bytes to locate the uncompressed EC public-key point directly. +#[cfg(all(feature = "rustls", not(feature = "openssl")))] +pub fn build_jwks_from_pem(pem: &str) -> crate::AuthResult { + use base64::Engine as _; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use sha2::{Digest, Sha256}; + + let xy = ec_xy_from_pem_der(pem)?; + + let kid = hex::encode(Sha256::digest(&xy)); + let jwk = serde_json::json!({ + "kty": "EC", + "crv": "P-256", + "use": "sig", + "alg": "ES256", + "kid": kid, + "x": URL_SAFE_NO_PAD.encode(&xy[0..32]), + "y": URL_SAFE_NO_PAD.encode(&xy[32..64]), + }); + Ok(JwksData(serde_json::json!({ "keys": [jwk] }))) +} + +/// Locate the 64-byte x‖y from a P-256 EC key embedded in raw DER bytes. +/// +/// The uncompressed-point marker `0x04` is always the last content byte of the +/// BitString that encodes the SubjectPublicKeyInfo's public key. In a P-256 +/// SubjectPublicKeyInfo the DER is exactly 91 bytes and `0x04` is at byte 26. +/// In an X.509 certificate the same BitString appears near the end of the +/// TBSCertificate, so scanning backwards for `0x00 0x04` (no unused bits + +/// uncompressed marker) followed by exactly 64 bytes is reliable. +#[cfg(all(feature = "rustls", not(feature = "openssl")))] +fn ec_xy_from_pem_der(pem: &str) -> crate::AuthResult<[u8; 64]> { + let label = if pem.contains("BEGIN CERTIFICATE") { + "CERTIFICATE" + } else { + "PUBLIC KEY" + }; + let der = pem_to_der_bytes(pem, label)?; + + // Search for the last occurrence of [0x00, 0x04] (BitString no-unused-bits + + // uncompressed EC point marker) where 64 bytes follow. + let pos = der + .windows(2) + .enumerate() + .rev() + .find(|(i, w)| w[0] == 0x00 && w[1] == 0x04 && i + 2 + 64 <= der.len()) + .map(|(i, _)| i + 2) // first byte of x + .ok_or_else(|| { + AuthError::Config("Cannot locate P-256 uncompressed point in DER".to_string()) + })?; + + let mut xy = [0u8; 64]; + xy.copy_from_slice(&der[pos..pos + 64]); + Ok(xy) +} + +fn pem_to_der_bytes(pem_str: &str, label: &str) -> crate::AuthResult> { + let parsed = pem::parse(pem_str) + .map_err(|e| AuthError::Config(format!("Failed to parse PEM ({label}): {e}")))?; + if parsed.tag() != label { + return Err(AuthError::Config(format!( + "Expected PEM label {label}, got {}", + parsed.tag() + ))); + } + Ok(parsed.into_contents()) +} + pub struct JwtTokenConfig { pub algorithm: Algorithm, pub encoding_key: EncodingKey, diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index 868ab55..620e77f 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -5,7 +5,7 @@ mod factory; pub use factory::create_session_store_with_collector; mod jwt; -pub use jwt::{JwtTokenConfig, issue_token, validate_token}; +pub use jwt::{JwksData, JwtTokenConfig, build_jwks_from_pem, issue_token, validate_token}; mod session_store; pub use session_store::SessionStore;