diff --git a/Cargo.lock b/Cargo.lock index e92b97d12c..6d5dd1e0db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5462,8 +5462,11 @@ dependencies = [ "nym-crypto", "nym-ecash-contract-common", "nym-ecash-signer-check", + "nym-http-api-client", "nym-http-api-common", "nym-network-defaults", + "nym-pemstore", + "nym-upgrade-mode-check", "nym-validator-client", "rand 0.8.5", "reqwest 0.12.22", @@ -5534,6 +5537,7 @@ dependencies = [ "nym-http-api-client", "nym-http-api-common", "nym-serde-helpers", + "nym-upgrade-mode-check", "reqwest 0.12.22", "schemars 0.8.22", "serde", @@ -5661,6 +5665,7 @@ dependencies = [ "aead", "aes", "aes-gcm-siv", + "anyhow", "base64 0.22.1", "blake3", "bs58", @@ -5674,10 +5679,12 @@ dependencies = [ "jwt-simple", "nym-pemstore", "nym-sphinx-types", + "nym-test-utils", "rand 0.8.5", "rand_chacha 0.3.1", "serde", "serde_bytes", + "serde_json", "sha2 0.10.9", "subtle-encoding", "thiserror 2.0.12", @@ -7378,12 +7385,14 @@ dependencies = [ "jwt-simple", "nym-crypto", "nym-http-api-client", + "nym-test-utils", "reqwest 0.12.22", "serde", "serde_json", "thiserror 2.0.12", "time", "tracing", + "utoipa", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c125c58216..f0a5cdf1ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -453,6 +453,11 @@ opt-level = 'z' unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tokio_unstable)'] } [workspace.lints.clippy] +suspicious = "deny" +complexity = "deny" +perf = "deny" +style = "deny" + unwrap_used = "deny" expect_used = "deny" todo = "deny" diff --git a/common/credential-proxy/build.rs b/common/credential-proxy/build.rs index bedf911da7..6e29ca0430 100644 --- a/common/credential-proxy/build.rs +++ b/common/credential-proxy/build.rs @@ -8,7 +8,7 @@ async fn main() -> anyhow::Result<()> { use sqlx::{Connection, SqliteConnection}; use std::env; - let out_dir = env::var("OUT_DIR")?; + let out_dir = env::var("OUT_DIR").context("missing OUT_DIR env variable")?; let database_path = format!("{out_dir}/nym-credential-proxy-example.sqlite"); // remove the db file if it already existed from previous build diff --git a/common/credential-proxy/src/error.rs b/common/credential-proxy/src/error.rs index 8db781da1e..b1e1506763 100644 --- a/common/credential-proxy/src/error.rs +++ b/common/credential-proxy/src/error.rs @@ -127,6 +127,13 @@ pub enum CredentialProxyError { #[error("failed to create deposit")] DepositFailure, + #[error("failed to load jwt signing key from {path}: {err}")] + JWTSigningKeyLoadFailure { + path: String, + #[source] + err: std::io::Error, + }, + #[error("can't obtain sufficient number of credential shares due to unavailable quorum")] UnavailableSigningQuorum, diff --git a/common/credential-proxy/src/ticketbook_manager/ticketbook_handlers.rs b/common/credential-proxy/src/ticketbook_manager/ticketbook_handlers.rs index bcfd4c3e9d..8eb4f6bbae 100644 --- a/common/credential-proxy/src/ticketbook_manager/ticketbook_handlers.rs +++ b/common/credential-proxy/src/ticketbook_manager/ticketbook_handlers.rs @@ -7,9 +7,9 @@ use crate::ticketbook_manager::TicketbookManager; use nym_compact_ecash::Base58; use nym_credential_proxy_requests::api::v1::ticketbook::models::{ CurrentEpochResponse, DepositResponse, GlobalDataParams, MasterVerificationKeyResponse, - PartialVerificationKey, PartialVerificationKeysResponse, TicketbookAsyncRequest, - TicketbookObtainParams, TicketbookRequest, TicketbookWalletSharesAsyncResponse, - TicketbookWalletSharesResponse, + ObtainTicketBookSharesAsyncResponse, PartialVerificationKey, PartialVerificationKeysResponse, + TicketbookAsyncRequest, TicketbookObtainParams, TicketbookRequest, + TicketbookWalletSharesAsyncResponse, TicketbookWalletSharesResponse, }; use time::OffsetDateTime; use tracing::{Instrument, Level, error, info, span, warn}; @@ -65,7 +65,7 @@ impl TicketbookManager { uuid: Uuid, request: TicketbookAsyncRequest, params: TicketbookObtainParams, - ) -> Result { + ) -> Result { let requested_on = OffsetDateTime::now_utc(); let span = span!(Level::INFO, "[async] obtain ticketboook", uuid = %uuid); async move { @@ -110,7 +110,7 @@ impl TicketbookManager { } // 4. in the meantime, return the id to the user - Ok(TicketbookWalletSharesAsyncResponse { id, uuid }) + Ok(TicketbookWalletSharesAsyncResponse { id, uuid }.into()) } .instrument(span) .await diff --git a/common/crypto/Cargo.toml b/common/crypto/Cargo.toml index 96549a448d..37a1e317e5 100644 --- a/common/crypto/Cargo.toml +++ b/common/crypto/Cargo.toml @@ -36,7 +36,11 @@ nym-sphinx-types = { path = "../nymsphinx/types", version = "0.2.0", default-fea nym-pemstore = { path = "../../common/pemstore", version = "0.3.0" } [dev-dependencies] +anyhow = { workspace = true } rand_chacha = { workspace = true } +serde_json = { workspace = true } +nym-test-utils = { path = "../test-utils" } + [features] default = [] diff --git a/common/crypto/src/asymmetric/ed25519/serde_helpers.rs b/common/crypto/src/asymmetric/ed25519/serde_helpers.rs index 51a0ac2dfe..6fbc6f3cf7 100644 --- a/common/crypto/src/asymmetric/ed25519/serde_helpers.rs +++ b/common/crypto/src/asymmetric/ed25519/serde_helpers.rs @@ -17,6 +17,51 @@ pub mod bs58_ed25519_pubkey { } } +pub mod vec_bs58_ed25519_pubkey { + use super::*; + use serde::{Deserialize, Deserializer, Serializer, ser::SerializeSeq}; + + pub fn serialize( + keys: &Vec, + serializer: S, + ) -> Result { + let mut seq = serializer.serialize_seq(Some(keys.len()))?; + for key in keys { + seq.serialize_element(&Bs58KeyWrapper(*key))?; + } + seq.end() + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + let wrapped = Vec::::deserialize(deserializer)?; + Ok(wrapped.into_iter().map(|k| k.0).collect()) + } + + struct Bs58KeyWrapper(PublicKey); + + impl serde::Serialize for Bs58KeyWrapper { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + bs58_ed25519_pubkey::serialize(&self.0, serializer) + } + } + + impl<'de> Deserialize<'de> for Bs58KeyWrapper { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Ok(Bs58KeyWrapper(bs58_ed25519_pubkey::deserialize( + deserializer, + )?)) + } + } +} + pub mod bs58_ed25519_signature { use crate::asymmetric::ed25519::Signature; use serde::{Deserialize, Deserializer, Serializer}; @@ -33,3 +78,53 @@ pub mod bs58_ed25519_signature { Signature::from_base58_string(s).map_err(serde::de::Error::custom) } } + +#[cfg(test)] +mod tests { + use super::*; + use jwt_simple::reexports::{anyhow, serde_json}; + use nym_test_utils::helpers::deterministic_rng; + use serde::{Deserialize, Serialize}; + + #[test] + fn vec_bs58_ed25519_pubkey_json() -> anyhow::Result<()> { + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct KeysWrapper(#[serde(with = "vec_bs58_ed25519_pubkey")] Vec); + + use crate::asymmetric::ed25519; + let mut rng = deterministic_rng(); + let empty = KeysWrapper(vec![]); + let single_key = KeysWrapper(vec![PublicKey::from_base58_string( + "Be9wH7xuXBRJAuV1pC7MALZv6a61RvWQ3SypsNarqTt", + )?]); + let three_keys = KeysWrapper(vec![ + ed25519::KeyPair::new(&mut rng).public_key, + ed25519::KeyPair::new(&mut rng).public_key, + ed25519::KeyPair::new(&mut rng).public_key, + ]); + + let se_empty = serde_json::to_string(&empty)?; + let se_single_key = serde_json::to_string(&single_key)?; + let se_three_keys = serde_json::to_string(&three_keys)?; + + assert_eq!(se_empty, r#"[]"#); + assert_eq!( + se_single_key, + r#"["Be9wH7xuXBRJAuV1pC7MALZv6a61RvWQ3SypsNarqTt"]"# + ); + assert_eq!( + se_three_keys, + r#"["HmgHDV79LpnEaSUp8QZQwSroxVvS4RewF7yM9e7qu8y3","311xRh859qCd5MVqoPRCoNx26eYhLknGwtjzkkTJFGhf","A5BMp8WJ6Uk91U4JpWRv2Bc6X35AaRaSEy8QEWeAkaBv"]"# + ); + + let empty_de = serde_json::from_str::(&se_empty)?; + let single_key_de = serde_json::from_str::(&se_single_key)?; + let three_keys_de = serde_json::from_str::(&se_three_keys)?; + + assert_eq!(empty, empty_de); + assert_eq!(single_key, single_key_de); + assert_eq!(three_keys, three_keys_de); + + Ok(()) + } +} diff --git a/common/http-api-client/src/lib.rs b/common/http-api-client/src/lib.rs index 3baa390c53..220d151ba7 100644 --- a/common/http-api-client/src/lib.rs +++ b/common/http-api-client/src/lib.rs @@ -902,6 +902,8 @@ impl Client { if self.base_urls.len() > 1 { let orig = self.current_idx.load(Ordering::Relaxed); + + #[allow(unused_mut)] let mut next = (orig + 1) % self.base_urls.len(); // if fronting is enabled we want to update to a host that has fronts configured diff --git a/common/upgrade-mode-check/Cargo.toml b/common/upgrade-mode-check/Cargo.toml index 50e0cb3f7c..17f292a566 100644 --- a/common/upgrade-mode-check/Cargo.toml +++ b/common/upgrade-mode-check/Cargo.toml @@ -15,9 +15,10 @@ jwt-simple = { workspace = true } reqwest = { workspace = true, features = ["rustls-tls"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -time = { workspace = true, features = ["serde"] } +time = { workspace = true, features = ["serde", "formatting", "parsing"] } thiserror = { workspace = true } tracing = { workspace = true } +utoipa = { workspace = true, optional = true } nym-http-api-client = { path = "../http-api-client", default-features = false } nym-crypto = { path = "../crypto", features = ["asymmetric", "serde", "naive_jwt"] } @@ -25,6 +26,12 @@ nym-crypto = { path = "../crypto", features = ["asymmetric", "serde", "naive_jwt [dev-dependencies] anyhow = { workspace = true } time = { workspace = true, features = ["macros"] } +nym-test-utils = { path = "../test-utils" } +nym-crypto = { path = "../crypto", features = ["rand"] } + + +[features] +openapi = ["utoipa"] [lints] workspace = true diff --git a/common/upgrade-mode-check/src/attestation.rs b/common/upgrade-mode-check/src/attestation.rs index 42c115f3cd..3d052caadd 100644 --- a/common/upgrade-mode-check/src/attestation.rs +++ b/common/upgrade-mode-check/src/attestation.rs @@ -1,31 +1,43 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use crate::UpgradeModeCheckError; use nym_crypto::asymmetric::ed25519; -use nym_http_api_client::generate_user_agent; use serde::{Deserialize, Serialize}; -use std::time::Duration; use time::OffsetDateTime; -#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Copy)] +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] pub struct UpgradeModeAttestation { #[serde(flatten)] pub content: UpgradeModeAttestationContent, #[serde(with = "ed25519::bs58_ed25519_signature")] + #[cfg_attr(feature = "openapi", schema(value_type = String))] pub signature: ed25519::Signature, } -#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Copy)] +impl UpgradeModeAttestation { + pub fn authorised_to_issue_jwt(&self, key: &ed25519::PublicKey) -> bool { + self.content.authorised_jwt_issuers.contains(key) + } +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] #[serde(tag = "type")] #[serde(rename = "upgrade_mode")] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] pub struct UpgradeModeAttestationContent { - #[serde(with = "time::serde::timestamp")] + #[serde(with = "time::serde::rfc3339")] + #[cfg_attr(feature = "openapi", schema(value_type = String))] pub starting_time: OffsetDateTime, #[serde(with = "ed25519::bs58_ed25519_pubkey")] + #[cfg_attr(feature = "openapi", schema(value_type = String))] pub attester_public_key: ed25519::PublicKey, + + #[serde(with = "ed25519::vec_bs58_ed25519_pubkey")] + #[cfg_attr(feature = "openapi", schema(value_type = Vec))] + pub authorised_jwt_issuers: Vec, } impl UpgradeModeAttestation { @@ -45,17 +57,26 @@ impl UpgradeModeAttestationContent { } } -pub fn generate_new_attestation(key: &ed25519::PrivateKey) -> UpgradeModeAttestation { - generate_new_attestation_with_starting_time(key, OffsetDateTime::now_utc()) +pub fn generate_new_attestation( + key: &ed25519::PrivateKey, + authorised_jwt_issuers: Vec, +) -> UpgradeModeAttestation { + generate_new_attestation_with_starting_time( + key, + authorised_jwt_issuers, + OffsetDateTime::now_utc(), + ) } pub fn generate_new_attestation_with_starting_time( key: &ed25519::PrivateKey, + authorised_jwt_issuers: Vec, starting_time: OffsetDateTime, ) -> UpgradeModeAttestation { let content = UpgradeModeAttestationContent { starting_time, attester_public_key: key.into(), + authorised_jwt_issuers, }; UpgradeModeAttestation { signature: key.sign(content.as_json()), @@ -63,17 +84,19 @@ pub fn generate_new_attestation_with_starting_time( } } -pub async fn attempt_retrieve( +#[cfg(not(target_arch = "wasm32"))] +pub async fn attempt_retrieve_attestation( url: &str, -) -> Result, UpgradeModeCheckError> { - let retrieval_failure = |source| UpgradeModeCheckError::AttestationRetrievalFailure { + user_agent: Option, +) -> Result, crate::UpgradeModeCheckError> { + let retrieval_failure = |source| crate::UpgradeModeCheckError::AttestationRetrievalFailure { url: url.to_string(), source, }; let attestation = reqwest::ClientBuilder::new() - .user_agent(generate_user_agent!()) - .timeout(Duration::from_secs(5)) + .user_agent(user_agent.unwrap_or_else(|| nym_http_api_client::generate_user_agent!())) + .timeout(std::time::Duration::from_secs(5)) .build() .map_err(retrieval_failure)? .get(url) @@ -101,13 +124,20 @@ mod tests { 163, 122, 170, 79, 198, 87, 85, 36, 29, 243, 92, 64, 161, ])?; - let attestation = generate_new_attestation_with_starting_time(&key, starting_time); + let authorised_jwt_issuers = vec![ed25519::PublicKey::from_base58_string( + "Be9wH7xuXBRJAuV1pC7MALZv6a61RvWQ3SypsNarqTt", + )?]; + let attestation = generate_new_attestation_with_starting_time( + &key, + authorised_jwt_issuers, + starting_time, + ); let attestation_json = serde_json::to_string(&attestation)?; let attestation_content_json = attestation.content.as_json(); - let expected_attestation = r#"{"type":"upgrade_mode","starting_time":1629720000,"attester_public_key":"3pkFcBXCEmbmXBT2G8CkFMuKisJcH54mbBGvncHaDibt","signature":"5rWUr2ypaDTtrMKegMP3tQkkZGFAuhNTnEVCVe5Azv6QqvLzoGdQiMkFmeyhDd1XSfoXpL9fFM58rsdA1kf4GYMM"}"#; - let expected_content = r#"{"type":"upgrade_mode","starting_time":1629720000,"attester_public_key":"3pkFcBXCEmbmXBT2G8CkFMuKisJcH54mbBGvncHaDibt"}"#; + let expected_attestation = r#"{"type":"upgrade_mode","starting_time":"2021-08-23T12:00:00Z","attester_public_key":"3pkFcBXCEmbmXBT2G8CkFMuKisJcH54mbBGvncHaDibt","authorised_jwt_issuers":["Be9wH7xuXBRJAuV1pC7MALZv6a61RvWQ3SypsNarqTt"],"signature":"5Kt9dfwvnkdnDcENbwNyitrxghyckWUYycBv8jUUn7hJUMohWEMc6otb3scXQfCrAGSE7FD5m7kr6auBmkAmfczY"}"#; + let expected_content = r#"{"type":"upgrade_mode","starting_time":"2021-08-23T12:00:00Z","attester_public_key":"3pkFcBXCEmbmXBT2G8CkFMuKisJcH54mbBGvncHaDibt","authorised_jwt_issuers":["Be9wH7xuXBRJAuV1pC7MALZv6a61RvWQ3SypsNarqTt"]}"#; assert_eq!(attestation_content_json, expected_content); assert_eq!(attestation_json, expected_attestation); diff --git a/common/upgrade-mode-check/src/error.rs b/common/upgrade-mode-check/src/error.rs index d1f26b7c5f..0edf4201e8 100644 --- a/common/upgrade-mode-check/src/error.rs +++ b/common/upgrade-mode-check/src/error.rs @@ -12,6 +12,9 @@ pub enum UpgradeModeCheckError { #[error("the jwt metadata didn't contain explicit public key")] MissingTokenPublicKey, + #[error("the jwt signer does not appear in the authorised attestation set")] + UnauthorisedIssuer, + #[error("the attached public key was not valid ed25519 public key")] MalformedEd25519PublicKey { source: Ed25519RecoveryError }, diff --git a/common/upgrade-mode-check/src/jwt.rs b/common/upgrade-mode-check/src/jwt.rs index 93be406ab2..060546f0bb 100644 --- a/common/upgrade-mode-check/src/jwt.rs +++ b/common/upgrade-mode-check/src/jwt.rs @@ -65,6 +65,12 @@ pub fn validate_upgrade_mode_jwt( .map_err(|source| UpgradeModeCheckError::JwtVerificationFailure { source })? .custom; + // jwt itself is cryptographically valid, + // but let's see if this entity has been permitted to issue the token in the first place + if !attestation.authorised_to_issue_jwt(&ed25519_pub_key) { + return Err(UpgradeModeCheckError::UnauthorisedIssuer); + } + Ok(attestation) } @@ -73,6 +79,7 @@ mod tests { use super::*; use crate::generate_new_attestation; use nym_crypto::asymmetric::ed25519; + use nym_test_utils::helpers::deterministic_rng; #[test] fn generate_and_validate_jwt() { @@ -86,15 +93,25 @@ mod tests { 2, 52, 215, 241, 219, 200, 18, 159, 241, 76, 111, 42, 32, ]) .unwrap(); - let keys = ed25519::KeyPair::from(jwt_key); + let jwt_keys = ed25519::KeyPair::from(jwt_key); - let attestation = generate_new_attestation(&attestation_key); + let mut rng = deterministic_rng(); + let unauthorised_jwt_keys = ed25519::KeyPair::new(&mut rng); + + let attestation = generate_new_attestation(&attestation_key, vec![*jwt_keys.public_key()]); let jwt_issuer = generate_jwt_for_upgrade_mode_attestation( - attestation, + attestation.clone(), Duration::from_secs(60 * 60), - &keys, + &jwt_keys, Some("nym-credential-proxy"), ); + let unauthorised_jwt = generate_jwt_for_upgrade_mode_attestation( + attestation.clone(), + Duration::from_secs(60 * 60), + &unauthorised_jwt_keys, + Some("nym-credential-proxy"), + ); + // we expect 'nym-credential-proxy' issuer assert!(validate_upgrade_mode_jwt(&jwt_issuer, Some("nym-credential-proxy")).is_ok()); @@ -104,10 +121,15 @@ mod tests { // we expect another-issuer assert!(validate_upgrade_mode_jwt(&jwt_issuer, Some("another-issuer")).is_err()); + // the key is not in the authorised set inside the attestation + assert!( + validate_upgrade_mode_jwt(&unauthorised_jwt, Some("nym-credential-proxy")).is_err() + ); + let jwt_no_issuer = generate_jwt_for_upgrade_mode_attestation( attestation, Duration::from_secs(60 * 60), - &keys, + &jwt_keys, None, ); // we expect 'nym-credential-proxy' issuer diff --git a/common/upgrade-mode-check/src/lib.rs b/common/upgrade-mode-check/src/lib.rs index d0f952b361..36cbf43c91 100644 --- a/common/upgrade-mode-check/src/lib.rs +++ b/common/upgrade-mode-check/src/lib.rs @@ -6,8 +6,10 @@ pub(crate) mod error; pub(crate) mod jwt; pub use attestation::{ - UpgradeModeAttestation, attempt_retrieve, generate_new_attestation, - generate_new_attestation_with_starting_time, + UpgradeModeAttestation, generate_new_attestation, generate_new_attestation_with_starting_time, }; pub use error::UpgradeModeCheckError; pub use jwt::{generate_jwt_for_upgrade_mode_attestation, validate_upgrade_mode_jwt}; + +#[cfg(not(target_arch = "wasm32"))] +pub use attestation::attempt_retrieve_attestation; diff --git a/nym-credential-proxy/nym-credential-proxy-requests/Cargo.toml b/nym-credential-proxy/nym-credential-proxy-requests/Cargo.toml index 7897a1287e..76f91373b8 100644 --- a/nym-credential-proxy/nym-credential-proxy-requests/Cargo.toml +++ b/nym-credential-proxy/nym-credential-proxy-requests/Cargo.toml @@ -30,6 +30,7 @@ nym-credentials-interface = { path = "../../common/credentials-interface" } nym-http-api-common = { path = "../../common/http-api-common", optional = true } nym-http-api-client = { path = "../../common/http-api-client" } nym-serde-helpers = { path = "../../common/serde-helpers", features = ["bs58"] } +nym-upgrade-mode-check = { path = "../../common/upgrade-mode-check" } [target."cfg(target_arch = \"wasm32\")".dependencies.wasmtimer] workspace = true @@ -38,5 +39,8 @@ features = ["tokio"] [features] default = ["query-types"] query-types = ["nym-http-api-common", "nym-http-api-common/output"] -openapi = ["utoipa", "nym-http-api-common/utoipa"] +openapi = ["utoipa", "nym-http-api-common/utoipa", "nym-upgrade-mode-check/openapi"] tsify = ["dep:tsify", "wasm-bindgen"] + +[lints] +workspace = true \ No newline at end of file diff --git a/nym-credential-proxy/nym-credential-proxy-requests/src/api/v1/ticketbook/models.rs b/nym-credential-proxy/nym-credential-proxy-requests/src/api/v1/ticketbook/models.rs index 3ed36ade31..7f1b844774 100644 --- a/nym-credential-proxy/nym-credential-proxy-requests/src/api/v1/ticketbook/models.rs +++ b/nym-credential-proxy/nym-credential-proxy-requests/src/api/v1/ticketbook/models.rs @@ -12,17 +12,19 @@ use serde::{Deserialize, Serialize}; use serde_with::{DisplayFromStr, serde_as}; use std::ops::{Deref, DerefMut}; use time::{Date, OffsetDateTime}; +use uuid::Uuid; #[cfg(feature = "query-types")] use nym_http_api_common::Output; #[cfg(feature = "tsify")] use tsify::Tsify; -use uuid::Uuid; #[cfg(feature = "tsify")] use wasm_bindgen::prelude::wasm_bindgen; +pub use nym_upgrade_mode_check::UpgradeModeAttestation; + #[derive(JsonSchema)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] pub struct PlaceholderJsonSchemaImpl {} @@ -225,6 +227,27 @@ pub struct TicketbookWalletSharesResponse { pub aggregated_expiration_date_signatures: Option, } +#[derive(Serialize, Deserialize, Debug, Clone)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +#[serde(untagged)] +pub enum ObtainTicketBookSharesAsyncResponse { + InProgress(TicketbookWalletSharesAsyncResponse), + UpgradeMode(Box), +} + +impl From for ObtainTicketBookSharesAsyncResponse { + fn from(response: TicketbookWalletSharesAsyncResponse) -> Self { + Self::InProgress(response) + } +} + +impl From for ObtainTicketBookSharesAsyncResponse { + fn from(response: UpgradeModeResponse) -> Self { + Self::UpgradeMode(Box::new(response)) + } +} + #[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] @@ -319,3 +342,13 @@ pub struct SharesQueryParams { #[serde(flatten)] pub global: GlobalDataParams, } + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct UpgradeModeResponse { + pub upgrade_mode_attestation: UpgradeModeAttestation, + /// The issued upgrade mode JWT. + /// The value is `None` if this credential proxy is not-authorised to be issuing one + pub jwt: Option, +} diff --git a/nym-credential-proxy/nym-credential-proxy-requests/src/lib.rs b/nym-credential-proxy/nym-credential-proxy-requests/src/lib.rs index 8415c35ba6..6ff65c9fda 100644 --- a/nym-credential-proxy/nym-credential-proxy-requests/src/lib.rs +++ b/nym-credential-proxy/nym-credential-proxy-requests/src/lib.rs @@ -1,15 +1,12 @@ // Copyright 2024 Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only -#![warn(clippy::expect_used)] -#![warn(clippy::unwrap_used)] -#![warn(clippy::todo)] -#![warn(clippy::dbg_macro)] - pub mod api; pub mod client; mod helpers; +pub const CREDENTIAL_PROXY_JWT_ISSUER: &str = "nym-credential-proxy"; + macro_rules! absolute_route { ( $name:ident, $parent:expr, $suffix:expr ) => { pub fn $name() -> String { diff --git a/nym-credential-proxy/nym-credential-proxy/Cargo.toml b/nym-credential-proxy/nym-credential-proxy/Cargo.toml index 63a2df37a9..c727488d37 100644 --- a/nym-credential-proxy/nym-credential-proxy/Cargo.toml +++ b/nym-credential-proxy/nym-credential-proxy/Cargo.toml @@ -62,13 +62,16 @@ nym-http-api-common = { path = "../../common/http-api-common", features = [ "utoipa", "middleware", ] } +nym-http-api-client = { path = "../../common/http-api-client", default-features = false } nym-validator-client = { path = "../../common/client-libs/validator-client" } nym-network-defaults = { path = "../../common/network-defaults" } nym-credential-proxy-requests = { path = "../nym-credential-proxy-requests", features = [ "openapi", ] } +nym-upgrade-mode-check = { path = "../../common/upgrade-mode-check" } nym-ecash-signer-check = { path = "../../common/ecash-signer-check" } +nym-pemstore = { path = "../../common/pemstore" } nym-credential-proxy-lib = { path = "../../common/credential-proxy" } diff --git a/nym-credential-proxy/nym-credential-proxy/src/attestation_watcher.rs b/nym-credential-proxy/nym-credential-proxy/src/attestation_watcher.rs new file mode 100644 index 0000000000..e0ff97ac05 --- /dev/null +++ b/nym-credential-proxy/nym-credential-proxy/src/attestation_watcher.rs @@ -0,0 +1,97 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::http::state::nyx_upgrade_mode::UpgradeModeState; +use nym_crypto::asymmetric::ed25519; +use nym_http_api_client::generate_user_agent; +use nym_upgrade_mode_check::attempt_retrieve_attestation; +use std::sync::Arc; +use std::time::Duration; +use tokio::time::Instant; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info}; +use url::Url; + +pub struct AttestationWatcher { + // default polling interval + regular_polling_interval: Duration, + + // expedited polling interval once upgrade mode is detected + expedited_poll_interval: Duration, + + attestation_url: Url, + + jwt_signing_keys: ed25519::KeyPair, + + jwt_validity: Duration, + + upgrade_mode_state: UpgradeModeState, +} + +impl AttestationWatcher { + pub(crate) fn new( + regular_polling_interval: Duration, + expedited_poll_interval: Duration, + attestation_url: Url, + jwt_signing_keys: ed25519::KeyPair, + jwt_validity: Duration, + ) -> Self { + AttestationWatcher { + regular_polling_interval, + expedited_poll_interval, + attestation_url, + jwt_signing_keys, + jwt_validity, + upgrade_mode_state: UpgradeModeState { + inner: Arc::new(Default::default()), + }, + } + } + + pub(crate) fn shared_state(&self) -> UpgradeModeState { + self.upgrade_mode_state.clone() + } + + async fn try_update_state(&self) { + match attempt_retrieve_attestation( + self.attestation_url.as_str(), + Some(generate_user_agent!()), + ) + .await + { + Err(err) => { + info!("upgrade mode attestation is not available at this time"); + debug!("retrieval error: {err}") + } + Ok(attestation) => { + self.upgrade_mode_state + .update(attestation, &self.jwt_signing_keys, self.jwt_validity) + .await + } + } + } + + pub async fn run_forever(self, cancellation_token: CancellationToken) { + info!("starting the attestation watcher task"); + + let check_wait = tokio::time::sleep(self.regular_polling_interval); + tokio::pin!(check_wait); + + loop { + tokio::select! { + biased; + _ = cancellation_token.cancelled() => { + break + } + _ = &mut check_wait => { + self.try_update_state().await; + if self.upgrade_mode_state.has_attestation().await { + check_wait.as_mut().reset(Instant::now() + self.expedited_poll_interval); + } else { + check_wait.as_mut().reset(Instant::now() + self.regular_polling_interval) + } + } + } + } + } +} diff --git a/nym-credential-proxy/nym-credential-proxy/src/cli.rs b/nym-credential-proxy/nym-credential-proxy/src/cli.rs index 40ba50aa56..a8de014423 100644 --- a/nym-credential-proxy/nym-credential-proxy/src/cli.rs +++ b/nym-credential-proxy/nym-credential-proxy/src/cli.rs @@ -7,10 +7,13 @@ use clap::{Args, Parser}; use nym_bin_common::bin_info; use nym_credential_proxy_lib::error::CredentialProxyError; use nym_credential_proxy_lib::webhook::ZkNymWebhook; +use nym_crypto::asymmetric::ed25519; +use nym_crypto::asymmetric::ed25519::Ed25519RecoveryError; use std::fs::create_dir_all; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::path::PathBuf; -use std::sync::OnceLock; +use std::str::FromStr; +use std::sync::{Arc, OnceLock}; use std::time::Duration; use tracing::info; use url::Url; @@ -20,6 +23,61 @@ fn pretty_build_info_static() -> &'static str { PRETTY_BUILD_INFORMATION.get_or_init(|| bin_info!().pretty_print()) } +// the reason for `Arc` is that `ArgMatches` impls `Clone`, +// so we also need to make the type clone-able +// https://github.com/clap-rs/clap/issues/4286#issuecomment-1262527218 +#[derive(Debug, Clone)] +struct PrivateKeyCliWrapper(Arc); + +impl FromStr for PrivateKeyCliWrapper { + type Err = Ed25519RecoveryError; + fn from_str(s: &str) -> Result { + Ok(PrivateKeyCliWrapper(Arc::new(s.parse()?))) + } +} + +#[derive(Debug, Args)] +#[clap(group = clap::ArgGroup::new("jwt-signing-keys").required(true).multiple(false))] +pub struct JwtSigningKeysArgs { + /// Explicit base58-encoded ed25519 private key used for signing upgrade-mode jwt. + #[clap( + long, + group = "jwt-signing-keys", + env = "NYM_CREDENTIAL_PROXY_JWT_SIGNING_KEY" + )] + jwt_signing_key: Option, + + /// Path to PEM file containing ed25519 private key used for signing upgrade-mode jwt. + #[clap( + long, + group = "jwt-signing-keys", + env = "NYM_CREDENTIAL_PROXY_JWT_SIGNING_KEY_PATH" + )] + jwt_signing_key_path: Option, +} + +impl JwtSigningKeysArgs { + pub(crate) fn signing_keys(self) -> Result { + if let Some(key) = self.jwt_signing_key { + // SAFETY: the arc has never been cloned + #[allow(clippy::unwrap_used)] + return Ok(Arc::into_inner(key.0).unwrap().into()); + } + + // SAFETY: due to clap group, clap ensures only one value here is set + #[allow(clippy::unwrap_used)] + let key_path = self.jwt_signing_key_path.unwrap(); + + let key: ed25519::PrivateKey = nym_pemstore::load_key(&key_path).map_err(|err| { + CredentialProxyError::JWTSigningKeyLoadFailure { + path: key_path.to_str().map(|s| s.to_owned()).unwrap_or_default(), + err, + } + })?; + Ok(key.into()) + } +} + // if needed this could be split into subcommands #[derive(Parser, Debug)] #[clap(author = "Nymtech", version, about, long_version = pretty_build_info_static())] @@ -27,6 +85,12 @@ pub struct Cli { #[clap(flatten)] pub(crate) webhook: ZkNymWebHookConfig, + #[clap(flatten)] + pub(crate) upgrade_mode: UpgradeModeConfig, + + #[clap(flatten)] + pub(crate) jwt_signing_keys: JwtSigningKeysArgs, + /// Path pointing to an env file that configures the binary. #[clap(short, long)] pub(crate) config_env_file: Option, @@ -89,6 +153,44 @@ pub struct Cli { pub(crate) persistent_storage_path: Option, } +#[derive(Args, Debug, Clone)] +pub struct UpgradeModeConfig { + /// URL for polling for upgrade mode changes. + #[clap( + long, + env = "NYM_CREDENTIAL_PROXY_ATTESTATION_CHECK_URL", + default_value = "5m" + )] + pub(crate) attestation_check_url: Url, + + /// Default polling interval of the upgrade mode endpoint. + #[clap( + long, + value_parser = humantime::parse_duration, + env = "NYM_CREDENTIAL_PROXY_ATTESTATION_CHECK_REGULAR_POLLING_INTERVAL", + default_value = "5m", + )] + pub(crate) attestation_check_regular_polling_interval: Duration, + + /// Expedited polling interval of the upgrade mode endpoint if the UM is enabled. + #[clap( + long, + value_parser = humantime::parse_duration, + env = "NYM_CREDENTIAL_PROXY_ATTESTATION_CHECK_EXPEDITED_POLLING_INTERVAL", + default_value = "1m", + )] + pub(crate) attestation_check_expedited_polling_interval: Duration, + + /// Validity duration of the issued JWT during upgrade mode. + #[clap( + long, + value_parser = humantime::parse_duration, + env = "NYM_CREDENTIAL_PROXY_UPGRADE_MODE_JWT_VALIDITY", + default_value = "1h", + )] + pub(crate) upgrade_mode_jwt_validity: Duration, +} + #[derive(Args, Debug, Clone)] pub struct ZkNymWebHookConfig { #[clap(long, env = "WEBHOOK_ZK_NYMS_URL")] diff --git a/nym-credential-proxy/nym-credential-proxy/src/helpers.rs b/nym-credential-proxy/nym-credential-proxy/src/helpers.rs index 41e474cf23..a353c3b447 100644 --- a/nym-credential-proxy/nym-credential-proxy/src/helpers.rs +++ b/nym-credential-proxy/nym-credential-proxy/src/helpers.rs @@ -1,6 +1,8 @@ // Copyright 2024 Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only +use crate::attestation_watcher::AttestationWatcher; +use crate::http::state::ApiState; use crate::{cli::Cli, http::HttpServer}; use nym_bin_common::bin_info; use nym_credential_proxy_lib::error::CredentialProxyError; @@ -51,6 +53,7 @@ pub(crate) async fn run_api(cli: Cli) -> Result<(), CredentialProxyError> { let mnemonic = cli.mnemonic; let auth_token = cli.http_auth_token; let webhook_cfg = cli.webhook; + let jwt_signing_keys = cli.jwt_signing_keys.signing_keys()?; let ticketbook_manager = TicketbookManager::new( build_sha_short(), @@ -63,7 +66,24 @@ pub(crate) async fn run_api(cli: Cli) -> Result<(), CredentialProxyError> { ) .await?; - let http_server = HttpServer::new(bind_address, ticketbook_manager.clone(), auth_token); + let attestation_watcher = AttestationWatcher::new( + cli.upgrade_mode.attestation_check_regular_polling_interval, + cli.upgrade_mode + .attestation_check_expedited_polling_interval, + cli.upgrade_mode.attestation_check_url, + jwt_signing_keys, + cli.upgrade_mode.upgrade_mode_jwt_validity, + ); + + let api_state = ApiState::new( + ticketbook_manager.clone(), + attestation_watcher.shared_state(), + ); + + // spawn the attestation watcher as a separate task + api_state.try_spawn_in_background(attestation_watcher.run_forever(api_state.shutdown_token())); + + let http_server = HttpServer::new(bind_address, api_state, auth_token); // spawn the http server as a separate task / thread(-ish) http_server.spawn_as_task(); diff --git a/nym-credential-proxy/nym-credential-proxy/src/http/mod.rs b/nym-credential-proxy/nym-credential-proxy/src/http/mod.rs index de37dbba58..c66239ddeb 100644 --- a/nym-credential-proxy/nym-credential-proxy/src/http/mod.rs +++ b/nym-credential-proxy/nym-credential-proxy/src/http/mod.rs @@ -2,8 +2,8 @@ // SPDX-License-Identifier: GPL-3.0-only use crate::http::router::build_router; +use crate::http::state::ApiState; use nym_credential_proxy_lib::error::CredentialProxyError; -use nym_credential_proxy_lib::ticketbook_manager::TicketbookManager; use std::net::SocketAddr; use tracing::info; @@ -12,30 +12,28 @@ pub mod state; pub struct HttpServer { bind_address: SocketAddr, - ticketbook_manager: TicketbookManager, + state: ApiState, auth_token: String, } impl HttpServer { - pub fn new( - bind_address: SocketAddr, - ticketbook_manager: TicketbookManager, - auth_token: String, - ) -> Self { + pub fn new(bind_address: SocketAddr, state: ApiState, auth_token: String) -> Self { HttpServer { bind_address, - ticketbook_manager, + state, auth_token, } } pub fn spawn_as_task(self) { - let cancellation = self.ticketbook_manager.shutdown_token(); + let cancellation = self.state.ticketbooks().shutdown_token(); - let ticketbook_manager = self.ticketbook_manager.clone(); + // not the best name, but that's due to the branch rotting, + // where refactoring would be counter-productive + let ticketbook_manager = self.state.ticketbooks().clone(); ticketbook_manager.try_spawn_in_background(async move { let address = self.bind_address; - let router = build_router(self.ticketbook_manager, self.auth_token); + let router = build_router(self.state, self.auth_token); info!("starting the http server on http://{address}"); let listener = tokio::net::TcpListener::bind(address) diff --git a/nym-credential-proxy/nym-credential-proxy/src/http/router/api/v1/ticketbook/mod.rs b/nym-credential-proxy/nym-credential-proxy/src/http/router/api/v1/ticketbook/mod.rs index e09531af1e..0ccf4633c9 100644 --- a/nym-credential-proxy/nym-credential-proxy/src/http/router/api/v1/ticketbook/mod.rs +++ b/nym-credential-proxy/nym-credential-proxy/src/http/router/api/v1/ticketbook/mod.rs @@ -9,8 +9,8 @@ use nym_credential_proxy_lib::helpers::random_uuid; use nym_credential_proxy_lib::http_helpers::RequestError; use nym_credential_proxy_requests::api::v1::ticketbook::models::{ CurrentEpochResponse, DepositResponse, MasterVerificationKeyResponse, - PartialVerificationKeysResponse, TicketbookAsyncRequest, TicketbookObtainQueryParams, - TicketbookRequest, TicketbookWalletSharesAsyncResponse, TicketbookWalletSharesResponse, + ObtainTicketBookSharesAsyncResponse, PartialVerificationKeysResponse, TicketbookAsyncRequest, + TicketbookObtainQueryParams, TicketbookRequest, TicketbookWalletSharesResponse, }; use nym_credential_proxy_requests::routes::api::v1::ticketbook; use nym_http_api_common::{FormattedResponse, OutputParams}; @@ -25,7 +25,7 @@ pub type FormattedPartialVerificationKeysResponse = pub type FormattedTicketbookWalletSharesResponse = FormattedResponse; pub type FormattedTicketbookWalletSharesAsyncResponse = - FormattedResponse; + FormattedResponse; /// Attempt to obtain blinded shares of an ecash ticketbook wallet #[utoipa::path( @@ -63,7 +63,7 @@ pub(crate) async fn obtain_ticketbook_shares( let output = params.output.unwrap_or_default(); let response = state - .inner_state() + .ticketbooks() .obtain_ticketbook_shares(uuid, payload, params.obtain_params.global) .await .map_err(|err| RequestError::new_server_error(err, uuid))?; @@ -84,8 +84,8 @@ pub(crate) async fn obtain_ticketbook_shares( ), responses( (status = 200, content( - (TicketbookWalletSharesAsyncResponse = "application/json"), - (TicketbookWalletSharesAsyncResponse = "application/yaml"), + (ObtainTicketBookSharesAsyncResponse = "application/json"), + (ObtainTicketBookSharesAsyncResponse = "application/yaml"), )), (status = 400, description = "the provided request hasn't been created against correct attributes"), (status = 401, description = "authentication token is missing or is invalid"), @@ -107,8 +107,13 @@ pub(crate) async fn obtain_ticketbook_shares_async( let uuid = random_uuid(); let output = params.output.unwrap_or_default(); + // 0. check if we're in 'upgrade-mode' - if so, just return the attestation and associated jwt + if let Some(upgrade_mode_response) = state.upgrade_mode_response().await { + return Ok(output.to_response(upgrade_mode_response.into())); + } + let response = state - .inner_state() + .ticketbooks() .obtain_ticketbook_shares_async(uuid, payload, params.obtain_params) .await .map_err(|err| RequestError::new_server_error(err, uuid))?; @@ -142,7 +147,7 @@ pub(crate) async fn current_deposit( let output = output.output.unwrap_or_default(); let response = state - .inner_state() + .ticketbooks() .current_deposit() .await .map_err(RequestError::new_plain_error)?; @@ -177,7 +182,7 @@ pub(crate) async fn partial_verification_keys( let output = output.output.unwrap_or_default(); let response = state - .inner_state() + .ticketbooks() .partial_verification_keys() .await .map_err(RequestError::new_plain_error)?; @@ -212,7 +217,7 @@ pub(crate) async fn master_verification_key( let output = output.output.unwrap_or_default(); let response = state - .inner_state() + .ticketbooks() .master_verification_key() .await .map_err(RequestError::new_plain_error)?; @@ -248,7 +253,7 @@ pub(crate) async fn current_epoch( let output = output.output.unwrap_or_default(); let response = state - .inner_state() + .ticketbooks() .current_epoch() .await .map_err(RequestError::new_plain_error)?; diff --git a/nym-credential-proxy/nym-credential-proxy/src/http/router/api/v1/ticketbook/shares.rs b/nym-credential-proxy/nym-credential-proxy/src/http/router/api/v1/ticketbook/shares.rs index ba460a1ac1..0dd2a8efe2 100644 --- a/nym-credential-proxy/nym-credential-proxy/src/http/router/api/v1/ticketbook/shares.rs +++ b/nym-credential-proxy/nym-credential-proxy/src/http/router/api/v1/ticketbook/shares.rs @@ -43,7 +43,7 @@ pub(crate) async fn query_for_shares_by_id( let output = params.output.unwrap_or_default(); let response = state - .inner_state() + .ticketbooks() .query_for_shares_by_id(uuid, params.global, share_id) .await .map_err(|err| RequestError::new_server_error(err, uuid))?; @@ -80,7 +80,7 @@ pub(crate) async fn query_for_shares_by_device_id_and_credential_id( let output = params.output.unwrap_or_default(); let response = state - .inner_state() + .ticketbooks() .query_for_shares_by_device_id_and_credential_id( uuid, params.global, diff --git a/nym-credential-proxy/nym-credential-proxy/src/http/router/mod.rs b/nym-credential-proxy/nym-credential-proxy/src/http/router/mod.rs index 8b23bbab42..8d8f89dcff 100644 --- a/nym-credential-proxy/nym-credential-proxy/src/http/router/mod.rs +++ b/nym-credential-proxy/nym-credential-proxy/src/http/router/mod.rs @@ -18,7 +18,7 @@ fn swagger_redirect() -> MethodRouter { get(|| async { Redirect::to("/api/v1/swagger/") }) } -pub fn build_router(state: impl Into, auth_token: String) -> Router { +pub fn build_router(state: ApiState, auth_token: String) -> Router { // let auth_layer = from_extractor::(); let auth_middleware = AuthLayer::new(Arc::new(Zeroizing::new(auth_token))); @@ -32,7 +32,7 @@ pub fn build_router(state: impl Into, auth_token: String) -> Router { // we don't have to be using middleware, but we already had that code // we might want something like: https://github.com/tokio-rs/axum/blob/main/examples/tracing-aka-logging/src/main.rs#L44 instead .layer(axum::middleware::from_fn(logging::log_request_info)) - .with_state(state.into()); + .with_state(state); cfg_if::cfg_if! { if #[cfg(feature = "cors")] { diff --git a/nym-credential-proxy/nym-credential-proxy/src/http/state/mod.rs b/nym-credential-proxy/nym-credential-proxy/src/http/state/mod.rs index a7c1b5b2b5..7c2c015ef5 100644 --- a/nym-credential-proxy/nym-credential-proxy/src/http/state/mod.rs +++ b/nym-credential-proxy/nym-credential-proxy/src/http/state/mod.rs @@ -1,21 +1,50 @@ // Copyright 2024 Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only +use crate::http::state::nyx_upgrade_mode::UpgradeModeState; use nym_credential_proxy_lib::ticketbook_manager::TicketbookManager; +use nym_credential_proxy_requests::api::v1::ticketbook::models::UpgradeModeResponse; +use std::future::Future; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + +pub(crate) mod nyx_upgrade_mode; #[derive(Clone)] pub struct ApiState { - inner: TicketbookManager, -} - -impl From for ApiState { - fn from(inner: TicketbookManager) -> Self { - Self { inner } - } + ticketbooks: TicketbookManager, + upgrade_mode: UpgradeModeState, } impl ApiState { - pub(crate) fn inner_state(&self) -> &TicketbookManager { - &self.inner + pub(crate) fn new(ticketbooks: TicketbookManager, upgrade_mode: UpgradeModeState) -> Self { + Self { + ticketbooks, + upgrade_mode, + } + } + + pub(crate) fn ticketbooks(&self) -> &TicketbookManager { + &self.ticketbooks + } + + pub fn shutdown_token(&self) -> CancellationToken { + self.ticketbooks.shutdown_token() + } + + pub(crate) fn try_spawn_in_background(&self, task: F) -> Option> + where + F: Future + Send + 'static, + F::Output: Send + 'static, + { + self.ticketbooks().try_spawn_in_background(task) + } + + pub(crate) async fn upgrade_mode_response(&self) -> Option { + let (upgrade_mode_attestation, jwt) = self.upgrade_mode.attestation_with_jwt().await?; + Some(UpgradeModeResponse { + upgrade_mode_attestation, + jwt, + }) } } diff --git a/nym-credential-proxy/nym-credential-proxy/src/http/state/nyx_upgrade_mode.rs b/nym-credential-proxy/nym-credential-proxy/src/http/state/nyx_upgrade_mode.rs new file mode 100644 index 0000000000..80c94a055f --- /dev/null +++ b/nym-credential-proxy/nym-credential-proxy/src/http/state/nyx_upgrade_mode.rs @@ -0,0 +1,144 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use nym_credential_proxy_requests::CREDENTIAL_PROXY_JWT_ISSUER; +use nym_credential_proxy_requests::api::v1::ticketbook::models::UpgradeModeAttestation; +use nym_crypto::asymmetric::ed25519; +use nym_upgrade_mode_check::generate_jwt_for_upgrade_mode_attestation; +use std::sync::Arc; +use std::time::Duration; +use time::OffsetDateTime; +use tokio::sync::RwLock; + +#[derive(Debug, Clone)] +pub(crate) struct UpgradeModeState { + pub(crate) inner: Arc>>, +} + +impl UpgradeModeState { + pub(crate) async fn has_attestation(&self) -> bool { + self.inner.read().await.is_some() + } + + pub(crate) async fn update( + &self, + retrieved_attestation: Option, + jwt_signing_keys: &ed25519::KeyPair, + jwt_validity: Duration, + ) { + let mut guard = self.inner.write().await; + let Some(attestation) = retrieved_attestation else { + *guard = None; + return; + }; + + match guard.as_mut() { + None => { + // no existing state - it's the first time we're going into upgrade mode, + // so generate the jwt + *guard = Some(UpgradeModeStateInner::new_fresh( + attestation, + jwt_signing_keys, + jwt_validity, + )); + } + Some(current_state) => { + let mut should_refresh = false; + // update the jwt if we have issued one and: + // - either the attestation has changed + // - or the existing jwt is close to expiry + if current_state.attestation != attestation { + should_refresh = true; + } + + if let Some(issued_jwt) = current_state.jwt.as_ref() + && issued_jwt.close_to_expiry() + { + should_refresh = true; + } + + if should_refresh { + current_state.attestation = attestation; + current_state.refresh_jwt(jwt_signing_keys, jwt_validity); + } + } + } + } + + pub(crate) async fn attestation_with_jwt( + &self, + ) -> Option<(UpgradeModeAttestation, Option)> { + let guard = self.inner.read().await; + let inner = guard.as_ref()?; + Some(( + inner.attestation.clone(), + inner.jwt.as_ref().map(|jwt| jwt.token.clone()), + )) + } +} + +#[derive(Debug)] +pub(crate) struct UpgradeModeStateInner { + pub(crate) attestation: UpgradeModeAttestation, + pub(crate) jwt: Option, +} + +impl UpgradeModeStateInner { + fn try_generate_jwt( + attestation: &UpgradeModeAttestation, + jwt_signing_keys: &ed25519::KeyPair, + jwt_validity: Duration, + ) -> Option { + if attestation.authorised_to_issue_jwt(jwt_signing_keys.public_key()) { + Some(Jwt::generate(attestation, jwt_signing_keys, jwt_validity)) + } else { + None + } + } + + fn new_fresh( + attestation: UpgradeModeAttestation, + jwt_signing_keys: &ed25519::KeyPair, + jwt_validity: Duration, + ) -> Self { + let jwt = Self::try_generate_jwt(&attestation, jwt_signing_keys, jwt_validity); + UpgradeModeStateInner { attestation, jwt } + } + + fn refresh_jwt(&mut self, keys: &ed25519::KeyPair, validity: Duration) { + self.jwt = Self::try_generate_jwt(&self.attestation, keys, validity); + } +} + +#[derive(Debug)] +pub(crate) struct Jwt { + pub(crate) issued_at: OffsetDateTime, + pub(crate) issued_for: Duration, + pub(crate) token: String, +} + +impl Jwt { + fn generate( + upgrade_mode_attestation: &UpgradeModeAttestation, + keys: &ed25519::KeyPair, + validity: Duration, + ) -> Self { + Jwt { + issued_at: OffsetDateTime::now_utc(), + issued_for: validity, + token: generate_jwt_for_upgrade_mode_attestation( + upgrade_mode_attestation.clone(), + validity, + keys, + Some(CREDENTIAL_PROXY_JWT_ISSUER), + ), + } + } + + fn close_to_expiry(&self) -> bool { + // less than 20% of validity left + let now = OffsetDateTime::now_utc(); + let validity_threshold = Duration::from_secs_f32(self.issued_for.as_secs_f32() * 0.8); + now - self.issued_at >= validity_threshold + } +} diff --git a/nym-credential-proxy/nym-credential-proxy/src/main.rs b/nym-credential-proxy/nym-credential-proxy/src/main.rs index 8b85fef400..87b6440ab9 100644 --- a/nym-credential-proxy/nym-credential-proxy/src/main.rs +++ b/nym-credential-proxy/nym-credential-proxy/src/main.rs @@ -14,6 +14,7 @@ cfg_if::cfg_if! { pub mod config; pub mod helpers; pub mod http; + pub mod attestation_watcher; } } @@ -40,6 +41,7 @@ async fn main() -> anyhow::Result<()> { #[cfg(not(unix))] #[tokio::main] +#[allow(clippy::exit)] async fn main() -> anyhow::Result<()> { eprintln!("This tool is only supported on Unix systems"); #[allow(clippy::exit)]