diff --git a/.gitignore b/.gitignore index 8d27b217..6a59324b 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ npm-debug.log* yarn-debug.log* yarn-error.log* +# Concordium account key files +*.export + target dist .eslintcache diff --git a/Cargo.lock b/Cargo.lock index e93b8f3a..bb606a52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -301,6 +301,19 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" +[[package]] +name = "async-compression" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89bce6054c720275ac2432fbba080a66a2106a44a1b804553930ca6909f4e0" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -638,6 +651,8 @@ version = "1.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1599538de2394445747c8cf7935946e3cc27e9625f889d979bfb2aaf569362" dependencies = [ + "jobserver", + "libc", "shlex", ] @@ -777,6 +792,23 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "compression-codecs" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "302266479cb963552d11bd042013a58ef1adc56768016c8b82b4199488f2d4ad" +dependencies = [ + "compression-core", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + [[package]] name = "concordium-contracts-common" version = "9.2.0" @@ -932,6 +964,7 @@ dependencies = [ "ed25519-dalek", "either", "ff 0.13.1", + "generic-array", "hex", "hmac", "itertools 0.14.0", @@ -1049,8 +1082,11 @@ dependencies = [ "prometheus-client", "serde", "serde_json", + "thiserror 2.0.17", "tokio", "tokio-util", + "tonic", + "tower-http", "tracing", "tracing-subscriber", ] @@ -2365,6 +2401,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.77" @@ -4481,8 +4527,10 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ + "async-compression", "bitflags", "bytes", + "futures-core", "futures-util", "http 1.3.1", "http-body 1.0.1", @@ -4490,6 +4538,7 @@ dependencies = [ "iri-string", "pin-project-lite", "tokio", + "tokio-util", "tower 0.5.2", "tower-layer", "tower-service", @@ -5271,3 +5320,31 @@ dependencies = [ "quote", "syn 2.0.104", ] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/credential-verification-service/CHANGELOG.md b/credential-verification-service/CHANGELOG.md new file mode 100644 index 00000000..38865bf5 --- /dev/null +++ b/credential-verification-service/CHANGELOG.md @@ -0,0 +1,5 @@ +## Unreleased + +- Added account nonce management to the service. +- Added the logic of the `/verifiable-presentations/create-verification-request` api endpoint flow. This endpoint submits the `verification-request-anchor (VRA)` on-chain. +- Initial service. diff --git a/credential-verification-service/Cargo.toml b/credential-verification-service/Cargo.toml index dd90eb8c..a2a24d9f 100644 --- a/credential-verification-service/Cargo.toml +++ b/credential-verification-service/Cargo.toml @@ -18,6 +18,11 @@ tokio-util.workspace = true prometheus-client.workspace = true tracing.workspace = true tracing-subscriber.workspace = true +tonic = {workspace = true, features = ["tls", "tls-roots"]} +thiserror.workspace = true +tower-http = {workspace = true, features = [ + "compression-zstd", +] } [[bin]] name = "credential-verification-service" diff --git a/credential-verification-service/README.md b/credential-verification-service/README.md index fad2f8e3..6eab2958 100644 --- a/credential-verification-service/README.md +++ b/credential-verification-service/README.md @@ -25,12 +25,38 @@ docker run --rm \ credential-verification-service ``` - -you should then be able to curl the health endpoint from outside the container, for example: +You should then be able to curl the health endpoint from outside the container, for example: `curl http://localhost:8001/health` +## Build the service from the source code + +You can build the serive locally as follows: +``` +cargo build +``` + +## Run the servie from the source code + +You can run the serive locally as follows: + +``` +cargo run -- --node-endpoint https://grpc.testnet.concordium.com:20000 --account 4bbdAUCDK2D6cUvUeprGr4FaSaHXKuYmYVjyCa4bXSCu3NUXzA.export +``` + +## Configuration options + +The following options are supported: + +- `--node-endpoint [env: CREDENTIAL_VERIFICATION_SERVICE_NODE_GRPC_ENDPOINT]`: the URL of the node's GRPC V2 interface, e.g., http://node.testnet.concordium.com:20000 +- `--request-timeout [env: CREDENTIAL_VERIFICATION_SERVICE_REQUEST_TIMEOUT]`: The request timeout for a request to be processed with the credential service api in milliseconds (defaults to 15 seconds if not given). +- `--grpc-node-request-timeout [env: CREDENTIAL_VERIFICATION_GRPC_NODE_REQUEST_TIMEOUT]`: The request timeout to the Concordium node in milliseconds (defaults to 1 second if not given). +- `--log-level [env: CREDENTIAL_VERIFICATION_SERVICE_LOG_LEVEL]`: The log level (defaults to info if not given). +- `--account [env: CREDENTIAL_VERIFICATION_SERVICE_ACCOUNT]`: The path to the account key file. +- `--api-address [env: CREDENTIAL_VERIFICATION_SERVICE_API_ADDRESS]`: The socket address where the service exposes its API (defaults to `127.0.0.1:8000` if not given). +- `--monitoring-address [env: CREDENTIAL_VERIFICATION_SERVICE_MONITORING_ADDRESS]`: The socket address used for health and metrics monitoring (defaults to `127.0.0.1:8001` if not given). +- `--transaction-expiry [env: CREDENTIAL_VERIFICATION_SERVICE_TRANSACTION_EXPIRY]`: The number of seconds in the future when the anchor transactions should expiry (defaults to 15 seconds if not given). ## API Documentation @@ -67,4 +93,3 @@ Diagrams and Sample Payloads: ## Architecture - 🗺️ [Architecture Overview](docs/architecture.md) - diff --git a/credential-verification-service/src/api.rs b/credential-verification-service/src/api.rs index b559e410..18314c22 100644 --- a/credential-verification-service/src/api.rs +++ b/credential-verification-service/src/api.rs @@ -1,17 +1,29 @@ -use axum::{Router, routing::get}; +use crate::types::Service; +use axum::{ + Router, + routing::{get, post}, +}; use prometheus_client::registry::Registry; use std::sync::Arc; -use crate::service::Service; - mod monitoring; +mod verification_request; mod verifier; /// Router exposing the service's endpoints -pub fn router(service: Arc) -> Router { +pub fn router(service: Arc, request_timeout: u64) -> Router { Router::new() - .route("/verify", get(verifier::verify)) + .route("/verifiable-presentations/verify", post(verifier::verify)) + .route( + "/verifiable-presentations/create-verification-request", + post(verification_request::create_verification_request), + ) .with_state(service) + .layer(tower_http::timeout::TimeoutLayer::new( + std::time::Duration::from_millis(request_timeout), + )) + .layer(tower_http::limit::RequestBodyLimitLayer::new(1_000_000)) // at most 1000kB of data. + .layer(tower_http::compression::CompressionLayer::new()) } /// Router exposing the Prometheus metrics and health endpoint. diff --git a/credential-verification-service/src/api/verification_request.rs b/credential-verification-service/src/api/verification_request.rs new file mode 100644 index 00000000..27e092ff --- /dev/null +++ b/credential-verification-service/src/api/verification_request.rs @@ -0,0 +1,127 @@ +//! Handler for create-verification-request endpoint. +use crate::{ + api_types::CreateVerificationRequest, + types::{ServerError, Service}, +}; +use axum::{Json, extract::State}; +use concordium_rust_sdk::{ + base::web3id::v1::anchor::{ + LabeledContextProperty, UnfilledContextInformationBuilder, VerificationRequest, + VerificationRequestDataBuilder, + }, + common::types::TransactionTime, + v2::{QueryError, RPCError}, + web3id::v1::{ + AnchorTransactionMetadata, CreateAnchorError::Query, + create_verification_request_and_submit_request_anchor, + }, +}; +use std::sync::Arc; + +pub async fn create_verification_request( + State(state): State>, + Json(params): Json, +) -> Result, ServerError> { + let context = UnfilledContextInformationBuilder::new_simple( + params.nonce, + params.connection_id, + params.context_string, + ) + .given(LabeledContextProperty::ResourceId(params.rescource_id)) + .build(); + + let mut builder = VerificationRequestDataBuilder::new(context); + for claim in params.requested_claims { + builder = builder.subject_claim(claim); + } + let verification_request_data = builder.build(); + + // Transaction should expiry after some seconds. + let expiry = TransactionTime::seconds_after(state.transaction_expiry_secs); + + let mut node_client = state.node_client.clone(); + + // Get the current nonce for the backend wallet and lock it. This is necessary + // since it is possible that API requests come in parallel. The nonce is + // increased by 1 and its lock is released after the transaction is submitted to + // the blockchain. + let mut account_sequence_number = state.nonce.lock().await; + + let anchor_transaction_metadata = AnchorTransactionMetadata { + signer: &state.account_keys, + sender: state.account_keys.address, + account_sequence_number: *account_sequence_number, + expiry, + }; + + let verification_request = create_verification_request_and_submit_request_anchor( + &mut node_client, + anchor_transaction_metadata, + verification_request_data.clone(), + None, + ) + .await; + + match verification_request { + Ok(req) => { + // If the submission of the anchor transaction was successful, + // increase the account_sequence_number tracked in this service. + *account_sequence_number = account_sequence_number.next(); + Ok(Json(req)) + } + + Err(e) => { + // If the error is due to an account sequence number mismatch, + // refresh the value in the state and try to resubmit the transaction. + if let Query(QueryError::RPCError(RPCError::CallError(ref err))) = e { + let msg = err.message(); + let is_nonce_err = msg == "Duplicate nonce" || msg == "Nonce too large"; + + if is_nonce_err { + tracing::warn!( + "Unable to submit transaction on-chain successfully due to account nonce mismatch: {}. + Account nonce will be re-freshed and transaction will be re-submitted.", + msg + ); + + // Refresh nonce + let nonce_response = node_client + .get_next_account_sequence_number(&state.account_keys.address) + .await + .map_err(|e| ServerError::SubmitAnchorTransaction(e.into()))?; + + *account_sequence_number = nonce_response.nonce; + + tracing::info!("Refreshed account nonce successfully."); + + // Retry anchor transaction. + let meta = AnchorTransactionMetadata { + signer: &state.account_keys, + sender: state.account_keys.address, + account_sequence_number: nonce_response.nonce, + expiry, + }; + + let verification_request = + create_verification_request_and_submit_request_anchor( + &mut node_client, + meta, + verification_request_data, + None, + ) + .await?; + + tracing::info!( + "Successfully submitted anchor transaction after the account nonce was refreshed." + ); + + *account_sequence_number = account_sequence_number.next(); + + return Ok(Json(verification_request)); + } + } + + Err(ServerError::SubmitAnchorTransaction(e)) + } + } +} diff --git a/credential-verification-service/src/api/verifier.rs b/credential-verification-service/src/api/verifier.rs index 263a671b..bc5a893a 100644 --- a/credential-verification-service/src/api/verifier.rs +++ b/credential-verification-service/src/api/verifier.rs @@ -1,5 +1,8 @@ -//! Handlers for verification endpoints. +//! Handler for the verification endpoints. +use crate::types::Service; +use axum::{Json, extract::State}; +use std::sync::Arc; -pub async fn verify() -> Result { - Ok("Verified".to_owned()) +pub async fn verify(_state: State>, Json(_payload): Json) -> Json { + Json("ok".to_string()) } diff --git a/credential-verification-service/src/api_types.rs b/credential-verification-service/src/api_types.rs new file mode 100644 index 00000000..de94aac6 --- /dev/null +++ b/credential-verification-service/src/api_types.rs @@ -0,0 +1,25 @@ +use concordium_rust_sdk::base::web3id::v1::anchor::{self, RequestedSubjectClaims}; + +/// Parameters posted to this service when calling the API +/// endpoint `/verifiable-presentations/create-verification-request`. +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +pub struct CreateVerificationRequest { + /// The nonce included in the verification request context. + /// This nonce must be freshly and randomly generated for each request so that the + /// verification request cannot be inferred from the on-chain request anchor hash + /// by attempting to guess its preimage. + /// In short: to keep the anchor hash random, this nonce must be truly random. + pub nonce: anchor::Nonce, + /// An identifier for some connection (e.g. wallet-connect topic) included in the verification request context. + pub connection_id: String, + /// A rescource id to track the connected website (e.g. website URL or TLS fingerprint). + pub rescource_id: String, + /// A general purpose string value included in the verification request context. + pub context_string: String, + /// The subject claims being requested to be proven. + pub requested_claims: Vec, + // TODO: Remaining missing field + // Additional public info which will be included in the anchor transaction (VRA) + // that is submitted on-chain. + // pub public_info: HashMap, +} diff --git a/credential-verification-service/src/configs.rs b/credential-verification-service/src/configs.rs index bb6eab3d..2666a75d 100644 --- a/credential-verification-service/src/configs.rs +++ b/credential-verification-service/src/configs.rs @@ -1,31 +1,58 @@ use clap::Parser; use concordium_rust_sdk::v2; - use std::{net::SocketAddr, path::PathBuf}; #[derive(Parser, Debug)] #[clap(arg_required_else_help(true))] pub struct ServiceConfigs { #[arg(long, env = "CREDENTIAL_VERIFICATION_SERVICE_NODE_GRPC_ENDPOINT")] - pub node_address: v2::Endpoint, + pub node_endpoint: v2::Endpoint, + #[arg( + long = "request-timeout", + help = "The request timeout for a request to be processed with the credential service api in milliseconds.", + default_value = "15000", + env = "CREDENTIAL_VERIFICATION_SERVICE_REQUEST_TIMEOUT" + )] + pub request_timeout: u64, + #[arg( + long = "grpc-node-request-timeout", + help = "The request timeout to the Concordium node in milliseconds", + default_value = "1000", + env = "CREDENTIAL_VERIFICATION_GRPC_NODE_REQUEST_TIMEOUT" + )] + pub grpc_node_request_timeout: u64, #[arg( long, - env = "CREDENTIAL_VERIFICATION_SERVICE_API_ADDRESS", - default_value = "127.0.0.1:8000" + help = "The socket address where the service exposes its API.", + default_value = "127.0.0.1:8000", + env = "CREDENTIAL_VERIFICATION_SERVICE_API_ADDRESS" )] pub api_address: SocketAddr, #[arg( long, - env = "CREDENTIAL_VERIFICATION_SERVICE_MONITORING_ADDRESS", - default_value = "127.0.0.1:8001" + help = "The socket address used for health and metrics monitoring.", + default_value = "127.0.0.1:8001", + env = "CREDENTIAL_VERIFICATION_SERVICE_MONITORING_ADDRESS" )] pub monitoring_address: SocketAddr, #[arg( long, - env = "CREDENTIAL_VERIFICATION_SERVICE_ACCOUNT", - help = "Path to the wallet keys." + help = "Path to the wallet keys.", + env = "CREDENTIAL_VERIFICATION_SERVICE_ACCOUNT" )] pub account: PathBuf, - #[arg(long, default_value = "info", env = "LOG_LEVEL")] + #[arg( + long = "transaction-expiry", + help = "The number of seconds in the future when the anchor transactions should expiry.", + default_value = "15", + env = "CREDENTIAL_VERIFICATION_SERVICE_TRANSACTION_EXPIRY" + )] + pub transaction_expiry_secs: u32, + #[arg( + long, + help = "The log level [`off`, `error`, `warn`, `info`, `debug`, or `trace`]", + default_value = "info", + env = "CREDENTIAL_VERIFICATION_SERVICE_LOG_LEVEL" + )] pub log_level: tracing_subscriber::filter::LevelFilter, } diff --git a/credential-verification-service/src/lib.rs b/credential-verification-service/src/lib.rs index e963cccb..69264085 100644 --- a/credential-verification-service/src/lib.rs +++ b/credential-verification-service/src/lib.rs @@ -1,4 +1,6 @@ +pub mod api_types; pub mod configs; pub mod service; +pub mod types; mod api; diff --git a/credential-verification-service/src/service.rs b/credential-verification-service/src/service.rs index 141d63fc..5acd6690 100644 --- a/credential-verification-service/src/service.rs +++ b/credential-verification-service/src/service.rs @@ -1,23 +1,19 @@ -use anyhow::Context; +use crate::{api, configs::ServiceConfigs, types::Service}; +use anyhow::{Context, anyhow}; use concordium_rust_sdk::{ + constants::{MAINNET_GENESIS_BLOCK_HASH, TESTNET_GENESIS_BLOCK_HASH}, types::WalletAccount, - v2::{self, Client}, + v2::{self}, + web3id::did::Network, }; use futures_util::TryFutureExt; -use prometheus_client::metrics; -use prometheus_client::registry::Registry; +use prometheus_client::{metrics, registry::Registry}; use std::sync::Arc; -use tokio::net::TcpListener; +use tokio::{net::TcpListener, sync::Mutex}; use tokio_util::{sync::CancellationToken, task::TaskTracker}; +use tonic::transport::ClientTlsConfig; use tracing::{error, info}; -use crate::{api, configs::ServiceConfigs}; - -pub struct Service { - pub client: v2::Client, - pub keys: WalletAccount, -} - pub async fn run(configs: ServiceConfigs) -> anyhow::Result<()> { let service_info = metrics::info::Info::new([("version", clap::crate_version!().to_string())]); let mut metrics_registry = Registry::default(); @@ -28,9 +24,59 @@ pub async fn run(configs: ServiceConfigs) -> anyhow::Result<()> { metrics::gauge::ConstGauge::new(chrono::Utc::now().timestamp_millis()), ); - let client = Client::new(configs.node_address).await?; - let keys = WalletAccount::from_json_file(configs.account)?; - let service = Arc::new(Service { client, keys }); + let endpoint = configs + .node_endpoint + .tls_config(ClientTlsConfig::new()) + .context("Unable to construct TLS configuration for Concordium node.")?; + + let node_timeout = std::time::Duration::from_millis(configs.grpc_node_request_timeout); + + let endpoint = endpoint + .connect_timeout(node_timeout) + .timeout(node_timeout) + .keep_alive_while_idle(true); + + let mut node_client = v2::Client::new(endpoint) + .await + .context("Unable to establish connection to the node.")?; + + // Load account keys and sender address from a file + let keys: WalletAccount = + WalletAccount::from_json_file(configs.account).context("Could not read the keys file.")?; + + let account_keys = Arc::new(keys); + + let nonce_response = node_client + .get_next_account_sequence_number(&account_keys.address) + .await + .context("Unable to query the next account sequence number.")?; + + let nonce = Arc::new(Mutex::new(nonce_response.nonce)); + + let consensus_info = node_client + .get_consensus_info() + .await + .context("Unable to query the consesnsus info from the chain")?; + let genesis_hash = consensus_info.genesis_block.bytes; + + let network = match genesis_hash { + hash if hash == TESTNET_GENESIS_BLOCK_HASH => Network::Testnet, + hash if hash == MAINNET_GENESIS_BLOCK_HASH => Network::Mainnet, + _ => { + return Err(anyhow!( + "Only TESTNET/MAINNET supported. Unknown genesis hash: {:?}", + genesis_hash + )); + } + }; + + let service = Arc::new(Service { + node_client, + account_keys, + nonce, + transaction_expiry_secs: configs.transaction_expiry_secs, + network, + }); let cancel_token = CancellationToken::new(); let monitoring_task = { @@ -55,9 +101,12 @@ pub async fn run(configs: ServiceConfigs) -> anyhow::Result<()> { .await .context("Failed to parse API TCP address")?; let stop_signal = cancel_token.child_token(); - info!("Server is running at {:?}", configs.api_address); + info!( + "API server is running at {:?} with account {} and current account nonce: {}.", + configs.api_address, service.account_keys.address, nonce_response.nonce + ); - axum::serve(listener, api::router(service)) + axum::serve(listener, api::router(service, configs.request_timeout)) .with_graceful_shutdown(stop_signal.cancelled_owned()) .into_future() }; diff --git a/credential-verification-service/src/types.rs b/credential-verification-service/src/types.rs new file mode 100644 index 00000000..9db3563e --- /dev/null +++ b/credential-verification-service/src/types.rs @@ -0,0 +1,45 @@ +use axum::{Json, http::StatusCode}; +use concordium_rust_sdk::{ + types::{Nonce, WalletAccount}, + v2, + web3id::{did::Network, v1::CreateAnchorError}, +}; +use std::sync::Arc; +use tokio::sync::Mutex; + +/// Holds the service state in memory. +/// +/// Note: A new instance of this struct is created whenever the service restarts. +pub struct Service { + /// The client to interact with the node. + pub node_client: v2::Client, + /// The network of the connected node. + pub network: Network, + /// The key and address of the account submitting the anchor transactions on-chain. + pub account_keys: Arc, + /// The current nonce of the account submitting the anchor transactions on-chain. + pub nonce: Arc>, + /// The number of seconds in the future when the anchor transactions should expiry. + pub transaction_expiry_secs: u32, +} + +#[derive(Debug, thiserror::Error)] +pub enum ServerError { + #[error("Unable to submit anchor transaction on chain successfully: {0}.")] + SubmitAnchorTransaction(#[from] CreateAnchorError), +} + +impl axum::response::IntoResponse for ServerError { + fn into_response(self) -> axum::response::Response { + let r = match self { + ServerError::SubmitAnchorTransaction(error) => { + tracing::error!("Internal error: {error}."); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json("Internal error.".to_string()), + ) + } + }; + r.into_response() + } +} diff --git a/deps/concordium-rust-sdk b/deps/concordium-rust-sdk index e6c421b6..e8e4b010 160000 --- a/deps/concordium-rust-sdk +++ b/deps/concordium-rust-sdk @@ -1 +1 @@ -Subproject commit e6c421b654747b51e4b0ca55a5a0225cd0bd6b4e +Subproject commit e8e4b0109d51d13bf99c0d5aaad9959d7c90420e