diff --git a/bindings/grpc/Cargo.toml b/bindings/grpc/Cargo.toml index 8d22802dea..8b8a28fa97 100644 --- a/bindings/grpc/Cargo.toml +++ b/bindings/grpc/Cargo.toml @@ -18,8 +18,9 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.75" +futures = { version = "0.3" } identity_eddsa_verifier = { path = "../../identity_eddsa_verifier" } -identity_iota = { path = "../../identity_iota", features = ["resolver", "sd-jwt"] } +identity_iota = { path = "../../identity_iota", features = ["resolver", "sd-jwt", "domain-linkage", "domain-linkage-fetch"] } identity_stronghold = { path = "../../identity_stronghold", features = ["send-sync-storage"] } iota-sdk = { version = "1.1.2", features = ["stronghold"] } prost = "0.12" @@ -32,6 +33,7 @@ tokio-stream = { version = "0.1.14", features = ["net"] } tonic = "0.10" tracing = { version = "0.1.40", features = ["async-await"] } tracing-subscriber = "0.3.18" +url = { version = "2.5", default-features = false } [dev-dependencies] identity_storage = { path = "../../identity_storage", features = ["memstore"] } diff --git a/bindings/grpc/README.md b/bindings/grpc/README.md index 955a7bce91..39c69632f0 100644 --- a/bindings/grpc/README.md +++ b/bindings/grpc/README.md @@ -15,11 +15,113 @@ The provided docker image requires the following variables to be set in order to Make sure to provide a valid stronghold snapshot at the provided `SNAPSHOT_PATH` prefilled with all the needed key material. ### Available services -| Service description | Service Id | Proto File | -|--------------------------------|------------------------------------------|------------| -| Credential Revocation Checking | `credentials/CredentialRevocation.check` | [credentials.proto](https://github.com/iotaledger/identity.rs/blob/grpc-bindings/bindings/grpc/proto/credentials.proto) | -| SD-JWT Validation | `sd_jwt/Verification.verify` | [sd_jwt.proto](https://github.com/iotaledger/identity.rs/blob/grpc-bindings/bindings/grpc/proto/sd_jwt.proto) | -| Credential JWT creation | `credentials/Jwt.create` | [credentials.proto](https://github.com/iotaledger/identity.rs/blob/grpc-bindings/bindings/grpc/proto/credentials.proto) | -| Credential JWT validation | `credentials/VcValidation.validate` | [credentials.proto](https://github.com/iotaledger/identity.rs/blob/grpc-bindings/bindings/grpc/proto/credentials.proto) | -| DID Document Creation | `document/DocumentService.create` | [document.proto](https://github.com/iotaledger/identity.rs/blob/grpc-bindings/bindings/grpc/proto/document.proto) | +| Service description | Service Id | Proto File | +| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | +| Credential Revocation Checking | `credentials/CredentialRevocation.check` | [credentials.proto](https://github.com/iotaledger/identity.rs/blob/grpc-bindings/bindings/grpc/proto/credentials.proto) | +| SD-JWT Validation | `sd_jwt/Verification.verify` | [sd_jwt.proto](https://github.com/iotaledger/identity.rs/blob/grpc-bindings/bindings/grpc/proto/sd_jwt.proto) | +| Credential JWT creation | `credentials/Jwt.create` | [credentials.proto](https://github.com/iotaledger/identity.rs/blob/grpc-bindings/bindings/grpc/proto/credentials.proto) | +| Credential JWT validation | `credentials/VcValidation.validate` | [credentials.proto](https://github.com/iotaledger/identity.rs/blob/grpc-bindings/bindings/grpc/proto/credentials.proto) | +| DID Document Creation | `document/DocumentService.create` | [document.proto](https://github.com/iotaledger/identity.rs/blob/grpc-bindings/bindings/grpc/proto/document.proto) | +| Domain Linkage - validate domain, let server fetch did-configuration | `domain_linkage/DomainLinkage.validate_domain` | [domain_linkage.proto](https://github.com/iotaledger/identity.rs/blob/grpc-bindings/bindings/grpc/proto/domain_linkage.proto) | +| Domain Linkage - validate domain, pass did-configuration to service | `domain_linkage/DomainLinkage.validate_domain_against_did_configuration` | [domain_linkage.proto](https://github.com/iotaledger/identity.rs/blob/grpc-bindings/bindings/grpc/proto/domain_linkage.proto) | +| Domain Linkage - validate endpoints in DID, let server fetch did-configuration | `domain_linkage/DomainLinkage.validate_did` | [domain_linkage.proto](https://github.com/iotaledger/identity.rs/blob/grpc-bindings/bindings/grpc/proto/domain_linkage.proto) | +| Domain Linkage - validate endpoints in DID, pass did-configuration to service | `domain_linkage/DomainLinkage.validate_did_against_did_configurations` | [domain_linkage.proto](https://github.com/iotaledger/identity.rs/blob/grpc-bindings/bindings/grpc/proto/domain_linkage.proto) | +## Testing +### Domain Linkage +Following is a description about how to manually test the domain linkage service. The steps for the other services might vary a bit. + +#### Http server +If you want to test domain linkage, you need a server, that's reachable via HTTPS. If you already have one, ignore the server setup steps here and just make sure your server provides the `did-configuration.json` file as described here. + +- create test server folder with did configuration in it, e.g. (you can also use the template in `./tooling/domain-linkage-test-server`) + ```raw + test-server/ + └── .well-known + └── did-configuration.json + ``` + + `did-configuration` looks like this for now: + + ```json + { + "@context": "https://identity.foundation/.well-known/did-configuration/v1", + "linked_dids": [ + "add your domain linkage credential here" + ] + } + ``` +- start a server, that will serve this folder, e.g. with a "http-server" from NodeJs : `http-server ./test-server/`, in this example the server should now be running on local port 8080 +- now tunnel your server's port (here 8080) to a public domain with https, e.g. with ngrok: + `ngrok http http://127.0.0.1:8080` + the output should now have a line like + `Forwarding https://0d40-2003-d3-2710-e200-485f-e8bb-7431-79a7.ngrok-free.app -> http://127.0.0.1:8080` + check that the https url is reachable, this will be used in the next step. you can also start ngrok with a static domain, that you do not have to update credentials after each http server restart +- for convenience, you can find a script to start the HTTP server, that you can adjust in `tooling/start-http-server.sh`, don't forget to insert your static domain or to remove the `--domain` parameter + +#### Domain linkage credential +- copy this public url and insert it into the advanced test 6 (the one for domain linkage) as domain 1, e.g. `let domain_1: Url = Url::parse("https://0d40-2003-d3-2710-e200-485f-e8bb-7431-79a7.ngrok-free.app")?;` +- run the example with `cargo run --release --example 6_domain_linkage` + +#### GRPC server +- grab the configuration resource from the log and replace the contents of your `did-configuration.json` with it +- you now have a publicly reachable (sub)domain, that serves a `did-configuration` file containing a credential pointing to your DID +- to verify this, run the server via Docker or with the following command, remember to replace the placeholders ;) `API_ENDPOINT=replace_me STRONGHOLD_PWD=replace_me SNAPSHOT_PATH=replace_me cargo run --release`, arguments can be taken from examples, e.g. after running a `6_domain_linkage.rs`, that also logs snapshot path passed to secret manager (`let snapshot_path = random_stronghold_path(); dbg!(&snapshot_path.to_str());`), for example + - API_ENDPOINT: `"http://localhost"` + - STRONGHOLD_PWD: `"secure_password"` + - SNAPSHOT_PATH: `"/var/folders/41/s1sm86jx0xl4x435t81j81440000gn/T/test_strongholds/8o2Nyiv5ENBi7Ik3dEDq9gNzSrqeUdqi.stronghold"` +- for convenience, you can find a script to start the GRPC server, that you can adjust in `tooling/start-rpc-server.sh`, don't forget to insert the env variables as described above + +#### Calling the endpoints +- call the `validate_domain` endpoint with your domain, e.g with: + + ```json + { + "domain": "https://0d40-2003-d3-2710-e200-485f-e8bb-7431-79a7.ngrok-free.app" + } + ``` + + you should now receive a response like this: + + ```json + { + "linked_dids": [ + { + "document": "... (compact JWT domain linkage credential)", + "status": "ok" + } + ] + } + ``` + +- to call the `validate_did` endpoint, you need a DID to check, you can find a testable in you domain linkage credential. for this just decode it (e.g. on jwt.io) and get the `iss` value, then you can submit as "did" like following + + ```json + { + "did": "did:iota:snd:0x967bf8f0c7487f61378611b6a1c6a59cb99e65b839681ee70be691b09a024ab9" + } + ``` + + you should not receive a response like this: + + ```json + { + "service": [ + { + "service_endpoint": [ + { + "valid": true, + "document": "eyJraWQiOiJkaWQ6aW90YTpzbmQ6MHg5NjdiZjhmMGM3NDg3ZjYxMzc4NjExYjZhMWM2YTU5Y2I5OWU2NWI4Mzk2ODFlZTcwYmU2OTFiMDlhMDI0YWI5IzA3QjVWRkxBa0FabkRhaC1OTnYwYUN3TzJ5ZnRzX09ZZ0YzNFNudUloMlUiLCJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSJ9.eyJleHAiOjE3NDE2NzgyNzUsImlzcyI6ImRpZDppb3RhOnNuZDoweDk2N2JmOGYwYzc0ODdmNjEzNzg2MTFiNmExYzZhNTljYjk5ZTY1YjgzOTY4MWVlNzBiZTY5MWIwOWEwMjRhYjkiLCJuYmYiOjE3MTAxNDIyNzUsInN1YiI6ImRpZDppb3RhOnNuZDoweDk2N2JmOGYwYzc0ODdmNjEzNzg2MTFiNmExYzZhNTljYjk5ZTY1YjgzOTY4MWVlNzBiZTY5MWIwOWEwMjRhYjkiLCJ2YyI6eyJAY29udGV4dCI6WyJodHRwczovL3d3dy53My5vcmcvMjAxOC9jcmVkZW50aWFscy92MSIsImh0dHBzOi8vaWRlbnRpdHkuZm91bmRhdGlvbi8ud2VsbC1rbm93bi9kaWQtY29uZmlndXJhdGlvbi92MSJdLCJ0eXBlIjpbIlZlcmlmaWFibGVDcmVkZW50aWFsIiwiRG9tYWluTGlua2FnZUNyZWRlbnRpYWwiXSwiY3JlZGVudGlhbFN1YmplY3QiOnsib3JpZ2luIjoiaHR0cHM6Ly9ob3QtYnVsbGRvZy1wcm9mb3VuZC5uZ3Jvay1mcmVlLmFwcC8ifX19.69e7T0DbRw9Kz7eEQ96P9E5HWbEo5F1fLuMjyQN6_Oa1lwBdbfj0wLlhS1j_d8AuNmvu60lMdLVixjMZJLQ5AA" + }, + { + "valid": false, + "error": "domain linkage error: error sending request for url (https://bar.example.com/.well-known/did-configuration.json): error trying to connect: dns error: failed to lookup address information: nodename nor servname provided, or not known" + } + ], + "id": "did:iota:snd:0x967bf8f0c7487f61378611b6a1c6a59cb99e65b839681ee70be691b09a024ab9" + } + ] + } + ``` + + Which tells us that it found a DID document with one matching service with a serviceEndpoint, that contains two domains. Out of these domains one links back to the given DID, the other domain could not be resolved. diff --git a/bindings/grpc/build.rs b/bindings/grpc/build.rs index b72c4193f6..562a01141e 100644 --- a/bindings/grpc/build.rs +++ b/bindings/grpc/build.rs @@ -1,5 +1,4 @@ fn main() -> Result<(), Box> { - //tonic_build::compile_protos("proto/helloworld.proto")?; let proto_files = std::fs::read_dir("./proto")? .filter_map(|entry| entry.ok().map(|e| e.path())) .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("proto")); diff --git a/bindings/grpc/proto/domain_linkage.proto b/bindings/grpc/proto/domain_linkage.proto new file mode 100644 index 0000000000..b137e86e6c --- /dev/null +++ b/bindings/grpc/proto/domain_linkage.proto @@ -0,0 +1,60 @@ +syntax = "proto3"; +package domain_linkage; + +message ValidateDomainRequest { + // domain to validate + string domain = 1; +} + +message ValidateDomainAgainstDidConfigurationRequest { + // domain to validate + string domain = 1; + // already resolved domain linkage config + string did_configuration = 2; +} + +message LinkedDidValidationStatus { + // validation succeeded or not, `error` property is added for `false` cases + bool valid = 1; + // credential from `linked_dids` as compact JWT domain linkage credential if it could be retrieved + optional string document = 2; + // an error message, that occurred when validated, omitted if valid + optional string error = 3; +} + +message ValidateDomainResponse { + // list of JWT domain linkage credential, uses the same order as the `did-configuration.json` file for domain + repeated LinkedDidValidationStatus linked_dids = 1; +} + +message LinkedDidEndpointValidationStatus { + // id of service endpoint entry + string id = 1; + // list of JWT domain linkage credential, uses the same order as the `did-configuration.json` file for domain + repeated LinkedDidValidationStatus service_endpoint = 2; +} + +message ValidateDidRequest { + // DID to validate + string did = 1; +} + +message ValidateDidAgainstDidConfigurationsRequest { + // DID to validate + string did = 1; + // already resolved domain linkage configs + repeated ValidateDomainAgainstDidConfigurationRequest did_configurations = 2; +} + +message ValidateDidResponse { + // mapping of service entries from DID with validation status for endpoint URLs + repeated LinkedDidEndpointValidationStatus service = 1; +} + +service DomainLinkage { + rpc validate_domain(ValidateDomainRequest) returns (ValidateDomainResponse); + rpc validate_domain_against_did_configuration(ValidateDomainAgainstDidConfigurationRequest) returns (ValidateDomainResponse); + + rpc validate_did(ValidateDidRequest) returns (ValidateDidResponse); + rpc validate_did_against_did_configurations(ValidateDidAgainstDidConfigurationsRequest) returns (ValidateDidResponse); +} \ No newline at end of file diff --git a/bindings/grpc/src/services/domain_linkage.rs b/bindings/grpc/src/services/domain_linkage.rs new file mode 100644 index 0000000000..fc62e11425 --- /dev/null +++ b/bindings/grpc/src/services/domain_linkage.rs @@ -0,0 +1,374 @@ +use std::collections::HashMap; +use std::error::Error; + +use domain_linkage::domain_linkage_server::DomainLinkage; +use domain_linkage::domain_linkage_server::DomainLinkageServer; +use domain_linkage::LinkedDidEndpointValidationStatus; +use domain_linkage::LinkedDidValidationStatus; +use domain_linkage::ValidateDidAgainstDidConfigurationsRequest; +use domain_linkage::ValidateDidRequest; +use domain_linkage::ValidateDidResponse; +use domain_linkage::ValidateDomainAgainstDidConfigurationRequest; +use domain_linkage::ValidateDomainRequest; +use domain_linkage::ValidateDomainResponse; +use futures::stream::FuturesOrdered; +use futures::TryStreamExt; +use identity_eddsa_verifier::EdDSAJwsVerifier; +use identity_iota::core::FromJson; +use identity_iota::core::Url; +use identity_iota::credential::DomainLinkageConfiguration; +use identity_iota::credential::JwtCredentialValidationOptions; +use identity_iota::credential::JwtDomainLinkageValidator; +use identity_iota::credential::LinkedDomainService; +use identity_iota::did::CoreDID; +use identity_iota::iota::IotaDID; +use identity_iota::iota::IotaDocument; +use identity_iota::resolver::Resolver; +use iota_sdk::client::Client; +use serde::Deserialize; +use serde::Serialize; +use thiserror::Error; +use tonic::Request; +use tonic::Response; +use tonic::Status; +use url::Origin; + +#[allow(clippy::module_inception)] +mod domain_linkage { + tonic::include_proto!("domain_linkage"); +} + +#[derive(Debug, Error, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[serde(tag = "error", content = "reason")] +enum DomainLinkageError { + #[error("domain argument invalid: {0}")] + DomainParsing(String), + #[error("did configuration argument invalid: {0}")] + DidConfigurationParsing(String), + #[error("did resolving failed: {0}")] + DidResolving(String), +} + +impl From for tonic::Status { + fn from(value: DomainLinkageError) -> Self { + let code = match &value { + DomainLinkageError::DomainParsing(_) => tonic::Code::InvalidArgument, + DomainLinkageError::DidConfigurationParsing(_) => tonic::Code::InvalidArgument, + DomainLinkageError::DidResolving(_) => tonic::Code::Internal, + }; + let message = value.to_string(); + let error_json = serde_json::to_vec(&value).expect("plenty of memory!"); // ? + + tonic::Status::with_details(code, message, error_json.into()) + } +} + +/// Helper struct that allows to convert `ValidateDomainAgainstDidConfigurationRequest` input struct +/// with `String` config to a struct with `DomainLinkageService` config. +struct DomainValidationConfig { + domain: Url, + config: DomainLinkageConfiguration, +} + +impl DomainValidationConfig { + /// Parses did-configuration inputs from: + /// + /// - `validate_domain_against_did_configuration` + /// - `validate_did_against_did_configurations` + pub fn try_parse(request_config: &ValidateDomainAgainstDidConfigurationRequest) -> Result { + Ok(Self { + domain: Url::parse(&request_config.domain).map_err(|e| DomainLinkageError::DomainParsing(e.to_string()))?, + config: DomainLinkageConfiguration::from_json(&request_config.did_configuration).map_err(|err| { + DomainLinkageError::DidConfigurationParsing(format!("could not parse given DID configuration; {}", &err)) + })?, + }) + } +} + +/// Builds a validation status for a failed validation from an `Error`. +fn get_validation_failed_status(message: &str, err: &impl Error) -> LinkedDidValidationStatus { + LinkedDidValidationStatus { + valid: false, + document: None, + error: Some(format!("{}; {}", message, &err.to_string())), + } +} + +#[derive(Debug)] +pub struct DomainLinkageService { + resolver: Resolver, +} + +impl DomainLinkageService { + pub fn new(client: &Client) -> Self { + let mut resolver = Resolver::new(); + resolver.attach_iota_handler(client.clone()); + Self { resolver } + } + + /// Validates a DID' `LinkedDomains` service endpoints. Pre-fetched did-configurations can be passed to skip fetching + /// them on server. + /// + /// Arguments: + /// + /// * `did`: DID to validate + /// * `did_configurations`: A list of domains and their did-configuration, if omitted config will be fetched + async fn validate_did_with_optional_configurations( + &self, + did: &IotaDID, + did_configurations: Option>, + ) -> Result, DomainLinkageError> { + // fetch DID document for given DID + let did_document = self + .resolver + .resolve(did) + .await + .map_err(|e| DomainLinkageError::DidResolving(e.to_string()))?; + + let services: Vec = did_document + .service() + .iter() + .cloned() + .filter_map(|service| LinkedDomainService::try_from(service).ok()) + .collect(); + + let config_map: HashMap = match did_configurations { + Some(configurations) => configurations + .into_iter() + .map(|value| (value.domain.origin(), value.config)) + .collect::>(), + None => HashMap::new(), + }; + + // check validation for all services and endpoints in them + let mut service_futures = FuturesOrdered::new(); + for service in services { + let service_id: CoreDID = did.clone().into(); + let domains: Vec = service.domains().into(); + let local_config_map = config_map.clone(); + service_futures.push_back(async move { + let mut domain_futures = FuturesOrdered::new(); + for domain in domains { + let config = local_config_map.get(&domain.origin()).map(|value| value.to_owned()); + domain_futures.push_back(self.validate_domains_with_optional_configuration( + domain.clone(), + Some(did.clone().into()), + config, + )); + } + domain_futures + .try_collect::>>() + .await + .map(|value| LinkedDidEndpointValidationStatus { + id: service_id.to_string(), + service_endpoint: value.into_iter().flatten().collect(), + }) + }); + } + let endpoint_validation_status = service_futures + .try_collect::>() + .await?; + + Ok(endpoint_validation_status) + } + + /// Validates domain linkage for given origin. + /// + /// Arguments: + /// + /// * `domain`: An origin to validate domain linkage for + /// * `did`: A DID to restrict validation to, if omitted all DIDs from config will be validated + /// * `config`: A domain linkage configuration can be passed if already loaded, if omitted config will be fetched from + /// origin + async fn validate_domains_with_optional_configuration( + &self, + domain: Url, + did: Option, + config: Option, + ) -> Result, DomainLinkageError> { + // get domain linkage config + let domain_linkage_configuration: DomainLinkageConfiguration = if let Some(config_value) = config { + config_value + } else { + match DomainLinkageConfiguration::fetch_configuration(domain.clone()).await { + Ok(value) => value, + Err(err) => { + return Ok(vec![get_validation_failed_status( + "could not get domain linkage config", + &err, + )]); + } + } + }; + + // get issuers of `linked_dids` credentials + let linked_dids: Vec = if let Some(issuer_did) = did { + vec![issuer_did] + } else { + match domain_linkage_configuration.issuers() { + Ok(value) => value, + Err(err) => { + return Ok(vec![get_validation_failed_status( + "could not get issuers from domain linkage config credential", + &err, + )]); + } + } + }; + + // resolve all issuers + let resolved = match self.resolver.resolve_multiple(&linked_dids).await { + Ok(value) => value, + Err(err) => { + return Ok(vec![get_validation_failed_status( + "could not resolve linked DIDs from domain linkage config", + &err, + )]); + } + }; + + // check linked DIDs separately + let errors: Vec> = resolved + .values() + .map(|issuer_did_doc| { + JwtDomainLinkageValidator::with_signature_verifier(EdDSAJwsVerifier::default()) + .validate_linkage( + &issuer_did_doc, + &domain_linkage_configuration, + &domain.clone(), + &JwtCredentialValidationOptions::default(), + ) + .err() + .map(|err| err.to_string()) + }) + .collect(); + + // collect resolved documents and their validation status into array following the order of `linked_dids` + let status_infos = domain_linkage_configuration + .linked_dids() + .iter() + .zip(errors.iter()) + .map(|(credential, error)| LinkedDidValidationStatus { + valid: error.is_none(), + document: Some(credential.as_str().to_string()), + error: error.clone(), + }) + .collect(); + + Ok(status_infos) + } +} + +#[tonic::async_trait] +impl DomainLinkage for DomainLinkageService { + #[tracing::instrument( + name = "validate_domain", + skip_all, + fields(request = ?req.get_ref()) + ret, + err, + )] + async fn validate_domain( + &self, + req: Request, + ) -> Result, Status> { + let request_data = &req.into_inner(); + // parse given domain + let domain: Url = Url::parse(&request_data.domain) + .map_err(|err| DomainLinkageError::DomainParsing(err.to_string()))?; + + // get validation status for all issuer dids + let status_infos = self + .validate_domains_with_optional_configuration(domain, None, None) + .await?; + + Ok(Response::new(ValidateDomainResponse { + linked_dids: status_infos, + })) + } + + #[tracing::instrument( + name = "validate_domain_against_did_configuration", + skip_all, + fields(request = ?req.get_ref()) + ret, + err, + )] + async fn validate_domain_against_did_configuration( + &self, + req: Request, + ) -> Result, Status> { + let request_data = &req.into_inner(); + // parse given domain + let domain: Url = Url::parse(&request_data.domain) + .map_err(|err| DomainLinkageError::DomainParsing(err.to_string()))?; + // parse config + let config = DomainLinkageConfiguration::from_json(&request_data.did_configuration.to_string()).map_err(|err| { + DomainLinkageError::DidConfigurationParsing(format!("could not parse given DID configuration; {}", &err)) + })?; + + // get validation status for all issuer dids + let status_infos = self + .validate_domains_with_optional_configuration(domain, None, Some(config)) + .await?; + + Ok(Response::new(ValidateDomainResponse { + linked_dids: status_infos, + })) + } + + #[tracing::instrument( + name = "validate_did", + skip_all, + fields(request = ?req.get_ref()) + ret, + err, + )] + async fn validate_did(&self, req: Request) -> Result, Status> { + // fetch DID document for given DID + let did: IotaDID = IotaDID::parse(req.into_inner().did).map_err(|e| Status::internal(e.to_string()))?; + + let endpoint_validation_status = self.validate_did_with_optional_configurations(&did, None).await?; + + let response = ValidateDidResponse { + service: endpoint_validation_status, + }; + + Ok(Response::new(response)) + } + + #[tracing::instrument( + name = "validate_did_against_did_configurations", + skip_all, + fields(request = ?req.get_ref()) + ret, + err, + )] + async fn validate_did_against_did_configurations( + &self, + req: Request, + ) -> Result, Status> { + let request_data = &req.into_inner(); + let did: IotaDID = IotaDID::parse(&request_data.did).map_err(|e| Status::internal(e.to_string()))?; + let did_configurations = request_data + .did_configurations + .iter() + .map(DomainValidationConfig::try_parse) + .collect::, DomainLinkageError>>()?; + + let endpoint_validation_status = self + .validate_did_with_optional_configurations(&did, Some(did_configurations)) + .await?; + + let response = ValidateDidResponse { + service: endpoint_validation_status, + }; + + Ok(Response::new(response)) + } +} + +pub fn service(client: &Client) -> DomainLinkageServer { + DomainLinkageServer::new(DomainLinkageService::new(client)) +} diff --git a/bindings/grpc/src/services/mod.rs b/bindings/grpc/src/services/mod.rs index 4db5b3d588..195f5fc21a 100644 --- a/bindings/grpc/src/services/mod.rs +++ b/bindings/grpc/src/services/mod.rs @@ -1,5 +1,6 @@ pub mod credential; pub mod document; +pub mod domain_linkage; pub mod health_check; pub mod sd_jwt; @@ -13,6 +14,7 @@ pub fn routes(client: &Client, stronghold: &StrongholdStorage) -> Routes { routes.add_service(health_check::service()); credential::init_services(&mut routes, client, stronghold); routes.add_service(sd_jwt::service(client)); + routes.add_service(domain_linkage::service(client)); routes.add_service(document::service(client, stronghold)); routes.routes() diff --git a/bindings/grpc/tests/api/domain_linkage.rs b/bindings/grpc/tests/api/domain_linkage.rs new file mode 100644 index 0000000000..6e272ccab2 --- /dev/null +++ b/bindings/grpc/tests/api/domain_linkage.rs @@ -0,0 +1,171 @@ +use identity_iota::core::Duration; +use identity_iota::core::Object; +use identity_iota::core::OrderedSet; +use identity_iota::core::Timestamp; +use identity_iota::core::Url; +use identity_iota::credential::Credential; +use identity_iota::credential::DomainLinkageConfiguration; +use identity_iota::credential::DomainLinkageCredentialBuilder; +use identity_iota::credential::Jwt; +use identity_iota::credential::LinkedDomainService; +use identity_iota::did::DIDUrl; +use identity_iota::did::DID; +use identity_storage::JwkDocumentExt; +use identity_storage::JwsSignatureOptions; +use identity_stronghold::StrongholdStorage; + +use crate::domain_linkage::_credentials::domain_linkage_client::DomainLinkageClient; +use crate::domain_linkage::_credentials::LinkedDidEndpointValidationStatus; +use crate::domain_linkage::_credentials::LinkedDidValidationStatus; +use crate::domain_linkage::_credentials::ValidateDidAgainstDidConfigurationsRequest; +use crate::domain_linkage::_credentials::ValidateDidResponse; +use crate::domain_linkage::_credentials::ValidateDomainAgainstDidConfigurationRequest; +use crate::domain_linkage::_credentials::ValidateDomainResponse; +use crate::helpers::make_stronghold; +use crate::helpers::Entity; +use crate::helpers::TestServer; + +mod _credentials { + tonic::include_proto!("domain_linkage"); +} + +/// Prepares basically the same test setup as in test `examples/1_advanced/6_domain_linkage.rs`. +async fn prepare_test() -> anyhow::Result<(TestServer, Url, String, Jwt)> { + let stronghold = StrongholdStorage::new(make_stronghold()); + let server = TestServer::new_with_stronghold(stronghold.clone()).await; + let api_client = server.client(); + + let mut issuer = Entity::new_with_stronghold(stronghold); + issuer.create_did(api_client).await?; + let did = issuer + .document() + .ok_or_else(|| anyhow::anyhow!("no DID document for issuer"))? + .id(); + let did_string = did.to_string(); + // ===================================================== + // Create Linked Domain service + // ===================================================== + + // The DID should be linked to the following domains. + let domain_1: Url = Url::parse("https://foo.example.com")?; + let domain_2: Url = Url::parse("https://bar.example.com")?; + + let mut domains: OrderedSet = OrderedSet::new(); + domains.append(domain_1.clone()); + domains.append(domain_2.clone()); + + // Create a Linked Domain Service to enable the discovery of the linked domains through the DID Document. + // This is optional since it is not a hard requirement by the specs. + let service_url: DIDUrl = did.clone().join("#domain-linkage")?; + let linked_domain_service: LinkedDomainService = LinkedDomainService::new(service_url, domains, Object::new())?; + issuer + .update_document(&api_client, |mut doc| { + doc.insert_service(linked_domain_service.into()).ok().map(|_| doc) + }) + .await?; + let updated_did_document = issuer + .document() + .ok_or_else(|| anyhow::anyhow!("no DID document for issuer"))?; + + println!("DID document with linked domain service: {updated_did_document:#}"); + + // ===================================================== + // Create DID Configuration resource + // ===================================================== + + // Create the Domain Linkage Credential. + let domain_linkage_credential: Credential = DomainLinkageCredentialBuilder::new() + .issuer(updated_did_document.id().clone().into()) + .origin(domain_1.clone()) + .issuance_date(Timestamp::now_utc()) + // Expires after a year. + .expiration_date( + Timestamp::now_utc() + .checked_add(Duration::days(365)) + .ok_or_else(|| anyhow::anyhow!("calculation should not overflow"))?, + ) + .build()?; + + let jwt: Jwt = updated_did_document + .create_credential_jwt( + &domain_linkage_credential, + &issuer.storage(), + &issuer + .fragment() + .ok_or_else(|| anyhow::anyhow!("no fragment for issuer"))?, + &JwsSignatureOptions::default(), + None, + ) + .await?; + + Ok((server, domain_1, did_string, jwt)) +} + +#[tokio::test] +async fn can_validate_domain() -> anyhow::Result<()> { + let (server, linked_domain, _, jwt) = prepare_test().await?; + let configuration_resource: DomainLinkageConfiguration = DomainLinkageConfiguration::new(vec![jwt.clone()]); + let mut grpc_client = DomainLinkageClient::connect(server.endpoint()).await?; + + let response = grpc_client + .validate_domain_against_did_configuration(ValidateDomainAgainstDidConfigurationRequest { + domain: linked_domain.to_string(), + did_configuration: configuration_resource.to_string(), + }) + .await?; + + assert_eq!( + response.into_inner(), + ValidateDomainResponse { + linked_dids: vec![LinkedDidValidationStatus { + valid: true, + document: Some(jwt.as_str().to_string()), + error: None, + }], + } + ); + + Ok(()) +} + +#[tokio::test] +async fn can_validate_did() -> anyhow::Result<()> { + let (server, linked_domain, issuer_did, jwt) = prepare_test().await?; + let configuration_resource: DomainLinkageConfiguration = DomainLinkageConfiguration::new(vec![jwt.clone()]); + let mut grpc_client = DomainLinkageClient::connect(server.endpoint()).await?; + + let response = grpc_client + .validate_did_against_did_configurations(ValidateDidAgainstDidConfigurationsRequest { + did: issuer_did.clone(), + did_configurations: vec![ValidateDomainAgainstDidConfigurationRequest { + domain: linked_domain.to_string(), + did_configuration: configuration_resource.to_string(), + }], + }) + .await?; + + assert_eq!( + response.into_inner(), + ValidateDidResponse { + service: vec![ + LinkedDidEndpointValidationStatus { + id: issuer_did, + service_endpoint: vec![ + LinkedDidValidationStatus { + valid: true, + document: Some(jwt.as_str().to_string()), + error: None, + }, + LinkedDidValidationStatus { + valid: false, + document: None, + error: Some("could not get domain linkage config; domain linkage error: error sending request for url (https://bar.example.com/.well-known/did-configuration.json): error trying to connect: dns error: failed to lookup address information: nodename nor servname provided, or not known".to_string()), + } + ], + } + ] + } + ); + + Ok(()) +} diff --git a/bindings/grpc/tests/api/main.rs b/bindings/grpc/tests/api/main.rs index 68b9122ec6..02ce2eab71 100644 --- a/bindings/grpc/tests/api/main.rs +++ b/bindings/grpc/tests/api/main.rs @@ -1,6 +1,7 @@ mod credential_revocation_check; mod credential_validation; mod did_document_creation; +mod domain_linkage; mod health_check; mod helpers; mod jwt; diff --git a/bindings/grpc/tooling/domain-linkage-test-server/.well-known/did-configuration.json b/bindings/grpc/tooling/domain-linkage-test-server/.well-known/did-configuration.json new file mode 100644 index 0000000000..802f453e3e --- /dev/null +++ b/bindings/grpc/tooling/domain-linkage-test-server/.well-known/did-configuration.json @@ -0,0 +1,6 @@ +{ + "@context": "https://identity.foundation/.well-known/did-configuration/v1", + "linked_dids": [ + "add your domain linkage credential here" + ] +} \ No newline at end of file diff --git a/bindings/grpc/tooling/start-http-server.sh b/bindings/grpc/tooling/start-http-server.sh new file mode 100644 index 0000000000..4cebbf82d2 --- /dev/null +++ b/bindings/grpc/tooling/start-http-server.sh @@ -0,0 +1,4 @@ +#!/bin/sh +http-server ./domain-linkage-test-server & +# replace or omint the --domain parameter if you don't have a static domain or don't want to use it +ngrok http --domain=example-static-domain.ngrok-free.app 8080 \ No newline at end of file diff --git a/bindings/grpc/tooling/start-rpc-server.sh b/bindings/grpc/tooling/start-rpc-server.sh new file mode 100755 index 0000000000..69c207f6cf --- /dev/null +++ b/bindings/grpc/tooling/start-rpc-server.sh @@ -0,0 +1,7 @@ +#!/bin/sh +cd .. + +API_ENDPOINT=replace_me \ +STRONGHOLD_PWD=replace_me \ +SNAPSHOT_PATH=replace_me \ +cargo +nightly run --release diff --git a/identity_credential/src/credential/linked_domain_service.rs b/identity_credential/src/credential/linked_domain_service.rs index c6efbae255..3a76b10eb5 100644 --- a/identity_credential/src/credential/linked_domain_service.rs +++ b/identity_credential/src/credential/linked_domain_service.rs @@ -144,6 +144,11 @@ impl LinkedDomainService { .as_slice(), } } + + /// Returns a reference to the `Service` id. + pub fn id(&self) -> &DIDUrl { + self.service.id() + } } #[cfg(test)] diff --git a/identity_credential/src/error.rs b/identity_credential/src/error.rs index 356d89d3d2..62964415e4 100644 --- a/identity_credential/src/error.rs +++ b/identity_credential/src/error.rs @@ -35,7 +35,7 @@ pub enum Error { #[error("invalid credential status: {0}")] InvalidStatus(String), /// Caused when constructing an invalid `LinkedDomainService` or `DomainLinkageConfiguration`. - #[error("domain linkage error")] + #[error("domain linkage error: {0}")] DomainLinkageError(#[source] Box), /// Caused when attempting to encode a `Credential` containing multiple subjects as a JWT. #[error("could not create JWT claim set from verifiable credential: more than one subject")]