diff --git a/Cargo.lock b/Cargo.lock index 3e21ba974..0c8f332da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3587,6 +3587,7 @@ dependencies = [ "rand 0.8.6", "regex", "reqwest", + "rsa", "serde", "serde_json", "serde_urlencoded", @@ -3711,6 +3712,7 @@ dependencies = [ "axum", "axum-embed", "axum-htmx", + "base64 0.22.1", "chrono", "fluent-syntax 0.11.1", "fluent-templates", @@ -3722,10 +3724,13 @@ dependencies = [ "http-body-util", "json-patch", "jsonwebtoken", + "p384", "reqwest", + "rsa", "rust-embed", "serde", "serde_json", + "sha2 0.10.9", "similar", "tokio", "tower", @@ -4561,6 +4566,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin 0.9.8", +] [[package]] name = "leb128fmt" @@ -5122,6 +5130,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -5768,6 +5792,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", +] + [[package]] name = "pkcs8" version = "0.9.0" @@ -6662,6 +6697,26 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "signature 2.2.0", + "spki 0.7.3", + "subtle", + "zeroize", +] + [[package]] name = "rusqlite" version = "0.33.0" diff --git a/crates/rest/Cargo.toml b/crates/rest/Cargo.toml index 1bdf82695..c84a5622d 100644 --- a/crates/rest/Cargo.toml +++ b/crates/rest/Cargo.toml @@ -120,6 +120,7 @@ hmac = "0.12" # A*CBC-HS* authenticat sha2 = "0.10" # Concat KDF, A*CBC-HS* p256 = { version = "0.13", features = ["ecdh", "jwk", "pem", "pkcs8"] } # ECDH-ES (P-256) p384 = { version = "0.13", features = ["ecdh", "jwk", "pem", "pkcs8"] } # ECDH-ES (P-384) +rsa = { version = "0.9", features = ["pem"] } # RS384 public JWK derivation (#529) flate2 = "1" # JWE `zip: "DEF"` payloads [dev-dependencies] diff --git a/crates/rest/src/bulk_submit_oauth.rs b/crates/rest/src/bulk_submit_oauth.rs index 1a4b9eed2..c475520d8 100644 --- a/crates/rest/src/bulk_submit_oauth.rs +++ b/crates/rest/src/bulk_submit_oauth.rs @@ -26,6 +26,8 @@ pub struct JwtClientCredentialsTokenProvider { client_id: String, signing_alg: Algorithm, encoding_key: EncodingKey, + /// RFC 7638 thumbprint of the public key, used as `kid` in assertion headers. + kid: Option, /// Cache of `(token_endpoint, scope) -> (token, expiry)`. cache: Mutex>, } @@ -45,11 +47,14 @@ impl JwtClientCredentialsTokenProvider { ), _ => return None, }; + let kid = derive_public_jwk(private_key_pem, signing_alg) + .and_then(|jwk| jwk.get("kid").and_then(|v| v.as_str()).map(String::from)); Some(Arc::new(Self { client: reqwest::Client::new(), client_id: client_id.to_string(), signing_alg: alg, encoding_key: key, + kid, cache: Mutex::new(HashMap::new()), })) } @@ -79,6 +84,7 @@ impl JwtClientCredentialsTokenProvider { }); let mut header = Header::new(self.signing_alg); header.typ = Some("JWT".to_string()); + header.kid = self.kid.clone(); jsonwebtoken::encode(&header, &claims, &self.encoding_key).ok() } @@ -144,6 +150,63 @@ impl FileTokenProvider for JwtClientCredentialsTokenProvider { } } +/// Derives the public JWK (with RFC 7638 thumbprint as `kid`) from a PEM private key. +/// +/// Supports ES384 (P-384) and RS384. Returns `None` for any other algorithm or +/// for a key that cannot be parsed. +pub(crate) fn derive_public_jwk(pem: &str, alg: &str) -> Option { + use base64::Engine as _; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use sha2::{Digest, Sha256}; + + match alg { + "ES384" => { + use p384::elliptic_curve::sec1::ToEncodedPoint; + use p384::pkcs8::DecodePrivateKey; + + let secret = p384::SecretKey::from_pkcs8_pem(pem) + .or_else(|_| p384::SecretKey::from_sec1_pem(pem)) + .ok()?; + let point = secret.public_key().to_encoded_point(false); + let x = URL_SAFE_NO_PAD.encode(point.x()?); + let y = URL_SAFE_NO_PAD.encode(point.y()?); + // RFC 7638 §3.3: required EC members in lexicographic order. + let canonical = format!(r#"{{"crv":"P-384","kty":"EC","x":"{x}","y":"{y}"}}"#); + let kid = URL_SAFE_NO_PAD.encode(Sha256::digest(canonical.as_bytes())); + Some(serde_json::json!({ + "kty": "EC", + "crv": "P-384", + "x": x, + "y": y, + "kid": kid, + "use": "sig", + "alg": "ES384", + })) + } + "RS384" => { + use rsa::pkcs8::DecodePrivateKey; + use rsa::traits::PublicKeyParts; + + let private_key = rsa::RsaPrivateKey::from_pkcs8_pem(pem).ok()?; + let pub_key = private_key.to_public_key(); + let n = URL_SAFE_NO_PAD.encode(pub_key.n().to_bytes_be()); + let e = URL_SAFE_NO_PAD.encode(pub_key.e().to_bytes_be()); + // RFC 7638 §3.3: required RSA members in lexicographic order. + let canonical = format!(r#"{{"e":"{e}","kty":"RSA","n":"{n}"}}"#); + let kid = URL_SAFE_NO_PAD.encode(Sha256::digest(canonical.as_bytes())); + Some(serde_json::json!({ + "kty": "RSA", + "n": n, + "e": e, + "kid": kid, + "use": "sig", + "alg": "RS384", + })) + } + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/rest/src/handlers/bulk_submit_jwks.rs b/crates/rest/src/handlers/bulk_submit_jwks.rs new file mode 100644 index 000000000..82d6d7753 --- /dev/null +++ b/crates/rest/src/handlers/bulk_submit_jwks.rs @@ -0,0 +1,30 @@ +//! JWKS endpoint for HFS's own bulk-submit signing key. +//! +//! Serves `/.well-known/bulk-submit-jwks.json` — the public key HFS uses when +//! signing outbound SMART Backend Services client assertions. Recipients register +//! this URL once instead of hand-carrying a PEM on every key rotation. + +use axum::{Json, extract::State, response::IntoResponse}; +use helios_persistence::core::ResourceStorage; + +use crate::bulk_submit_oauth::derive_public_jwk; +use crate::state::AppState; + +/// `GET /.well-known/bulk-submit-jwks.json` +/// +/// Returns a JWK Set containing the server's current signing public key. +/// Responds with an empty `keys` array when no signing key is configured or +/// when the configured algorithm is not ES384. +pub async fn bulk_submit_jwks_handler(State(state): State>) -> impl IntoResponse +where + S: ResourceStorage + Send + Sync, +{ + let config = state.bulk_submit_config(); + let keys = config + .private_key + .as_deref() + .and_then(|pem| derive_public_jwk(pem, &config.signing_alg)) + .into_iter() + .collect::>(); + Json(serde_json::json!({ "keys": keys })) +} diff --git a/crates/rest/src/handlers/mod.rs b/crates/rest/src/handlers/mod.rs index 2969f7631..da09bb680 100644 --- a/crates/rest/src/handlers/mod.rs +++ b/crates/rest/src/handlers/mod.rs @@ -20,6 +20,7 @@ pub mod batch; pub mod bulk_common; pub mod bulk_export; pub mod bulk_submit; +pub mod bulk_submit_jwks; pub mod capabilities; pub mod compartment; pub mod console_metrics; @@ -71,6 +72,7 @@ pub use bulk_submit::{ bulk_submit_cancel_handler, bulk_submit_file_handler, bulk_submit_kickoff_handler, bulk_submit_poll_handler, bulk_submit_status_kickoff_handler, }; +pub use bulk_submit_jwks::bulk_submit_jwks_handler; pub use capabilities::capabilities_handler; pub use compartment::compartment_search_handler; pub use create::create_handler; diff --git a/crates/rest/src/middleware/auth.rs b/crates/rest/src/middleware/auth.rs index bf7aa12aa..6e6df2f4a 100644 --- a/crates/rest/src/middleware/auth.rs +++ b/crates/rest/src/middleware/auth.rs @@ -57,6 +57,7 @@ const EXEMPT_PATHS: &[&str] = &[ "/_liveness", "/_readiness", "/.well-known/smart-configuration", + "/.well-known/bulk-submit-jwks.json", "/$versions", ]; diff --git a/crates/rest/src/routing/fhir_routes.rs b/crates/rest/src/routing/fhir_routes.rs index 8ae71f8da..8a0d1fa66 100644 --- a/crates/rest/src/routing/fhir_routes.rs +++ b/crates/rest/src/routing/fhir_routes.rs @@ -232,6 +232,10 @@ where "/.well-known/smart-configuration", get(handlers::smart_discovery::smart_configuration_handler::), ) + .route( + "/.well-known/bulk-submit-jwks.json", + get(handlers::bulk_submit_jwks_handler::), + ) .route("/_history", get(handlers::history_system_handler::)) // Per-user UI settings. The leading `_` keeps these authenticated yet // exempt from FHIR scope checks, and out of the FHIR resource namespace. diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 55076fd20..377cf768b 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -62,6 +62,11 @@ chrono.workspace = true # Bulk Import workspace (#527). uuid = { version = "1", features = ["v4"] } jsonwebtoken = "9" +# RFC 7638 thumbprint derivation for the bulk-submit signing key kid (#529). +p384 = { version = "0.13", features = ["pem", "pkcs8"] } +rsa = { version = "0.9", features = ["pem"] } +sha2 = "0.10" +base64 = "0.22" # Compile-time, type-checked, auto-escaping templates (Jinja2-like). # Markup lives in templates/, never in Rust source. diff --git a/crates/ui/src/bulk_import.rs b/crates/ui/src/bulk_import.rs index a660bbc5d..8780945f7 100644 --- a/crates/ui/src/bulk_import.rs +++ b/crates/ui/src/bulk_import.rs @@ -574,6 +574,41 @@ fn url_origin(url: &str) -> String { } } +/// Computes the RFC 7638 thumbprint of a private key as the `kid`. +/// Supports ES384 (P-384) and RS384. Returns `None` when the PEM cannot be parsed. +fn signing_kid(pem: &str, alg: &str) -> Option { + use base64::Engine as _; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use sha2::{Digest, Sha256}; + + match alg { + "RS384" => { + use rsa::pkcs8::DecodePrivateKey; + use rsa::traits::PublicKeyParts; + + let private_key = rsa::RsaPrivateKey::from_pkcs8_pem(pem).ok()?; + let pub_key = private_key.to_public_key(); + let n = URL_SAFE_NO_PAD.encode(pub_key.n().to_bytes_be()); + let e = URL_SAFE_NO_PAD.encode(pub_key.e().to_bytes_be()); + let canonical = format!(r#"{{"e":"{e}","kty":"RSA","n":"{n}"}}"#); + Some(URL_SAFE_NO_PAD.encode(Sha256::digest(canonical.as_bytes()))) + } + _ => { + use p384::elliptic_curve::sec1::ToEncodedPoint; + use p384::pkcs8::DecodePrivateKey; + + let secret = p384::SecretKey::from_pkcs8_pem(pem) + .or_else(|_| p384::SecretKey::from_sec1_pem(pem)) + .ok()?; + let point = secret.public_key().to_encoded_point(false); + let x = URL_SAFE_NO_PAD.encode(point.x()?); + let y = URL_SAFE_NO_PAD.encode(point.y()?); + let canonical = format!(r#"{{"crv":"P-384","kty":"EC","x":"{x}","y":"{y}"}}"#); + Some(URL_SAFE_NO_PAD.encode(Sha256::digest(canonical.as_bytes()))) + } + } +} + /// Mints a SMART Backend Services access token (`client_credentials` + /// `private_key_jwt`) against the submission's token endpoint. The signing key /// is the server-wide `HFS_BULK_SUBMIT_PRIVATE_KEY`, shared with the consumer @@ -605,11 +640,7 @@ async fn backend_services_token(client_id: &str, token_url: &str) -> Result Response { - match std::env::var("HFS_BULK_SUBMIT_PUBLIC_JWK") - .ok() - .and_then(|raw| serde_json::from_str::(&raw).ok()) - { - Some(jwk) => axum::Json(json!({ "keys": [jwk] })).into_response(), - None => StatusCode::NOT_FOUND.into_response(), - } + axum::response::Redirect::permanent("/.well-known/bulk-submit-jwks.json").into_response() } /// `GET /ui/bulk-import/empty-manifest.json` — an empty Bulk Export Manifest. diff --git a/crates/ui/templates/pages/bulk-import.html b/crates/ui/templates/pages/bulk-import.html index 31983cb06..d2d67f9ce 100644 --- a/crates/ui/templates/pages/bulk-import.html +++ b/crates/ui/templates/pages/bulk-import.html @@ -67,6 +67,12 @@

{{ i18n.t("bulk-import-title") }}

{{ i18n.t("bulk-import-field-token-url-hint") }} +

+ {{ i18n.t("bulk-import-jwks-hint") }} + + /.well-known/bulk-submit-jwks.json + +