From 2f00b22b11fcfde84b62aeae1cf32f77cee28cff Mon Sep 17 00:00:00 2001 From: Enrico Marconi Date: Wed, 27 Mar 2024 09:00:54 +0100 Subject: [PATCH] merge main --- .dockerignore | 3 + .github/workflows/build-and-test-grpc.yml | 41 ++ .github/workflows/build-and-test.yml | 4 +- .../workflows/grpc-publish-to-dockerhub.yml | 52 +++ Cargo.toml | 5 +- README.md | 11 +- bindings/grpc/Cargo.toml | 43 ++ bindings/grpc/Dockerfile | 20 + bindings/grpc/README.md | 130 ++++++ bindings/grpc/build.rs | 14 + bindings/grpc/proto/credentials.proto | 61 +++ bindings/grpc/proto/document.proto | 24 ++ bindings/grpc/proto/domain_linkage.proto | 63 +++ bindings/grpc/proto/health_check.proto | 15 + bindings/grpc/proto/sd_jwt.proto | 30 ++ bindings/grpc/proto/status_list_2021.proto | 50 +++ bindings/grpc/src/lib.rs | 7 + bindings/grpc/src/main.rs | 47 +++ bindings/grpc/src/server.rs | 33 ++ bindings/grpc/src/services/credential/jwt.rs | 85 ++++ bindings/grpc/src/services/credential/mod.rs | 16 + .../src/services/credential/revocation.rs | 161 ++++++++ .../src/services/credential/validation.rs | 135 +++++++ bindings/grpc/src/services/document.rs | 115 ++++++ bindings/grpc/src/services/domain_linkage.rs | 377 ++++++++++++++++++ bindings/grpc/src/services/health_check.rs | 36 ++ bindings/grpc/src/services/mod.rs | 26 ++ bindings/grpc/src/services/sd_jwt.rs | 164 ++++++++ .../grpc/src/services/status_list_2021.rs | 170 ++++++++ .../tests/api/credential_revocation_check.rs | 99 +++++ .../grpc/tests/api/credential_validation.rs | 151 +++++++ .../grpc/tests/api/did_document_creation.rs | 43 ++ bindings/grpc/tests/api/domain_linkage.rs | 174 ++++++++ bindings/grpc/tests/api/health_check.rs | 24 ++ bindings/grpc/tests/api/helpers.rs | 336 ++++++++++++++++ bindings/grpc/tests/api/jwt.rs | 54 +++ bindings/grpc/tests/api/main.rs | 12 + bindings/grpc/tests/api/sd_jwt_validation.rs | 165 ++++++++ bindings/grpc/tests/api/status_list_2021.rs | 94 +++++ .../.well-known/did-configuration.json | 6 + bindings/grpc/tooling/start-http-server.sh | 4 + bindings/grpc/tooling/start-rpc-server.sh | 7 + bindings/wasm/docs/api-reference.md | 228 +++++++---- bindings/wasm/src/iota/iota_document.rs | 20 + bindings/wasm/src/sd_jwt/wasm_sd_jwt.rs | 1 - .../wasm/src/verification/wasm_method_data.rs | 50 +++ .../wasm/src/verification/wasm_method_type.rs | 5 + .../verification/wasm_verification_method.rs | 19 + examples/Cargo.toml | 4 +- identity_core/Cargo.toml | 3 + identity_credential/Cargo.toml | 7 +- .../src/credential/linked_domain_service.rs | 5 + identity_credential/src/error.rs | 2 +- .../revocation/status_list_2021/credential.rs | 52 ++- .../jwt_credential_validator_utils.rs | 2 +- identity_did/Cargo.toml | 3 + identity_document/Cargo.toml | 3 + identity_eddsa_verifier/Cargo.toml | 3 + identity_iota/Cargo.toml | 3 + identity_iota/README.md | 10 +- identity_iota_core/Cargo.toml | 3 + .../src/document/iota_document.rs | 135 ++++++- .../src/state_metadata/document.rs | 29 +- identity_jose/Cargo.toml | 3 + identity_resolver/Cargo.toml | 3 + identity_storage/Cargo.toml | 3 + identity_stronghold/Cargo.toml | 3 + identity_verification/Cargo.toml | 5 +- .../src/verification_method/material.rs | 113 +++++- .../src/verification_method/method.rs | 46 ++- .../src/verification_method/method_type.rs | 4 + .../src/verification_method/mod.rs | 1 + 72 files changed, 3767 insertions(+), 108 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/build-and-test-grpc.yml create mode 100644 .github/workflows/grpc-publish-to-dockerhub.yml create mode 100644 bindings/grpc/Cargo.toml create mode 100644 bindings/grpc/Dockerfile create mode 100644 bindings/grpc/README.md create mode 100644 bindings/grpc/build.rs create mode 100644 bindings/grpc/proto/credentials.proto create mode 100644 bindings/grpc/proto/document.proto create mode 100644 bindings/grpc/proto/domain_linkage.proto create mode 100644 bindings/grpc/proto/health_check.proto create mode 100644 bindings/grpc/proto/sd_jwt.proto create mode 100644 bindings/grpc/proto/status_list_2021.proto create mode 100644 bindings/grpc/src/lib.rs create mode 100644 bindings/grpc/src/main.rs create mode 100644 bindings/grpc/src/server.rs create mode 100644 bindings/grpc/src/services/credential/jwt.rs create mode 100644 bindings/grpc/src/services/credential/mod.rs create mode 100644 bindings/grpc/src/services/credential/revocation.rs create mode 100644 bindings/grpc/src/services/credential/validation.rs create mode 100644 bindings/grpc/src/services/document.rs create mode 100644 bindings/grpc/src/services/domain_linkage.rs create mode 100644 bindings/grpc/src/services/health_check.rs create mode 100644 bindings/grpc/src/services/mod.rs create mode 100644 bindings/grpc/src/services/sd_jwt.rs create mode 100644 bindings/grpc/src/services/status_list_2021.rs create mode 100644 bindings/grpc/tests/api/credential_revocation_check.rs create mode 100644 bindings/grpc/tests/api/credential_validation.rs create mode 100644 bindings/grpc/tests/api/did_document_creation.rs create mode 100644 bindings/grpc/tests/api/domain_linkage.rs create mode 100644 bindings/grpc/tests/api/health_check.rs create mode 100644 bindings/grpc/tests/api/helpers.rs create mode 100644 bindings/grpc/tests/api/jwt.rs create mode 100644 bindings/grpc/tests/api/main.rs create mode 100644 bindings/grpc/tests/api/sd_jwt_validation.rs create mode 100644 bindings/grpc/tests/api/status_list_2021.rs create mode 100644 bindings/grpc/tooling/domain-linkage-test-server/.well-known/did-configuration.json create mode 100644 bindings/grpc/tooling/start-http-server.sh create mode 100755 bindings/grpc/tooling/start-rpc-server.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..115fe4a561 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +target/ +bindings/wasm/ +bindings/grpc/target/ diff --git a/.github/workflows/build-and-test-grpc.yml b/.github/workflows/build-and-test-grpc.yml new file mode 100644 index 0000000000..80311728c8 --- /dev/null +++ b/.github/workflows/build-and-test-grpc.yml @@ -0,0 +1,41 @@ +name: Build and run grpc tests + +on: + push: + branches: + - main + pull_request: + types: [ opened, synchronize, reopened, ready_for_review ] + branches: + - main + - 'epic/**' + - 'support/**' + paths: + - '.github/workflows/build-and-test.yml' + - '.github/actions/**' + - '**.rs' + - '**.toml' + - 'bindings/grpc/**' + +jobs: + check-for-run-condition: + runs-on: ubuntu-latest + outputs: + should-run: ${{ !github.event.pull_request || github.event.pull_request.draft == false }} + steps: + - run: | + # this run step does nothing, but is needed to get the job output + + build-and-test: + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Build Docker image + uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671 + with: + context: . + file: bindings/grpc/Dockerfile + push: false + labels: iotaledger/identity-grpc:latest \ No newline at end of file diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index d60c351e6c..a58316790f 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -101,7 +101,7 @@ jobs: if: matrix.os == 'ubuntu-latest' run: | cargo metadata --format-version 1 | \ - jq -r '.workspace_members[] | select(contains("examples") | not)' | \ + jq -r '.workspace_members[]' | \ awk '{print $1}' | \ xargs -I {} cargo check -p {} --no-default-features @@ -109,7 +109,7 @@ jobs: if: matrix.os == 'ubuntu-latest' run: | cargo metadata --format-version 1 | \ - jq -r '.workspace_members[] | select(contains("examples") | not)' | \ + jq -r '.workspace_members[]' | \ awk '{print $1}' | \ xargs -I {} cargo check -p {} diff --git a/.github/workflows/grpc-publish-to-dockerhub.yml b/.github/workflows/grpc-publish-to-dockerhub.yml new file mode 100644 index 0000000000..d72fe20702 --- /dev/null +++ b/.github/workflows/grpc-publish-to-dockerhub.yml @@ -0,0 +1,52 @@ +name: gRPC publish to dockerhub + +on: + workflow_dispatch: + inputs: + tag: + description: 'Tag to publish under, defaults to latest' + required: false + default: latest + branch: + description: 'Branch to run publish from' + required: true + dry-run: + description: 'Run in dry-run mode' + type: boolean + required: false + default: true + +jobs: + push_to_registry: + environment: release + name: Push Docker image to Docker Hub + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.branch }} + + - name: Log in to Docker Hub + uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a + with: + username: ${{ secrets.IOTALEDGER_DOCKER_USERNAME }} + password: ${{ secrets.IOTALEDGER_DOCKER_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671 + with: + context: . + file: bindings/grpc/Dockerfile + push: ${{ !inputs.dry-run }} + labels: iotaledger/identity-grpc:${{ inputs.tag }} + + - name: Docker Hub Description + uses: peter-evans/dockerhub-description@e98e4d1628a5f3be2be7c231e50981aee98723ae + with: + username: ${{ secrets.IOTALEDGER_DOCKER_USERNAME }} + password: ${{ secrets.IOTALEDGER_DOCKER_PASSWORD }} + repository: iotaledger/identity-grpc + readme-filepath: ./bindigns/grpc/README.md + short-description: ${{ github.event.repository.description }} + diff --git a/Cargo.toml b/Cargo.toml index d4726e0d4c..0799f08ca1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ members = [ "examples", ] -exclude = ["bindings/wasm"] +exclude = ["bindings/wasm", "bindings/grpc"] [workspace.dependencies] serde = { version = "1.0", default-features = false, features = ["alloc", "derive"] } @@ -31,3 +31,6 @@ homepage = "https://www.iota.org" license = "Apache-2.0" repository = "https://github.com/iotaledger/identity.rs" rust-version = "1.65" + +[workspace.lints.clippy] +result_large_err = "allow" diff --git a/README.md b/README.md index 658479444f..3b6a4d6305 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ ## Introduction -IOTA Identity is a [Rust](https://www.rust-lang.org/) implementation of decentralized digital identity, also known as Self-Sovereign Identity (SSI). It implements the W3C [Decentralized Identifiers (DID)](https://www.w3.org/TR/did-core/) and [Verifiable Credentials](https://www.w3.org/TR/vc-data-model/) specifications. This library can be used to create, resolve and authenticate digital identities and to create verifiable credentials and presentations in order to share information in a verifiable manner and establish trust in the digital world. It does so while supporting secure storage of cryptographic keys, which can be implemented for your preferred key management system. Many of the individual libraries (Rust crates) are agnostic over the concrete DID method, with the exception of some libraries dedicated to implement the [IOTA DID method](https://wiki.iota.org/shimmer/identity.rs/specs/did/iota_did_method_spec/), which is an implementation of decentralized digital identity on the IOTA and Shimmer networks. Written in stable Rust, IOTA Identity has strong guarantees of memory safety and process integrity while maintaining exceptional performance. +IOTA Identity is a [Rust](https://www.rust-lang.org/) implementation of decentralized digital identity, also known as Self-Sovereign Identity (SSI). It implements the W3C [Decentralized Identifiers (DID)](https://www.w3.org/TR/did-core/) and [Verifiable Credentials](https://www.w3.org/TR/vc-data-model/) specifications. This library can be used to create, resolve and authenticate digital identities and to create verifiable credentials and presentations in order to share information in a verifiable manner and establish trust in the digital world. It does so while supporting secure storage of cryptographic keys, which can be implemented for your preferred key management system. Many of the individual libraries (Rust crates) are agnostic over the concrete DID method, with the exception of some libraries dedicated to implement the [IOTA DID method](https://wiki.iota.org/identity.rs/specs/did/iota_did_method_spec/), which is an implementation of decentralized digital identity on the IOTA and Shimmer networks. Written in stable Rust, IOTA Identity has strong guarantees of memory safety and process integrity while maintaining exceptional performance. ## Bindings @@ -32,12 +32,15 @@ IOTA Identity is a [Rust](https://www.rust-lang.org/) implementation of decentra - [Web Assembly](https://github.com/iotaledger/identity.rs/blob/HEAD/bindings/wasm/) (JavaScript/TypeScript) +## gRPC + +We provide a collection of experimental [gRPC services](https://github.com/iotaledger/identity.rs/blob/HEAD/bindings/grpc/) ## Documentation and Resources - API References: - [Rust API Reference](https://docs.rs/identity_iota/latest/identity_iota/): Package documentation (cargo docs). - - [Wasm API Reference](https://wiki.iota.org/shimmer/identity.rs/libraries/wasm/api_reference/): Wasm Package documentation. -- [Identity Documentation Pages](https://wiki.iota.org/shimmer/identity.rs/introduction): Supplementing documentation with context around identity and simple examples on library usage. + - [Wasm API Reference](https://wiki.iota.org/identity.rs/libraries/wasm/api_reference/): Wasm Package documentation. +- [Identity Documentation Pages](https://wiki.iota.org/identity.rs/introduction): Supplementing documentation with context around identity and simple examples on library usage. - [Examples](https://github.com/iotaledger/identity.rs/blob/HEAD/examples): Practical code snippets to get you started with the library. ## Prerequisites @@ -238,7 +241,7 @@ For detailed development progress, see the IOTA Identity development [kanban boa We would love to have you help us with the development of IOTA Identity. Each and every contribution is greatly valued! -Please review the [contribution](https://wiki.iota.org/shimmer/identity.rs/contribute) and [workflow](https://wiki.iota.org/shimmer/identity.rs/workflow) sections in the [IOTA Wiki](https://wiki.iota.org/). +Please review the [contribution](https://wiki.iota.org/identity.rs/contribute) and [workflow](https://wiki.iota.org/identity.rs/workflow) sections in the [IOTA Wiki](https://wiki.iota.org/). To contribute directly to the repository, simply fork the project, push your changes to your fork and create a pull request to get them included! diff --git a/bindings/grpc/Cargo.toml b/bindings/grpc/Cargo.toml new file mode 100644 index 0000000000..f594dc56d4 --- /dev/null +++ b/bindings/grpc/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "identity-grpc" +version = "0.1.0" +authors = ["IOTA Stiftung"] +edition = "2021" +homepage = "https://www.iota.org" +license = "Apache-2.0" +repository = "https://github.com/iotaledger/identity.rs" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +path = "src/lib.rs" + +[[bin]] +name = "identity-grpc" +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", "domain-linkage", "domain-linkage-fetch", "status-list-2021"] } +identity_stronghold = { path = "../../identity_stronghold", features = ["send-sync-storage"] } +iota-sdk = { version = "1.1.2", features = ["stronghold"] } +openssl = { version = "0.10", features = ["vendored"] } +prost = "0.12" +rand = "0.8.5" +serde = { version = "1.0.193", features = ["derive", "alloc"] } +serde_json = { version = "1.0.108", features = ["alloc"] } +thiserror = "1.0.50" +tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] } +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"] } + +[build-dependencies] +tonic-build = "0.10" diff --git a/bindings/grpc/Dockerfile b/bindings/grpc/Dockerfile new file mode 100644 index 0000000000..b7faca7c63 --- /dev/null +++ b/bindings/grpc/Dockerfile @@ -0,0 +1,20 @@ +FROM rust:bookworm as builder + +# install protobuf +RUN apt-get update && apt-get install -y protobuf-compiler libprotobuf-dev musl-tools + +COPY . /usr/src/app/ +WORKDIR /usr/src/app/bindings/grpc +RUN rustup target add x86_64-unknown-linux-musl +RUN cargo build --target x86_64-unknown-linux-musl --release --bin identity-grpc + +FROM gcr.io/distroless/static-debian11 as runner + +# get binary +COPY --from=builder /usr/src/app/bindings/grpc/target/x86_64-unknown-linux-musl/release/identity-grpc / + +# set run env +EXPOSE 50051 + +# run it +CMD ["/identity-grpc"] \ No newline at end of file diff --git a/bindings/grpc/README.md b/bindings/grpc/README.md new file mode 100644 index 0000000000..814e82a7f8 --- /dev/null +++ b/bindings/grpc/README.md @@ -0,0 +1,130 @@ +# Identity.rs gRPC Bindings +This project provides the functionalities of [Identity.rs](https://github.com/iotaledger/identity.rs) in a language-agnostic way through a [gRPC](https://grpc.io) server. + +The server can easily be run with docker using [this dockerfile](https://github.com/iotaledger/identity.rs/blob/grpc-bindings/bindings/grpc/Dockerfile). + +## Build +Run `docker build -f bindings/grpc/Dockerfile -t iotaleger/identity-grpc .` from the project root. + +### Dockerimage env variables and volume binds +The provided docker image requires the following variables to be set in order to properly work: +- `API_ENDPOINT`: IOTA node address. +- `STRONGHOLD_PWD`: Stronghold password. +- `SNAPSHOT_PATH`: Stronghold's snapshot location. + +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) | +| 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) | +| `StatusList2021Credential` creation | `status_list_2021/StatusList2021Svc.create` | [status_list_2021.proto](https://github.com/iotaledger/identity.rs/blob/grpc-bindings/bindings/grpc/proto/status_list_2021.proto) | +| `StatusList2021Credential` update | `status_list_2021/StatusList2021Svc.update` | [status_list_2021.proto](https://github.com/iotaledger/identity.rs/blob/grpc-bindings/bindings/grpc/proto/status_list_2021.proto) | + +## Testing + +### Domain Linkage + +#### Http server +In order to test domain linkage, you need access to a server that is reachable via HTTPS. If you already have one, you can ignore the server setup steps here and and provide the `did-configuration.json` on your server. + +1. create a 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 + ``` + + the `did-configuration` should look like this for now: + + ```json + { + "@context": "https://identity.foundation/.well-known/did-configuration/v1", + "linked_dids": [ + "add your domain linkage credential here" + ] + } + ``` +1. start a server that will serve this folder, e.g. with a NodeJs "http-server": `http-server ./test-server/`, in this example the server should now be running on local port 8080 +1. 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, which means you don't have to update credentials after each http server restart +1. 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 +1. copy the public url and insert it into [6_domain_linkage.rs](../../examples/1_advanced/6_domain_linkage.rs) as domain 1, e.g. `let domain_1: Url = Url::parse("https://0d40-2003-d3-2710-e200-485f-e8bb-7431-79a7.ngrok-free.app")?;` +.1 run the example with `cargo run --release --example 6_domain_linkage` + +#### GRPC server +1. grab the configuration resource from the log and replace the contents of your `did-configuration.json` with it +1. you now have a publicly reachable (sub)domain, that serves a `did-configuration` file containing a credential pointing to your DID +1. 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` +The arguments can be taken from examples, e.g. after running a `6_domain_linkage.rs`, which 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"` +1. 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 +1. 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" + } + ] + } + ``` + +1. 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 new file mode 100644 index 0000000000..c48bbdce41 --- /dev/null +++ b/bindings/grpc/build.rs @@ -0,0 +1,14 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +fn main() -> Result<(), Box> { + 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")); + + for proto in proto_files { + tonic_build::compile_protos(proto)?; + } + + Ok(()) +} diff --git a/bindings/grpc/proto/credentials.proto b/bindings/grpc/proto/credentials.proto new file mode 100644 index 0000000000..ae34c7b4b6 --- /dev/null +++ b/bindings/grpc/proto/credentials.proto @@ -0,0 +1,61 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; +package credentials; + +// -- CREDENTIALS REVOCATION --------------------------------------------- + +// The States a credential can be in. +enum RevocationStatus { + REVOKED = 0; + SUSPENDED = 1; + VALID = 2; +} + +message RevocationCheckRequest { + string type = 1; + string url = 2; + map properties = 3; +} + +message RevocationCheckResponse { + RevocationStatus status = 1; +} + +service CredentialRevocation { + // Checks whether a credential has been revoked with `RevocationBitmap2022`. + rpc check(RevocationCheckRequest) returns (RevocationCheckResponse); +} + +message JwtCreationRequest { + string credential_json = 1; + string issuer_fragment = 2; +} + +message JwtCreationResponse { + string jwt = 1; +} + +service Jwt { + // Encodes a given JSON credential into JWT, using the issuer's fragment to fetch the key from stronghold. + rpc create(JwtCreationRequest) returns (JwtCreationResponse); +} + +message VcValidationRequest { + // JWT encoded credential. + string credential_jwt = 1; + // JSON encoded `StatusList2021Credential`, used for status checking. + // If missing, status checking will be performed with `RevocationBitmap2022`. + optional string status_list_credential_json = 2; +} + +message VcValidationResponse { + // JSON encoded credential (extracted from request's JWT). + string credential_json = 1; +} + +service VcValidation { + // Performs encoding, syntax, signature, time constraints and status checking on the provided credential. + rpc validate(VcValidationRequest) returns (VcValidationResponse); +} \ No newline at end of file diff --git a/bindings/grpc/proto/document.proto b/bindings/grpc/proto/document.proto new file mode 100644 index 0000000000..d25558c243 --- /dev/null +++ b/bindings/grpc/proto/document.proto @@ -0,0 +1,24 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; +package document; + +message CreateDIDRequest { + // An IOTA's bech32 encoded address. + string bech32_address = 1; +} + +message CreateDIDResponse { + // The created DID document, encoded as JSON. + string document_json = 1; + // The stronghold's fragment for the generated document's auth method. + string fragment = 2; + // The DID of the created document. + string did = 3; +} + +service DocumentService { + /// Creates a new DID document stored on Tangle. + rpc create(CreateDIDRequest) returns (CreateDIDResponse); +} \ No newline at end of file diff --git a/bindings/grpc/proto/domain_linkage.proto b/bindings/grpc/proto/domain_linkage.proto new file mode 100644 index 0000000000..f2fe3426df --- /dev/null +++ b/bindings/grpc/proto/domain_linkage.proto @@ -0,0 +1,63 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +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/proto/health_check.proto b/bindings/grpc/proto/health_check.proto new file mode 100644 index 0000000000..0c4bee8ba5 --- /dev/null +++ b/bindings/grpc/proto/health_check.proto @@ -0,0 +1,15 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; +package health_check; + +message HealthCheckRequest {} + +message HealthCheckResponse { + string status = 1; +} + +service HealthCheck { + rpc Check(HealthCheckRequest) returns (HealthCheckResponse); +} \ No newline at end of file diff --git a/bindings/grpc/proto/sd_jwt.proto b/bindings/grpc/proto/sd_jwt.proto new file mode 100644 index 0000000000..86d6b5f7fe --- /dev/null +++ b/bindings/grpc/proto/sd_jwt.proto @@ -0,0 +1,30 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; +package sd_jwt; + +message KeyBindingOptions { + optional string nonce = 1; + optional string aud = 2; + // TODO: add JWS validation options + optional string earliest_issuance_date = 3; + optional string latest_issuance_date = 4; + string holder_did = 5; +} + +message VerificationRequest { + // SD-JWT encoded credential. + string jwt = 1; + optional KeyBindingOptions kb_options = 2; +} + +message VerificationResponse { + // JSON encoded credential, extracted from the request's SD-JWT. + string credential = 1; +} + +service Verification { + // Performs all validation steps on a SD-JWT encoded credential. + rpc verify(VerificationRequest) returns (VerificationResponse); +} \ No newline at end of file diff --git a/bindings/grpc/proto/status_list_2021.proto b/bindings/grpc/proto/status_list_2021.proto new file mode 100644 index 0000000000..f84eb738b1 --- /dev/null +++ b/bindings/grpc/proto/status_list_2021.proto @@ -0,0 +1,50 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; +package status_list_2021; + +enum Purpose { + REVOCATION = 0; + SUSPENSION = 1; +} + +message CreateRequest { + // Whether this status list will be used for revoking or suspending credentials. + Purpose purpose = 1; + // Amount of entries in the status list (a minimum of 131072 entries is required). + optional uint64 length = 2; + // The URL that identifies the credential. + optional string id = 3; + // Timestamp representing the expiration date for this credential, if it has to expire. + optional string expiration_date = 4; + // A list of credential's contexts, used to fill the credential's "@context" property. + // "https://www.w3.org/2018/credentials/v1" is provided by default. + repeated string contexts = 5; + // A list of credential's types, used to fill the credential's "type" property. + // "VerifiableCredential" is provided by default. + repeated string types = 6; + // The issuer DID URL. + string issuer = 7; +} + +message StatusListCredential { + // JSON encoded `StatusList2021Credential`. + string credential_json = 1; +} + +message UpdateRequest { + // JSON encoded `StatusList2021Credential`. + string credential_json = 1; + // Changes to apply to the status list represented as the map "entry-index -> bool value" + // where `true` means that the entry at the given index is revoked/suspended depending on + // the list's purpose. + map entries = 2; +} + +service StatusList2021Svc { + // Creates a new `StatusList2021Credential`. + rpc create(CreateRequest) returns(StatusListCredential); + // Sets the value for a list of entries in the provided `StatusList2021Credential`. + rpc update(UpdateRequest) returns(StatusListCredential); +} diff --git a/bindings/grpc/src/lib.rs b/bindings/grpc/src/lib.rs new file mode 100644 index 0000000000..d26756e597 --- /dev/null +++ b/bindings/grpc/src/lib.rs @@ -0,0 +1,7 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +#![allow(clippy::blocks_in_conditions)] + +pub mod server; +pub mod services; diff --git a/bindings/grpc/src/main.rs b/bindings/grpc/src/main.rs new file mode 100644 index 0000000000..4e6e3e11fa --- /dev/null +++ b/bindings/grpc/src/main.rs @@ -0,0 +1,47 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use identity_grpc::server::GRpcServer; +use identity_stronghold::StrongholdStorage; +use iota_sdk::client::stronghold::StrongholdAdapter; +use iota_sdk::client::Client; + +#[tokio::main] +#[tracing::instrument(err)] +async fn main() -> anyhow::Result<()> { + tracing::subscriber::set_global_default(tracing_subscriber::fmt().compact().finish()) + .expect("Failed to setup global tracing subscriber."); + + let api_endpoint = std::env::var("API_ENDPOINT")?; + + let client: Client = Client::builder() + .with_primary_node(&api_endpoint, None)? + .finish() + .await?; + let stronghold = init_stronghold()?; + + let addr = "0.0.0.0:50051".parse()?; + tracing::info!("gRPC server listening on {}", addr); + GRpcServer::new(client, stronghold).serve(addr).await?; + + Ok(()) +} + +#[tracing::instrument] +fn init_stronghold() -> anyhow::Result { + let stronghold_password = std::env::var("STRONGHOLD_PWD")?; + let snapshot_path = std::env::var("SNAPSHOT_PATH")?; + + // Check for snapshot file at specified path + let metadata = std::fs::metadata(&snapshot_path)?; + if !metadata.is_file() { + return Err(anyhow::anyhow!("No snapshot at provided path \"{}\"", &snapshot_path)); + } + + Ok( + StrongholdAdapter::builder() + .password(stronghold_password) + .build(snapshot_path) + .map(StrongholdStorage::new)?, + ) +} diff --git a/bindings/grpc/src/server.rs b/bindings/grpc/src/server.rs new file mode 100644 index 0000000000..c7fa5b527c --- /dev/null +++ b/bindings/grpc/src/server.rs @@ -0,0 +1,33 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::net::SocketAddr; + +use identity_stronghold::StrongholdStorage; +use iota_sdk::client::Client; +use tonic::transport::server::Router; +use tonic::transport::server::Server; + +use crate::services; + +#[derive(Debug)] +pub struct GRpcServer { + router: Router, + stronghold: StrongholdStorage, +} + +impl GRpcServer { + pub fn new(client: Client, stronghold: StrongholdStorage) -> Self { + let router = Server::builder().add_routes(services::routes(&client, &stronghold)); + Self { router, stronghold } + } + pub async fn serve(self, addr: SocketAddr) -> Result<(), tonic::transport::Error> { + self.router.serve(addr).await + } + pub fn into_router(self) -> Router { + self.router + } + pub fn stronghold(&self) -> StrongholdStorage { + self.stronghold.clone() + } +} diff --git a/bindings/grpc/src/services/credential/jwt.rs b/bindings/grpc/src/services/credential/jwt.rs new file mode 100644 index 0000000000..6cfb3368e6 --- /dev/null +++ b/bindings/grpc/src/services/credential/jwt.rs @@ -0,0 +1,85 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use _credentials::jwt_server::Jwt as JwtSvc; +use identity_iota::core::FromJson; +use identity_iota::core::Object; +use identity_iota::credential::Credential; +use identity_iota::iota::IotaDID; +use identity_iota::iota::IotaDocument; +use identity_iota::resolver::Resolver; +use identity_iota::storage::JwkDocumentExt; +use identity_iota::storage::JwsSignatureOptions; +use identity_iota::storage::Storage; +use identity_stronghold::StrongholdStorage; +use iota_sdk::client::Client; +use tonic::Request; +use tonic::Response; +use tonic::Status; + +use self::_credentials::jwt_server::JwtServer; +use self::_credentials::JwtCreationRequest; +use self::_credentials::JwtCreationResponse; + +mod _credentials { + tonic::include_proto!("credentials"); +} + +pub struct JwtService { + resolver: Resolver, + storage: Storage, +} + +impl JwtService { + pub fn new(client: &Client, stronghold: &StrongholdStorage) -> Self { + let mut resolver = Resolver::new(); + resolver.attach_iota_handler(client.clone()); + Self { + resolver, + storage: Storage::new(stronghold.clone(), stronghold.clone()), + } + } +} + +#[tonic::async_trait] +impl JwtSvc for JwtService { + #[tracing::instrument( + name = "create_jwt_credential", + skip_all, + fields(request = ?req.get_ref()) + ret, + err, + )] + async fn create(&self, req: Request) -> Result, Status> { + let JwtCreationRequest { + credential_json, + issuer_fragment, + } = req.into_inner(); + let credential = + Credential::::from_json(credential_json.as_str()).map_err(|e| Status::invalid_argument(e.to_string()))?; + let issuer_did = + IotaDID::parse(credential.issuer.url().as_str()).map_err(|e| Status::invalid_argument(e.to_string()))?; + let issuer_document = self + .resolver + .resolve(&issuer_did) + .await + .map_err(|e| Status::not_found(e.to_string()))?; + + let jwt = issuer_document + .create_credential_jwt( + &credential, + &self.storage, + &issuer_fragment, + &JwsSignatureOptions::default(), + None, + ) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(JwtCreationResponse { jwt: jwt.into() })) + } +} + +pub fn service(client: &Client, stronghold: &StrongholdStorage) -> JwtServer { + JwtServer::new(JwtService::new(client, stronghold)) +} diff --git a/bindings/grpc/src/services/credential/mod.rs b/bindings/grpc/src/services/credential/mod.rs new file mode 100644 index 0000000000..8d71ccacee --- /dev/null +++ b/bindings/grpc/src/services/credential/mod.rs @@ -0,0 +1,16 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +pub mod jwt; +pub mod revocation; +pub mod validation; + +use identity_stronghold::StrongholdStorage; +use iota_sdk::client::Client; +use tonic::transport::server::RoutesBuilder; + +pub fn init_services(routes: &mut RoutesBuilder, client: &Client, stronghold: &StrongholdStorage) { + routes.add_service(revocation::service(client)); + routes.add_service(jwt::service(client, stronghold)); + routes.add_service(validation::service(client)); +} diff --git a/bindings/grpc/src/services/credential/revocation.rs b/bindings/grpc/src/services/credential/revocation.rs new file mode 100644 index 0000000000..d637bce22e --- /dev/null +++ b/bindings/grpc/src/services/credential/revocation.rs @@ -0,0 +1,161 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use credential_verification::credential_revocation_server::CredentialRevocation; +use credential_verification::credential_revocation_server::CredentialRevocationServer; +use credential_verification::RevocationCheckRequest; +use credential_verification::RevocationCheckResponse; +use credential_verification::RevocationStatus; +use identity_iota::credential::JwtCredentialValidatorUtils; +use identity_iota::credential::JwtValidationError; +use identity_iota::credential::RevocationBitmapStatus; +use identity_iota::credential::{self}; +use identity_iota::prelude::IotaDocument; +use identity_iota::prelude::Resolver; +use iota_sdk::client::Client; +use prost::bytes::Bytes; +use serde::Deserialize; +use serde::Serialize; + +use thiserror::Error; +use tonic::Request; +use tonic::Response; +use tonic::{self}; + +mod credential_verification { + use super::RevocationCheckError; + use identity_iota::credential::RevocationBitmapStatus; + use identity_iota::credential::Status; + + tonic::include_proto!("credentials"); + + impl TryFrom for Status { + type Error = RevocationCheckError; + fn try_from(req: RevocationCheckRequest) -> Result { + use identity_iota::core::Object; + use identity_iota::core::Url; + + if req.r#type.as_str() != RevocationBitmapStatus::TYPE { + Err(Self::Error::UnknownRevocationType(req.r#type)) + } else { + let parsed_url = req + .url + .parse::() + .map_err(|_| Self::Error::InvalidRevocationUrl(req.url))?; + let properties = req + .properties + .into_iter() + .map(|(k, v)| serde_json::to_value(v).map(|v| (k, v))) + .collect::>() + .map_err(|_| Self::Error::MalformedPropertiesObject)?; + + Ok(Status { + id: parsed_url, + type_: req.r#type, + properties, + }) + } + } + } +} + +#[derive(Debug, Error, Serialize, Deserialize)] +#[serde(tag = "error_type", content = "reason")] +#[serde(rename_all = "snake_case")] +pub enum RevocationCheckError { + #[error("Unknown revocation type {0}")] + UnknownRevocationType(String), + #[error("Could not parse {0} into a valid URL")] + InvalidRevocationUrl(String), + #[error("Properties isn't a valid JSON object")] + MalformedPropertiesObject, + #[error("Invalid credential status: {0}")] + InvalidCredentialStatus(String), + #[error("Issuer's DID resolution error: {0}")] + ResolutionError(String), + #[error("Revocation map not found")] + RevocationMapNotFound, +} + +impl From for tonic::Status { + fn from(e: RevocationCheckError) -> Self { + let message = e.to_string(); + let code = match &e { + RevocationCheckError::InvalidCredentialStatus(_) + | RevocationCheckError::MalformedPropertiesObject + | RevocationCheckError::UnknownRevocationType(_) + | RevocationCheckError::InvalidRevocationUrl(_) => tonic::Code::InvalidArgument, + RevocationCheckError::ResolutionError(_) => tonic::Code::Internal, + RevocationCheckError::RevocationMapNotFound => tonic::Code::NotFound, + }; + let error_json = serde_json::to_vec(&e).unwrap_or_default(); + + tonic::Status::with_details(code, message, Bytes::from(error_json)) + } +} + +impl TryFrom for RevocationCheckError { + type Error = (); + fn try_from(value: tonic::Status) -> Result { + serde_json::from_slice(value.details()).map_err(|_| ()) + } +} + +#[derive(Debug)] +pub struct CredentialVerifier { + resolver: Resolver, +} + +impl CredentialVerifier { + pub fn new(client: &Client) -> Self { + let mut resolver = Resolver::new(); + resolver.attach_iota_handler(client.clone()); + Self { resolver } + } +} + +#[tonic::async_trait] +impl CredentialRevocation for CredentialVerifier { + #[tracing::instrument( + name = "credential_check", + skip_all, + fields(request = ?req.get_ref()) + ret, + err, + )] + async fn check( + &self, + req: Request, + ) -> Result, tonic::Status> { + let credential_revocation_status = { + let revocation_status = credential::Status::try_from(req.into_inner())?; + RevocationBitmapStatus::try_from(revocation_status) + .map_err(|e| RevocationCheckError::InvalidCredentialStatus(e.to_string()))? + }; + let issuer_did = credential_revocation_status.id().unwrap(); // Safety: already parsed as a valid URL + let issuer_doc = self + .resolver + .resolve(issuer_did.did()) + .await + .map_err(|e| RevocationCheckError::ResolutionError(e.to_string()))?; + + if let Err(e) = + JwtCredentialValidatorUtils::check_revocation_bitmap_status(&issuer_doc, credential_revocation_status) + { + match &e { + JwtValidationError::Revoked => Ok(Response::new(RevocationCheckResponse { + status: RevocationStatus::Revoked.into(), + })), + _ => Err(RevocationCheckError::RevocationMapNotFound.into()), + } + } else { + Ok(Response::new(RevocationCheckResponse { + status: RevocationStatus::Valid.into(), + })) + } + } +} + +pub fn service(client: &Client) -> CredentialRevocationServer { + CredentialRevocationServer::new(CredentialVerifier::new(client)) +} diff --git a/bindings/grpc/src/services/credential/validation.rs b/bindings/grpc/src/services/credential/validation.rs new file mode 100644 index 0000000000..fb218b727b --- /dev/null +++ b/bindings/grpc/src/services/credential/validation.rs @@ -0,0 +1,135 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use identity_eddsa_verifier::EdDSAJwsVerifier; +use identity_iota::core::FromJson; +use identity_iota::core::Object; +use identity_iota::core::ToJson; +use identity_iota::credential::status_list_2021::StatusList2021Credential; +use identity_iota::credential::FailFast; +use identity_iota::credential::Jwt; +use identity_iota::credential::JwtCredentialValidationOptions; +use identity_iota::credential::JwtCredentialValidator; +use identity_iota::credential::JwtCredentialValidatorUtils; +use identity_iota::credential::JwtValidationError; +use identity_iota::credential::StatusCheck; +use identity_iota::iota::IotaDID; +use identity_iota::resolver; +use identity_iota::resolver::Resolver; +use iota_sdk::client::Client; + +use _credentials::vc_validation_server::VcValidation; +use _credentials::vc_validation_server::VcValidationServer; +use _credentials::VcValidationRequest; +use _credentials::VcValidationResponse; +use tonic::Code; +use tonic::Request; +use tonic::Response; +use tonic::Status; + +mod _credentials { + tonic::include_proto!("credentials"); +} + +#[derive(Debug, thiserror::Error)] +pub enum VcValidationError { + #[error(transparent)] + JwtValidationError(#[from] JwtValidationError), + #[error("DID resolution error")] + DidResolutionError(#[source] resolver::Error), + #[error("Provided an invalid StatusList2021Credential")] + InvalidStatusList2021Credential(#[source] identity_iota::core::Error), + #[error("The provided credential has been revoked")] + RevokedCredential, + #[error("The provided credential has expired")] + ExpiredCredential, + #[error("The provided credential has been suspended")] + SuspendedCredential, +} + +impl From for Status { + fn from(error: VcValidationError) -> Self { + let code = match &error { + VcValidationError::InvalidStatusList2021Credential(_) => Code::InvalidArgument, + _ => Code::Internal, + }; + + Status::new(code, error.to_string()) + } +} + +pub struct VcValidator { + resolver: Resolver, +} + +impl VcValidator { + pub fn new(client: &Client) -> Self { + let mut resolver = Resolver::new(); + resolver.attach_iota_handler(client.clone()); + Self { resolver } + } +} + +#[tonic::async_trait] +impl VcValidation for VcValidator { + #[tracing::instrument( + name = "validate_jwt_credential", + skip_all, + fields(request = ?req.get_ref()) + ret, + err, +)] + async fn validate(&self, req: Request) -> Result, Status> { + let VcValidationRequest { + credential_jwt, + status_list_credential_json, + } = req.into_inner(); + let jwt = Jwt::new(credential_jwt); + let issuer_did = JwtCredentialValidatorUtils::extract_issuer_from_jwt::(&jwt) + .map_err(VcValidationError::JwtValidationError)?; + let issuer_doc = self + .resolver + .resolve(&issuer_did) + .await + .map_err(VcValidationError::DidResolutionError)?; + + let mut validation_option = JwtCredentialValidationOptions::default(); + if status_list_credential_json.is_some() { + validation_option = validation_option.status_check(StatusCheck::SkipAll); + } + + let validator = JwtCredentialValidator::with_signature_verifier(EdDSAJwsVerifier::default()); + let decoded_credential = validator + .validate::<_, Object>(&jwt, &issuer_doc, &validation_option, FailFast::FirstError) + .map_err(|mut e| match e.validation_errors.swap_remove(0) { + JwtValidationError::Revoked => VcValidationError::RevokedCredential, + JwtValidationError::ExpirationDate | JwtValidationError::IssuanceDate => VcValidationError::ExpiredCredential, + e => VcValidationError::JwtValidationError(e), + })?; + + if let Some(status_list_json) = status_list_credential_json { + let status_list = StatusList2021Credential::from_json(&status_list_json) + .map_err(VcValidationError::InvalidStatusList2021Credential)?; + JwtCredentialValidatorUtils::check_status_with_status_list_2021( + &decoded_credential.credential, + &status_list, + StatusCheck::Strict, + ) + .map_err(|e| match e { + JwtValidationError::Revoked => VcValidationError::RevokedCredential, + JwtValidationError::Suspended => VcValidationError::SuspendedCredential, + e => VcValidationError::JwtValidationError(e), + })?; + } + + let response = Response::new(VcValidationResponse { + credential_json: decoded_credential.credential.to_json().unwrap(), + }); + + Ok(response) + } +} + +pub fn service(client: &Client) -> VcValidationServer { + VcValidationServer::new(VcValidator::new(client)) +} diff --git a/bindings/grpc/src/services/document.rs b/bindings/grpc/src/services/document.rs new file mode 100644 index 0000000000..0ed1298637 --- /dev/null +++ b/bindings/grpc/src/services/document.rs @@ -0,0 +1,115 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use _document::document_service_server::DocumentService; +use _document::document_service_server::DocumentServiceServer; +use _document::CreateDidRequest; +use _document::CreateDidResponse; +use identity_iota::core::ToJson; +use identity_iota::iota::IotaClientExt; +use identity_iota::iota::IotaDocument; +use identity_iota::iota::IotaIdentityClientExt; +use identity_iota::storage::JwkDocumentExt; +use identity_iota::storage::JwkStorageDocumentError; +use identity_iota::storage::Storage; +use identity_iota::verification::jws::JwsAlgorithm; +use identity_iota::verification::MethodScope; +use identity_stronghold::StrongholdStorage; +use identity_stronghold::ED25519_KEY_TYPE; +use iota_sdk::client::Client; +use iota_sdk::types::block::address::Address; +use std::error::Error as _; +use tonic::Code; +use tonic::Request; +use tonic::Response; +use tonic::Status; + +mod _document { + tonic::include_proto!("document"); +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("The provided address is not a valid bech32 encoded address")] + InvalidAddress, + #[error(transparent)] + IotaClientError(identity_iota::iota::Error), + #[error(transparent)] + StorageError(JwkStorageDocumentError), +} + +impl From for Status { + fn from(value: Error) -> Self { + let code = match &value { + Error::InvalidAddress => Code::InvalidArgument, + _ => Code::Internal, + }; + Status::new(code, value.to_string()) + } +} + +pub struct DocumentSvc { + storage: Storage, + client: Client, +} + +impl DocumentSvc { + pub fn new(client: &Client, stronghold: &StrongholdStorage) -> Self { + Self { + storage: Storage::new(stronghold.clone(), stronghold.clone()), + client: client.clone(), + } + } +} + +#[tonic::async_trait] +impl DocumentService for DocumentSvc { + #[tracing::instrument( + name = "create_did_document", + skip_all, + fields(request = ?req.get_ref()) + ret, + err, + )] + async fn create(&self, req: Request) -> Result, Status> { + let CreateDidRequest { bech32_address } = req.into_inner(); + let address = Address::try_from_bech32(&bech32_address).map_err(|_| Error::InvalidAddress)?; + let network_name = self.client.network_name().await.map_err(Error::IotaClientError)?; + + let mut document = IotaDocument::new(&network_name); + let fragment = document + .generate_method( + &self.storage, + ED25519_KEY_TYPE.clone(), + JwsAlgorithm::EdDSA, + None, + MethodScope::VerificationMethod, + ) + .await + .map_err(Error::StorageError)?; + + let alias_output = self + .client + .new_did_output(address, document, None) + .await + .map_err(Error::IotaClientError)?; + + let document = self + .client + .publish_did_output(self.storage.key_storage().as_secret_manager(), alias_output) + .await + .map_err(Error::IotaClientError) + .inspect_err(|e| tracing::error!("{:?}", e.source()))?; + let did = document.id(); + + Ok(Response::new(CreateDidResponse { + document_json: document.to_json().unwrap(), + fragment, + did: did.to_string(), + })) + } +} + +pub fn service(client: &Client, stronghold: &StrongholdStorage) -> DocumentServiceServer { + DocumentServiceServer::new(DocumentSvc::new(client, stronghold)) +} diff --git a/bindings/grpc/src/services/domain_linkage.rs b/bindings/grpc/src/services/domain_linkage.rs new file mode 100644 index 0000000000..3c3935a413 --- /dev/null +++ b/bindings/grpc/src/services/domain_linkage.rs @@ -0,0 +1,377 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +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/health_check.rs b/bindings/grpc/src/services/health_check.rs new file mode 100644 index 0000000000..27cf808c4f --- /dev/null +++ b/bindings/grpc/src/services/health_check.rs @@ -0,0 +1,36 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use health_check::health_check_server::HealthCheck; +use health_check::health_check_server::HealthCheckServer; +use health_check::HealthCheckRequest; +use health_check::HealthCheckResponse; +use tonic::Request; +use tonic::Response; +use tonic::Status; + +#[allow(clippy::module_inception)] +mod health_check { + tonic::include_proto!("health_check"); +} + +#[derive(Debug, Default)] +pub struct HealthChecker {} + +#[tonic::async_trait] +impl HealthCheck for HealthChecker { + #[tracing::instrument( + name = "health_check", + skip_all, + fields(request = ?_req.get_ref()) + ret, + err, + )] + async fn check(&self, _req: Request) -> Result, Status> { + Ok(Response::new(HealthCheckResponse { status: "OK".into() })) + } +} + +pub fn service() -> HealthCheckServer { + HealthCheckServer::new(HealthChecker::default()) +} diff --git a/bindings/grpc/src/services/mod.rs b/bindings/grpc/src/services/mod.rs new file mode 100644 index 0000000000..f632feb91a --- /dev/null +++ b/bindings/grpc/src/services/mod.rs @@ -0,0 +1,26 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +pub mod credential; +pub mod document; +pub mod domain_linkage; +pub mod health_check; +pub mod sd_jwt; +pub mod status_list_2021; + +use identity_stronghold::StrongholdStorage; +use iota_sdk::client::Client; +use tonic::transport::server::Routes; +use tonic::transport::server::RoutesBuilder; + +pub fn routes(client: &Client, stronghold: &StrongholdStorage) -> Routes { + let mut routes = RoutesBuilder::default(); + 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.add_service(status_list_2021::service()); + + routes.routes() +} diff --git a/bindings/grpc/src/services/sd_jwt.rs b/bindings/grpc/src/services/sd_jwt.rs new file mode 100644 index 0000000000..af792e51f6 --- /dev/null +++ b/bindings/grpc/src/services/sd_jwt.rs @@ -0,0 +1,164 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use _sd_jwt::verification_server::Verification; +use _sd_jwt::verification_server::VerificationServer; +use _sd_jwt::VerificationRequest; +use _sd_jwt::VerificationResponse; +use identity_eddsa_verifier::EdDSAJwsVerifier; +use identity_iota::core::Object; +use identity_iota::core::Timestamp; +use identity_iota::core::ToJson; +use identity_iota::credential::FailFast; +use identity_iota::credential::Jwt; +use identity_iota::credential::JwtCredentialValidationOptions; +use identity_iota::credential::JwtCredentialValidatorUtils; +use identity_iota::credential::KeyBindingJWTValidationOptions; +use identity_iota::credential::SdJwtCredentialValidator; +use identity_iota::iota::IotaDID; +use identity_iota::iota::IotaDocument; +use identity_iota::resolver::Resolver; +use identity_iota::sd_jwt_payload::SdJwt; +use identity_iota::sd_jwt_payload::SdObjectDecoder; +use iota_sdk::client::Client; +use serde::Deserialize; +use serde::Serialize; +use thiserror::Error; + +use self::_sd_jwt::KeyBindingOptions; + +mod _sd_jwt { + tonic::include_proto!("sd_jwt"); +} + +impl From for KeyBindingJWTValidationOptions { + fn from(value: KeyBindingOptions) -> Self { + let mut kb_options = Self::default(); + kb_options.nonce = value.nonce; + kb_options.aud = value.aud; + kb_options.earliest_issuance_date = value + .earliest_issuance_date + .and_then(|t| Timestamp::parse(t.as_str()).ok()); + kb_options.latest_issuance_date = value + .latest_issuance_date + .and_then(|t| Timestamp::parse(t.as_str()).ok()); + + kb_options + } +} + +#[derive(Debug, Error, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[serde(tag = "error", content = "reason")] +enum SdJwtVerificationError { + #[error("Failed to parse SD-JWT: {0}")] + DeserializationError(String), + #[error("Failed to parse JWT: {0}")] + JwtError(String), + #[error("Credential verification failed: {0}")] + VerificationError(String), + #[error("Failed to resolve DID Document: {0}")] + DidResolutionError(String), + #[error("Missing \"kb_options\".")] + MissingKbOptions, + #[error("{0}")] + KeyBindingJwtError(String), + #[error("Provided an invalid holder's id.")] + InvalidHolderDid, +} + +impl From for tonic::Status { + fn from(value: SdJwtVerificationError) -> Self { + let code = match &value { + SdJwtVerificationError::DeserializationError(_) => tonic::Code::InvalidArgument, + SdJwtVerificationError::JwtError(_) => tonic::Code::InvalidArgument, + SdJwtVerificationError::VerificationError(_) => tonic::Code::InvalidArgument, + SdJwtVerificationError::DidResolutionError(_) => tonic::Code::NotFound, + SdJwtVerificationError::MissingKbOptions => tonic::Code::InvalidArgument, + SdJwtVerificationError::KeyBindingJwtError(_) => tonic::Code::Internal, + SdJwtVerificationError::InvalidHolderDid => tonic::Code::InvalidArgument, + }; + 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()) + } +} + +#[derive(Debug)] +pub struct SdJwtService { + resolver: Resolver, +} + +impl SdJwtService { + pub fn new(client: &Client) -> Self { + let mut resolver = Resolver::new(); + resolver.attach_iota_handler(client.clone()); + Self { resolver } + } +} + +#[tonic::async_trait] +impl Verification for SdJwtService { + #[tracing::instrument( + name = "sd_jwt_verification", + skip_all, + fields(request = ?request.get_ref()) + ret, + err, + )] + async fn verify( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let VerificationRequest { jwt, kb_options } = request.into_inner(); + let mut sd_jwt = SdJwt::parse(&jwt).map_err(|e| SdJwtVerificationError::DeserializationError(e.to_string()))?; + let jwt = Jwt::new(sd_jwt.jwt); + + let issuer_did = JwtCredentialValidatorUtils::extract_issuer_from_jwt::(&jwt) + .map_err(|e| SdJwtVerificationError::VerificationError(e.to_string()))?; + let issuer_document = self + .resolver + .resolve(&issuer_did) + .await + .map_err(|e| SdJwtVerificationError::DidResolutionError(e.to_string()))?; + sd_jwt.jwt = jwt.into(); + + let decoder = SdObjectDecoder::new_with_sha256(); + let validator = SdJwtCredentialValidator::with_signature_verifier(EdDSAJwsVerifier::default(), decoder); + let credential = validator + .validate_credential::<_, Object>( + &sd_jwt, + &issuer_document, + &JwtCredentialValidationOptions::default(), + FailFast::FirstError, + ) + .map_err(|e| SdJwtVerificationError::VerificationError(e.to_string()))?; + + if sd_jwt.key_binding_jwt.is_some() { + let Some(kb_options) = kb_options else { + return Err(SdJwtVerificationError::MissingKbOptions.into()); + }; + let holder = { + let did = + IotaDID::parse(kb_options.holder_did.as_str()).map_err(|_| SdJwtVerificationError::InvalidHolderDid)?; + self + .resolver + .resolve(&did) + .await + .map_err(|e| SdJwtVerificationError::DidResolutionError(e.to_string()))? + }; + let _ = validator + .validate_key_binding_jwt(&sd_jwt, &holder, &kb_options.into()) + .map_err(|e| SdJwtVerificationError::KeyBindingJwtError(e.to_string()))?; + } + + Ok(tonic::Response::new(VerificationResponse { + credential: credential.credential.to_json().unwrap(), + })) + } +} + +pub fn service(client: &Client) -> VerificationServer { + VerificationServer::new(SdJwtService::new(client)) +} diff --git a/bindings/grpc/src/services/status_list_2021.rs b/bindings/grpc/src/services/status_list_2021.rs new file mode 100644 index 0000000000..be0595c9a4 --- /dev/null +++ b/bindings/grpc/src/services/status_list_2021.rs @@ -0,0 +1,170 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashSet; + +use identity_iota::core::Context; +use identity_iota::core::FromJson; +use identity_iota::core::Timestamp; +use identity_iota::core::ToJson; +use identity_iota::core::Url; +use identity_iota::credential::status_list_2021::StatusList2021; +use identity_iota::credential::status_list_2021::StatusList2021Credential; +use identity_iota::credential::status_list_2021::StatusList2021CredentialBuilder; +use identity_iota::credential::status_list_2021::StatusList2021CredentialError; +use identity_iota::credential::status_list_2021::StatusPurpose; +use identity_iota::credential::Issuer; +use identity_iota::credential::{self}; + +use _status_list_2021::status_list2021_svc_server::StatusList2021Svc; +use _status_list_2021::status_list2021_svc_server::StatusList2021SvcServer; +use _status_list_2021::CreateRequest; +use _status_list_2021::Purpose; +use _status_list_2021::StatusListCredential; +use _status_list_2021::UpdateRequest; +use tonic::Code; +use tonic::Request; +use tonic::Response; +use tonic::Status; + +mod _status_list_2021 { + use identity_iota::credential::status_list_2021::StatusPurpose; + + tonic::include_proto!("status_list_2021"); + + impl From for StatusPurpose { + fn from(value: Purpose) -> Self { + match value { + Purpose::Revocation => StatusPurpose::Revocation, + Purpose::Suspension => StatusPurpose::Suspension, + } + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("A valid status list must have at least 16KB entries")] + InvalidStatusListLength, + #[error("\"{0}\" is not a valid context")] + InvalidContext(String), + #[error("\"{0}\" is not a valid issuer")] + InvalidIssuer(String), + #[error("\"{0}\" is not a valid timestamp")] + InvalidTimestamp(String), + #[error("\"{0}\" is not a valid id")] + InvalidId(String), + #[error("Failed to deserialize into a valid StatusList2021Credential")] + CredentialDeserializationError(#[source] identity_iota::core::Error), + #[error(transparent)] + CredentialError(#[from] credential::Error), + #[error(transparent)] + StatusListError(StatusList2021CredentialError), +} + +impl From for Status { + fn from(value: Error) -> Self { + let code = match &value { + Error::InvalidStatusListLength + | Error::InvalidContext(_) + | Error::InvalidIssuer(_) + | Error::InvalidTimestamp(_) => Code::InvalidArgument, + _ => Code::Internal, + }; + + Status::new(code, value.to_string()) + } +} + +pub struct StatusList2021Service; + +#[tonic::async_trait] +impl StatusList2021Svc for StatusList2021Service { + #[tracing::instrument( + name = "create_status_list_credential", + skip_all, + fields(request = ?req.get_ref()) + ret, + err, +)] + async fn create(&self, req: Request) -> Result, Status> { + let CreateRequest { + purpose, + length, + id, + expiration_date, + contexts, + types, + issuer, + } = req.into_inner(); + let status_list = length + .map(|entries| StatusList2021::new(entries as usize)) + .unwrap_or(Ok(StatusList2021::default())) + .map_err(|_| Error::InvalidStatusListLength)?; + + let mut builder = StatusList2021CredentialBuilder::new(status_list); + let contexts = contexts.into_iter().collect::>(); + for ctx in contexts { + let url = Url::parse(&ctx).map_err(move |_| Error::InvalidContext(ctx))?; + builder = builder.context(Context::Url(url)); + } + + let types = types.into_iter().collect::>(); + for t in types { + builder = builder.add_type(t); + } + let issuer = Url::parse(&issuer) + .map_err(move |_| Error::InvalidIssuer(issuer)) + .map(Issuer::Url)?; + builder = builder.issuer(issuer); + builder = builder.purpose(StatusPurpose::from(Purpose::try_from(purpose).unwrap())); + if let Some(exp) = expiration_date { + let exp = Timestamp::parse(&exp).map_err(move |_| Error::InvalidTimestamp(exp))?; + builder = builder.expiration_date(exp); + } + if let Some(id) = id { + let id = Url::parse(&id).map_err(move |_| Error::InvalidId(id))?; + builder = builder.subject_id(id); + } + let status_list_credential = builder.build().map_err(Error::CredentialError)?; + let res = StatusListCredential { + credential_json: status_list_credential.to_json().unwrap(), + }; + + Ok(Response::new(res)) + } + + #[tracing::instrument( + name = "update_status_list_credential", + skip_all, + fields(request = ?req.get_ref()) + ret, + err, + )] + async fn update(&self, req: Request) -> Result, Status> { + let UpdateRequest { + credential_json, + entries, + } = req.into_inner(); + let mut status_list_credential = + StatusList2021Credential::from_json(&credential_json).map_err(Error::CredentialDeserializationError)?; + + status_list_credential + .update(move |status_list| { + for (idx, value) in entries { + status_list.set_entry(idx as usize, value)? + } + + Ok(()) + }) + .map_err(Error::StatusListError)?; + + Ok(Response::new(StatusListCredential { + credential_json: status_list_credential.to_json().unwrap(), + })) + } +} + +pub fn service() -> StatusList2021SvcServer { + StatusList2021SvcServer::new(StatusList2021Service) +} diff --git a/bindings/grpc/tests/api/credential_revocation_check.rs b/bindings/grpc/tests/api/credential_revocation_check.rs new file mode 100644 index 0000000000..9e92197c72 --- /dev/null +++ b/bindings/grpc/tests/api/credential_revocation_check.rs @@ -0,0 +1,99 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use credentials::credential_revocation_client::CredentialRevocationClient; +use credentials::RevocationStatus; +use identity_iota::credential::RevocationBitmap; +use identity_iota::credential::RevocationBitmapStatus; +use identity_iota::credential::{self}; +use identity_iota::did::DID; +use serde_json::json; + +use crate::credential_revocation_check::credentials::RevocationCheckRequest; +use crate::helpers::Entity; +use crate::helpers::TestServer; + +mod credentials { + tonic::include_proto!("credentials"); +} + +#[tokio::test] +async fn checking_status_of_credential_works() -> anyhow::Result<()> { + let server = TestServer::new().await; + let client = server.client(); + let mut issuer = Entity::new(); + issuer.create_did(client).await?; + + let mut subject = Entity::new(); + subject.create_did(client).await?; + + let service_id = issuer + .document() + .unwrap() // Safety: `create_did` didn't fail + .id() + .to_url() + .join("#my-revocation-service")?; + + // Add a revocation service to the issuer's DID document + issuer + .update_document(client, |mut doc| { + let service = RevocationBitmap::new().to_service(service_id.clone()).unwrap(); + + doc.insert_service(service).ok().map(|_| doc) + }) + .await?; + + let credential_status: credential::Status = RevocationBitmapStatus::new(service_id, 3).into(); + + let mut grpc_client = CredentialRevocationClient::connect(server.endpoint()).await?; + let req = RevocationCheckRequest { + r#type: credential_status.type_, + url: credential_status.id.into_string(), + properties: credential_status + .properties + .into_iter() + .map(|(k, v)| (k, v.to_string().trim_matches('"').to_owned())) + .collect(), + }; + let res = grpc_client.check(tonic::Request::new(req.clone())).await?.into_inner(); + + assert_eq!(res.status(), RevocationStatus::Valid); + + // Revoke credential + issuer + .update_document(&client, |mut doc| { + doc.revoke_credentials("my-revocation-service", &[3]).ok().map(|_| doc) + }) + .await?; + + let res = grpc_client.check(tonic::Request::new(req)).await?.into_inner(); + assert_eq!(res.status(), RevocationStatus::Revoked); + + Ok(()) +} + +#[tokio::test] +async fn checking_status_of_valid_but_unresolvable_url_fails() -> anyhow::Result<()> { + use identity_grpc::services::credential::revocation::RevocationCheckError; + let server = TestServer::new().await; + + let mut grpc_client = CredentialRevocationClient::connect(server.endpoint()).await?; + let properties = json!({ + "revocationBitmapIndex": "3" + }); + let req = RevocationCheckRequest { + r#type: RevocationBitmap::TYPE.to_owned(), + url: "did:example:1234567890#my-revocation-service".to_owned(), + properties: properties + .as_object() + .unwrap() + .into_iter() + .map(|(k, v)| (k.clone(), v.to_string().trim_matches('"').to_owned())) + .collect(), + }; + let res_error = grpc_client.check(tonic::Request::new(req.clone())).await; + + assert!(res_error.is_err_and(|e| matches!(e.try_into().unwrap(), RevocationCheckError::ResolutionError(_)))); + + Ok(()) +} diff --git a/bindings/grpc/tests/api/credential_validation.rs b/bindings/grpc/tests/api/credential_validation.rs new file mode 100644 index 0000000000..f1bfedf100 --- /dev/null +++ b/bindings/grpc/tests/api/credential_validation.rs @@ -0,0 +1,151 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use _credentials::vc_validation_client::VcValidationClient; +use _credentials::VcValidationRequest; +use identity_iota::core::FromJson; +use identity_iota::core::ToJson; +use identity_iota::core::Url; +use identity_iota::credential::status_list_2021::StatusList2021; +use identity_iota::credential::status_list_2021::StatusList2021CredentialBuilder; +use identity_iota::credential::status_list_2021::StatusPurpose; +use identity_iota::credential::Credential; +use identity_iota::credential::CredentialBuilder; +use identity_iota::credential::Issuer; +use identity_iota::credential::Subject; +use identity_iota::did::DID; +use identity_storage::JwkDocumentExt; +use identity_storage::JwsSignatureOptions; +use identity_stronghold::StrongholdStorage; +use serde_json::json; + +use crate::helpers::make_stronghold; +use crate::helpers::Entity; +use crate::helpers::TestServer; + +mod _credentials { + tonic::include_proto!("credentials"); +} + +#[tokio::test] +async fn credential_validation() -> anyhow::Result<()> { + 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 mut holder = Entity::new(); + holder.create_did(api_client).await?; + + let subject = Subject::from_json_value(json!({ + "id": holder.document().unwrap().id().as_str(), + "name": "Alice", + "degree": { + "type": "BachelorDegree", + "name": "Bachelor of Science and Arts", + }, + "GPA": "4.0", + }))?; + + // Build credential using subject above and issuer. + let credential: Credential = CredentialBuilder::default() + .id(Url::parse("https://example.edu/credentials/3732")?) + .issuer(Url::parse(issuer.document().unwrap().id().as_str())?) + .type_("UniversityDegreeCredential") + .subject(subject) + .build()?; + + let credential_jwt = issuer + .document() + .unwrap() + .create_credential_jwt( + &credential, + &issuer.storage(), + &issuer.fragment().unwrap(), + &JwsSignatureOptions::default(), + None, + ) + .await? + .into(); + + let mut grpc_client = VcValidationClient::connect(server.endpoint()).await?; + let decoded_cred = grpc_client + .validate(VcValidationRequest { + credential_jwt, + status_list_credential_json: None, + }) + .await? + .into_inner() + .credential_json; + + let decoded_cred = serde_json::from_str::(&decoded_cred)?; + assert_eq!(decoded_cred, credential); + + Ok(()) +} + +#[tokio::test] +async fn revoked_credential_validation() -> anyhow::Result<()> { + 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 mut holder = Entity::new(); + holder.create_did(api_client).await?; + + let subject = Subject::from_json_value(json!({ + "id": holder.document().unwrap().id().as_str(), + "name": "Alice", + "degree": { + "type": "BachelorDegree", + "name": "Bachelor of Science and Arts", + }, + "GPA": "4.0", + }))?; + + let mut status_list_credential = StatusList2021CredentialBuilder::new(StatusList2021::default()) + .issuer(Issuer::Url(Url::parse(issuer.document().unwrap().id().as_str())?)) + .purpose(StatusPurpose::Revocation) + .subject_id(Url::parse("https://example.edu/credentials/status/1")?) + .build()?; + + // Build credential using subject above and issuer. + let mut credential: Credential = CredentialBuilder::default() + .id(Url::parse("https://example.edu/credentials/3732")?) + .issuer(Url::parse(issuer.document().unwrap().id().as_str())?) + .type_("UniversityDegreeCredential") + .subject(subject) + .build()?; + status_list_credential.set_credential_status(&mut credential, 0, true)?; + + let credential_jwt = issuer + .document() + .unwrap() + .create_credential_jwt( + &credential, + &issuer.storage(), + &issuer.fragment().unwrap(), + &JwsSignatureOptions::default(), + None, + ) + .await? + .into(); + + let mut grpc_client = VcValidationClient::connect(server.endpoint()).await?; + let error = grpc_client + .validate(VcValidationRequest { + credential_jwt, + status_list_credential_json: Some(status_list_credential.to_json()?), + }) + .await + .unwrap_err(); + + assert!(error.message().contains("revoked")); + + Ok(()) +} diff --git a/bindings/grpc/tests/api/did_document_creation.rs b/bindings/grpc/tests/api/did_document_creation.rs new file mode 100644 index 0000000000..394217e7a3 --- /dev/null +++ b/bindings/grpc/tests/api/did_document_creation.rs @@ -0,0 +1,43 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use identity_stronghold::StrongholdStorage; +use iota_sdk::types::block::address::ToBech32Ext; +use tonic::Request; + +use crate::helpers::get_address_with_funds; +use crate::helpers::make_stronghold; +use crate::helpers::Entity; +use crate::helpers::TestServer; +use crate::helpers::FAUCET_ENDPOINT; +use _document::document_service_client::DocumentServiceClient; +use _document::CreateDidRequest; + +mod _document { + tonic::include_proto!("document"); +} + +#[tokio::test] +async fn did_document_creation() -> anyhow::Result<()> { + let stronghold = StrongholdStorage::new(make_stronghold()); + let server = TestServer::new_with_stronghold(stronghold.clone()).await; + let api_client = server.client(); + let hrp = api_client.get_bech32_hrp().await?; + + let user = Entity::new_with_stronghold(stronghold); + let user_address = get_address_with_funds( + api_client, + user.storage().key_storage().as_secret_manager(), + FAUCET_ENDPOINT, + ) + .await?; + + let mut grpc_client = DocumentServiceClient::connect(server.endpoint()).await?; + grpc_client + .create(Request::new(CreateDidRequest { + bech32_address: user_address.to_bech32(hrp).to_string(), + })) + .await?; + + Ok(()) +} diff --git a/bindings/grpc/tests/api/domain_linkage.rs b/bindings/grpc/tests/api/domain_linkage.rs new file mode 100644 index 0000000000..a79b732d58 --- /dev/null +++ b/bindings/grpc/tests/api/domain_linkage.rs @@ -0,0 +1,174 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +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/health_check.rs b/bindings/grpc/tests/api/health_check.rs new file mode 100644 index 0000000000..d8ea486269 --- /dev/null +++ b/bindings/grpc/tests/api/health_check.rs @@ -0,0 +1,24 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use health_check::health_check_client::HealthCheckClient; +use health_check::HealthCheckRequest; +use health_check::HealthCheckResponse; + +use crate::helpers::TestServer; + +mod health_check { + tonic::include_proto!("health_check"); +} + +#[tokio::test] +async fn health_check() -> anyhow::Result<()> { + let server = TestServer::new().await; + let mut grpc_client = HealthCheckClient::connect(server.endpoint()).await?; + let request = tonic::Request::new(HealthCheckRequest {}); + + let response = grpc_client.check(request).await?; + assert_eq!(response.into_inner(), HealthCheckResponse { status: "OK".into() }); + + Ok(()) +} diff --git a/bindings/grpc/tests/api/helpers.rs b/bindings/grpc/tests/api/helpers.rs new file mode 100644 index 0000000000..c307213db7 --- /dev/null +++ b/bindings/grpc/tests/api/helpers.rs @@ -0,0 +1,336 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::Context; +use identity_iota::iota::IotaClientExt; +use identity_iota::iota::IotaDocument; +use identity_iota::iota::IotaIdentityClientExt; +use identity_iota::iota::NetworkName; +use identity_iota::verification::jws::JwsAlgorithm; +use identity_iota::verification::MethodScope; +use identity_storage::key_id_storage::KeyIdMemstore; +use identity_storage::key_storage::JwkMemStore; +use identity_storage::JwkDocumentExt; +use identity_storage::JwkStorage; +use identity_storage::KeyIdStorage; +use identity_storage::Storage; +use identity_stronghold::StrongholdStorage; +use iota_sdk::client::api::GetAddressesOptions; +use iota_sdk::client::node_api::indexer::query_parameters::QueryParameter; +use iota_sdk::client::secret::stronghold::StrongholdSecretManager; +use iota_sdk::client::secret::SecretManager; +use iota_sdk::client::stronghold::StrongholdAdapter; +use iota_sdk::client::Client; +use iota_sdk::client::Password; +use iota_sdk::crypto::keys::bip39; +use iota_sdk::types::block::address::Address; +use iota_sdk::types::block::address::Bech32Address; +use iota_sdk::types::block::address::Hrp; +use iota_sdk::types::block::output::AliasOutputBuilder; +use rand::distributions::Alphanumeric; +use rand::distributions::DistString; +use rand::thread_rng; +use std::net::SocketAddr; +use std::path::PathBuf; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tonic::transport::Uri; + +pub type MemStorage = Storage; + +pub const API_ENDPOINT: &str = "http://localhost"; +pub const FAUCET_ENDPOINT: &str = "http://localhost/faucet/api/enqueue"; + +#[derive(Debug)] +pub struct TestServer { + client: Client, + addr: SocketAddr, + _handle: JoinHandle>, +} + +impl TestServer { + pub async fn new() -> Self { + let stronghold = StrongholdSecretManager::builder() + .password(random_password(18)) + .build(random_stronghold_path()) + .map(StrongholdStorage::new) + .expect("Failed to create temporary stronghold"); + + Self::new_with_stronghold(stronghold).await + } + + pub async fn new_with_stronghold(stronghold: StrongholdStorage) -> Self { + let _ = tracing::subscriber::set_global_default(tracing_subscriber::fmt().compact().finish()); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("Failed to bind to random OS's port"); + let addr = listener.local_addr().unwrap(); + + let client: Client = Client::builder() + .with_primary_node(API_ENDPOINT, None) + .unwrap() + .finish() + .await + .expect("Failed to connect to API's endpoint"); + + let server = identity_grpc::server::GRpcServer::new(client.clone(), stronghold) + .into_router() + .serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener)); + TestServer { + _handle: tokio::spawn(server), + addr, + client, + } + } + + pub fn endpoint(&self) -> Uri { + format!("https://{}", self.addr) + .parse() + .expect("Failed to parse server's URI") + } + + pub fn client(&self) -> &Client { + &self.client + } +} + +pub async fn create_did( + client: &Client, + secret_manager: &mut SecretManager, + storage: &Storage, +) -> anyhow::Result<(Address, IotaDocument, String)> +where + K: JwkStorage, + I: KeyIdStorage, +{ + let address: Address = get_address_with_funds(client, secret_manager, FAUCET_ENDPOINT) + .await + .context("failed to get address with funds")?; + + let network_name = client.network_name().await?; + let (document, fragment): (IotaDocument, String) = create_did_document(&network_name, storage).await?; + let alias_output = client.new_did_output(address, document, None).await?; + + let document: IotaDocument = client.publish_did_output(secret_manager, alias_output).await?; + + Ok((address, document, fragment)) +} + +/// Creates an example DID document with the given `network_name`. +/// +/// Its functionality is equivalent to the "create DID" example +/// and exists for convenient calling from the other examples. +pub async fn create_did_document( + network_name: &NetworkName, + storage: &Storage, +) -> anyhow::Result<(IotaDocument, String)> +where + I: KeyIdStorage, + K: JwkStorage, +{ + let mut document: IotaDocument = IotaDocument::new(network_name); + + let fragment: String = document + .generate_method( + storage, + JwkMemStore::ED25519_KEY_TYPE, + JwsAlgorithm::EdDSA, + None, + MethodScope::VerificationMethod, + ) + .await?; + + Ok((document, fragment)) +} + +/// Generates an address from the given [`SecretManager`] and adds funds from the faucet. +pub async fn get_address_with_funds( + client: &Client, + stronghold: &SecretManager, + faucet_endpoint: &str, +) -> anyhow::Result
{ + let address = get_address(client, stronghold).await?; + + request_faucet_funds(client, address, faucet_endpoint) + .await + .context("failed to request faucet funds")?; + + Ok(*address) +} + +/// Initializes the [`SecretManager`] with a new mnemonic, if necessary, +/// and generates an address from the given [`SecretManager`]. +pub async fn get_address(client: &Client, secret_manager: &SecretManager) -> anyhow::Result { + let random: [u8; 32] = rand::random(); + let mnemonic = bip39::wordlist::encode(random.as_ref(), &bip39::wordlist::ENGLISH) + .map_err(|err| anyhow::anyhow!(format!("{err:?}")))?; + + if let SecretManager::Stronghold(ref stronghold) = secret_manager { + match stronghold.store_mnemonic(mnemonic).await { + Ok(()) => (), + Err(iota_sdk::client::stronghold::Error::MnemonicAlreadyStored) => (), + Err(err) => anyhow::bail!(err), + } + } else { + anyhow::bail!("expected a `StrongholdSecretManager`"); + } + + let bech32_hrp: Hrp = client.get_bech32_hrp().await?; + let address: Bech32Address = secret_manager + .generate_ed25519_addresses( + GetAddressesOptions::default() + .with_range(0..1) + .with_bech32_hrp(bech32_hrp), + ) + .await?[0]; + + Ok(address) +} + +/// Requests funds from the faucet for the given `address`. +async fn request_faucet_funds(client: &Client, address: Bech32Address, faucet_endpoint: &str) -> anyhow::Result<()> { + iota_sdk::client::request_funds_from_faucet(faucet_endpoint, &address).await?; + + tokio::time::timeout(std::time::Duration::from_secs(45), async { + loop { + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + + let balance = get_address_balance(client, &address) + .await + .context("failed to get address balance")?; + if balance > 0 { + break; + } + } + Ok::<(), anyhow::Error>(()) + }) + .await + .context("maximum timeout exceeded")??; + + Ok(()) +} + +pub struct Entity { + secret_manager: SecretManager, + storage: Storage, + did: Option<(Address, IotaDocument, String)>, +} + +pub fn random_password(len: usize) -> Password { + let mut rng = thread_rng(); + Alphanumeric.sample_string(&mut rng, len).into() +} + +pub fn random_stronghold_path() -> PathBuf { + let mut file = std::env::temp_dir(); + file.push("test_strongholds"); + file.push(rand::distributions::Alphanumeric.sample_string(&mut rand::thread_rng(), 32)); + file.set_extension("stronghold"); + file.to_owned() +} + +impl Default for Entity { + fn default() -> Self { + let secret_manager = SecretManager::Stronghold(make_stronghold()); + let storage = MemStorage::new(JwkMemStore::new(), KeyIdMemstore::new()); + + Self { + secret_manager, + storage, + did: None, + } + } +} + +impl Entity { + pub fn new() -> Self { + Self::default() + } +} + +impl Entity { + pub fn new_with_stronghold(s: StrongholdStorage) -> Self { + let secret_manager = SecretManager::Stronghold(make_stronghold()); + let storage = Storage::new(s.clone(), s); + + Self { + secret_manager, + storage, + did: None, + } + } +} + +impl Entity { + pub async fn create_did(&mut self, client: &Client) -> anyhow::Result<()> { + let Entity { + secret_manager, + storage, + did, + } = self; + *did = Some(create_did(client, secret_manager, storage).await?); + + Ok(()) + } + + pub fn storage(&self) -> &Storage { + &self.storage + } + + pub fn document(&self) -> Option<&IotaDocument> { + self.did.as_ref().map(|(_, doc, _)| doc) + } + + pub fn fragment(&self) -> Option<&str> { + self.did.as_ref().map(|(_, _, frag)| frag.as_ref()) + } + + pub async fn update_document(&mut self, client: &Client, f: F) -> anyhow::Result<()> + where + F: FnOnce(IotaDocument) -> Option, + { + let (address, doc, fragment) = self.did.take().context("Missing doc")?; + let mut new_doc = f(doc.clone()); + if let Some(doc) = new_doc.take() { + let alias_output = client.update_did_output(doc.clone()).await?; + let rent_structure = client.get_rent_structure().await?; + let alias_output = AliasOutputBuilder::from(&alias_output) + .with_minimum_storage_deposit(rent_structure) + .finish()?; + + new_doc = Some(client.publish_did_output(&self.secret_manager, alias_output).await?); + } + + self.did = Some((address, new_doc.unwrap_or(doc), fragment)); + + Ok(()) + } +} +/// Returns the balance of the given Bech32-encoded `address`. +async fn get_address_balance(client: &Client, address: &Bech32Address) -> anyhow::Result { + let output_ids = client + .basic_output_ids(vec![ + QueryParameter::Address(address.to_owned()), + QueryParameter::HasExpiration(false), + QueryParameter::HasTimelock(false), + QueryParameter::HasStorageDepositReturn(false), + ]) + .await?; + + let outputs = client.get_outputs(&output_ids).await?; + + let mut total_amount = 0; + for output_response in outputs { + total_amount += output_response.output().amount(); + } + + Ok(total_amount) +} + +pub fn make_stronghold() -> StrongholdAdapter { + StrongholdAdapter::builder() + .password(random_password(18)) + .build(random_stronghold_path()) + .expect("Failed to create temporary stronghold") +} diff --git a/bindings/grpc/tests/api/jwt.rs b/bindings/grpc/tests/api/jwt.rs new file mode 100644 index 0000000000..927027b300 --- /dev/null +++ b/bindings/grpc/tests/api/jwt.rs @@ -0,0 +1,54 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use _credentials::jwt_client::JwtClient; +use _credentials::JwtCreationRequest; +use identity_iota::core::Object; +use identity_iota::core::Timestamp; +use identity_iota::core::ToJson; +use identity_iota::credential::CredentialBuilder; +use identity_iota::did::DID; +use identity_stronghold::StrongholdStorage; +use iota_sdk::Url; +use serde_json::json; + +use crate::helpers::make_stronghold; +use crate::helpers::Entity; +use crate::helpers::TestServer; + +mod _credentials { + tonic::include_proto!("credentials"); +} + +#[tokio::test] +async fn jwt_creation() -> anyhow::Result<()> { + 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 mut holder = Entity::new(); + holder.create_did(api_client).await?; + + let credential = CredentialBuilder::::default() + .issuance_date(Timestamp::now_utc()) + .issuer(Url::parse(issuer.document().unwrap().id().as_str())?) + .subject(serde_json::from_value(json!({ + "id": holder.document().unwrap().id().as_str(), + "type": "UniversityDegree", + "gpa": "4.0", + }))?) + .build()?; + + let mut grpc_client = JwtClient::connect(server.endpoint()).await?; + let _ = grpc_client + .create(JwtCreationRequest { + credential_json: credential.to_json()?, + issuer_fragment: issuer.fragment().unwrap().to_owned(), + }) + .await?; + + Ok(()) +} diff --git a/bindings/grpc/tests/api/main.rs b/bindings/grpc/tests/api/main.rs new file mode 100644 index 0000000000..e187cf7f1c --- /dev/null +++ b/bindings/grpc/tests/api/main.rs @@ -0,0 +1,12 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +mod credential_revocation_check; +mod credential_validation; +mod did_document_creation; +mod domain_linkage; +mod health_check; +mod helpers; +mod jwt; +mod sd_jwt_validation; +mod status_list_2021; diff --git a/bindings/grpc/tests/api/sd_jwt_validation.rs b/bindings/grpc/tests/api/sd_jwt_validation.rs new file mode 100644 index 0000000000..e746e930c3 --- /dev/null +++ b/bindings/grpc/tests/api/sd_jwt_validation.rs @@ -0,0 +1,165 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use _sd_jwt::verification_client::VerificationClient; +use _sd_jwt::KeyBindingOptions; +use _sd_jwt::VerificationRequest; +use identity_iota::core::FromJson; +use identity_iota::core::Timestamp; +use identity_iota::core::ToJson; +use identity_iota::core::Url; +use identity_iota::credential::Credential; +use identity_iota::credential::CredentialBuilder; +use identity_iota::credential::Jws; +use identity_iota::credential::Subject; +use identity_iota::did::DID; +use identity_iota::sd_jwt_payload::KeyBindingJwtClaims; +use identity_iota::sd_jwt_payload::SdJwt; +use identity_iota::sd_jwt_payload::SdObjectEncoder; +use identity_iota::sd_jwt_payload::Sha256Hasher; +use identity_storage::JwkDocumentExt; +use identity_storage::JwsSignatureOptions; + +use crate::helpers::Entity; +use crate::helpers::TestServer; + +mod _sd_jwt { + tonic::include_proto!("sd_jwt"); +} + +#[tokio::test] +async fn sd_jwt_validation_works() -> anyhow::Result<()> { + let server = TestServer::new().await; + let client = server.client(); + let mut issuer = Entity::new(); + issuer.create_did(client).await?; + + let mut holder = Entity::new(); + holder.create_did(client).await?; + + // Create an address credential subject. + let subject = Subject::from_json_value(serde_json::json!({ + "id": holder.document().unwrap().id().as_str(), + "name": "Alice", + "address": { + "locality": "Maxstadt", + "postal_code": "12344", + "country": "DE", + "street_address": "Weidenstraße 22" + } + }))?; + // Build credential using subject above and issuer. + let credential: Credential = CredentialBuilder::default() + .id(Url::parse("https://example.com/credentials/3732")?) + .issuer(Url::parse(issuer.document().unwrap().id().as_str())?) + .type_("AddressCredential") + .subject(subject) + .build()?; + + // In Order to create an selective disclosure JWT, the plain text JWT + // claims set must be created first. + let payload = credential.serialize_jwt(None)?; + + // Using the crate `sd-jwt` properties of the claims can be made selectively disclosable. + // The default sha-256 hasher will be used to create the digests. + // Read more in https://github.com/iotaledger/sd-jwt-payload . + let mut encoder = SdObjectEncoder::new(&payload)?; + + // Make "locality", "postal_code" and "street_address" selectively disclosable while keeping + // other properties in plain text. + let disclosures = vec![ + encoder.conceal("/vc/credentialSubject/address/locality", None)?, + encoder.conceal("/vc/credentialSubject/address/postal_code", None)?, + encoder.conceal("/vc/credentialSubject/address/street_address", None)?, + ]; + + // Add the `_sd_alg` property. + encoder.add_sd_alg_property(); + let encoded_payload = encoder.try_to_string()?; + + // Create the signed JWT. + let jwt: Jws = issuer + .document() + .unwrap() + .create_jws( + issuer.storage(), + issuer.fragment().unwrap(), + encoded_payload.as_bytes(), + &JwsSignatureOptions::default(), + ) + .await?; + + // One way to send the JWT and the disclosures, is by creating an SD-JWT with all the + // disclosures. + let disclosures: Vec = disclosures + .into_iter() + .map(|disclosure| disclosure.to_string()) + .collect(); + let sd_jwt_str = SdJwt::new(jwt.into(), disclosures, None).presentation(); + + const VERIFIER_DID: &str = "did:example:verifier"; + // A unique random challenge generated by the requester per presentation can mitigate replay attacks. + let nonce: &str = "475a7984-1bb5-4c4c-a56f-822bccd46440"; + + // =========================================================================== + // Step 5: Holder creates an SD-JWT to be presented to a verifier. + // =========================================================================== + + let sd_jwt = SdJwt::parse(&sd_jwt_str)?; + + // The holder only wants to present "locality" and "postal_code" but not "street_address". + let disclosures = vec![ + sd_jwt.disclosures.first().unwrap().clone(), + sd_jwt.disclosures.get(1).unwrap().clone(), + ]; + + // Optionally, the holder can add a Key Binding JWT (KB-JWT). This is dependent on the verifier's policy. + // Issuing the KB-JWT is done by creating the claims set and setting the header `typ` value + // with the help of `KeyBindingJwtClaims`. + let binding_claims = KeyBindingJwtClaims::new( + &Sha256Hasher::new(), + sd_jwt.jwt.as_str().to_string(), + disclosures.clone(), + nonce.to_string(), + VERIFIER_DID.to_string(), + Timestamp::now_utc().to_unix(), + ) + .to_json()?; + + // Setting the `typ` in the header is required. + let options = JwsSignatureOptions::new().typ(KeyBindingJwtClaims::KB_JWT_HEADER_TYP); + + // Create the KB-JWT. + let kb_jwt: Jws = holder + .document() + .unwrap() + .create_jws( + holder.storage(), + holder.fragment().unwrap(), + binding_claims.as_bytes(), + &options, + ) + .await?; + + // Create the final SD-JWT. + let sd_jwt_obj = SdJwt::new(sd_jwt.jwt, disclosures, Some(kb_jwt.into())); + + // Holder presents the SD-JWT to the verifier. + let sd_jwt_presentation: String = sd_jwt_obj.presentation(); + + // Verify the JWT. + let mut sd_jwt_verification_client = VerificationClient::connect(server.endpoint()).await?; + let _ = sd_jwt_verification_client + .verify(VerificationRequest { + jwt: sd_jwt_presentation, + kb_options: Some(KeyBindingOptions { + nonce: Some(nonce.to_owned()), + aud: Some(VERIFIER_DID.to_owned()), + holder_did: holder.document().unwrap().id().to_string(), + ..Default::default() + }), + }) + .await?; + + Ok(()) +} diff --git a/bindings/grpc/tests/api/status_list_2021.rs b/bindings/grpc/tests/api/status_list_2021.rs new file mode 100644 index 0000000000..67ad31b34d --- /dev/null +++ b/bindings/grpc/tests/api/status_list_2021.rs @@ -0,0 +1,94 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use crate::helpers::TestServer; +use _status_list_2021::status_list2021_svc_client::StatusList2021SvcClient; +use _status_list_2021::CreateRequest; +use _status_list_2021::Purpose; +use _status_list_2021::UpdateRequest; +use identity_iota::core::FromJson; +use identity_iota::core::ToJson; +use identity_iota::core::Url; +use identity_iota::credential::status_list_2021::StatusList2021; +use identity_iota::credential::status_list_2021::StatusList2021Credential; +use identity_iota::credential::status_list_2021::StatusList2021CredentialBuilder; +use identity_iota::credential::status_list_2021::StatusPurpose; +use identity_iota::credential::Issuer; +use tonic::Request; + +mod _status_list_2021 { + tonic::include_proto!("status_list_2021"); +} + +#[tokio::test] +async fn status_list_2021_credential_creation() -> anyhow::Result<()> { + let server = TestServer::new().await; + + let id = Url::parse("http://example.com/credentials/status/1").unwrap(); + let issuer = Issuer::Url(Url::parse("http://example.com/issuers/1").unwrap()); + let status_list_credential = StatusList2021CredentialBuilder::new(StatusList2021::default()) + .purpose(StatusPurpose::Revocation) + .subject_id(id.clone()) + .issuer(issuer.clone()) + .build() + .unwrap(); + + let mut grpc_client = StatusList2021SvcClient::connect(server.endpoint()).await?; + let res = grpc_client + .create(Request::new(CreateRequest { + id: Some(id.into_string()), + issuer: issuer.url().to_string(), + purpose: Purpose::Revocation as i32, + length: None, + expiration_date: None, + contexts: vec![], + types: vec![], + })) + .await? + .into_inner() + .credential_json; + let grpc_credential = StatusList2021Credential::from_json(&res)?; + + assert_eq!(status_list_credential, grpc_credential); + Ok(()) +} + +#[tokio::test] +async fn status_list_2021_credential_update() -> anyhow::Result<()> { + let server = TestServer::new().await; + + let id = Url::parse("http://example.com/credentials/status/1").unwrap(); + let issuer = Issuer::Url(Url::parse("http://example.com/issuers/1").unwrap()); + let mut status_list_credential = StatusList2021CredentialBuilder::new(StatusList2021::default()) + .purpose(StatusPurpose::Revocation) + .subject_id(id) + .issuer(issuer) + .build() + .unwrap(); + + let entries_to_set = [0_u64, 42, 420, 4200]; + let entries = entries_to_set.iter().map(|i| (*i, true)).collect(); + + let mut grpc_client = StatusList2021SvcClient::connect(server.endpoint()).await?; + let grpc_credential = grpc_client + .update(Request::new(UpdateRequest { + credential_json: status_list_credential.to_json().unwrap(), + entries, + })) + .await + .map(|res| res.into_inner().credential_json) + .map(|credential_json| StatusList2021Credential::from_json(&credential_json).unwrap()) + .unwrap(); + + status_list_credential.update(|status_list| { + for idx in entries_to_set { + if let Err(e) = status_list.set_entry(idx as usize, true) { + return Err(e); + } + } + Ok(()) + })?; + + assert_eq!(status_list_credential, grpc_credential); + Ok(()) +} 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/bindings/wasm/docs/api-reference.md b/bindings/wasm/docs/api-reference.md index e9e3a65a85..2f50e4ed3d 100644 --- a/bindings/wasm/docs/api-reference.md +++ b/bindings/wasm/docs/api-reference.md @@ -11,6 +11,9 @@ if the object is being concurrently modified.

Credential
+
CustomMethodData
+

A custom verification method data format.

+
DIDUrl

A method agnostic DID Url.

@@ -187,12 +190,9 @@ working with storage backed DID documents.

## Members
-
StateMetadataEncoding
-
-
MethodRelationship
-
-
CredentialStatus
-
+
StatusPurpose
+

Purpose of a StatusList2021.

+
SubjectHolderRelationship

Declares how credential subjects must relate to the presentation holder.

See also the Subject-Holder Relationship section of the specification.

@@ -207,6 +207,8 @@ This variant is the default.

Any

The holder is not required to have any kind of relationship to any credential subject.

+
StateMetadataEncoding
+
FailFast

Declares when validation should return if an error occurs.

@@ -216,9 +218,10 @@ This variant is the default.

FirstError

Return after the first error occurs.

-
StatusPurpose
-

Purpose of a StatusList2021.

-
+
MethodRelationship
+
+
CredentialStatus
+
StatusCheck

Controls validation behaviour when checking whether or not a credential has been revoked by its credentialStatus.

@@ -241,12 +244,6 @@ This variant is the default.

## Functions
-
encodeB64(data)string
-

Encode the given bytes in url-safe base64.

-
-
decodeB64(data)Uint8Array
-

Decode the given url-safe base64-encoded slice into its raw bytes.

-
verifyEd25519(alg, signingInput, decodedSignature, publicKey)

Verify a JWS signature secured with the EdDSA algorithm and curve Ed25519.

This function is useful when one is composing a IJwsVerifier that delegates @@ -255,6 +252,12 @@ This variant is the default.

This function does not check whether alg = EdDSA in the protected header. Callers are expected to assert this prior to calling the function.

+
encodeB64(data)string
+

Encode the given bytes in url-safe base64.

+
+
decodeB64(data)Uint8Array
+

Decode the given url-safe base64-encoded slice into its raw bytes.

+
start()

Initializes the console error panic hook for better error messages

@@ -1138,6 +1141,53 @@ Deserializes an instance from a JSON object. | --- | --- | | json | any | + + +## CustomMethodData +A custom verification method data format. + +**Kind**: global class + +* [CustomMethodData](#CustomMethodData) + * [new CustomMethodData(name, data)](#new_CustomMethodData_new) + * _instance_ + * [.clone()](#CustomMethodData+clone) ⇒ [CustomMethodData](#CustomMethodData) + * [.toJSON()](#CustomMethodData+toJSON) ⇒ any + * _static_ + * [.fromJSON(json)](#CustomMethodData.fromJSON) ⇒ [CustomMethodData](#CustomMethodData) + + + +### new CustomMethodData(name, data) + +| Param | Type | +| --- | --- | +| name | string | +| data | any | + + + +### customMethodData.clone() ⇒ [CustomMethodData](#CustomMethodData) +Deep clones the object. + +**Kind**: instance method of [CustomMethodData](#CustomMethodData) + + +### customMethodData.toJSON() ⇒ any +Serializes this to a JSON object. + +**Kind**: instance method of [CustomMethodData](#CustomMethodData) + + +### CustomMethodData.fromJSON(json) ⇒ [CustomMethodData](#CustomMethodData) +Deserializes an instance from a JSON object. + +**Kind**: static method of [CustomMethodData](#CustomMethodData) + +| Param | Type | +| --- | --- | +| json | any | + ## DIDUrl @@ -1967,6 +2017,7 @@ if the object is being concurrently modified. * _instance_ * [.id()](#IotaDocument+id) ⇒ [IotaDID](#IotaDID) * [.controller()](#IotaDocument+controller) ⇒ [Array.<IotaDID>](#IotaDID) + * [.setController(controller)](#IotaDocument+setController) * [.alsoKnownAs()](#IotaDocument+alsoKnownAs) ⇒ Array.<string> * [.setAlsoKnownAs(urls)](#IotaDocument+setAlsoKnownAs) * [.properties()](#IotaDocument+properties) ⇒ Map.<string, any> @@ -2039,6 +2090,20 @@ NOTE: controllers are determined by the `state_controller` unlock condition of t during resolution and are omitted when publishing. **Kind**: instance method of [IotaDocument](#IotaDocument) + + +### iotaDocument.setController(controller) +Sets the controllers of the document. + +Note: Duplicates will be ignored. +Use `null` to remove all controllers. + +**Kind**: instance method of [IotaDocument](#IotaDocument) + +| Param | Type | +| --- | --- | +| controller | [Array.<IotaDID>](#IotaDID) \| null | + ### iotaDocument.alsoKnownAs() ⇒ Array.<string> @@ -4328,6 +4393,7 @@ Supported verification method data formats. * [MethodData](#MethodData) * _instance_ + * [.tryCustom()](#MethodData+tryCustom) ⇒ [CustomMethodData](#CustomMethodData) * [.tryDecode()](#MethodData+tryDecode) ⇒ Uint8Array * [.tryPublicKeyJwk()](#MethodData+tryPublicKeyJwk) ⇒ [Jwk](#Jwk) * [.toJSON()](#MethodData+toJSON) ⇒ any @@ -4336,8 +4402,15 @@ Supported verification method data formats. * [.newBase58(data)](#MethodData.newBase58) ⇒ [MethodData](#MethodData) * [.newMultibase(data)](#MethodData.newMultibase) ⇒ [MethodData](#MethodData) * [.newJwk(key)](#MethodData.newJwk) ⇒ [MethodData](#MethodData) + * [.newCustom(name, data)](#MethodData.newCustom) ⇒ [MethodData](#MethodData) * [.fromJSON(json)](#MethodData.fromJSON) ⇒ [MethodData](#MethodData) + + +### methodData.tryCustom() ⇒ [CustomMethodData](#CustomMethodData) +Returns the wrapped custom method data format is `Custom`. + +**Kind**: instance method of [MethodData](#MethodData) ### methodData.tryDecode() ⇒ Uint8Array @@ -4404,6 +4477,18 @@ An error is thrown if the given `key` contains any private components. | --- | --- | | key | [Jwk](#Jwk) | + + +### MethodData.newCustom(name, data) ⇒ [MethodData](#MethodData) +Creates a new custom [MethodData](#MethodData). + +**Kind**: static method of [MethodData](#MethodData) + +| Param | Type | +| --- | --- | +| name | string | +| data | any | + ### MethodData.fromJSON(json) ⇒ [MethodData](#MethodData) @@ -4555,6 +4640,7 @@ Supported verification method types. * [.Ed25519VerificationKey2018()](#MethodType.Ed25519VerificationKey2018) ⇒ [MethodType](#MethodType) * [.X25519KeyAgreementKey2019()](#MethodType.X25519KeyAgreementKey2019) ⇒ [MethodType](#MethodType) * [.JsonWebKey()](#MethodType.JsonWebKey) ⇒ [MethodType](#MethodType) + * [.custom(type_)](#MethodType.custom) ⇒ [MethodType](#MethodType) * [.fromJSON(json)](#MethodType.fromJSON) ⇒ [MethodType](#MethodType) @@ -4590,6 +4676,17 @@ A verification method for use with JWT verification as prescribed by the [Jwk](# in the `publicKeyJwk` entry. **Kind**: static method of [MethodType](#MethodType) + + +### MethodType.custom(type_) ⇒ [MethodType](#MethodType) +A custom method. + +**Kind**: static method of [MethodType](#MethodType) + +| Param | Type | +| --- | --- | +| type_ | string | + ### MethodType.fromJSON(json) ⇒ [MethodType](#MethodType) @@ -4991,11 +5088,9 @@ Representation of an SD-JWT of the format * [.jwt()](#SdJwt+jwt) ⇒ string * [.disclosures()](#SdJwt+disclosures) ⇒ Array.<string> * [.keyBindingJwt()](#SdJwt+keyBindingJwt) ⇒ string \| undefined - * [.toJSON()](#SdJwt+toJSON) ⇒ any * [.clone()](#SdJwt+clone) ⇒ [SdJwt](#SdJwt) * _static_ * [.parse(sd_jwt)](#SdJwt.parse) ⇒ [SdJwt](#SdJwt) - * [.fromJSON(json)](#SdJwt.fromJSON) ⇒ [SdJwt](#SdJwt) @@ -5038,12 +5133,6 @@ The disclosures part. ### sdJwt.keyBindingJwt() ⇒ string \| undefined The optional key binding JWT. -**Kind**: instance method of [SdJwt](#SdJwt) - - -### sdJwt.toJSON() ⇒ any -Serializes this to a JSON object. - **Kind**: instance method of [SdJwt](#SdJwt) @@ -5065,17 +5154,6 @@ Returns `DeserializationError` if parsing fails. | --- | --- | | sd_jwt | string | - - -### SdJwt.fromJSON(json) ⇒ [SdJwt](#SdJwt) -Deserializes an instance from a JSON object. - -**Kind**: static method of [SdJwt](#SdJwt) - -| Param | Type | -| --- | --- | -| json | any | - ## SdJwtCredentialValidator @@ -5952,6 +6030,7 @@ A DID Document Verification Method. **Kind**: global class * [VerificationMethod](#VerificationMethod) + * [new VerificationMethod(id, controller, type_, data)](#new_VerificationMethod_new) * _instance_ * [.id()](#VerificationMethod+id) ⇒ [DIDUrl](#DIDUrl) * [.setId(id)](#VerificationMethod+setId) @@ -5969,6 +6048,19 @@ A DID Document Verification Method. * [.newFromJwk(did, key, [fragment])](#VerificationMethod.newFromJwk) ⇒ [VerificationMethod](#VerificationMethod) * [.fromJSON(json)](#VerificationMethod.fromJSON) ⇒ [VerificationMethod](#VerificationMethod) + + +### new VerificationMethod(id, controller, type_, data) +Create a custom [VerificationMethod](#VerificationMethod). + + +| Param | Type | +| --- | --- | +| id | [DIDUrl](#DIDUrl) | +| controller | [CoreDID](#CoreDID) | +| type_ | [MethodType](#MethodType) | +| data | [MethodData](#MethodData) | + ### verificationMethod.id() ⇒ [DIDUrl](#DIDUrl) @@ -6104,17 +6196,11 @@ Deserializes an instance from a JSON object. | --- | --- | | json | any | - - -## StateMetadataEncoding -**Kind**: global variable - + -## MethodRelationship -**Kind**: global variable - +## StatusPurpose +Purpose of a [StatusList2021](#StatusList2021). -## CredentialStatus **Kind**: global variable @@ -6142,6 +6228,10 @@ The holder must match the subject only for credentials where the [`nonTransferab ## Any The holder is not required to have any kind of relationship to any credential subject. +**Kind**: global variable + + +## StateMetadataEncoding **Kind**: global variable @@ -6161,11 +6251,13 @@ Return all errors that occur during validation. Return after the first error occurs. **Kind**: global variable - + -## StatusPurpose -Purpose of a [StatusList2021](#StatusList2021). +## MethodRelationship +**Kind**: global variable + +## CredentialStatus **Kind**: global variable @@ -6198,28 +6290,6 @@ Validate the status if supported, skip any unsupported Skip all status checks. **Kind**: global variable - - -## encodeB64(data) ⇒ string -Encode the given bytes in url-safe base64. - -**Kind**: global function - -| Param | Type | -| --- | --- | -| data | Uint8Array | - - - -## decodeB64(data) ⇒ Uint8Array -Decode the given url-safe base64-encoded slice into its raw bytes. - -**Kind**: global function - -| Param | Type | -| --- | --- | -| data | Uint8Array | - ## verifyEd25519(alg, signingInput, decodedSignature, publicKey) @@ -6242,6 +6312,28 @@ prior to calling the function. | decodedSignature | Uint8Array | | publicKey | [Jwk](#Jwk) | + + +## encodeB64(data) ⇒ string +Encode the given bytes in url-safe base64. + +**Kind**: global function + +| Param | Type | +| --- | --- | +| data | Uint8Array | + + + +## decodeB64(data) ⇒ Uint8Array +Decode the given url-safe base64-encoded slice into its raw bytes. + +**Kind**: global function + +| Param | Type | +| --- | --- | +| data | Uint8Array | + ## start() diff --git a/bindings/wasm/src/iota/iota_document.rs b/bindings/wasm/src/iota/iota_document.rs index 8f8cbe6823..8d004422ad 100644 --- a/bindings/wasm/src/iota/iota_document.rs +++ b/bindings/wasm/src/iota/iota_document.rs @@ -5,12 +5,14 @@ use std::rc::Rc; use identity_iota::core::Object; use identity_iota::core::OneOrMany; + use identity_iota::core::OrderedSet; use identity_iota::core::Timestamp; use identity_iota::core::Url; use identity_iota::credential::Credential; use identity_iota::credential::JwtPresentationOptions; use identity_iota::credential::Presentation; + use identity_iota::did::DIDUrl; use identity_iota::iota::block::output::dto::AliasOutputDto; use identity_iota::iota::block::output::AliasOutput; @@ -48,6 +50,7 @@ use crate::credential::WasmJws; use crate::credential::WasmJwt; use crate::credential::WasmPresentation; use crate::did::CoreDocumentLock; + use crate::did::PromiseJws; use crate::did::PromiseJwt; use crate::did::WasmCoreDocument; @@ -156,6 +159,20 @@ impl WasmIotaDocument { ) } + /// Sets the controllers of the document. + /// + /// Note: Duplicates will be ignored. + /// Use `null` to remove all controllers. + #[wasm_bindgen(js_name = setController)] + pub fn set_controller(&mut self, controller: &OptionArrayIotaDID) -> Result<()> { + let controller: Option> = controller.into_serde().wasm_result()?; + match controller { + Some(controller) => self.0.try_write()?.set_controller(controller), + None => self.0.try_write()?.set_controller([]), + }; + Ok(()) + } + /// Returns a copy of the document's `alsoKnownAs` set. #[wasm_bindgen(js_name = alsoKnownAs)] pub fn also_known_as(&self) -> Result { @@ -845,6 +862,9 @@ impl From for WasmIotaDocument { #[wasm_bindgen] extern "C" { + #[wasm_bindgen(typescript_type = "IotaDID[] | null")] + pub type OptionArrayIotaDID; + #[wasm_bindgen(typescript_type = "IotaDID[]")] pub type ArrayIotaDID; diff --git a/bindings/wasm/src/sd_jwt/wasm_sd_jwt.rs b/bindings/wasm/src/sd_jwt/wasm_sd_jwt.rs index 7b4f201206..c55de229e6 100644 --- a/bindings/wasm/src/sd_jwt/wasm_sd_jwt.rs +++ b/bindings/wasm/src/sd_jwt/wasm_sd_jwt.rs @@ -77,5 +77,4 @@ impl WasmSdJwt { } } -impl_wasm_json!(WasmSdJwt, SdJwt); impl_wasm_clone!(WasmSdJwt, SdJwt); diff --git a/bindings/wasm/src/verification/wasm_method_data.rs b/bindings/wasm/src/verification/wasm_method_data.rs index 5bba4aa5a9..58a9c65820 100644 --- a/bindings/wasm/src/verification/wasm_method_data.rs +++ b/bindings/wasm/src/verification/wasm_method_data.rs @@ -1,6 +1,7 @@ // Copyright 2020-2023 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use identity_iota::verification::CustomMethodData; use identity_iota::verification::MethodData; use wasm_bindgen::prelude::*; @@ -45,6 +46,27 @@ impl WasmMethodData { Ok(Self(MethodData::PublicKeyJwk(key.0.clone()))) } + /// Creates a new custom {@link MethodData}. + #[wasm_bindgen(js_name = newCustom)] + pub fn new_custom(name: String, data: JsValue) -> Result { + let data = data.into_serde::().wasm_result()?; + Ok(Self(MethodData::Custom(CustomMethodData { name, data }))) + } + + /// Returns the wrapped custom method data format is `Custom`. + #[wasm_bindgen(js_name = tryCustom)] + pub fn try_custom(&self) -> Result { + self + .0 + .custom() + .map(|custom| custom.clone().into()) + .ok_or(WasmError::new( + Cow::Borrowed("MethodDataFormatError"), + Cow::Borrowed("method data format is not Custom"), + )) + .wasm_result() + } + /// Returns a `Uint8Array` containing the decoded bytes of the {@link MethodData}. /// /// This is generally a public key identified by a {@link MethodData} value. @@ -78,3 +100,31 @@ impl From for WasmMethodData { WasmMethodData(data) } } + +/// A custom verification method data format. +#[wasm_bindgen(js_name = CustomMethodData, inspectable)] +pub struct WasmCustomMethodData(pub(crate) CustomMethodData); + +#[wasm_bindgen(js_class = CustomMethodData)] +impl WasmCustomMethodData { + #[wasm_bindgen(constructor)] + pub fn new(name: String, data: JsValue) -> Result { + let data = data.into_serde::().wasm_result()?; + Ok(Self(CustomMethodData { name, data })) + } +} + +impl From for WasmCustomMethodData { + fn from(value: CustomMethodData) -> Self { + Self(value) + } +} + +impl From for CustomMethodData { + fn from(value: WasmCustomMethodData) -> Self { + value.0 + } +} + +impl_wasm_clone!(WasmCustomMethodData, CustomMethodData); +impl_wasm_json!(WasmCustomMethodData, CustomMethodData); diff --git a/bindings/wasm/src/verification/wasm_method_type.rs b/bindings/wasm/src/verification/wasm_method_type.rs index 9fb1fff660..4b7d297a62 100644 --- a/bindings/wasm/src/verification/wasm_method_type.rs +++ b/bindings/wasm/src/verification/wasm_method_type.rs @@ -27,6 +27,11 @@ impl WasmMethodType { WasmMethodType(MethodType::JSON_WEB_KEY) } + /// A custom method. + pub fn custom(type_: String) -> WasmMethodType { + WasmMethodType(MethodType::custom(type_)) + } + /// Returns the {@link MethodType} as a string. #[allow(clippy::inherent_to_string)] #[wasm_bindgen(js_name = toString)] diff --git a/bindings/wasm/src/verification/wasm_verification_method.rs b/bindings/wasm/src/verification/wasm_verification_method.rs index 62b5103c9d..6f01436ffe 100644 --- a/bindings/wasm/src/verification/wasm_verification_method.rs +++ b/bindings/wasm/src/verification/wasm_verification_method.rs @@ -8,6 +8,7 @@ use crate::did::WasmCoreDID; use crate::did::WasmDIDUrl; use crate::error::Result; use crate::error::WasmResult; +use identity_iota::core::Object; use identity_iota::did::CoreDID; use identity_iota::verification::VerificationMethod; use wasm_bindgen::prelude::*; @@ -37,6 +38,24 @@ impl WasmVerificationMethod { .wasm_result() } + /// Create a custom {@link VerificationMethod}. + #[wasm_bindgen(constructor)] + pub fn new( + id: &WasmDIDUrl, + controller: &WasmCoreDID, + type_: &WasmMethodType, + data: &WasmMethodData, + ) -> Result { + VerificationMethod::builder(Object::new()) + .type_(type_.0.clone()) + .data(data.0.clone()) + .controller(controller.0.clone()) + .id(id.0.clone()) + .build() + .map(Self) + .wasm_result() + } + /// Returns a copy of the {@link DIDUrl} of the {@link VerificationMethod}'s `id`. #[wasm_bindgen] pub fn id(&self) -> WasmDIDUrl { diff --git a/examples/Cargo.toml b/examples/Cargo.toml index a9337d6a33..3b837244dc 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -8,12 +8,12 @@ publish = false [dependencies] anyhow = "1.0.62" identity_eddsa_verifier = { path = "../identity_eddsa_verifier", default-features = false } -identity_iota = { path = "../identity_iota", default-features = false, features = ["memstore", "domain-linkage", "revocation-bitmap", "status-list-2021"] } +identity_iota = { path = "../identity_iota", default-features = false, features = ["iota-client", "client", "memstore", "domain-linkage", "revocation-bitmap", "status-list-2021"] } identity_stronghold = { path = "../identity_stronghold", default-features = false } iota-sdk = { version = "1.0", default-features = false, features = ["tls", "client", "stronghold"] } primitive-types = "0.12.1" rand = "0.8.5" -sd-jwt-payload = { version = "0.2.0", default-features = false, features = ["sha"] } +sd-jwt-payload = { version = "0.2.1", default-features = false, features = ["sha"] } serde_json = { version = "1.0", default-features = false } tokio = { version = "1.29", default-features = false, features = ["rt"] } diff --git a/identity_core/Cargo.toml b/identity_core/Cargo.toml index 120d6dc9be..7fcba9777c 100644 --- a/identity_core/Cargo.toml +++ b/identity_core/Cargo.toml @@ -35,3 +35,6 @@ quickcheck_macros = { version = "1.0" } # RUSTDOCFLAGS="--cfg docsrs" cargo +nightly doc --all-features --no-deps --workspace --open all-features = true rustdoc-args = ["--cfg", "docsrs"] + +[lints] +workspace = true diff --git a/identity_credential/Cargo.toml b/identity_credential/Cargo.toml index 876d5577d5..38c65045e5 100644 --- a/identity_credential/Cargo.toml +++ b/identity_credential/Cargo.toml @@ -22,8 +22,8 @@ indexmap = { version = "2.0", default-features = false, features = ["std", "serd itertools = { version = "0.11", default-features = false, features = ["use_std"], optional = true } once_cell = { version = "1.18", default-features = false, features = ["std"] } reqwest = { version = "0.11", default-features = false, features = ["default-tls", "json", "stream"], optional = true } -roaring = { version = "0.10", default-features = false, features = ["std"], optional = true } -sd-jwt-payload = { version = "0.2.0", default-features = false, features = ["sha"], optional = true } +roaring = { version = "0.10.2", default-features = false, features = ["serde"], optional = true } +sd-jwt-payload = { version = "0.2.1", default-features = false, features = ["sha"], optional = true } serde.workspace = true serde-aux = { version = "4.3.1", default-features = false, optional = true } serde_json.workspace = true @@ -55,3 +55,6 @@ validator = ["dep:itertools", "dep:serde_repr", "credential", "presentation"] domain-linkage = ["validator"] domain-linkage-fetch = ["domain-linkage", "dep:reqwest", "dep:futures"] sd-jwt = ["credential", "validator", "sd-jwt-payload"] + +[lints] +workspace = true 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")] diff --git a/identity_credential/src/revocation/status_list_2021/credential.rs b/identity_credential/src/revocation/status_list_2021/credential.rs index e3f0875216..cc52916967 100644 --- a/identity_credential/src/revocation/status_list_2021/credential.rs +++ b/identity_credential/src/revocation/status_list_2021/credential.rs @@ -143,6 +143,21 @@ impl StatusList2021Credential { Ok(entry) } + /// Apply `update_fn` to the status list encoded in this credential. + pub fn update(&mut self, update_fn: F) -> Result<(), StatusList2021CredentialError> + where + F: FnOnce(&mut MutStatusList) -> Result<(), StatusList2021CredentialError>, + { + let mut encapsuled_status_list = MutStatusList { + status_list: self.status_list()?, + purpose: self.purpose(), + }; + update_fn(&mut encapsuled_status_list)?; + + self.subject.encoded_list = encapsuled_status_list.status_list.into_encoded_str(); + Ok(()) + } + /// Sets the `index`-th entry to `value` pub(crate) fn set_entry(&mut self, index: usize, value: bool) -> Result<(), StatusList2021CredentialError> { let mut status_list = self.status_list()?; @@ -167,6 +182,25 @@ impl StatusList2021Credential { } } +/// A wrapper over the [`StatusList2021`] contained in a [`StatusList2021Credential`] +/// that allows for its mutation. +pub struct MutStatusList { + status_list: StatusList2021, + purpose: StatusPurpose, +} + +impl MutStatusList { + /// Sets the value of the `index`-th entry in the status list. + pub fn set_entry(&mut self, index: usize, value: bool) -> Result<(), StatusList2021CredentialError> { + let entry_status = self.status_list.get(index)?; + if self.purpose == StatusPurpose::Revocation && !value && entry_status { + return Err(StatusList2021CredentialError::UnreversibleRevocation); + } + self.status_list.set(index, value)?; + Ok(()) + } +} + /// The status of a credential referenced inside a [`StatusList2021Credential`] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum CredentialStatus { @@ -241,7 +275,7 @@ impl From for Subject { impl StatusList2021CredentialSubject { /// Parse a StatusListCredentialSubject out of a credential, without copying. fn try_from_credential(credential: &mut Credential) -> Result { - let OneOrMany::One(subject) = &mut credential.credential_subject else { + let OneOrMany::One(mut subject) = std::mem::take(&mut credential.credential_subject) else { return Err(StatusList2021CredentialError::MultipleCredentialSubject); }; if let Some(subject_type) = subject.properties.get("type") { @@ -283,7 +317,7 @@ impl StatusList2021CredentialSubject { .map(std::mem::take)?; Ok(StatusList2021CredentialSubject { - id: std::mem::take(&mut subject.id), + id: subject.id, encoded_list, status_purpose, }) @@ -363,11 +397,17 @@ impl StatusList2021CredentialBuilder { .inner_builder .type_(CREDENTIAL_TYPE) .issuance_date(Timestamp::now_utc()) - .subject(self.credential_subject.clone().into()) + .subject(Subject { + id: self.credential_subject.id.clone(), + ..Default::default() + }) .build() - .map(|credential| StatusList2021Credential { - subject: self.credential_subject, - inner: credential, + .map(|mut credential| { + credential.credential_subject = OneOrMany::default(); + StatusList2021Credential { + subject: self.credential_subject, + inner: credential, + } }) } } diff --git a/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator_utils.rs b/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator_utils.rs index e7a43bcdab..d454122c15 100644 --- a/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator_utils.rs +++ b/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator_utils.rs @@ -171,7 +171,7 @@ impl JwtCredentialValidatorUtils { /// Check the given `status` against the matching [`RevocationBitmap`] service in the /// issuer's DID Document. #[cfg(feature = "revocation-bitmap")] - fn check_revocation_bitmap_status + ?Sized>( + pub fn check_revocation_bitmap_status + ?Sized>( issuer: &DOC, status: crate::credential::RevocationBitmapStatus, ) -> ValidationUnitResult { diff --git a/identity_did/Cargo.toml b/identity_did/Cargo.toml index 5d23782c8b..9b32efb13b 100644 --- a/identity_did/Cargo.toml +++ b/identity_did/Cargo.toml @@ -27,3 +27,6 @@ serde_json.workspace = true # RUSTDOCFLAGS="--cfg docsrs" cargo +nightly doc --all-features --no-deps --workspace --open all-features = true rustdoc-args = ["--cfg", "docsrs"] + +[lints] +workspace = true diff --git a/identity_document/Cargo.toml b/identity_document/Cargo.toml index c25671873d..c2fceac707 100644 --- a/identity_document/Cargo.toml +++ b/identity_document/Cargo.toml @@ -28,3 +28,6 @@ serde_json.workspace = true [[bench]] name = "deserialize_document" harness = false + +[lints] +workspace = true diff --git a/identity_eddsa_verifier/Cargo.toml b/identity_eddsa_verifier/Cargo.toml index 257fa5d5a4..69ba2ff005 100644 --- a/identity_eddsa_verifier/Cargo.toml +++ b/identity_eddsa_verifier/Cargo.toml @@ -18,3 +18,6 @@ iota-crypto = { version = "0.23", default-features = false, features = ["std"] } [features] ed25519 = ["iota-crypto/ed25519"] default = ["ed25519"] + +[lints] +workspace = true diff --git a/identity_iota/Cargo.toml b/identity_iota/Cargo.toml index bd5aa25125..9910b03c5e 100644 --- a/identity_iota/Cargo.toml +++ b/identity_iota/Cargo.toml @@ -69,3 +69,6 @@ sd-jwt = ["identity_credential/sd-jwt"] # RUSTDOCFLAGS="--cfg docsrs" cargo +nightly doc --all-features --no-deps --workspace --open all-features = true rustdoc-args = ["--cfg", "docsrs"] + +[lints] +workspace = true diff --git a/identity_iota/README.md b/identity_iota/README.md index e210d6e10d..69d68defd8 100644 --- a/identity_iota/README.md +++ b/identity_iota/README.md @@ -24,7 +24,7 @@ ## Introduction -IOTA Identity is a [Rust](https://www.rust-lang.org/) implementation of decentralized digital identity, also known as Self-Sovereign Identity (SSI). It implements the W3C [Decentralized Identifiers (DID)](https://www.w3.org/TR/did-core/) and [Verifiable Credentials](https://www.w3.org/TR/vc-data-model/) specifications. This library can be used to create, resolve and authenticate digital identities and to create verifiable credentials and presentations in order to share information in a verifiable manner and establish trust in the digital world. It does so while supporting secure storage of cryptographic keys, which can be implemented for your preferred key management system. Many of the individual libraries (Rust crates) are agnostic over the concrete DID method, with the exception of some libraries dedicated to implement the [IOTA DID method](https://wiki.iota.org/shimmer/identity.rs/specs/did/iota_did_method_spec/), which is an implementation of decentralized digital identity on the IOTA and Shimmer networks. Written in stable Rust, IOTA Identity has strong guarantees of memory safety and process integrity while maintaining exceptional performance. +IOTA Identity is a [Rust](https://www.rust-lang.org/) implementation of decentralized digital identity, also known as Self-Sovereign Identity (SSI). It implements the W3C [Decentralized Identifiers (DID)](https://www.w3.org/TR/did-core/) and [Verifiable Credentials](https://www.w3.org/TR/vc-data-model/) specifications. This library can be used to create, resolve and authenticate digital identities and to create verifiable credentials and presentations in order to share information in a verifiable manner and establish trust in the digital world. It does so while supporting secure storage of cryptographic keys, which can be implemented for your preferred key management system. Many of the individual libraries (Rust crates) are agnostic over the concrete DID method, with the exception of some libraries dedicated to implement the [IOTA DID method](https://wiki.iota.org/identity.rs/specs/did/iota_did_method_spec/), which is an implementation of decentralized digital identity on the IOTA and Shimmer networks. Written in stable Rust, IOTA Identity has strong guarantees of memory safety and process integrity while maintaining exceptional performance. ## Bindings @@ -36,8 +36,8 @@ IOTA Identity is a [Rust](https://www.rust-lang.org/) implementation of decentra - API References: - [Rust API Reference](https://docs.rs/identity_iota/latest/identity_iota/): Package documentation (cargo docs). - - [Wasm API Reference](https://wiki.iota.org/shimmer/identity.rs/libraries/wasm/api_reference/): Wasm Package documentation. -- [Identity Documentation Pages](https://wiki.iota.org/shimmer/identity.rs/introduction): Supplementing documentation with context around identity and simple examples on library usage. + - [Wasm API Reference](https://wiki.iota.org/identity.rs/libraries/wasm/api_reference/): Wasm Package documentation. +- [Identity Documentation Pages](https://wiki.iota.org/identity.rs/introduction): Supplementing documentation with context around identity and simple examples on library usage. - [Examples](https://github.com/iotaledger/identity.rs/blob/HEAD/examples): Practical code snippets to get you started with the library. ## Prerequisites @@ -74,7 +74,7 @@ version = "1.0.0" edition = "2021" [dependencies] -identity_iota = {version = "1.1.1", features = ["memstore"]} +identity_iota = { version = "1.1.1", features = ["memstore"] } iota-sdk = { version = "1.0.2", default-features = true, features = ["tls", "client", "stronghold"] } tokio = { version = "1", features = ["full"] } anyhow = "1.0.62" @@ -214,7 +214,7 @@ For detailed development progress, see the IOTA Identity development [kanban boa We would love to have you help us with the development of IOTA Identity. Each and every contribution is greatly valued! -Please review the [contribution](https://wiki.iota.org/shimmer/identity.rs/contribute) and [workflow](https://wiki.iota.org/shimmer/identity.rs/workflow) sections in the [IOTA Wiki](https://wiki.iota.org/). +Please review the [contribution](https://wiki.iota.org/identity.rs/contribute) and [workflow](https://wiki.iota.org/identity.rs/workflow) sections in the [IOTA Wiki](https://wiki.iota.org/). To contribute directly to the repository, simply fork the project, push your changes to your fork and create a pull request to get them included! diff --git a/identity_iota_core/Cargo.toml b/identity_iota_core/Cargo.toml index c5fbfa1f89..6c55db6b12 100644 --- a/identity_iota_core/Cargo.toml +++ b/identity_iota_core/Cargo.toml @@ -53,3 +53,6 @@ revocation-bitmap = ["identity_credential/revocation-bitmap"] send-sync-client-ext = [] # Disables the blanket implementation of `IotaIdentityClientExt`. test = ["client"] + +[lints] +workspace = true diff --git a/identity_iota_core/src/document/iota_document.rs b/identity_iota_core/src/document/iota_document.rs index 89abf06cf5..7ae60381d7 100644 --- a/identity_iota_core/src/document/iota_document.rs +++ b/identity_iota_core/src/document/iota_document.rs @@ -5,7 +5,6 @@ use core::fmt; use core::fmt::Debug; use core::fmt::Display; use identity_credential::credential::Jws; -#[cfg(feature = "client")] use identity_did::CoreDID; use identity_did::DIDUrl; use identity_document::verifiable::JwsVerificationOptions; @@ -15,7 +14,6 @@ use serde::Deserialize; use serde::Serialize; use identity_core::common::Object; -#[cfg(feature = "client")] use identity_core::common::OneOrSet; use identity_core::common::OrderedSet; use identity_core::common::Url; @@ -123,9 +121,6 @@ impl IotaDocument { } /// Returns an iterator yielding the DID controllers. - /// - /// NOTE: controllers are determined by the `state_controller` unlock condition of the output - /// during resolution and are omitted when publishing. pub fn controller(&self) -> impl Iterator + '_ { let core_did_controller_iter = self .document @@ -134,11 +129,31 @@ impl IotaDocument { .into_iter() .flatten(); - // CORRECTNESS: These casts are OK because the public API does not expose methods - // enabling unchecked mutation of the controllers. + // CORRECTNESS: These casts are OK because the public API only allows setting IotaDIDs. core_did_controller_iter.map(IotaDID::from_inner_ref_unchecked) } + /// Sets the value of the document controller. + /// + /// Note: + /// * Duplicates in `controller` will be ignored. + /// * Use an empty collection to clear all controllers. + pub fn set_controller(&mut self, controller: T) + where + T: IntoIterator, + { + let controller_core_dids: Option> = { + let controller_set: OrderedSet = controller.into_iter().map(CoreDID::from).collect(); + if controller_set.is_empty() { + None + } else { + Some(OneOrSet::new_set(controller_set).expect("controller is checked to be not empty")) + } + }; + + *self.document.controller_mut() = controller_core_dids; + } + /// Returns a reference to the `alsoKnownAs` set. pub fn also_known_as(&self) -> &OrderedSet { self.document.also_known_as() @@ -442,7 +457,14 @@ mod client_document { _ => None, }; - *self.core_document_mut().controller_mut() = controller_did.map(CoreDID::from).map(OneOrSet::new_one); + if let Some(controller_did) = controller_did { + match self.core_document_mut().controller_mut() { + Some(controllers) => { + controllers.append(CoreDID::from(controller_did)); + } + None => *self.core_document_mut().controller_mut() = Some(OneOrSet::new_one(CoreDID::from(controller_did))), + } + } Ok(()) } @@ -731,6 +753,98 @@ mod tests { assert_eq!(doc1, doc2); } + #[test] + fn test_unpack_no_external_controller() { + let document_did: IotaDID = "did:iota:0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + .parse() + .unwrap(); + let alias_controller: IotaDID = "did:iota:0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + .parse() + .unwrap(); + + let mut original_doc: IotaDocument = IotaDocument::new_with_id(document_did.clone()); + original_doc.set_controller([]); + + let alias_output: AliasOutput = AliasOutputBuilder::new_with_amount(1, AliasId::from(&document_did)) + .with_state_metadata(original_doc.pack().unwrap()) + .add_unlock_condition(UnlockCondition::StateControllerAddress( + StateControllerAddressUnlockCondition::new(Address::Alias(AliasAddress::new(AliasId::from(&alias_controller)))), + )) + .add_unlock_condition(UnlockCondition::GovernorAddress(GovernorAddressUnlockCondition::new( + Address::Alias(AliasAddress::new(AliasId::from(&alias_controller))), + ))) + .finish() + .unwrap(); + + let document: IotaDocument = IotaDocument::unpack_from_output(&document_did, &alias_output, true).unwrap(); + let controllers: Vec = document.controller().cloned().collect::>(); + assert_eq!(controllers.first().unwrap(), &alias_controller); + assert_eq!(controllers.len(), 1); + } + + #[test] + fn test_unpack_with_duplicate_controller() { + let document_did: IotaDID = "did:iota:0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + .parse() + .unwrap(); + let alias_controller: IotaDID = "did:iota:0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + .parse() + .unwrap(); + + let mut original_doc: IotaDocument = IotaDocument::new_with_id(document_did.clone()); + original_doc.set_controller([alias_controller.clone()]); + + let alias_output: AliasOutput = AliasOutputBuilder::new_with_amount(1, AliasId::from(&document_did)) + .with_state_metadata(original_doc.pack().unwrap()) + .add_unlock_condition(UnlockCondition::StateControllerAddress( + StateControllerAddressUnlockCondition::new(Address::Alias(AliasAddress::new(AliasId::from(&alias_controller)))), + )) + .add_unlock_condition(UnlockCondition::GovernorAddress(GovernorAddressUnlockCondition::new( + Address::Alias(AliasAddress::new(AliasId::from(&alias_controller))), + ))) + .finish() + .unwrap(); + + let document: IotaDocument = IotaDocument::unpack_from_output(&document_did, &alias_output, true).unwrap(); + let controllers: Vec = document.controller().cloned().collect::>(); + assert_eq!(controllers.first().unwrap(), &alias_controller); + assert_eq!(controllers.len(), 1); + } + + #[test] + fn test_unpack_with_external_controller() { + let document_did: IotaDID = "did:iota:0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + .parse() + .unwrap(); + let alias_controller: IotaDID = "did:iota:0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + .parse() + .unwrap(); + let external_controller_did: IotaDID = + "did:iota:0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC" + .parse() + .unwrap(); + + let mut original_doc: IotaDocument = IotaDocument::new_with_id(document_did.clone()); + original_doc.set_controller([external_controller_did.clone()]); + + let alias_output: AliasOutput = AliasOutputBuilder::new_with_amount(1, AliasId::from(&document_did)) + .with_state_metadata(original_doc.pack().unwrap()) + .add_unlock_condition(UnlockCondition::StateControllerAddress( + StateControllerAddressUnlockCondition::new(Address::Alias(AliasAddress::new(AliasId::from(&alias_controller)))), + )) + .add_unlock_condition(UnlockCondition::GovernorAddress(GovernorAddressUnlockCondition::new( + Address::Alias(AliasAddress::new(AliasId::from(&alias_controller))), + ))) + .finish() + .unwrap(); + + let document: IotaDocument = IotaDocument::unpack_from_output(&document_did, &alias_output, true).unwrap(); + let controllers: Vec = document.controller().cloned().collect::>(); + assert_eq!(controllers.first().unwrap(), &external_controller_did); + assert_eq!(controllers.get(1).unwrap(), &alias_controller); + assert_eq!(controllers.len(), 2); + } + #[test] fn test_unpack_empty() { let controller_did: IotaDID = valid_did(); @@ -765,7 +879,10 @@ mod tests { let packed: Vec = document.pack_with_encoding(StateMetadataEncoding::Json).unwrap(); let state_metadata_document: StateMetadataDocument = StateMetadataDocument::unpack(&packed).unwrap(); let unpacked_document: IotaDocument = state_metadata_document.into_iota_document(&did).unwrap(); - assert!(unpacked_document.document.controller().is_none()); + assert_eq!( + unpacked_document.document.controller().unwrap().get(0).unwrap().clone(), + CoreDID::from(controller_did) + ); assert!(unpacked_document.metadata.state_controller_address.is_none()); assert!(unpacked_document.metadata.governor_address.is_none()); } diff --git a/identity_iota_core/src/state_metadata/document.rs b/identity_iota_core/src/state_metadata/document.rs index d15f0d8d26..e14e381f5b 100644 --- a/identity_iota_core/src/state_metadata/document.rs +++ b/identity_iota_core/src/state_metadata/document.rs @@ -79,7 +79,6 @@ impl StateMetadataDocument { // Unset Governor and State Controller Addresses to avoid bloating the payload self.metadata.governor_address = None; self.metadata.state_controller_address = None; - *self.document.controller_mut() = None; let encoded_message_data: Vec = match encoding { StateMetadataEncoding::Json => self @@ -410,8 +409,7 @@ mod tests { let TestSetup { document, .. } = test_document(); let mut state_metadata_doc: StateMetadataDocument = StateMetadataDocument::from(document); let packed: Vec = state_metadata_doc.clone().pack(StateMetadataEncoding::Json).unwrap(); - // Controller and State Controller are set to None when packing - *state_metadata_doc.document.controller_mut() = None; + // Governor and State Controller are set to None when packing state_metadata_doc.metadata.governor_address = None; state_metadata_doc.metadata.state_controller_address = None; let expected_payload: String = format!( @@ -434,6 +432,31 @@ mod tests { assert_eq!(&packed[7..], expected_payload.as_bytes()); } + #[test] + fn test_no_controller() { + let TestSetup { + mut document, did_self, .. + } = test_document(); + *document.core_document_mut().controller_mut() = None; + let state_metadata_doc: StateMetadataDocument = StateMetadataDocument::from(document); + let packed: Vec = state_metadata_doc.clone().pack(StateMetadataEncoding::Json).unwrap(); + let expected_payload: String = format!( + "{{\"doc\":{},\"meta\":{}}}", + state_metadata_doc.document, state_metadata_doc.metadata + ); + assert_eq!(&packed[7..], expected_payload.as_bytes()); + let unpacked = StateMetadataDocument::unpack(&packed).unwrap(); + assert_eq!( + unpacked + .into_iota_document(&did_self) + .unwrap() + .controller() + .collect::>() + .len(), + 0 + ); + } + #[test] fn test_unpack_length_prefix() { // Changing the serialization is a breaking change! diff --git a/identity_jose/Cargo.toml b/identity_jose/Cargo.toml index aa2a53f13a..5465f93473 100644 --- a/identity_jose/Cargo.toml +++ b/identity_jose/Cargo.toml @@ -29,3 +29,6 @@ signature = { version = "2", default-features = false } [[example]] name = "jws_encoding_decoding" test = true + +[lints] +workspace = true diff --git a/identity_resolver/Cargo.toml b/identity_resolver/Cargo.toml index bd28b248ad..76c88ca6c1 100644 --- a/identity_resolver/Cargo.toml +++ b/identity_resolver/Cargo.toml @@ -40,3 +40,6 @@ default = ["revocation-bitmap", "iota"] revocation-bitmap = ["identity_credential/revocation-bitmap", "identity_iota_core?/revocation-bitmap"] # Enables the IOTA integration for the resolver. iota = ["dep:identity_iota_core"] + +[lints] +workspace = true diff --git a/identity_storage/Cargo.toml b/identity_storage/Cargo.toml index 75086ccab9..d0f62b08c2 100644 --- a/identity_storage/Cargo.toml +++ b/identity_storage/Cargo.toml @@ -42,3 +42,6 @@ memstore = ["dep:tokio", "dep:rand", "dep:iota-crypto"] send-sync-storage = [] # Implements the JwkStorageDocumentExt trait for IotaDocument iota-document = ["dep:identity_iota_core"] + +[lints] +workspace = true diff --git a/identity_stronghold/Cargo.toml b/identity_stronghold/Cargo.toml index e45a6d4eb7..dccb20b695 100644 --- a/identity_stronghold/Cargo.toml +++ b/identity_stronghold/Cargo.toml @@ -30,3 +30,6 @@ tokio = { version = "1.29.0", default-features = false, features = ["macros", "s default = [] # Enables `Send` + `Sync` bounds for the trait implementations on `StrongholdStorage`. send-sync-storage = ["identity_storage/send-sync-storage"] + +[lints] +workspace = true diff --git a/identity_verification/Cargo.toml b/identity_verification/Cargo.toml index 1b6bb11d77..0e19f1fadf 100644 --- a/identity_verification/Cargo.toml +++ b/identity_verification/Cargo.toml @@ -13,8 +13,11 @@ identity_core = { version = "=1.1.1", path = "./../identity_core", default-featu identity_did = { version = "=1.1.1", path = "./../identity_did", default-features = false } identity_jose = { version = "=1.1.1", path = "./../identity_jose", default-features = false } serde.workspace = true +serde_json.workspace = true strum.workspace = true thiserror.workspace = true [dev-dependencies] -serde_json.workspace = true + +[lints] +workspace = true diff --git a/identity_verification/src/verification_method/material.rs b/identity_verification/src/verification_method/material.rs index d8553a4368..8e881253c5 100644 --- a/identity_verification/src/verification_method/material.rs +++ b/identity_verification/src/verification_method/material.rs @@ -5,6 +5,12 @@ use crate::jose::jwk::Jwk; use core::fmt::Debug; use core::fmt::Formatter; use identity_core::convert::BaseEncoding; +use serde::de::Visitor; +use serde::ser::SerializeMap; +use serde::Deserialize; +use serde::Serialize; +use serde::Serializer; +use serde_json::Value; use crate::error::Error; use crate::error::Result; @@ -21,6 +27,9 @@ pub enum MethodData { PublicKeyBase58(String), /// Verification Material in the JSON Web Key format. PublicKeyJwk(Jwk), + /// Arbitrary verification material. + #[serde(untagged)] + Custom(CustomMethodData), } impl MethodData { @@ -36,6 +45,11 @@ impl MethodData { Self::PublicKeyMultibase(BaseEncoding::encode_multibase(&data, None)) } + /// Creates a new `MethodData` variant from custom data. + pub fn new_custom(data: impl Into) -> Self { + Self::Custom(data.into()) + } + /// Returns a `Vec` containing the decoded bytes of the `MethodData`. /// /// This is generally a public key identified by a `MethodType` value. @@ -45,7 +59,7 @@ impl MethodData { /// represented as a vector of bytes. pub fn try_decode(&self) -> Result> { match self { - Self::PublicKeyJwk(_) => Err(Error::InvalidMethodDataTransformation( + Self::PublicKeyJwk(_) | Self::Custom(_) => Err(Error::InvalidMethodDataTransformation( "method data is not base encoded", )), Self::PublicKeyMultibase(input) => { @@ -68,6 +82,15 @@ impl MethodData { pub fn try_public_key_jwk(&self) -> Result<&Jwk> { self.public_key_jwk().ok_or(Error::NotPublicKeyJwk) } + + /// Returns the custom method data, if any. + pub fn custom(&self) -> Option<&CustomMethodData> { + if let Self::Custom(method_data) = self { + Some(method_data) + } else { + None + } + } } impl Debug for MethodData { @@ -76,6 +99,94 @@ impl Debug for MethodData { Self::PublicKeyJwk(inner) => f.write_fmt(format_args!("PublicKeyJwk({inner:#?})")), Self::PublicKeyMultibase(inner) => f.write_fmt(format_args!("PublicKeyMultibase({inner})")), Self::PublicKeyBase58(inner) => f.write_fmt(format_args!("PublicKeyBase58({inner})")), + Self::Custom(CustomMethodData { name, data }) => f.write_fmt(format_args!("{name}({data})")), } } } + +#[derive(Clone, Debug, PartialEq, Eq)] +/// Custom verification method. +pub struct CustomMethodData { + /// Verification method's name. + pub name: String, + /// Verification method's data. + pub data: Value, +} + +impl Serialize for CustomMethodData { + fn serialize(&self, serializer: S) -> std::prelude::v1::Result + where + S: Serializer, + { + let mut map = serializer.serialize_map(Some(1))?; + map.serialize_entry(&self.name, &self.data)?; + map.end() + } +} + +impl<'de> Deserialize<'de> for CustomMethodData { + fn deserialize(deserializer: D) -> std::prelude::v1::Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_map(CustomMethodDataVisitor) + } +} + +struct CustomMethodDataVisitor; + +impl<'de> Visitor<'de> for CustomMethodDataVisitor { + type Value = CustomMethodData; + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("\"\": ") + } + fn visit_map(self, mut map: A) -> std::prelude::v1::Result + where + A: serde::de::MapAccess<'de>, + { + let mut custom_method_data = CustomMethodData { + name: String::default(), + data: Value::Null, + }; + while let Some((name, data)) = map.next_entry::()? { + custom_method_data = CustomMethodData { name, data }; + } + + Ok(custom_method_data) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn serialize_custom_method_data() { + let custom = MethodData::Custom(CustomMethodData { + name: "anArbitraryMethod".to_owned(), + data: json!({"a": 1, "b": 2}), + }); + let target_str = json!({ + "anArbitraryMethod": {"a": 1, "b": 2}, + }) + .to_string(); + assert_eq!(serde_json::to_string(&custom).unwrap(), target_str); + } + #[test] + fn deserialize_custom_method_data() { + let inner_data = json!({ + "firstCustomField": "a random string", + "secondCustomField": 420, + }); + let json_method_data = json!({ + "myCustomVerificationMethod": &inner_data, + }); + let custom = serde_json::from_value::(json_method_data.clone()).unwrap(); + let target_method_data = MethodData::Custom(CustomMethodData { + name: "myCustomVerificationMethod".to_owned(), + data: inner_data, + }); + assert_eq!(custom, target_method_data); + } +} diff --git a/identity_verification/src/verification_method/method.rs b/identity_verification/src/verification_method/method.rs index 360f2efe55..8c48e06893 100644 --- a/identity_verification/src/verification_method/method.rs +++ b/identity_verification/src/verification_method/method.rs @@ -20,6 +20,7 @@ use crate::verification_method::MethodBuilder; use crate::verification_method::MethodData; use crate::verification_method::MethodRef; use crate::verification_method::MethodType; +use crate::CustomMethodData; use identity_did::CoreDID; use identity_did::DIDUrl; use identity_did::DID; @@ -28,8 +29,8 @@ use identity_did::DID; /// /// [Specification](https://www.w3.org/TR/did-core/#verification-method-properties) #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(from = "_VerificationMethod")] pub struct VerificationMethod { - #[serde(deserialize_with = "deserialize_id_with_fragment")] pub(crate) id: DIDUrl, pub(crate) controller: CoreDID, #[serde(rename = "type")] @@ -245,3 +246,46 @@ impl KeyComparable for VerificationMethod { self.id() } } + +// Horrible workaround for a tracked serde issue https://github.com/serde-rs/serde/issues/2200. Serde doesn't "consume" +// the input when deserializing flattened enums (MethodData in this case) causing duplication of data (in this case +// it ends up in the properties object). This workaround simply removes the duplication. +#[derive(Deserialize)] +struct _VerificationMethod { + #[serde(deserialize_with = "deserialize_id_with_fragment")] + pub(crate) id: DIDUrl, + pub(crate) controller: CoreDID, + #[serde(rename = "type")] + pub(crate) type_: MethodType, + #[serde(flatten)] + pub(crate) data: MethodData, + #[serde(flatten)] + pub(crate) properties: Object, +} + +impl From<_VerificationMethod> for VerificationMethod { + fn from(value: _VerificationMethod) -> Self { + let _VerificationMethod { + id, + controller, + type_, + data, + mut properties, + } = value; + let key = match &data { + MethodData::PublicKeyBase58(_) => "publicKeyBase58", + MethodData::PublicKeyJwk(_) => "publicKeyJwk", + MethodData::PublicKeyMultibase(_) => "publicKeyMultibase", + MethodData::Custom(CustomMethodData { name, .. }) => name.as_str(), + }; + properties.remove(key); + + VerificationMethod { + id, + controller, + type_, + data, + properties, + } + } +} diff --git a/identity_verification/src/verification_method/method_type.rs b/identity_verification/src/verification_method/method_type.rs index e387db14de..ae3877948d 100644 --- a/identity_verification/src/verification_method/method_type.rs +++ b/identity_verification/src/verification_method/method_type.rs @@ -25,6 +25,10 @@ impl MethodType { /// A verification method for use with JWT verification as prescribed by the [`Jwk`](::identity_jose::jwk::Jwk) /// in the [`publicKeyJwk`](crate::MethodData::PublicKeyJwk) entry. pub const JSON_WEB_KEY: Self = Self(Cow::Borrowed(JSON_WEB_KEY_METHOD_TYPE)); + /// Construct a custom method type. + pub fn custom(type_: impl AsRef) -> Self { + Self(Cow::Owned(type_.as_ref().to_owned())) + } } impl MethodType { diff --git a/identity_verification/src/verification_method/mod.rs b/identity_verification/src/verification_method/mod.rs index af6da98529..585b58639c 100644 --- a/identity_verification/src/verification_method/mod.rs +++ b/identity_verification/src/verification_method/mod.rs @@ -15,6 +15,7 @@ mod method_scope; mod method_type; pub use self::builder::MethodBuilder; +pub use self::material::CustomMethodData; pub use self::material::MethodData; pub use self::method::VerificationMethod; pub use self::method_ref::MethodRef;