Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
245 changes: 218 additions & 27 deletions Cargo.lock

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion agent_api_rest/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,30 @@ anyhow.workspace = true
axum.workspace = true
axum-auth = "0.8"
axum-macros = "0.5"
base64.workspace = true
chrono.workspace = true
cqrs-es.workspace = true
did_manager = { git = "https://github.com/impierce/did-manager", tag = "v1.0.0-beta.6" }
futures.workspace = true
http = "1.3"
http-api-problem = { version = "0.60", features = ["axum", "api-error"] }
http-body-util = "0.1"
hyper = { version = "1.2" }
identity_core.workspace = true
identity_credential.workspace = true
identity_did.workspace = true
identity_iota.workspace = true
identity_jose = { git = "https://github.com/iotaledger/identity", tag = "v1.6.0-beta.7" }
jsonwebtoken.workspace = true
metrics = "0.24"
metrics-exporter-prometheus = { version = "0.17", default-features = false }
oauth_tsl.workspace = true
oid4vc = { git = "https://github.com/impierce/openid4vc", rev = "4cbf854" }
oid4vc-core.workspace = true
oid4vci.workspace = true
oid4vp.workspace = true
rand.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_with.workspace = true
serde_json.workspace = true
Expand Down Expand Up @@ -61,7 +69,6 @@ agent_verification = { path = "../agent_verification", features = [
"test_utils",
] }

futures.workspace = true
jsonwebtoken.workspace = true
lazy_static.workspace = true
mime.workspace = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ pub mod tests {
Mock, MockServer, ResponseTemplate,
};

const CREDENTIAL_JWT: &str = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0I3o2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCJ9.eyJpc3MiOiJkaWQ6a2V5Ono2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCIsInN1YiI6ImRpZDprZXk6ejZNa2lpZXlvTE1TVnNKQVp2N0pqZTV3V1NrREV5bVVna3lGOGtiY3JqWnBYM3FkIiwibmJmIjoxMjYyMzA0MDAwLCJpYXQiOjEyNjIzMDQwMDAsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIl0sInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiXSwiY3JlZGVudGlhbFN1YmplY3QiOnsiaWQiOiJkaWQ6a2V5Ono2TWtpaWV5b0xNU1ZzSkFadjdKamU1d1dTa0RFeW1VZ2t5RjhrYmNyalpwWDNxZCIsImZpcnN0X25hbWUiOiJGZXJyaXMiLCJsYXN0X25hbWUiOiJSdXN0YWNlYW4ifSwiaXNzdWVyIjoiZGlkOmtleTp6Nk1rZ0U4NE5DTXBNZUF4OWpLOWNmNVc0RzhnY1o5eHV3SnZHMWU3d05rOEtDZ3QiLCJpc3N1YW5jZURhdGUiOiIyMDEwLTAxLTAxVDAwOjAwOjAwWiIsImNyZWRlbnRpYWxTdGF0dXMiOnsiaWQiOiJodHRwczovL215LWRvbWFpbi5leGFtcGxlLm9yZy9pZXRmLW9hdXRoLXRva2VuLXN0YXR1cy1saXN0LzAiLCJ0eXBlIjoic3RhdHVzbGlzdCtqd3QiLCJpZHgiOjEyMywidXJpIjoiaHR0cHM6Ly9teS1kb21haW4uZXhhbXBsZS5vcmcvaWV0Zi1vYXV0aC10b2tlbi1zdGF0dXMtbGlzdC8wIn19LCJzdGF0dXMiOnsic3RhdHVzX2xpc3QiOnsiaWR4IjoxMjMsInVyaSI6Imh0dHBzOi8vbXktZG9tYWluLmV4YW1wbGUub3JnL2lldGYtb2F1dGgtdG9rZW4tc3RhdHVzLWxpc3QvMCJ9fX0.LpNq8l-qqqCA-htsB8KZLaVoNCfxqTrsPxVmEj0dsPAGFhOqO8lXI7DU0FhNwzWedxJ1ySS_Vq7ChBW-TgY7Bw";
const CREDENTIAL_JWT: &str = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0I3o2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCJ9.eyJpc3MiOiJkaWQ6a2V5Ono2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCIsInN1YiI6ImRpZDprZXk6ejZNa2lpZXlvTE1TVnNKQVp2N0pqZTV3V1NrREV5bVVna3lGOGtiY3JqWnBYM3FkIiwibmJmIjoxMjYyMzA0MDAwLCJpYXQiOjEyNjIzMDQwMDAsImp0aSI6InRlc3QtY3JlZGVudGlhbC1pZC0xMjM0NSIsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIl0sInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiXSwiY3JlZGVudGlhbFN1YmplY3QiOnsiaWQiOiJkaWQ6a2V5Ono2TWtpaWV5b0xNU1ZzSkFadjdKamU1d1dTa0RFeW1VZ2t5RjhrYmNyalpwWDNxZCIsImZpcnN0X25hbWUiOiJGZXJyaXMiLCJsYXN0X25hbWUiOiJSdXN0YWNlYW4ifSwiaXNzdWVyIjoiZGlkOmtleTp6Nk1rZ0U4NE5DTXBNZUF4OWpLOWNmNVc0RzhnY1o5eHV3SnZHMWU3d05rOEtDZ3QiLCJpc3N1YW5jZURhdGUiOiIyMDEwLTAxLTAxVDAwOjAwOjAwWiIsImNyZWRlbnRpYWxTdGF0dXMiOnsiaWQiOiJodHRwczovL215LWRvbWFpbi5leGFtcGxlLm9yZy9pZXRmLW9hdXRoLXRva2VuLXN0YXR1cy1saXN0LzAiLCJ0eXBlIjoic3RhdHVzbGlzdCtqd3QiLCJpZHgiOjEyMywidXJpIjoiaHR0cHM6Ly9teS1kb21haW4uZXhhbXBsZS5vcmcvaWV0Zi1vYXV0aC10b2tlbi1zdGF0dXMtbGlzdC8wIn19LCJzdGF0dXMiOnsic3RhdHVzX2xpc3QiOnsiaWR4IjoxMjMsInVyaSI6Imh0dHBzOi8vbXktZG9tYWluLmV4YW1wbGUub3JnL2lldGYtb2F1dGgtdG9rZW4tc3RhdHVzLWxpc3QvMCJ9fX0.jtRMtLPjz8ji3XiJVEkAu65LMO-UT8Mmbw4hoyXaOO4qHZwdcjbAf755Bs6LqMykN5iWxNrgoZ4-s3Aee335AQ";
const DEFAULT_EXTERNAL_SERVER_RESPONSE_TIMEOUT_MS: u64 = 1000;

trait CredentialEventTrigger {
Expand Down
8 changes: 5 additions & 3 deletions agent_api_rest/src/issuance/mod.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
pub mod credential_configurations;
pub mod credential_issuer;
pub mod credentials;
pub mod offers;

pub mod error;
pub mod offers;
pub mod public_credential;

use crate::issuance::{
credential_configurations::credential_configurations,
Expand All @@ -19,6 +19,7 @@ use crate::issuance::{
},
credentials::{all_credentials, credentials, patch_credential},
offers::{all_offers, offer, offers, send::send},
public_credential::public_credential,
};
use crate::API_VERSION;
use agent_issuance::state::IssuanceState;
Expand All @@ -38,7 +39,8 @@ pub fn router(issuance_state: IssuanceState) -> Router {
.route("/credential-configurations", post(credential_configurations))
.route("/offers", post(offers).get(all_offers))
.route("/offers/{offer_id}", get(offer))
.route("/offers/send", post(send)),
.route("/offers/send", post(send))
.route("/public-credential", get(public_credential)),
)
.route(
"/.well-known/oauth-authorization-server",
Expand Down
264 changes: 264 additions & 0 deletions agent_api_rest/src/issuance/public_credential.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
use crate::handlers::query_handler;
use agent_holder::credential::aggregate::get_unverified_jwt_claims;
use agent_issuance::state::IssuanceState;
use agent_secret_manager::subject::get_public_key_from_kid;
use agent_shared::config::{get_all_enabled_did_methods, get_all_enabled_signing_algorithms_supported};
use axum::{
extract::{Query, State},
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use http_api_problem::ApiError;
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
use serde::Deserialize;
use tracing::info;

#[derive(Deserialize)]
pub struct PublicLinkQuery {
#[serde(rename = "public-credential-token")]
public_credential_token: String,
}

/// This endpoint receives a Public Credential Token as a query parameter and then perform several validation steps on the token.
/// When all validations pass, the requested credential is returned in the response.
/// When any validation fails, only the error is returned.
/// Both the verifier and the Issuer need to perform all these checks on the Public Credential Token, zero trust is assumed.
pub async fn public_credential(
State(state): State<IssuanceState>,
Query(parameter): Query<PublicLinkQuery>,
) -> Result<Response, ApiError> {
let jwt = parameter.public_credential_token;

// Get unverified claims
let jwt_value = serde_json::Value::String(jwt.clone());
let claims = get_unverified_jwt_claims(&jwt_value).map_err(|_| {
ApiError::builder(StatusCode::BAD_REQUEST)
.title("Invalid JWT")
.message("Failed to decode Public Credential Token")
.finish()
})?;

// The subject of the Public Credential Token is the JTI (credential ID) of the issued credential which a Verifier is trying to access
let sub = claims.get("sub").and_then(|v| v.as_str()).ok_or_else(|| {
ApiError::builder(StatusCode::BAD_REQUEST)
.title("Invalid Token")
.message("Failed to get `sub` claim from Public Credential Token")
.finish()
})?;

// Because the JTI needs to be a valid URL, we appended the credential ID to the issuer URL of the Public Credential Token.
// This means only the last segment of the `sub` claim is the actual credential ID.
let credential_id = sub.rsplit('/').next().ok_or_else(|| {
ApiError::builder(StatusCode::BAD_REQUEST)
.title("Invalid Token")
.message("Failed to parse credential ID from `sub` claim in Public Credential Token")
.finish()
})?;

// Get the credential, if there is one for the given `sub`
let credential = query_handler(credential_id, &state.query.credential)
.await?
.ok_or_else(|| {
ApiError::builder(StatusCode::NOT_FOUND)
.title("Invalid Token")
.message("Public Credential Token `sub` claim does not hold a credential ID to a valid credential")
.finish()
})?;

info!("credential data: {:#?}", credential.signed);
let signed_credential = credential.signed.ok_or(
ApiError::builder(StatusCode::INTERNAL_SERVER_ERROR)
.title("Invalid internal credential stored for given credential ID")
.message("Unable to read the JWT of the requested credential")
.finish(),
)?;
let data = get_unverified_jwt_claims(&signed_credential).map_err(|_| {
ApiError::builder(StatusCode::INTERNAL_SERVER_ERROR)
.title("Invalid internal credential stored for given credential ID")
.message("Unable to read the JWT of the requested credential")
.finish()
})?;

// Extract credential subject ID (we need as_ref to avoid moving credential)
let credential_subject_id = data
.get("vc")
.and_then(|vc| vc.get("credentialSubject"))
.and_then(|cs| cs.get("id"))
.and_then(|id| id.as_str())
.ok_or_else(|| {
ApiError::builder(StatusCode::UNPROCESSABLE_ENTITY)
.title("Invalid Credential")
.message("Requested credential is missing the credentialSubject.id field. Publicly sharing anonymous credentials is not supported.")
.finish()
})?;

// Extract iss from claims and validate
let iss = claims.get("iss").and_then(|v| v.as_str()).ok_or_else(|| {
ApiError::builder(StatusCode::BAD_REQUEST)
.title("Invalid Token")
.message("Failed to get `iss` claim from Public Credential Token")
.finish()
})?;

info!("issuer of Public Credential Token: {}", iss);
info!(
"credential subject ID of requested credential: {}",
credential_subject_id
);

// Check whether the issuer of the Public Credential Token matches the subject of the requested credential
// TODO
// This check means we currently don't allow to publicly share anonymous credentials
// if iss != credential_subject_id {
// return Err(ApiError::builder(StatusCode::UNAUTHORIZED)
// .title("Invalid Token")
// .message("Public Credential Token issuer does not match requested credential subject")
// .finish());
// }

// TODO: validate status

// TODO: skip `aud` validation for now
// // Extract the `aud` claim
// let aud = claims.get("aud").and_then(|v| v.as_str()).ok_or_else(|| {
// ApiError::builder(StatusCode::BAD_REQUEST)
// .title("Invalid Token")
// .message("Failed to get `aud` claim from Public Credential Token")
// .finish()
// })?;

// // Validate that the `aud` matches an enabled DID of this Unicorn instance
// let supported_signing_algorithms = get_all_enabled_signing_algorithms_supported();
// let enabled_did_methods = get_all_enabled_did_methods();

// let mut dids = Vec::new();

// // TODO: this is ugly but for now the easiest way for me to get all did_methods for the hackathon
// // In fact i don't need the full identifier (kid), just the DID.
// for did_method in &enabled_did_methods {
// for alg in &supported_signing_algorithms {
// let did = state
// .subject
// .identifier(did_method.to_string().as_ref(), *alg)
// .await
// .map_err(|_| ApiError::builder(StatusCode::INTERNAL_SERVER_ERROR).finish())?;

// dids.push(
// did.split('#')
// .next()
// .ok_or(ApiError::builder(StatusCode::INTERNAL_SERVER_ERROR))?
// .to_string(),
// );
// }
// }

// if !dids.contains(&aud.to_string()) {
// return Err(ApiError::builder(StatusCode::UNAUTHORIZED)
// .title("Invalid Token")
// .message("Public Credential Token audience does not match this holder's DID")
// .finish());
// }

// Decode header to get kid
let jwt_header = decode_header(&jwt).map_err(|e| {
ApiError::builder(StatusCode::BAD_REQUEST)
.title("Invalid Token")
.message(format!("Failed to decode Public Credential Token header: {e}"))
.finish()
})?;

let kid = jwt_header.kid.ok_or_else(|| {
ApiError::builder(StatusCode::BAD_REQUEST)
.title("Invalid Token")
.message("Failed to get `kid` from Public Credential Token header")
.finish()
})?;

// Validate the kid belongs to the same DID as credential subject
// TODO
// let kid_did = kid.split('#').next().unwrap_or(&kid);
// if kid_did != credential_subject_id {
// return Err(ApiError::builder(StatusCode::UNAUTHORIZED)
// .title("Invalid Token")
// .message("Public Credential Token kid does not match requested credential subject DID")
// .finish());
// }

// Fetch the public key using the kid
let public_key = get_public_key_from_kid(&kid).await.map_err(|_| {
ApiError::builder(StatusCode::BAD_REQUEST)
.title("Invalid Token")
.message("Failed to retrieve public key for kid")
.finish()
})?;

// Create decoding key based on the algorithm
let decoding_key = match jwt_header.alg {
Algorithm::EdDSA => DecodingKey::from_ed_der(&public_key),
Algorithm::ES256 => DecodingKey::from_ec_der(&public_key),
_ => {
return Err(ApiError::builder(StatusCode::BAD_REQUEST)
.title("Invalid Token")
.message(format!(
"Public Credential Token kid uses an unsupported algorithm: {:?}",
jwt_header.alg
))
.finish());
}
};

let mut validation = Validation::new(jwt_header.alg);
validation.validate_aud = false; // we are skipping aud validation for now
// TODO: more validation parameters should be set

// Decode and verify the JWT signature
let _token_data = decode::<serde_json::Value>(&jwt, &decoding_key, &validation).map_err(|e| {
ApiError::builder(StatusCode::BAD_REQUEST)
.title("Invalid Token")
.message(format!("JWT verification failed: {}", e))
.finish()
})?;

// Return the credential if all validations pass
Ok((StatusCode::OK, Json(signed_credential)).into_response())
}

#[cfg(test)]
pub mod tests {
use crate::issuance::router;
use crate::API_VERSION;
use agent_issuance::state::initialize;
use agent_secret_manager::service::Service;
use agent_store::in_memory;
use axum::{
body::Body,
http::{self, Request},
};
use tower::Service as _;

#[tokio::test]
#[tracing_test::traced_test]
async fn test_public_credential_endpoint_invalid_parameter() {
let issuance_state = in_memory::issuance_state(Service::default(), Default::default()).await;
initialize(&issuance_state).await.unwrap();

let mut app = router(issuance_state);

let response = app
.call(
Request::builder()
.method(http::Method::GET)
.uri(format!(
"{API_VERSION}/public-credential?public_credential_token=invalid"
))
.header(http::header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref())
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();

assert_eq!(response.status(), http::StatusCode::BAD_REQUEST);
}
}
4 changes: 1 addition & 3 deletions agent_api_rest/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ pub mod utils;
use agent_holder::state::HolderState;
use agent_identity::state::IdentityState;
use agent_issuance::state::IssuanceState;
use agent_shared::config::config;
use agent_shared::config::{config, API_VERSION};
use agent_verification::state::VerificationState;
use axum::{
body::{Body, Bytes},
Expand All @@ -28,8 +28,6 @@ use tower::ServiceBuilder;
use tower_http::trace::TraceLayer;
use tracing::{debug, info, info_span, Span};

pub const API_VERSION: &str = "/v0";

pub const DOCUMENTATION_URL: &str = "https://beta.docs.impierce.com/unicore/";

#[derive(Default)]
Expand Down
8 changes: 6 additions & 2 deletions agent_api_rest/src/verification/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
pub mod authorization_requests;
pub mod public_verification;
pub mod relying_party;
pub mod validate_domain_linkage;
pub mod validate_linked_verifiable_presentation;

pub mod error;

Expand All @@ -10,7 +13,7 @@ use axum::{routing::post, Router};

use crate::verification::{
authorization_requests::authorization_request, authorization_requests::authorization_requests,
relying_party::redirect::redirect, relying_party::request::request,
public_verification::public_verification, relying_party::redirect::redirect, relying_party::request::request,
};
use crate::API_VERSION;

Expand All @@ -26,7 +29,8 @@ pub fn router(verification_state: VerificationState) -> Router {
.route(
"/authorization_requests/{authorization_request_id}",
get(authorization_request),
),
)
.route("/public-verification", get(public_verification)),
)
.route("/request/{request_id}", get(request))
.route("/redirect", post(redirect))
Expand Down
Loading
Loading