Feature/credential proxy jwt (#5957)

* squashed feature/credential-proxy-jwt [#5957]

post rebasing fixes

clippy

changed obtain-async endpoint to conditionally return jwt instead of pending zk-nym

watching for the attestation file and issuing jwt

* changed attestation starting time serialisation into rfc3339

* including authorised JWT issuers in attestation

* reduce attestation retrieval error log
This commit is contained in:
Jędrzej Stuczyński
2025-11-03 16:42:39 +00:00
committed by GitHub
parent e24e094711
commit d9c2f6ebda
27 changed files with 694 additions and 74 deletions
Generated
+9
View File
@@ -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]]
+5
View File
@@ -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"
+1 -1
View File
@@ -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
+7
View File
@@ -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,
@@ -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<TicketbookWalletSharesAsyncResponse, CredentialProxyError> {
) -> Result<ObtainTicketBookSharesAsyncResponse, CredentialProxyError> {
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
+4
View File
@@ -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 = []
@@ -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<S: Serializer>(
keys: &Vec<PublicKey>,
serializer: S,
) -> Result<S::Ok, S::Error> {
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<Vec<PublicKey>, D::Error> {
let wrapped = Vec::<Bs58KeyWrapper>::deserialize(deserializer)?;
Ok(wrapped.into_iter().map(|k| k.0).collect())
}
struct Bs58KeyWrapper(PublicKey);
impl serde::Serialize for Bs58KeyWrapper {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
bs58_ed25519_pubkey::serialize(&self.0, serializer)
}
}
impl<'de> Deserialize<'de> for Bs58KeyWrapper {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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<PublicKey>);
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::<KeysWrapper>(&se_empty)?;
let single_key_de = serde_json::from_str::<KeysWrapper>(&se_single_key)?;
let three_keys_de = serde_json::from_str::<KeysWrapper>(&se_three_keys)?;
assert_eq!(empty, empty_de);
assert_eq!(single_key, single_key_de);
assert_eq!(three_keys, three_keys_de);
Ok(())
}
}
+2
View File
@@ -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
+8 -1
View File
@@ -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
+46 -16
View File
@@ -1,31 +1,43 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// 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<String>))]
pub authorised_jwt_issuers: Vec<ed25519::PublicKey>,
}
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<ed25519::PublicKey>,
) -> 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<ed25519::PublicKey>,
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<Option<UpgradeModeAttestation>, UpgradeModeCheckError> {
let retrieval_failure = |source| UpgradeModeCheckError::AttestationRetrievalFailure {
user_agent: Option<nym_http_api_client::UserAgent>,
) -> Result<Option<UpgradeModeAttestation>, 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);
+3
View File
@@ -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 },
+27 -5
View File
@@ -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
+4 -2
View File
@@ -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;
@@ -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
@@ -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<AggregatedExpirationDateSignaturesResponse>,
}
#[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<UpgradeModeResponse>),
}
impl From<TicketbookWalletSharesAsyncResponse> for ObtainTicketBookSharesAsyncResponse {
fn from(response: TicketbookWalletSharesAsyncResponse) -> Self {
Self::InProgress(response)
}
}
impl From<UpgradeModeResponse> 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<String>,
}
@@ -1,15 +1,12 @@
// Copyright 2024 Nym Technologies SA <contact@nymtech.net>
// 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 {
@@ -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" }
@@ -0,0 +1,97 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// 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)
}
}
}
}
}
}
@@ -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<ed25519::PrivateKey>);
impl FromStr for PrivateKeyCliWrapper {
type Err = Ed25519RecoveryError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
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<PrivateKeyCliWrapper>,
/// 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<PathBuf>,
}
impl JwtSigningKeysArgs {
pub(crate) fn signing_keys(self) -> Result<ed25519::KeyPair, CredentialProxyError> {
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<PathBuf>,
@@ -89,6 +153,44 @@ pub struct Cli {
pub(crate) persistent_storage_path: Option<PathBuf>,
}
#[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")]
@@ -1,6 +1,8 @@
// Copyright 2024 Nym Technologies SA <contact@nymtech.net>
// 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();
@@ -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)
@@ -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<TicketbookWalletSharesResponse>;
pub type FormattedTicketbookWalletSharesAsyncResponse =
FormattedResponse<TicketbookWalletSharesAsyncResponse>;
FormattedResponse<ObtainTicketBookSharesAsyncResponse>;
/// 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)?;
@@ -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,
@@ -18,7 +18,7 @@ fn swagger_redirect<S: Clone + Send + Sync + 'static>() -> MethodRouter<S> {
get(|| async { Redirect::to("/api/v1/swagger/") })
}
pub fn build_router(state: impl Into<ApiState>, auth_token: String) -> Router {
pub fn build_router(state: ApiState, auth_token: String) -> Router {
// let auth_layer = from_extractor::<RequireAuth>();
let auth_middleware = AuthLayer::new(Arc::new(Zeroizing::new(auth_token)));
@@ -32,7 +32,7 @@ pub fn build_router(state: impl Into<ApiState>, 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")] {
@@ -1,21 +1,50 @@
// Copyright 2024 Nym Technologies SA <contact@nymtech.net>
// 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<TicketbookManager> 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<F>(&self, task: F) -> Option<JoinHandle<F::Output>>
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<UpgradeModeResponse> {
let (upgrade_mode_attestation, jwt) = self.upgrade_mode.attestation_with_jwt().await?;
Some(UpgradeModeResponse {
upgrade_mode_attestation,
jwt,
})
}
}
@@ -0,0 +1,144 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// 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<RwLock<Option<UpgradeModeStateInner>>>,
}
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<UpgradeModeAttestation>,
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<String>)> {
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<Jwt>,
}
impl UpgradeModeStateInner {
fn try_generate_jwt(
attestation: &UpgradeModeAttestation,
jwt_signing_keys: &ed25519::KeyPair,
jwt_validity: Duration,
) -> Option<Jwt> {
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
}
}
@@ -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)]