From c9512d26fe85b5e4e3a6fca1a42d2aa02983daaa Mon Sep 17 00:00:00 2001 From: Abdulrahim Al Methiab <31316147+abdulmth@users.noreply.github.com> Date: Thu, 18 Jan 2024 11:39:42 +0100 Subject: [PATCH] Support Selective Disclosure SD-JWT (#1268) --- bindings/wasm/Cargo.toml | 2 +- .../cypress/e2e/1_advanced/6_sd_jwt.cy.js | 11 + bindings/wasm/docs/api-reference.md | 770 +++++++++++++++++- bindings/wasm/examples/README.md | 1 + .../wasm/examples/src/1_advanced/6_sd_jwt.ts | 209 +++++ bindings/wasm/examples/src/main.ts | 3 + bindings/wasm/examples/src/tests/6_sd_jwt.ts | 8 + bindings/wasm/src/credential/credential.rs | 23 + .../kb_validation_options.rs | 83 ++ .../jwt_credential_validation/mod.rs | 4 + .../sd_jwt_validator.rs | 149 ++++ bindings/wasm/src/error.rs | 4 +- bindings/wasm/src/iota/iota_document.rs | 32 + bindings/wasm/src/lib.rs | 1 + bindings/wasm/src/sd_jwt/decoder.rs | 43 + bindings/wasm/src/sd_jwt/disclosure.rs | 74 ++ bindings/wasm/src/sd_jwt/encoder.rs | 131 +++ .../wasm/src/sd_jwt/key_binding_jwt_claims.rs | 110 +++ bindings/wasm/src/sd_jwt/mod.rs | 14 + bindings/wasm/src/sd_jwt/wasm_sd_jwt.rs | 81 ++ bindings/wasm/tests/sd_jwt.ts | 54 ++ examples/0_basic/5_create_vc.rs | 2 +- examples/0_basic/6_create_vp.rs | 2 +- examples/0_basic/7_revoke_vc.rs | 2 +- examples/1_advanced/6_domain_linkage.rs | 4 +- examples/1_advanced/7_sd_jwt.rs | 227 ++++++ examples/Cargo.toml | 6 + examples/README.md | 6 +- examples/utils/utils.rs | 10 + identity_credential/Cargo.toml | 14 +- identity_credential/src/lib.rs | 3 + .../jwt_credential_validator.rs | 141 ++-- identity_credential/src/validator/mod.rs | 4 + .../src/validator/sd_jwt/error.rs | 45 + .../validator/sd_jwt/kb_validation_options.rs | 71 ++ .../src/validator/sd_jwt/mod.rs | 10 + .../src/validator/sd_jwt/validator.rs | 294 +++++++ .../src/document/core_document.rs | 6 +- identity_iota/Cargo.toml | 3 + identity_iota/src/lib.rs | 6 + .../src/storage/jwk_document_ext.rs | 8 +- identity_storage/src/storage/tests/kb_jwt.rs | 246 ++++++ identity_storage/src/storage/tests/mod.rs | 1 + identity_stronghold/src/lib.rs | 1 - 44 files changed, 2791 insertions(+), 128 deletions(-) create mode 100644 bindings/wasm/cypress/e2e/1_advanced/6_sd_jwt.cy.js create mode 100644 bindings/wasm/examples/src/1_advanced/6_sd_jwt.ts create mode 100644 bindings/wasm/examples/src/tests/6_sd_jwt.ts create mode 100644 bindings/wasm/src/credential/jwt_credential_validation/kb_validation_options.rs create mode 100644 bindings/wasm/src/credential/jwt_credential_validation/sd_jwt_validator.rs create mode 100644 bindings/wasm/src/sd_jwt/decoder.rs create mode 100644 bindings/wasm/src/sd_jwt/disclosure.rs create mode 100644 bindings/wasm/src/sd_jwt/encoder.rs create mode 100644 bindings/wasm/src/sd_jwt/key_binding_jwt_claims.rs create mode 100644 bindings/wasm/src/sd_jwt/mod.rs create mode 100644 bindings/wasm/src/sd_jwt/wasm_sd_jwt.rs create mode 100644 bindings/wasm/tests/sd_jwt.ts create mode 100644 examples/1_advanced/7_sd_jwt.rs create mode 100644 identity_credential/src/validator/sd_jwt/error.rs create mode 100644 identity_credential/src/validator/sd_jwt/kb_validation_options.rs create mode 100644 identity_credential/src/validator/sd_jwt/mod.rs create mode 100644 identity_credential/src/validator/sd_jwt/validator.rs create mode 100644 identity_storage/src/storage/tests/kb_jwt.rs diff --git a/bindings/wasm/Cargo.toml b/bindings/wasm/Cargo.toml index 41a34753bf..40d8057caf 100644 --- a/bindings/wasm/Cargo.toml +++ b/bindings/wasm/Cargo.toml @@ -34,7 +34,7 @@ wasm-bindgen-futures = { version = "0.4", default-features = false } version = "1.0.0" path = "../../identity_iota" default-features = false -features = ["client", "revocation-bitmap", "resolver", "domain-linkage"] +features = ["client", "revocation-bitmap", "resolver", "domain-linkage", "sd-jwt"] [dev-dependencies] rand = "0.8.5" diff --git a/bindings/wasm/cypress/e2e/1_advanced/6_sd_jwt.cy.js b/bindings/wasm/cypress/e2e/1_advanced/6_sd_jwt.cy.js new file mode 100644 index 0000000000..c9a32f813c --- /dev/null +++ b/bindings/wasm/cypress/e2e/1_advanced/6_sd_jwt.cy.js @@ -0,0 +1,11 @@ +import { sdJwt } from "../../../examples/dist/web/1_advanced/6_sd_jwt"; +import { setup } from "../../support/setup"; + +describe( + "SdJwt", + () => { + it("SD Jwt", async () => { + await setup(sdJwt); + }); + }, +); diff --git a/bindings/wasm/docs/api-reference.md b/bindings/wasm/docs/api-reference.md index 480fe9f3ac..0ed364a27c 100644 --- a/bindings/wasm/docs/api-reference.md +++ b/bindings/wasm/docs/api-reference.md @@ -28,6 +28,11 @@ It does not imply anything about a potentially present proof property on the cre

Note that having an instance of this type only means the JWS it was constructed from was verified. It does not imply anything about a potentially present proof property on the presentation itself.

+
Disclosure
+

Represents an elements constructing a disclosure. +Object properties and array elements disclosures are supported.

+

See: https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-07.html#name-disclosures

+
DomainLinkageConfiguration

DID Configuration Resource which contains Domain Linkage Credentials. It can be placed in an origin's .well-known directory to prove linkage between the origin and a DID. @@ -92,6 +97,12 @@ and resolution of DID documents in Alias Outputs.

JwtPresentationValidator
+
KeyBindingJWTValidationOptions
+

Options to declare validation criteria when validating credentials.

+
+
KeyBindingJwtClaims
+

Claims set for key binding JWT.

+
LinkedDomainService
MethodData
@@ -130,6 +141,21 @@ verifiable Credentials and Pre
RevocationBitmap

A compressed bitmap for managing credential revocation.

+
SdJwt
+

Representation of an SD-JWT of the format +<Issuer-signed JWT>~<Disclosure 1>~<Disclosure 2>~...~<Disclosure N>~<optional KB-JWT>.

+
+
SdJwtCredentialValidator
+

A type for decoding and validating Credential.

+
+
SdObjectDecoder
+

Substitutes digests in an SD-JWT object by their corresponding plaintext values provided by disclosures.

+
+
SdObjectEncoder
+

Transforms a JSON object into an SD-JWT object by substituting selected values +with their corresponding disclosure digests.

+

Note: digests are created using the sha-256 algorithm.

+
Service

A DID Document Service used to enable trusted interactions associated with a DID subject.

@@ -149,10 +175,6 @@ working with storage backed DID documents.

## Members
-
StateMetadataEncoding
-
-
MethodRelationship
-
StatusCheck

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

@@ -193,11 +215,23 @@ This variant is the default.

FirstError

Return after the first error occurs.

+
StateMetadataEncoding
+
+
MethodRelationship
+
## Functions
+
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 +EdDSA verification with curve Ed25519 to this function.

+

Warning

+

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

+
start()

Initializes the console error panic hook for better error messages

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

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 -EdDSA verification with curve Ed25519 to this function.

-

Warning

-

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

-
@@ -914,6 +940,7 @@ Deserializes an instance from a plain JS representation. * [.proof()](#Credential+proof) ⇒ [Proof](#Proof) \| undefined * [.properties()](#Credential+properties) ⇒ Map.<string, any> * [.setProof(proof)](#Credential+setProof) + * [.toJwtClaims(custom_claims)](#Credential+toJwtClaims) ⇒ Record.<string, any> * [.toJSON()](#Credential+toJSON) ⇒ any * [.clone()](#Credential+clone) ⇒ [Credential](#Credential) * _static_ @@ -1036,6 +1063,20 @@ Note that this proof is not related to JWT. | --- | --- | | proof | [Proof](#Proof) \| undefined | + + +### credential.toJwtClaims(custom_claims) ⇒ Record.<string, any> +Serializes the `Credential` as a JWT claims set +in accordance with [VC Data Model v1.1](https://www.w3.org/TR/vc-data-model/#json-web-token). + +The resulting object can be used as the payload of a JWS when issuing the credential. + +**Kind**: instance method of [Credential](#Credential) + +| Param | Type | +| --- | --- | +| custom_claims | Record.<string, any> \| undefined | + ### credential.toJSON() ⇒ any @@ -1384,6 +1425,108 @@ The `aud` property parsed from JWT claims. The custom claims parsed from the JWT. **Kind**: instance method of [DecodedJwtPresentation](#DecodedJwtPresentation) + + +## Disclosure +Represents an elements constructing a disclosure. +Object properties and array elements disclosures are supported. + +See: https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-07.html#name-disclosures + +**Kind**: global class + +* [Disclosure](#Disclosure) + * [new Disclosure(salt, claim_name, claim_value)](#new_Disclosure_new) + * _instance_ + * [.disclosure()](#Disclosure+disclosure) ⇒ string + * [.toEncodedString()](#Disclosure+toEncodedString) ⇒ string + * [.toString()](#Disclosure+toString) ⇒ string + * [.salt()](#Disclosure+salt) ⇒ string + * [.claimName()](#Disclosure+claimName) ⇒ string \| undefined + * [.claimValue()](#Disclosure+claimValue) ⇒ any + * [.toJSON()](#Disclosure+toJSON) ⇒ any + * _static_ + * [.parse(disclosure)](#Disclosure.parse) ⇒ [Disclosure](#Disclosure) + * [.fromJSON(json)](#Disclosure.fromJSON) ⇒ [Disclosure](#Disclosure) + + + +### new Disclosure(salt, claim_name, claim_value) + +| Param | Type | +| --- | --- | +| salt | string | +| claim_name | string \| undefined | +| claim_value | any | + + + +### disclosure.disclosure() ⇒ string +Returns a copy of the base64url-encoded string. + +**Kind**: instance method of [Disclosure](#Disclosure) + + +### disclosure.toEncodedString() ⇒ string +Returns a copy of the base64url-encoded string. + +**Kind**: instance method of [Disclosure](#Disclosure) + + +### disclosure.toString() ⇒ string +Returns a copy of the base64url-encoded string. + +**Kind**: instance method of [Disclosure](#Disclosure) + + +### disclosure.salt() ⇒ string +Returns a copy of the salt value. + +**Kind**: instance method of [Disclosure](#Disclosure) + + +### disclosure.claimName() ⇒ string \| undefined +Returns a copy of the claim name, optional for array elements. + +**Kind**: instance method of [Disclosure](#Disclosure) + + +### disclosure.claimValue() ⇒ any +Returns a copy of the claim Value which can be of any type. + +**Kind**: instance method of [Disclosure](#Disclosure) + + +### disclosure.toJSON() ⇒ any +Serializes this to a JSON object. + +**Kind**: instance method of [Disclosure](#Disclosure) + + +### Disclosure.parse(disclosure) ⇒ [Disclosure](#Disclosure) +Parses a Base64 encoded disclosure into a `Disclosure`. + +## Error + +Returns an `InvalidDisclosure` if input is not a valid disclosure. + +**Kind**: static method of [Disclosure](#Disclosure) + +| Param | Type | +| --- | --- | +| disclosure | string | + + + +### Disclosure.fromJSON(json) ⇒ [Disclosure](#Disclosure) +Deserializes an instance from a JSON object. + +**Kind**: static method of [Disclosure](#Disclosure) + +| Param | Type | +| --- | --- | +| json | any | + ## DomainLinkageConfiguration @@ -1843,7 +1986,8 @@ if the object is being concurrently modified. * [.toCoreDocument()](#IotaDocument+toCoreDocument) ⇒ [CoreDocument](#CoreDocument) * [.generateMethod(storage, keyType, alg, fragment, scope)](#IotaDocument+generateMethod) ⇒ Promise.<string> * [.purgeMethod(storage, id)](#IotaDocument+purgeMethod) ⇒ Promise.<void> - * [.createJwt(storage, fragment, payload, options)](#IotaDocument+createJwt) ⇒ [Promise.<Jws>](#Jws) + * ~~[.createJwt(storage, fragment, payload, options)](#IotaDocument+createJwt) ⇒ [Promise.<Jws>](#Jws)~~ + * [.createJws(storage, fragment, payload, options)](#IotaDocument+createJws) ⇒ [Promise.<Jws>](#Jws) * [.createCredentialJwt(storage, fragment, credential, options, custom_claims)](#IotaDocument+createCredentialJwt) ⇒ [Promise.<Jwt>](#Jwt) * [.createPresentationJwt(storage, fragment, presentation, signature_options, presentation_options)](#IotaDocument+createPresentationJwt) ⇒ [Promise.<Jwt>](#Jwt) * _static_ @@ -2259,7 +2403,27 @@ the given `storage`. -### iotaDocument.createJwt(storage, fragment, payload, options) ⇒ [Promise.<Jws>](#Jws) +### ~~iotaDocument.createJwt(storage, fragment, payload, options) ⇒ [Promise.<Jws>](#Jws)~~ +***Deprecated*** + +Sign the `payload` according to `options` with the storage backed private key corresponding to the public key +material in the verification method identified by the given `fragment. + +Upon success a string representing a JWS encoded according to the Compact JWS Serialization format is returned. +See [RFC7515 section 3.1](https://www.rfc-editor.org/rfc/rfc7515#section-3.1). + +**Kind**: instance method of [IotaDocument](#IotaDocument) + +| Param | Type | +| --- | --- | +| storage | [Storage](#Storage) | +| fragment | string | +| payload | string | +| options | [JwsSignatureOptions](#JwsSignatureOptions) | + + + +### iotaDocument.createJws(storage, fragment, payload, options) ⇒ [Promise.<Jws>](#Jws) Sign the `payload` according to `options` with the storage backed private key corresponding to the public key material in the verification method identified by the given `fragment. @@ -3900,6 +4064,159 @@ Attempt to extract the holder of the presentation. | --- | --- | | presentation | [Jwt](#Jwt) | + + +## KeyBindingJWTValidationOptions +Options to declare validation criteria when validating credentials. + +**Kind**: global class + +* [KeyBindingJWTValidationOptions](#KeyBindingJWTValidationOptions) + * [new KeyBindingJWTValidationOptions(options)](#new_KeyBindingJWTValidationOptions_new) + * _instance_ + * [.toJSON()](#KeyBindingJWTValidationOptions+toJSON) ⇒ any + * [.clone()](#KeyBindingJWTValidationOptions+clone) ⇒ [KeyBindingJWTValidationOptions](#KeyBindingJWTValidationOptions) + * _static_ + * [.fromJSON(json)](#KeyBindingJWTValidationOptions.fromJSON) ⇒ [KeyBindingJWTValidationOptions](#KeyBindingJWTValidationOptions) + + + +### new KeyBindingJWTValidationOptions(options) + +| Param | Type | +| --- | --- | +| options | IKeyBindingJWTValidationOptions \| undefined | + + + +### keyBindingJWTValidationOptions.toJSON() ⇒ any +Serializes this to a JSON object. + +**Kind**: instance method of [KeyBindingJWTValidationOptions](#KeyBindingJWTValidationOptions) + + +### keyBindingJWTValidationOptions.clone() ⇒ [KeyBindingJWTValidationOptions](#KeyBindingJWTValidationOptions) +Deep clones the object. + +**Kind**: instance method of [KeyBindingJWTValidationOptions](#KeyBindingJWTValidationOptions) + + +### KeyBindingJWTValidationOptions.fromJSON(json) ⇒ [KeyBindingJWTValidationOptions](#KeyBindingJWTValidationOptions) +Deserializes an instance from a JSON object. + +**Kind**: static method of [KeyBindingJWTValidationOptions](#KeyBindingJWTValidationOptions) + +| Param | Type | +| --- | --- | +| json | any | + + + +## KeyBindingJwtClaims +Claims set for key binding JWT. + +**Kind**: global class + +* [KeyBindingJwtClaims](#KeyBindingJwtClaims) + * [new KeyBindingJwtClaims(jwt, disclosures, nonce, aud, issued_at, custom_properties)](#new_KeyBindingJwtClaims_new) + * _instance_ + * [.toString()](#KeyBindingJwtClaims+toString) ⇒ string + * [.iat()](#KeyBindingJwtClaims+iat) ⇒ bigint + * [.aud()](#KeyBindingJwtClaims+aud) ⇒ string + * [.nonce()](#KeyBindingJwtClaims+nonce) ⇒ string + * [.sdHash()](#KeyBindingJwtClaims+sdHash) ⇒ string + * [.customProperties()](#KeyBindingJwtClaims+customProperties) ⇒ Record.<string, any> + * [.toJSON()](#KeyBindingJwtClaims+toJSON) ⇒ any + * [.clone()](#KeyBindingJwtClaims+clone) ⇒ [KeyBindingJwtClaims](#KeyBindingJwtClaims) + * _static_ + * [.keyBindingJwtHeaderTyp()](#KeyBindingJwtClaims.keyBindingJwtHeaderTyp) ⇒ string + * [.fromJSON(json)](#KeyBindingJwtClaims.fromJSON) ⇒ [KeyBindingJwtClaims](#KeyBindingJwtClaims) + + + +### new KeyBindingJwtClaims(jwt, disclosures, nonce, aud, issued_at, custom_properties) +Creates a new [`KeyBindingJwtClaims`]. +When `issued_at` is left as None, it will automatically default to the current time. + +# Error +When `issued_at` is set to `None` and the system returns time earlier than `SystemTime::UNIX_EPOCH`. + + +| Param | Type | +| --- | --- | +| jwt | string | +| disclosures | Array.<string> | +| nonce | string | +| aud | string | +| issued_at | [Timestamp](#Timestamp) \| undefined | +| custom_properties | Record.<string, any> \| undefined | + + + +### keyBindingJwtClaims.toString() ⇒ string +Returns a string representation of the claims. + +**Kind**: instance method of [KeyBindingJwtClaims](#KeyBindingJwtClaims) + + +### keyBindingJwtClaims.iat() ⇒ bigint +Returns a copy of the issued at `iat` property. + +**Kind**: instance method of [KeyBindingJwtClaims](#KeyBindingJwtClaims) + + +### keyBindingJwtClaims.aud() ⇒ string +Returns a copy of the audience `aud` property. + +**Kind**: instance method of [KeyBindingJwtClaims](#KeyBindingJwtClaims) + + +### keyBindingJwtClaims.nonce() ⇒ string +Returns a copy of the `nonce` property. + +**Kind**: instance method of [KeyBindingJwtClaims](#KeyBindingJwtClaims) + + +### keyBindingJwtClaims.sdHash() ⇒ string +Returns a copy of the `sd_hash` property. + +**Kind**: instance method of [KeyBindingJwtClaims](#KeyBindingJwtClaims) + + +### keyBindingJwtClaims.customProperties() ⇒ Record.<string, any> +Returns a copy of the custom properties. + +**Kind**: instance method of [KeyBindingJwtClaims](#KeyBindingJwtClaims) + + +### keyBindingJwtClaims.toJSON() ⇒ any +Serializes this to a JSON object. + +**Kind**: instance method of [KeyBindingJwtClaims](#KeyBindingJwtClaims) + + +### keyBindingJwtClaims.clone() ⇒ [KeyBindingJwtClaims](#KeyBindingJwtClaims) +Deep clones the object. + +**Kind**: instance method of [KeyBindingJwtClaims](#KeyBindingJwtClaims) + + +### KeyBindingJwtClaims.keyBindingJwtHeaderTyp() ⇒ string +Returns the value of the `typ` property of the JWT header according to +https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-07.html#name-key-binding-jwt + +**Kind**: static method of [KeyBindingJwtClaims](#KeyBindingJwtClaims) + + +### KeyBindingJwtClaims.fromJSON(json) ⇒ [KeyBindingJwtClaims](#KeyBindingJwtClaims) +Deserializes an instance from a JSON object. + +**Kind**: static method of [KeyBindingJwtClaims](#KeyBindingJwtClaims) + +| Param | Type | +| --- | --- | +| json | any | + ## LinkedDomainService @@ -4627,6 +4944,371 @@ if it is a valid Revocation Bitmap Service. | --- | --- | | service | [Service](#Service) | + + +## SdJwt +Representation of an SD-JWT of the format +`~~~...~~`. + +**Kind**: global class + +* [SdJwt](#SdJwt) + * [new SdJwt(jwt, disclosures, key_binding_jwt)](#new_SdJwt_new) + * _instance_ + * [.presentation()](#SdJwt+presentation) ⇒ string + * [.toString()](#SdJwt+toString) ⇒ string + * [.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) + + + +### new SdJwt(jwt, disclosures, key_binding_jwt) +Creates a new `SdJwt` from its components. + + +| Param | Type | +| --- | --- | +| jwt | string | +| disclosures | Array.<string> | +| key_binding_jwt | string \| undefined | + + + +### sdJwt.presentation() ⇒ string +Serializes the components into the final SD-JWT. + +**Kind**: instance method of [SdJwt](#SdJwt) + + +### sdJwt.toString() ⇒ string +Serializes the components into the final SD-JWT. + +**Kind**: instance method of [SdJwt](#SdJwt) + + +### sdJwt.jwt() ⇒ string +The JWT part. + +**Kind**: instance method of [SdJwt](#SdJwt) + + +### sdJwt.disclosures() ⇒ Array.<string> +The disclosures part. + +**Kind**: instance method of [SdJwt](#SdJwt) + + +### 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) + + +### sdJwt.clone() ⇒ [SdJwt](#SdJwt) +Deep clones the object. + +**Kind**: instance method of [SdJwt](#SdJwt) + + +### SdJwt.parse(sd_jwt) ⇒ [SdJwt](#SdJwt) +Parses an SD-JWT into its components as [`SdJwt`]. + +## Error +Returns `DeserializationError` if parsing fails. + +**Kind**: static method of [SdJwt](#SdJwt) + +| Param | Type | +| --- | --- | +| 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 +A type for decoding and validating [Credential](#Credential). + +**Kind**: global class + +* [SdJwtCredentialValidator](#SdJwtCredentialValidator) + * [new SdJwtCredentialValidator(signatureVerifier)](#new_SdJwtCredentialValidator_new) + * [.validateCredential(sd_jwt, issuer, options, fail_fast)](#SdJwtCredentialValidator+validateCredential) ⇒ [DecodedJwtCredential](#DecodedJwtCredential) + * [.verifySignature(credential, trustedIssuers, options)](#SdJwtCredentialValidator+verifySignature) ⇒ [DecodedJwtCredential](#DecodedJwtCredential) + * [.validateKeyBindingJwt(sdJwt, holder, options)](#SdJwtCredentialValidator+validateKeyBindingJwt) ⇒ [KeyBindingJwtClaims](#KeyBindingJwtClaims) + + + +### new SdJwtCredentialValidator(signatureVerifier) +Creates a new `SdJwtCredentialValidator`. If a `signatureVerifier` is provided it will be used when +verifying decoded JWS signatures, otherwise the default which is only capable of handling the `EdDSA` +algorithm will be used. + + +| Param | Type | +| --- | --- | +| signatureVerifier | IJwsVerifier | + + + +### sdJwtCredentialValidator.validateCredential(sd_jwt, issuer, options, fail_fast) ⇒ [DecodedJwtCredential](#DecodedJwtCredential) +Decodes and validates a `Credential` issued as an SD-JWT. A `DecodedJwtCredential` is returned upon success. +The credential is constructed by replacing disclosures following the +[`Selective Disclosure for JWTs (SD-JWT)`](https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-07.html) standard. + +The following properties are validated according to `options`: +- the issuer's signature on the JWS, +- the expiration date, +- the issuance date, +- the semantic structure. + +# Warning +* The key binding JWT is not validated. If needed, it must be validated separately using +`SdJwtValidator::validate_key_binding_jwt`. +* The lack of an error returned from this method is in of itself not enough to conclude that the credential can be +trusted. This section contains more information on additional checks that should be carried out before and after +calling this method. + +## The state of the issuer's DID Document +The caller must ensure that `issuer` represents an up-to-date DID Document. + +## Properties that are not validated + There are many properties defined in [The Verifiable Credentials Data Model](https://www.w3.org/TR/vc-data-model/) that are **not** validated, such as: +`proof`, `credentialStatus`, `type`, `credentialSchema`, `refreshService` **and more**. +These should be manually checked after validation, according to your requirements. + +# Errors +An error is returned whenever a validated condition is not satisfied. + +**Kind**: instance method of [SdJwtCredentialValidator](#SdJwtCredentialValidator) + +| Param | Type | +| --- | --- | +| sd_jwt | [SdJwt](#SdJwt) | +| issuer | [CoreDocument](#CoreDocument) \| IToCoreDocument | +| options | [JwtCredentialValidationOptions](#JwtCredentialValidationOptions) | +| fail_fast | number | + + + +### sdJwtCredentialValidator.verifySignature(credential, trustedIssuers, options) ⇒ [DecodedJwtCredential](#DecodedJwtCredential) +Decode and verify the JWS signature of a `Credential` issued as an SD-JWT using the DID Document of a trusted +issuer and replaces the disclosures. + +A `DecodedJwtCredential` is returned upon success. + +# Warning +The caller must ensure that the DID Documents of the trusted issuers are up-to-date. + +## Proofs + Only the JWS signature is verified. If the `Credential` contains a `proof` property this will not be verified +by this method. + +# Errors +* If the issuer' URL cannot be parsed. +* If Signature verification fails. +* If SD decoding fails. + +**Kind**: instance method of [SdJwtCredentialValidator](#SdJwtCredentialValidator) + +| Param | Type | +| --- | --- | +| credential | [SdJwt](#SdJwt) | +| trustedIssuers | Array.<(CoreDocument\|IToCoreDocument)> | +| options | [JwsVerificationOptions](#JwsVerificationOptions) | + + + +### sdJwtCredentialValidator.validateKeyBindingJwt(sdJwt, holder, options) ⇒ [KeyBindingJwtClaims](#KeyBindingJwtClaims) +Validates a Key Binding JWT (KB-JWT) according to `https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-07.html#name-key-binding-jwt`. +The Validation process includes: + * Signature validation using public key materials defined in the `holder` document. + * `typ` value in KB-JWT header. + * `sd_hash` claim value in the KB-JWT claim. + * Optional `nonce`, `aud` and issuance date validation. + +**Kind**: instance method of [SdJwtCredentialValidator](#SdJwtCredentialValidator) + +| Param | Type | +| --- | --- | +| sdJwt | [SdJwt](#SdJwt) | +| holder | [CoreDocument](#CoreDocument) \| IToCoreDocument | +| options | [KeyBindingJWTValidationOptions](#KeyBindingJWTValidationOptions) | + + + +## SdObjectDecoder +Substitutes digests in an SD-JWT object by their corresponding plaintext values provided by disclosures. + +**Kind**: global class + +* [SdObjectDecoder](#SdObjectDecoder) + * [new SdObjectDecoder()](#new_SdObjectDecoder_new) + * [.decode(object, disclosures)](#SdObjectDecoder+decode) ⇒ Record.<string, any> + + + +### new SdObjectDecoder() +Creates a new `SdObjectDecoder` with `sha-256` hasher. + + + +### sdObjectDecoder.decode(object, disclosures) ⇒ Record.<string, any> +Decodes an SD-JWT `object` containing by Substituting the digests with their corresponding +plaintext values provided by `disclosures`. + +## Notes +* Claims like `exp` or `iat` are not validated in the process of decoding. +* `_sd_alg` property will be removed if present. + +**Kind**: instance method of [SdObjectDecoder](#SdObjectDecoder) + +| Param | Type | +| --- | --- | +| object | Record.<string, any> | +| disclosures | Array.<string> | + + + +## SdObjectEncoder +Transforms a JSON object into an SD-JWT object by substituting selected values +with their corresponding disclosure digests. + +Note: digests are created using the sha-256 algorithm. + +**Kind**: global class + +* [SdObjectEncoder](#SdObjectEncoder) + * [new SdObjectEncoder(object)](#new_SdObjectEncoder_new) + * [.conceal(path, salt)](#SdObjectEncoder+conceal) ⇒ [Disclosure](#Disclosure) + * [.concealArrayEntry(path, element_index, salt)](#SdObjectEncoder+concealArrayEntry) ⇒ [Disclosure](#Disclosure) + * [.addSdAlgProperty()](#SdObjectEncoder+addSdAlgProperty) + * [.encodeToString()](#SdObjectEncoder+encodeToString) ⇒ string + * [.toString()](#SdObjectEncoder+toString) ⇒ string + * [.encodeToObject()](#SdObjectEncoder+encodeToObject) ⇒ Record.<string, any> + * [.toJSON()](#SdObjectEncoder+toJSON) ⇒ any + * [.addDecoys(path, number_of_decoys)](#SdObjectEncoder+addDecoys) + + + +### new SdObjectEncoder(object) +Creates a new `SdObjectEncoder` with `sha-256` hash function. + + +| Param | Type | +| --- | --- | +| object | any | + + + +### sdObjectEncoder.conceal(path, salt) ⇒ [Disclosure](#Disclosure) +Substitutes a value with the digest of its disclosure. +If no salt is provided, the disclosure will be created with a random salt value. + +The value of the key specified in `path` will be concealed. E.g. for path +`["claim", "subclaim"]` the value of `claim.subclaim` will be concealed. + +## Error +`InvalidPath` if path is invalid or the path slice is empty. +`DataTypeMismatch` if existing SD format is invalid. + +## Note +Use `concealArrayEntry` for values in arrays. + +**Kind**: instance method of [SdObjectEncoder](#SdObjectEncoder) + +| Param | Type | +| --- | --- | +| path | Array.<string> | +| salt | string \| undefined | + + + +### sdObjectEncoder.concealArrayEntry(path, element_index, salt) ⇒ [Disclosure](#Disclosure) +Substitutes a value within an array with the digest of its disclosure. +If no salt is provided, the disclosure will be created with random salt value. + +`path` is used to specify the array in the object, while `element_index` specifies +the index of the element to be concealed (index start at 0). + +## Error +`InvalidPath` if path is invalid or the path slice is empty. +`DataTypeMismatch` if existing SD format is invalid. +`IndexOutofBounds` if `element_index` is out of bounds. + +**Kind**: instance method of [SdObjectEncoder](#SdObjectEncoder) + +| Param | Type | +| --- | --- | +| path | Array.<string> | +| element_index | number | +| salt | string \| undefined | + + + +### sdObjectEncoder.addSdAlgProperty() +Adds the `_sd_alg` property to the top level of the object, with +its value set to "sha-256". + +**Kind**: instance method of [SdObjectEncoder](#SdObjectEncoder) + + +### sdObjectEncoder.encodeToString() ⇒ string +Returns the modified object as a string. + +**Kind**: instance method of [SdObjectEncoder](#SdObjectEncoder) + + +### sdObjectEncoder.toString() ⇒ string +Returns the modified object as a string. + +**Kind**: instance method of [SdObjectEncoder](#SdObjectEncoder) + + +### sdObjectEncoder.encodeToObject() ⇒ Record.<string, any> +Returns the modified object. + +**Kind**: instance method of [SdObjectEncoder](#SdObjectEncoder) + + +### sdObjectEncoder.toJSON() ⇒ any +Returns the modified object. + +**Kind**: instance method of [SdObjectEncoder](#SdObjectEncoder) + + +### sdObjectEncoder.addDecoys(path, number_of_decoys) +Adds a decoy digest to the specified path. +If path is an empty slice, decoys will be added to the top level. + +**Kind**: instance method of [SdObjectEncoder](#SdObjectEncoder) + +| Param | Type | +| --- | --- | +| path | Array.<string> | +| number_of_decoys | number | + ## Service @@ -5041,14 +5723,6 @@ Deserializes an instance from a JSON object. | --- | --- | | json | any | - - -## StateMetadataEncoding -**Kind**: global variable - - -## MethodRelationship -**Kind**: global variable ## StatusCheck @@ -5125,6 +5799,36 @@ Return all errors that occur during validation. Return after the first error occurs. **Kind**: global variable + + +## StateMetadataEncoding +**Kind**: global variable + + +## MethodRelationship +**Kind**: global variable + + +## 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 +`EdDSA` verification with curve `Ed25519` to this function. + +# Warning + +This function does not check whether `alg = EdDSA` in the protected header. Callers are expected to assert this +prior to calling the function. + +**Kind**: global function + +| Param | Type | +| --- | --- | +| alg | JwsAlgorithm | +| signingInput | Uint8Array | +| decodedSignature | Uint8Array | +| publicKey | [Jwk](#Jwk) | + ## start() @@ -5153,25 +5857,3 @@ Decode the given url-safe base64-encoded slice into its raw bytes. | --- | --- | | data | Uint8Array | - - -## 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 -`EdDSA` verification with curve `Ed25519` to this function. - -# Warning - -This function does not check whether `alg = EdDSA` in the protected header. Callers are expected to assert this -prior to calling the function. - -**Kind**: global function - -| Param | Type | -| --- | --- | -| alg | JwsAlgorithm | -| signingInput | Uint8Array | -| decodedSignature | Uint8Array | -| publicKey | [Jwk](#Jwk) | - diff --git a/bindings/wasm/examples/README.md b/bindings/wasm/examples/README.md index eca0f2c810..98cec6b1f7 100644 --- a/bindings/wasm/examples/README.md +++ b/bindings/wasm/examples/README.md @@ -59,6 +59,7 @@ The following advanced examples are available: | [3_did_issues_tokens](src/1_advanced/3_did_issues_tokens.ts) | Demonstrates how an identity can issue and control a Token Foundry and its tokens. | | [4_custom_resolution](src/1_advanced/4_custom_resolution.ts) | Demonstrates how to set up a resolver using custom handlers. | | [5_domain_linkage](src/1_advanced/5_domain_linkage.ts) | Demonstrates how to link a domain and a DID and verify the linkage. | +| [6_sd_jwt](src/1_advanced/6_sd_jwt.ts) | Demonstrates how to create a selective disclosure verifiable credential | ## Browser diff --git a/bindings/wasm/examples/src/1_advanced/6_sd_jwt.ts b/bindings/wasm/examples/src/1_advanced/6_sd_jwt.ts new file mode 100644 index 0000000000..397f608ee3 --- /dev/null +++ b/bindings/wasm/examples/src/1_advanced/6_sd_jwt.ts @@ -0,0 +1,209 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { + Credential, + DecodedJwtCredential, + EdDSAJwsVerifier, + FailFast, + JwkMemStore, + JwsSignatureOptions, + JwsVerificationOptions, + JwtCredentialValidationOptions, + KeyBindingJwtClaims, + KeyBindingJWTValidationOptions, + KeyIdMemStore, + SdJwt, + SdJwtCredentialValidator, + SdObjectEncoder, + Storage, + Timestamp, +} from "@iota/identity-wasm/node"; +import { Client, MnemonicSecretManager, Utils } from "@iota/sdk-wasm/node"; +import { API_ENDPOINT, createDid } from "../util"; + +/** + * Demonstrates how to create a selective disclosure verifiable credential and validate it + * using the [Selective Disclosure for JWTs (SD-JWT)](https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-07.html) specification. + */ +export async function sdJwt() { + // =========================================================================== + // Step 1: Create identities for the issuer and the holder. + // =========================================================================== + + const client = new Client({ + primaryNode: API_ENDPOINT, + localPow: true, + }); + + // Creates a new wallet and identity (see "0_create_did" example). + const issuerSecretManager: MnemonicSecretManager = { + mnemonic: Utils.generateMnemonic(), + }; + const issuerStorage: Storage = new Storage( + new JwkMemStore(), + new KeyIdMemStore(), + ); + let { document: issuerDocument, fragment: issuerFragment } = await createDid( + client, + issuerSecretManager, + issuerStorage, + ); + + // Create an identity for the holder, in this case also the subject. + const aliceSecretManager: MnemonicSecretManager = { + mnemonic: Utils.generateMnemonic(), + }; + const aliceStorage: Storage = new Storage( + new JwkMemStore(), + new KeyIdMemStore(), + ); + let { document: aliceDocument, fragment: aliceFragment } = await createDid( + client, + aliceSecretManager, + aliceStorage, + ); + + // =========================================================================== + // Step 2: Issuer creates and signs a selectively disclosable JWT verifiable credential. + // =========================================================================== + + // Create an address credential subject. + const subject = { + id: aliceDocument.id(), + name: "Alice", + nationalities: ["DE", "US"], + address: { + locality: "Maxstadt", + postal_code: "12344", + country: "DE", + street_address: "Weidenstraße 22", + }, + }; + + // Build credential using subject above and issuer. + const credential = new Credential({ + id: "https://example.com/credentials/3732", + type: "AddressCredential", + issuer: issuerDocument.id(), + credentialSubject: subject, + }); + + // In Order to create an selective disclosure JWT, the plain text JWT + // claims set must be created first. + let payload = credential.toJwtClaims(); + + // The issuer can make all or subset of the claims selectively disclosable. + let encoder = new SdObjectEncoder(payload); + + // Make "locality", "postal_code", "street_address" and the first entry of "nationalities" + // selectively disclosable while keeping other properties in plain text. + let disclosures = [ + encoder.conceal(["vc", "credentialSubject", "address", "locality"]), + encoder.conceal(["vc", "credentialSubject", "address", "postal_code"]), + encoder.conceal(["vc", "credentialSubject", "address", "street_address"]), + encoder.concealArrayEntry(["vc", "credentialSubject", "nationalities"], 1), + ]; + + // Add decoys in the credential top level, nationalities array and address object. + encoder.addDecoys(["vc", "credentialSubject", "nationalities"], 3); + encoder.addDecoys(["vc"], 4); + encoder.addDecoys(["vc", "credentialSubject", "address"], 2); + + // Add the `_sd_alg` property. + encoder.addSdAlgProperty(); + + console.log("Claims set with disclosure digests: "); + console.log(JSON.stringify(encoder.encodeToObject(), null, 2), "\n"); + + // Create the signed JWT. + const encodedPayload = encoder.encodeToString(); + let jws = await issuerDocument.createJws(issuerStorage, issuerFragment, encodedPayload, new JwsSignatureOptions()); + + // =========================================================================== + // Step 3: Issuer sends the JWT and the disclosures to the holder. + // =========================================================================== + + // One way to send the JWT and the disclosures, is by creating an SD-JWT with all the + // disclosures. + const strDisclosures = disclosures.map(disclosure => disclosure.toEncodedString()); + + let sdJwt = new SdJwt(jws.toString(), strDisclosures).presentation(); + + // =========================================================================== + // Step 4: Verifier sends the holder a challenge and requests a signed Verifiable Presentation. + // =========================================================================== + + const VERIFIER_DID = "did:example:verifier"; + // A unique random challenge generated by the requester per presentation can mitigate replay attacks. + let nonce = "475a7984-1bb5-4c4c-a56f-822bccd46440"; + + // =========================================================================== + // Step 5: Holder creates an SD-JWT to be presented to a verifier. + // =========================================================================== + + const sdJwtReceived = SdJwt.parse(sdJwt); + + // The holder only wants to present "locality" and "postal_code" but not "street_address" or the "US" nationality. + const receivedDisclosures = sdJwtReceived.disclosures(); + const toBeDisclosed = [ + receivedDisclosures[0], + receivedDisclosures[1], + ]; + + // 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`. + const bindingClaims = new KeyBindingJwtClaims( + sdJwtReceived.jwt(), + toBeDisclosed, + nonce, + VERIFIER_DID, + Timestamp.nowUTC(), + ); + + // Setting the `typ` in the header is required. + const options = new JwsSignatureOptions({ + typ: KeyBindingJwtClaims.keyBindingJwtHeaderTyp(), + }); + const kbJwt = await aliceDocument.createJws(aliceStorage, aliceFragment, bindingClaims.toString(), options); + + // Create the final SD-JWT. + let sdJwtWithKb = new SdJwt(sdJwtReceived.jwt().toString(), toBeDisclosed, kbJwt.toString()); + + // =========================================================================== + // Step 6: Holder presents the SD-JWT to the verifier. + // =========================================================================== + + let sdJwtPresentation = sdJwtWithKb.presentation(); + + // =========================================================================== + // Step 7: Verifier receives the SD-JWT and verifies it. + // =========================================================================== + + const sdJwtObj = SdJwt.parse(sdJwtPresentation); + + // Verify the JWT. + let validator = new SdJwtCredentialValidator(new EdDSAJwsVerifier()); + let decodedCredential: DecodedJwtCredential = validator.validateCredential( + sdJwtObj, + issuerDocument, + new JwtCredentialValidationOptions(), + FailFast.FirstError, + ); + + console.log("JWT successfully validated"); + console.log("Decoded credential: \n", decodedCredential.credential()); + + // Verify the Key Binding JWT. + let kbValidationOptions = new KeyBindingJWTValidationOptions( + { + aud: VERIFIER_DID, + nonce: nonce, + jwsOptions: new JwsVerificationOptions(), + }, + ); + validator.validateKeyBindingJwt(sdJwtObj, aliceDocument, kbValidationOptions); + + console.log("Key Binding JWT successfully validated"); +} diff --git a/bindings/wasm/examples/src/main.ts b/bindings/wasm/examples/src/main.ts index a49cd0f9dd..bf71211e3e 100644 --- a/bindings/wasm/examples/src/main.ts +++ b/bindings/wasm/examples/src/main.ts @@ -15,6 +15,7 @@ import { nftOwnsDid } from "./1_advanced/2_nft_owns_did"; import { didIssuesTokens } from "./1_advanced/3_did_issues_tokens"; import { customResolution } from "./1_advanced/4_custom_resolution"; import { domainLinkage } from "./1_advanced/5_domain_linkage"; +import { sdJwt } from "./1_advanced/6_sd_jwt"; async function main() { // Extract example name. @@ -52,6 +53,8 @@ async function main() { return await customResolution(); case "5_domain_linkage": return await domainLinkage(); + case "6_sd_jwt": + return await sdJwt(); default: throw "Unknown example name: '" + argument + "'"; } diff --git a/bindings/wasm/examples/src/tests/6_sd_jwt.ts b/bindings/wasm/examples/src/tests/6_sd_jwt.ts new file mode 100644 index 0000000000..2155cc453c --- /dev/null +++ b/bindings/wasm/examples/src/tests/6_sd_jwt.ts @@ -0,0 +1,8 @@ +import { sdJwt } from "../1_advanced/6_sd_jwt"; + +// Only verifies that no uncaught exceptions are thrown, including syntax errors etc. +describe("Test node examples", function() { + it("Domain Linkage", async () => { + await sdJwt(); + }); +}); diff --git a/bindings/wasm/src/credential/credential.rs b/bindings/wasm/src/credential/credential.rs index aeef53a25c..98158bd97e 100644 --- a/bindings/wasm/src/credential/credential.rs +++ b/bindings/wasm/src/credential/credential.rs @@ -6,11 +6,14 @@ use identity_iota::core::Object; use identity_iota::credential::Credential; use identity_iota::credential::CredentialBuilder; use identity_iota::credential::DomainLinkageCredentialBuilder; +use serde_json::Value; +use std::collections::BTreeMap; use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; use crate::common::ArrayString; use crate::common::MapStringAny; +use crate::common::RecordStringAny; use crate::common::WasmTimestamp; use crate::credential::domain_linkage_credential_builder::IDomainLinkageCredential; use crate::credential::ArrayContext; @@ -215,6 +218,26 @@ impl WasmCredential { pub fn set_proof(&mut self, proof: Option) { self.0.set_proof(proof.map(|wasm_proof| wasm_proof.0)) } + + /// Serializes the `Credential` as a JWT claims set + /// in accordance with [VC Data Model v1.1](https://www.w3.org/TR/vc-data-model/#json-web-token). + /// + /// The resulting object can be used as the payload of a JWS when issuing the credential. + #[wasm_bindgen(js_name = "toJwtClaims")] + pub fn to_jwt_claims(&self, custom_claims: Option) -> Result { + let serialized: String = if let Some(object) = custom_claims { + let object: BTreeMap = object.into_serde().wasm_result()?; + self.0.serialize_jwt(Some(object)).wasm_result()? + } else { + self.0.serialize_jwt(None).wasm_result()? + }; + let serialized: BTreeMap = serde_json::from_str(&serialized).wasm_result()?; + Ok( + JsValue::from_serde(&serialized) + .wasm_result()? + .unchecked_into::(), + ) + } } impl_wasm_json!(WasmCredential, Credential); diff --git a/bindings/wasm/src/credential/jwt_credential_validation/kb_validation_options.rs b/bindings/wasm/src/credential/jwt_credential_validation/kb_validation_options.rs new file mode 100644 index 0000000000..aea8dfe247 --- /dev/null +++ b/bindings/wasm/src/credential/jwt_credential_validation/kb_validation_options.rs @@ -0,0 +1,83 @@ +// Copyright 2020-2023 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use crate::error::Result; +use crate::error::WasmResult; +use identity_iota::credential::KeyBindingJWTValidationOptions; +use wasm_bindgen::prelude::*; + +/// Options to declare validation criteria when validating credentials. +#[wasm_bindgen(js_name = KeyBindingJWTValidationOptions)] +pub struct WasmKeyBindingJWTValidationOptions(pub(crate) KeyBindingJWTValidationOptions); + +#[wasm_bindgen(js_class = KeyBindingJWTValidationOptions)] +impl WasmKeyBindingJWTValidationOptions { + #[wasm_bindgen(constructor)] + pub fn new(options: Option) -> Result { + if let Some(opts) = options { + let options: KeyBindingJWTValidationOptions = opts.into_serde().wasm_result()?; + Ok(WasmKeyBindingJWTValidationOptions::from(options)) + } else { + Ok(WasmKeyBindingJWTValidationOptions::from( + KeyBindingJWTValidationOptions::default(), + )) + } + } +} + +impl_wasm_json!(WasmKeyBindingJWTValidationOptions, KeyBindingJWTValidationOptions); +impl_wasm_clone!(WasmKeyBindingJWTValidationOptions, KeyBindingJWTValidationOptions); + +impl From for WasmKeyBindingJWTValidationOptions { + fn from(options: KeyBindingJWTValidationOptions) -> Self { + Self(options) + } +} + +impl From for KeyBindingJWTValidationOptions { + fn from(options: WasmKeyBindingJWTValidationOptions) -> Self { + options.0 + } +} + +// Interface to allow creating `KeyBindingJWTValidationOptions` easily. +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "IKeyBindingJWTValidationOptions")] + pub type IKeyBindingJWTValidationOptions; +} + +#[wasm_bindgen(typescript_custom_section)] +const I_KEY_BINDING_JWT_VALIDATION_OPTIONS: &'static str = r#" +/** Holds options to create a new `KeyBindingJWTValidationOptions`. */ +interface IKeyBindingJWTValidationOptions { + /** + * Validates the nonce value of the KB-JWT claims. + */ + readonly nonce?: string; + + /** + * Validates the `aud` properties in the KB-JWT claims. + */ + readonly aud?: string; + + /** + * Options which affect the verification of the signature on the KB-JWT. + */ + readonly jwsOptions: JwsVerificationOptions; + + /** + * Declares that the KB-JWT is considered invalid if the `iat` value in the claims + * is earlier than this timestamp. + */ + readonly earliestIssuanceDate?: Timestamp; + + /** + * Declares that the KB-JWT is considered invalid if the `iat` value in the claims is + * later than this timestamp. + * + * Uses the current timestamp during validation if not set. + */ + readonly latestIssuanceDate?: Timestamp; + +}"#; diff --git a/bindings/wasm/src/credential/jwt_credential_validation/mod.rs b/bindings/wasm/src/credential/jwt_credential_validation/mod.rs index 62890a9d6f..826d0388d9 100644 --- a/bindings/wasm/src/credential/jwt_credential_validation/mod.rs +++ b/bindings/wasm/src/credential/jwt_credential_validation/mod.rs @@ -3,10 +3,14 @@ mod decoded_jwt_credential; mod jwt_credential_validator; +mod kb_validation_options; mod options; +mod sd_jwt_validator; mod unknown_credential; pub use self::decoded_jwt_credential::*; pub use self::jwt_credential_validator::*; +pub use self::kb_validation_options::*; pub use self::options::*; +pub use self::sd_jwt_validator::*; pub use self::unknown_credential::*; diff --git a/bindings/wasm/src/credential/jwt_credential_validation/sd_jwt_validator.rs b/bindings/wasm/src/credential/jwt_credential_validation/sd_jwt_validator.rs new file mode 100644 index 0000000000..812b25414b --- /dev/null +++ b/bindings/wasm/src/credential/jwt_credential_validation/sd_jwt_validator.rs @@ -0,0 +1,149 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use super::options::WasmJwtCredentialValidationOptions; +use super::WasmKeyBindingJWTValidationOptions; +use crate::common::ImportedDocumentLock; +use crate::common::ImportedDocumentReadGuard; +use crate::credential::WasmDecodedJwtCredential; +use crate::credential::WasmFailFast; +use crate::did::ArrayIToCoreDocument; +use crate::did::IToCoreDocument; +use crate::did::WasmJwsVerificationOptions; +use crate::error::Result; +use crate::error::WasmResult; +use crate::sd_jwt::WasmKeyBindingJwtClaims; +use crate::sd_jwt::WasmSdJwt; +use crate::verification::IJwsVerifier; +use crate::verification::WasmJwsVerifier; +use identity_iota::credential::SdJwtCredentialValidator; +use identity_iota::sd_jwt_payload::KeyBindingJwtClaims; +use identity_iota::sd_jwt_payload::SdObjectDecoder; + +use wasm_bindgen::prelude::*; + +/// A type for decoding and validating {@link Credential}. +#[wasm_bindgen(js_name = SdJwtCredentialValidator)] +pub struct WasmSdJwtCredentialValidator(SdJwtCredentialValidator); + +#[wasm_bindgen(js_class = SdJwtCredentialValidator)] +impl WasmSdJwtCredentialValidator { + /// Creates a new `SdJwtCredentialValidator`. If a `signatureVerifier` is provided it will be used when + /// verifying decoded JWS signatures, otherwise the default which is only capable of handling the `EdDSA` + /// algorithm will be used. + #[wasm_bindgen(constructor)] + #[allow(non_snake_case)] + pub fn new(signatureVerifier: IJwsVerifier) -> WasmSdJwtCredentialValidator { + let signature_verifier = WasmJwsVerifier::new(signatureVerifier); + WasmSdJwtCredentialValidator(SdJwtCredentialValidator::with_signature_verifier( + signature_verifier, + SdObjectDecoder::new_with_sha256(), + )) + } + + /// Decodes and validates a `Credential` issued as an SD-JWT. A `DecodedJwtCredential` is returned upon success. + /// The credential is constructed by replacing disclosures following the + /// [`Selective Disclosure for JWTs (SD-JWT)`](https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-07.html) standard. + /// + /// The following properties are validated according to `options`: + /// - the issuer's signature on the JWS, + /// - the expiration date, + /// - the issuance date, + /// - the semantic structure. + /// + /// # Warning + /// * The key binding JWT is not validated. If needed, it must be validated separately using + /// `SdJwtValidator::validate_key_binding_jwt`. + /// * The lack of an error returned from this method is in of itself not enough to conclude that the credential can be + /// trusted. This section contains more information on additional checks that should be carried out before and after + /// calling this method. + /// + /// ## The state of the issuer's DID Document + /// The caller must ensure that `issuer` represents an up-to-date DID Document. + /// + /// ## Properties that are not validated + /// There are many properties defined in [The Verifiable Credentials Data Model](https://www.w3.org/TR/vc-data-model/) that are **not** validated, such as: + /// `proof`, `credentialStatus`, `type`, `credentialSchema`, `refreshService` **and more**. + /// These should be manually checked after validation, according to your requirements. + /// + /// # Errors + /// An error is returned whenever a validated condition is not satisfied. + #[wasm_bindgen(js_name = validateCredential)] + pub fn validate_credential( + &self, + sd_jwt: &WasmSdJwt, + issuer: &IToCoreDocument, + options: &WasmJwtCredentialValidationOptions, + fail_fast: WasmFailFast, + ) -> Result { + let issuer_lock = ImportedDocumentLock::from(issuer); + let issuer_guard = issuer_lock.try_read()?; + + self + .0 + .validate_credential(&sd_jwt.0, &issuer_guard, &options.0, fail_fast.into()) + .wasm_result() + .map(WasmDecodedJwtCredential) + } + + /// Decode and verify the JWS signature of a `Credential` issued as an SD-JWT using the DID Document of a trusted + /// issuer and replaces the disclosures. + /// + /// A `DecodedJwtCredential` is returned upon success. + /// + /// # Warning + /// The caller must ensure that the DID Documents of the trusted issuers are up-to-date. + /// + /// ## Proofs + /// Only the JWS signature is verified. If the `Credential` contains a `proof` property this will not be verified + /// by this method. + /// + /// # Errors + /// * If the issuer' URL cannot be parsed. + /// * If Signature verification fails. + /// * If SD decoding fails. + #[wasm_bindgen(js_name = verifySignature)] + #[allow(non_snake_case)] + pub fn verify_signature( + &self, + credential: &WasmSdJwt, + trustedIssuers: &ArrayIToCoreDocument, + options: &WasmJwsVerificationOptions, + ) -> Result { + let issuer_locks: Vec = trustedIssuers.into(); + let trusted_issuers: Vec> = issuer_locks + .iter() + .map(ImportedDocumentLock::try_read) + .collect::>>>( + )?; + + self + .0 + .verify_signature(&credential.0, &trusted_issuers, &options.0) + .wasm_result() + .map(WasmDecodedJwtCredential) + } + + /// Validates a Key Binding JWT (KB-JWT) according to `https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-07.html#name-key-binding-jwt`. + /// The Validation process includes: + /// * Signature validation using public key materials defined in the `holder` document. + /// * `typ` value in KB-JWT header. + /// * `sd_hash` claim value in the KB-JWT claim. + /// * Optional `nonce`, `aud` and issuance date validation. + #[wasm_bindgen(js_name = validateKeyBindingJwt)] + #[allow(non_snake_case)] + pub fn validate_key_binding_jwt( + &self, + sdJwt: &WasmSdJwt, + holder: &IToCoreDocument, + options: &WasmKeyBindingJWTValidationOptions, + ) -> Result { + let holder_lock = ImportedDocumentLock::from(holder); + let holder_guard = holder_lock.try_read()?; + let claims: KeyBindingJwtClaims = self + .0 + .validate_key_binding_jwt(&sdJwt.0, &holder_guard, &options.0) + .wasm_result()?; + Ok(WasmKeyBindingJwtClaims(claims)) + } +} diff --git a/bindings/wasm/src/error.rs b/bindings/wasm/src/error.rs index aa13d289c0..44fc571946 100644 --- a/bindings/wasm/src/error.rs +++ b/bindings/wasm/src/error.rs @@ -104,7 +104,9 @@ impl_wasm_error_from!( identity_iota::credential::JwtValidationError, identity_iota::credential::RevocationError, identity_iota::verification::Error, - identity_iota::credential::DomainLinkageValidationError + identity_iota::credential::DomainLinkageValidationError, + identity_iota::sd_jwt_payload::Error, + identity_iota::credential::KeyBindingJwtError ); // Similar to `impl_wasm_error_from`, but uses the types name instead of requiring/calling Into &'static str diff --git a/bindings/wasm/src/iota/iota_document.rs b/bindings/wasm/src/iota/iota_document.rs index e01c96a90b..8f8cbe6823 100644 --- a/bindings/wasm/src/iota/iota_document.rs +++ b/bindings/wasm/src/iota/iota_document.rs @@ -704,7 +704,39 @@ impl WasmIotaDocument { /// /// Upon success a string representing a JWS encoded according to the Compact JWS Serialization format is returned. /// See [RFC7515 section 3.1](https://www.rfc-editor.org/rfc/rfc7515#section-3.1). + /// + /// @deprecated Use `createJws()` instead. + #[deprecated] #[wasm_bindgen(js_name = createJwt)] + pub fn create_jwt( + &self, + storage: &WasmStorage, + fragment: String, + payload: String, + options: &WasmJwsSignatureOptions, + ) -> Result { + let storage_clone: Rc = storage.0.clone(); + let options_clone: JwsSignatureOptions = options.0.clone(); + let document_lock_clone: Rc = self.0.clone(); + let promise: Promise = future_to_promise(async move { + document_lock_clone + .read() + .await + .create_jws(&storage_clone, &fragment, payload.as_bytes(), &options_clone) + .await + .wasm_result() + .map(WasmJws::new) + .map(JsValue::from) + }); + Ok(promise.unchecked_into()) + } + + /// Sign the `payload` according to `options` with the storage backed private key corresponding to the public key + /// material in the verification method identified by the given `fragment. + /// + /// Upon success a string representing a JWS encoded according to the Compact JWS Serialization format is returned. + /// See [RFC7515 section 3.1](https://www.rfc-editor.org/rfc/rfc7515#section-3.1). + #[wasm_bindgen(js_name = createJws)] pub fn create_jws( &self, storage: &WasmStorage, diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 6cb051ed8a..208edca0d0 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -26,6 +26,7 @@ pub mod iota; pub mod jose; pub mod resolver; pub mod revocation; +pub mod sd_jwt; pub mod storage; pub mod verification; diff --git a/bindings/wasm/src/sd_jwt/decoder.rs b/bindings/wasm/src/sd_jwt/decoder.rs new file mode 100644 index 0000000000..0df8100506 --- /dev/null +++ b/bindings/wasm/src/sd_jwt/decoder.rs @@ -0,0 +1,43 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use crate::common::ArrayString; +use crate::common::RecordStringAny; +use crate::error::Result; +use crate::error::WasmResult; +use identity_iota::sd_jwt_payload::SdObjectDecoder; +use serde_json::Map; +use serde_json::Value; +use wasm_bindgen::prelude::*; + +/// Substitutes digests in an SD-JWT object by their corresponding plaintext values provided by disclosures. +#[wasm_bindgen(js_name = SdObjectDecoder, inspectable)] +pub struct WasmSdObjectDecoder(pub(crate) SdObjectDecoder); + +#[wasm_bindgen(js_class = SdObjectDecoder)] +#[allow(clippy::new_without_default)] +impl WasmSdObjectDecoder { + /// Creates a new `SdObjectDecoder` with `sha-256` hasher. + #[wasm_bindgen(constructor)] + pub fn new() -> WasmSdObjectDecoder { + Self(SdObjectDecoder::new_with_sha256()) + } + + /// Decodes an SD-JWT `object` containing by Substituting the digests with their corresponding + /// plaintext values provided by `disclosures`. + /// + /// ## Notes + /// * Claims like `exp` or `iat` are not validated in the process of decoding. + /// * `_sd_alg` property will be removed if present. + #[wasm_bindgen] + pub fn decode(&self, object: RecordStringAny, disclosures: ArrayString) -> Result { + let object: Map = object.into_serde().wasm_result()?; + let disclosures: Vec = disclosures.into_serde().wasm_result()?; + let decoded = self.0.decode(&object, &disclosures).wasm_result()?; + Ok( + JsValue::from_serde(&decoded) + .wasm_result()? + .unchecked_into::(), + ) + } +} diff --git a/bindings/wasm/src/sd_jwt/disclosure.rs b/bindings/wasm/src/sd_jwt/disclosure.rs new file mode 100644 index 0000000000..d5313976e0 --- /dev/null +++ b/bindings/wasm/src/sd_jwt/disclosure.rs @@ -0,0 +1,74 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use crate::error::Result; +use crate::error::WasmResult; +use identity_iota::sd_jwt_payload::Disclosure; +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsValue; + +/// Represents an elements constructing a disclosure. +/// Object properties and array elements disclosures are supported. +/// +/// See: https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-07.html#name-disclosures +#[wasm_bindgen(js_name = Disclosure, inspectable)] +pub struct WasmDisclosure(pub(crate) Disclosure); + +#[wasm_bindgen(js_class = Disclosure)] +impl WasmDisclosure { + #[wasm_bindgen(constructor)] + pub fn new(salt: String, claim_name: Option, claim_value: JsValue) -> Result { + Ok(Self(Disclosure::new( + salt, + claim_name, + claim_value.into_serde().wasm_result()?, + ))) + } + + /// Parses a Base64 encoded disclosure into a `Disclosure`. + /// + /// ## Error + /// + /// Returns an `InvalidDisclosure` if input is not a valid disclosure. + #[wasm_bindgen] + pub fn parse(disclosure: String) -> Result { + Ok(WasmDisclosure(Disclosure::parse(disclosure).wasm_result()?)) + } + + /// Returns a copy of the base64url-encoded string. + #[wasm_bindgen(js_name = disclosure)] + pub fn disclosure(&self) -> String { + self.0.disclosure.clone() + } + + /// Returns a copy of the base64url-encoded string. + #[wasm_bindgen(js_name = toEncodedString)] + pub fn to_encoded_string(&self) -> String { + self.0.disclosure.clone() + } + + /// Returns a copy of the base64url-encoded string. + #[wasm_bindgen(js_name = toString)] + pub fn to_string_clone(&self) -> String { + self.0.disclosure.clone() + } + + /// Returns a copy of the salt value. + #[wasm_bindgen(js_name = salt)] + pub fn salt(&self) -> String { + self.0.salt.clone() + } + + /// Returns a copy of the claim name, optional for array elements. + #[wasm_bindgen(js_name = claimName)] + pub fn claim_name(&self) -> Option { + self.0.claim_name.clone() + } + + /// Returns a copy of the claim Value which can be of any type. + #[wasm_bindgen(js_name = claimValue)] + pub fn claim_value(&self) -> Result { + JsValue::from_serde(&self.0.claim_value.clone()).wasm_result() + } +} +impl_wasm_json!(WasmDisclosure, Disclosure); diff --git a/bindings/wasm/src/sd_jwt/encoder.rs b/bindings/wasm/src/sd_jwt/encoder.rs new file mode 100644 index 0000000000..79ad041dc1 --- /dev/null +++ b/bindings/wasm/src/sd_jwt/encoder.rs @@ -0,0 +1,131 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use super::disclosure::WasmDisclosure; +use crate::common::ArrayString; +use crate::common::RecordStringAny; +use crate::error::Result; +use crate::error::WasmResult; +use identity_iota::sd_jwt_payload::SdObjectEncoder; +use identity_iota::sd_jwt_payload::Sha256Hasher; +use js_sys::Array; +use js_sys::JsString; +use serde_json::Value; +use wasm_bindgen::prelude::*; + +/// Transforms a JSON object into an SD-JWT object by substituting selected values +/// with their corresponding disclosure digests. +/// +/// Note: digests are created using the sha-256 algorithm. +#[wasm_bindgen(js_name = SdObjectEncoder, inspectable)] +pub struct WasmSdObjectEncoder(pub(crate) SdObjectEncoder); + +#[wasm_bindgen(js_class = SdObjectEncoder)] +impl WasmSdObjectEncoder { + /// Creates a new `SdObjectEncoder` with `sha-256` hash function. + #[wasm_bindgen(constructor)] + pub fn new(object: &JsValue) -> Result { + let object: Value = object.into_serde().wasm_result()?; + Ok(Self(SdObjectEncoder::try_from_serializable(object).wasm_result()?)) + } + + /// Substitutes a value with the digest of its disclosure. + /// If no salt is provided, the disclosure will be created with a random salt value. + /// + /// The value of the key specified in `path` will be concealed. E.g. for path + /// `["claim", "subclaim"]` the value of `claim.subclaim` will be concealed. + /// + /// ## Error + /// `InvalidPath` if path is invalid or the path slice is empty. + /// `DataTypeMismatch` if existing SD format is invalid. + /// + /// ## Note + /// Use `concealArrayEntry` for values in arrays. + #[wasm_bindgen(js_name = conceal)] + pub fn conceal(&mut self, path: ArrayString, salt: Option) -> Result { + let path: Vec = path + .dyn_into::()? + .iter() + .map(|item| item.dyn_into::().map(String::from)) + .collect::>>()?; + let path: Vec<&str> = path.iter().map(|s| &**s).collect(); + let disclosure = self.0.conceal(&path, salt).wasm_result()?; + Ok(WasmDisclosure(disclosure)) + } + + /// Substitutes a value within an array with the digest of its disclosure. + /// If no salt is provided, the disclosure will be created with random salt value. + /// + /// `path` is used to specify the array in the object, while `element_index` specifies + /// the index of the element to be concealed (index start at 0). + /// + /// ## Error + /// `InvalidPath` if path is invalid or the path slice is empty. + /// `DataTypeMismatch` if existing SD format is invalid. + /// `IndexOutofBounds` if `element_index` is out of bounds. + #[wasm_bindgen(js_name = concealArrayEntry)] + pub fn conceal_array_entry( + &mut self, + path: ArrayString, + element_index: usize, + salt: Option, + ) -> Result { + let path: Vec = path + .dyn_into::()? + .iter() + .map(|item| item.dyn_into::().map(String::from)) + .collect::>>()?; + let path: Vec<&str> = path.iter().map(|s| &**s).collect(); + let disclosure = self.0.conceal_array_entry(&path, element_index, salt).wasm_result()?; + Ok(WasmDisclosure(disclosure)) + } + + /// Adds the `_sd_alg` property to the top level of the object, with + /// its value set to "sha-256". + #[wasm_bindgen(js_name = addSdAlgProperty)] + pub fn add_sd_alg_property(&mut self) { + self.0.add_sd_alg_property(); + } + + /// Returns the modified object as a string. + #[wasm_bindgen(js_name = encodeToString)] + pub fn encoded_to_string(&self) -> Result { + self.0.try_to_string().wasm_result() + } + + /// Returns the modified object as a string. + #[wasm_bindgen(js_name = toString)] + pub fn to_string(&self) -> Result { + self.0.try_to_string().wasm_result() + } + + /// Returns the modified object. + #[wasm_bindgen(js_name = encodeToObject)] + pub fn encode_to_object(&self) -> Result { + Ok( + JsValue::from_serde(&self.0.object()) + .wasm_result()? + .unchecked_into::(), + ) + } + + /// Returns the modified object. + #[wasm_bindgen(js_name = toJSON)] + pub fn to_json(&self) -> Result { + JsValue::from_serde(&self.0.object()).wasm_result() + } + + /// Adds a decoy digest to the specified path. + /// If path is an empty slice, decoys will be added to the top level. + #[wasm_bindgen(js_name = addDecoys)] + pub fn add_decoys(&mut self, path: ArrayString, number_of_decoys: usize) -> Result<()> { + let path: Vec = path + .dyn_into::()? + .iter() + .map(|item| item.dyn_into::().map(String::from)) + .collect::>>()?; + let path: Vec<&str> = path.iter().map(|s| &**s).collect(); + self.0.add_decoys(&path, number_of_decoys).wasm_result()?; + Ok(()) + } +} diff --git a/bindings/wasm/src/sd_jwt/key_binding_jwt_claims.rs b/bindings/wasm/src/sd_jwt/key_binding_jwt_claims.rs new file mode 100644 index 0000000000..dca5c1a8c1 --- /dev/null +++ b/bindings/wasm/src/sd_jwt/key_binding_jwt_claims.rs @@ -0,0 +1,110 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use crate::common::ArrayString; +use crate::common::RecordStringAny; +use crate::common::WasmTimestamp; +use crate::error::Result; +use crate::error::WasmResult; +use identity_iota::core::Timestamp; +use identity_iota::core::ToJson; +use identity_iota::sd_jwt_payload::KeyBindingJwtClaims; +use identity_iota::sd_jwt_payload::Sha256Hasher; +use js_sys::Array; +use js_sys::JsString; +use serde_json::Value; +use std::collections::BTreeMap; +use wasm_bindgen::prelude::*; + +/// Claims set for key binding JWT. +#[wasm_bindgen(js_name = KeyBindingJwtClaims, inspectable)] +pub struct WasmKeyBindingJwtClaims(pub(crate) KeyBindingJwtClaims); + +#[wasm_bindgen(js_class = KeyBindingJwtClaims)] +impl WasmKeyBindingJwtClaims { + /// Creates a new [`KeyBindingJwtClaims`]. + /// When `issued_at` is left as None, it will automatically default to the current time. + /// + /// # Error + /// When `issued_at` is set to `None` and the system returns time earlier than `SystemTime::UNIX_EPOCH`. + #[wasm_bindgen(constructor)] + pub fn new( + jwt: String, + disclosures: ArrayString, + nonce: String, + aud: String, + issued_at: Option, + custom_properties: Option, + ) -> Result { + let disclosures: Vec = disclosures + .dyn_into::()? + .iter() + .map(|item| item.dyn_into::().map(String::from)) + .collect::>>()?; + let mut claims = KeyBindingJwtClaims::new( + &Sha256Hasher::new(), + jwt, + disclosures, + nonce, + aud, + issued_at + .map(|value| value.0.to_unix()) + .unwrap_or(Timestamp::now_utc().to_unix()), + ); + if let Some(custom_properties) = custom_properties { + let custom_properties: BTreeMap = custom_properties.into_serde().wasm_result()?; + claims.properties = custom_properties + } + Ok(WasmKeyBindingJwtClaims(claims)) + } + + /// Returns a string representation of the claims. + #[wasm_bindgen(js_name = toString)] + pub fn to_string(&self) -> Result { + self.0.to_json().wasm_result() + } + + /// Returns a copy of the issued at `iat` property. + #[wasm_bindgen] + pub fn iat(&self) -> i64 { + self.0.iat + } + + /// Returns a copy of the audience `aud` property. + #[wasm_bindgen] + pub fn aud(&self) -> String { + self.0.aud.clone() + } + + /// Returns a copy of the `nonce` property. + #[wasm_bindgen] + pub fn nonce(&self) -> String { + self.0.nonce.clone() + } + + /// Returns a copy of the `sd_hash` property. + #[wasm_bindgen(js_name = sdHash)] + pub fn sd_hash(&self) -> String { + self.0.sd_hash.clone() + } + + /// Returns a copy of the custom properties. + #[wasm_bindgen(js_name = customProperties)] + pub fn custom_properties(&self) -> Result { + Ok( + JsValue::from_serde(&self.0.properties.clone()) + .wasm_result()? + .unchecked_into::(), + ) + } + + /// Returns the value of the `typ` property of the JWT header according to + /// https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-07.html#name-key-binding-jwt + #[wasm_bindgen(js_name = keyBindingJwtHeaderTyp)] + pub fn header_type() -> String { + KeyBindingJwtClaims::KB_JWT_HEADER_TYP.to_string() + } +} + +impl_wasm_json!(WasmKeyBindingJwtClaims, KeyBindingJwtClaims); +impl_wasm_clone!(WasmKeyBindingJwtClaims, KeyBindingJwtClaims); diff --git a/bindings/wasm/src/sd_jwt/mod.rs b/bindings/wasm/src/sd_jwt/mod.rs new file mode 100644 index 0000000000..f9c9d9db49 --- /dev/null +++ b/bindings/wasm/src/sd_jwt/mod.rs @@ -0,0 +1,14 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +mod decoder; +mod disclosure; +mod encoder; +mod key_binding_jwt_claims; +mod wasm_sd_jwt; + +pub use decoder::*; +pub use disclosure::*; +pub use encoder::*; +pub use key_binding_jwt_claims::*; +pub use wasm_sd_jwt::*; diff --git a/bindings/wasm/src/sd_jwt/wasm_sd_jwt.rs b/bindings/wasm/src/sd_jwt/wasm_sd_jwt.rs new file mode 100644 index 0000000000..7b4f201206 --- /dev/null +++ b/bindings/wasm/src/sd_jwt/wasm_sd_jwt.rs @@ -0,0 +1,81 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use crate::common::ArrayString; +use crate::error::Result; +use crate::error::WasmResult; +use identity_iota::sd_jwt_payload::SdJwt; +use js_sys::Array; +use js_sys::JsString; +use wasm_bindgen::prelude::*; + +/// Representation of an SD-JWT of the format +/// `~~~...~~`. +#[wasm_bindgen(js_name = SdJwt, inspectable)] +pub struct WasmSdJwt(pub(crate) SdJwt); + +#[wasm_bindgen(js_class = SdJwt)] +impl WasmSdJwt { + /// Creates a new `SdJwt` from its components. + #[wasm_bindgen(constructor)] + pub fn new(jwt: String, disclosures: ArrayString, key_binding_jwt: Option) -> Result { + let disclosures: Vec = disclosures + .dyn_into::()? + .iter() + .map(|item| item.dyn_into::().map(String::from)) + .collect::>>()?; + let sd_jwt = SdJwt::new(jwt, disclosures, key_binding_jwt); + Ok(WasmSdJwt(sd_jwt)) + } + + /// Serializes the components into the final SD-JWT. + #[wasm_bindgen] + pub fn presentation(&self) -> String { + self.0.presentation() + } + + /// Parses an SD-JWT into its components as [`SdJwt`]. + /// + /// ## Error + /// Returns `DeserializationError` if parsing fails. + #[wasm_bindgen] + pub fn parse(sd_jwt: String) -> Result { + let sd_jwt = SdJwt::parse(&sd_jwt).wasm_result()?; + Ok(WasmSdJwt(sd_jwt)) + } + + /// Serializes the components into the final SD-JWT. + #[wasm_bindgen(js_name = toString)] + pub fn to_string_clone(&self) -> String { + self.0.presentation() + } + + /// The JWT part. + #[wasm_bindgen] + pub fn jwt(&self) -> String { + self.0.jwt.clone() + } + + /// The disclosures part. + #[wasm_bindgen] + pub fn disclosures(&self) -> ArrayString { + self + .0 + .disclosures + .clone() + .iter() + .map(|url| url.to_string()) + .map(JsValue::from) + .collect::() + .unchecked_into::() + } + + /// The optional key binding JWT. + #[wasm_bindgen(js_name = keyBindingJwt)] + pub fn key_binding_jwt(&self) -> Option { + self.0.key_binding_jwt.clone() + } +} + +impl_wasm_json!(WasmSdJwt, SdJwt); +impl_wasm_clone!(WasmSdJwt, SdJwt); diff --git a/bindings/wasm/tests/sd_jwt.ts b/bindings/wasm/tests/sd_jwt.ts new file mode 100644 index 0000000000..3c471fab6d --- /dev/null +++ b/bindings/wasm/tests/sd_jwt.ts @@ -0,0 +1,54 @@ +import * as assert from "assert"; +import { SdJwt, SdObjectDecoder, SdObjectEncoder } from "../node"; + +describe("sd-jwt-payload", function() { + describe("#encoder", function() { + it("should work", async () => { + let obj = { + "sub": "user_42", + "given_name": "John", + "family_name": "Doe", + "email": "johndoe@example.com", + "phone_number": "+1-202-555-0101", + "phone_number_verified": true, + "address": { + "street_address": "123 Main St", + "locality": "Anytown", + "region": "Anystate", + "country": "US", + }, + "birthdate": "1940-01-01", + "updated_at": 1570000000, + "nationalities": [ + "US", + "DE", + ], + }; + + let encoder = new SdObjectEncoder(obj); + let emailDisclosure = encoder.conceal(["email"], "tstsalt"); + console.log(emailDisclosure); + assert.deepStrictEqual(emailDisclosure.claimName(), "email"); + assert.deepStrictEqual(emailDisclosure.claimValue(), "johndoe@example.com"); + assert.deepStrictEqual(emailDisclosure.salt(), "tstsalt"); + assert.deepStrictEqual( + emailDisclosure.disclosure(), + "WyJ0c3RzYWx0IiwgImVtYWlsIiwgImpvaG5kb2VAZXhhbXBsZS5jb20iXQ", + ); + + let disclosures = [ + emailDisclosure.toEncodedString(), + encoder.conceal(["address", "street_address"]).toEncodedString(), + encoder.concealArrayEntry(["nationalities"], 0).toEncodedString(), + ]; + encoder.addSdAlgProperty(); + encoder.addDecoys([], 3); + let encoded = encoder.encodeToObject(); + assert.equal(encoded._sd.length, 4); + + let decoder = new SdObjectDecoder(); + let decoded = decoder.decode(encoded, disclosures); + assert.deepStrictEqual(obj, decoded); + }); + }); +}); diff --git a/examples/0_basic/5_create_vc.rs b/examples/0_basic/5_create_vc.rs index 1da4ce3bcc..3a14e262e2 100644 --- a/examples/0_basic/5_create_vc.rs +++ b/examples/0_basic/5_create_vc.rs @@ -7,7 +7,7 @@ //! This Verifiable Credential can be verified by anyone, allowing Alice to take control of it and share it with //! whomever they please. //! -//! cargo run --example 5_create_vc +//! cargo run --release --example 5_create_vc use examples::create_did; use examples::MemStorage; diff --git a/examples/0_basic/6_create_vp.rs b/examples/0_basic/6_create_vp.rs index 98174b54b2..8c157295ef 100644 --- a/examples/0_basic/6_create_vp.rs +++ b/examples/0_basic/6_create_vp.rs @@ -5,7 +5,7 @@ //! A Verifiable Presentation is the format in which a (collection of) Verifiable Credential(s) gets shared. //! It is signed by the subject, to prove control over the Verifiable Credential with a nonce or timestamp. //! -//! cargo run --example 6_create_vp +//! cargo run --release --example 6_create_vp use std::collections::HashMap; diff --git a/examples/0_basic/7_revoke_vc.rs b/examples/0_basic/7_revoke_vc.rs index eee2ba6b4b..48d947a7ff 100644 --- a/examples/0_basic/7_revoke_vc.rs +++ b/examples/0_basic/7_revoke_vc.rs @@ -8,7 +8,7 @@ //! //! Note: make sure `API_ENDPOINT` and `FAUCET_ENDPOINT` are set to the correct network endpoints. //! -//! cargo run --example 7_revoke_vc +//! cargo run --release --example 7_revoke_vc use anyhow::anyhow; use examples::create_did; diff --git a/examples/1_advanced/6_domain_linkage.rs b/examples/1_advanced/6_domain_linkage.rs index a77bc3c5b0..6e7a629110 100644 --- a/examples/1_advanced/6_domain_linkage.rs +++ b/examples/1_advanced/6_domain_linkage.rs @@ -180,12 +180,12 @@ async fn main() -> anyhow::Result<()> { // Get the domains included in the Linked Domain Service. let domains: &[Url] = linked_domain_services - .get(0) + .first() .ok_or_else(|| anyhow::anyhow!("expected a domain"))? .domains(); let domain_foo: Url = domains - .get(0) + .first() .ok_or_else(|| anyhow::anyhow!("expected a domain"))? .clone(); assert_eq!(domain_foo, domain_1); diff --git a/examples/1_advanced/7_sd_jwt.rs b/examples/1_advanced/7_sd_jwt.rs new file mode 100644 index 0000000000..90fac13307 --- /dev/null +++ b/examples/1_advanced/7_sd_jwt.rs @@ -0,0 +1,227 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! This example shows how to create a selective disclosure verifiable credential and validate it +//! using the standard [Selective Disclosure for JWTs (SD-JWT)](https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-07.html). +//! +//! cargo run --release --example 7_sd_jwt + +use examples::create_did; +use examples::pretty_print_json; +use examples::random_stronghold_path; +use examples::MemStorage; +use examples::API_ENDPOINT; +use identity_eddsa_verifier::EdDSAJwsVerifier; +use identity_iota::core::json; +use identity_iota::core::FromJson; +use identity_iota::core::Object; +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::FailFast; +use identity_iota::credential::Jws; +use identity_iota::credential::JwtCredentialValidationOptions; +use identity_iota::credential::KeyBindingJWTValidationOptions; +use identity_iota::credential::SdJwtCredentialValidator; +use identity_iota::credential::Subject; +use identity_iota::did::DID; +use identity_iota::iota::IotaDocument; +use identity_iota::storage::JwkDocumentExt; +use identity_iota::storage::JwkMemStore; +use identity_iota::storage::JwsSignatureOptions; +use identity_iota::storage::KeyIdMemstore; +use iota_sdk::client::secret::stronghold::StrongholdSecretManager; +use iota_sdk::client::secret::SecretManager; +use iota_sdk::client::Client; +use iota_sdk::client::Password; +use iota_sdk::types::block::address::Address; +use sd_jwt_payload::KeyBindingJwtClaims; +use sd_jwt_payload::SdJwt; +use sd_jwt_payload::SdObjectDecoder; +use sd_jwt_payload::SdObjectEncoder; +use sd_jwt_payload::Sha256Hasher; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // =========================================================================== + // Step 1: Create identities for the issuer and the holder. + // =========================================================================== + + // Create a new client to interact with the IOTA ledger. + let client: Client = Client::builder() + .with_primary_node(API_ENDPOINT, None)? + .finish() + .await?; + + // Create an identity for the issuer with one verification method `key-1`. + let mut secret_manager_issuer: SecretManager = SecretManager::Stronghold( + StrongholdSecretManager::builder() + .password(Password::from("secure_password_1".to_owned())) + .build(random_stronghold_path())?, + ); + let issuer_storage: MemStorage = MemStorage::new(JwkMemStore::new(), KeyIdMemstore::new()); + let (_, issuer_document, fragment): (Address, IotaDocument, String) = + create_did(&client, &mut secret_manager_issuer, &issuer_storage).await?; + + // Create an identity for the holder, in this case also the subject. + let mut secret_manager_alice: SecretManager = SecretManager::Stronghold( + StrongholdSecretManager::builder() + .password(Password::from("secure_password_2".to_owned())) + .build(random_stronghold_path())?, + ); + let alice_storage: MemStorage = MemStorage::new(JwkMemStore::new(), KeyIdMemstore::new()); + let (_, alice_document, alice_fragment): (Address, IotaDocument, String) = + create_did(&client, &mut secret_manager_alice, &alice_storage).await?; + + // =========================================================================== + // Step 2: Issuer creates and signs a selectively disclosable JWT verifiable credential. + // =========================================================================== + + // Create an address credential subject. + let subject: Subject = Subject::from_json_value(json!({ + "id": alice_document.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.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)?; + pretty_print_json("Claims set in plain text", &payload); + + // 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()?; + + pretty_print_json("Claims set with disclosure digests", &encoded_payload); + + // Create the signed JWT. + let jwt: Jws = issuer_document + .create_jws( + &issuer_storage, + &fragment, + encoded_payload.as_bytes(), + &JwsSignatureOptions::default(), + ) + .await?; + + // =========================================================================== + // Step 3: Issuer sends the JWT and the disclosures to the holder. + // =========================================================================== + + // 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(); + + // =========================================================================== + // Step 4: Verifier sends the holder a challenge and requests a signed Verifiable 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 = alice_document + .create_jws(&alice_storage, &alice_fragment, 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())); + + // =========================================================================== + // Step 6: Holder presents the SD-JWT to the verifier. + // =========================================================================== + + let sd_jwt_presentation: String = sd_jwt_obj.presentation(); + + // =========================================================================== + // Step 7: Verifier receives the SD-JWT and verifies it. + // =========================================================================== + + let sd_jwt = SdJwt::parse(&sd_jwt_presentation)?; + + // Verify the JWT. + let decoder = SdObjectDecoder::new_with_sha256(); + let validator = SdJwtCredentialValidator::with_signature_verifier(EdDSAJwsVerifier::default(), decoder); + let validation = validator + .validate_credential::<_, Object>( + &sd_jwt, + &issuer_document, + &JwtCredentialValidationOptions::default(), + FailFast::FirstError, + ) + .unwrap(); + + println!("JWT successfully validated"); + pretty_print_json("Decoded Credential", &validation.credential.to_string()); + + // Verify the Key Binding JWT. + let options = KeyBindingJWTValidationOptions::new().nonce(nonce).aud(VERIFIER_DID); + let _kb_validation = validator.validate_key_binding_jwt(&sd_jwt_obj, &alice_document, &options)?; + + println!("Key Binding JWT successfully validated"); + + Ok(()) +} diff --git a/examples/Cargo.toml b/examples/Cargo.toml index cc4cb21dc8..7651e67de9 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -13,6 +13,8 @@ identity_stronghold = { path = "../identity_stronghold", default-features = fals 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.1.2", default-features = false, features = ["sha"] } +serde_json = { version = "1.0", default-features = false } tokio = { version = "1.29", default-features = false, features = ["rt"] } [lib] @@ -81,3 +83,7 @@ name = "5_custom_resolution" [[example]] path = "1_advanced/6_domain_linkage.rs" name = "6_domain_linkage" + +[[example]] +path = "1_advanced/7_sd_jwt.rs" +name = "7_sd_jwt" diff --git a/examples/README.md b/examples/README.md index 84326e00d7..92ba330adf 100644 --- a/examples/README.md +++ b/examples/README.md @@ -13,9 +13,12 @@ cargo run --example For instance, to run the example `0_create_did`, use: ```rust -cargo run --example 0_create_did +cargo run --release --example 0_create_did ``` +### Note: Running the examples with the release flag will be significantly faster due to stronghold performance issues in debug mode. + + ## Basic Examples The following basic CRUD (Create, Read, Update, Delete) examples are available: @@ -44,3 +47,4 @@ The following advanced examples are available: | [4_alias_output_history](./1_advanced/4_alias_output_history.rs) | Demonstrates fetching the history of an Alias Output. | | [5_custom_resolution](./1_advanced/5_custom_resolution.rs) | Demonstrates how to set up a resolver using custom handlers. | | [6_domain_linkage](./1_advanced/6_domain_linkage) | Demonstrates how to link a domain and a DID and verify the linkage. | +| [7_sd_jwt](./1_advanced/7_sd_jwt) | Demonstrates how to create and verify selective disclosure verifiable credentials. | diff --git a/examples/utils/utils.rs b/examples/utils/utils.rs index 5965123beb..a9b8ca5154 100644 --- a/examples/utils/utils.rs +++ b/examples/utils/utils.rs @@ -26,6 +26,7 @@ use iota_sdk::types::block::address::Address; use iota_sdk::types::block::address::Bech32Address; use iota_sdk::types::block::address::Hrp; use rand::distributions::DistString; +use serde_json::Value; pub static API_ENDPOINT: &str = "http://localhost:14265"; pub static FAUCET_ENDPOINT: &str = "http://localhost:8091/api/enqueue"; @@ -175,3 +176,12 @@ pub fn random_stronghold_path() -> PathBuf { file.set_extension("stronghold"); file.to_owned() } + +pub fn pretty_print_json(label: &str, value: &str) { + let data: Value = serde_json::from_str(value).unwrap(); + let pretty_json = serde_json::to_string_pretty(&data).unwrap(); + println!("--------------------------------------"); + println!("{}:", label); + println!("--------------------------------------"); + println!("{} \n", pretty_json); +} diff --git a/identity_credential/Cargo.toml b/identity_credential/Cargo.toml index 59633f516a..332e695521 100644 --- a/identity_credential/Cargo.toml +++ b/identity_credential/Cargo.toml @@ -13,7 +13,7 @@ description = "An implementation of the Verifiable Credentials standard." [dependencies] dataurl = { version = "0.1.2", default-features = false, optional = true } -flate2 = { version = "1.0.23", default-features = false, features = ["rust_backend"], optional = true } +flate2 = { version = "1.0.28", default-features = false, features = ["rust_backend"], optional = true } futures = { version = "0.3", default-features = false, optional = true } identity_core = { version = "=1.0.0", path = "../identity_core", default-features = false } identity_did = { version = "=1.0.0", path = "../identity_did", default-features = false } @@ -24,18 +24,19 @@ itertools = { version = "0.11", default-features = false, features = ["use_std"] 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, optional = true } +sd-jwt-payload = { version = "0.1.2", default-features = false, features = ["sha"], optional = true } serde.workspace = true +serde_json.workspace = true serde_repr = { version = "0.1", default-features = false, optional = true } strum.workspace = true thiserror.workspace = true -url = { version = "2.4", default-features = false } +url = { version = "2.5", default-features = false } [dev-dependencies] identity_eddsa_verifier = { version = "=1.0.0", path = "../identity_eddsa_verifier", default-features = false, features = ["ed25519"] } iota-crypto = { version = "0.23", default-features = false, features = ["ed25519", "std", "random"] } -proptest = { version = "1.0.0", default-features = false, features = ["std"] } -serde_json.workspace = true -tokio = { version = "1.29.0", default-features = false, features = ["rt-multi-thread", "macros"] } +proptest = { version = "1.4.0", default-features = false, features = ["std"] } +tokio = { version = "1.35.0", default-features = false, features = ["rt-multi-thread", "macros"] } [package.metadata.docs.rs] # To build locally: @@ -44,10 +45,11 @@ all-features = true rustdoc-args = ["--cfg", "docsrs"] [features] -default = ["revocation-bitmap", "validator", "credential", "presentation", "domain-linkage-fetch"] +default = ["revocation-bitmap", "validator", "credential", "presentation", "domain-linkage-fetch", "sd-jwt"] credential = [] presentation = ["credential"] revocation-bitmap = ["dep:dataurl", "dep:flate2", "dep:roaring"] 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"] diff --git a/identity_credential/src/lib.rs b/identity_credential/src/lib.rs index 1901dd0ca5..3111b72e0a 100644 --- a/identity_credential/src/lib.rs +++ b/identity_credential/src/lib.rs @@ -29,3 +29,6 @@ pub mod validator; pub use error::Error; pub use error::Result; + +#[cfg(feature = "sd-jwt")] +pub use sd_jwt_payload; diff --git a/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator.rs b/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator.rs index e10009ba85..c099d763ab 100644 --- a/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator.rs +++ b/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator.rs @@ -68,9 +68,18 @@ impl JwtCredentialValidator { T: ToOwned + serde::Serialize + serde::de::DeserializeOwned, DOC: AsRef, { - Self::validate_extended::( - &self.0, - credential_jwt, + let credential_token = self + .verify_signature( + credential_jwt, + std::slice::from_ref(issuer.as_ref()), + &options.verification_options, + ) + .map_err(|err| CompoundCredentialValidationError { + validation_errors: [err].into(), + })?; + + Self::validate_decoded_credential::( + credential_token, std::slice::from_ref(issuer.as_ref()), options, fail_fast, @@ -109,27 +118,16 @@ impl JwtCredentialValidator { // This method takes a slice of issuer's instead of a single issuer in order to better accommodate presentation // validation. It also validates the relationship between a holder and the credential subjects when // `relationship_criterion` is Some. - pub(crate) fn validate_extended( - signature_verifier: &S, - credential: &Jwt, + pub(crate) fn validate_decoded_credential( + credential_token: DecodedJwtCredential, issuers: &[DOC], options: &JwtCredentialValidationOptions, fail_fast: FailFast, ) -> Result, CompoundCredentialValidationError> where T: ToOwned + serde::Serialize + serde::de::DeserializeOwned, - S: JwsVerifier, DOC: AsRef, { - // First verify the JWS signature and decode the result into a credential token, then apply all other validations. - // If this errors we have to return early regardless of the `fail_fast` flag as all other validations require a - // `&Credential`. - let credential_token = - Self::verify_signature_with_verifier(signature_verifier, credential, issuers, &options.verification_options) - .map_err(|err| CompoundCredentialValidationError { - validation_errors: [err].into(), - })?; - let credential: &Credential = &credential_token.credential; // Run all single concern Credential validations in turn and fail immediately if `fail_fast` is true. @@ -184,28 +182,18 @@ impl JwtCredentialValidator { } } - /// Stateless version of [`Self::verify_signature`] - fn verify_signature_with_verifier( - signature_verifier: &S, - credential: &Jwt, - trusted_issuers: &[DOC], + pub(crate) fn parse_jwk<'a, 'i, DOC>( + jws: &JwsValidationItem<'a>, + trusted_issuers: &'i [DOC], options: &JwsVerificationOptions, - ) -> Result, JwtValidationError> + ) -> Result<(&'a Jwk, DIDUrl), JwtValidationError> where - T: ToOwned + serde::Serialize + serde::de::DeserializeOwned, DOC: AsRef, - S: JwsVerifier, + 'i: 'a, { - // Note the below steps are necessary because `CoreDocument::verify_jws` decodes the JWS and then searches for a - // method with a fragment (or full DID Url) matching `kid` in the given document. We do not want to carry out - // that process for potentially every document in `trusted_issuers`. - - // Start decoding the credential - let decoded: JwsValidationItem<'_> = Self::decode(credential.as_str())?; - let nonce: Option<&str> = options.nonce.as_deref(); // Validate the nonce - if decoded.nonce() != nonce { + if jws.nonce() != nonce { return Err(JwtValidationError::JwsDecodingError( identity_verification::jose::error::Error::InvalidParam("invalid nonce value"), )); @@ -213,25 +201,26 @@ impl JwtCredentialValidator { // If no method_url is set, parse the `kid` to a DID Url which should be the identifier // of a verification method in a trusted issuer's DID document. - let method_id: DIDUrl = match &options.method_id { - Some(method_id) => method_id.clone(), - None => { - let kid: &str = decoded.protected_header().and_then(|header| header.kid()).ok_or( - JwtValidationError::MethodDataLookupError { - source: None, - message: "could not extract kid from protected header", + let method_id: DIDUrl = + match &options.method_id { + Some(method_id) => method_id.clone(), + None => { + let kid: &str = jws.protected_header().and_then(|header| header.kid()).ok_or( + JwtValidationError::MethodDataLookupError { + source: None, + message: "could not extract kid from protected header", + signer_ctx: SignerContext::Issuer, + }, + )?; + + // Convert kid to DIDUrl + DIDUrl::parse(kid).map_err(|err| JwtValidationError::MethodDataLookupError { + source: Some(err.into()), + message: "could not parse kid as a DID Url", signer_ctx: SignerContext::Issuer, - }, - )?; - - // Convert kid to DIDUrl - DIDUrl::parse(kid).map_err(|err| JwtValidationError::MethodDataLookupError { - source: Some(err.into()), - message: "could not parse kid as a DID Url", - signer_ctx: SignerContext::Issuer, - })? - } - }; + })? + } + }; // locate the corresponding issuer let issuer: &CoreDocument = trusted_issuers @@ -241,14 +230,36 @@ impl JwtCredentialValidator { .ok_or(JwtValidationError::DocumentMismatch(SignerContext::Issuer))?; // Obtain the public key from the issuer's DID document - let public_key: &Jwk = issuer + issuer .resolve_method(&method_id, options.method_scope) .and_then(|method| method.data().public_key_jwk()) .ok_or_else(|| JwtValidationError::MethodDataLookupError { source: None, message: "could not extract JWK from a method identified by kid", signer_ctx: SignerContext::Issuer, - })?; + }) + .map(move |jwk| (jwk, method_id)) + } + + /// Stateless version of [`Self::verify_signature`] + fn verify_signature_with_verifier( + signature_verifier: &S, + credential: &Jwt, + trusted_issuers: &[DOC], + options: &JwsVerificationOptions, + ) -> Result, JwtValidationError> + where + T: ToOwned + serde::Serialize + serde::de::DeserializeOwned, + DOC: AsRef, + S: JwsVerifier, + { + // Note the below steps are necessary because `CoreDocument::verify_jws` decodes the JWS and then searches for a + // method with a fragment (or full DID Url) matching `kid` in the given document. We do not want to carry out + // that process for potentially every document in `trusted_issuers`. + + // Start decoding the credential + let decoded: JwsValidationItem<'_> = Self::decode(credential.as_str())?; + let (public_key, method_id) = Self::parse_jwk(&decoded, trusted_issuers, options)?; let credential_token = Self::verify_decoded_signature(decoded, public_key, signature_verifier)?; @@ -264,7 +275,7 @@ impl JwtCredentialValidator { } /// Decode the credential into a [`JwsValidationItem`]. - fn decode(credential_jws: &str) -> Result, JwtValidationError> { + pub(crate) fn decode(credential_jws: &str) -> Result, JwtValidationError> { let decoder: Decoder = Decoder::new(); decoder @@ -272,6 +283,19 @@ impl JwtCredentialValidator { .map_err(JwtValidationError::JwsDecodingError) } + pub(crate) fn verify_signature_raw<'a, S: JwsVerifier>( + decoded: JwsValidationItem<'a>, + public_key: &Jwk, + signature_verifier: &S, + ) -> Result, JwtValidationError> { + decoded + .verify(signature_verifier, public_key) + .map_err(|err| JwtValidationError::Signature { + source: err, + signer_ctx: SignerContext::Issuer, + }) + } + /// Verify the signature using the given `public_key` and `signature_verifier`. fn verify_decoded_signature( decoded: JwsValidationItem<'_>, @@ -282,15 +306,8 @@ impl JwtCredentialValidator { T: ToOwned + serde::Serialize + serde::de::DeserializeOwned, { // Verify the JWS signature and obtain the decoded token containing the protected header and raw claims - let DecodedJws { protected, claims, .. } = - decoded - .verify(signature_verifier, public_key) - .map_err(|err| JwtValidationError::Signature { - source: err, - signer_ctx: SignerContext::Issuer, - })?; - - // Deserialize the raw claims + let DecodedJws { protected, claims, .. } = Self::verify_signature_raw(decoded, public_key, signature_verifier)?; + let credential_claims: CredentialJwtClaims<'_, T> = CredentialJwtClaims::from_json_slice(&claims).map_err(|err| { JwtValidationError::CredentialStructure(crate::Error::JwtClaimsSetDeserializationError(err.into())) diff --git a/identity_credential/src/validator/mod.rs b/identity_credential/src/validator/mod.rs index c630f6c2ed..37611334c3 100644 --- a/identity_credential/src/validator/mod.rs +++ b/identity_credential/src/validator/mod.rs @@ -8,9 +8,13 @@ pub use self::jwt_presentation_validation::*; pub use self::options::FailFast; pub use self::options::StatusCheck; pub use self::options::SubjectHolderRelationship; +#[cfg(feature = "sd-jwt")] +pub use self::sd_jwt::*; mod jwt_credential_validation; mod jwt_presentation_validation; mod options; +#[cfg(feature = "sd-jwt")] +mod sd_jwt; #[cfg(test)] pub(crate) mod test_utils; diff --git a/identity_credential/src/validator/sd_jwt/error.rs b/identity_credential/src/validator/sd_jwt/error.rs new file mode 100644 index 0000000000..1e9940bb3b --- /dev/null +++ b/identity_credential/src/validator/sd_jwt/error.rs @@ -0,0 +1,45 @@ +// Copyright 2020-2023 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use crate::validator::JwtValidationError; + +/// An error associated with validating KB-JWT. +#[derive(Debug, thiserror::Error, strum::IntoStaticStr)] +#[non_exhaustive] +pub enum KeyBindingJwtError { + /// Invalid key binding JWT. + #[error("KB-JWT is invalid")] + JwtValidationError(#[from] JwtValidationError), + + /// Deserialization failed. + #[error("Deserialization error")] + DeserializationError(String), + + /// Error from `sd_jwt_payload`. + #[error("SdJwt Error {0}")] + SdJwtError(#[from] sd_jwt_payload::Error), + + /// Invalid hash value. + #[error("the `_sd_hash` value of the KB-JWT does not match the derived value from the provided SD-JWT")] + InvalidDigest, + + /// Invalid nonce value. + #[error("provided nonce does not match the KB-JWT nonce claim")] + InvalidNonce, + + /// Invalid `aud` value. + #[error("provided audiance value does not match the KB-JWT `aud` claim")] + AudianceMismatch, + + /// Issuance date validation error. + #[error("KB-JWT `iat` value is invalid, {0}")] + IssuanceDate(String), + + /// SD-JWT does not contain a key binding JWT. + #[error("the provided SD-JWT does not include a KB-JWT")] + MissingKeyBindingJwt, + + /// Header value `typ` is invalid. + #[error("header `typ` value is missing or not equal to `kb+jwt`")] + InvalidHeaderTypValue, +} diff --git a/identity_credential/src/validator/sd_jwt/kb_validation_options.rs b/identity_credential/src/validator/sd_jwt/kb_validation_options.rs new file mode 100644 index 0000000000..a0a6866b5f --- /dev/null +++ b/identity_credential/src/validator/sd_jwt/kb_validation_options.rs @@ -0,0 +1,71 @@ +// Copyright 2020-2023 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use identity_core::common::Timestamp; +use identity_document::verifiable::JwsVerificationOptions; +use serde::Deserialize; +use serde::Serialize; + +/// Criteria for validating a Key Binding JWT (KB-JWT). +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct KeyBindingJWTValidationOptions { + /// Validates the nonce value of the KB-JWT claims. + #[serde(skip_serializing_if = "Option::is_none")] + pub nonce: Option, + /// Validates the `aud` properties in the KB-JWT claims. + #[serde(skip_serializing_if = "Option::is_none")] + pub aud: Option, + /// Options which affect the verification of the signature on the KB-JWT. + pub jws_options: JwsVerificationOptions, + /// Declares that the KB-JWT is considered invalid if the `iat` value in the claims is + /// earlier than this timestamp. + #[serde(skip_serializing_if = "Option::is_none")] + pub earliest_issuance_date: Option, + /// Declares that the KB-JWT is considered invalid if the `iat` value in the claims is + /// later than this timestamp. + /// Uses the current timestamp during validation if not set. + #[serde(skip_serializing_if = "Option::is_none")] + pub latest_issuance_date: Option, +} + +impl KeyBindingJWTValidationOptions { + /// Constructor that sets all options to their defaults. + pub fn new() -> Self { + Self::default() + } + + /// Validates the nonce value of the KB-JWT claims. + pub fn nonce(mut self, nonce: impl Into) -> Self { + self.nonce = Some(nonce.into()); + self + } + + /// Set options which affect the verification of the signature on the KB-JWT. + pub fn jws_verifier_options(mut self, options: JwsVerificationOptions) -> Self { + self.jws_options = options; + self + } + + /// Sets the `aud` property for verification. + pub fn aud(mut self, aud: impl Into) -> Self { + self.aud = Some(aud.into()); + self + } + + /// Declares that the KB-JWT is considered invalid if the `iat` value in the claims is + /// earlier than this timestamp. + pub fn earliest_issuance_date(mut self, earliest_issuance_date: Timestamp) -> Self { + self.earliest_issuance_date = Some(earliest_issuance_date); + self + } + + /// Declares that the KB-JWT is considered invalid if the `iat` value in the claims is + /// later than this timestamp. + /// Uses the current timestamp during validation if not set. + pub fn latest_issuance_date(mut self, latest_issuance_date: Timestamp) -> Self { + self.latest_issuance_date = Some(latest_issuance_date); + self + } +} diff --git a/identity_credential/src/validator/sd_jwt/mod.rs b/identity_credential/src/validator/sd_jwt/mod.rs new file mode 100644 index 0000000000..ac0d72d0d4 --- /dev/null +++ b/identity_credential/src/validator/sd_jwt/mod.rs @@ -0,0 +1,10 @@ +// Copyright 2020-2023 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +mod error; +mod kb_validation_options; +mod validator; + +pub use error::*; +pub use kb_validation_options::*; +pub use validator::*; diff --git a/identity_credential/src/validator/sd_jwt/validator.rs b/identity_credential/src/validator/sd_jwt/validator.rs new file mode 100644 index 0000000000..0eedf13bf5 --- /dev/null +++ b/identity_credential/src/validator/sd_jwt/validator.rs @@ -0,0 +1,294 @@ +// Copyright 2020-2023 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use crate::credential::CredentialJwtClaims; +use crate::validator::CompoundCredentialValidationError; +use crate::validator::DecodedJwtCredential; +use crate::validator::FailFast; +use crate::validator::JwtCredentialValidationOptions; +use crate::validator::JwtCredentialValidator; +use crate::validator::JwtCredentialValidatorUtils; +use crate::validator::JwtValidationError; +use crate::validator::SignerContext; +use identity_core::common::Timestamp; +use identity_core::convert::FromJson; +use identity_did::CoreDID; +use identity_did::DIDUrl; +use identity_document::document::CoreDocument; +use identity_document::verifiable::JwsVerificationOptions; +use identity_verification::jwk::Jwk; +use identity_verification::jws::DecodedJws; +use identity_verification::jws::Decoder; +use identity_verification::jws::JwsValidationItem; +use identity_verification::jws::JwsVerifier; +use itertools::Itertools; +use sd_jwt_payload::KeyBindingJwtClaims; +use sd_jwt_payload::SdJwt; +use sd_jwt_payload::SdObjectDecoder; +use serde_json::Value; + +use super::KeyBindingJWTValidationOptions; +use super::KeyBindingJwtError; + +/// A type for decoding and validating [`SdJwt`]s. +#[non_exhaustive] +pub struct SdJwtCredentialValidator(V, SdObjectDecoder); + +impl SdJwtCredentialValidator { + /// Creates a new [`SdJwtValidator`]that delegates cryptographic signature verification to the given + /// `signature_verifier`. + pub fn with_signature_verifier(signature_verifier: V, sd_decoder: SdObjectDecoder) -> Self { + Self(signature_verifier, sd_decoder) + } + + /// Decodes and validates a [`Credential`] issued as an SD-JWT. A [`DecodedJwtCredential`] is returned upon success. + /// The credential is constructed by replacing disclosures following the + /// [`Selective Disclosure for JWTs (SD-JWT)`](https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-07.html) standard. + /// + /// The following properties are validated according to `options`: + /// - the issuer's signature on the JWS, + /// - the expiration date, + /// - the issuance date, + /// - the semantic structure. + /// + /// # Warning + /// * The key binding JWT is not validated. If needed, it must be validated separately using + /// `SdJwtValidator::validate_key_binding_jwt`. + /// * The lack of an error returned from this method is in of itself not enough to conclude that the credential can be + /// trusted. This section contains more information on additional checks that should be carried out before and after + /// calling this method. + /// + /// ## The state of the issuer's DID Document + /// The caller must ensure that `issuer` represents an up-to-date DID Document. + /// + /// ## Properties that are not validated + /// There are many properties defined in [The Verifiable Credentials Data Model](https://www.w3.org/TR/vc-data-model/) that are **not** validated, such as: + /// `proof`, `credentialStatus`, `type`, `credentialSchema`, `refreshService` **and more**. + /// These should be manually checked after validation, according to your requirements. + /// + /// # Errors + /// An error is returned whenever a validated condition is not satisfied. + pub fn validate_credential( + &self, + sd_jwt: &SdJwt, + issuer: &DOC, + options: &JwtCredentialValidationOptions, + fail_fast: FailFast, + ) -> Result, CompoundCredentialValidationError> + where + T: ToOwned + serde::Serialize + serde::de::DeserializeOwned, + DOC: AsRef, + { + let issuers = std::slice::from_ref(issuer.as_ref()); + let credential = self + .verify_signature(sd_jwt, issuers, &options.verification_options) + .map_err(|err| CompoundCredentialValidationError { + validation_errors: [err].into(), + })?; + + JwtCredentialValidator::::validate_decoded_credential(credential, issuers, options, fail_fast) + } + + /// Decode and verify the JWS signature of a [`Credential`] issued as an SD-JWT using the DID Document of a trusted + /// issuer and replaces the disclosures. + /// + /// A [`DecodedJwtCredential`] is returned upon success. + /// + /// # Warning + /// The caller must ensure that the DID Documents of the trusted issuers are up-to-date. + /// + /// ## Proofs + /// Only the JWS signature is verified. If the [`Credential`] contains a `proof` property this will not be verified + /// by this method. + /// + /// # Errors + /// * If the issuer' URL cannot be parsed. + /// * If Signature verification fails. + /// * If SD decoding fails. + pub fn verify_signature( + &self, + credential: &SdJwt, + trusted_issuers: &[DOC], + options: &JwsVerificationOptions, + ) -> Result, JwtValidationError> + where + T: ToOwned + serde::Serialize + serde::de::DeserializeOwned, + DOC: AsRef, + { + let SdJwt { jwt, disclosures, .. } = credential; + let signature = JwtCredentialValidator::::decode(jwt.as_str())?; + let (public_key, method_id) = JwtCredentialValidator::::parse_jwk(&signature, trusted_issuers, options)?; + + let DecodedJws { protected, claims, .. } = + JwtCredentialValidator::::verify_signature_raw(signature, public_key, &self.0)?; + + let value: Value = serde_json::from_slice(&claims).map_err(|err| { + JwtValidationError::CredentialStructure(crate::Error::JwtClaimsSetDeserializationError(err.into())) + })?; + let obj = value.as_object().ok_or(JwtValidationError::JwsDecodingError( + identity_verification::jose::error::Error::InvalidClaim("sd-jwt claims could not be deserialized"), + ))?; + let decoded: String = Value::Object(self.1.decode(obj, disclosures).map_err(|e| { + let err_str = format!("sd-jwt claims decoding failed, {}", e); + let err: &'static str = Box::leak(err_str.into_boxed_str()); + JwtValidationError::JwsDecodingError(identity_verification::jose::error::Error::InvalidClaim(err)) + })?) + .to_string(); + + let claims = CredentialJwtClaims::from_json(&decoded).map_err(|err| { + JwtValidationError::CredentialStructure(crate::Error::JwtClaimsSetDeserializationError(err.into())) + })?; + let custom_claims = claims.custom.clone(); + let credential = claims + .try_into_credential() + .map_err(JwtValidationError::CredentialStructure)?; + + let decoded_credential = DecodedJwtCredential { + credential, + header: Box::new(protected), + custom_claims, + }; + + // Check that the DID component of the parsed `kid` does indeed correspond to the issuer in the credential before + // returning. + let issuer_id = JwtCredentialValidatorUtils::extract_issuer::(&decoded_credential.credential)?; + if &issuer_id != method_id.did() { + return Err(JwtValidationError::IdentifierMismatch { + signer_ctx: SignerContext::Issuer, + }); + }; + + Ok(decoded_credential) + } + + /// Validates a Key Binding JWT (KB-JWT) according to `https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-07.html#name-key-binding-jwt`. + /// The Validation process includes: + /// * Signature validation using public key materials defined in the `holder` document. + /// * `typ` value in KB-JWT header. + /// * `sd_hash` claim value in the KB-JWT claim. + /// * Optional `nonce`, `aud` and issuance date validation. + pub fn validate_key_binding_jwt( + &self, + sd_jwt: &SdJwt, + holder: &DOC, + options: &KeyBindingJWTValidationOptions, + ) -> Result + where + DOC: AsRef, + { + // Check if KB exists in the SD-JWT. + let kb_jwt = if let Some(kb_jwt) = &sd_jwt.key_binding_jwt { + kb_jwt.clone() + } else { + return Err(KeyBindingJwtError::MissingKeyBindingJwt); + }; + + // Calculate the digest from the `sd_jwt.jwt` and the disclosures. + let jws_decoder = Decoder::new(); + let decoded: JwsValidationItem<'_> = jws_decoder + .decode_compact_serialization(sd_jwt.jwt.as_bytes(), None) + .map_err(|err| KeyBindingJwtError::JwtValidationError(JwtValidationError::JwsDecodingError(err)))?; + let sd_jwt_claims: Value = serde_json::from_slice(decoded.claims()) + .map_err(|_| KeyBindingJwtError::DeserializationError("failed to deserialize sd-jwt claims".to_string()))?; + let sd_jwt_claims_object = sd_jwt_claims + .as_object() + .ok_or(KeyBindingJwtError::DeserializationError( + "failed to deserialize sd-jwt claims".to_string(), + ))?; + let hasher = self.1.determine_hasher(sd_jwt_claims_object)?; + let disclosures = sd_jwt.disclosures.iter().join("~"); + let hash_payload = format!("{}~{}~", sd_jwt.jwt, disclosures); + let digest = hasher.encoded_digest(&hash_payload); + + // Verify the signature of the KB-JWT and extract claims. + let kb_decoded: JwsValidationItem<'_> = jws_decoder + .decode_compact_serialization(kb_jwt.as_bytes(), None) + .map_err(JwtValidationError::JwsDecodingError)?; + let typ: &str = kb_decoded + .protected_header() + .ok_or(KeyBindingJwtError::InvalidHeaderTypValue)? + .typ() + .ok_or(KeyBindingJwtError::InvalidHeaderTypValue)?; + + if typ != KeyBindingJwtClaims::KB_JWT_HEADER_TYP { + return Err(KeyBindingJwtError::InvalidHeaderTypValue); + } + let method_id: DIDUrl = match &options.jws_options.method_id { + Some(method_id) => method_id.clone(), + None => { + let kid: &str = kb_decoded.protected_header().and_then(|header| header.kid()).ok_or( + JwtValidationError::MethodDataLookupError { + source: None, + message: "could not extract kid from protected header", + signer_ctx: SignerContext::Holder, + }, + )?; + + // Convert kid to DIDUrl + DIDUrl::parse(kid).map_err(|err| JwtValidationError::MethodDataLookupError { + source: Some(err.into()), + message: "could not parse kid as a DID Url", + signer_ctx: SignerContext::Issuer, + })? + } + }; + + // Obtain the public key from the holder's DID document + let public_key: &Jwk = holder + .as_ref() + .resolve_method(&method_id, options.jws_options.method_scope) + .and_then(|method| method.data().public_key_jwk()) + .ok_or_else(|| JwtValidationError::MethodDataLookupError { + source: None, + message: "could not extract JWK from a method identified by kid", + signer_ctx: SignerContext::Holder, + })?; + let decoded: JwsValidationItem<'_> = jws_decoder + .decode_compact_serialization(kb_jwt.as_bytes(), None) + .map_err(|err| KeyBindingJwtError::JwtValidationError(JwtValidationError::JwsDecodingError(err)))?; + let decoded_kb_jws = decoded.verify(&self.0, public_key).unwrap(); + + let kb_jwt_claims: KeyBindingJwtClaims = serde_json::from_slice(&decoded_kb_jws.claims) + .map_err(|_| KeyBindingJwtError::DeserializationError("failed to deserialize kb-jwt claims".into()))?; + + // Check if the `_sd_hash` matches. + if kb_jwt_claims.sd_hash != digest { + return Err(KeyBindingJwtError::InvalidDigest); + } + + if let Some(nonce) = &options.nonce { + if *nonce != kb_jwt_claims.nonce { + return Err(KeyBindingJwtError::InvalidNonce); + } + } + + if let Some(aud) = &options.aud { + if *aud != kb_jwt_claims.aud { + return Err(KeyBindingJwtError::AudianceMismatch); + } + } + + let issuance_date = Timestamp::from_unix(kb_jwt_claims.iat) + .map_err(|_| KeyBindingJwtError::IssuanceDate("deserialization of `iat` failed".to_string()))?; + + if let Some(earliest_issuance_date) = options.earliest_issuance_date { + if issuance_date < earliest_issuance_date { + return Err(KeyBindingJwtError::IssuanceDate( + "value is earlier than `earliest_issuance_date`".to_string(), + )); + } + } + + if let Some(latest_issuance_date) = options.latest_issuance_date { + if issuance_date > latest_issuance_date { + return Err(KeyBindingJwtError::IssuanceDate( + "value is later than `latest_issuance_date`".to_string(), + )); + } + } else if issuance_date > Timestamp::now_utc() { + return Err(KeyBindingJwtError::IssuanceDate("value is in the future".to_string())); + } + + Ok(kb_jwt_claims) + } +} diff --git a/identity_document/src/document/core_document.rs b/identity_document/src/document/core_document.rs index b079111ed4..87fddd0fed 100644 --- a/identity_document/src/document/core_document.rs +++ b/identity_document/src/document/core_document.rs @@ -1153,7 +1153,7 @@ mod tests { let document: CoreDocument = document(); // Access methods by index. - assert_eq!(document.methods(None).get(0).unwrap().id().to_string(), "did:example:1234#key-1"); + assert_eq!(document.methods(None).first().unwrap().id().to_string(), "did:example:1234#key-1"); assert_eq!(document.methods(None).get(2).unwrap().id().to_string(), "did:example:1234#key-3"); } @@ -1164,7 +1164,7 @@ mod tests { // VerificationMethod let verification_methods: Vec<&VerificationMethod> = document.methods(Some(MethodScope::VerificationMethod)); assert_eq!( - verification_methods.get(0).unwrap().id().to_string(), + verification_methods.first().unwrap().id().to_string(), "did:example:1234#key-1" ); assert_eq!( @@ -1180,7 +1180,7 @@ mod tests { // Authentication let authentication: Vec<&VerificationMethod> = document.methods(Some(MethodScope::authentication())); assert_eq!( - authentication.get(0).unwrap().id().to_string(), + authentication.first().unwrap().id().to_string(), "did:example:1234#auth-key" ); assert_eq!( diff --git a/identity_iota/Cargo.toml b/identity_iota/Cargo.toml index 1779019daa..38d406989e 100644 --- a/identity_iota/Cargo.toml +++ b/identity_iota/Cargo.toml @@ -58,6 +58,9 @@ domain-linkage-fetch = ["identity_credential/domain-linkage-fetch"] # Exposes in-memory implementations of the storage traits intended exclusively for testing. memstore = ["identity_storage/memstore"] +# Enables selective disclosure features. +sd-jwt = ["identity_credential/sd-jwt"] + [package.metadata.docs.rs] # To build locally: # RUSTDOCFLAGS="--cfg docsrs" cargo +nightly doc --all-features --no-deps --workspace --open diff --git a/identity_iota/src/lib.rs b/identity_iota/src/lib.rs index f4262ba8b8..24a20359eb 100644 --- a/identity_iota/src/lib.rs +++ b/identity_iota/src/lib.rs @@ -107,3 +107,9 @@ pub mod storage { //! Storage traits. pub use identity_storage::*; } + +#[cfg(feature = "sd-jwt")] +pub mod sd_jwt_payload { + //! Expose the selective disclosure crate. + pub use identity_credential::sd_jwt_payload::*; +} diff --git a/identity_storage/src/storage/jwk_document_ext.rs b/identity_storage/src/storage/jwk_document_ext.rs index f47ca10c77..8b412a285a 100644 --- a/identity_storage/src/storage/jwk_document_ext.rs +++ b/identity_storage/src/storage/jwk_document_ext.rs @@ -1,6 +1,10 @@ // Copyright 2020-2023 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use super::JwkStorageDocumentError as Error; +use super::JwsSignatureOptions; +use super::Storage; + use crate::key_id_storage::KeyIdStorage; use crate::key_id_storage::KeyIdStorageResult; use crate::key_id_storage::MethodDigest; @@ -10,10 +14,6 @@ use crate::key_storage::KeyId; use crate::key_storage::KeyStorageResult; use crate::key_storage::KeyType; -use super::JwkStorageDocumentError as Error; -use super::JwsSignatureOptions; -use super::Storage; - use async_trait::async_trait; use identity_core::common::Object; use identity_credential::credential::Credential; diff --git a/identity_storage/src/storage/tests/kb_jwt.rs b/identity_storage/src/storage/tests/kb_jwt.rs new file mode 100644 index 0000000000..5ea27ed5b9 --- /dev/null +++ b/identity_storage/src/storage/tests/kb_jwt.rs @@ -0,0 +1,246 @@ +// Copyright 2020-2023 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use super::test_utils::setup_iotadocument; +use super::test_utils::Setup; +use crate::JwkDocumentExt; +use crate::JwsSignatureOptions; +use identity_core::common::Duration; +use identity_core::common::Object; +use identity_core::common::Timestamp; +use identity_core::common::Url; +use identity_core::convert::FromJson; +use identity_core::convert::ToJson; +use identity_credential::credential::Credential; +use identity_credential::credential::CredentialBuilder; +use identity_credential::credential::Jws; +use identity_credential::credential::Subject; +use identity_credential::sd_jwt_payload::KeyBindingJwtClaims; +use identity_credential::sd_jwt_payload::SdJwt; +use identity_credential::sd_jwt_payload::SdObjectDecoder; +use identity_credential::sd_jwt_payload::SdObjectEncoder; +use identity_credential::sd_jwt_payload::Sha256Hasher; +use identity_credential::validator::FailFast; +use identity_credential::validator::JwtCredentialValidationOptions; +use identity_credential::validator::KeyBindingJWTValidationOptions; +use identity_credential::validator::KeyBindingJwtError; +use identity_credential::validator::SdJwtCredentialValidator; +use identity_eddsa_verifier::EdDSAJwsVerifier; +use identity_iota_core::IotaDocument; +use serde_json::json; + +const NONCE: &str = "nonce-test"; +const VERIFIER_ID: &str = "did:test:verifier"; + +async fn setup_test() -> (Setup, Credential, SdJwt) { + let setup: Setup = setup_iotadocument(None, None).await; + + let subject: Subject = Subject::from_json_value(json!({ + "id": setup.subject_doc.id().to_string(), + "degree": { + "type": "BachelorDegree", + "name": "Bachelor of Science in Mechanical Engineering" + } + })) + .unwrap(); + + // Build credential using subject above and issuer. + let credential: Credential = CredentialBuilder::default() + .id(Url::parse("https://example.edu/credentials/3732").unwrap()) + .issuer(Url::parse(setup.issuer_doc.id().to_string()).unwrap()) + .type_("AddressCredential") + .subject(subject) + .build() + .unwrap(); + + let payload = credential.serialize_jwt(None).unwrap(); + + let mut encoder = SdObjectEncoder::new(&payload).unwrap(); + let disclosures = vec![ + encoder + .conceal(&["vc", "credentialSubject", "degree", "type"], None) + .unwrap(), + encoder + .conceal(&["vc", "credentialSubject", "degree", "name"], None) + .unwrap(), + ]; + encoder.add_sd_alg_property(); + let encoded_payload = encoder.try_to_string().unwrap(); + + let jwt: Jws = setup + .issuer_doc + .create_jws( + &setup.issuer_storage, + &setup.issuer_method_fragment, + encoded_payload.as_bytes(), + &JwsSignatureOptions::default(), + ) + .await + .unwrap(); + + let disclosures: Vec = disclosures + .clone() + .into_iter() + .map(|disclosure| disclosure.to_string()) + .collect(); + + let binding_claims = KeyBindingJwtClaims::new( + &Sha256Hasher::new(), + jwt.as_str().to_string(), + disclosures.clone(), + NONCE.to_string(), + VERIFIER_ID.to_string(), + Timestamp::now_utc().to_unix(), + ) + .to_json() + .unwrap(); + + // 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 = setup + .subject_doc + .create_jws( + &setup.subject_storage, + &setup.subject_method_fragment, + binding_claims.as_bytes(), + &options, + ) + .await + .unwrap(); + let sd_jwt_obj = SdJwt::new(jwt.into(), disclosures.clone(), Some(kb_jwt.into())); + (setup, credential, sd_jwt_obj) +} + +#[tokio::test] +async fn sd_jwt_validation() { + let (setup, credential, sd_jwt) = setup_test().await; + let decoder = SdObjectDecoder::new_with_sha256(); + let validator = SdJwtCredentialValidator::with_signature_verifier(EdDSAJwsVerifier::default(), decoder); + let validation = validator + .validate_credential::<_, Object>( + &sd_jwt, + &setup.issuer_doc, + &JwtCredentialValidationOptions::default(), + FailFast::FirstError, + ) + .unwrap(); + assert_eq!(validation.credential, credential); +} + +#[tokio::test] +async fn kb_validation() { + let (setup, _credential, sd_jwt) = setup_test().await; + let decoder = SdObjectDecoder::new_with_sha256(); + let validator = SdJwtCredentialValidator::with_signature_verifier(EdDSAJwsVerifier::default(), decoder); + let options = KeyBindingJWTValidationOptions::new().nonce(NONCE).aud(VERIFIER_ID); + let _kb_validation = validator + .validate_key_binding_jwt(&sd_jwt, &setup.subject_doc, &options) + .expect("KB validation failed!"); +} + +#[tokio::test] +async fn kb_too_early() { + let (setup, _credential, sd_jwt) = setup_test().await; + let decoder = SdObjectDecoder::new_with_sha256(); + let validator = SdJwtCredentialValidator::with_signature_verifier(EdDSAJwsVerifier::default(), decoder); + let timestamp = Timestamp::now_utc().checked_add(Duration::seconds(1)).unwrap(); + let options = KeyBindingJWTValidationOptions::new() + .nonce(NONCE) + .aud(VERIFIER_ID) + .earliest_issuance_date(timestamp); + let kb_validation = validator.validate_key_binding_jwt(&sd_jwt, &setup.subject_doc, &options); + // let err = + assert!(matches!( + kb_validation.err().unwrap(), + KeyBindingJwtError::IssuanceDate(_) + )); +} + +#[tokio::test] +async fn kb_too_late() { + let (setup, _credential, sd_jwt) = setup_test().await; + let decoder = SdObjectDecoder::new_with_sha256(); + let validator = SdJwtCredentialValidator::with_signature_verifier(EdDSAJwsVerifier::default(), decoder); + let timestamp = Timestamp::now_utc().checked_sub(Duration::seconds(20)).unwrap(); + let options = KeyBindingJWTValidationOptions::new() + .nonce(NONCE) + .aud(VERIFIER_ID) + .latest_issuance_date(timestamp); + let kb_validation = validator.validate_key_binding_jwt(&sd_jwt, &setup.subject_doc, &options); + assert!(matches!( + kb_validation.err().unwrap(), + KeyBindingJwtError::IssuanceDate(_) + )); +} + +#[tokio::test] +async fn kb_in_the_future() { + let (setup, _credential, sd_jwt) = setup_test().await; + let binding_claims = KeyBindingJwtClaims::new( + &Sha256Hasher::new(), + sd_jwt.jwt.as_str().to_string(), + sd_jwt.disclosures.clone(), + NONCE.to_string(), + VERIFIER_ID.to_string(), + Timestamp::now_utc() + .checked_add(Duration::seconds(30)) + .unwrap() + .to_unix(), + ) + .to_json() + .unwrap(); + + // 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 = setup + .subject_doc + .create_jws( + &setup.subject_storage, + &setup.subject_method_fragment, + binding_claims.as_bytes(), + &options, + ) + .await + .unwrap(); + let sd_jwt = SdJwt::new(sd_jwt.jwt, sd_jwt.disclosures.clone(), Some(kb_jwt.into())); + + let decoder = SdObjectDecoder::new_with_sha256(); + let validator = SdJwtCredentialValidator::with_signature_verifier(EdDSAJwsVerifier::default(), decoder); + let options = KeyBindingJWTValidationOptions::new() + .nonce(NONCE.to_string()) + .aud(VERIFIER_ID.to_string()); + let kb_validation = validator.validate_key_binding_jwt(&sd_jwt, &setup.subject_doc, &options); + assert!(matches!( + kb_validation.err().unwrap(), + KeyBindingJwtError::IssuanceDate(_) + )); +} + +#[tokio::test] +async fn kb_aud() { + let (setup, _credential, sd_jwt) = setup_test().await; + let decoder = SdObjectDecoder::new_with_sha256(); + let validator = SdJwtCredentialValidator::with_signature_verifier(EdDSAJwsVerifier::default(), decoder); + let options = KeyBindingJWTValidationOptions::new().nonce(NONCE).aud("wrong verifier"); + let kb_validation = validator.validate_key_binding_jwt(&sd_jwt, &setup.subject_doc, &options); + assert!(matches!( + kb_validation.err().unwrap(), + KeyBindingJwtError::AudianceMismatch + )); +} + +#[tokio::test] +async fn kb_nonce() { + let (setup, _credential, sd_jwt) = setup_test().await; + let decoder = SdObjectDecoder::new_with_sha256(); + let validator = SdJwtCredentialValidator::with_signature_verifier(EdDSAJwsVerifier::default(), decoder); + let options = KeyBindingJWTValidationOptions::new() + .nonce("wrong nonce") + .aud(VERIFIER_ID); + let kb_validation = validator.validate_key_binding_jwt(&sd_jwt, &setup.subject_doc, &options); + assert!(matches!(kb_validation.err().unwrap(), KeyBindingJwtError::InvalidNonce)); +} diff --git a/identity_storage/src/storage/tests/mod.rs b/identity_storage/src/storage/tests/mod.rs index 15d90da402..f2d1a548d3 100644 --- a/identity_storage/src/storage/tests/mod.rs +++ b/identity_storage/src/storage/tests/mod.rs @@ -4,5 +4,6 @@ mod api; mod credential_jws; mod credential_validation; +mod kb_jwt; mod presentation_validation; pub(crate) mod test_utils; diff --git a/identity_stronghold/src/lib.rs b/identity_stronghold/src/lib.rs index a5cfc8b475..decb2c4c00 100644 --- a/identity_stronghold/src/lib.rs +++ b/identity_stronghold/src/lib.rs @@ -8,4 +8,3 @@ mod stronghold_key_id; mod tests; pub use stronghold_jwk_storage::*; -pub use stronghold_key_id::*;