diff --git a/.github/workflows/test-jvm.yml b/.github/workflows/test-jvm.yml index 4873b0fed..bded76a39 100644 --- a/.github/workflows/test-jvm.yml +++ b/.github/workflows/test-jvm.yml @@ -13,7 +13,7 @@ jobs: distribution: 'temurin' java-version: '17' - name: Run tests - run: ./gradlew -Dpublishing.excludeIncludedBuilds=true jvmTest + run: ./gradlew jvmTest - name: Test Report uses: dorny/test-reporter@v1 if: success() || failure() diff --git a/.gitignore b/.gitignore index 49f65a680..56355301b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ local.properties !**/src/test/**/build/ .kotlin +**/src/androidInstrumentedTest/ + ### STS ### .apt_generated .classpath diff --git a/CHANGELOG.md b/CHANGELOG.md index d411742da..2c09f49aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,63 @@ # Changelog +Release 5.0.0: + - Remove `OidcSiopWallet.newDefaultInstance()` and replace it with a constructor + - Remove `OidcSiopVerifier.newInstance()` methods and replace them with constructors + - Remove `Validator.newDefaultInstance()` methods and replace them with constructors + - Remove `WalletService.newDefaultInstance()` methods and replace them with constructors + * Add `TransactionDataEntry` class + * Add `DocumentDigestEntry` class + * Add `DocumentDigestEntryCSC` class + * Add `DocumentLocationsEntry` class + * Add `Method` class + * Update `InputDescriptors` + * New member `transaction_data` + * Removed member `schema` + * Update `AuthorizationDetails` + * Now sealed class with subclasses + * `OpenIdCredential` + * `CSCCredential` + * Extend `AuthenticationRequestParameters` to be able to handle CSC/QES flows + * Extend `TokenRequestParameters` to be able to handle CSC/QES flows + * Extend `TokenResponseParameters` to be able to handle CSC/QES flows + - In `TokenRequestParameters`, change `transactionCode` to `String`, as it needs to be entered by the user potentially + - Add extension method to build DPoP headers acc. to [RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449), see `WalletService` + * Proper registration of serializers for ISO credentials (breaking change), see API in `LibraryInitializer` + * Update dependencies to have everything aligned with Kotlin 2.0.20: + * Kotlin 2.0.20 + * EU PID + MDL Credentials in test scope + * Serialization 1.7.2 proper + * JsonPath4K 2.3.0 (with proper Kotlin 2.0.20 support) + * Signum 3.7.0 (only dependency updates to align everything, no alignments in code) + * Add `KeyStoreMaterial` to JVM target for convenience + - Update implementation of [OpenID for Verifiable Credential Issuance](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html) to draft 14 from 2024-08-21 + - Move some fields from `IssuerMetadata` to `OAuth2AuthorizationServerMetadata` to match the semantics + - Remove proof type `cwt` for OpenID for Verifiable Credential Issuance, as per draft 14, but keep parsing it for a bit of backwards-compatibility + - Remove binding method for `did:key`, as it was never completely implemented, but add binding method `jwk` for JSON Web Keys. + - Rework interface of `WalletService` to make selecting the credential configuration by its ID more explicit + - Support requesting issuance of credential using scope values + - Introudce `OAuth2Client` to extract creating authentication requests and token requests from OID4VCI `WalletService` + - Refactor `SimpleAuthorizationService` to extract actual authentication and authorization into `AuthorizationServiceStrategy` + - Implement JWE encryption with AES-CBC-HMAC algorithms + - SIOPv2/OpenID4VP: Support requesting and receiving claims from different credentials, i.e. a combined presentation + - Require request options on every method in `OidcSiopVerifier` + - Move `credentialScheme`, `representation`, `requestedAttributes` from `RequestOptions` to `RequestOptionsCredentials` + - In `OidcSiopVerifier` move `responseUrl` from constructor parameter to `RequestOptions` + - Add `IdToken` as result case to `OidcSiopVerifier.AuthnResponseResult`, when only an `id_token` is requested and received + - Disclosures for SD-JWT (in class `SelectiveDisclosureItem`) now contain a `JsonPrimitive` for the value, so that implementers can deserialize the value accordingly + +Release 4.1.2: + * In `OidcSiopVerifier` add parameter `nonceService` to externalize creation and validation of nonces, e.g. for deployments in load-balanced environments + * In `SimpleAuthorizationService` change type of `tokenService` to `NonceService` + * Add constructor parameters to `SimpleAuthorizationService` to externalize storage of maps, e.g. for deployments in load-balanced environments + * Add constructor parameter to `WalletService` to externalize storage of state-to-code map, e.g. for deployments in load-balanced environments +* Update to latest Signum for KMP signer and verifier. +* Update dependencies: + * Kotlin 2.0.20 + * Serialization 1.7.2 stable + * JsonPath4K 2.3.0 +* Add Android targets + Release 4.1.1 (Bugfix Release): * correctly configure and name JSON serializer: * `jsonSerializer` -> `vckJsonSerializer` diff --git a/README.md b/README.md index 45bbfdc6e..65454105e 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ [![A-SIT Plus Official](https://img.shields.io/badge/A--SIT_Plus-official-005b79?logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNDMuNzYyODYgMTg0LjgxOTk5Ij48ZGVmcz48Y2xpcFBhdGggaWQ9ImEiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj48cGF0aCBkPSJNMCA1OTUuMjhoODQxLjg5VjBIMFoiLz48L2NsaXBQYXRoPjwvZGVmcz48ZyBjbGlwLXBhdGg9InVybCgjYSkiIHRyYW5zZm9ybT0ibWF0cml4KDEuMzMzMzMzMyAwIDAgLTEuMzMzMzMzMyAtNDgyLjI1IDUxNy41MykiPjxwYXRoIGZpbGw9IiMwMDViNzkiIGQ9Ik00MTUuNjcgMjQ5LjUzYy03LjE1LjA4LTEzLjk0IDEtMjAuMTcgMi43NWE1Mi4zMyA1Mi4zMyAwIDAgMC0xNy40OCA4LjQ2IDQwLjQzIDQwLjQzIDAgMCAwLTExLjk2IDE0LjU2Yy0yLjY4IDUuNDEtNC4xNCAxMS44NC00LjM1IDE5LjA5bC0uMDIgNi4xMnYyLjE3YS43MS43MSAwIDAgMCAuNy43M2gxNi41MmMuMzkgMCAuNy0uMzIuNzEtLjdsLjAxLTIuMmMwLTIuNi4wMi01LjgyLjAzLTYuMDcuMi00LjYgMS4yNC04LjY2IDMuMDgtMTIuMDZhMjguNTIgMjguNTIgMCAwIDEgOC4yMy05LjU4IDM1LjI1IDM1LjI1IDAgMCAxIDExLjk2LTUuNTggNTUuMzggNTUuMzggMCAwIDEgMTIuNTgtMS43NmM0LjMyLjEgOC42LjcgMTIuNzQgMS44YTM1LjA3IDM1LjA3IDAgMCAxIDExLjk2IDUuNTcgMjguNTQgMjguNTQgMCAwIDEgOC4yNCA5LjU3YzEuOTYgMy42NCAzIDguMDIgMy4xMiAxMy4wMnYyNC4wOUgzNjIuNGEuNy43IDAgMCAwLS43MS43VjMzNWMwIDguNDMuMDEgOC4wNS4wMSA4LjE0LjIgNy4zIDEuNjcgMTMuNzcgNC4zNiAxOS4yMmE0MC40MyA0MC40MyAwIDAgMCAxMS45NiAxNC41N2M1IDMuNzYgMTAuODcgNi42MSAxNy40OCA4LjQ2YTc3LjUgNzcuNSAwIDAgMCAyMC4wMiAyLjc3YzcuMTUtLjA3IDEzLjk0LTEgMjAuMTctMi43NGE1Mi4zIDUyLjMgMCAwIDAgMTcuNDgtOC40NiA0MC40IDQwLjQgMCAwIDAgMTEuOTUtMTQuNTdjMS42Mi0zLjI2IDMuNzctMTAuMDQgMy43Ny0xNC42OCAwLS4zOC0uMTctLjc0LS41NC0uODJsLTE2Ljg5LS40Yy0uMi0uMDQtLjM0LjM0LS4zNC41NCAwIC4yNy0uMDMuNC0uMDYuNi0uNSAyLjgyLTEuMzggNS40LTIuNjEgNy42OWEyOC41MyAyOC41MyAwIDAgMS04LjI0IDkuNTggMzUuMDEgMzUuMDEgMCAwIDEtMTEuOTYgNS41NyA1NS4yNSA1NS4yNSAwIDAgMS0xMi41NyAxLjc3Yy00LjMyLS4xLTguNjEtLjcxLTEyLjc1LTEuOGEzNS4wNSAzNS4wNSAwIDAgMS0xMS45Ni01LjU3IDI4LjUyIDI4LjUyIDAgMCAxLTguMjMtOS41OGMtMS44Ni0zLjQ0LTIuOS03LjU1LTMuMDktMTIuMmwtLjAxLTcuNDdoODkuMTZhLjcuNyAwIDAgMCAuNy0uNzJ2LTM5LjVjLS4xLTcuNjUtMS41OC0xNC40LTQuMzgtMjAuMDZhNDAuNCA0MC40IDAgMCAwLTExLjk1LTE0LjU2IDUyLjM3IDUyLjM3IDAgMCAwLTE3LjQ4LTguNDcgNzcuNTYgNzcuNTYgMCAwIDAtMjAuMDEtMi43N1oiLz48cGF0aCBmaWxsPSIjY2U0OTJlIiBkPSJNNDE5LjM4IDI4MC42M2gtNy41N2EuNy43IDAgMCAwLS43MS43MXYxNS40MmE4LjE3IDguMTcgMCAwIDAtMy43OCA2LjkgOC4yOCA4LjI4IDAgMCAwIDE2LjU0IDAgOC4yOSA4LjI5IDAgMCAwLTMuNzYtNi45di0xNS40MmEuNy43IDAgMCAwLS43Mi0uNzEiLz48L2c%2BPC9zdmc%2B&logoColor=white&labelColor=white)](https://a-sit-plus.github.io) [![GitHub license](https://img.shields.io/badge/license-Apache%20License%202.0-brightgreen.svg?style=flat)](http://www.apache.org/licenses/LICENSE-2.0) [![Kotlin](https://img.shields.io/badge/kotlin-multiplatform--mobile-orange.svg?logo=kotlin)](http://kotlinlang.org) -[![Kotlin](https://img.shields.io/badge/kotlin-2.0.0-blue.svg?logo=kotlin)](http://kotlinlang.org) +[![Kotlin](https://img.shields.io/badge/kotlin-2.0.20-blue.svg?logo=kotlin)](http://kotlinlang.org) [![Java](https://img.shields.io/badge/java-17-blue.svg?logo=OPENJDK)](https://www.oracle.com/java/technologies/downloads/#java17) [![Maven Central](https://img.shields.io/maven-central/v/at.asitplus.wallet/vck)](https://mvnrepository.com/artifact/at.asitplus.wallet/vck) @@ -21,70 +21,62 @@ This library implements verifiable credentials to support several use cases, i.e. issuing of credentials, presentation of credentials and validation thereof. This library may be shared between backend services issuing credentials, wallet apps holding credentials, and verifier apps validating them. -Credentials may be represented in the [W3C VC Data Model](https://w3c.github.io/vc-data-model/) or as ISO credentials according to [ISO/IEC 18013-5:2021](https://www.iso.org/standard/69084.html). Issuing may happen according to [ARIES RFC 0453 Issue Credential V2](https://github.com/hyperledger/aries-rfcs/tree/main/features/0453-issue-credential-v2) or with [OpenID for Verifiable Credential Issuance](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html). Presentation of credentials may happen according to [ARIES RFC 0454 Present Proof V2](https://github.com/hyperledger/aries-rfcs/tree/main/features/0454-present-proof-v2) or with [Self-Issued OpenID Provider v2](https://openid.net/specs/openid-connect-self-issued-v2-1_0.html), supporting [OpenID for Verifiable Presentations](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html). ## Architecture This library was built with [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform.html) and [Multiplatform Mobile](https://kotlinlang.org/lp/mobile/) in mind. Its primary targets are JVM, Android and iOS. In order to achieve smooth usage especially under iOS, there have been some notable design decisions: - Code interfacing with client implementations uses the return type `KmmResult` to transport the `Success` case (i.e. a custom data type) as well as potential errors from native implementations as a `Failure`. - - Native implementations can be plugged in by implementing interfaces, e.g. `CryptoService` and `KeyPairAdapter`, as opposed to callback functions. + - Native implementations can be plugged in by implementing interfaces, e.g. `PlatformCryptoShim` and `KeyMaterial`, as opposed to callback functions. - Use of primitive data types for constructor properties instead of e.g. [kotlinx datetime](https://github.com/Kotlin/kotlinx-datetime) types. - - This library provides some "default" implementations, e.g. `DefaultCryptoService` to test as much code as possible from the `commonMain` module. - - Some classes feature additional constructors or factory methods with a shorter argument list because the default arguments are lost when called from Swift. + - As much code as possible is implemented in the `commonMain` module Notable features for multiplatform are: - Use of [Napier](https://github.com/AAkira/Napier) as the logging framework - Use of [Kotest](https://kotest.io/) for unit tests - Use of [kotlinx-datetime](https://github.com/Kotlin/kotlinx-datetime) for date and time classes - - Use of [kotlinx-serialization](https://github.com/Kotlin/kotlinx.serialization) for serialization from/to JSON and CBOR (extended CBOR functionality in [our fork of kotlinx.serialization](https://github.com/a-sit-plus/kotlinx.serialization/)) + - Use of [kotlinx-serialization](https://github.com/Kotlin/kotlinx.serialization) for serialization from/to JSON and CBOR - Implementation of a ZLIB service in Kotlin with native parts, see `ZlibService` - Implementation of JWS and JWE operations in pure Kotlin (delegating to native crypto), see `JwsService` - - Abstraction of several cryptographic primitives, to be implemented in native code, see `CryptoService` - Implementation of COSE operations in pure Kotlin (delegating to native crypto), see `CoseService` Some parts for increased multiplatform support have been extracted into separate repositories: - Reimplementation of Kotlin's [Result](https://kotlinlang.org/api/latest/jvm/stdlib/kotlin/-result/) called [KmmResult](https://github.com/a-sit-plus/kmmresult) for easy use from Swift code (since inline classes are [not supported](https://kotlinlang.org/docs/native-objc-interop.html#unsupported)). - - Several crypto datatypes including an ASN.1 parser and encoder called [Signum](https://github.com/a-sit-plus/signum). + - Several crypto datatypes (including an ASN.1 parser and encoder), as well as a mulitplatform crypto library, called [Signum](https://github.com/a-sit-plus/signum). The main entry point for applications is an instance of `HolderAgent`, `VerifierAgent` or `IssuerAgent`, according to the nomenclature from the [W3C VC Data Model](https://w3c.github.io/vc-data-model/). Many classes define several constructor parameters, some of them with default values, to enable a simple form of dependency injection. Implementers are advised to specify the parameter names of arguments passed to increase readability and prepare for future extensions. -### Aries +## Features -A single run of an [ARIES protocol](https://github.com/hyperledger/aries-rfcs/blob/main/concepts/0003-protocols/README.md) (for issuing or presentation) is implemented by the `*Protocol` classes, whereas the `*Messenger` classes should be used by applications to manage several runs of a protocol. These classes reside in the artifact `vck-aries`. +Credentials may be represented as plain JWTs in the [W3C VC Data Model](https://w3c.github.io/vc-data-model/), as ISO mDoc credentials according to [ISO/IEC 18013-5:2021](https://www.iso.org/standard/69084.html), or simply as a list of claims and values for [SD-JWT](https://datatracker.ietf.org/doc/draft-ietf-oauth-selective-disclosure-jwt/). -### OpenId +When using the plain JWT representation, the single credential itself is an instance of `CredentialSubject`. For ISO mDoc claims see `IssuerSignedItems` and related classes like `Document` and `MobileSecurityObject`. For SD-JWT claims see `SelectiveDisclosureItem` and `SdJwtSigned`. -For SIOPv2 see `OidcSiopProtocol`, and for OpenId4VCI see `WalletService`. Most code resides in the artifact/subdirectory `vck-openid`. Both protocols are able to transport credentials as plain JWTs, SD-JWT or ISO 18013-5. +Other libraries implementing credential schemes may call `LibraryInitializer.registerExtensionLibrary()` to register with this library. See our implementation of the [EU PID credential](https://github.com/a-sit-plus/eu-pid-credential) or our implementation of the [Mobile Driving Licence](https://github.com/a-sit-plus/mobile-driving-licence-credential/) for examples. We also maintain a comprehensive list of [all credentials powered by this library](https://github.com/a-sit-plus/credentials-collection). -## Limitations - - - For Verifiable Credentials and Presentations, only the JWT proof mechanism is implemented. - - Json Web Keys always use a `kid` of `did:key:mEpA...` with a custom, uncompressed representation of `secp256r1` keys. - - Several parts of the W3C VC Data Model have not been fully implemented, i.e. everything around resolving cryptographic key material. - - Anything related to ledgers (e.g. resolving DID documents) is out of scope. - - Cryptographic operations are implemented for EC cryptography on the `secp256r1` curve to fully support hardware-backed keys on Android and iOS. However, the enum classes for cryptographic primitives may be extended to support other algorithms. - -## iOS Implementation +Two [ARIES protocols](https://github.com/hyperledger/aries-rfcs/blob/main/concepts/0003-protocols/README.md) are implemented: Issuing of credentials according to [ARIES RFC 0453 Issue Credential V2](https://github.com/hyperledger/aries-rfcs/tree/main/features/0453-issue-credential-v2), see `IssueCredentialProtocol`. Presentation of credential according to [ARIES RFC 0454 Present Proof V2](https://github.com/hyperledger/aries-rfcs/tree/main/features/0454-present-proof-v2), see `PresentProofProtocol`. -The `DefaultCryptoService` for iOS should not be used in production as it does not implement encryption, decryption, key agreement and message digests correctly. See the [Swift Package](https://github.com/a-sit-plus/swift-package-kmm-vc-library) for details on a more correct iOS implementation. +For the OpenID protocol family, issuing is implemented using [OpenID for Verifiable Credential Issuance](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html), see `WalletService` and `CredentialIssuer`. Presentation of credentials is implemented using [Self-Issued OpenID Provider v2](https://openid.net/specs/openid-connect-self-issued-v2-1_0.html), supporting [OpenID for Verifiable Presentations](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html), see `OidcSiopVerifier` and `OidcSiopWallet`. -## Credentials -A single credential itself is an instance of `CredentialSubject` (when using the plain JWT representation with ECDSA signatures) and has no special meaning attached to it. This library implements `AtomicAttribute2023` as a trivial sample of a custom credential. For [Selective Disclosure JWT](https://datatracker.ietf.org/doc/draft-ietf-oauth-selective-disclosure-jwt/) and ISO representations, only the claims (holding names and values) exist, without any data class holding the values. +## Limitations -Other libraries using this library may call `LibraryInitializer.registerExtensionLibrary()` to register that extension with this library. See our implementation of the [EU PID credential](https://github.com/a-sit-plus/eu-pid-credential) or our implementation of the [Mobile Driving Licence](https://github.com/a-sit-plus/mobile-driving-licence-credential/) for examples. We also maintain a comprehensive list of all credentials powered by this library [over here](https://github.com/a-sit-plus/credentials-collection). + - Several parts of the W3C VC Data Model have not been fully implemented, i.e. everything around resolving cryptographic key material. + - Anything related to ledgers (e.g. resolving DID documents) is out of scope. + - Trust relationships are mostly up to clients using this library. + - The `PlatformCryptoShim` for iOS should not be used in production as it does not implement encryption, decryption, key agreement and message digests correctly. See the [Swift Package](https://github.com/a-sit-plus/swift-package-kmm-vc-library) for details on a more correct iOS implementation, or track the progress for [Signum milestone 1](https://github.com/a-sit-plus/signum/milestone/1). ## Dataflow for OID4VCI We'll present an issuing process according to [OID4VCI](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html), along with [OID4VP](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html), with all terms taken from there. -The credential issuer serves the following metadata: +
+The credential issuer serves the following metadata: -``` +```json { "issuer": "https://wallet.a-sit.at/credential-issuer", "credential_issuer": "https://wallet.a-sit.at/credential-issuer", @@ -156,10 +148,12 @@ The credential issuer serves the following metadata: } } ``` +
-The credential issuer starts with a credential offer: +
+The credential issuer starts with a credential offer: -``` +```json { "credential_issuer": "https://wallet.a-sit.at/credential-issuer", "credential_configuration_ids": [ @@ -179,10 +173,12 @@ The credential issuer starts with a credential offer: } } ``` +
-Since the issuer gives an pre-authorized code, the wallet uses this for the token request: +
+Since the issuer provides a pre-authorized code, the wallet uses this for the token request: -``` +```json { "grant_type": "urn:ietf:params:oauth:grant-type:pre-authorized_code", "redirect_uri": "https://wallet.a-sit.at/app/callback", @@ -197,10 +193,12 @@ Since the issuer gives an pre-authorized code, the wallet uses this for the toke "pre-authorized_code": "2c74a00b-70b8-4062-9757-75652174bc5d" } ``` +
-The credential issuer answers with an access token: +
+The credential issuer answers with an access token: -``` +```json { "access_token": "413ed326-107b-4429-8efa-872cb89949d8", "token_type": "bearer", @@ -209,10 +207,12 @@ The credential issuer answers with an access token: "authorization_details": [] } ``` +
-The wallet creates a credential request, including a proof-of-posession of its private key: +
+The wallet creates a credential request, including a proof-of-possession of its private key: -``` +```json { "format": "vc+sd-jwt", "vct": "AtomicAttribute2023", @@ -222,10 +222,12 @@ The wallet creates a credential request, including a proof-of-posession of its p } } ``` +
-The JWT included decodes to the following: +
+The JWT included decodes to the following: -``` +```json { "typ": "openid4vci-proof+jwt", "alg": "ES256", @@ -236,7 +238,11 @@ The JWT included decodes to the following: "y": "bRFQx-fxoF9DI97TZdPfcXQyDcEtxD2vSS5cY0DGarY" } } -. +``` + +and + +```json { "iss": "https://wallet.a-sit.at/app", "aud": "https://wallet.a-sit.at/credential-issuer", @@ -244,25 +250,23 @@ The JWT included decodes to the following: "iat": 1720422537 } ``` +
-The credential issuer issues the following credential: +
+The credential issuer issues the following credential: -``` +```json { "format": "vc+sd-jwt", "credential": "eyJraWQiOiJkaWQ6a2V5OnpEbmFleVlrcDlqZ0hjN3lheEcybkZTMXc4MXJISkVyS3hZSkVtTExVRTJVU05wWmUiLCJ0eXAiOiJ2YytzZC1qd3QiLCJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJkaWQ6a2V5OnpEbmFlVEN2aDdZS1N5b21hUnFONFZ3TnZSZFRLdzJBakVkR0VkcndtNHZEUXU5RXciLCJuYmYiOjE3MjA0MjI1MzcsImlzcyI6ImRpZDprZXk6ekRuYWV5WWtwOWpnSGM3eWF4RzJuRlMxdzgxckhKRXJLeFlKRW1MTFVFMlVTTnBaZSIsImV4cCI6MTcyMDQyMjU5NywiaWF0IjoxNzIwNDIyNTM3LCJqdGkiOiJ1cm46dXVpZDpkODE1ZTEwZC1hNDRkLTQxNDQtYmZhNS05Zjk5MTNjZjE5ZmUiLCJfc2QiOlsiaHBNUTFpeWV0cTkzeWVCNG5FZXVmeDYweHY0WTVqbHFWcThIc2tJc1pZMCIsIkFHR1hZRTlIeXF1bGxTSF9iTC02dkRTSEhyZU9HckRWUVVOdEVQM3p1QlEiLCJva2VaSElRQkNQVlVxdUo4VmI3bHd2bWtLWnNmUktKVUE3X0VOMlZjX2M0Il0sInZjdCI6IkF0b21pY0F0dHJpYnV0ZTIwMjMiLCJzdGF0dXMiOnsiaWQiOiJodHRwczovL3dhbGxldC5hLXNpdC5hdC9iYWNrZW5kL2NyZWRlbnRpYWxzL3N0YXR1cy8xIzMiLCJ0eXBlIjoiUmV2b2NhdGlvbkxpc3QyMDIwU3RhdHVzIiwicmV2b2NhdGlvbkxpc3RJbmRleCI6MywicmV2b2NhdGlvbkxpc3RDcmVkZW50aWFsIjoiaHR0cHM6Ly93YWxsZXQuYS1zaXQuYXQvYmFja2VuZC9jcmVkZW50aWFscy9zdGF0dXMvMSJ9LCJfc2RfYWxnIjoic2hhLTI1NiIsImNuZiI6eyJjcnYiOiJQLTI1NiIsImt0eSI6IkVDIiwia2lkIjoidXJuOmlldGY6cGFyYW1zOm9hdXRoOmp3ay10aHVtYnByaW50OnNoYTI1NjpaaC1yNlZsWE5UQUZheTVYVjFzWFpJUG5mZjdHcGVZMFAzTHNPVDJmSFlRPSIsIngiOiJLVHZaVGlfbzF5NzlERDBKTUhqdnJhSmRpcURYak1mVkhlU1h6ZURVVXhnIiwieSI6ImJSRlF4LWZ4b0Y5REk5N1RaZFBmY1hReURjRXR4RDJ2U1M1Y1kwREdhclkifX0.oDdilbVBmoRpy162gcDR8a0vvYHlP7LXvJ3gxmjz4dYKRLhoMwM_tIcu0Dy5_ftXPq5IO1p9GYMkfUhHk881kw~WyI4cWRNYkN6SWZGajQxT1ZVUE13OEI5R2xaN2tiemxxaXg5T1RSU2huWGgwIiwiZ2l2ZW5fbmFtZSIsIkVyaWthIl0~WyIxUDdYQjB5eFFjaVBlZkVrV2o5R2N0MzNUYVZXeGNnVER1N19aTGptWG13IiwiZmFtaWx5X25hbWUiLCJNdXN0ZXJmcmF1Il0~WyJ3a2VlWG4wejAwa2tiaHVaeVBXM2dwZVBpOXhSVWU1cmVrQ3Npc2d6ZXg4Iiwic3ViamVjdCIsInN1YmplY3QiXQ" } ``` +
-The SD-JWT included decodes to the following: +
+The SD-JWT included decodes to the following: -``` -{ - "kid": "did:key:zDnaeyYkp9jgHc7yaxG2nFS1w81rHJErKxYJEmLLUE2USNpZe", - "typ": "vc+sd-jwt", - "alg": "ES256" -} -. +```json { "sub": "did:key:zDnaeTCvh7YKSyomaRqN4VwNvRdTKw2AjEdGEdrwm4vDQu9Ew", "nbf": 1720422537, @@ -292,20 +296,23 @@ The SD-JWT included decodes to the following: } } ``` +
-with the following claims appended: +
+with the following claims appended: -``` +```json ["8qdMbCzIfFj41OVUPMw8B9GlZ7kbzlqix9OTRShnXh0","given_name","Erika"] ``` -``` +```json ["1P7XB0yxQciPefEkWj9Gct33TaVWxcgTDu7_ZLjmXmw","family_name","Musterfrau"] ``` -``` +```json ["wkeeXn0z00kkbhuZyPW3gpePi9xRUe5rekCsisgzex8","date_of_birth","1980-01-13"] ``` +
@@ -313,24 +320,29 @@ with the following claims appended: We'll present a presentation process according to [SIOPv2](https://openid.net/specs/openid-connect-self-issued-v2-1_0.html), along with [OID4VP](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html), with all terms taken from there. -The verifier creates a URL to be displayet to the wallet, containing a reference to the authentication request itself: +
+The verifier creates a URL to be displayed to the wallet, containing a reference to the authentication request itself: ``` https://wallet.a-sit.at/mobile ?client_id=https://example.com/rp &request_uri=https://example.com/request/15a4e87c-8e3e-4368-87a3-48083bac7232 ``` +
-The authentication request is signed as a JWS and contains the following header and payload: +
+The authentication request is signed as a JWS and contains the following header and payload: -``` +```json { "alg": "ES256", "x5c": [ "MIIBNDCB2qADAgECAgjm7Gfdm0IFUDAKBggqhkjOPQQDAjASMRAwDgYDVQQDDAdEZWZhdWx0MB4XDTI0MDcxNzEzNTUzMVoXDTI0MDcxNzEzNTYwMVowEjEQMA4GA1UEAwwHRGVmYXVsdDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABCaD9zTN05m6zZSX+QrQzdF4l1Bt3pVSo6VV9VFUSFCkImDkSsyCbgkJoZFkUNObbMiZbccQk6H33HAfxGWpLTyjGjAYMBYGA1UdEQQPMA2CC2V4YW1wbGUuY29tMAoGCCqGSM49BAMCA0kAMEYCIQDSotU4YnsO0Ar5+barXkCn3PQlCaPJEnBwRL60SnHW9wIhAK5mLYgfslZvT5jFmMrS3Ivzm2EpNyjdCPGVT3wpt6/t" ] } -. +``` + +```json { "response_type": "id_token vp_token", "client_id": "example.com", @@ -410,14 +422,17 @@ The authentication request is signed as a JWS and contains the following header "iss": "https://example.com/rp" } ``` - The certificate includes the X.509 SAN extension with the value `example.com`. +
The wallet posts back the response to `https://example.com/response` with the parameters `presentation_submission`, `vp_token` and `state`. +
+ The value for `presentation_submission` is: + -``` +```json { "id": "be3972bc-18cf-4e24-abf4-fe49eb870bab", "definition_id": "b0611eea-89e4-4b78-8f44-40a2cb608d4a", @@ -430,16 +445,20 @@ The value for `presentation_submission` is: ] } ``` +
-The value for `vp_token` is an SD-JWT, with header and payload: +
+The value for `vp_token` is an SD-JWT, with header and payload: -``` +```json { "kid": "did:key:zDnaezoC5xdfzSdKoCZCZiw52eqnTks3xMKMDRTFKfSiq9obD", "typ": "vc+sd-jwt", "alg": "ES256" } -. +``` + +```json { "sub": "did:key:zDnaexYu9PCJsDmg7YwyBCJR2qQT8DxqwXhm4BLAJVWUgW1Ms", "nbf": 1721224531, @@ -467,24 +486,28 @@ The value for `vp_token` is an SD-JWT, with header and payload: } } ``` +
-with the following claims appended: +
+With the following claims appended: -``` +```json ["_bBdCtzFzC1DYL1r6gNq2fZPMvMdR31mo2zp6gq1sZ4","given_name","Susanne"] ``` -``` +```json ["SNDcrxkCBGQrLj0kHiX72Gn4l5dz-sfKo12tbqAoNf8","family_name","Meier"] ``` -``` +```json ["wkeeXn0z00kkbhuZyPW3gpePi9xRUe5rekCsisgzex8","date_of_birth","1990-02-14"] ``` +
-as well as a key binding (the JWT is decoded): +
+As well as a key binding (the JWT is decoded): -``` +```json { "typ": "kb+jwt", "alg": "ES256", @@ -495,7 +518,9 @@ as well as a key binding (the JWT is decoded): "y": "6K5c5nYUfRBY3vRo-Q043hr8cFCqYj3iwnxwGFF2Ca8" } } -. +``` + +```json { "iat": 1721224531, "aud": "did:key:zDnaeT2KFSXyCSo3WLjrZeQsNwaCQv2r6bV4bLZNbdhZKhBbh", @@ -503,6 +528,13 @@ as well as a key binding (the JWT is decoded): "sd_hash": "UkLqyUz3zKR1iMTam7AsP89aEzYvbQqiJ4jfV82A96c" } ``` +
+ +## Contributing +External contributions are greatly appreciated! Be sure to observe the contribution guidelines (see [CONTRIBUTING.md](CONTRIBUTING.md)). +In particular, external contributions to this project are subject to the A-SIT Plus Contributor License Agreement (see also [CONTRIBUTING.md](CONTRIBUTING.md)). + + ## Contributing External contributions are greatly appreciated! Be sure to observe the contribution guidelines (see [CONTRIBUTING.md](CONTRIBUTING.md)). diff --git a/conventions-vclib/build.gradle.kts b/conventions-vclib/build.gradle.kts index b962c379f..ad1ba3686 100644 --- a/conventions-vclib/build.gradle.kts +++ b/conventions-vclib/build.gradle.kts @@ -10,11 +10,19 @@ group = "at.asitplus.gradle" dependencies { api("at.asitplus.gradle:k2") + api("com.squareup:kotlinpoet:1.16.0") + api("com.android.library:com.android.library.gradle.plugin:8.2.2") + api("de.mannodermaus.gradle.plugins:android-junit5:1.11.0.0") + } repositories { - maven("https://maven.pkg.jetbrains.space/kotlin/p/dokka/dev") + maven { + url = uri("https://raw.githubusercontent.com/a-sit-plus/gradle-conventions-plugin/mvn/repo") + name = "aspConventions" + } //KOTEST snapshot mavenCentral() + google() gradlePluginPortal() } diff --git a/conventions-vclib/gradle-conventions-plugin b/conventions-vclib/gradle-conventions-plugin index 1fbb7f4f0..3f1fe1b97 160000 --- a/conventions-vclib/gradle-conventions-plugin +++ b/conventions-vclib/gradle-conventions-plugin @@ -1 +1 @@ -Subproject commit 1fbb7f4f02674089979dbf265b6a961f53dbf8e5 +Subproject commit 3f1fe1b97a8fda7e1877379148755b42b4c8b0f6 diff --git a/conventions-vclib/src/main/kotlin/VcLibConventions.kt b/conventions-vclib/src/main/kotlin/VcLibConventions.kt index 07944af83..e24a0348f 100644 --- a/conventions-vclib/src/main/kotlin/VcLibConventions.kt +++ b/conventions-vclib/src/main/kotlin/VcLibConventions.kt @@ -3,39 +3,41 @@ package at.asitplus.gradle import VcLibVersions +import com.android.build.gradle.LibraryExtension +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.TypeSpec import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.artifacts.VersionCatalog import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.getByName import org.gradle.kotlin.dsl.getByType import org.gradle.kotlin.dsl.withType import org.jetbrains.kotlin.gradle.plugin.KotlinDependencyHandler import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask - +import java.io.FileInputStream +import java.util.regex.Pattern val Project.signumVersionCatalog: VersionCatalog get() = extensions.getByType().named("signum") inline fun Project.commonApiDependencies(): List { project.AspVersions.versions["signum"] = VcLibVersions.signum + project.AspVersions.versions["supreme"] = VcLibVersions.supreme project.AspVersions.versions["jsonpath"] = VcLibVersions.jsonpath project.AspVersions.versions["okio"] = signumVersionCatalog.findVersion("okio").get().toString() - project.AspVersions.versions["encoding"] = "2.2.1" return listOf( coroutines(), - serialization("json"), - serialization("cbor"), - addDependency("at.asitplus.signum:indispensable", "signum"), //for iOS Export + addDependency("at.asitplus.signum:supreme", "supreme"), addDependency("at.asitplus.signum:indispensable-cosef", "signum"), addDependency("at.asitplus.signum:indispensable-josef", "signum"), - addDependency("at.asitplus:jsonpath4k", "jsonpath"), - datetime(), addDependency("com.squareup.okio:okio", "okio"), - addDependency("io.matthewnelson.encoding:base16", "encoding"), - addDependency("io.matthewnelson.encoding:base64", "encoding"), - addDependency("io.matthewnelson.encoding:core", "encoding"), + addDependency("at.asitplus:jsonpath4k", "jsonpath"), ) } @@ -48,29 +50,148 @@ inline fun KotlinDependencyHandler.commonImplementationDependencies() { implementation(project.ktor("http")) implementation(project.napier()) implementation(project.ktor("utils")) - project.AspVersions.versions["uuid"] = VcLibVersions.uuid - implementation(project.addDependency("com.benasher44:uuid", "uuid")) } +/** + * Hooks up Kotest tests from common using a frankensteined JUnit runner. + * It generates code using KotlinPoet to hook it up as per https://github.com/kotest/kotest/issues/189 + */ +inline fun Project.wireAndroidInstrumentedTests() { + logger.lifecycle(" Wiring up Android Instrumented Tests") + val targetDir = project.layout.projectDirectory.dir("src") + .dir("androidInstrumentedTest").dir("kotlin") + .dir("generated").asFile.apply { deleteRecursively() } + + val packagePattern = Pattern.compile("package\\s+(\\S+)", Pattern.UNICODE_CHARACTER_CLASS) + val searchPattern = + Pattern.compile("\\s+class\\s+(\\S+)\\s*:\\s*FreeSpec", Pattern.UNICODE_CHARACTER_CLASS) + project.layout.projectDirectory.dir("src").dir("commonTest") + .dir("kotlin").asFileTree.filter { it.extension == "kt" }.forEach { file -> + FileInputStream(file).bufferedReader().use { reader -> + val source = reader.readText() + + val packageName = packagePattern.matcher(source).run { + if (find()) group(1) else null + } + + val matcher = searchPattern.matcher(source) + + while (matcher.find()) { + val className = matcher.group(1) + logger.lifecycle("Found Test class $className in file ${file.name}") + + FileSpec.builder("at.asitplus.wallet.instrumented", "Android$className") + .addType( + TypeSpec.classBuilder("Android$className") + .apply { + // this.superclass(ClassName(packageName ?: "", className)) + addFunction( + FunSpec.Companion.builder("test").addCode( + "%L", + """ + val listener = io.kotest.engine.listener.CollectingTestEngineListener() + io.kotest.engine.TestEngineLauncher(listener) + .withClasses( + """.trimIndent() + + ClassName( + packageName ?: "", + className + ).canonicalName + "::class)" + + """ + .launch() + listener.tests.map { entry -> + { + val testCase = entry.key + val descriptor = testCase.descriptor.chain().joinToString(" > ") { + it.id.value + } + val cause = when (val value = entry.value) { + is io.kotest.core.test.TestResult.Error -> value.cause + is io.kotest.core.test.TestResult.Failure -> value.cause + else -> null + } + org.junit.jupiter.api.Assertions.assertFalse(entry.value.isErrorOrFailure) { + """.trimIndent() + + "\"\"\"\$descriptor\n" + + " |\${cause?.stackTraceToString()}\"\"\".trimMargin()\n" + + """ + } + } + }.let { + org.junit.jupiter.api.assertAll(it) + } + """.trimIndent() + + "\nprint(\"Total \${listener.tests.size}\")\n" + + "println(\" Failure \${listener.tests.count { it.value.isErrorOrFailure }}\")" + + ).addAnnotation(ClassName("org.junit.jupiter.api", "Test")) + .build() + + + ) + .build() + }.build() + ).build().apply { + targetDir.also { file -> + file.mkdirs() + writeTo(file) + } + } + } + } + } +} + +fun Project.setupAndroid() { + project.extensions.getByName("android").apply { + namespace = "$group.${name.replace('-','.')}".also { logger.lifecycle("Setting Android namespace to $it") } + compileSdk = 34 + defaultConfig { + minSdk = 30 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + -fun Project.commonIosExports() = arrayOf( - datetime(), - "com.ionspin.kotlin:bignum:${signumVersionCatalog.findVersion("bignum").get()}", - kmmresult(), - "at.asitplus.signum:indispensable:${VcLibVersions.signum}", - "at.asitplus.signum:indispensable-cosef:${VcLibVersions.signum}", - "at.asitplus.signum:indispensable-josef:${VcLibVersions.signum}", - "at.asitplus:jsonpath4k:${VcLibVersions.jsonpath}", - "io.matthewnelson.encoding:core:${AspVersions.versions["encoding"]}", - "io.matthewnelson.encoding:base16:${AspVersions.versions["encoding"]}", - "io.matthewnelson.encoding:base64:${AspVersions.versions["encoding"]}", -) + dependencies { + add("androidTestImplementation", "androidx.test:runner:${VcLibVersions.Android.testRunner}") + add("androidTestImplementation", "androidx.test:core:${VcLibVersions.Android.testCore}") + add("testImplementation", "org.junit.jupiter:junit-jupiter-api:${VcLibVersions.Android.junit}") + add("testRuntimeOnly", "org.junit.jupiter:junit-jupiter-engine:${VcLibVersions.Android.junit}") + add("androidTestImplementation", "org.junit.jupiter:junit-jupiter-api:${VcLibVersions.Android.junit}") + } + + packaging { + resources.excludes.add("/META-INF/{AL2.0,LGPL2.1}") + resources.excludes.add("win32-x86-64/attach_hotspot_windows.dll") + resources.excludes.add("win32-x86/attach_hotspot_windows.dll") + resources.excludes.add("META-INF/versions/9/OSGI-INF/MANIFEST.MF") + resources.excludes.add("META-INF/licenses/*") + } + + testOptions { + managedDevices { + localDevices.create("pixel2api33") { + device = "Pixel 2" + apiLevel = 33 + systemImageSource = "aosp-atd" + } + } + } + } +} class VcLibConventions : Plugin { override fun apply(target: Project) { + if (target.rootProject != target) target.plugins.apply("com.android.library") target.plugins.apply("at.asitplus.gradle.conventions") + if (target.rootProject != target) target.plugins.apply("de.mannodermaus.android-junit5") + + target.task("wireAndroidInstrumentedTests") { + doFirst { target.wireAndroidInstrumentedTests() } + } target.gradle.taskGraph.whenReady { + target.wireAndroidInstrumentedTests() target.tasks.withType>().configureEach { compilerOptions { freeCompilerArgs.add("-Xexpect-actual-classes") diff --git a/conventions-vclib/src/main/kotlin/VcLibVersions.kt b/conventions-vclib/src/main/kotlin/VcLibVersions.kt index a45dffb42..c48bf0e27 100644 --- a/conventions-vclib/src/main/kotlin/VcLibVersions.kt +++ b/conventions-vclib/src/main/kotlin/VcLibVersions.kt @@ -10,6 +10,7 @@ object VcLibVersions { val uuid get() = versionOf("uuid") val signum get() = versionOf("signum") + val supreme get() = versionOf("supreme") val jsonpath get() = versionOf("jsonpath") val eupidcredential get() = versionOf("eupid") val mdl get() = versionOf("mdl") @@ -18,4 +19,12 @@ object VcLibVersions { val json get() = versionOf("jvm.json") val `authlete-cbor` get() = versionOf("jvm.cbor") } + + object Android { + val compileSDK get() = versionOf("android.compileSDK") + val minSDK get() = versionOf("android.minSDK") + val testRunner get() = versionOf("android.testRunner") + val testCore get() = versionOf("android.testCore") + val junit get() = versionOf("android.junit") + } } diff --git a/conventions-vclib/src/main/resources/vcLibVersions.properties b/conventions-vclib/src/main/resources/vcLibVersions.properties index 1fb7f9b1a..2d33c9202 100644 --- a/conventions-vclib/src/main/resources/vcLibVersions.properties +++ b/conventions-vclib/src/main/resources/vcLibVersions.properties @@ -1,7 +1,14 @@ -signum=3.6.0 +signum=3.9.0 +supreme=0.4.0 uuid=0.8.1 -jsonpath=2.2.0 +jsonpath=2.3.0 jvm.json=20230618 jvm.cbor=1.18 -eupid=2.1.3-SNAPSHOT -mdl=1.0.2-SNAPSHOT \ No newline at end of file +eupid=2.2.0-SNAPSHOT +mdl=1.1.0-SNAPSHOT + +android.compileSDK=34 +android.minSDK=33 +android.testRunner=1.6.2 +android.testCore=1.6.1 +android.junit=5.11.0 \ No newline at end of file diff --git a/dif-data-classes/build.gradle.kts b/dif-data-classes/build.gradle.kts new file mode 100644 index 000000000..b335caff1 --- /dev/null +++ b/dif-data-classes/build.gradle.kts @@ -0,0 +1,122 @@ +import at.asitplus.gradle.* +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree.Companion.test + +plugins { + kotlin("multiplatform") + kotlin("plugin.serialization") + id("at.asitplus.gradle.vclib-conventions") + id("org.jetbrains.dokka") + id("signing") +} + +/* required for maven publication */ +val artifactVersion: String by extra +group = "at.asitplus.wallet" +version = artifactVersion + + +kotlin { + + jvm() + androidTarget { + publishLibraryVariants("release") + @OptIn(ExperimentalKotlinGradlePluginApi::class) + instrumentedTestVariant.sourceSetTree.set(test) + } + iosArm64() + iosSimulatorArm64() + iosX64() + sourceSets { + + commonMain { + dependencies { + implementation(project.napier()) + implementation(project.ktor("http")) + api("com.benasher44:uuid:${VcLibVersions.uuid}") + api("at.asitplus.signum:indispensable-cosef:${VcLibVersions.signum}") + api("at.asitplus.signum:indispensable-josef:${VcLibVersions.signum}") + api("at.asitplus:jsonpath4k:${VcLibVersions.jsonpath}") + } + } + } +} + + +setupAndroid() + +exportIosFramework( + "DifDataClasses", + transitiveExports = true, + "at.asitplus.signum:indispensable-cosef:${VcLibVersions.signum}", + "at.asitplus.signum:indispensable-josef:${VcLibVersions.signum}", + "at.asitplus:jsonpath4k:${VcLibVersions.jsonpath}", + "com.benasher44:uuid:${VcLibVersions.uuid}" +) + +val javadocJar = setupDokka( + baseUrl = "https://github.com/a-sit-plus/vck/tree/main/", + multiModuleDoc = true +) + +publishing { + publications { + withType { + if (this.name != "relocation") artifact(javadocJar) + pom { + name.set("DIF Data Classes") + description.set("Kotlin Multiplatform data classes for DIF") + url.set("https://github.com/a-sit-plus/vck") + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + developers { + developer { + id.set("JesusMcCloud") + name.set("Bernd Prünster") + email.set("bernd.pruenster@a-sit.at") + } + developer { + id.set("nodh") + name.set("Christian Kollmann") + email.set("christian.kollmann@a-sit.at") + } + } + scm { + connection.set("scm:git:git@github.com:a-sit-plus/vck.git") + developerConnection.set("scm:git:git@github.com:a-sit-plus/vck.git") + url.set("https://github.com/a-sit-plus/vck") + } + } + } + } + repositories { + mavenLocal { + signing.isRequired = false + } + maven { + url = uri(layout.projectDirectory.dir("..").dir("repo")) + name = "local" + signing.isRequired = false + } + } +} + + + +repositories { + maven(url = uri("https://s01.oss.sonatype.org/content/repositories/snapshots/")) + mavenCentral() +} + +signing { + val signingKeyId: String? by project + val signingKey: String? by project + val signingPassword: String? by project + useInMemoryPgpKeys(signingKeyId, signingKey, signingPassword) + sign(publishing.publications) +} + diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/ClaimFormatEnum.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/ClaimFormatEnum.kt similarity index 81% rename from vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/ClaimFormatEnum.kt rename to dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/ClaimFormatEnum.kt index 3f10a74b0..b41b322d7 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/ClaimFormatEnum.kt +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/ClaimFormatEnum.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.data.dif +package at.asitplus.dif import kotlinx.serialization.Serializable @@ -19,6 +19,6 @@ enum class ClaimFormatEnum(val text: String) { MSO_MDOC("mso_mdoc"); companion object { - fun parse(text: String) = values().firstOrNull { it.text == text } + fun parse(text: String) = entries.firstOrNull { it.text == text } } } \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/ClaimFormatEnumSerializer.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/ClaimFormatEnumSerializer.kt similarity index 95% rename from vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/ClaimFormatEnumSerializer.kt rename to dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/ClaimFormatEnumSerializer.kt index 508a3e95c..fe101504f 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/ClaimFormatEnumSerializer.kt +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/ClaimFormatEnumSerializer.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.data.dif +package at.asitplus.dif import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/Constraint.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/Constraint.kt similarity index 95% rename from vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/Constraint.kt rename to dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/Constraint.kt index 3213763fc..aa035fa3c 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/Constraint.kt +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/Constraint.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.data.dif +package at.asitplus.dif import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/ConstraintField.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/ConstraintField.kt similarity index 94% rename from vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/ConstraintField.kt rename to dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/ConstraintField.kt index d5b879e4c..d35f46c18 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/ConstraintField.kt +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/ConstraintField.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.data.dif +package at.asitplus.dif import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/ConstraintFilter.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/ConstraintFilter.kt similarity index 96% rename from vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/ConstraintFilter.kt rename to dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/ConstraintFilter.kt index e6efdf6cb..8842f117b 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/ConstraintFilter.kt +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/ConstraintFilter.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.data.dif +package at.asitplus.dif import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/ConstraintHolder.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/ConstraintHolder.kt similarity index 91% rename from vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/ConstraintHolder.kt rename to dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/ConstraintHolder.kt index a3f6f6149..f88bb1591 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/ConstraintHolder.kt +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/ConstraintHolder.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.data.dif +package at.asitplus.dif import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/ConstraintNotFilter.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/ConstraintNotFilter.kt similarity index 91% rename from vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/ConstraintNotFilter.kt rename to dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/ConstraintNotFilter.kt index 0b662bab9..55737ea75 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/ConstraintNotFilter.kt +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/ConstraintNotFilter.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.data.dif +package at.asitplus.dif import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/ConstraintStatus.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/ConstraintStatus.kt similarity index 89% rename from vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/ConstraintStatus.kt rename to dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/ConstraintStatus.kt index 2cfea152d..46b93d0bd 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/ConstraintStatus.kt +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/ConstraintStatus.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.data.dif +package at.asitplus.dif import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/ConstraintStatusHolder.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/ConstraintStatusHolder.kt similarity index 92% rename from vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/ConstraintStatusHolder.kt rename to dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/ConstraintStatusHolder.kt index 790408353..340231f4c 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/ConstraintStatusHolder.kt +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/ConstraintStatusHolder.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.data.dif +package at.asitplus.dif import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/CredentialDefinition.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/CredentialDefinition.kt similarity index 90% rename from vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/CredentialDefinition.kt rename to dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/CredentialDefinition.kt index 6174dc86b..32996f35f 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/CredentialDefinition.kt +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/CredentialDefinition.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.data.dif +package at.asitplus.dif import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/CredentialManifest.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/CredentialManifest.kt similarity index 91% rename from vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/CredentialManifest.kt rename to dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/CredentialManifest.kt index 726208bc6..7671a68f5 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/CredentialManifest.kt +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/CredentialManifest.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.data.dif +package at.asitplus.dif import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/FormatContainerJwt.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/FormatContainerJwt.kt similarity index 91% rename from vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/FormatContainerJwt.kt rename to dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/FormatContainerJwt.kt index bab2df089..d8bed8df1 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/FormatContainerJwt.kt +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/FormatContainerJwt.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.data.dif +package at.asitplus.dif import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/FormatContainerLdp.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/FormatContainerLdp.kt similarity index 89% rename from vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/FormatContainerLdp.kt rename to dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/FormatContainerLdp.kt index 4e1425c23..bd1063eeb 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/FormatContainerLdp.kt +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/FormatContainerLdp.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.data.dif +package at.asitplus.dif import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/FormatHolder.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/FormatHolder.kt similarity index 96% rename from vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/FormatHolder.kt rename to dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/FormatHolder.kt index 18414f9ce..1a66dbc66 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/FormatHolder.kt +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/FormatHolder.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.data.dif +package at.asitplus.dif import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/InputDescriptor.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/InputDescriptor.kt new file mode 100644 index 000000000..73b6962d1 --- /dev/null +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/InputDescriptor.kt @@ -0,0 +1,75 @@ +@file:UseSerializers(InputDescriptorSerializer::class) + +package at.asitplus.dif + +import at.asitplus.dif.rqes.Base64URLTransactionDataSerializer +import at.asitplus.dif.rqes.TransactionDataEntry +import com.benasher44.uuid.uuid4 +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.json.JsonContentPolymorphicSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonObject + +@Serializable(with = InputDescriptorSerializer::class) +sealed interface InputDescriptor { + val id: String + val group: String? + val name: String? + val purpose: String? + val format: FormatHolder? + val constraints: Constraint? +} + +/** + * Data class for + * [DIF Presentation Exchange v2.1.1](https://identity.foundation/presentation-exchange/spec/v2.1.1/#term:presentation-definition) + */ +@Serializable +data class DifInputDescriptor( + @SerialName("id") + override val id: String, + @SerialName("group") + override val group: String? = null, + @SerialName("name") + override val name: String? = null, + @SerialName("purpose") + override val purpose: String? = null, + @SerialName("format") + override val format: FormatHolder? = null, + @SerialName("constraints") + override val constraints: Constraint? = null, +) : InputDescriptor { + constructor(name: String, constraints: Constraint? = null) : this( + id = uuid4().toString(), + name = name, + constraints = constraints, + ) +} + +@Serializable +data class QesInputDescriptor( + @SerialName("id") + override val id: String, + @SerialName("group") + override val group: String? = null, + @SerialName("name") + override val name: String? = null, + @SerialName("purpose") + override val purpose: String? = null, + @SerialName("format") + override val format: FormatHolder? = null, + @SerialName("constraints") + override val constraints: Constraint? = null, + @SerialName("transaction_data") + val transactionData: List<@Serializable(Base64URLTransactionDataSerializer::class) TransactionDataEntry>, +) : InputDescriptor + + +object InputDescriptorSerializer : JsonContentPolymorphicSerializer(InputDescriptor::class) { + override fun selectDeserializer(element: JsonElement) = when { + "transaction_data" in element.jsonObject -> QesInputDescriptor.serializer() + else -> DifInputDescriptor.serializer() + } +} \ No newline at end of file diff --git a/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/Json.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/Json.kt new file mode 100644 index 000000000..a1142ef69 --- /dev/null +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/Json.kt @@ -0,0 +1,12 @@ +package at.asitplus.dif + +import kotlinx.serialization.json.Json + +val jsonSerializer by lazy { + Json { + prettyPrint = false + encodeDefaults = false + classDiscriminator = "type" + ignoreUnknownKeys = true + } +} diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/PresentationDefinition.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/PresentationDefinition.kt similarity index 86% rename from vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/PresentationDefinition.kt rename to dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/PresentationDefinition.kt index 083998e24..a8f773e72 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/PresentationDefinition.kt +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/PresentationDefinition.kt @@ -1,7 +1,6 @@ -package at.asitplus.wallet.lib.data.dif +package at.asitplus.dif import at.asitplus.KmmResult.Companion.wrap -import at.asitplus.wallet.lib.data.vckJsonSerializer import com.benasher44.uuid.uuid4 import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -21,11 +20,13 @@ data class PresentationDefinition( val purpose: String? = null, @SerialName("input_descriptors") val inputDescriptors: Collection, + @Deprecated(message = "Removed in DIF Presentation Exchange 2.0.0", ReplaceWith("inputDescriptors.format")) @SerialName("format") val formats: FormatHolder? = null, @SerialName("submission_requirements") val submissionRequirements: Collection? = null, ) { + @Deprecated(message = "Removed in DIF Presentation Exchange 2.0.0") constructor( inputDescriptors: Collection, formats: FormatHolder diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/PresentationSubmission.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/PresentationSubmission.kt similarity index 92% rename from vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/PresentationSubmission.kt rename to dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/PresentationSubmission.kt index ecc26ac66..2f46374b5 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/PresentationSubmission.kt +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/PresentationSubmission.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.data.dif +package at.asitplus.dif import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/PresentationSubmissionDescriptor.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/PresentationSubmissionDescriptor.kt similarity index 93% rename from vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/PresentationSubmissionDescriptor.kt rename to dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/PresentationSubmissionDescriptor.kt index fd4a4347c..7664d6a17 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/PresentationSubmissionDescriptor.kt +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/PresentationSubmissionDescriptor.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.data.dif +package at.asitplus.dif import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/RequirementEnum.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/RequirementEnum.kt similarity index 92% rename from vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/RequirementEnum.kt rename to dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/RequirementEnum.kt index 738b70a81..f40c25c60 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/RequirementEnum.kt +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/RequirementEnum.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.data.dif +package at.asitplus.dif import kotlinx.serialization.Serializable diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/RequirementEnumSerializer.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/RequirementEnumSerializer.kt similarity index 95% rename from vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/RequirementEnumSerializer.kt rename to dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/RequirementEnumSerializer.kt index 4ae0e937c..4138796a4 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/RequirementEnumSerializer.kt +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/RequirementEnumSerializer.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.data.dif +package at.asitplus.dif import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/SchemaReference.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/SchemaReference.kt similarity index 85% rename from vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/SchemaReference.kt rename to dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/SchemaReference.kt index 5297f8605..e3ee75693 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/SchemaReference.kt +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/SchemaReference.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.data.dif +package at.asitplus.dif import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/StatusDirectiveEnum.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/StatusDirectiveEnum.kt similarity index 79% rename from vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/StatusDirectiveEnum.kt rename to dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/StatusDirectiveEnum.kt index b999a60d6..e609687c5 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/StatusDirectiveEnum.kt +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/StatusDirectiveEnum.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.data.dif +package at.asitplus.dif import kotlinx.serialization.Serializable @@ -14,6 +14,6 @@ enum class StatusDirectiveEnum(val text: String) { DISALLOWED("disallowed"); companion object { - fun parse(text: String) = values().firstOrNull { it.text == text } + fun parse(text: String) = entries.firstOrNull { it.text == text } } } \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/StatusDirectiveEnumSerializer.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/StatusDirectiveEnumSerializer.kt similarity index 95% rename from vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/StatusDirectiveEnumSerializer.kt rename to dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/StatusDirectiveEnumSerializer.kt index 0f3e04b19..05b30c7a1 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/StatusDirectiveEnumSerializer.kt +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/StatusDirectiveEnumSerializer.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.data.dif +package at.asitplus.dif import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/SubmissionRequirement.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/SubmissionRequirement.kt similarity index 98% rename from vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/SubmissionRequirement.kt rename to dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/SubmissionRequirement.kt index 5f1a832ce..3e622a87f 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/SubmissionRequirement.kt +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/SubmissionRequirement.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.data.dif +package at.asitplus.dif import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/SubmissionRequirementRuleEnum.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/SubmissionRequirementRuleEnum.kt similarity index 78% rename from vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/SubmissionRequirementRuleEnum.kt rename to dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/SubmissionRequirementRuleEnum.kt index e86828ca9..717b0b5de 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/SubmissionRequirementRuleEnum.kt +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/SubmissionRequirementRuleEnum.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.data.dif +package at.asitplus.dif import kotlinx.serialization.Serializable @@ -13,6 +13,6 @@ enum class SubmissionRequirementRuleEnum(val text: String) { ALL("all"); companion object { - fun parse(text: String) = values().firstOrNull { it.text == text } + fun parse(text: String) = entries.firstOrNull { it.text == text } } } \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/SubmissionRequirementRuleEnumSerializer.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/SubmissionRequirementRuleEnumSerializer.kt similarity index 95% rename from vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/SubmissionRequirementRuleEnumSerializer.kt rename to dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/SubmissionRequirementRuleEnumSerializer.kt index 0cb55be1a..12aabb041 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/SubmissionRequirementRuleEnumSerializer.kt +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/SubmissionRequirementRuleEnumSerializer.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.data.dif +package at.asitplus.dif import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind diff --git a/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/rqes/Base64URLTransactionDataSerializer.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/rqes/Base64URLTransactionDataSerializer.kt new file mode 100644 index 000000000..653a4caea --- /dev/null +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/rqes/Base64URLTransactionDataSerializer.kt @@ -0,0 +1,35 @@ +package at.asitplus.dif.rqes + +import at.asitplus.dif.jsonSerializer +import at.asitplus.signum.indispensable.io.Base64UrlStrict +import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray +import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encodeToString +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * According to "Transaction Data entries as defined in D3.1: UC Specification WP3" the encoding + * is JSON and every entry is serialized to a base64 encoded string + */ +object Base64URLTransactionDataSerializer : KSerializer { + + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("Base64URLTransactionDataSerializer", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): TransactionDataEntry { + val jsonString = decoder.decodeString() + val base64URLString = jsonString.decodeToByteArray(Base64UrlStrict).decodeToString() + return jsonSerializer.decodeFromString(base64URLString) + } + + override fun serialize(encoder: Encoder, value: TransactionDataEntry) { + val jsonString = jsonSerializer.encodeToString(value) + val base64URLString = jsonString.encodeToByteArray().encodeToString(Base64UrlStrict) + encoder.encodeString(base64URLString) + } +} \ No newline at end of file diff --git a/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/rqes/DocumentDigestEntry.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/rqes/DocumentDigestEntry.kt new file mode 100644 index 000000000..ee1c3e721 --- /dev/null +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/rqes/DocumentDigestEntry.kt @@ -0,0 +1,192 @@ +@file:UseSerializers(UrlSerializer::class) + +package at.asitplus.dif.rqes + +import at.asitplus.KmmResult +import at.asitplus.KmmResult.Companion.wrap +import at.asitplus.signum.indispensable.asn1.ObjectIdSerializer +import at.asitplus.signum.indispensable.asn1.ObjectIdentifier +import at.asitplus.signum.indispensable.io.ByteArrayBase64Serializer +import io.ktor.http.* +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers + +@ConsistentCopyVisibility +@Serializable +data class DocumentDigestEntry private constructor( + /** + * D3.1: UC Specification WP3: REQUIRED. + * String containing a human-readable + * description of the document to + * be signed (SD). The Wallet MUST + * show the label element in the + * user interaction. It MUST be UTF- + * 8 encoded. + */ + @SerialName("label") + val label: String, + + /** + * D3.1: UC Specification WP3: OPTIONAL. + * String containing the base64-encoded + * octet-representation of applying + * the algorithm from + * [hashAlgorithmOID] to the octet- + * representation of the document + * to be signed (SD). + */ + @SerialName("hash") + @Serializable(ByteArrayBase64Serializer::class) + val hash: ByteArray? = null, + + /** + * D3.1: UC Specification WP3: OPTIONAL. + * String containing the OID of the + * hash algorithm used to generate + * the hash listed in the [hash]. + */ + @SerialName("hashAlgorithmOID") + @Serializable(ObjectIdSerializer::class) + val hashAlgorithmOID: ObjectIdentifier? = null, + + /** + * D3.1: UC Specification WP3: OPTIONAL. + * URL to the document + * to be signed (SD); the parameter + * [hash] MUST be the hash value + * of the designated document. + */ + @SerialName("documentLocation_uri") + val documentLocationUri: Url? = null, + + /** + * D3.1: UC Specification WP3: OPTIONAL. + * An object with + * information how to access + * [documentLocationUri]. + */ + @SerialName("documentLocation_method") + val documentLocationMethod: DocumentLocationMethod? = null, + + /** + * D3.1: UC Specification WP3: OPTIONAL. + * String containing data to be signed + * representation as defined in CEN + * EN 419241-1 and ETSI/TR 119 + * 001:2016 (as base64-encoded octet). + */ + @SerialName("dtbsr") + @Serializable(ByteArrayBase64Serializer::class) + val dataToBeSignedRepresentation: ByteArray? = null, + + /** + * D3.1: UC Specification WP3: OPTIONAL. + * String containing the + * OID of the hash algorithm used + * to generate the hash listed in + * [dataToBeSignedRepresentation] + */ + @SerialName("dtbsrHashAlgorithmOID") + @Serializable(ObjectIdSerializer::class) + val dtbsrHashAlgorithmOID: ObjectIdentifier? = null, +) { + /** + * D3.1: UC Specification WP3: + * If in each of the following bullet points one of the mentioned parameters is + * present, the other must be present: + * - [hash] and [hashAlgorithmOID] + * - [documentLocationUri] and [documentLocationMethod] + * - [dtbsr] and [dtbsrHashAlgorithmOID] + * In each of the following bullet points at least one of the mentioned + * parameters must be present: + * - [hash] or [dtbsr] + */ + init { + require(hash != null || dataToBeSignedRepresentation != null) + require(hashAlgorithmOID?.toString() iff hash?.toString()) + require(dtbsrHashAlgorithmOID?.toString() iff dataToBeSignedRepresentation?.toString()) + require(documentLocationUri?.toString() iff hash?.toString()) + require(documentLocationMethod?.toString() iff documentLocationUri?.toString()) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as DocumentDigestEntry + + if (label != other.label) return false + if (hash != null) { + if (other.hash == null) return false + if (!hash.contentEquals(other.hash)) return false + } else if (other.hash != null) return false + if (hashAlgorithmOID != other.hashAlgorithmOID) return false + if (documentLocationUri != other.documentLocationUri) return false + if (documentLocationMethod != other.documentLocationMethod) return false + if (dataToBeSignedRepresentation != null) { + if (other.dataToBeSignedRepresentation == null) return false + if (!dataToBeSignedRepresentation.contentEquals(other.dataToBeSignedRepresentation)) return false + } else if (other.dataToBeSignedRepresentation != null) return false + if (dtbsrHashAlgorithmOID != other.dtbsrHashAlgorithmOID) return false + + return true + } + + override fun hashCode(): Int { + var result = label.hashCode() + result = 31 * result + (hash?.contentHashCode() ?: 0) + result = 31 * result + (hashAlgorithmOID?.hashCode() ?: 0) + result = 31 * result + (documentLocationUri?.hashCode() ?: 0) + result = 31 * result + (documentLocationMethod?.hashCode() ?: 0) + result = 31 * result + (dataToBeSignedRepresentation?.contentHashCode() ?: 0) + result = 31 * result + (dtbsrHashAlgorithmOID?.hashCode() ?: 0) + return result + } + + /** + * D3.1: UC Specification WP3: OPTIONAL. + * An object with + * information how to access + * [documentLocationUri]. + */ + @ConsistentCopyVisibility + @Serializable + @SerialName("documentLocation_method") + data class DocumentLocationMethod private constructor( + val method: Method, + ) + + companion object { + /** + * Safe way to construct the object as init throws + */ + fun create( + label: String, + hash: ByteArray?, + hashAlgorithmOID: ObjectIdentifier?, + documentLocationUri: Url?, + documentLocationMethod: DocumentLocationMethod?, + dtbsr: ByteArray?, + dtbsrHashAlgorithmOID: ObjectIdentifier?, + ): KmmResult = + kotlin.runCatching { + DocumentDigestEntry( + label = label, + hash = hash, + hashAlgorithmOID = hashAlgorithmOID, + documentLocationUri = documentLocationUri, + documentLocationMethod = documentLocationMethod, + dataToBeSignedRepresentation = dtbsr, + dtbsrHashAlgorithmOID = dtbsrHashAlgorithmOID, + ) + }.wrap() + + } +} + +/** + * Checks that either both strings are present or null + */ +private infix fun String?.iff(other: String?): Boolean = + (this != null && other != null) or (this == null && other == null) diff --git a/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/rqes/DocumentLocationEntry.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/rqes/DocumentLocationEntry.kt new file mode 100644 index 000000000..e869a69bd --- /dev/null +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/rqes/DocumentLocationEntry.kt @@ -0,0 +1,14 @@ +package at.asitplus.dif.rqes + +import io.ktor.http.* +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DocumentLocationEntry( + @SerialName("uri") + @Serializable(UrlSerializer::class) + val uri: Url, + @SerialName("method") + val method: Method, +) diff --git a/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/rqes/Method.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/rqes/Method.kt new file mode 100644 index 000000000..2f033ec2e --- /dev/null +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/rqes/Method.kt @@ -0,0 +1,73 @@ +package at.asitplus.dif.rqes + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * After D3.1: UC Specification WP3. + * However, this class is potentially a mistake in the draft spec vs test vector, + * currently we need it to be a sealed class with polymorphic serialization to get the structure + * `method: {type: NAME}` + * sealed class would instead serialize to + * `method: NAME` + * which might be the corrected implementation in the next draft. + * + * The method describes the restrictions/way of accessing a document + */ +@Serializable +@SerialName("method") +sealed class Method { + /** + * D3.1: UC Specification WP3: + * The document corresponding to the parameter [hash] can be + * fetched from [documentLocationUri] with a https-request + * without further restrictions. + */ + @Serializable + @SerialName("public") + data object Public : Method() + + /** + * D3.1: UC Specification WP3: + * The wallet displays the parameter [oneTimePassword] to the + * user. A webclient accessing the uri offers a way for the user to + * input the shown value and only then allows to fetch the + * document corresponding to [hash]. + */ + @Serializable + @SerialName("otp") + data class OTP( + val oneTimePassword: String + ) : Method() + + /** + * D3.1: UC Specification WP3: + * The wallet fetches the document from + * [documentLocationUri]. The document should be fetched + * using the ‘Basic’ HTTP Authentication Scheme (RFC 7617). + */ + @Serializable + @SerialName("basic_auth") + data object Basic : Method() + + /** + * D3.1: UC Specification WP3: + * The wallet fetches the document from + * [documentLocationUri]. The document should be fetched + * using the ‘Digest’ HTTP Authentication Scheme (RFC 7616). + */ + @Serializable + @SerialName("digest_auth") + data object Digest : Method() + + /** + * D3.1: UC Specification WP3: + * The wallet fetches the document from + * [documentLocationUri]. The document should be fetched + * using the ‘OAuth 2.0’ Authentication Framework (RFC6749 + * and RFC8252). + */ + @Serializable + @SerialName("oauth_20") + data object Oauth2 : Method() +} \ No newline at end of file diff --git a/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/rqes/RqesConstants.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/rqes/RqesConstants.kt new file mode 100644 index 000000000..1fa8de5a4 --- /dev/null +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/rqes/RqesConstants.kt @@ -0,0 +1,9 @@ +package at.asitplus.dif.rqes + +object RqesConstants { + + const val SCOPE = "credential" + + const val SIGNATURE_QUALIFIER = "eu_eidas_qes" + +} \ No newline at end of file diff --git a/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/rqes/TransactionDataEntry.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/rqes/TransactionDataEntry.kt new file mode 100644 index 000000000..498744236 --- /dev/null +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/rqes/TransactionDataEntry.kt @@ -0,0 +1,168 @@ +@file:UseSerializers(UrlSerializer::class) + +package at.asitplus.dif.rqes + +import at.asitplus.KmmResult +import at.asitplus.KmmResult.Companion.wrap +import at.asitplus.signum.indispensable.asn1.ObjectIdSerializer +import at.asitplus.signum.indispensable.asn1.ObjectIdentifier +import at.asitplus.signum.indispensable.io.ByteArrayBase64Serializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers + + +/** + * Implements "Transaction Data entries as defined in D3.1: UC Specification WP3" + * leveraging upcoming changes to [OpenID4VP](https://github.com/openid/OpenID4VP/pull/197) + */ +@Serializable +sealed class TransactionDataEntry { + + /** + * D3.1: UC Specification WP3: + * Transaction data entry used to authorize a qualified electronic signature + */ + @ConsistentCopyVisibility + @Serializable + @SerialName("qes_authorization") + data class QesAuthorization private constructor( + /** + * CSC: OPTIONAL. + * Identifier of the signature type to be created, e.g. 'eu_eidas_qes' + * to denote a Qualified Electronic Signature according to eIDAS. + */ + @SerialName("signatureQualifier") + val signatureQualifier: String? = null, + + /** + * CSC: OPTIONAL. + * The unique identifier associated to the credential + */ + @SerialName("credentialID") + val credentialID: String? = null, + + /** + * D3.1: UC Specification WP3: REQUIRED. + * An array composed of entries for every + * document to be signed (SD). This + * applies for both cases, where a + * document is signed, or a digest is + * signed. Every entry is [DocumentDigestEntry] + * + * !!! Currently not compatible with the CSC definition of documentDigests + */ + @SerialName("documentDigests") + val documentDigests: List, + + /** + * D3.1: UC Specification WP3: OPTIONAL. + * An opaque value used by the QTSP to + * internally link the transaction to this + * request. The parameter is not supposed + * to contain a human-readable value + */ + @SerialName("processID") + val processID: String? = null, + ) : TransactionDataEntry() { + + /** + * D3.1: UC Specification WP3: + * At least one of the mentioned parameters must be present: + * - [signatureQualifier] or [credentialID] + */ + init { + require(signatureQualifier != null || credentialID != null) + } + + companion object { + /** + * Safe way to construct the object as init throws + */ + fun create( + signatureQualifier: String?, + credentialId: String?, + documentDigest: List, + processID: String?, + ): KmmResult = + runCatching { + QesAuthorization( + signatureQualifier = signatureQualifier, + credentialID = credentialId, + documentDigests = documentDigest, + processID = processID, + ) + }.wrap() + } + } + + /** + * D3.1: UC Specification WP3: + * Transaction data entry used to gather the user’s consent to the terms of + * service of the Verifier (e.g. the QTSP) + */ + @Serializable + @SerialName("qcert_creation_acceptance") + data class QCertCreationAcceptance( + /** + * D3.1: UC Specification WP3: REQUIRED. + * URL that points to a human-readable + * terms of service document for the end + * user that describes a contractual + * relationship between the end-user and + * the Qualified Trust Service Provider + * The value of this field MUST + * point to a document which is + * accessible and displayable by the + * Wallet + */ + @SerialName("QC_terms_conditions_uri") + val qcTermsConditionsUri: String, + + /** + * D3.1: UC Specification WP3: REQUIRED. + * String containing the base64-encoded + * octet-representation of applying the + * algorithm from + * [qcHashAlgorithmOID] to the octet- + * representation of the document + * referenced by [qcTermsConditionsUri] + */ + @SerialName("QC_hash") + @Serializable(ByteArrayBase64Serializer::class) + val qcHash: ByteArray, + + /** + * D3.1: UC Specification WP3: REQUIRED. + * String containing the + * OID of the hash algorithm used + * to generate the hash listed in + * [qcHash] + */ + @SerialName("QC_hashAlgorithmOID") + @Serializable(ObjectIdSerializer::class) + val qcHashAlgorithmOID: ObjectIdentifier, + ) : TransactionDataEntry() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as QCertCreationAcceptance + + if (qcTermsConditionsUri != other.qcTermsConditionsUri) return false + if (!qcHash.contentEquals(other.qcHash)) return false + if (qcHashAlgorithmOID != other.qcHashAlgorithmOID) return false + + return true + } + + override fun hashCode(): Int { + var result = qcTermsConditionsUri.hashCode() + result = 31 * result + qcHash.contentHashCode() + result = 31 * result + qcHashAlgorithmOID.hashCode() + return result + } + } +} + + diff --git a/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/rqes/UrlSerializer.kt b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/rqes/UrlSerializer.kt new file mode 100644 index 000000000..2063baa07 --- /dev/null +++ b/dif-data-classes/src/commonMain/kotlin/at/asitplus/dif/rqes/UrlSerializer.kt @@ -0,0 +1,20 @@ +package at.asitplus.dif.rqes + +import io.ktor.http.* +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +object UrlSerializer : KSerializer { + + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UrlSerializer", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): Url = Url(decoder.decodeString()) + + override fun serialize(encoder: Encoder, value: Url) { + encoder.encodeString(value.toString()) + } +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index da6208259..2bb7059da 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,7 +12,7 @@ kotlin.mpp.enableCInteropCommonization=true kotlin.mpp.stability.nowarn=true kotlin.native.ignoreDisabledTargets=true -artifactVersion = 4.1.1 +artifactVersion = 5.0.0 jdk.version=17 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml deleted file mode 100644 index 1e9758689..000000000 --- a/gradle/libs.versions.toml +++ /dev/null @@ -1,2 +0,0 @@ -[versions] -serialization="1.8.0-SNAPSHOT" \ No newline at end of file diff --git a/openid-data-classes/build.gradle.kts b/openid-data-classes/build.gradle.kts new file mode 100644 index 000000000..6d962cab2 --- /dev/null +++ b/openid-data-classes/build.gradle.kts @@ -0,0 +1,139 @@ +import at.asitplus.gradle.* +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree.Companion.test + +plugins { + kotlin("multiplatform") + kotlin("plugin.serialization") + id("at.asitplus.gradle.vclib-conventions") + id("org.jetbrains.dokka") + id("signing") +} + +/* required for maven publication */ +val artifactVersion: String by extra +group = "at.asitplus.wallet" +version = artifactVersion + + +kotlin { + + jvm() + androidTarget { + publishLibraryVariants("release") + @OptIn(ExperimentalKotlinGradlePluginApi::class) + instrumentedTestVariant.sourceSetTree.set(test) + } + iosArm64() + iosSimulatorArm64() + iosX64() + sourceSets { + + commonMain { + dependencies { + api(project(":dif-data-classes")) + implementation(project.napier()) + api(serialization("json")) + api(serialization("cbor")) + api(datetime()) + api("com.ionspin.kotlin:bignum:${signumVersionCatalog.findVersion("bignum").get()}") + api(kmmresult()) + api("at.asitplus.signum:indispensable:${VcLibVersions.signum}") + api("at.asitplus.signum:indispensable-cosef:${VcLibVersions.signum}") + api("at.asitplus.signum:indispensable-josef:${VcLibVersions.signum}") + api("at.asitplus:jsonpath4k:${VcLibVersions.jsonpath}") + api("io.matthewnelson.encoding:core:${AspVersions.versions["encoding"]}") + api("io.matthewnelson.encoding:base16:${AspVersions.versions["encoding"]}") + api("io.matthewnelson.encoding:base64:${AspVersions.versions["encoding"]}") + } + } + + commonTest { + dependencies { + } + } + + jvmMain { + dependencies { + } + } + + jvmTest { + dependencies { + } + } + } +} + +setupAndroid() + +exportIosFramework( + "OpenIdDataClasses", + transitiveExports = true, + project(":dif-data-classes") +) + +val javadocJar = setupDokka( + baseUrl = "https://github.com/a-sit-plus/vck/tree/main/", + multiModuleDoc = true +) + +publishing { + publications { + withType { + if (this.name != "relocation") artifact(javadocJar) + pom { + name.set("OpenID Data Classes") + description.set("Kotlin Multiplatform data classes for OpenId") + url.set("https://github.com/a-sit-plus/vck") + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + developers { + developer { + id.set("JesusMcCloud") + name.set("Bernd Prünster") + email.set("bernd.pruenster@a-sit.at") + } + developer { + id.set("nodh") + name.set("Christian Kollmann") + email.set("christian.kollmann@a-sit.at") + } + } + scm { + connection.set("scm:git:git@github.com:a-sit-plus/vck.git") + developerConnection.set("scm:git:git@github.com:a-sit-plus/vck.git") + url.set("https://github.com/a-sit-plus/vck") + } + } + } + } + repositories { + mavenLocal { + signing.isRequired = false + } + maven { + url = uri(layout.projectDirectory.dir("..").dir("repo")) + name = "local" + signing.isRequired = false + } + } +} + +repositories { + maven(url = uri("https://s01.oss.sonatype.org/content/repositories/snapshots/")) + mavenCentral() +} + +signing { + val signingKeyId: String? by project + val signingKey: String? by project + val signingPassword: String? by project + useInMemoryPgpKeys(signingKeyId, signingKey, signingPassword) + sign(publishing.publications) +} + diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationRequestParameters.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/AuthenticationRequestParameters.kt similarity index 80% rename from vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationRequestParameters.kt rename to openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/AuthenticationRequestParameters.kt index b8c8581a8..295d322ea 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationRequestParameters.kt +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/AuthenticationRequestParameters.kt @@ -1,18 +1,26 @@ -package at.asitplus.wallet.lib.oidc +package at.asitplus.openid import at.asitplus.KmmResult.Companion.wrap -import at.asitplus.wallet.lib.data.InstantLongSerializer -import at.asitplus.wallet.lib.data.dif.PresentationDefinition -import at.asitplus.wallet.lib.oidvci.AuthorizationDetails +import at.asitplus.dif.PresentationDefinition +import at.asitplus.signum.indispensable.io.ByteArrayBase64Serializer +import at.asitplus.signum.indispensable.io.ByteArrayBase64UrlSerializer +import at.asitplus.signum.indispensable.josef.JsonWebToken +import at.asitplus.signum.indispensable.josef.io.InstantLongSerializer import kotlinx.datetime.Instant +import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encodeToString +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder /** * Contents of an OIDC Authentication Request. * - * Usually, these parameters are appended to the Authorization Endpoint URL of the OpenId Provider (may be the + * Usually, these parameters are appended to the Authorization Endpoint URL of the OpenId Provider (maybe the * Wallet App in case of SIOPv2, or the Credential Issuer for OID4VCI). */ @Serializable @@ -259,6 +267,78 @@ data class AuthenticationRequestParameters( */ @SerialName("code_challenge_method") val codeChallengeMethod: String? = null, + + /** + * CSC: Optional + * Request a preferred language according to RFC 5646 + */ + @SerialName("lang") + val lang: String? = null, + + /** + * CSC: REQUIRED-"credential" + * The identifier associated to the credential to authorize + */ + @SerialName("credentialID") + val credentialID: String? = null, + + /** + * CSC: Required-"credential" + * This parameter contains the symbolic identifier determining the kind of + * signature to be created + */ + @SerialName("signatureQualifier") + val signatureQualifier: String? = null, + + /** + * CSC: Required-"credential" + * The number of signatures to authorize + */ + @SerialName("numSignatures") + val numSignatures: Int? = null, + + /** + * CSC: REQUIRED-"credential" + * One or more base64-encoded hash values to be signed + */ + @SerialName("hashes") + @Serializable(HashesSerializer::class) + val hashes: List? = null, + + /** + * CSC: REQUIRED-"credential" + * String containing the OID of the hash algorithm used to generate the hashes + */ + @SerialName("hashAlgorithmOID") + val hashAlgorithmOID: String? = null, + + /** + * CSC: OPTIONAL + * A free form description of the authorization transaction in the lang language. + * The maximum size of the string is 500 characters + */ + @SerialName("description") + val description: String? = null, + + /** + * CSC: OPTIONAL + * To restrict access to the authorization server of a remote service, this specification introduces the + * additional account_token parameter to be used when calling the oauth2/authorize endpoint. This + * parameter contains a secure token designed to authenticate the authorization request based on an + * Account ID that SHALL be uniquely assigned by the signature application to the signing user or to the + * user’s application account + */ + @SerialName("account_token") + val accountToken: JsonWebToken? = null, + + /** + * CSC: OPTIONAL + * Arbitrary data from the signature application. It can be used to handle a + * transaction identifier or other application-spe cific data that may be useful for + * debugging purposes + */ + @SerialName("clientData") + val clientData: String? = null, ) { fun serialize() = jsonSerializer.encodeToString(this) @@ -269,3 +349,4 @@ data class AuthenticationRequestParameters( }.wrap() } } + diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationResponseParameters.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/AuthenticationResponseParameters.kt similarity index 96% rename from vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationResponseParameters.kt rename to openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/AuthenticationResponseParameters.kt index 2cbd460a8..023f91829 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationResponseParameters.kt +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/AuthenticationResponseParameters.kt @@ -1,8 +1,8 @@ -package at.asitplus.wallet.lib.oidc +package at.asitplus.openid import at.asitplus.KmmResult.Companion.wrap -import at.asitplus.wallet.lib.data.InstantLongSerializer -import at.asitplus.wallet.lib.data.dif.PresentationSubmission +import at.asitplus.signum.indispensable.josef.io.InstantLongSerializer +import at.asitplus.dif.PresentationSubmission import kotlinx.datetime.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/AuthnRequestClaims.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/AuthnRequestClaims.kt new file mode 100644 index 000000000..03584d793 --- /dev/null +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/AuthnRequestClaims.kt @@ -0,0 +1,37 @@ +package at.asitplus.openid + +import at.asitplus.KmmResult.Companion.wrap +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString + +@Serializable +data class AuthnRequestClaims( + /** + * OIDC: OPTIONAL. Requests that the listed individual Claims be returned in the ID Token. If present, the listed + * Claims are being requested to be added to the default Claims in the ID Token. If not present, the default + * ID Token Claims are requested. + */ + @SerialName("id_token") + val idTokenMap: Map? = null, + + /** + * OIDC: OPTIONAL. Requests that the listed individual Claims be returned from the UserInfo Endpoint. If present, + * the listed Claims are being requested to be added to any Claims that are being requested using `scope` values. + * If not present, the Claims being requested from the UserInfo Endpoint are only those requested using `scope` + * values. When the `userinfo` member is used, the request MUST also use a `response_type` value that results in an + * Access Token being issued to the Client for use at the UserInfo Endpoint. + */ + @SerialName("userinfo") + val userInfoMap: Map? = null, +) { + + fun serialize() = jsonSerializer.encodeToString(this) + + companion object { + fun deserialize(it: String) = kotlin.runCatching { + jsonSerializer.decodeFromString(it) + }.wrap() + } + +} \ No newline at end of file diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthnRequestClaims.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/AuthnRequestSingleClaim.kt similarity index 57% rename from vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthnRequestClaims.kt rename to openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/AuthnRequestSingleClaim.kt index 19ae921d4..f7cfedee4 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthnRequestClaims.kt +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/AuthnRequestSingleClaim.kt @@ -1,41 +1,10 @@ -package at.asitplus.wallet.lib.oidc +package at.asitplus.openid import at.asitplus.KmmResult.Companion.wrap import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString -@Serializable -data class AuthnRequestClaims( - /** - * OIDC: OPTIONAL. Requests that the listed individual Claims be returned in the ID Token. If present, the listed - * Claims are being requested to be added to the default Claims in the ID Token. If not present, the default - * ID Token Claims are requested. - */ - @SerialName("id_token") - val idTokenMap: Map? = null, - - /** - * OIDC: OPTIONAL. Requests that the listed individual Claims be returned from the UserInfo Endpoint. If present, - * the listed Claims are being requested to be added to any Claims that are being requested using `scope` values. - * If not present, the Claims being requested from the UserInfo Endpoint are only those requested using `scope` - * values. When the `userinfo` member is used, the request MUST also use a `response_type` value that results in an - * Access Token being issued to the Client for use at the UserInfo Endpoint. - */ - @SerialName("userinfo") - val userInfoMap: Map? = null, -) { - - fun serialize() = jsonSerializer.encodeToString(this) - - companion object { - fun deserialize(it: String) = kotlin.runCatching { - jsonSerializer.decodeFromString(it) - }.wrap() - } - -} - @Serializable data class AuthnRequestSingleClaim( /** @@ -60,6 +29,7 @@ data class AuthnRequestSingleClaim( ) { fun serialize() = jsonSerializer.encodeToString(this) + override fun equals(other: Any?): Boolean { if (this === other) return true if (other == null || this::class != other::class) return false diff --git a/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/AuthorizationDetails.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/AuthorizationDetails.kt new file mode 100644 index 000000000..835e167ee --- /dev/null +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/AuthorizationDetails.kt @@ -0,0 +1,149 @@ +package at.asitplus.openid + +import at.asitplus.dif.rqes.DocumentLocationEntry +import at.asitplus.signum.indispensable.asn1.ObjectIdSerializer +import at.asitplus.signum.indispensable.asn1.ObjectIdentifier +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonArray + + +@Serializable +sealed class AuthorizationDetails { + /** + * OID4VCI: The request parameter `authorization_details` defined in Section 2 of (RFC9396) MUST be used to convey + * the details about the Credentials the Wallet wants to obtain. This specification introduces a new authorization + * details type `openid_credential` and defines the following parameters to be used with this authorization details + * type. + */ + @Serializable + @SerialName("openid_credential") + data class OpenIdCredential( + /** + * OID4VCI: REQUIRED when [format] parameter is not present. String specifying a unique identifier of the + * Credential being described in [IssuerMetadata.supportedCredentialConfigurations]. + */ + @SerialName("credential_configuration_id") + val credentialConfigurationId: String? = null, + + /** + * OID4VCI: REQUIRED when [credentialConfigurationId] parameter is not present. + * String identifying the format of the Credential the Wallet needs. + * This Credential format identifier determines further claims in the authorization details object needed to + * identify the Credential type in the requested format. + */ + @SerialName("format") + val format: CredentialFormatEnum? = null, + + /** + * OID4VCI: ISO mDL: OPTIONAL. This claim contains the type value the Wallet requests authorization for at the + * Credential Issuer. It MUST only be present if the [format] claim is present. It MUST not be present + * otherwise. + */ + @SerialName("doctype") + val docType: String? = null, + + /** + * OID4VCI: ISO mDL: OPTIONAL. Object as defined in Appendix A.3.2 excluding the `display` and `value_type` + * parameters. The `mandatory` parameter here is used by the Wallet to indicate to the Issuer that it only + * accepts Credential(s) issued with those claim(s). + */ + @SerialName("claims") + val claims: Map>? = null, + + /** + * OID4VCI: W3C VC: OPTIONAL. Object containing a detailed description of the Credential consisting of the + * following parameters, see [SupportedCredentialFormatDefinition]. + */ + @SerialName("credential_definition") + val credentialDefinition: SupportedCredentialFormatDefinition? = null, + + /** + * OID4VCI: IETF SD-JWT VC: REQUIRED. String as defined in Appendix A.3.2. This claim contains the type values + * the Wallet requests authorization for at the Credential Issuer. + * It MUST only be present if the [format] claim is present. It MUST not be present otherwise. + */ + @SerialName("vct") + val sdJwtVcType: String? = null, + + /** + * OID4VCI: If the Credential Issuer metadata contains an [IssuerMetadata.authorizationServers] parameter, the + * authorization detail's locations common data field MUST be set to the Credential Issuer Identifier value. + */ + @SerialName("locations") + val locations: Set? = null, + + /** + * OID4VCI: REQUIRED. Array of strings, each uniquely identifying a Credential Dataset that can be issued using + * the Access Token returned in this response. Each of these Credential Datasets corresponds to the same + * Credential Configuration in the [IssuerMetadata.supportedCredentialConfigurations]. The Wallet MUST use these + * identifiers together with an Access Token in subsequent Credential Requests. + */ + @SerialName("credential_identifiers") + val credentialIdentifiers: Set? = null, + ) : AuthorizationDetails() + + /** + * CSC: The authorization details type credential allows applications to pass the details of a certain + * credential authorization in a single JSON object + */ + @Serializable + @SerialName("credential") + data class CSCCredential( + /** + * CSC: The identifier associated to the credential to authorize + */ + @SerialName("credentialID") + val credentialID: String? = null, + + /** + * CSC: This parameter contains the symbolic identifier determining the kind of + * signature to be created + */ + @SerialName("signatureQualifier") + val signatureQualifier: String? = null, + + /** + * CSC: An array composed of entries for every document to be signed. This applies for + * array both cases, where are document is signed or a digest is signed + */ + @SerialName("documentDigests") + val documentDigestsCSC: Collection, + + /** + * CSC: String containing the OID of the hash algorithm used to generate the hashes + * listed in documentDigests. + */ + @SerialName("hashAlgorithmOID") + @Serializable(ObjectIdSerializer::class) + val hashAlgorithmOID: ObjectIdentifier, + + /** + * CSC: An array of strings designating the locations of + * array the API where the access token issued in a certain OAuth transaction shall be used. + */ + @SerialName("locations") + val locations: Collection? = null, + + /** + * QES: This parameter is used to convey the + * signer document. This parameter + * SHALL not be used when the signer + * document is not required for the + * creation of the signature (for example, + * in the Wallet-centric model) + */ + @SerialName("documentLocations") + val documentLocations: Collection, + ) : AuthorizationDetails() + + companion object { + fun parse(input: String): List = + jsonSerializer.decodeFromString(input).map { + jsonSerializer.decodeFromJsonElement( + serializer(), + it + ) + } + } +} \ No newline at end of file diff --git a/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/BatchCredentialIssuanceMetadata.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/BatchCredentialIssuanceMetadata.kt new file mode 100644 index 000000000..3e41938b8 --- /dev/null +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/BatchCredentialIssuanceMetadata.kt @@ -0,0 +1,14 @@ +package at.asitplus.openid + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class BatchCredentialIssuanceMetadata( + /** + * OID4VCI: REQUIRED. Integer value specifying the maximum array size for the proofs parameter in a + * Credential Request. + */ + @SerialName("batch_size") + val batchSize: Int +) diff --git a/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialFormatEnum.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialFormatEnum.kt new file mode 100644 index 000000000..25eabd02e --- /dev/null +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialFormatEnum.kt @@ -0,0 +1,17 @@ +package at.asitplus.openid + +import kotlinx.serialization.Serializable + +@Serializable(with = CredentialFormatSerializer::class) +enum class CredentialFormatEnum(val text: String) { + NONE("none"), + JWT_VC("jwt_vc_json"), + VC_SD_JWT("vc+sd-jwt"), + JWT_VC_JSON_LD("jwt_vc_json-ld"), + JSON_LD("ldp_vc"), + MSO_MDOC("mso_mdoc"); + + companion object { + fun parse(text: String) = entries.firstOrNull { it.text == text } + } +} \ No newline at end of file diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialFormatEnum.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialFormatSerializer.kt similarity index 57% rename from vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialFormatEnum.kt rename to openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialFormatSerializer.kt index 1247199e3..84e283b73 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialFormatEnum.kt +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialFormatSerializer.kt @@ -1,31 +1,12 @@ -package at.asitplus.wallet.lib.oidvci +package at.asitplus.openid import kotlinx.serialization.KSerializer -import kotlinx.serialization.Serializable import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder -@Serializable(with = CredentialFormatSerializer::class) -enum class CredentialFormatEnum(val text: String) { - NONE("none"), - JWT_VC("jwt_vc_json"), - /** - * Unofficial constant, used by this library prior to implementing OID4VCI Draft 13. - */ - JWT_VC_SD_UNOFFICIAL("jwt_vc_sd"), - VC_SD_JWT("vc+sd-jwt"), - JWT_VC_JSON_LD("jwt_vc_json-ld"), - JSON_LD("ldp_vc"), - MSO_MDOC("mso_mdoc"); - - companion object { - fun parse(text: String) = entries.firstOrNull { it.text == text } - } -} - object CredentialFormatSerializer : KSerializer { override val descriptor: SerialDescriptor = diff --git a/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialOffer.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialOffer.kt new file mode 100644 index 000000000..5fb88f172 --- /dev/null +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialOffer.kt @@ -0,0 +1,48 @@ +package at.asitplus.openid + +import at.asitplus.KmmResult +import at.asitplus.KmmResult.Companion.wrap +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.decodeFromJsonElement + +@Serializable +data class CredentialOffer( + /** + * OID4VCI: REQUIRED. The URL of the Credential Issuer, as defined in Section 11.2.1, from which the Wallet is + * requested to obtain one or more Credentials. The Wallet uses it to obtain the Credential Issuer's Metadata + * following the steps defined in Section 11.2.2. + */ + @SerialName("credential_issuer") + val credentialIssuer: String, + + /** + * OID4VCI: REQUIRED. Array of unique strings that each identify one of the keys in the name/value pairs stored in + * [IssuerMetadata.supportedCredentialConfigurations]. The Wallet uses these string values to + * obtain the respective object that contains information about the Credential being offered as defined in + * Section 11.2.3. For example, these string values can be used to obtain `scope` values to be used in the + * Authorization Request, see [AuthenticationRequestParameters.scope]. + */ + @SerialName("credential_configuration_ids") + val configurationIds: Collection, + + /** + * OID4VCI: OPTIONAL. If [grants] is not present or is empty, the Wallet MUST determine the Grant Types the + * Credential Issuer's Authorization Server supports using the respective metadata. When multiple grants are + * present, it is at the Wallet's discretion which one to use. + */ + @SerialName("grants") + val grants: CredentialOfferGrants? = null, +) { + fun serialize() = jsonSerializer.encodeToString(this) + + companion object { + fun deserialize(input: String): KmmResult = + runCatching { jsonSerializer.decodeFromString(input) }.wrap() + + fun deserialize(input: JsonElement): KmmResult = + runCatching { jsonSerializer.decodeFromJsonElement(input) }.wrap() + } +} \ No newline at end of file diff --git a/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialOfferGrants.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialOfferGrants.kt new file mode 100644 index 000000000..981e50e0b --- /dev/null +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialOfferGrants.kt @@ -0,0 +1,19 @@ +package at.asitplus.openid + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * OID4VCI: Object indicating to the Wallet the Grant Types the Credential Issuer's Authorization Server is prepared to + * process for this Credential Offer. Every grant is represented by a name/value pair. The name is the Grant Type + * identifier; the value is an object that contains parameters either determining the way the Wallet MUST use the + * particular grant and/or parameters the Wallet MUST send with the respective request(s). + */ +@Serializable +data class CredentialOfferGrants( + @SerialName("authorization_code") + val authorizationCode: CredentialOfferGrantsAuthCode? = null, + + @SerialName("urn:ietf:params:oauth:grant-type:pre-authorized_code") + val preAuthorizedCode: CredentialOfferGrantsPreAuthCode? = null +) \ No newline at end of file diff --git a/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialOfferGrantsAuthCode.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialOfferGrantsAuthCode.kt new file mode 100644 index 000000000..6bd54cf90 --- /dev/null +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialOfferGrantsAuthCode.kt @@ -0,0 +1,25 @@ +package at.asitplus.openid + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CredentialOfferGrantsAuthCode( + /** + * OID4VCI: OPTIONAL. String value created by the Credential Issuer and opaque to the Wallet that is used to bind + * the subsequent Authorization Request with the Credential Issuer to a context set up during previous steps. If the + * Wallet decides to use the Authorization Code Flow and received a value for this parameter, it MUST include it in + * the subsequent Authorization Request to the Credential Issuer as the `issuer_state` parameter value. + */ + @SerialName("issuer_state") + val issuerState: String? = null, + + /** + * OID4VCI: OPTIONAL string that the Wallet can use to identify the Authorization Server to use with this grant + * type when `authorization_servers` parameter in the Credential Issuer metadata has multiple entries. It MUST NOT + * be used otherwise. The value of this parameter MUST match with one of the values in the `authorization_servers` + * array obtained from the Credential Issuer metadata. + */ + @SerialName("authorization_server") + val authorizationServer: String? = null, +) \ No newline at end of file diff --git a/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialOfferGrantsPreAuthCode.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialOfferGrantsPreAuthCode.kt new file mode 100644 index 000000000..684da2457 --- /dev/null +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialOfferGrantsPreAuthCode.kt @@ -0,0 +1,38 @@ +package at.asitplus.openid + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CredentialOfferGrantsPreAuthCode( + /** + * OID4VCI: REQUIRED. The code representing the Credential Issuer's authorization for the Wallet to obtain + * Credentials of a certain type. This code MUST be short lived and single use. If the Wallet decides to use the + * Pre-Authorized Code Flow, this parameter value MUST be included in the subsequent Token Request with the + * Pre-Authorized Code Flow. + */ + @SerialName("pre-authorized_code") + val preAuthorizedCode: String, + + /** + * OID4VCI: OPTIONAL. Object specifying whether the Authorization Server expects presentation of a Transaction Code + * by the End-User along with the Token Request in a Pre-Authorized Code Flow. If the Authorization Server does not + * expect a Transaction Code, this object is absent; this is the default. + */ + @SerialName("tx_code") + val transactionCode: CredentialOfferGrantsPreAuthCodeTransactionCode? = null, + + /** + * OID4VCI: OPTIONAL. The minimum amount of time in seconds that the Wallet SHOULD wait between polling requests to + * the token endpoint. If no value is provided, Wallets MUST use 5 as the default. + */ + @SerialName("interval") + val waitIntervalSeconds: Int? = 5, + + /** + * OID4VCI: OPTIONAL string that the Wallet can use to identify the Authorization Server to use with this grant + * type when `authorization_servers` parameter in the Credential Issuer metadata has multiple entries. + */ + @SerialName("authorization_server") + val authorizationServer: String? = null +) \ No newline at end of file diff --git a/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialOfferGrantsPreAuthCodeTransactionCode.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialOfferGrantsPreAuthCodeTransactionCode.kt new file mode 100644 index 000000000..707283976 --- /dev/null +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialOfferGrantsPreAuthCodeTransactionCode.kt @@ -0,0 +1,28 @@ +package at.asitplus.openid + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CredentialOfferGrantsPreAuthCodeTransactionCode( + /** + * OID4VCI: OPTIONAL. String specifying the input character set. Possible values are `numeric` (only digits) and + * `text` (any characters). The default is `numeric`. + */ + @SerialName("input_mode") + val inputMode: String? = "numeric", + + /** + * OID4VCI: OPTIONAL. Integer specifying the length of the Transaction Code. This helps the Wallet to render the + * input screen and improve the user experience. + */ + @SerialName("length") + val length: Int? = null, + + /** + * OID4VCI: OPTIONAL. String containing guidance for the Holder of the Wallet on how to obtain the Transaction + * Code, e.g., describing over which communication channel it is delivered. + */ + @SerialName("description") + val description: String? = null, +) \ No newline at end of file diff --git a/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialOfferUrlParameters.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialOfferUrlParameters.kt new file mode 100644 index 000000000..3ec89b06e --- /dev/null +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialOfferUrlParameters.kt @@ -0,0 +1,37 @@ +package at.asitplus.openid + +import at.asitplus.KmmResult +import at.asitplus.KmmResult.Companion.wrap +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.JsonObject + +/** + * OID4VCI: The Credential Issuer sends Credential Offer using an HTTP GET request or an HTTP redirect to the Wallet's + * Credential Offer Endpoint defined in Section 11.1.The Credential Offer object, which is a JSON-encoded object with + * the Credential Offer parameters, can be sent by value or by reference. + */ +@Serializable +data class CredentialOfferUrlParameters( + /** + * OID4VCI: Object with the Credential Offer parameters. This MUST NOT be present when the [credentialOfferUrl] + * parameter is present. + */ + @SerialName("credential_offer") + val credentialOffer: JsonObject? = null, + + /** + * OID4VCI: String that is a URL using the `https` scheme referencing a resource containing a JSON object with the + * Credential Offer parameters. This MUST NOT be present when the [credentialOffer] parameter is present. + */ + @SerialName("credential_offer_uri") + val credentialOfferUrl: String? = null, +) { + fun serialize() = jsonSerializer.encodeToString(this) + + companion object { + fun deserialize(input: String): KmmResult = + runCatching { jsonSerializer.decodeFromString(input) }.wrap() + } +} \ No newline at end of file diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestParameters.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialRequestParameters.kt similarity index 56% rename from vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestParameters.kt rename to openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialRequestParameters.kt index 896401c0d..4f35da7ef 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestParameters.kt +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialRequestParameters.kt @@ -1,9 +1,7 @@ -package at.asitplus.wallet.lib.oidvci +package at.asitplus.openid import at.asitplus.KmmResult import at.asitplus.KmmResult.Companion.wrap -import at.asitplus.wallet.lib.oidc.jsonSerializer -import at.asitplus.wallet.lib.oidvci.mdl.RequestedCredentialClaimSpecification import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString @@ -11,25 +9,25 @@ import kotlinx.serialization.encodeToString @Serializable data class CredentialRequestParameters( /** - * OID4VCI: REQUIRED when the `credential_identifiers` parameter was not returned from the Token Response. - * It MUST NOT be used otherwise. It is a String that determines the format of the Credential to be issued, - * which may determine the type and any other information related to the Credential to be issued. - * Credential Format Profiles consist of the Credential format specific parameters that are defined in Appendix A. - * When this parameter is used, the [credentialIdentifier] Credential Request parameter MUST NOT be present. - * REQUIRED. Format of the Credential to be issued. This Credential format identifier determines further parameters - * required to determine the type and (optionally) the content of the credential to be issued. + * OID4VCI: REQUIRED when an [AuthorizationDetails.OpenIdCredential] was returned from the + * [TokenResponseParameters]. It MUST NOT be used otherwise. A string that identifies a Credential Dataset that is + * requested for issuance. When this parameter is used, the [format] parameter and any other Credential format + * specific parameters such as those defined in Appendix A MUST NOT be present */ - @SerialName("format") - val format: CredentialFormatEnum? = null, + @SerialName("credential_identifier") + val credentialIdentifier: String? = null, /** - * OID4VCI: REQUIRED when `credential_identifiers` parameter was returned from the Token Response. - * It MUST NOT be used otherwise. It is a String that identifies a Credential that is being requested to be issued. - * When this parameter is used, the [format] parameter and any other Credential format specific parameters such - * as those defined in Appendix A MUST NOT be present. + * OID4VCI: REQUIRED if an [AuthorizationDetails.OpenIdCredential] was not returned from the + * [TokenResponseParameters] (e.g. when the credential was requested using a [AuthenticationRequestParameters.scope] + * or a pre-authorisation code was used that did not return an [AuthorizationDetails.OpenIdCredential]). + * It MUST NOT be used otherwise. A string that determines the format of the Credential to be issued, which may + * determine the type and any other information related to the Credential to be issued. Credential Format Profiles + * consist of the Credential format specific parameters that are defined in Appendix A. When this parameter is used, + * the [credentialIdentifier] parameter MUST NOT be present. */ - @SerialName("credential_identifier") - val credentialIdentifier: String? = null, + @SerialName("format") + val format: CredentialFormatEnum? = null, /** * OID4VCI: OPTIONAL. Object containing information for encrypting the Credential Response. If this request element @@ -70,13 +68,20 @@ data class CredentialRequestParameters( val sdJwtVcType: String? = null, /** - * OID4VCI: OPTIONAL. Object containing the proof of possession of the cryptographic key material the issued - * Credential would be bound to. The proof object is REQUIRED if the [SupportedCredentialFormat.supportedProofTypes] - * parameter is non-empty and present in the [IssuerMetadata.supportedCredentialConfigurations] for the requested - * Credential. + * OID4VCI: OPTIONAL. Object providing a single proof of possession of the cryptographic key material to which the + * issued Credential instance will be bound to. [proof] parameter MUST NOT be present if [proofs] parameter is used. */ @SerialName("proof") val proof: CredentialRequestProof? = null, + + /** + * OID4VCI: OPTIONAL. Object providing one or more proof of possessions of the cryptographic key material to which + * the issued Credential instances will be bound to. The [proofs] parameter MUST NOT be present if [proof] parameter + * is used. [proofs] object contains exactly one parameter named as the proof type in Section 7.2.1, the value set + * for this parameter is an array containing parameters as defined by the corresponding proof type. + */ + @SerialName("proofs") + val proofs: CredentialRequestProofContainer? = null, ) { fun serialize() = jsonSerializer.encodeToString(this) @@ -86,4 +91,4 @@ data class CredentialRequestParameters( runCatching { jsonSerializer.decodeFromString(input) }.wrap() } -} \ No newline at end of file +} diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestProof.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialRequestProof.kt similarity index 74% rename from vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestProof.kt rename to openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialRequestProof.kt index 6eb1a11de..259954901 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestProof.kt +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialRequestProof.kt @@ -1,13 +1,13 @@ -package at.asitplus.wallet.lib.oidvci +package at.asitplus.openid -import at.asitplus.wallet.lib.oidc.OpenIdConstants +import at.asitplus.openid.OpenIdConstants import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class CredentialRequestProof( /** - * OID4VCI: e.g. `jwt`, or `cwt`, or `ldp_vp`. See [at.asitplus.wallet.lib.oidc.OpenIdConstants.ProofType]. + * OID4VCI: e.g. `jwt`, or `ldp_vp`. See [at.asitplus.openid.OpenIdConstants.ProofType]. */ @SerialName("proof_type") val proofType: OpenIdConstants.ProofType, @@ -22,6 +22,8 @@ data class CredentialRequestProof( /** * OID4VCI: A CWT (RFC8392) is used as proof of possession. When [proofType] is `cwt`, a proof object MUST include * a `cwt` claim containing a CWT defined in Section 7.2.1.3. + * + * Removed in OID4VCI Draft 14, kept here for a bit of backwards-compatibility */ @SerialName("cwt") val cwt: String? = null, diff --git a/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialRequestProofContainer.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialRequestProofContainer.kt new file mode 100644 index 000000000..101b234d9 --- /dev/null +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialRequestProofContainer.kt @@ -0,0 +1,20 @@ +package at.asitplus.openid + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CredentialRequestProofContainer( + /** + * OID4VCI: e.g. `jwt`, or `ldp_vp`. See [at.asitplus.openid.OpenIdConstants.ProofType]. + */ + @SerialName("proof_type") + val proofType: OpenIdConstants.ProofType, + + /** + * OID4VCI: A JWT (RFC7519) is used as proof of possession. When [proofType] is `jwt`, a proof object MUST include + * a `jwt` claim containing a JWT defined in Section 7.2.1.1. + */ + @SerialName("jwt") + val jwt: Set? = null, +) \ No newline at end of file diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestProofSupported.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialRequestProofSupported.kt similarity index 93% rename from vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestProofSupported.kt rename to openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialRequestProofSupported.kt index 7caadc8fe..ce56be4d1 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialRequestProofSupported.kt +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialRequestProofSupported.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.oidvci +package at.asitplus.openid import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialResponseParameters.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialResponseParameters.kt similarity index 92% rename from vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialResponseParameters.kt rename to openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialResponseParameters.kt index ba6e87860..f790931e9 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialResponseParameters.kt +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialResponseParameters.kt @@ -1,9 +1,7 @@ -package at.asitplus.wallet.lib.oidvci +package at.asitplus.openid import at.asitplus.KmmResult import at.asitplus.KmmResult.Companion.wrap -import at.asitplus.wallet.lib.data.DurationSecondsIntSerializer -import at.asitplus.wallet.lib.oidc.jsonSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialSubjectMetadataSingle.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialSubjectMetadataSingle.kt similarity index 97% rename from vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialSubjectMetadataSingle.kt rename to openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialSubjectMetadataSingle.kt index d8916972f..7ce0e2c95 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialSubjectMetadataSingle.kt +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/CredentialSubjectMetadataSingle.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.oidvci +package at.asitplus.openid import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -31,5 +31,5 @@ data class CredentialSubjectMetadataSingle( @SerialName("display") val display: Set? = null, -) + ) diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/DisplayLogoProperties.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/DisplayLogoProperties.kt similarity index 96% rename from vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/DisplayLogoProperties.kt rename to openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/DisplayLogoProperties.kt index caa4c2fba..458525938 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/DisplayLogoProperties.kt +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/DisplayLogoProperties.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.oidvci +package at.asitplus.openid import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/DisplayProperties.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/DisplayProperties.kt similarity index 97% rename from vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/DisplayProperties.kt rename to openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/DisplayProperties.kt index b516dafb9..5b5348b76 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/DisplayProperties.kt +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/DisplayProperties.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.oidvci +package at.asitplus.openid import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/DocumentDigestCSCEntry.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/DocumentDigestCSCEntry.kt new file mode 100644 index 000000000..8a722ea4e --- /dev/null +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/DocumentDigestCSCEntry.kt @@ -0,0 +1,44 @@ +package at.asitplus.openid + +import at.asitplus.signum.indispensable.io.ByteArrayBase64Serializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * CSC: Entry for document to be signed + */ +@Serializable +data class DocumentDigestCSCEntry ( + /** + * CSC: Conditional String containing the actual Base64- + * encoded octet-representation of the hash of the document + */ + @SerialName("hash") + @Serializable(ByteArrayBase64Serializer::class) + val hash: ByteArray, + + /** + * CSC: String containing a human-readable description of the respective + * document + */ + @SerialName("label") + val label: String, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as DocumentDigestCSCEntry + + if (!hash.contentEquals(other.hash)) return false + if (label != other.label) return false + + return true + } + + override fun hashCode(): Int { + var result = hash.contentHashCode() + result = 31 * result + label.hashCode() + return result + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/DurationSecondsIntSerializer.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/DurationSecondsIntSerializer.kt similarity index 95% rename from vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/DurationSecondsIntSerializer.kt rename to openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/DurationSecondsIntSerializer.kt index 35035b360..4a694a5ba 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/DurationSecondsIntSerializer.kt +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/DurationSecondsIntSerializer.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.data +package at.asitplus.openid import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind @@ -9,6 +9,7 @@ import kotlinx.serialization.encoding.Encoder import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds + object DurationSecondsIntSerializer : KSerializer { override val descriptor: SerialDescriptor = diff --git a/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/HashesSerializer.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/HashesSerializer.kt new file mode 100644 index 000000000..4de8dc998 --- /dev/null +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/HashesSerializer.kt @@ -0,0 +1,28 @@ +package at.asitplus.openid + +import at.asitplus.signum.indispensable.io.ByteArrayBase64Serializer +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * CSC: Multiple hash values can be passed as comma separated values, + * e.g. oauth2/authorize?hash=dnN3ZX.. .ZmRm,ZjIxM3… Z2Zk,… + */ +object HashesSerializer : KSerializer> { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("HashesSerializer", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): List { + val listOfHashes = decoder.decodeString().split(",") + return listOfHashes.map { at.asitplus.dif.jsonSerializer.decodeFromString(ByteArrayBase64Serializer, it) } + } + + override fun serialize(encoder: Encoder, value: List) { + val listOfHashes = value.map { at.asitplus.dif.jsonSerializer.encodeToString(ByteArrayBase64Serializer, it) } + encoder.encodeString(listOfHashes.joinToString(",")) + } +} \ No newline at end of file diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/IdToken.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/IdToken.kt similarity index 97% rename from vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/IdToken.kt rename to openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/IdToken.kt index 24d62f998..267e648c1 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/IdToken.kt +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/IdToken.kt @@ -1,8 +1,8 @@ -package at.asitplus.wallet.lib.oidc +package at.asitplus.openid import at.asitplus.KmmResult.Companion.wrap import at.asitplus.signum.indispensable.josef.JsonWebKey -import at.asitplus.wallet.lib.data.InstantLongSerializer +import at.asitplus.signum.indispensable.josef.io.InstantLongSerializer import kotlinx.datetime.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/IdTokenType.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/IdTokenType.kt new file mode 100644 index 000000000..77d74322e --- /dev/null +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/IdTokenType.kt @@ -0,0 +1,11 @@ +package at.asitplus.openid + +import kotlinx.serialization.Serializable + +@Serializable(with = IdTokenTypeSerializer::class) +enum class IdTokenType(val text: String) { + + SUBJECT_SIGNED("subject_signed_id_token"), + ATTESTER_SIGNED("attester_signed_id_token") + +} \ No newline at end of file diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/IdTokenType.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/IdTokenTypeSerializer.kt similarity index 74% rename from vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/IdTokenType.kt rename to openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/IdTokenTypeSerializer.kt index ce03a6291..b09c5b3d8 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/IdTokenType.kt +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/IdTokenTypeSerializer.kt @@ -1,21 +1,12 @@ -package at.asitplus.wallet.lib.oidc +package at.asitplus.openid import kotlinx.serialization.KSerializer -import kotlinx.serialization.Serializable import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder -@Serializable(with = IdTokenTypeSerializer::class) -enum class IdTokenType(val text: String) { - - SUBJECT_SIGNED("subject_signed_id_token"), - ATTESTER_SIGNED("attester_signed_id_token") - -} - object IdTokenTypeSerializer : KSerializer { override val descriptor: SerialDescriptor = diff --git a/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/IssuerMetadata.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/IssuerMetadata.kt new file mode 100644 index 000000000..1dd73402e --- /dev/null +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/IssuerMetadata.kt @@ -0,0 +1,122 @@ +package at.asitplus.openid + +import at.asitplus.KmmResult +import at.asitplus.KmmResult.Companion.wrap +import at.asitplus.signum.indispensable.josef.JsonWebAlgorithm +import at.asitplus.signum.indispensable.josef.JwsAlgorithm +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString + +/** + * Metadata about the credential issuer in + * [OpenID4VCI](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html) + * + * To be serialized into `/.well-known/openid-credential-issuer`. + */ +@Serializable +data class IssuerMetadata( + /** + * OIDC Discovery: REQUIRED. URL using the https scheme with no query or fragment component that the OP asserts as + * its Issuer Identifier. If Issuer discovery is supported (see Section 2), this value MUST be identical to the + * issuer value returned by WebFinger. This also MUST be identical to the `iss` Claim value in ID Tokens issued + * from this Issuer. + */ + @SerialName("issuer") + val issuer: String? = null, + + /** + * OID4VCI: REQUIRED. The Credential Issuer's identifier, by a case sensitive URL using the `https` + * scheme that contains scheme, host and, optionally, port number and path components, but no query + * or fragment components + */ + @SerialName("credential_issuer") + val credentialIssuer: String, + + /** + * OID4VCI: OPTIONAL. Array of strings, where each string is an identifier of the OAuth 2.0 Authorization Server + * (as defined in RFC8414) the Credential Issuer relies on for authorization. If this parameter is omitted, the + * entity providing the Credential Issuer is also acting as the Authorization Server, i.e., the Credential Issuer's + * identifier is used to obtain the Authorization Server metadata. + */ + @SerialName("authorization_servers") + val authorizationServers: Set? = null, + + /** + * OID4VCI: REQUIRED. URL of the Credential Issuer's Credential Endpoint. This URL MUST use the https scheme and + * MAY contain port, path and query parameter components. + */ + @SerialName("credential_endpoint") + val credentialEndpointUrl: String, + + /** + * OID4VCI: OPTIONAL. URL of the Credential Issuer's Deferred Credential Endpoint, as defined in Section 8. + * This URL MUST use the `https` scheme and MAY contain port, path, and query parameter components. + * If omitted, the Credential Issuer does not support the Deferred Credential Endpoint. + */ + @SerialName("deferred_credential_endpoint") + val deferredCredentialEndpointUrl: String? = null, + + /** + * OID4VCI: OPTIONAL. URL of the Credential Issuer's Notification Endpoint, as defined in Section 10. + * This URL MUST use the `https` scheme and MAY contain port, path, and query parameter components. + * If omitted, the Credential Issuer does not support the Notification Endpoint. + */ + @SerialName("notification_endpoint") + val notificationEndpointUrl: String? = null, + + /** + * OID4VCI: OPTIONAL. Object containing information about whether the Credential Issuer supports encryption of the + * Credential and Batch Credential Response on top of TLS. + */ + @SerialName("credential_response_encryption") + val credentialResponseEncryption: SupportedAlgorithmsContainer? = null, + + /** + * OID4VCI: OPTIONAL. Object containing information about the Credential Issuer's supports for batch issuance of + * Credentials on the Credential Endpoint. The presence of this parameter means that the issuer supports the proofs + * parameter in the Credential Request so can issue more than one Verifiable Credential for the same Credential + * Dataset in a single request/response. + */ + @SerialName("batch_credential_issuance") + val batchCredentialIssuance: BatchCredentialIssuanceMetadata? = null, + + /** + * OPTIONAL. String that is a signed JWT. This JWT contains Credential Issuer metadata parameters as claims. The + * signed metadata MUST be secured using JSON Web Signature (JWS) (`RFC7515`) and MUST contain an `iat` (Issued At) + * claim, an `iss` (Issuer) claim denoting the party attesting to the claims in the signed metadata, and `sub` + * (Subject) claim matching the Credential Issuer identifier. If the Wallet supports signed metadata, metadata + * values conveyed in the signed JWT MUST take precedence over the corresponding values conveyed using plain JSON + * elements. If the Credential Issuer wants to enforce use of signed metadata, it omits the respective metadata + * parameters from the unsigned part of the Credential Issuer metadata. A [signedMetadata] metadata value MUST NOT + * appear as a claim in the JWT. The Wallet MUST establish trust in the signer of the metadata, and obtain the keys + * to validate the signature before processing the metadata. The concrete mechanism how to do that is out of scope + * of this specification and MAY be defined in the profiles of this specification. + */ + // TODO Analyze usage + @SerialName("signed_metadata") + val signedMetadata: String? = null, + + /** + * OID4VCI: OPTIONAL. An array of objects, where each object contains display properties of a Credential Issuer for + * a certain language. + */ + @SerialName("display") + val displayProperties: Set? = null, + + /** + * OID4VCI: REQUIRED. Object that describes specifics of the Credential that the Credential Issuer supports + * issuance of. This object contains a list of name/value pairs, where each name is a unique identifier of the + * supported Credential being described. This identifier is used in the Credential Offer as defined in + * Section 4.1.1 to communicate to the Wallet which Credential is being offered, see [CredentialOffer]. + */ + @SerialName("credential_configurations_supported") + val supportedCredentialConfigurations: Map? = null, +) { + fun serialize() = jsonSerializer.encodeToString(this) + + companion object { + fun deserialize(input: String): KmmResult = + runCatching { jsonSerializer.decodeFromString(input) }.wrap() + } +} \ No newline at end of file diff --git a/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/Json.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/Json.kt new file mode 100644 index 000000000..2b2c32dd1 --- /dev/null +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/Json.kt @@ -0,0 +1,13 @@ +package at.asitplus.openid + + +import kotlinx.serialization.json.Json + +val jsonSerializer by lazy { + Json { + prettyPrint = false + encodeDefaults = false + classDiscriminator = "type" + ignoreUnknownKeys = true + } +} diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2AuthorizationServerMetadata.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/OAuth2AuthorizationServerMetadata.kt similarity index 73% rename from vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2AuthorizationServerMetadata.kt rename to openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/OAuth2AuthorizationServerMetadata.kt index 50c3e14fd..02690fc7e 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2AuthorizationServerMetadata.kt +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/OAuth2AuthorizationServerMetadata.kt @@ -1,8 +1,9 @@ -package at.asitplus.wallet.lib.oidvci +package at.asitplus.openid import at.asitplus.KmmResult import at.asitplus.catching -import at.asitplus.wallet.lib.oidc.jsonSerializer +import at.asitplus.signum.indispensable.josef.JsonWebAlgorithm +import at.asitplus.signum.indispensable.josef.JwsAlgorithm import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString @@ -15,7 +16,6 @@ import kotlinx.serialization.encodeToString * which is the registered default-path, see * [IANA](https://www.iana.org/assignments/well-known-uris/well-known-uris.xhtml) */ - @Serializable data class OAuth2AuthorizationServerMetadata( /** @@ -36,10 +36,11 @@ data class OAuth2AuthorizationServerMetadata( * `RFC6749`. This is REQUIRED unless no grant types are supported * that use the authorization endpoint. * - * NOTE: Mandatory for our use-case + * OIDC SIOPv2: REQUIRED. URL of the Self-Issued OP used by the RP to perform Authentication of the End-User. + * Can be custom URI scheme, or Universal Links/App links. */ @SerialName("authorization_endpoint") - val authorizationEndpoint: String, + val authorizationEndpoint: String? = null, /** * RFC 9126: The URL of the pushed authorization request endpoint at which a client can post an authorization @@ -62,11 +63,9 @@ data class OAuth2AuthorizationServerMetadata( /** * URL of the authorization server's token endpoint `RFC6749`. This * is REQUIRED unless only the implicit grant type is supported. - * - * NOTE: Mandatory for our use-case */ @SerialName("token_endpoint") - val tokenEndpoint: String, + val tokenEndpoint: String? = null, /** * OPTIONAL. URL of the authorization server's JWK Set `JWK` @@ -78,9 +77,12 @@ data class OAuth2AuthorizationServerMetadata( * encryption keys are made available, a "use" (public key use) * parameter value is REQUIRED for all keys in the referenced JWK Set * to indicate each key's intended usage. + * + * OIDC SIOPv2: MUST NOT be present in Self-Issued OP Metadata. If it is, the RP MUST ignore it and use the `sub` + * Claim in the ID Token to obtain signing keys to validate the signatures from the Self-Issued OpenID Provider. */ @SerialName("jwks_uri") - val jwksUri: String? = null, + val jsonWebKeySetUrl: String? = null, /** * OPTIONAL. URL of the authorization server's OAuth 2.0 Dynamic @@ -94,8 +96,11 @@ data class OAuth2AuthorizationServerMetadata( * `RFC6749` "scope" values that this authorization server supports. * Servers MAY choose not to advertise some supported scope values * even when this parameter is used. + * + * OIDC SIOPv2: REQUIRED. A JSON array of strings representing supported scopes. + * MUST support the `openid` scope value. */ - @SerialName("scope_supported") + @SerialName("scopes_supported") val scopesSupported: Set? = null, /** @@ -104,6 +109,8 @@ data class OAuth2AuthorizationServerMetadata( * The array values used are the same as those used with the * "response_types" parameter defined by "OAuth 2.0 Dynamic Client * Registration Protocol" `RFC7591`. + * + * OIDC SIOPv2: MUST be `id_token`. */ @SerialName("response_types_supported") val responseTypesSupported: Set? = null, @@ -155,6 +162,77 @@ data class OAuth2AuthorizationServerMetadata( @SerialName("token_endpoint_auth_signing_alg_methods_supported") val tokenEndPointAuthSigningAlgValuesSupported: Set? = null, + /** + * OIDC Discovery: REQUIRED. JSON array containing a list of the Subject Identifier types that this OP supports. + * Valid types include `pairwise` and `public`. + */ + @SerialName("subject_types_supported") + val subjectTypesSupported: Set? = null, + + /** + * OIDC Discovery: REQUIRED. A JSON array containing a list of the JWS signing algorithms (`alg` values) supported + * by the OP for the ID Token to encode the Claims in a JWT (RFC7519). + * Valid values include `RS256`, `ES256`, `ES256K`, and `EdDSA`. + */ + @SerialName("id_token_signing_alg_values_supported") + val idTokenSigningAlgorithmsSupported: Set? = null, + + /** + * OIDC SIOPv2: REQUIRED. A JSON array containing a list of the JWS signing algorithms (alg values) supported by the + * OP for Request Objects, which are described in Section 6.1 of OpenID.Core. + * Valid values include `none`, `RS256`, `ES256`, `ES256K`, and `EdDSA`. + */ + @SerialName("request_object_signing_alg_values_supported") + val requestObjectSigningAlgorithmsSupported: Set? = null, + + /** + * OIDC SIOPv2: REQUIRED. A JSON array of strings representing URI scheme identifiers and optionally method names of + * supported Subject Syntax Types. + * Valid values include `urn:ietf:params:oauth:jwk-thumbprint`, `did:example` and others. + */ + @SerialName("subject_syntax_types_supported") + // TODO Verify usage of "jwk", maybe remove did + val subjectSyntaxTypesSupported: Set? = null, + + /** + * OIDC SIOPv2: OPTIONAL. A JSON array of strings containing the list of ID Token types supported by the OP, + * the default value is `attester_signed_id_token` (the id token is issued by the party operating the OP, i.e. this + * is the classical id token as defined in OpenID.Core), may also include `subject_signed_id_token` (Self-Issued + * ID Token, i.e. the id token is signed with key material under the end-user's control). + */ + @SerialName("id_token_types_supported") + val idTokenTypesSupported: Set? = null, + + /** + * OID4VP: OPTIONAL. Boolean value specifying whether the Wallet supports the transfer of `presentation_definition` + * by reference, with true indicating support. If omitted, the default value is true. + */ + @SerialName("presentation_definition_uri_supported") + val presentationDefinitionUriSupported: Boolean = true, + + /** + * OID4VP: REQUIRED. An object containing a list of key value pairs, where the key is a string identifying a + * Credential format supported by the Wallet. Valid Credential format identifier values are defined in Annex E + * of OpenID.VCI. Other values may be used when defined in the profiles of this specification. + */ + @SerialName("vp_formats_supported") + val vpFormatsSupported: VpFormatsSupported? = null, + + /** + * OID4VP: OPTIONAL. Array of JSON Strings containing the values of the Client Identifier schemes that the Wallet + * supports. The values defined by this specification are `pre-registered`, `redirect_uri`, `entity_id`, `did`. + * If omitted, the default value is pre-registered. + */ + @SerialName("client_id_schemes_supported") + val clientIdSchemesSupported: Set? = null, + + /** + * RFC 9449: A JSON array containing a list of the JWS alg values (from the `IANA.JOSE.ALGS` registry) supported + * by the authorization server for DPoP proof JWTs. + */ + @SerialName("dpop_signing_alg_values_supported") + val dpopSigningAlgValuesSupported: Set? = null, + /** * OPTIONAL. URL of a page containing human-readable information * that developers might want or need to know when using the diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OidcAddressClaim.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/OidcAddressClaim.kt similarity index 94% rename from vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OidcAddressClaim.kt rename to openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/OidcAddressClaim.kt index c1919603c..a920fa14e 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OidcAddressClaim.kt +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/OidcAddressClaim.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.oidvci +package at.asitplus.openid import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OidcUserInfo.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/OidcUserInfo.kt similarity index 94% rename from vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OidcUserInfo.kt rename to openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/OidcUserInfo.kt index 1127c0d33..59fd08efe 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OidcUserInfo.kt +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/OidcUserInfo.kt @@ -1,6 +1,6 @@ -package at.asitplus.wallet.lib.oidvci +package at.asitplus.openid -import at.asitplus.wallet.lib.data.InstantLongSerializer +import at.asitplus.signum.indispensable.josef.io.InstantLongSerializer import kotlinx.datetime.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OidcUserInfoExtended.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/OidcUserInfoExtended.kt similarity index 96% rename from vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OidcUserInfoExtended.kt rename to openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/OidcUserInfoExtended.kt index acf26ebb2..01c4e84de 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OidcUserInfoExtended.kt +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/OidcUserInfoExtended.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.oidvci +package at.asitplus.openid import at.asitplus.KmmResult import at.asitplus.KmmResult.Companion.wrap diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OpenIdConstants.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/OpenIdConstants.kt similarity index 89% rename from vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OpenIdConstants.kt rename to openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/OpenIdConstants.kt index 2242a2f08..a0f9ce73e 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OpenIdConstants.kt +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/OpenIdConstants.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.oidc +package at.asitplus.openid import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable @@ -30,18 +30,22 @@ object OpenIdConstants { const val PREFIX_DID_KEY = "did:key" + /** OID4VCI support binding keys to [at.asitplus.signum.indispensable.josef.JsonWebKey] */ + const val BINDING_METHOD_JWK = "jwk" + const val PATH_WELL_KNOWN_CREDENTIAL_ISSUER = "/.well-known/openid-credential-issuer" + const val PATH_WELL_KNOWN_OPENID_CONFIGURATION = "/.well-known/openid-configuration" + const val SCOPE_OPENID = "openid" const val SCOPE_PROFILE = "profile" const val CODE_CHALLENGE_METHOD_SHA256 = "S256" - /** - * To be used in [at.asitplus.wallet.lib.oidvci.AuthorizationDetails.type] - */ - const val CREDENTIAL_TYPE_OPENID = "openid_credential" + const val PROOF_JWT_TYPE = "openid4vci-proof+jwt" + + const val PROOF_CWT_TYPE = "openid4vci-proof+cwt" @Serializable(with = ProofType.Serializer::class) sealed class ProofType(val stringRepresentation: String) { @@ -54,43 +58,28 @@ object OpenIdConstants { companion object { private const val STRING_JWT = "jwt" private const val STRING_CWT = "cwt" - private const val STRING_JWT_HEADER = "openid4vci-proof+jwt" - private const val STRING_CWT_HEADER = "openid4vci-proof+cwt" - } /** - * Proof type in [at.asitplus.wallet.lib.oidvci.CredentialRequestProof] + * Proof type in [at.asitplus.openid.CredentialRequestProof] */ - @Serializable(with = ProofType.Serializer::class) + @Serializable(with = Serializer::class) object JWT : ProofType(STRING_JWT) /** - * Proof type in [at.asitplus.wallet.lib.oidvci.CredentialRequestProof] + * Proof type in [at.asitplus.openid.CredentialRequestProof] + * + * Removed in OID4VCI Draft 14, kept here for a bit of backwards-compatibility */ - @Serializable(with = ProofType.Serializer::class) + @Serializable(with = Serializer::class) object CWT : ProofType(STRING_CWT) - //TODO why are these located here? - /** - * Constant from OID4VCI - */ - @Serializable(with = ProofType.Serializer::class) - object JWT_HEADER_TYPE : ProofType(STRING_JWT_HEADER) - - /** - * Constant from OID4VCI - */ - @Serializable(with = ProofType.Serializer::class) - object CWT_HEADER_TYPE : ProofType(STRING_CWT_HEADER) - /** * Any proof type not natively supported by this library */ - @Serializable(with = ProofType.Serializer::class) + @Serializable(with = Serializer::class) class OTHER(stringRepresentation: String) : ProofType(stringRepresentation) - object Serializer : KSerializer { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor(serialName = "ProofType", PrimitiveKind.STRING) @@ -99,8 +88,6 @@ object OpenIdConstants { return when (val str = decoder.decodeString()) { STRING_JWT -> JWT STRING_CWT -> CWT - STRING_CWT_HEADER -> CWT_HEADER_TYPE - STRING_JWT_HEADER -> JWT_HEADER_TYPE else -> OTHER(str) } } @@ -138,7 +125,7 @@ object OpenIdConstants { * Wallet in advance of the Authorization Request. The Verifier metadata is obtained using RFC7591 or * through out-of-band mechanisms. */ - @Serializable(with = ClientIdScheme.Serializer::class) + @Serializable(with = Serializer::class) object PRE_REGISTERED : ClientIdScheme(STRING_PRE_REGISTERED) /** @@ -147,7 +134,7 @@ object OpenIdConstants { * Authorization Request parameter, and all Verifier metadata parameters MUST be passed using the * `client_metadata` or `client_metadata_uri` parameter. */ - @Serializable(with = ClientIdScheme.Serializer::class) + @Serializable(with = Serializer::class) object REDIRECT_URI : ClientIdScheme(STRING_REDIRECT_URI) /** @@ -164,7 +151,7 @@ object OpenIdConstants { * to freely choose the `redirect_uri` value. If not, the FQDN of the `redirect_uri` value MUST match the * Client Identifier. */ - @Serializable(with = ClientIdScheme.Serializer::class) + @Serializable(with = Serializer::class) object X509_SAN_DNS : ClientIdScheme(STRING_X509_SAN_DNS) /** @@ -181,7 +168,7 @@ object OpenIdConstants { * to freely choose the `redirect_uri` value. If not, the FQDN of the `redirect_uri` value MUST match the * Client Identifier. */ - @Serializable(with = ClientIdScheme.Serializer::class) + @Serializable(with = Serializer::class) object X509_SAN_URI : ClientIdScheme(STRING_X509_SAN_URI) /** @@ -192,7 +179,7 @@ object OpenIdConstants { * `client_metadata_uri` parameter MUST NOT be present in the Authorization Request when this Client * Identifier scheme is used. */ - @Serializable(with = ClientIdScheme.Serializer::class) + @Serializable(with = Serializer::class) object ENTITY_ID : ClientIdScheme(STRING_ENTITY_ID) /** @@ -204,7 +191,7 @@ object OpenIdConstants { * the Verifier. All Verifier metadata other than the public key MUST be obtained from the `client_metadata` * or the `client_metadata_uri` parameter. */ - @Serializable(with = ClientIdScheme.Serializer::class) + @Serializable(with = Serializer::class) object DID : ClientIdScheme(STRING_DID) /** @@ -220,13 +207,13 @@ object OpenIdConstants { * the `redirect_uris` claim entries. All Verifier metadata other than the public key MUST be obtained from the * `client_metadata` or the `client_metadata_uri parameter`. */ - @Serializable(with = ClientIdScheme.Serializer::class) + @Serializable(with = Serializer::class) object VERIFIER_ATTESTATION : ClientIdScheme(STRING_VERIFIER_ATTESTATION) /** * Any not natively supported client id scheme, so it can still be parsed */ - @Serializable(with = ClientIdScheme.Serializer::class) + @Serializable(with = Serializer::class) class OTHER(stringRepresentation: String) : ClientIdScheme(stringRepresentation) object Serializer : KSerializer { @@ -277,7 +264,7 @@ object OpenIdConstants { * to the Verifier, or it can end with a redirect that follows the HTTPS POST request, if the Verifier responds * with a redirect URI to the Wallet. */ - @Serializable(with = ResponseMode.Serializer::class) + @Serializable(with = Serializer::class) object DIRECT_POST : ResponseMode(STRING_DIRECT_POST) /** @@ -286,27 +273,27 @@ object OpenIdConstants { * containing the JWT as defined in Section 4.1. of JARM and Section 6.3 in the body of an HTTPS POST request * using the `application/x-www-form-urlencoded` content type. */ - @Serializable(with = ResponseMode.Serializer::class) + @Serializable(with = Serializer::class) object DIRECT_POST_JWT : ResponseMode(STRING_DIRECT_POST_JWT) /** * OAuth 2.0: In this mode, Authorization Response parameters are encoded in the query string added to the * `redirect_uri` when redirecting back to the Client. */ - @Serializable(with = ResponseMode.Serializer::class) + @Serializable(with = Serializer::class) object QUERY : ResponseMode(STRING_QUERY) /** * OAuth 2.0: In this mode, Authorization Response parameters are encoded in the fragment added to the * `redirect_uri` when redirecting back to the Client. */ - @Serializable(with = ResponseMode.Serializer::class) + @Serializable(with = Serializer::class) object FRAGMENT : ResponseMode(STRING_FRAGMENT) /** * Any not natively supported Client ID Scheme, so it can still be parsed */ - @Serializable(with = ResponseMode.Serializer::class) + @Serializable(with = Serializer::class) class OTHER(stringRepresentation: String) : ResponseMode(stringRepresentation) object Serializer : KSerializer { diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/RelyingPartyMetadata.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/RelyingPartyMetadata.kt similarity index 98% rename from vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/RelyingPartyMetadata.kt rename to openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/RelyingPartyMetadata.kt index 4a8b90b62..bf4d6adc2 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/RelyingPartyMetadata.kt +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/RelyingPartyMetadata.kt @@ -1,11 +1,11 @@ -package at.asitplus.wallet.lib.oidc +package at.asitplus.openid import at.asitplus.KmmResult.Companion.wrap import at.asitplus.signum.indispensable.josef.JsonWebKeySet import at.asitplus.signum.indispensable.josef.JweAlgorithm import at.asitplus.signum.indispensable.josef.JweEncryption import at.asitplus.signum.indispensable.josef.JwsAlgorithm -import at.asitplus.wallet.lib.data.dif.FormatHolder +import at.asitplus.dif.FormatHolder import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/mdl/RequestedCredentialClaimSpecification.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/RequestedCredentialClaimSpecification.kt similarity index 92% rename from vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/mdl/RequestedCredentialClaimSpecification.kt rename to openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/RequestedCredentialClaimSpecification.kt index f6d961d69..54d7c5bd6 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/mdl/RequestedCredentialClaimSpecification.kt +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/RequestedCredentialClaimSpecification.kt @@ -1,6 +1,5 @@ -package at.asitplus.wallet.lib.oidvci.mdl +package at.asitplus.openid -import at.asitplus.wallet.lib.oidvci.DisplayProperties import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedAlgorithmsContainer.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/SupportedAlgorithmsContainer.kt similarity index 97% rename from vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedAlgorithmsContainer.kt rename to openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/SupportedAlgorithmsContainer.kt index f74a4836c..ff09a1aff 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedAlgorithmsContainer.kt +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/SupportedAlgorithmsContainer.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.oidvci +package at.asitplus.openid import at.asitplus.signum.indispensable.josef.JsonWebAlgorithm import at.asitplus.signum.indispensable.josef.JweAlgorithm diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedCredentialFormat.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/SupportedCredentialFormat.kt similarity index 98% rename from vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedCredentialFormat.kt rename to openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/SupportedCredentialFormat.kt index e9944b107..8b0ec692f 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedCredentialFormat.kt +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/SupportedCredentialFormat.kt @@ -1,6 +1,5 @@ -package at.asitplus.wallet.lib.oidvci +package at.asitplus.openid -import at.asitplus.wallet.lib.oidvci.mdl.RequestedCredentialClaimSpecification import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement @@ -14,6 +13,7 @@ import kotlinx.serialization.json.encodeToJsonElement * being offered. */ @Serializable +@ConsistentCopyVisibility data class SupportedCredentialFormat private constructor( /** * OID4VCI: REQUIRED. A JSON string identifying the format of this credential, e.g. `jwt_vc_json` or `ldp_vc`. diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedCredentialFormatDefinition.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/SupportedCredentialFormatDefinition.kt similarity index 74% rename from vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedCredentialFormatDefinition.kt rename to openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/SupportedCredentialFormatDefinition.kt index b679e3b60..42f25d3cc 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SupportedCredentialFormatDefinition.kt +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/SupportedCredentialFormatDefinition.kt @@ -1,6 +1,5 @@ -package at.asitplus.wallet.lib.oidvci +package at.asitplus.openid -import at.asitplus.wallet.lib.oidvci.mdl.RequestedCredentialClaimSpecification import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -16,7 +15,7 @@ data class SupportedCredentialFormatDefinition( * according to (VC_DATA), Section 4.3, e.g. `VerifiableCredential`, `UniversityDegreeCredential` */ @SerialName("type") - val types: Collection? = null, + val types: Set? = null, /** * OID4VCI: @@ -27,7 +26,4 @@ data class SupportedCredentialFormatDefinition( @SerialName("credentialSubject") val credentialSubject: Map? = null, - // TODO is present in EUDIW issuer ... but is this really valid? - @SerialName("claims") - val claims: Map? = null, ) \ No newline at end of file diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/TokenRequestParameters.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/TokenRequestParameters.kt similarity index 62% rename from vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/TokenRequestParameters.kt rename to openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/TokenRequestParameters.kt index afe3ac72c..947f7f5f9 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/TokenRequestParameters.kt +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/TokenRequestParameters.kt @@ -1,8 +1,7 @@ -package at.asitplus.wallet.lib.oidvci +package at.asitplus.openid import at.asitplus.KmmResult import at.asitplus.KmmResult.Companion.wrap -import at.asitplus.wallet.lib.oidc.jsonSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString @@ -23,6 +22,23 @@ data class TokenRequestParameters( @SerialName("code") val code: String? = null, + /** + * RFC6749: OPTIONAL. The authorization and token endpoints allow the client to specify the + * scope of the access request using the "scope" request parameter. In + * turn, the authorization server uses the "scope" response parameter to + * inform the client of the scope of the access token issued. + */ + @SerialName("scope") + val scope: String? = null, + + /** + * RFC8707: When requesting a token, the client can indicate the desired target service(s) where it intends to use + * that token by way of the [resource] parameter and can indicate the desired scope of the requested token using the + * [scope] parameter. + */ + @SerialName("resource") + val resource: String? = null, + /** * RFC6749: * REQUIRED, if the "redirect_uri" parameter was included in the authorization request, @@ -39,8 +55,9 @@ data class TokenRequestParameters( val clientId: String, /** - * OID4VP: TODO Definition - * RFC9396 + * OID4VCI: Credential Issuers MAY support requesting authorization to issue a Credential using this parameter. + * The request parameter `authorization_details` defined in Section 2 of `RFC9396` MUST be used to convey the + * details about the Credentials the Wallet wants to obtain. */ @SerialName("authorization_details") val authorizationDetails: Set? = null, @@ -58,7 +75,7 @@ data class TokenRequestParameters( * This parameter MUST only be used if the [grantType] is `urn:ietf:params:oauth:grant-type:pre-authorized_code`. */ @SerialName("tx_code") - val transactionCode: CredentialOfferGrantsPreAuthCodeTransactionCode? = null, + val transactionCode: String? = null, /** * RFC7636: A cryptographically random string that is used to correlate the authorization request to the token @@ -66,6 +83,15 @@ data class TokenRequestParameters( */ @SerialName("code_verifier") val codeVerifier: String? = null, + + /** + * CSC: OPTIONAL + * Arbitrary data from the signature application. It can be used to handle a + * transaction identifier or other application-spe cific data that may be useful for + * debugging purposes + */ + @SerialName("clientData") + val clientData: String? = null, ) { fun serialize() = jsonSerializer.encodeToString(this) diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/TokenResponseParameters.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/TokenResponseParameters.kt similarity index 86% rename from vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/TokenResponseParameters.kt rename to openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/TokenResponseParameters.kt index d93cb4ba4..fbbb85322 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/TokenResponseParameters.kt +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/TokenResponseParameters.kt @@ -1,9 +1,7 @@ -package at.asitplus.wallet.lib.oidvci +package at.asitplus.openid import at.asitplus.KmmResult import at.asitplus.KmmResult.Companion.wrap -import at.asitplus.wallet.lib.data.DurationSecondsIntSerializer -import at.asitplus.wallet.lib.oidc.jsonSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString @@ -90,6 +88,17 @@ data class TokenResponseParameters( */ @SerialName("authorization_details") val authorizationDetails: Set? = null, + + /** + * CSC: OPTIONAL + * The identifier associated to the credential authorized in the corresponding authorization + * request. This response parameter MAY be present in case the scope credential is used + * in the authorization request along with the parameter “signatureQualifier” and the + * authorization server determined a credentialID in the authorization process to be used + * in subsequent signature operations. + */ + @SerialName("credentialID") + val credentialId: String? = null, ) { fun serialize() = jsonSerializer.encodeToString(this) diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/VpFormatsSupported.kt b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/VpFormatsSupported.kt similarity index 95% rename from vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/VpFormatsSupported.kt rename to openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/VpFormatsSupported.kt index d1a0a60c2..576083de5 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/VpFormatsSupported.kt +++ b/openid-data-classes/src/commonMain/kotlin/at/asitplus/openid/VpFormatsSupported.kt @@ -1,4 +1,4 @@ -package at.asitplus.wallet.lib.oidvci +package at.asitplus.openid import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/settings.gradle.kts b/settings.gradle.kts index 4f658428c..6671f6bad 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -4,10 +4,14 @@ import java.util.* pluginManagement { includeBuild("conventions-vclib") repositories { - maven("https://maven.pkg.jetbrains.space/kotlin/p/dokka/dev") google() gradlePluginPortal() mavenCentral() + maven("https://s01.oss.sonatype.org/content/repositories/snapshots") + maven { + url = uri("https://raw.githubusercontent.com/a-sit-plus/gradle-conventions-plugin/mvn/repo") + name = "aspConventions" + } } } @@ -17,18 +21,26 @@ if (System.getProperty("publishing.excludeIncludedBuilds") != "true") { substitute(module("at.asitplus.signum:indispensable")).using(project(":indispensable")) substitute(module("at.asitplus.signum:indispensable-josef")).using(project(":indispensable-josef")) substitute(module("at.asitplus.signum:indispensable-cosef")).using(project(":indispensable-cosef")) + substitute(module("at.asitplus.signum:supreme")).using(project(":supreme")) } } } else logger.lifecycle("Excluding Signum from this build") rootProject.name = "vc-k" +include(":dif-data-classes") +include(":openid-data-classes") include(":vck") include(":vck-aries") include(":vck-openid") +include(":mobiledrivinglicence") dependencyResolutionManagement { - repositories.add(repositories.mavenCentral()) - repositories.add(repositories.mavenLocal()) + repositories { + maven("https://s01.oss.sonatype.org/content/repositories/snapshots") //Signum snapshot + mavenLocal() + mavenCentral() + } + versionCatalogs { val versions = Properties().apply { kotlin.runCatching { diff --git a/signum b/signum index fc3e39a32..e8f9bd4a0 160000 --- a/signum +++ b/signum @@ -1 +1 @@ -Subproject commit fc3e39a32ddf56fcf01df265437f04d846758716 +Subproject commit e8f9bd4a0be5a9018e6e4ffe137b22191b06c1e1 diff --git a/vck-aries/build.gradle.kts b/vck-aries/build.gradle.kts index fd5359cc5..2465c34bd 100644 --- a/vck-aries/build.gradle.kts +++ b/vck-aries/build.gradle.kts @@ -1,7 +1,6 @@ -import at.asitplus.gradle.commonImplementationDependencies -import at.asitplus.gradle.commonIosExports -import at.asitplus.gradle.exportIosFramework -import at.asitplus.gradle.setupDokka +import at.asitplus.gradle.* +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree.Companion.test plugins { kotlin("multiplatform") @@ -17,9 +16,18 @@ group = "at.asitplus.wallet" version = artifactVersion +setupAndroid() + kotlin { jvm() + + androidTarget { + publishLibraryVariants("release") + @OptIn(ExperimentalKotlinGradlePluginApi::class) + instrumentedTestVariant.sourceSetTree.set(test) + } + iosArm64() iosSimulatorArm64() iosX64() @@ -48,8 +56,8 @@ kotlin { exportIosFramework( "VckAriesKmm", - static = false, - *commonIosExports(), project(":vck") + transitiveExports = false, + project(":vck") ) val javadocJar = setupDokka( diff --git a/vck-aries/src/commonMain/kotlin/at/asitplus/wallet/lib/aries/IssueCredentialProtocol.kt b/vck-aries/src/commonMain/kotlin/at/asitplus/wallet/lib/aries/IssueCredentialProtocol.kt index 0efb92ff3..762c929c6 100644 --- a/vck-aries/src/commonMain/kotlin/at/asitplus/wallet/lib/aries/IssueCredentialProtocol.kt +++ b/vck-aries/src/commonMain/kotlin/at/asitplus/wallet/lib/aries/IssueCredentialProtocol.kt @@ -10,9 +10,9 @@ import at.asitplus.wallet.lib.data.AriesGoalCodeParser import at.asitplus.wallet.lib.data.AttributeIndex import at.asitplus.wallet.lib.data.ConstantIndex import at.asitplus.wallet.lib.data.SchemaIndex -import at.asitplus.wallet.lib.data.dif.CredentialDefinition -import at.asitplus.wallet.lib.data.dif.CredentialManifest -import at.asitplus.wallet.lib.data.dif.SchemaReference +import at.asitplus.dif.CredentialDefinition +import at.asitplus.dif.CredentialManifest +import at.asitplus.dif.SchemaReference import at.asitplus.wallet.lib.iso.IssuerSigned import at.asitplus.wallet.lib.msg.AttachmentFormatReference import at.asitplus.wallet.lib.msg.IssueCredential @@ -143,7 +143,7 @@ class IssueCredentialProtocol( } private fun createOobInvitation(): InternalNextMessage { - val recipientKey = issuer?.keyPair?.identifier + val recipientKey = issuer?.keyMaterial?.identifier ?: return InternalNextMessage.IncorrectState("issuer") val message = OutOfBandInvitation( body = OutOfBandInvitationBody( diff --git a/vck-aries/src/commonMain/kotlin/at/asitplus/wallet/lib/aries/MessageWrapper.kt b/vck-aries/src/commonMain/kotlin/at/asitplus/wallet/lib/aries/MessageWrapper.kt index 719ba12c9..e959f1db7 100644 --- a/vck-aries/src/commonMain/kotlin/at/asitplus/wallet/lib/aries/MessageWrapper.kt +++ b/vck-aries/src/commonMain/kotlin/at/asitplus/wallet/lib/aries/MessageWrapper.kt @@ -2,34 +2,25 @@ package at.asitplus.wallet.lib.aries import at.asitplus.KmmResult import at.asitplus.catching -import at.asitplus.signum.indispensable.josef.JsonWebKey -import at.asitplus.signum.indispensable.josef.JweAlgorithm -import at.asitplus.signum.indispensable.josef.JweEncrypted -import at.asitplus.signum.indispensable.josef.JweEncryption -import at.asitplus.signum.indispensable.josef.JwsSigned -import at.asitplus.signum.indispensable.josef.toJsonWebKey +import at.asitplus.signum.indispensable.josef.* import at.asitplus.wallet.lib.agent.DefaultCryptoService -import at.asitplus.wallet.lib.agent.KeyPairAdapter -import at.asitplus.wallet.lib.jws.DefaultJwsService -import at.asitplus.wallet.lib.jws.DefaultVerifierJwsService -import at.asitplus.wallet.lib.jws.JwsContentTypeConstants -import at.asitplus.wallet.lib.jws.JwsService -import at.asitplus.wallet.lib.jws.VerifierJwsService +import at.asitplus.wallet.lib.agent.KeyMaterial +import at.asitplus.wallet.lib.jws.* import at.asitplus.wallet.lib.msg.JsonWebMessage import io.github.aakira.napier.Napier class MessageWrapper( - private val keyPairAdapter: KeyPairAdapter, - private val jwsService: JwsService = DefaultJwsService(DefaultCryptoService(keyPairAdapter)), + private val keyMaterial: KeyMaterial, + private val jwsService: JwsService = DefaultJwsService(DefaultCryptoService(keyMaterial)), private val verifierJwsService: VerifierJwsService = DefaultVerifierJwsService(), ) { suspend fun parseMessage(it: String): ReceivedMessage { - val jwsSigned = JwsSigned.parse(it).getOrNull() + val jwsSigned = JwsSigned.deserialize(it).getOrNull() if (jwsSigned != null) { - return parseJwsMessage(jwsSigned, it) + return parseJwsMessage(jwsSigned) } - val jweEncrypted = JweEncrypted.parse(it).getOrNull() + val jweEncrypted = JweEncrypted.deserialize(it).getOrNull() if (jweEncrypted != null) return parseJweMessage(jweEncrypted, it) return ReceivedMessage.Error @@ -47,10 +38,10 @@ class MessageWrapper( } val payloadString = joseObject.payload.decodeToString() if (joseObject.header.contentType == JwsContentTypeConstants.DIDCOMM_SIGNED_JSON) { - val parsed = JwsSigned.parse(payloadString).getOrNull() + val parsed = JwsSigned.deserialize(payloadString).getOrNull() ?: return ReceivedMessage.Error .also { Napier.w("Could not parse inner JWS") } - return parseJwsMessage(parsed, payloadString) + return parseJwsMessage(parsed) } if (joseObject.header.contentType == JwsContentTypeConstants.DIDCOMM_PLAIN_JSON) { val message = JsonWebMessage.deserialize(payloadString).getOrElse { ex -> @@ -63,7 +54,7 @@ class MessageWrapper( .also { Napier.w("ContentType not matching") } } - private fun parseJwsMessage(joseObject: JwsSigned, serialized: String): ReceivedMessage { + private fun parseJwsMessage(joseObject: JwsSigned): ReceivedMessage { Napier.d("Parsing JWS ${joseObject.serialize()}") if (!verifierJwsService.verifyJwsObject(joseObject)) return ReceivedMessage.Error diff --git a/vck-aries/src/commonMain/kotlin/at/asitplus/wallet/lib/aries/PresentProofProtocol.kt b/vck-aries/src/commonMain/kotlin/at/asitplus/wallet/lib/aries/PresentProofProtocol.kt index dc2fadace..962c4ede1 100644 --- a/vck-aries/src/commonMain/kotlin/at/asitplus/wallet/lib/aries/PresentProofProtocol.kt +++ b/vck-aries/src/commonMain/kotlin/at/asitplus/wallet/lib/aries/PresentProofProtocol.kt @@ -1,5 +1,6 @@ package at.asitplus.wallet.lib.aries +import at.asitplus.dif.* import at.asitplus.signum.indispensable.josef.JsonWebKey import at.asitplus.signum.indispensable.josef.JwsAlgorithm import at.asitplus.wallet.lib.agent.Holder @@ -7,26 +8,7 @@ import at.asitplus.wallet.lib.agent.Verifier import at.asitplus.wallet.lib.data.AriesGoalCodeParser import at.asitplus.wallet.lib.data.ConstantIndex import at.asitplus.wallet.lib.data.SchemaIndex -import at.asitplus.wallet.lib.data.dif.Constraint -import at.asitplus.wallet.lib.data.dif.ConstraintField -import at.asitplus.wallet.lib.data.dif.ConstraintFilter -import at.asitplus.wallet.lib.data.dif.FormatContainerJwt -import at.asitplus.wallet.lib.data.dif.FormatHolder -import at.asitplus.wallet.lib.data.dif.InputDescriptor -import at.asitplus.wallet.lib.data.dif.PresentationDefinition -import at.asitplus.wallet.lib.data.dif.SchemaReference -import at.asitplus.wallet.lib.msg.AttachmentFormatReference -import at.asitplus.wallet.lib.msg.JsonWebMessage -import at.asitplus.wallet.lib.msg.JwmAttachment -import at.asitplus.wallet.lib.msg.OutOfBandInvitation -import at.asitplus.wallet.lib.msg.OutOfBandInvitationBody -import at.asitplus.wallet.lib.msg.OutOfBandService -import at.asitplus.wallet.lib.msg.Presentation -import at.asitplus.wallet.lib.msg.PresentationBody -import at.asitplus.wallet.lib.msg.RequestPresentation -import at.asitplus.wallet.lib.msg.RequestPresentationAttachment -import at.asitplus.wallet.lib.msg.RequestPresentationAttachmentOptions -import at.asitplus.wallet.lib.msg.RequestPresentationBody +import at.asitplus.wallet.lib.msg.* import com.benasher44.uuid.uuid4 import io.github.aakira.napier.Napier import kotlinx.serialization.encodeToString @@ -204,18 +186,18 @@ class PresentProofProtocol( .also { this.state = State.REQUEST_PRESENTATION_SENT } } + @Suppress("DEPRECATION") private fun buildRequestPresentationMessage( credentialScheme: ConstantIndex.CredentialScheme, parentThreadId: String? = null, ): RequestPresentation? { - val verifierIdentifier = verifier?.keyPair?.identifier ?: return null + val verifierIdentifier = verifier?.keyMaterial?.identifier ?: return null val claimsConstraints = requestedClaims?.map(this::buildConstraintFieldForClaim) ?: listOf() val typeConstraints = buildConstraintFieldForType(credentialScheme.vcType!!) val presentationDefinition = PresentationDefinition( inputDescriptors = listOf( - InputDescriptor( + DifInputDescriptor( name = credentialScheme.vcType!!, - schema = SchemaReference(uri = credentialScheme.schemaUri), constraints = Constraint( fields = claimsConstraints + typeConstraints ) diff --git a/vck-aries/src/commonMain/kotlin/at/asitplus/wallet/lib/msg/RequestCredentialAttachment.kt b/vck-aries/src/commonMain/kotlin/at/asitplus/wallet/lib/msg/RequestCredentialAttachment.kt index e63fcee00..89cfd389f 100644 --- a/vck-aries/src/commonMain/kotlin/at/asitplus/wallet/lib/msg/RequestCredentialAttachment.kt +++ b/vck-aries/src/commonMain/kotlin/at/asitplus/wallet/lib/msg/RequestCredentialAttachment.kt @@ -2,8 +2,8 @@ package at.asitplus.wallet.lib.msg import at.asitplus.KmmResult.Companion.wrap import at.asitplus.wallet.lib.aries.jsonSerializer -import at.asitplus.wallet.lib.data.dif.CredentialManifest -import at.asitplus.wallet.lib.data.dif.PresentationSubmission +import at.asitplus.dif.CredentialManifest +import at.asitplus.dif.PresentationSubmission import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString diff --git a/vck-aries/src/commonMain/kotlin/at/asitplus/wallet/lib/msg/RequestPresentationAttachment.kt b/vck-aries/src/commonMain/kotlin/at/asitplus/wallet/lib/msg/RequestPresentationAttachment.kt index 4a6eed5d0..947bbf28c 100644 --- a/vck-aries/src/commonMain/kotlin/at/asitplus/wallet/lib/msg/RequestPresentationAttachment.kt +++ b/vck-aries/src/commonMain/kotlin/at/asitplus/wallet/lib/msg/RequestPresentationAttachment.kt @@ -2,7 +2,7 @@ package at.asitplus.wallet.lib.msg import at.asitplus.KmmResult.Companion.wrap import at.asitplus.wallet.lib.aries.jsonSerializer -import at.asitplus.wallet.lib.data.dif.PresentationDefinition +import at.asitplus.dif.PresentationDefinition import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString diff --git a/vck-aries/src/commonTest/kotlin/at/asitplus/wallet/lib/aries/DummyCredentialDataProvider.kt b/vck-aries/src/commonTest/kotlin/at/asitplus/wallet/lib/aries/DummyCredentialDataProvider.kt index 8325f1a15..f722e0783 100644 --- a/vck-aries/src/commonTest/kotlin/at/asitplus/wallet/lib/aries/DummyCredentialDataProvider.kt +++ b/vck-aries/src/commonTest/kotlin/at/asitplus/wallet/lib/aries/DummyCredentialDataProvider.kt @@ -8,8 +8,13 @@ import at.asitplus.wallet.lib.agent.CredentialToBeIssued import at.asitplus.wallet.lib.agent.IssuerCredentialDataProvider import at.asitplus.wallet.lib.data.AtomicAttribute2023 import at.asitplus.wallet.lib.data.ConstantIndex +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_DATE_OF_BIRTH +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_FAMILY_NAME +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_GIVEN_NAME +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_PORTRAIT import at.asitplus.wallet.lib.iso.IssuerSignedItem import kotlinx.datetime.Clock +import kotlinx.datetime.LocalDate import kotlin.random.Random import kotlin.time.Duration.Companion.minutes @@ -31,9 +36,10 @@ class DummyCredentialDataProvider( val subjectId = subjectPublicKey.didEncoded val expiration = clock.now() + defaultLifetime val claims = listOf( - ClaimToBeIssued("given_name", "Susanne"), - ClaimToBeIssued("family_name", "Meier"), - ClaimToBeIssued("date_of_birth", "1990-01-01"), + ClaimToBeIssued(CLAIM_GIVEN_NAME, "Susanne"), + ClaimToBeIssued(CLAIM_FAMILY_NAME, "Meier"), + ClaimToBeIssued(CLAIM_DATE_OF_BIRTH, LocalDate.parse("1990-01-01")), + ClaimToBeIssued(CLAIM_PORTRAIT, Random.nextBytes(32)), ) when (representation) { ConstantIndex.CredentialRepresentation.SD_JWT -> CredentialToBeIssued.VcSd( @@ -42,7 +48,7 @@ class DummyCredentialDataProvider( ) ConstantIndex.CredentialRepresentation.PLAIN_JWT -> CredentialToBeIssued.VcJwt( - subject = AtomicAttribute2023(subjectId, "given_name", "Susanne"), + subject = AtomicAttribute2023(subjectId, CLAIM_GIVEN_NAME, "Susanne"), expiration = expiration, ) diff --git a/vck-aries/src/commonTest/kotlin/at/asitplus/wallet/lib/aries/IssueCredentialMessengerConcurrentTest.kt b/vck-aries/src/commonTest/kotlin/at/asitplus/wallet/lib/aries/IssueCredentialMessengerConcurrentTest.kt index 287f80ea7..58e95639f 100644 --- a/vck-aries/src/commonTest/kotlin/at/asitplus/wallet/lib/aries/IssueCredentialMessengerConcurrentTest.kt +++ b/vck-aries/src/commonTest/kotlin/at/asitplus/wallet/lib/aries/IssueCredentialMessengerConcurrentTest.kt @@ -1,12 +1,6 @@ package at.asitplus.wallet.lib.aries -import at.asitplus.wallet.lib.agent.Holder -import at.asitplus.wallet.lib.agent.HolderAgent -import at.asitplus.wallet.lib.agent.Issuer -import at.asitplus.wallet.lib.agent.IssuerAgent -import at.asitplus.wallet.lib.agent.KeyPairAdapter -import at.asitplus.wallet.lib.agent.RandomKeyPairAdapter -import at.asitplus.wallet.lib.agent.SubjectCredentialStore +import at.asitplus.wallet.lib.agent.* import at.asitplus.wallet.lib.data.AtomicAttribute2023 import at.asitplus.wallet.lib.data.ConstantIndex import com.benasher44.uuid.uuid4 @@ -18,14 +12,14 @@ import kotlinx.coroutines.launch class IssueCredentialMessengerConcurrentTest : FreeSpec() { - private lateinit var issuerKeyPair: KeyPairAdapter + private lateinit var issuerKeyPair: KeyMaterial private lateinit var issuer: Issuer private lateinit var issuerServiceEndpoint: String private lateinit var issuerMessenger: IssueCredentialMessenger init { beforeEach { - issuerKeyPair = RandomKeyPairAdapter() + issuerKeyPair = EphemeralKeyWithoutCert() issuer = IssuerAgent(issuerKeyPair, DummyCredentialDataProvider()) issuerServiceEndpoint = "https://example.com/issue?${uuid4()}" issuerMessenger = initIssuerMessenger(ConstantIndex.AtomicAttribute2023) @@ -45,7 +39,7 @@ class IssueCredentialMessengerConcurrentTest : FreeSpec() { } private fun initHolderMessenger(): IssueCredentialMessenger { - val keyPair = RandomKeyPairAdapter() + val keyPair = EphemeralKeyWithoutCert() return IssueCredentialMessenger.newHolderInstance( holder = HolderAgent(keyPair), messageWrapper = MessageWrapper(keyPair), diff --git a/vck-aries/src/commonTest/kotlin/at/asitplus/wallet/lib/aries/IssueCredentialMessengerTest.kt b/vck-aries/src/commonTest/kotlin/at/asitplus/wallet/lib/aries/IssueCredentialMessengerTest.kt index 4272357fc..e197e2f85 100644 --- a/vck-aries/src/commonTest/kotlin/at/asitplus/wallet/lib/aries/IssueCredentialMessengerTest.kt +++ b/vck-aries/src/commonTest/kotlin/at/asitplus/wallet/lib/aries/IssueCredentialMessengerTest.kt @@ -1,25 +1,17 @@ package at.asitplus.wallet.lib.aries -import at.asitplus.wallet.lib.agent.Holder -import at.asitplus.wallet.lib.agent.HolderAgent -import at.asitplus.wallet.lib.agent.Issuer -import at.asitplus.wallet.lib.agent.IssuerAgent -import at.asitplus.wallet.lib.agent.KeyPairAdapter -import at.asitplus.wallet.lib.agent.RandomKeyPairAdapter -import at.asitplus.wallet.lib.agent.SubjectCredentialStore +import at.asitplus.wallet.lib.agent.* import at.asitplus.wallet.lib.data.AtomicAttribute2023 import at.asitplus.wallet.lib.data.ConstantIndex import com.benasher44.uuid.uuid4 import io.kotest.core.spec.style.FreeSpec -import io.kotest.matchers.collections.shouldBeEmpty -import io.kotest.matchers.collections.shouldNotBeEmpty import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf class IssueCredentialMessengerTest : FreeSpec() { - private lateinit var issuerKeyPair: KeyPairAdapter - private lateinit var holderKeyPair: KeyPairAdapter + private lateinit var issuerKeyMaterial: KeyMaterial + private lateinit var holderKeyMaterial: KeyMaterial private lateinit var issuer: Issuer private lateinit var holder: Holder private lateinit var issuerServiceEndpoint: String @@ -28,10 +20,10 @@ class IssueCredentialMessengerTest : FreeSpec() { init { beforeEach { - issuerKeyPair = RandomKeyPairAdapter() - holderKeyPair = RandomKeyPairAdapter() - issuer = IssuerAgent(issuerKeyPair, DummyCredentialDataProvider()) - holder = HolderAgent(holderKeyPair) + issuerKeyMaterial = EphemeralKeyWithoutCert() + holderKeyMaterial = EphemeralKeyWithoutCert() + issuer = IssuerAgent(issuerKeyMaterial, DummyCredentialDataProvider()) + holder = HolderAgent(holderKeyMaterial) issuerServiceEndpoint = "https://example.com/issue?${uuid4()}" holderMessenger = initHolderMessenger(ConstantIndex.AtomicAttribute2023) } @@ -50,14 +42,14 @@ class IssueCredentialMessengerTest : FreeSpec() { private fun initHolderMessenger(scheme: ConstantIndex.CredentialScheme) = IssueCredentialMessenger.newHolderInstance( holder = holder, - messageWrapper = MessageWrapper(holderKeyPair), + messageWrapper = MessageWrapper(holderKeyMaterial), credentialScheme = scheme, ) private fun initIssuerMessenger(scheme: ConstantIndex.CredentialScheme) = IssueCredentialMessenger.newIssuerInstance( issuer = issuer, - messageWrapper = MessageWrapper(issuerKeyPair), + messageWrapper = MessageWrapper(issuerKeyMaterial), serviceEndpoint = issuerServiceEndpoint, credentialScheme = scheme, ) diff --git a/vck-aries/src/commonTest/kotlin/at/asitplus/wallet/lib/aries/IssueCredentialProtocolTest.kt b/vck-aries/src/commonTest/kotlin/at/asitplus/wallet/lib/aries/IssueCredentialProtocolTest.kt index b75e1c7fe..3019d5c0b 100644 --- a/vck-aries/src/commonTest/kotlin/at/asitplus/wallet/lib/aries/IssueCredentialProtocolTest.kt +++ b/vck-aries/src/commonTest/kotlin/at/asitplus/wallet/lib/aries/IssueCredentialProtocolTest.kt @@ -11,18 +11,18 @@ import io.kotest.matchers.types.shouldBeInstanceOf class IssueCredentialProtocolTest : FreeSpec({ - lateinit var issuerKeyPair: KeyPairAdapter - lateinit var holderKeyPair: KeyPairAdapter + lateinit var issuerKeyMaterial: KeyMaterial + lateinit var holderKeyMaterial: KeyMaterial lateinit var issuer: Issuer lateinit var holder: Holder lateinit var issuerProtocol: IssueCredentialProtocol lateinit var holderProtocol: IssueCredentialProtocol beforeEach { - issuerKeyPair = RandomKeyPairAdapter() - holderKeyPair = RandomKeyPairAdapter() - issuer = IssuerAgent(issuerKeyPair, DummyCredentialDataProvider()) - holder = HolderAgent(holderKeyPair) + issuerKeyMaterial = EphemeralKeyWithoutCert() + holderKeyMaterial = EphemeralKeyWithoutCert() + issuer = IssuerAgent(issuerKeyMaterial, DummyCredentialDataProvider()) + holder = HolderAgent(holderKeyMaterial) issuerProtocol = IssueCredentialProtocol.newIssuerInstance( issuer = issuer, serviceEndpoint = "https://example.com/issue?${uuid4()}", @@ -40,17 +40,17 @@ class IssueCredentialProtocolTest : FreeSpec({ val invitationMessage = oobInvitation.message val parsedInvitation = - holderProtocol.parseMessage(invitationMessage, issuerKeyPair.jsonWebKey) + holderProtocol.parseMessage(invitationMessage, issuerKeyMaterial.jsonWebKey) parsedInvitation.shouldBeInstanceOf() val requestCredential = parsedInvitation.message val parsedRequestCredential = - issuerProtocol.parseMessage(requestCredential, holderKeyPair.jsonWebKey) + issuerProtocol.parseMessage(requestCredential, holderKeyMaterial.jsonWebKey) parsedRequestCredential.shouldBeInstanceOf() val issueCredential = parsedRequestCredential.message val parsedIssueCredential = - holderProtocol.parseMessage(issueCredential, issuerKeyPair.jsonWebKey) + holderProtocol.parseMessage(issueCredential, issuerKeyMaterial.jsonWebKey) parsedIssueCredential.shouldBeInstanceOf() val issuedCredential = parsedIssueCredential.lastMessage @@ -62,12 +62,12 @@ class IssueCredentialProtocolTest : FreeSpec({ requestCredential.shouldBeInstanceOf() val parsedRequestCredential = - issuerProtocol.parseMessage(requestCredential.message, holderKeyPair.jsonWebKey) + issuerProtocol.parseMessage(requestCredential.message, holderKeyMaterial.jsonWebKey) parsedRequestCredential.shouldBeInstanceOf() val issueCredential = parsedRequestCredential.message val parsedIssueCredential = - holderProtocol.parseMessage(issueCredential, issuerKeyPair.jsonWebKey) + holderProtocol.parseMessage(issueCredential, issuerKeyMaterial.jsonWebKey) parsedIssueCredential.shouldBeInstanceOf() val issuedCredential = parsedIssueCredential.lastMessage @@ -81,7 +81,7 @@ class IssueCredentialProtocolTest : FreeSpec({ threadId = uuid4().toString(), attachment = JwmAttachment(id = uuid4().toString(), "mimeType", JwmAttachmentData()) ), - issuerKeyPair.jsonWebKey + issuerKeyMaterial.jsonWebKey ) parsed.shouldBeInstanceOf() } @@ -92,7 +92,7 @@ class IssueCredentialProtocolTest : FreeSpec({ val invitationMessage = oobInvitation.message val parsedInvitation = - holderProtocol.parseMessage(invitationMessage, issuerKeyPair.jsonWebKey) + holderProtocol.parseMessage(invitationMessage, issuerKeyMaterial.jsonWebKey) parsedInvitation.shouldBeInstanceOf() val requestCredential = parsedInvitation.message @@ -110,7 +110,7 @@ class IssueCredentialProtocolTest : FreeSpec({ ) ) val parsedRequestCredential = - issuerProtocol.parseMessage(wrongRequestCredential, holderKeyPair.jsonWebKey) + issuerProtocol.parseMessage(wrongRequestCredential, holderKeyMaterial.jsonWebKey) parsedRequestCredential.shouldBeInstanceOf() val problemReport = parsedRequestCredential.message diff --git a/vck-aries/src/commonTest/kotlin/at/asitplus/wallet/lib/aries/PresentProofMessengerTest.kt b/vck-aries/src/commonTest/kotlin/at/asitplus/wallet/lib/aries/PresentProofMessengerTest.kt index d720c892c..ded2fcbce 100644 --- a/vck-aries/src/commonTest/kotlin/at/asitplus/wallet/lib/aries/PresentProofMessengerTest.kt +++ b/vck-aries/src/commonTest/kotlin/at/asitplus/wallet/lib/aries/PresentProofMessengerTest.kt @@ -16,9 +16,9 @@ import kotlin.time.toDuration class PresentProofMessengerTest : FreeSpec() { - private lateinit var holderKeyPair: KeyPairAdapter - private lateinit var verifierKeyPair: KeyPairAdapter - private lateinit var issuerKeyPair: KeyPairAdapter + private lateinit var holderKeyMaterial: KeyMaterial + private lateinit var verifierKeyMaterial: KeyMaterial + private lateinit var issuerKeyMaterial: KeyMaterial private lateinit var holderCredentialStore: SubjectCredentialStore private lateinit var holder: Holder private lateinit var verifier: Verifier @@ -30,13 +30,13 @@ class PresentProofMessengerTest : FreeSpec() { init { beforeEach { - holderKeyPair = RandomKeyPairAdapter() - verifierKeyPair = RandomKeyPairAdapter() - issuerKeyPair = RandomKeyPairAdapter() + holderKeyMaterial = EphemeralKeyWithoutCert() + verifierKeyMaterial = EphemeralKeyWithoutCert() + issuerKeyMaterial = EphemeralKeyWithoutCert() holderCredentialStore = InMemorySubjectCredentialStore() - holder = HolderAgent(holderKeyPair, holderCredentialStore) - verifier = VerifierAgent(verifierKeyPair) - issuer = IssuerAgent(issuerKeyPair, DummyCredentialDataProvider()) + holder = HolderAgent(holderKeyMaterial, holderCredentialStore) + verifier = VerifierAgent(verifierKeyMaterial) + issuer = IssuerAgent(issuerKeyMaterial, DummyCredentialDataProvider()) verifierChallenge = uuid4().toString() holderServiceEndpoint = "https://example.com/present-proof?${uuid4()}" } @@ -44,20 +44,20 @@ class PresentProofMessengerTest : FreeSpec() { "presentProof" { holder.storeCredential( issuer.issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT ).getOrThrow().toStoreCredentialInput() ) val holderMessenger = PresentProofMessenger.newHolderInstance( holder = holder, - messageWrapper = MessageWrapper(holderKeyPair), + messageWrapper = MessageWrapper(holderKeyMaterial), serviceEndpoint = holderServiceEndpoint, credentialScheme = ConstantIndex.AtomicAttribute2023, ) val verifierMessenger = PresentProofMessenger.newVerifierInstance( verifier = verifier, - messageWrapper = MessageWrapper(verifierKeyPair), + messageWrapper = MessageWrapper(verifierKeyMaterial), credentialScheme = ConstantIndex.AtomicAttribute2023, ) @@ -84,7 +84,7 @@ class PresentProofMessengerTest : FreeSpec() { "selectiveDisclosure" { val issuedCredential = issuer.issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT ).getOrThrow() @@ -97,13 +97,13 @@ class PresentProofMessengerTest : FreeSpec() { val holderMessenger = PresentProofMessenger.newHolderInstance( holder = holder, - messageWrapper = MessageWrapper(holderKeyPair), + messageWrapper = MessageWrapper(holderKeyMaterial), serviceEndpoint = "https://example.com", credentialScheme = ConstantIndex.AtomicAttribute2023, ) val verifierMessenger = PresentProofMessenger.newVerifierInstance( verifier = verifier, - messageWrapper = MessageWrapper(verifierKeyPair), + messageWrapper = MessageWrapper(verifierKeyMaterial), challengeForPresentation = verifierChallenge, credentialScheme = ConstantIndex.AtomicAttribute2023, requestedClaims = listOf(attributeName) diff --git a/vck-aries/src/commonTest/kotlin/at/asitplus/wallet/lib/aries/PresentProofProtocolTest.kt b/vck-aries/src/commonTest/kotlin/at/asitplus/wallet/lib/aries/PresentProofProtocolTest.kt index a90bc9c3d..7aa529d87 100644 --- a/vck-aries/src/commonTest/kotlin/at/asitplus/wallet/lib/aries/PresentProofProtocolTest.kt +++ b/vck-aries/src/commonTest/kotlin/at/asitplus/wallet/lib/aries/PresentProofProtocolTest.kt @@ -10,18 +10,18 @@ import io.kotest.matchers.types.shouldBeInstanceOf class PresentProofProtocolTest : FreeSpec({ - lateinit var holderKeyPair: KeyPairAdapter - lateinit var verifierKeyPair: KeyPairAdapter + lateinit var holderKeyMaterial: KeyMaterial + lateinit var verifierKeyMaterial: KeyMaterial lateinit var holder: Holder lateinit var verifier: Verifier lateinit var holderProtocol: PresentProofProtocol lateinit var verifierProtocol: PresentProofProtocol beforeEach { - holderKeyPair = RandomKeyPairAdapter() - verifierKeyPair = RandomKeyPairAdapter() - holder = HolderAgent(holderKeyPair) - verifier = VerifierAgent(verifierKeyPair) + holderKeyMaterial = EphemeralKeyWithoutCert() + verifierKeyMaterial = EphemeralKeyWithoutCert() + holder = HolderAgent(holderKeyMaterial) + verifier = VerifierAgent(verifierKeyMaterial) holderProtocol = PresentProofProtocol.newHolderInstance( holder = holder, serviceEndpoint = "https://example.com/", @@ -36,10 +36,10 @@ class PresentProofProtocolTest : FreeSpec({ "presentProofGenericWithInvitation" { holder.storeCredential( IssuerAgent( - RandomKeyPairAdapter(), + EphemeralKeyWithoutCert(), DummyCredentialDataProvider(), ).issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT ).getOrThrow().toStoreCredentialInput() @@ -50,17 +50,17 @@ class PresentProofProtocolTest : FreeSpec({ val invitationMessage = oobInvitation.message val parsedInvitation = - verifierProtocol.parseMessage(invitationMessage, holderKeyPair.jsonWebKey) + verifierProtocol.parseMessage(invitationMessage, holderKeyMaterial.jsonWebKey) parsedInvitation.shouldBeInstanceOf() val requestPresentation = parsedInvitation.message val parsedRequestPresentation = - holderProtocol.parseMessage(requestPresentation, verifierKeyPair.jsonWebKey) + holderProtocol.parseMessage(requestPresentation, verifierKeyMaterial.jsonWebKey) parsedRequestPresentation.shouldBeInstanceOf() val presentation = parsedRequestPresentation.message val parsedPresentation = - verifierProtocol.parseMessage(presentation, holderKeyPair.jsonWebKey) + verifierProtocol.parseMessage(presentation, holderKeyMaterial.jsonWebKey) parsedPresentation.shouldBeInstanceOf() val receivedPresentation = parsedPresentation.lastMessage @@ -70,10 +70,10 @@ class PresentProofProtocolTest : FreeSpec({ "presentProofGenericDirect" { holder.storeCredential( IssuerAgent( - RandomKeyPairAdapter(), + EphemeralKeyWithoutCert(), DummyCredentialDataProvider(), ).issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT ).getOrThrow().toStoreCredentialInput() @@ -83,12 +83,12 @@ class PresentProofProtocolTest : FreeSpec({ requestPresentation.shouldBeInstanceOf() val parsedRequestPresentation = - holderProtocol.parseMessage(requestPresentation.message, verifierKeyPair.jsonWebKey) + holderProtocol.parseMessage(requestPresentation.message, verifierKeyMaterial.jsonWebKey) parsedRequestPresentation.shouldBeInstanceOf() val presentation = parsedRequestPresentation.message val parsedPresentation = - verifierProtocol.parseMessage(presentation, holderKeyPair.jsonWebKey) + verifierProtocol.parseMessage(presentation, holderKeyMaterial.jsonWebKey) parsedPresentation.shouldBeInstanceOf() val receivedPresentation = parsedPresentation.lastMessage @@ -102,7 +102,7 @@ class PresentProofProtocolTest : FreeSpec({ parentThreadId = uuid4().toString(), attachment = JwmAttachment(id = uuid4().toString(), "mimeType", JwmAttachmentData()) ), - holderKeyPair.jsonWebKey + holderKeyMaterial.jsonWebKey ) parsed.shouldBeInstanceOf() } @@ -113,12 +113,12 @@ class PresentProofProtocolTest : FreeSpec({ val invitationMessage = oobInvitation.message val parsedInvitation = - verifierProtocol.parseMessage(invitationMessage, holderKeyPair.jsonWebKey) + verifierProtocol.parseMessage(invitationMessage, holderKeyMaterial.jsonWebKey) parsedInvitation.shouldBeInstanceOf() val requestPresentation = parsedInvitation.message val parsedRequestPresentation = - holderProtocol.parseMessage(requestPresentation, verifierKeyPair.jsonWebKey) + holderProtocol.parseMessage(requestPresentation, verifierKeyMaterial.jsonWebKey) parsedRequestPresentation.shouldBeInstanceOf() val problemReport = parsedRequestPresentation.message diff --git a/vck-openid/build.gradle.kts b/vck-openid/build.gradle.kts index 3280084cc..bb24f4f4e 100644 --- a/vck-openid/build.gradle.kts +++ b/vck-openid/build.gradle.kts @@ -1,7 +1,6 @@ -import at.asitplus.gradle.commonImplementationDependencies -import at.asitplus.gradle.commonIosExports -import at.asitplus.gradle.exportIosFramework -import at.asitplus.gradle.setupDokka +import at.asitplus.gradle.* +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree.Companion.test plugins { kotlin("multiplatform") @@ -17,9 +16,18 @@ group = "at.asitplus.wallet" version = artifactVersion +setupAndroid() + kotlin { jvm() + + androidTarget { + publishLibraryVariants("release") + @OptIn(ExperimentalKotlinGradlePluginApi::class) + instrumentedTestVariant.sourceSetTree.set(test) + } + iosArm64() iosSimulatorArm64() iosX64() @@ -28,6 +36,7 @@ kotlin { commonMain { dependencies { api(project(":vck")) + api(project(":openid-data-classes")) commonImplementationDependencies() } } @@ -56,8 +65,8 @@ kotlin { exportIosFramework( "VckOpenIdKmm", - static = false, - *commonIosExports(), project(":vck") + transitiveExports = false, + project(":vck") ) val javadocJar = setupDokka( diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oauth2/AuthorizationServiceStrategy.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oauth2/AuthorizationServiceStrategy.kt new file mode 100644 index 000000000..46ac49a40 --- /dev/null +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oauth2/AuthorizationServiceStrategy.kt @@ -0,0 +1,17 @@ +package at.asitplus.wallet.lib.oauth2 + +import at.asitplus.openid.AuthenticationRequestParameters +import at.asitplus.openid.AuthorizationDetails +import at.asitplus.openid.OidcUserInfoExtended + +/** + * Strategy to implement authentication and authorization in [SimpleAuthorizationService]. + */ +interface AuthorizationServiceStrategy { + + suspend fun loadUserInfo(request: AuthenticationRequestParameters, code: String): OidcUserInfoExtended? + + fun filterAuthorizationDetails(authorizationDetails: Set): Set + + +} diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oauth2/OAuth2Client.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oauth2/OAuth2Client.kt new file mode 100644 index 000000000..454300458 --- /dev/null +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oauth2/OAuth2Client.kt @@ -0,0 +1,174 @@ +package at.asitplus.wallet.lib.oauth2 + +import at.asitplus.openid.* +import at.asitplus.openid.OpenIdConstants.CODE_CHALLENGE_METHOD_SHA256 +import at.asitplus.openid.OpenIdConstants.GRANT_TYPE_CODE +import at.asitplus.signum.indispensable.io.Base64UrlStrict +import at.asitplus.wallet.lib.iso.sha256 +import at.asitplus.wallet.lib.jws.JwsService +import at.asitplus.wallet.lib.oidvci.* +import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString +import kotlin.random.Random + +/** + * Simple OAuth 2.0 client to authorize the client against an OAuth 2.0 Authorization Server and request tokens. + * + * Can be used in OID4VCI flows, e.g. [WalletService]. + */ +class OAuth2Client( + /** + * Used to create [AuthenticationRequestParameters], [TokenRequestParameters] and [CredentialRequestProof], + * typically a URI. + */ + private val clientId: String = "https://wallet.a-sit.at/app", + /** + * Used to create [AuthenticationRequestParameters] and [TokenRequestParameters]. + */ + private val redirectUrl: String = "$clientId/callback", + /** + * Used to store the code, associated to the state, to first send [AuthenticationRequestParameters.codeChallenge], + * and then [TokenRequestParameters.codeVerifier], see [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636). + */ + private val stateToCodeStore: MapStore = DefaultMapStore(), +) { + + /** + * Send the result as parameters (either POST or GET) to the server at `/authorize` (or more specific + * [OAuth2AuthorizationServerMetadata.authorizationEndpoint]). + * + * Sample ktor code: + * ``` + * val authnRequest = client.createAuthRequest(...) + * val authnResponse = httpClient.get(issuerMetadata.authorizationEndpointUrl!!) { + * url { + * authnRequest.encodeToParameters().forEach { parameters.append(it.key, it.value) } + * } + * } + * val authn = AuthenticationResponseParameters.deserialize(authnResponse.bodyAsText()).getOrThrow() + * ``` + * + * @param state to keep internal state in further requests + * @param scope in OID4VCI flows the value `scope` from [IssuerMetadata.supportedCredentialConfigurations] + * @param authorizationDetails from RFC 9396 OAuth 2.0 Rich Authorization Requests + * @param resource from RFC 8707 Resource Indicators for OAuth 2.0, in OID4VCI flows the value + * of [IssuerMetadata.credentialIssuer] + */ + suspend fun createAuthRequest( + state: String, + authorizationDetails: Set? = null, + scope: String? = null, + resource: String? = null, + ) = AuthenticationRequestParameters( + responseType = GRANT_TYPE_CODE, + state = state, + clientId = clientId, + authorizationDetails = authorizationDetails, + scope = scope, + resource = resource, + redirectUrl = redirectUrl, + codeChallenge = generateCodeVerifier(state), + codeChallengeMethod = CODE_CHALLENGE_METHOD_SHA256, + ) + + @OptIn(ExperimentalStdlibApi::class) + private suspend fun generateCodeVerifier(state: String): String { + val codeVerifier = Random.nextBytes(32).toHexString(HexFormat.Default) + stateToCodeStore.put(state, codeVerifier) + return codeVerifier.encodeToByteArray().sha256().encodeToString(Base64UrlStrict) + } + + sealed class AuthorizationForToken { + /** + * Authorization code from an actual OAuth2 Authorization Server, or [SimpleAuthorizationService.authorize] + */ + data class Code(val code: String) : AuthorizationForToken() + + /** + * Pre-auth code from [CredentialOfferGrantsPreAuthCode.preAuthorizedCode] in + * [CredentialOfferGrants.preAuthorizedCode] in [CredentialOffer.grants], + * optionally with a [transactionCode] which is transmitted out-of-band, and may be entered by the user. + */ + data class PreAuthCode( + val preAuthorizedCode: String, + val transactionCode: String? = null + ) : AuthorizationForToken() + } + + /** + * Request token with an authorization code, e.g. from [createAuthRequest], or pre-auth code. + * + * Send the result as POST parameters (form-encoded) to the server at `/token` (or more specific + * [OAuth2AuthorizationServerMetadata.tokenEndpoint]). + * + * Sample ktor code for authorization code: + * ``` + * val authnRequest = client.createAuthRequest(requestOptions) + * val authnResponse = authorizationService.authorize(authnRequest).getOrThrow() + * val code = authnResponse.params.code + * val tokenRequest = client.createTokenRequestParameters(state, AuthorizationForToken.Code(code)) + * val tokenResponse = httpClient.submitForm( + * url = issuerMetadata.tokenEndpointUrl!!, + * formParameters = parameters { + * tokenRequest.encodeToParameters().forEach { append(it.key, it.value) } + * } + * ) + * val token = TokenResponseParameters.deserialize(tokenResponse.bodyAsText()).getOrThrow() + * ``` + * + * Sample ktor code for pre-authn code: + * ``` + * val preAuth = credentialOffer.grants.preAuthorizedCode + * val transactionCode = "..." // get from user input + * val authorization = WalletService.AuthorizationForToken.PreAuthCode(preAuth, transactionCode) + * val tokenRequest = client.createTokenRequestParameters(state, authorization) + * val tokenResponse = httpClient.submitForm( + * url = issuerMetadata.tokenEndpointUrl!!, + * formParameters = parameters { + * tokenRequest.encodeToParameters().forEach { append(it.key, it.value) } + * } + * ) + * val token = TokenResponseParameters.deserialize(tokenResponse.bodyAsText()).getOrThrow() + * ``` + * + * Be sure to include a DPoP header if [OAuth2AuthorizationServerMetadata.dpopSigningAlgValuesSupported] is set, + * see [JwsService.buildDPoPHeader]. + * + * @param state to keep internal state in further requests + * @param authorization for the token endpoint + * @param authorizationDetails from RFC 9396 OAuth 2.0 Rich Authorization Requests + * @param scope in OID4VCI flows the value `scope` from [IssuerMetadata.supportedCredentialConfigurations] + * @param resource from RFC 8707 Resource Indicators for OAuth 2.0, in OID4VCI flows the value + * of [IssuerMetadata.credentialIssuer] + */ + suspend fun createTokenRequestParameters( + state: String, + authorization: AuthorizationForToken, + authorizationDetails: Set? = null, + scope: String? = null, + resource: String? = null, + ) = when (authorization) { + is AuthorizationForToken.Code -> TokenRequestParameters( + grantType = OpenIdConstants.GRANT_TYPE_AUTHORIZATION_CODE, + redirectUrl = redirectUrl, + clientId = clientId, + codeVerifier = stateToCodeStore.remove(state), + authorizationDetails = authorizationDetails, + scope = scope, + resource = resource, + code = authorization.code, + ) + + is AuthorizationForToken.PreAuthCode -> TokenRequestParameters( + grantType = OpenIdConstants.GRANT_TYPE_PRE_AUTHORIZED_CODE, + redirectUrl = redirectUrl, + clientId = clientId, + codeVerifier = stateToCodeStore.remove(state), + authorizationDetails = authorizationDetails, + scope = scope, + resource = resource, + transactionCode = authorization.transactionCode, + preAuthorizedCode = authorization.preAuthorizedCode, + ) + } + +} \ No newline at end of file diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SimpleAuthorizationService.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oauth2/SimpleAuthorizationService.kt similarity index 59% rename from vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SimpleAuthorizationService.kt rename to vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oauth2/SimpleAuthorizationService.kt index dc519de76..2d0ddb9d9 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/SimpleAuthorizationService.kt +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oauth2/SimpleAuthorizationService.kt @@ -1,39 +1,32 @@ -package at.asitplus.wallet.lib.oidvci +package at.asitplus.wallet.lib.oauth2 import at.asitplus.KmmResult import at.asitplus.catching +import at.asitplus.openid.* +import at.asitplus.openid.OpenIdConstants.Errors import at.asitplus.signum.indispensable.io.Base64UrlStrict -import at.asitplus.wallet.lib.data.ConstantIndex import at.asitplus.wallet.lib.iso.sha256 -import at.asitplus.wallet.lib.oidc.AuthenticationRequestParameters -import at.asitplus.wallet.lib.oidc.AuthenticationResponseParameters import at.asitplus.wallet.lib.oidc.AuthenticationResponseResult -import at.asitplus.wallet.lib.oidc.OpenIdConstants -import at.asitplus.wallet.lib.oidc.OpenIdConstants.Errors +import at.asitplus.wallet.lib.oidvci.* import io.github.aakira.napier.Napier import io.ktor.http.* import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlin.time.Duration.Companion.seconds /** * Simple authorization server implementation, to be used for [CredentialIssuer], - * when issuing credentials directly from a local [dataProvider]. + * with the actual authentication and authorization logic implemented in [strategy]. * - * Implemented from [OpenID for Verifiable Credential Issuance] - * (https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html), Draft 13, 2024-02-08. + * Implemented from + * [OpenID for Verifiable Credential Issuance](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html) + * , Draft 14, 2024-08-21. */ class SimpleAuthorizationService( /** - * Source of user data. + * Used to load user data and filter authorization details */ - private val dataProvider: OAuth2DataProvider, - /** - * List of supported schemes. - */ - private val credentialSchemes: Set, + private val strategy: AuthorizationServiceStrategy, /** * Used to create and verify authorization codes during issuing. */ @@ -41,7 +34,7 @@ class SimpleAuthorizationService( /** * Used to create and verify bearer tokens during issuing. */ - private val tokenService: TokenService = DefaultTokenService(), + private val tokenService: NonceService = DefaultNonceService(), /** * Used to provide challenge to clients to include in proof of possession of key material. */ @@ -54,25 +47,22 @@ class SimpleAuthorizationService( * Used to build [OAuth2AuthorizationServerMetadata.authorizationEndpoint], i.e. implementers need to forward requests * to that URI (which starts with [publicContext]) to [authorize]. */ - val authorizationEndpointPath: String = "/authorize", + private val authorizationEndpointPath: String = "/authorize", /** * Used to build [OAuth2AuthorizationServerMetadata.tokenEndpoint], i.e. implementers need to forward requests * to that URI (which starts with [publicContext]) to [token]. */ - val tokenEndpointPath: String = "/token", -) : OAuth2AuthorizationServer { - - private val codeToCodeChallengeMap = mutableMapOf() - private val codeToCodeChallengeMutex = Mutex() + private val tokenEndpointPath: String = "/token", + private val codeToCodeChallengeStore: MapStore = DefaultMapStore(), + private val codeToUserInfoStore: MapStore = DefaultMapStore(), + private val accessTokenToUserInfoStore: MapStore = DefaultMapStore(), +) : OAuth2AuthorizationServerAdapter { - private val codeToUserInfoMap = mutableMapOf() - private val codeToUserInfoMutex = Mutex() - - private val accessTokenToUserInfoMap = mutableMapOf() - private val accessTokenToUserInfoMutex = Mutex() + override val supportsClientNonce: Boolean = true /** - * Serve this result JSON-serialized under `/.well-known/openid-configuration` + * Serve this result JSON-serialized under `/.well-known/openid-configuration`, + * see [OpenIdConstants.PATH_WELL_KNOWN_OPENID_CONFIGURATION] */ val metadata: OAuth2AuthorizationServerMetadata by lazy { OAuth2AuthorizationServerMetadata( @@ -94,21 +84,21 @@ class SimpleAuthorizationService( throw OAuth2Exception(Errors.INVALID_REQUEST, "redirect_uri not set") .also { Napier.w("authorize: client did not set redirect_uri in $request") } - val code = codeService.provideCode().also { - val userInfo = dataProvider.loadUserInfo(request) + val code = codeService.provideCode().also { code -> + val userInfo = strategy.loadUserInfo(request, code) ?: throw OAuth2Exception(Errors.INVALID_REQUEST) .also { Napier.w("authorize: could not load user info from $request") } - codeToUserInfoMutex.withLock { codeToUserInfoMap[it] = userInfo } + codeToUserInfoStore.put(code, userInfo) } val responseParams = AuthenticationResponseParameters( code = code, state = request.state, ) - if (request.codeChallenge != null) { - codeToCodeChallengeMutex.withLock { codeToCodeChallengeMap[code] = request.codeChallenge } + request.codeChallenge?.let { + codeToCodeChallengeStore.put(code, it) } // TODO Also implement POST? - val url = URLBuilder(request.redirectUrl) + val url = URLBuilder(request.redirectUrl!!) .apply { responseParams.encodeToParameters().forEach { this.parameters.append(it.key, it.value) } } .buildString() @@ -125,17 +115,17 @@ class SimpleAuthorizationService( suspend fun token(params: TokenRequestParameters) = catching { val userInfo: OidcUserInfoExtended = when (params.grantType) { OpenIdConstants.GRANT_TYPE_AUTHORIZATION_CODE -> { - if (params.code == null || !codeService.verifyCode(params.code)) + if (params.code == null || !codeService.verifyAndRemove(params.code!!)) throw OAuth2Exception(Errors.INVALID_CODE) .also { Napier.w("token: client did not provide correct code") } - codeToUserInfoMutex.withLock { codeToUserInfoMap[params.code] } + params.code?.let { codeToUserInfoStore.remove(it) } } OpenIdConstants.GRANT_TYPE_PRE_AUTHORIZED_CODE -> { - if (params.preAuthorizedCode == null || !codeService.verifyCode(params.preAuthorizedCode)) + if (params.preAuthorizedCode == null || !codeService.verifyAndRemove(params.preAuthorizedCode!!)) throw OAuth2Exception(Errors.INVALID_GRANT) .also { Napier.w("token: client did not provide pre authorized code") } - codeToUserInfoMutex.withLock { codeToUserInfoMap[params.preAuthorizedCode] } + params.preAuthorizedCode?.let { codeToUserInfoStore.remove(it) } } else -> { @@ -145,26 +135,25 @@ class SimpleAuthorizationService( } ?: throw OAuth2Exception(Errors.INVALID_REQUEST) .also { Napier.w("token: could not load user info for $params}") } - // TODO work out mapping of credential identifiers in authorization details to schemes - val filteredAuthorizationDetails = params.authorizationDetails?.filter { - credentialSchemes.map { it.vcType }.contains(it.credentialConfigurationId) || - credentialSchemes.map { it.sdJwtType }.contains(it.credentialConfigurationId) || - credentialSchemes.map { it.isoDocType }.contains(it.credentialConfigurationId) - }?.toSet() - params.codeVerifier?.let { codeVerifier -> - codeToCodeChallengeMutex.withLock { codeToCodeChallengeMap.remove(params.code) }?.let { codeChallenge -> - val codeChallengeCalculated = codeVerifier.encodeToByteArray().sha256().encodeToString(Base64UrlStrict) - if (codeChallenge != codeChallengeCalculated) { - throw OAuth2Exception(Errors.INVALID_GRANT) - .also { Napier.w("token: client did not provide correct code verifier: $codeVerifier") } + params.code?.let { code -> + codeToCodeChallengeStore.remove(code)?.let { codeChallenge -> + val codeChallengeCalculated = + codeVerifier.encodeToByteArray().sha256().encodeToString(Base64UrlStrict) + if (codeChallenge != codeChallengeCalculated) { + throw OAuth2Exception(Errors.INVALID_GRANT) + .also { Napier.w("token: client did not provide correct code verifier: $codeVerifier") } + } } } } + val filteredAuthorizationDetails = params.authorizationDetails + ?.let { strategy.filterAuthorizationDetails(it) } + TokenResponseParameters( - accessToken = tokenService.provideToken().also { - accessTokenToUserInfoMutex.withLock { accessTokenToUserInfoMap[it] = userInfo } + accessToken = tokenService.provideNonce().also { + accessTokenToUserInfoStore.put(it, userInfo) }, tokenType = OpenIdConstants.TOKEN_TYPE_BEARER, expires = 3600.seconds, @@ -173,24 +162,20 @@ class SimpleAuthorizationService( ).also { Napier.i("token returns $it") } } - override suspend fun providePreAuthorizedCode(): String? { - return codeService.provideCode().also { - val userInfo = dataProvider.loadUserInfo() - ?: return null.also { Napier.w("authorize: could not load user info from data provider") } - codeToUserInfoMutex.withLock { codeToUserInfoMap[it] = userInfo } + override suspend fun providePreAuthorizedCode(user: OidcUserInfoExtended): String = + codeService.provideCode().also { + codeToUserInfoStore.put(it, user) } - } - override suspend fun verifyAndRemoveClientNonce(nonce: String): Boolean { - return clientNonceService.verifyAndRemoveNonce(nonce) - } + override suspend fun verifyClientNonce(nonce: String): Boolean = + clientNonceService.verifyNonce(nonce) override suspend fun getUserInfo(accessToken: String): KmmResult = catching { - if (!tokenService.verifyToken(accessToken)) { + if (!tokenService.verifyNonce(accessToken)) { throw OAuth2Exception(Errors.INVALID_TOKEN) .also { Napier.w("getUserInfo: client did not provide correct token: $accessToken") } } - val result = accessTokenToUserInfoMutex.withLock { accessTokenToUserInfoMap[accessToken] } + val result = accessTokenToUserInfoStore.get(accessToken) ?: throw OAuth2Exception(Errors.INVALID_TOKEN) .also { Napier.w("getUserInfo: could not load user info for $accessToken") } @@ -199,4 +184,4 @@ class SimpleAuthorizationService( } override suspend fun provideMetadata() = KmmResult.success(metadata) -} \ No newline at end of file +} diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationRequest.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationRequest.kt index 0e3001b66..44b40ee30 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationRequest.kt +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationRequest.kt @@ -4,16 +4,14 @@ package at.asitplus.wallet.lib.oidc import at.asitplus.catching +import at.asitplus.dif.rqes.UrlSerializer +import at.asitplus.openid.AuthenticationRequestParameters import at.asitplus.signum.indispensable.josef.JwsSigned import io.ktor.http.* -import kotlinx.serialization.KSerializer -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.UseSerializers +import kotlinx.serialization.* import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encodeToString import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder @@ -57,23 +55,10 @@ internal object JwsSignedSerializer : KSerializer { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("JwsSignedSerializer", PrimitiveKind.STRING) - override fun deserialize(decoder: Decoder): JwsSigned = JwsSigned.parse(decoder.decodeString()).getOrThrow() + override fun deserialize(decoder: Decoder): JwsSigned = JwsSigned.deserialize(decoder.decodeString()).getOrThrow() override fun serialize(encoder: Encoder, value: JwsSigned) { encoder.encodeString(value.serialize()) } -} - -internal object UrlSerializer : KSerializer { - - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("UrlSerializer", PrimitiveKind.STRING) - - override fun deserialize(decoder: Decoder): Url = Url(decoder.decodeString()) - - override fun serialize(encoder: Encoder, value: Url) { - encoder.encodeString(value.toString()) - } - -} +} \ No newline at end of file diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationResponse.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationResponse.kt index ecacaf6d8..67b6726b9 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationResponse.kt +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationResponse.kt @@ -1,5 +1,7 @@ package at.asitplus.wallet.lib.oidc +import at.asitplus.openid.AuthenticationResponseParameters +import at.asitplus.openid.RelyingPartyMetadata import at.asitplus.signum.indispensable.josef.JsonWebKey /** diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationResponseResult.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationResponseResult.kt index 64d6ab4f8..272dacff5 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationResponseResult.kt +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationResponseResult.kt @@ -1,5 +1,7 @@ package at.asitplus.wallet.lib.oidc +import at.asitplus.openid.AuthenticationResponseParameters + /** * Possible outcomes of creating the OIDC Authentication Response */ diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopVerifier.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopVerifier.kt index ccb018ff0..450610964 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopVerifier.kt +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopVerifier.kt @@ -2,62 +2,39 @@ package at.asitplus.wallet.lib.oidc import at.asitplus.KmmResult import at.asitplus.catching -import at.asitplus.signum.indispensable.CryptoPublicKey -import at.asitplus.signum.indispensable.josef.JsonWebKeySet -import at.asitplus.signum.indispensable.josef.JweEncrypted -import at.asitplus.signum.indispensable.josef.JwsHeader -import at.asitplus.signum.indispensable.josef.JwsSigned -import at.asitplus.signum.indispensable.josef.toJsonWebKey -import at.asitplus.signum.indispensable.pki.CertificateChain -import at.asitplus.signum.indispensable.pki.leaf +import at.asitplus.dif.* import at.asitplus.jsonpath.JsonPath import at.asitplus.jsonpath.core.NormalizedJsonPath import at.asitplus.jsonpath.core.NormalizedJsonPathSegment -import at.asitplus.wallet.lib.agent.DefaultCryptoService -import at.asitplus.wallet.lib.agent.DefaultVerifierCryptoService -import at.asitplus.wallet.lib.agent.Verifier -import at.asitplus.wallet.lib.data.ConstantIndex +import at.asitplus.openid.* +import at.asitplus.openid.OpenIdConstants.BINDING_METHOD_JWK +import at.asitplus.openid.OpenIdConstants.ClientIdScheme.* +import at.asitplus.openid.OpenIdConstants.ID_TOKEN +import at.asitplus.openid.OpenIdConstants.PREFIX_DID_KEY +import at.asitplus.openid.OpenIdConstants.SCOPE_OPENID +import at.asitplus.openid.OpenIdConstants.SCOPE_PROFILE +import at.asitplus.openid.OpenIdConstants.URN_TYPE_JWK_THUMBPRINT +import at.asitplus.openid.OpenIdConstants.VP_TOKEN +import at.asitplus.signum.indispensable.josef.* +import at.asitplus.signum.indispensable.pki.CertificateChain +import at.asitplus.signum.indispensable.pki.leaf +import at.asitplus.wallet.lib.agent.* +import at.asitplus.wallet.lib.data.* import at.asitplus.wallet.lib.data.ConstantIndex.supportsSdJwt import at.asitplus.wallet.lib.data.ConstantIndex.supportsVcJwt -import at.asitplus.wallet.lib.data.IsoDocumentParsed -import at.asitplus.wallet.lib.data.SelectiveDisclosureItem -import at.asitplus.wallet.lib.data.VerifiableCredentialSdJwt -import at.asitplus.wallet.lib.data.VerifiablePresentationParsed -import at.asitplus.wallet.lib.data.dif.ClaimFormatEnum -import at.asitplus.wallet.lib.data.dif.Constraint -import at.asitplus.wallet.lib.data.dif.ConstraintField -import at.asitplus.wallet.lib.data.dif.ConstraintFilter -import at.asitplus.wallet.lib.data.dif.FormatContainerJwt -import at.asitplus.wallet.lib.data.dif.FormatHolder -import at.asitplus.wallet.lib.data.dif.InputDescriptor -import at.asitplus.wallet.lib.data.dif.PresentationDefinition -import at.asitplus.wallet.lib.data.dif.PresentationSubmissionDescriptor -import at.asitplus.wallet.lib.data.dif.SchemaReference import at.asitplus.wallet.lib.jws.DefaultJwsService import at.asitplus.wallet.lib.jws.DefaultVerifierJwsService import at.asitplus.wallet.lib.jws.JwsService import at.asitplus.wallet.lib.jws.VerifierJwsService -import at.asitplus.wallet.lib.oidc.OpenIdConstants.ClientIdScheme.REDIRECT_URI -import at.asitplus.wallet.lib.oidc.OpenIdConstants.ClientIdScheme.VERIFIER_ATTESTATION -import at.asitplus.wallet.lib.oidc.OpenIdConstants.ClientIdScheme.X509_SAN_DNS -import at.asitplus.wallet.lib.oidc.OpenIdConstants.ID_TOKEN -import at.asitplus.wallet.lib.oidc.OpenIdConstants.PREFIX_DID_KEY -import at.asitplus.wallet.lib.oidc.OpenIdConstants.SCOPE_OPENID -import at.asitplus.wallet.lib.oidc.OpenIdConstants.SCOPE_PROFILE -import at.asitplus.wallet.lib.oidc.OpenIdConstants.URN_TYPE_JWK_THUMBPRINT -import at.asitplus.wallet.lib.oidc.OpenIdConstants.VP_TOKEN -import at.asitplus.wallet.lib.oidvci.decodeFromPostBody -import at.asitplus.wallet.lib.oidvci.decodeFromUrlQuery -import at.asitplus.wallet.lib.oidvci.encodeToParameters +import at.asitplus.wallet.lib.oidvci.* import com.benasher44.uuid.uuid4 import io.github.aakira.napier.Napier import io.ktor.http.* -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.datetime.Clock import kotlinx.serialization.encodeToString import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive +import kotlin.coroutines.cancellation.CancellationException import kotlin.time.DurationUnit import kotlin.time.toDuration @@ -72,105 +49,78 @@ import kotlin.time.toDuration class OidcSiopVerifier private constructor( private val verifier: Verifier, private val relyingPartyUrl: String?, - private val responseUrl: String?, - private val agentPublicKey: CryptoPublicKey, private val jwsService: JwsService, private val verifierJwsService: VerifierJwsService, timeLeewaySeconds: Long = 300L, private val clock: Clock = Clock.System, - /** - * Verifier Attestation JWT (from OID4VP) to include (in header `jwt`) when creating request objects as JWS, - * to allow the Wallet to verify the authenticity of this Verifier. - */ - private val attestationJwt: JwsSigned?, - private val x5c: CertificateChain?, - private val clientIdScheme: OpenIdConstants.ClientIdScheme + private val nonceService: NonceService = DefaultNonceService(), + private val clientIdScheme: ClientIdScheme = ClientIdScheme.RedirectUri, + private val stateToNonceStore: MapStore = DefaultMapStore(), + private val stateToResponseTypeStore: MapStore = DefaultMapStore(), ) { private val timeLeeway = timeLeewaySeconds.toDuration(DurationUnit.SECONDS) - private val challengeMutex = Mutex() - private val challengeSet = mutableSetOf() - - companion object { - fun newInstance( - verifier: Verifier, - relyingPartyUrl: String, - responseUrl: String? = null, - verifierJwsService: VerifierJwsService = DefaultVerifierJwsService(DefaultVerifierCryptoService()), - jwsService: JwsService = DefaultJwsService(DefaultCryptoService(verifier.keyPair)), - timeLeewaySeconds: Long = 300L, - clock: Clock = Clock.System, - attestationJwt: JwsSigned, - ) = OidcSiopVerifier( - verifier = verifier, - relyingPartyUrl = relyingPartyUrl, - responseUrl = responseUrl, - agentPublicKey = verifier.keyPair.publicKey, - jwsService = jwsService, - verifierJwsService = verifierJwsService, - timeLeewaySeconds = timeLeewaySeconds, - clock = clock, - attestationJwt = attestationJwt, - x5c = null, - clientIdScheme = VERIFIER_ATTESTATION - ) - fun newInstance( - verifier: Verifier, - relyingPartyUrl: String?, - responseUrl: String? = null, - verifierJwsService: VerifierJwsService = DefaultVerifierJwsService(DefaultVerifierCryptoService()), - jwsService: JwsService = DefaultJwsService(DefaultCryptoService(verifier.keyPair)), - timeLeewaySeconds: Long = 300L, - clock: Clock = Clock.System, - x5c: CertificateChain, - ) = OidcSiopVerifier( - verifier = verifier, - relyingPartyUrl = relyingPartyUrl, - responseUrl = responseUrl, - agentPublicKey = verifier.keyPair.publicKey, - jwsService = jwsService, - verifierJwsService = verifierJwsService, - timeLeewaySeconds = timeLeewaySeconds, - clock = clock, - attestationJwt = null, - clientIdScheme = X509_SAN_DNS, - x5c = x5c - ) + sealed class ClientIdScheme(val clientIdScheme: OpenIdConstants.ClientIdScheme) { + /** + * Verifier Attestation JWT to include (in header `jwt`) when creating request objects as JWS, + * to allow the Wallet to verify the authenticity of this Verifier. + * OID4VP client id scheme `verifier attestation`, + * see [at.asitplus.openid.OpenIdConstants.ClientIdScheme.VERIFIER_ATTESTATION]. + */ + data class VerifierAttestation(val attestationJwt: JwsSigned) : ClientIdScheme(VERIFIER_ATTESTATION) + /** + * Certificate chain to include in JWS headers and to extract `client_id` from (in SAN extension), from OID4VP + * client id scheme `x509_san_dns`, + * see [at.asitplus.openid.OpenIdConstants.ClientIdScheme.X509_SAN_DNS]. + */ + data class CertificateSanDns(val chain: CertificateChain) : ClientIdScheme(X509_SAN_DNS) - fun newInstance( - verifier: Verifier, - relyingPartyUrl: String, - responseUrl: String? = null, - verifierJwsService: VerifierJwsService = DefaultVerifierJwsService(DefaultVerifierCryptoService()), - jwsService: JwsService = DefaultJwsService(DefaultCryptoService(verifier.keyPair)), - timeLeewaySeconds: Long = 300L, - clock: Clock = Clock.System, - clientIdScheme: OpenIdConstants.ClientIdScheme = REDIRECT_URI, - ) = OidcSiopVerifier( - verifier = verifier, - relyingPartyUrl = relyingPartyUrl, - responseUrl = responseUrl, - agentPublicKey = verifier.keyPair.publicKey, - jwsService = jwsService, - verifierJwsService = verifierJwsService, - timeLeewaySeconds = timeLeewaySeconds, - clock = clock, - attestationJwt = null, - clientIdScheme = clientIdScheme, - x5c = null - ) + /** + * Simple: `redirect_uri` has to match `client_id` + */ + data object RedirectUri : ClientIdScheme(REDIRECT_URI) } + constructor( + keyMaterial: KeyMaterial = EphemeralKeyWithoutCert(), + verifier: Verifier = VerifierAgent(keyMaterial), + relyingPartyUrl: String? = null, + verifierJwsService: VerifierJwsService = DefaultVerifierJwsService(DefaultVerifierCryptoService()), + jwsService: JwsService = DefaultJwsService(DefaultCryptoService(keyMaterial)), + timeLeewaySeconds: Long = 300L, + clock: Clock = Clock.System, + nonceService: NonceService = DefaultNonceService(), + clientIdScheme: ClientIdScheme = ClientIdScheme.RedirectUri, + /** + * Used to store the nonce, associated to the state, to first send [AuthenticationRequestParameters.nonce], + * and then verify the challenge in the submitted verifiable presentation in + * [AuthenticationResponseParameters.vpToken]. + */ + stateToNonceStore: MapStore = DefaultMapStore(), + stateToResponseTypeStore: MapStore = DefaultMapStore(), + ) : this( + verifier = verifier, + relyingPartyUrl = relyingPartyUrl, + jwsService = jwsService, + verifierJwsService = verifierJwsService, + timeLeewaySeconds = timeLeewaySeconds, + clock = clock, + nonceService = nonceService, + clientIdScheme = clientIdScheme, + stateToNonceStore = stateToNonceStore, + stateToResponseTypeStore = stateToResponseTypeStore, + ) + private val containerJwt = FormatContainerJwt(algorithms = verifierJwsService.supportedAlgorithms.map { it.identifier }) val metadata by lazy { RelyingPartyMetadata( redirectUris = relyingPartyUrl?.let { listOf(it) }, - jsonWebKeySet = JsonWebKeySet(listOf(agentPublicKey.toJsonWebKey())), - subjectSyntaxTypesSupported = setOf(URN_TYPE_JWK_THUMBPRINT, PREFIX_DID_KEY), + jsonWebKeySet = JsonWebKeySet(listOf(verifier.keyMaterial.publicKey.toJsonWebKey())), + subjectSyntaxTypesSupported = setOf(URN_TYPE_JWK_THUMBPRINT, PREFIX_DID_KEY, BINDING_METHOD_JWK), vpFormats = FormatHolder( msoMdoc = containerJwt, jwtVp = containerJwt, @@ -201,10 +151,8 @@ class OidcSiopVerifier private constructor( requestUrl: String, ): String { val urlBuilder = URLBuilder(walletUrl) - val clientId = (x5c?.let { it.leaf.tbsCertificate.subjectAlternativeNames?.dnsNames?.firstOrNull() } - ?: relyingPartyUrl) AuthenticationRequestParameters( - clientId = clientId, + clientId = this.clientId, clientMetadataUri = clientMetadataUrl, requestUri = requestUrl, ).encodeToParameters() @@ -224,43 +172,64 @@ class OidcSiopVerifier private constructor( data class RequestOptions( /** - * Response mode to request, see [OpenIdConstants.ResponseMode] + * Requested credentials, should be at least one */ - val responseMode: OpenIdConstants.ResponseMode? = null, + val credentials: Set, /** - * Required representation, see [ConstantIndex.CredentialRepresentation] + * Response mode to request, see [OpenIdConstants.ResponseMode], + * by default [OpenIdConstants.ResponseMode.FRAGMENT]. + * Setting this to any other value may require setting [responseUrl] too. */ - val representation: ConstantIndex.CredentialRepresentation = ConstantIndex.CredentialRepresentation.PLAIN_JWT, + val responseMode: OpenIdConstants.ResponseMode = OpenIdConstants.ResponseMode.FRAGMENT, /** - * Opaque value which will be returned by the OpenId Provider and also in [AuthnResponseResult] + * Response URL to set in the [AuthenticationRequestParameters.responseUrl], + * required if [responseMode] is set to [OpenIdConstants.ResponseMode.DIRECT_POST] or + * [OpenIdConstants.ResponseMode.DIRECT_POST_JWT]. */ - val state: String? = uuid4().toString(), + val responseUrl: String? = null, /** - * Credential type to request, or `null` to make no restrictions + * Response type to set in [AuthenticationRequestParameters.responseType], + * by default only `vp_token` (as per OpenID4VP spec). + * Be sure to separate values by a space, e.g. `vp_token id_token`. */ - val credentialScheme: ConstantIndex.CredentialScheme? = null, + val responseType: String = VP_TOKEN, /** - * List of attributes that shall be requested explicitly (selective disclosure), - * or `null` to make no restrictions + * Opaque value which will be returned by the OpenId Provider and also in [AuthnResponseResult] */ - val requestedAttributes: List? = null, + val state: String = uuid4().toString(), /** * Optional URL to include [metadata] by reference instead of by value (directly embedding in authn request) */ val clientMetadataUrl: String? = null, /** * Set this value to include metadata with encryption parameters set. Beware if setting this value and also - * [clientMetadataUrl], that the URL shall point to [getCreateMetadataWithEncryption]. + * [clientMetadataUrl], that the URL shall point to [OidcSiopVerifier.metadataWithEncryption]. */ val encryption: Boolean = false, ) + data class RequestOptionsCredential( + /** + * Credential type to request, or `null` to make no restrictions + */ + val credentialScheme: ConstantIndex.CredentialScheme, + /** + * Required representation, see [ConstantIndex.CredentialRepresentation] + */ + val representation: ConstantIndex.CredentialRepresentation = ConstantIndex.CredentialRepresentation.PLAIN_JWT, + /** + * List of attributes that shall be requested explicitly (selective disclosure), + * or `null` to make no restrictions + */ + val requestedAttributes: List? = null, + ) + /** * Creates an OIDC Authentication Request, encoded as query parameters to the [walletUrl]. */ suspend fun createAuthnRequestUrl( walletUrl: String, - requestOptions: RequestOptions = RequestOptions(), + requestOptions: RequestOptions, ): String { val urlBuilder = URLBuilder(walletUrl) createAuthnRequest(requestOptions).encodeToParameters() @@ -274,7 +243,7 @@ class OidcSiopVerifier private constructor( */ suspend fun createAuthnRequestUrlWithRequestObject( walletUrl: String, - requestOptions: RequestOptions = RequestOptions() + requestOptions: RequestOptions, ): KmmResult = catching { val jar = createAuthnRequestAsSignedRequestObject(requestOptions).getOrThrow() val urlBuilder = URLBuilder(walletUrl) @@ -297,7 +266,7 @@ class OidcSiopVerifier private constructor( suspend fun createAuthnRequestUrlWithRequestObjectByReference( walletUrl: String, requestUrl: String, - requestOptions: RequestOptions = RequestOptions() + requestOptions: RequestOptions, ): KmmResult> = catching { val jar = createAuthnRequestAsSignedRequestObject(requestOptions).getOrThrow() val urlBuilder = URLBuilder(walletUrl) @@ -323,20 +292,22 @@ class OidcSiopVerifier private constructor( * ``` */ suspend fun createAuthnRequestAsSignedRequestObject( - requestOptions: RequestOptions = RequestOptions(), + requestOptions: RequestOptions, ): KmmResult = catching { val requestObject = createAuthnRequest(requestOptions) val requestObjectSerialized = jsonSerializer.encodeToString( requestObject.copy(audience = relyingPartyUrl, issuer = relyingPartyUrl) ) + val attestationJwt = (clientIdScheme as? ClientIdScheme.VerifierAttestation)?.attestationJwt?.serialize() + val certificateChain = (clientIdScheme as? ClientIdScheme.CertificateSanDns)?.chain jwsService.createSignedJwsAddingParams( header = JwsHeader( algorithm = jwsService.algorithm, - attestationJwt = attestationJwt?.serialize(), - certificateChain = x5c + attestationJwt = attestationJwt, + certificateChain = certificateChain, ), payload = requestObjectSerialized.encodeToByteArray(), - addJsonWebKey = x5c == null + addJsonWebKey = certificateChain == null, ).getOrThrow() } @@ -347,43 +318,58 @@ class OidcSiopVerifier private constructor( * Callers may serialize the result with `result.encodeToParameters().formUrlEncode()` */ suspend fun createAuthnRequest( - requestOptions: RequestOptions = RequestOptions(), + requestOptions: RequestOptions, ) = AuthenticationRequestParameters( - responseType = "$ID_TOKEN $VP_TOKEN", - clientId = buildClientId(), + responseType = requestOptions.responseType + .also { stateToResponseTypeStore.put(requestOptions.state, it) }, + clientId = clientId, redirectUrl = requestOptions.buildRedirectUrl(), - responseUrl = responseUrl, - clientIdScheme = clientIdScheme, + responseUrl = requestOptions.responseUrl, + clientIdScheme = clientIdScheme.clientIdScheme, scope = requestOptions.buildScope(), - nonce = uuid4().toString().also { challengeMutex.withLock { challengeSet += it } }, - clientMetadata = requestOptions.clientMetadataUrl?.let { null } - ?: if (requestOptions.encryption) metadataWithEncryption else metadata, + nonce = nonceService.provideNonce() + .also { stateToNonceStore.put(requestOptions.state, it) }, + clientMetadata = if (requestOptions.clientMetadataUrl != null) { + null + } else { + if (requestOptions.encryption) metadataWithEncryption else metadata + }, clientMetadataUri = requestOptions.clientMetadataUrl, idTokenType = IdTokenType.SUBJECT_SIGNED.text, responseMode = requestOptions.responseMode, state = requestOptions.state, presentationDefinition = PresentationDefinition( id = uuid4().toString(), - formats = requestOptions.representation.toFormatHolder(), - inputDescriptors = listOf( - requestOptions.toInputDescriptor() - ), + inputDescriptors = requestOptions.credentials.map { + it.toInputDescriptor() + }, ), ) - private fun RequestOptions.buildScope() = listOfNotNull(SCOPE_OPENID, SCOPE_PROFILE, credentialScheme?.sdJwtType, credentialScheme?.vcType, credentialScheme?.isoNamespace) - .joinToString(" ") + private fun RequestOptions.buildScope() = ( + listOf(SCOPE_OPENID, SCOPE_PROFILE) + + credentials.mapNotNull { it.credentialScheme.sdJwtType } + + credentials.mapNotNull { it.credentialScheme.vcType } + + credentials.mapNotNull { it.credentialScheme.isoNamespace } + ).joinToString(" ") + + private val clientId: String? by lazy { + clientIdFromCertificateChain ?: relyingPartyUrl + } - private fun buildClientId() = (x5c?.let { it.leaf.tbsCertificate.subjectAlternativeNames?.dnsNames?.firstOrNull() } - ?: relyingPartyUrl) + private val clientIdFromCertificateChain: String? by lazy { + (clientIdScheme as? ClientIdScheme.CertificateSanDns)?.chain + ?.let { it.leaf.tbsCertificate.subjectAlternativeNames?.dnsNames?.firstOrNull() } + } private fun RequestOptions.buildRedirectUrl() = if ((responseMode == OpenIdConstants.ResponseMode.DIRECT_POST) || (responseMode == OpenIdConstants.ResponseMode.DIRECT_POST_JWT) ) null else relyingPartyUrl - private fun RequestOptions.toInputDescriptor() = InputDescriptor( + //TODO extend for InputDescriptor interface in case QES + private fun RequestOptionsCredential.toInputDescriptor() = DifInputDescriptor( id = buildId(), - schema = listOfNotNull(credentialScheme?.schemaUri?.let { SchemaReference(it) }), + format = toFormatHolder(), constraints = toConstraint(), ) @@ -392,26 +378,24 @@ class OidcSiopVerifier private constructor( * encoding it into the descriptor id as in the following non-normative example fow now: * https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#appendix-A.3.1-4 */ - private fun RequestOptions.buildId() = - if (credentialScheme?.isoDocType != null && representation == ConstantIndex.CredentialRepresentation.ISO_MDOC) + private fun RequestOptionsCredential.buildId() = + if (credentialScheme.isoDocType != null && representation == ConstantIndex.CredentialRepresentation.ISO_MDOC) credentialScheme.isoDocType!! else uuid4().toString() - private fun RequestOptions.toConstraint() = + private fun RequestOptionsCredential.toConstraint() = Constraint(fields = (toAttributeConstraints() + toTypeConstraint()).filterNotNull()) - private fun RequestOptions.toAttributeConstraints() = + private fun RequestOptionsCredential.toAttributeConstraints() = requestedAttributes?.createConstraints(representation, credentialScheme) ?: listOf() - private fun RequestOptions.toTypeConstraint() = credentialScheme?.let { - when (representation) { - ConstantIndex.CredentialRepresentation.PLAIN_JWT -> it.toVcConstraint() - ConstantIndex.CredentialRepresentation.SD_JWT -> it.toSdJwtConstraint() - ConstantIndex.CredentialRepresentation.ISO_MDOC -> null - } + private fun RequestOptionsCredential.toTypeConstraint() = when (representation) { + ConstantIndex.CredentialRepresentation.PLAIN_JWT -> this.credentialScheme.toVcConstraint() + ConstantIndex.CredentialRepresentation.SD_JWT -> this.credentialScheme.toSdJwtConstraint() + ConstantIndex.CredentialRepresentation.ISO_MDOC -> null } - private fun ConstantIndex.CredentialRepresentation.toFormatHolder() = when (this) { + private fun RequestOptionsCredential.toFormatHolder() = when (representation) { ConstantIndex.CredentialRepresentation.PLAIN_JWT -> FormatHolder(jwtVp = containerJwt) ConstantIndex.CredentialRepresentation.SD_JWT -> FormatHolder(jwtSd = containerJwt) ConstantIndex.CredentialRepresentation.ISO_MDOC -> FormatHolder(msoMdoc = containerJwt) @@ -436,10 +420,10 @@ class OidcSiopVerifier private constructor( ) else null private fun List.createConstraints( - credentialRepresentation: ConstantIndex.CredentialRepresentation, + representation: ConstantIndex.CredentialRepresentation, credentialScheme: ConstantIndex.CredentialScheme?, ): Collection = map { - if (credentialRepresentation == ConstantIndex.CredentialRepresentation.ISO_MDOC) + if (representation == ConstantIndex.CredentialRepresentation.ISO_MDOC) credentialScheme.toConstraintField(it) else ConstraintField(path = listOf("\$[${it.quote()}]")) @@ -467,6 +451,11 @@ class OidcSiopVerifier private constructor( */ data class ValidationError(val field: String, val state: String?) : AuthnResponseResult() + /** + * Wallet provided an `id_token`, no `vp_token` (as requested by us!) + */ + data class IdToken(val idToken: at.asitplus.openid.IdToken, val state: String?) : AuthnResponseResult() + /** * Validation results of all returned verifiable presentations */ @@ -528,96 +517,132 @@ class OidcSiopVerifier private constructor( * Validates [AuthenticationResponseParameters] from the Wallet */ suspend fun validateAuthnResponse(params: AuthenticationResponseParameters): AuthnResponseResult { - if (params.response != null) { - JwsSigned.parse(params.response).getOrNull()?.let { jarmResponse -> + val state = params.state + ?: return AuthnResponseResult.ValidationError("state", params.state) + .also { Napier.w("Invalid state: ${params.state}") } + params.response?.let { response -> + JwsSigned.deserialize(response).getOrNull()?.let { jarmResponse -> if (!verifierJwsService.verifyJwsObject(jarmResponse)) { - return AuthnResponseResult.ValidationError("response", params.state) + return AuthnResponseResult.ValidationError("response", state) .also { Napier.w { "JWS of response not verified: ${params.response}" } } } AuthenticationResponseParameters.deserialize(jarmResponse.payload.decodeToString()) .getOrNull()?.let { return validateAuthnResponse(it) } } - JweEncrypted.parse(params.response).getOrNull()?.let { jarmResponse -> - jwsService.decryptJweObject(jarmResponse, params.response).getOrNull()?.let { decrypted -> + JweEncrypted.deserialize(response).getOrNull()?.let { jarmResponse -> + jwsService.decryptJweObject(jarmResponse, response).getOrNull()?.let { decrypted -> AuthenticationResponseParameters.deserialize(decrypted.payload.decodeToString()) .getOrNull()?.let { return validateAuthnResponse(it) } } } } - val idTokenJws = params.idToken - ?: return AuthnResponseResult.ValidationError("idToken", params.state) - .also { Napier.w("Could not parse idToken: $params") } - val jwsSigned = JwsSigned.parse(idTokenJws).getOrNull() - ?: return AuthnResponseResult.ValidationError("idToken", params.state) + val responseType = stateToResponseTypeStore.get(state) + ?: return AuthnResponseResult.ValidationError("state", state) + .also { Napier.w("State not associated with response type: $state") } + + val idToken: IdToken? = if (responseType.contains(ID_TOKEN)) { + params.idToken?.let { idToken -> + catching { + extractValidatedIdToken(idToken) + }.getOrElse { + return AuthnResponseResult.ValidationError("idToken", state) + } + } ?: return AuthnResponseResult.ValidationError("idToken", state) + .also { Napier.w("State not associated with response type: $state") } + } else null + + if (responseType.contains(VP_TOKEN)) { + val expectedNonce = stateToNonceStore.get(state) + ?: return AuthnResponseResult.ValidationError("state", state) + .also { Napier.w("State not associated with nonce: $state") } + val presentationSubmission = params.presentationSubmission + ?: return AuthnResponseResult.ValidationError("presentation_submission", state) + .also { Napier.w("presentation_submission empty") } + val descriptors = presentationSubmission.descriptorMap + ?: return AuthnResponseResult.ValidationError("presentation_submission", state) + .also { Napier.w("presentation_submission contains no descriptors") } + val verifiablePresentation = params.vpToken + ?: return AuthnResponseResult.ValidationError("vp_token is null", state) + .also { Napier.w("No VP in response") } + + val validationResults = descriptors.map { descriptor -> + val relatedPresentation = + JsonPath(descriptor.cumulativeJsonPath).query(verifiablePresentation).first().value + val result = runCatching { + verifyPresentationResult(descriptor, relatedPresentation, expectedNonce) + }.getOrElse { + return AuthnResponseResult.ValidationError("Invalid presentation format", state) + .also { Napier.w("Invalid presentation format: $relatedPresentation") } + } + result.mapToAuthnResponseResult(state) + } + + return if (validationResults.size != 1) { + AuthnResponseResult.VerifiablePresentationValidationResults(validationResults) + } else validationResults[0] + } + + return idToken?.let { AuthnResponseResult.IdToken(it, state) } + ?: AuthnResponseResult.Error("Neither id_token nor vp_token", state) + } + + + @Throws(IllegalArgumentException::class, CancellationException::class) + private suspend fun extractValidatedIdToken(idTokenJws: String): IdToken { + val jwsSigned = JwsSigned.deserialize(idTokenJws).getOrNull() + ?: throw IllegalArgumentException("idToken") .also { Napier.w("Could not parse JWS from idToken: $idTokenJws") } if (!verifierJwsService.verifyJwsObject(jwsSigned)) - return AuthnResponseResult.ValidationError("idToken", params.state) + throw IllegalArgumentException("idToken") .also { Napier.w { "JWS of idToken not verified: $idTokenJws" } } - val idToken = IdToken.deserialize(jwsSigned.payload.decodeToString()).getOrElse { ex -> - return AuthnResponseResult.ValidationError("idToken", params.state) - .also { Napier.w("Could not deserialize idToken: $idTokenJws", ex) } - } + val idToken = IdToken.deserialize(jwsSigned.payload.decodeToString()).getOrThrow() if (idToken.issuer != idToken.subject) - return AuthnResponseResult.ValidationError("iss", params.state) + throw IllegalArgumentException("idToken.iss") .also { Napier.d("Wrong issuer: ${idToken.issuer}, expected: ${idToken.subject}") } - val validAudiences = listOfNotNull(relyingPartyUrl, - x5c?.leaf?.tbsCertificate?.subjectAlternativeNames?.dnsNames?.firstOrNull()) + val validAudiences = listOfNotNull(relyingPartyUrl, clientIdFromCertificateChain) if (idToken.audience !in validAudiences) - return AuthnResponseResult.ValidationError("aud", params.state) + throw IllegalArgumentException("idToken.aud") .also { Napier.d("audience not valid: ${idToken.audience}") } if (idToken.expiration < (clock.now() - timeLeeway)) - return AuthnResponseResult.ValidationError("exp", params.state) + throw IllegalArgumentException("idToken.exp") .also { Napier.d("expirationDate before now: ${idToken.expiration}") } if (idToken.issuedAt > (clock.now() + timeLeeway)) - return AuthnResponseResult.ValidationError("iat", params.state) + throw IllegalArgumentException("idToken.iat") .also { Napier.d("issuedAt after now: ${idToken.issuedAt}") } - challengeMutex.withLock { - if (!challengeSet.remove(idToken.nonce)) - return AuthnResponseResult.ValidationError("nonce", params.state) - .also { Napier.d("nonce not valid: ${idToken.nonce}, not known to us") } + if (!nonceService.verifyAndRemoveNonce(idToken.nonce)) { + throw IllegalArgumentException("idToken.nonce") + .also { Napier.d("nonce not valid: ${idToken.nonce}, not known to us") } } if (idToken.subjectJwk == null) - return AuthnResponseResult.ValidationError("nonce", params.state) + throw IllegalArgumentException("idToken.sub_jwk") .also { Napier.d("sub_jwk is null") } - if (idToken.subject != idToken.subjectJwk.jwkThumbprint) - return AuthnResponseResult.ValidationError("sub", params.state) + if (idToken.subject != idToken.subjectJwk!!.jwkThumbprint) + throw IllegalArgumentException("idToken.sub") .also { Napier.d("subject does not equal thumbprint of sub_jwk: ${idToken.subject}") } + return idToken + } - val presentationSubmission = params.presentationSubmission - ?: return AuthnResponseResult.ValidationError("presentation_submission", params.state) - .also { Napier.w("presentation_submission empty") } - val descriptors = presentationSubmission.descriptorMap - ?: return AuthnResponseResult.ValidationError("presentation_submission", params.state) - .also { Napier.w("presentation_submission contains no descriptors") } - val verifiablePresentation = params.vpToken - ?: return AuthnResponseResult.ValidationError("vp_token is null", params.state) - .also { Napier.w("No VP in response") } - - val validationResults = descriptors.map { descriptor -> - val relatedPresentation = - JsonPath(descriptor.cumulativeJsonPath).query(verifiablePresentation).first().value - val result = runCatching { - when (descriptor.format) { - ClaimFormatEnum.JWT_VP -> verifyJwtVpResult(relatedPresentation, idToken) - ClaimFormatEnum.JWT_SD -> verifyJwtSdResult(relatedPresentation, idToken) - ClaimFormatEnum.MSO_MDOC -> verifyMsoMdocResult(relatedPresentation, idToken) - else -> throw IllegalArgumentException() - } - }.getOrElse { - return AuthnResponseResult.ValidationError("Invalid presentation format", params.state) - .also { Napier.w("Invalid presentation format: $relatedPresentation") } - } - result.mapToAuthnResponseResult(params.state) + /** + * Extract and verifies verifiable presentations, according to format defined in + * [OpenID for VCI](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html), + * as referenced by [OpenID for VP](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html). + */ + private fun verifyPresentationResult( + descriptor: PresentationSubmissionDescriptor, + relatedPresentation: JsonElement, + challenge: String + ) = when (descriptor.format) { + ClaimFormatEnum.JWT_SD, + ClaimFormatEnum.MSO_MDOC, + ClaimFormatEnum.JWT_VP -> when (relatedPresentation) { + is JsonPrimitive -> verifier.verifyPresentation(relatedPresentation.content, challenge) + else -> throw IllegalArgumentException() } - return if (validationResults.size != 1) { - AuthnResponseResult.VerifiablePresentationValidationResults(validationResults) - } else validationResults[0] + else -> throw IllegalArgumentException() } - private fun Verifier.VerifyPresentationResult.mapToAuthnResponseResult( - state: String? - ) = when (this) { + private fun Verifier.VerifyPresentationResult.mapToAuthnResponseResult(state: String) = when (this) { is Verifier.VerifyPresentationResult.InvalidStructure -> AuthnResponseResult.Error("parse vp failed", state) .also { Napier.w("VP error: $this") } @@ -639,35 +664,6 @@ class OidcSiopVerifier private constructor( .also { Napier.i("VP success: $this") } } - private fun verifyMsoMdocResult( - relatedPresentation: JsonElement, - idToken: IdToken - ) = when (relatedPresentation) { - // must be a string - // source: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#appendix-A.2.5-1 - is JsonPrimitive -> verifier.verifyPresentation(relatedPresentation.content, idToken.nonce) - else -> throw IllegalArgumentException() - } - - private fun verifyJwtSdResult( - relatedPresentation: JsonElement, - idToken: IdToken - ) = when (relatedPresentation) { - // must be a string - // source: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#appendix-A.3.5-1 - is JsonPrimitive -> verifier.verifyPresentation(relatedPresentation.content, idToken.nonce) - else -> throw IllegalArgumentException() - } - - private fun verifyJwtVpResult( - relatedPresentation: JsonElement, - idToken: IdToken - ) = when (relatedPresentation) { - // must be a string - // source: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#appendix-A.1.1.5-1 - is JsonPrimitive -> verifier.verifyPresentation(relatedPresentation.content, idToken.nonce) - else -> throw IllegalArgumentException() - } } diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopWallet.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopWallet.kt index ea663c9c8..d91639c93 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopWallet.kt +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopWallet.kt @@ -2,32 +2,28 @@ package at.asitplus.wallet.lib.oidc import at.asitplus.KmmResult import at.asitplus.catching +import at.asitplus.dif.PresentationDefinition +import at.asitplus.openid.* +import at.asitplus.openid.OpenIdConstants.BINDING_METHOD_JWK +import at.asitplus.openid.OpenIdConstants.Errors +import at.asitplus.openid.OpenIdConstants.ID_TOKEN +import at.asitplus.openid.OpenIdConstants.PREFIX_DID_KEY +import at.asitplus.openid.OpenIdConstants.SCOPE_OPENID +import at.asitplus.openid.OpenIdConstants.URN_TYPE_JWK_THUMBPRINT +import at.asitplus.openid.OpenIdConstants.VP_TOKEN import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.signum.indispensable.josef.JsonWebKey import at.asitplus.signum.indispensable.josef.JsonWebKeySet import at.asitplus.signum.indispensable.josef.JwsSigned import at.asitplus.signum.indispensable.josef.toJsonWebKey -import at.asitplus.wallet.lib.agent.DefaultCryptoService -import at.asitplus.wallet.lib.agent.Holder -import at.asitplus.wallet.lib.agent.HolderAgent -import at.asitplus.wallet.lib.agent.CredentialSubmission -import at.asitplus.wallet.lib.agent.KeyPairAdapter -import at.asitplus.wallet.lib.agent.RandomKeyPairAdapter -import at.asitplus.wallet.lib.data.dif.PresentationDefinition +import at.asitplus.wallet.lib.agent.* import at.asitplus.wallet.lib.jws.DefaultJwsService import at.asitplus.wallet.lib.jws.JwsService -import at.asitplus.wallet.lib.oidc.OpenIdConstants.Errors -import at.asitplus.wallet.lib.oidc.OpenIdConstants.ID_TOKEN -import at.asitplus.wallet.lib.oidc.OpenIdConstants.PREFIX_DID_KEY -import at.asitplus.wallet.lib.oidc.OpenIdConstants.SCOPE_OPENID -import at.asitplus.wallet.lib.oidc.OpenIdConstants.URN_TYPE_JWK_THUMBPRINT -import at.asitplus.wallet.lib.oidc.OpenIdConstants.VP_TOKEN import at.asitplus.wallet.lib.oidc.helper.AuthenticationRequestParser import at.asitplus.wallet.lib.oidc.helper.AuthenticationResponseFactory import at.asitplus.wallet.lib.oidc.helper.AuthorizationRequestValidator import at.asitplus.wallet.lib.oidc.helper.PresentationFactory import at.asitplus.wallet.lib.oidc.helpers.AuthorizationResponsePreparationState -import at.asitplus.wallet.lib.oidvci.IssuerMetadata import at.asitplus.wallet.lib.oidvci.OAuth2Exception import io.github.aakira.napier.Napier import io.matthewnelson.encoding.base16.Base16 @@ -68,40 +64,50 @@ class OidcSiopWallet( */ private val scopePresentationDefinitionRetriever: ScopePresentationDefinitionRetriever, ) { - companion object { - fun newDefaultInstance( - keyPairAdapter: KeyPairAdapter = RandomKeyPairAdapter(), - holder: Holder = HolderAgent(keyPairAdapter), - jwsService: JwsService = DefaultJwsService(DefaultCryptoService(keyPairAdapter)), - clock: Clock = Clock.System, - clientId: String = "https://wallet.a-sit.at/", - remoteResourceRetriever: RemoteResourceRetrieverFunction = { null }, - requestObjectJwsVerifier: RequestObjectJwsVerifier = RequestObjectJwsVerifier { _, _ -> true }, - scopePresentationDefinitionRetriever: ScopePresentationDefinitionRetriever = { null }, - ): OidcSiopWallet { - return OidcSiopWallet( - holder = holder, - agentPublicKey = keyPairAdapter.publicKey, - jwsService = jwsService, - clock = clock, - clientId = clientId, - remoteResourceRetriever = remoteResourceRetriever, - requestObjectJwsVerifier = requestObjectJwsVerifier, - scopePresentationDefinitionRetriever = scopePresentationDefinitionRetriever, - ) - } - } + constructor( + keyMaterial: KeyMaterial = EphemeralKeyWithoutCert(), + holder: Holder = HolderAgent(keyMaterial), + jwsService: JwsService = DefaultJwsService(DefaultCryptoService(keyMaterial)), + clock: Clock = Clock.System, + clientId: String = "https://wallet.a-sit.at/", + /** + * Need to implement if resources are defined by reference, i.e. the URL for a [JsonWebKeySet], + * or the authentication request itself as `request_uri`, or `presentation_definition_uri`. + * Implementations need to fetch the url passed in, and return either the body, if there is one, + * or the HTTP header `Location`, i.e. if the server sends the request object as a redirect. + */ + remoteResourceRetriever: RemoteResourceRetrieverFunction = { null }, + /** + * Need to verify the request object serialized as a JWS, + * which may be signed with a pre-registered key (see [OpenIdConstants.ClientIdScheme.PRE_REGISTERED]). + */ + requestObjectJwsVerifier: RequestObjectJwsVerifier = RequestObjectJwsVerifier { _, _ -> true }, + /** + * Need to implement if the presentation definition needs to be derived from a scope value. + * See [ScopePresentationDefinitionRetriever] for implementation instructions. + */ + scopePresentationDefinitionRetriever: ScopePresentationDefinitionRetriever = { null }, + ) : this( + holder = holder, + agentPublicKey = keyMaterial.publicKey, + jwsService = jwsService, + clock = clock, + clientId = clientId, + remoteResourceRetriever = remoteResourceRetriever, + requestObjectJwsVerifier = requestObjectJwsVerifier, + scopePresentationDefinitionRetriever = scopePresentationDefinitionRetriever, + ) - val metadata: IssuerMetadata by lazy { - IssuerMetadata( + val metadata: OAuth2AuthorizationServerMetadata by lazy { + OAuth2AuthorizationServerMetadata( issuer = clientId, - authorizationEndpointUrl = clientId, + authorizationEndpoint = clientId, responseTypesSupported = setOf(ID_TOKEN), scopesSupported = setOf(SCOPE_OPENID), subjectTypesSupported = setOf("pairwise", "public"), idTokenSigningAlgorithmsSupported = setOf(jwsService.algorithm), requestObjectSigningAlgorithmsSupported = setOf(jwsService.algorithm), - subjectSyntaxTypesSupported = setOf(URN_TYPE_JWK_THUMBPRINT, PREFIX_DID_KEY), + subjectSyntaxTypesSupported = setOf(URN_TYPE_JWK_THUMBPRINT, PREFIX_DID_KEY, BINDING_METHOD_JWK), idTokenTypesSupported = setOf(IdTokenType.SUBJECT_SIGNED), presentationDefinitionUriSupported = false, ) @@ -134,12 +140,10 @@ class OidcSiopWallet( */ suspend fun createAuthnResponse( request: AuthenticationRequestParametersFrom, - ): KmmResult = createAuthnResponseParams(request).map { - AuthenticationResponseFactory(jwsService).createAuthenticationResponse( - request, - response = it, - ) - } + ): KmmResult = + createAuthnResponseParams(request).map { + AuthenticationResponseFactory(jwsService).createAuthenticationResponse(request, it) + } /** * Creates the authentication response from the RP's [params] @@ -164,7 +168,7 @@ class OidcSiopWallet( } /** - * Starts the authorization response building process from the RP's authentication request in [input] + * Starts the authorization response building process from the RP's authentication request in [params] */ suspend fun startAuthorizationResponsePreparation( params: AuthenticationRequestParametersFrom diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/helper/AuthenticationRequestParser.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/helper/AuthenticationRequestParser.kt index 205ed835f..1715582c9 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/helper/AuthenticationRequestParser.kt +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/helper/AuthenticationRequestParser.kt @@ -2,21 +2,21 @@ package at.asitplus.wallet.lib.oidc.helper import at.asitplus.KmmResult import at.asitplus.catching +import at.asitplus.openid.AuthenticationRequestParameters +import at.asitplus.openid.AuthenticationResponseParameters +import at.asitplus.openid.OpenIdConstants +import at.asitplus.openid.OpenIdConstants.Errors import at.asitplus.signum.indispensable.josef.JsonWebKeySet import at.asitplus.signum.indispensable.josef.JwsSigned -import at.asitplus.wallet.lib.oidc.AuthenticationRequestParameters import at.asitplus.wallet.lib.oidc.AuthenticationRequestParametersFrom -import at.asitplus.wallet.lib.oidc.AuthenticationResponseParameters import at.asitplus.wallet.lib.oidc.AuthenticationResponseResult -import at.asitplus.wallet.lib.oidc.OpenIdConstants -import at.asitplus.wallet.lib.oidc.OpenIdConstants.Errors import at.asitplus.wallet.lib.oidc.RemoteResourceRetrieverFunction import at.asitplus.wallet.lib.oidc.RequestObjectJwsVerifier import at.asitplus.wallet.lib.oidvci.OAuth2Exception import at.asitplus.wallet.lib.oidvci.decodeFromUrlQuery import io.github.aakira.napier.Napier -import io.ktor.http.Url -import io.ktor.util.flattenEntries +import io.ktor.http.* +import io.ktor.util.* internal class AuthenticationRequestParser( /** @@ -78,7 +78,7 @@ internal class AuthenticationRequestParser( } private fun parseRequestObjectJws(requestObject: String): AuthenticationRequestParametersFrom.JwsSigned? { - return JwsSigned.parse(requestObject).getOrNull()?.let { jws -> + return JwsSigned.deserialize(requestObject).getOrNull()?.let { jws -> val params = AuthenticationRequestParameters.deserialize(jws.payload.decodeToString()).getOrElse { return null .apply { Napier.w("parseRequestObjectJws: Deserialization failed", it) } diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/helper/AuthenticationResponseFactory.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/helper/AuthenticationResponseFactory.kt index 50e8880e5..ab4df1a65 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/helper/AuthenticationResponseFactory.kt +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/helper/AuthenticationResponseFactory.kt @@ -1,23 +1,19 @@ package at.asitplus.wallet.lib.oidc.helper +import at.asitplus.openid.AuthenticationResponseParameters +import at.asitplus.openid.OpenIdConstants.Errors +import at.asitplus.openid.OpenIdConstants.ResponseMode.* +import at.asitplus.openid.RelyingPartyMetadata import at.asitplus.signum.indispensable.josef.JweHeader import at.asitplus.wallet.lib.jws.JwsService import at.asitplus.wallet.lib.oidc.AuthenticationRequestParametersFrom import at.asitplus.wallet.lib.oidc.AuthenticationResponse -import at.asitplus.wallet.lib.oidc.AuthenticationResponseParameters import at.asitplus.wallet.lib.oidc.AuthenticationResponseResult -import at.asitplus.wallet.lib.oidc.OpenIdConstants.Errors -import at.asitplus.wallet.lib.oidc.OpenIdConstants.ResponseMode.DIRECT_POST -import at.asitplus.wallet.lib.oidc.OpenIdConstants.ResponseMode.DIRECT_POST_JWT -import at.asitplus.wallet.lib.oidc.OpenIdConstants.ResponseMode.FRAGMENT -import at.asitplus.wallet.lib.oidc.OpenIdConstants.ResponseMode.OTHER -import at.asitplus.wallet.lib.oidc.OpenIdConstants.ResponseMode.QUERY -import at.asitplus.wallet.lib.oidc.RelyingPartyMetadata import at.asitplus.wallet.lib.oidvci.OAuth2Exception import at.asitplus.wallet.lib.oidvci.encodeToParameters import at.asitplus.wallet.lib.oidvci.formUrlEncode import io.github.aakira.napier.Napier -import io.ktor.http.URLBuilder +import io.ktor.http.* import io.matthewnelson.encoding.base64.Base64 import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray import io.matthewnelson.encoding.core.Encoder.Companion.encodeToByteArray @@ -44,10 +40,14 @@ internal class AuthenticationResponseFactory( request: AuthenticationRequestParametersFrom, response: AuthenticationResponse, ): AuthenticationResponseResult.Post { - val url = request.parameters.responseUrl ?: request.parameters.redirectUrl - ?: throw OAuth2Exception(Errors.INVALID_REQUEST) + val url = request.parameters.responseUrl + ?: request.parameters.redirectUrl + ?: throw OAuth2Exception(Errors.INVALID_REQUEST) val responseSerialized = buildJarm(request, response) - val jarm = AuthenticationResponseParameters(response = responseSerialized) + val jarm = AuthenticationResponseParameters( + response = responseSerialized, + state = request.parameters.state + ) return AuthenticationResponseResult.Post(url, jarm.encodeToParameters()) } @@ -55,8 +55,9 @@ internal class AuthenticationResponseFactory( request: AuthenticationRequestParametersFrom, response: AuthenticationResponse, ): AuthenticationResponseResult.Post { - val url = request.parameters.responseUrl ?: request.parameters.redirectUrl - ?: throw OAuth2Exception(Errors.INVALID_REQUEST) + val url = request.parameters.responseUrl + ?: request.parameters.redirectUrl + ?: throw OAuth2Exception(Errors.INVALID_REQUEST) return AuthenticationResponseResult.Post(url, response.params.encodeToParameters()) } @@ -84,8 +85,8 @@ internal class AuthenticationResponseFactory( ): AuthenticationResponseResult.Redirect { val url = request.parameters.redirectUrl?.let { redirectUrl -> URLBuilder(redirectUrl).apply { - encodedFragment = response.params.encodeToParameters().formUrlEncode() - }.buildString() + encodedFragment = response.params.encodeToParameters().formUrlEncode() + }.buildString() } ?: throw OAuth2Exception(Errors.INVALID_REQUEST) return AuthenticationResponseResult.Redirect(url, response.params) } diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/helper/AuthorizationRequestValidator.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/helper/AuthorizationRequestValidator.kt index 0c7887149..7bbef220e 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/helper/AuthorizationRequestValidator.kt +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/helper/AuthorizationRequestValidator.kt @@ -1,14 +1,14 @@ package at.asitplus.wallet.lib.oidc.helper import at.asitplus.signum.indispensable.pki.leaf -import at.asitplus.wallet.lib.oidc.AuthenticationRequestParameters +import at.asitplus.openid.AuthenticationRequestParameters import at.asitplus.wallet.lib.oidc.AuthenticationRequestParametersFrom -import at.asitplus.wallet.lib.oidc.OpenIdConstants -import at.asitplus.wallet.lib.oidc.OpenIdConstants.Errors -import at.asitplus.wallet.lib.oidc.OpenIdConstants.ID_TOKEN -import at.asitplus.wallet.lib.oidc.OpenIdConstants.ResponseMode.DIRECT_POST -import at.asitplus.wallet.lib.oidc.OpenIdConstants.ResponseMode.DIRECT_POST_JWT -import at.asitplus.wallet.lib.oidc.OpenIdConstants.VP_TOKEN +import at.asitplus.openid.OpenIdConstants +import at.asitplus.openid.OpenIdConstants.Errors +import at.asitplus.openid.OpenIdConstants.ID_TOKEN +import at.asitplus.openid.OpenIdConstants.ResponseMode.DIRECT_POST +import at.asitplus.openid.OpenIdConstants.ResponseMode.DIRECT_POST_JWT +import at.asitplus.openid.OpenIdConstants.VP_TOKEN import at.asitplus.wallet.lib.oidvci.OAuth2Exception import io.github.aakira.napier.Napier import io.ktor.http.Url diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/helper/AuthorizationResponsePreparationState.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/helper/AuthorizationResponsePreparationState.kt index 0fbf3085f..456ba2086 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/helper/AuthorizationResponsePreparationState.kt +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/helper/AuthorizationResponsePreparationState.kt @@ -1,7 +1,7 @@ package at.asitplus.wallet.lib.oidc.helpers -import at.asitplus.wallet.lib.data.dif.PresentationDefinition -import at.asitplus.wallet.lib.oidc.RelyingPartyMetadata +import at.asitplus.dif.PresentationDefinition +import at.asitplus.openid.RelyingPartyMetadata import kotlinx.serialization.Serializable @Serializable diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/helper/PresentationFactory.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/helper/PresentationFactory.kt index 200f270aa..c6efcd9b7 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/helper/PresentationFactory.kt +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/helper/PresentationFactory.kt @@ -2,24 +2,24 @@ package at.asitplus.wallet.lib.oidc.helper import at.asitplus.KmmResult import at.asitplus.catching +import at.asitplus.dif.ClaimFormatEnum +import at.asitplus.dif.FormatHolder +import at.asitplus.dif.PresentationDefinition +import at.asitplus.openid.AuthenticationRequestParameters +import at.asitplus.openid.IdToken +import at.asitplus.openid.OpenIdConstants.Errors +import at.asitplus.openid.OpenIdConstants.ID_TOKEN +import at.asitplus.openid.OpenIdConstants.VP_TOKEN +import at.asitplus.openid.RelyingPartyMetadata import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.signum.indispensable.josef.JwsSigned import at.asitplus.signum.indispensable.josef.toJsonWebKey import at.asitplus.wallet.lib.agent.CredentialSubmission import at.asitplus.wallet.lib.agent.Holder import at.asitplus.wallet.lib.agent.toDefaultSubmission -import at.asitplus.wallet.lib.data.dif.ClaimFormatEnum -import at.asitplus.wallet.lib.data.dif.FormatHolder -import at.asitplus.wallet.lib.data.dif.PresentationDefinition import at.asitplus.wallet.lib.data.dif.PresentationSubmissionValidator import at.asitplus.wallet.lib.jws.JwsService -import at.asitplus.wallet.lib.oidc.AuthenticationRequestParameters import at.asitplus.wallet.lib.oidc.AuthenticationRequestParametersFrom -import at.asitplus.wallet.lib.oidc.IdToken -import at.asitplus.wallet.lib.oidc.OpenIdConstants.Errors -import at.asitplus.wallet.lib.oidc.OpenIdConstants.ID_TOKEN -import at.asitplus.wallet.lib.oidc.OpenIdConstants.VP_TOKEN -import at.asitplus.wallet.lib.oidc.RelyingPartyMetadata import at.asitplus.wallet.lib.oidvci.OAuth2Exception import io.github.aakira.napier.Napier import kotlinx.datetime.Clock @@ -44,7 +44,7 @@ internal class PresentationFactory( val credentialSubmissions = inputDescriptorSubmissions ?: holder.matchInputDescriptorsAgainstCredentialStore( inputDescriptors = presentationDefinition.inputDescriptors, - fallbackFormatHolder = presentationDefinition.formats ?: clientMetadata?.vpFormats, + fallbackFormatHolder = clientMetadata?.vpFormats, ).getOrThrow().toDefaultSubmission() presentationDefinition.validateSubmission( @@ -103,7 +103,7 @@ internal class PresentationFactory( @Throws(OAuth2Exception::class) private fun AuthenticationRequestParameters.verifyResponseType() { - if (responseType == null || !responseType.contains(VP_TOKEN)) { + if (responseType == null || !responseType!!.contains(VP_TOKEN)) { Napier.w("vp_token not requested in response_type='$responseType'") throw OAuth2Exception(Errors.INVALID_REQUEST) } @@ -129,9 +129,9 @@ internal class PresentationFactory( } val constraintFieldMatches = holder.evaluateInputDescriptorAgainstCredential( - inputDescriptor, - submission.value.credential, - fallbackFormatHolder = this.formats ?: clientMetadata?.vpFormats, + inputDescriptor = inputDescriptor, + credential = submission.value.credential, + fallbackFormatHolder = clientMetadata?.vpFormats, pathAuthorizationValidator = { true }, ).getOrThrow() diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/AuthorizationDetails.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/AuthorizationDetails.kt deleted file mode 100644 index d2374e3a3..000000000 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/AuthorizationDetails.kt +++ /dev/null @@ -1,85 +0,0 @@ -package at.asitplus.wallet.lib.oidvci - -import at.asitplus.wallet.lib.oidvci.mdl.RequestedCredentialClaimSpecification -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * OID4VCI: The request parameter `authorization_details` defined in Section 2 of (RFC9396) MUST be used to convey the - * details about the Credentials the Wallet wants to obtain. This specification introduces a new authorization details - * type `openid_credential` and defines the following parameters to be used with this authorization details type. - */ -@Serializable -data class AuthorizationDetails( - /** - * OID4VCI: REQUIRED. String that determines the authorization details type. It MUST be set to `openid_credential` - * for the purpose of this specification. - */ - @SerialName("type") - val type: String, - - /** - * OID4VC: REQUIRED when [format] parameter is not present. String specifying a unique identifier of the Credential - * being described in [IssuerMetadata.supportedCredentialConfigurations]. - */ - @SerialName("credential_configuration_id") - val credentialConfigurationId: String? = null, - - /** - * OID4VCI: REQUIRED when [credentialConfigurationId] parameter is not present. - * String identifying the format of the Credential the Wallet needs. - * This Credential format identifier determines further claims in the authorization details object needed to - * identify the Credential type in the requested format. - */ - @SerialName("format") - val format: CredentialFormatEnum? = null, - - /** - * OID4VCI: ISO mDL: OPTIONAL. This claim contains the type value the Wallet requests authorization for at the - * Credential Issuer. It MUST only be present if the [format] claim is present. It MUST not be present otherwise. - */ - @SerialName("doctype") - val docType: String? = null, - - /** - * OID4VCI: ISO mDL: OPTIONAL. Object as defined in Appendix A.3.2 excluding the `display` and `value_type` - * parameters. The `mandatory` parameter here is used by the Wallet to indicate to the Issuer that it only accepts - * Credential(s) issued with those claim(s). - */ - @SerialName("claims") - val claims: Map>? = null, - - /** - * OID4VCI: W3C VC: OPTIONAL. Object containing a detailed description of the Credential consisting of the - * following parameters. see [SupportedCredentialFormatDefinition]. - */ - @SerialName("credential_definition") - val credentialDefinition: SupportedCredentialFormatDefinition? = null, - - /** - * OID4VCI: IETF SD-JWT VC: REQUIRED. String as defined in Appendix A.3.2. This claim contains the type values - * the Wallet requests authorization for at the Credential Issuer. - * It MUST only be present if the [format] claim is present. It MUST not be present otherwise. - */ - @SerialName("vct") - val sdJwtVcType: String? = null, - - /** - * Must contain an entry form [IssuerMetadata.authorizationServers]. - */ - @SerialName("locations") - val locations: Set? = null, - - /** - * OID4VCI: OPTIONAL. Array of strings, each uniquely identifying a Credential that can be issued using the Access - * Token returned in this response. Each of these Credentials corresponds to the same entry in the - * [IssuerMetadata.supportedCredentialConfigurations] but can contain different claim values or a - * different subset of claims within the claims set identified by that Credential type. - * This parameter can be used to simplify the Credential Request, as defined in Section 7.2, where the - * `credential_identifier` parameter replaces the format parameter and any other Credential format-specific - * parameters in the Credential Request. When received, the Wallet MUST use these values together with an Access - * Token in subsequent Credential Requests. - */ - @SerialName("credential_identifiers") - val credentialIdentifiers: Set? = null, -) \ No newline at end of file diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CodeService.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CodeService.kt index 11d4f646f..f85fd94ee 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CodeService.kt +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CodeService.kt @@ -6,7 +6,7 @@ interface CodeService { fun provideCode(): String - fun verifyCode(it: String): Boolean + fun verifyAndRemove(it: String): Boolean } @@ -18,7 +18,7 @@ class DefaultCodeService : CodeService { return uuid4().toString().also { validCodes += it } } - override fun verifyCode(it: String): Boolean { + override fun verifyAndRemove(it: String): Boolean { return validCodes.remove(it) } } \ No newline at end of file diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialAuthorizationServiceStrategy.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialAuthorizationServiceStrategy.kt new file mode 100644 index 000000000..01e96ee38 --- /dev/null +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialAuthorizationServiceStrategy.kt @@ -0,0 +1,44 @@ +package at.asitplus.wallet.lib.oidvci + +import at.asitplus.openid.AuthenticationRequestParameters +import at.asitplus.openid.AuthorizationDetails +import at.asitplus.wallet.lib.data.ConstantIndex +import at.asitplus.wallet.lib.oauth2.AuthorizationServiceStrategy + +/** + * Provide authentication and authorization for credential issuance. + */ +class CredentialAuthorizationServiceStrategy( + /** + * Source of user data. + */ + private val dataProvider: OAuth2DataProvider, + /** + * List of supported schemes. + */ + credentialSchemes: Set, +) : AuthorizationServiceStrategy { + + private val supportedCredentialSchemes = credentialSchemes + .flatMap { it.toSupportedCredentialFormat().entries } + .associate { it.key to it.value } + + override suspend fun loadUserInfo(request: AuthenticationRequestParameters, code: String) = + dataProvider.loadUserInfo(request, code) + + override fun filterAuthorizationDetails(authorizationDetails: Set) = + authorizationDetails + .filterIsInstance() + .filter { authnDetails -> + authnDetails.credentialConfigurationId?.let { + supportedCredentialSchemes.containsKey(it) + } ?: authnDetails.format?.let { + supportedCredentialSchemes.values.any { + it.format == authnDetails.format && + it.docType == authnDetails.docType && + it.sdJwtVcType == authnDetails.sdJwtVcType && + it.credentialDefinition == authnDetails.credentialDefinition + } + } ?: false + }.toSet() +} \ No newline at end of file diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialIssuer.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialIssuer.kt index c6b128791..a4d829a48 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialIssuer.kt +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialIssuer.kt @@ -2,6 +2,12 @@ package at.asitplus.wallet.lib.oidvci import at.asitplus.KmmResult import at.asitplus.catching +import at.asitplus.openid.* +import at.asitplus.openid.OpenIdConstants.Errors +import at.asitplus.openid.OpenIdConstants.PROOF_CWT_TYPE +import at.asitplus.openid.OpenIdConstants.PROOF_JWT_TYPE +import at.asitplus.openid.OpenIdConstants.ProofType +import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.signum.indispensable.cosef.CborWebToken import at.asitplus.signum.indispensable.cosef.CoseSigned import at.asitplus.signum.indispensable.io.Base64UrlStrict @@ -13,8 +19,6 @@ import at.asitplus.wallet.lib.agent.IssuerCredentialDataProvider import at.asitplus.wallet.lib.data.AttributeIndex import at.asitplus.wallet.lib.data.ConstantIndex import at.asitplus.wallet.lib.data.VcDataModelConstants.VERIFIABLE_CREDENTIAL -import at.asitplus.wallet.lib.oidc.OpenIdConstants.Errors -import at.asitplus.wallet.lib.oidc.OpenIdConstants.ProofType import com.benasher44.uuid.uuid4 import io.github.aakira.napier.Napier import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray @@ -22,14 +26,15 @@ import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray /** * Server implementation to issue credentials using OID4VCI. * - * Implemented from [OpenID for Verifiable Credential Issuance] - * (https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html), Draft 13, 2024-02-08. + * Implemented from + * [OpenID for Verifiable Credential Issuance](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html) + * , Draft 14, 2024-08-21. */ class CredentialIssuer( /** * Used to get the user data, and access tokens. */ - private val authorizationService: OAuth2AuthorizationServer, + private val authorizationService: OAuth2AuthorizationServerAdapter, /** * Used to actually issue the credential. */ @@ -63,32 +68,47 @@ class CredentialIssuer( credentialIssuer = publicContext, authorizationServers = setOf(authorizationService.publicContext), credentialEndpointUrl = "$publicContext$credentialEndpointPath", - authorizationEndpointUrl = if (authorizationService is SimpleAuthorizationService) - authorizationService.publicContext + authorizationService.authorizationEndpointPath - else null, - tokenEndpointUrl = if (authorizationService is SimpleAuthorizationService) - authorizationService.publicContext + authorizationService.tokenEndpointPath - else null, - supportedCredentialConfigurations = mutableMapOf().apply { - credentialSchemes.forEach { putAll(it.toSupportedCredentialFormat(issuer.cryptoAlgorithms)) } - }, - supportsCredentialIdentifiers = true, + supportedCredentialConfigurations = credentialSchemes + .flatMap { it.toSupportedCredentialFormat(issuer.cryptoAlgorithms).entries } + .associate { it.key to it.value }, + batchCredentialIssuance = BatchCredentialIssuanceMetadata(1) ) } /** * Offer all [credentialSchemes] to clients. - * Callers may need to transport this in [CredentialOfferUrlParameters] to (HTTPS) clients. + * + * Callers need to encode this in [CredentialOfferUrlParameters], and offer the resulting URL to clients, + * i.e. by displaying a QR Code that can be scanned with wallet appps. */ - suspend fun credentialOffer(): CredentialOffer = CredentialOffer( + suspend fun credentialOfferWithAuthorizationCode(): CredentialOffer = CredentialOffer( credentialIssuer = publicContext, - configurationIds = credentialSchemes.flatMap { it.toSupportedCredentialFormat(issuer.cryptoAlgorithms).keys }, + configurationIds = credentialSchemes.flatMap { it.toCredentialIdentifier() }, grants = CredentialOfferGrants( - authorizationCode = CredentialOfferGrantsAuthCode( - issuerState = uuid4().toString(), // TODO remember this state, for subsequent requests from the Wallet + authorizationCode = + CredentialOfferGrantsAuthCode( + // TODO remember this state, for subsequent requests from the Wallet + issuerState = uuid4().toString(), authorizationServer = authorizationService.publicContext ), - preAuthorizedCode = authorizationService.providePreAuthorizedCode()?.let { + ) + ) + + /** + * Offer all [credentialSchemes] to clients. + * + * Callers need to encode this in [CredentialOfferUrlParameters], and offer the resulting URL to clients, + * i.e. by displaying a QR Code that can be scanned with wallet appps. + * + * @param user used to create the credential when the wallet app requests the credential + */ + suspend fun credentialOfferWithPreAuthnForUser( + user: OidcUserInfoExtended, + ): CredentialOffer = CredentialOffer( + credentialIssuer = publicContext, + configurationIds = credentialSchemes.flatMap { it.toCredentialIdentifier() }, + grants = CredentialOfferGrants( + preAuthorizedCode = authorizationService.providePreAuthorizedCode(user)?.let { CredentialOfferGrantsPreAuthCode( preAuthorizedCode = it, authorizationServer = authorizationService.publicContext @@ -101,7 +121,8 @@ class CredentialIssuer( * Verifies the [accessToken] to contain a token from [authorizationService], * verifies the proof sent by the client (must contain a nonce sent from [authorizationService]), * and issues credentials to the client. - * Send the result JSON-serialized back to the client. + * + * Callers need to send the result JSON-serialized back to the client. * * @param accessToken The value of HTTP header `Authorization` sent by the client, * with the prefix `Bearer ` removed, so the plain access token @@ -111,112 +132,111 @@ class CredentialIssuer( accessToken: String, params: CredentialRequestParameters ): KmmResult = catching { - val proof = params.proof - ?: throw OAuth2Exception(Errors.INVALID_REQUEST) - .also { Napier.w("credential: client did not provide proof of possession") } - val subjectPublicKey = when (proof.proofType) { - ProofType.JWT -> { - if (proof.jwt == null) - throw OAuth2Exception(Errors.INVALID_PROOF) - .also { Napier.w("credential: client did provide invalid proof: $proof") } - val jwsSigned = JwsSigned.parse(proof.jwt).getOrNull() - ?: throw OAuth2Exception(Errors.INVALID_PROOF) - .also { Napier.w("credential: client did provide invalid proof: $proof") } - val jwt = JsonWebToken.deserialize(jwsSigned.payload.decodeToString()).getOrNull() - ?: throw OAuth2Exception(Errors.INVALID_PROOF) - .also { Napier.w("credential: client did provide invalid JWT in proof: $proof") } - if (jwt.nonce == null || !authorizationService.verifyAndRemoveClientNonce(jwt.nonce!!)) - throw OAuth2Exception(Errors.INVALID_PROOF) - .also { Napier.w("credential: client did provide invalid nonce in JWT in proof: ${jwt.nonce}") } - if (jwsSigned.header.type != ProofType.JWT_HEADER_TYPE.stringRepresentation) - throw OAuth2Exception(Errors.INVALID_PROOF) - .also { Napier.w("credential: client did provide invalid header type in JWT in proof: ${jwsSigned.header}") } - if (jwt.audience == null || jwt.audience != publicContext) - throw OAuth2Exception(Errors.INVALID_PROOF) - .also { Napier.w("credential: client did provide invalid audience in JWT in proof: ${jwsSigned.header}") } - jwsSigned.header.publicKey - ?: throw OAuth2Exception(Errors.INVALID_PROOF) - .also { Napier.w("credential: client did provide no valid key in header in JWT in proof: ${jwsSigned.header}") } - } - - ProofType.CWT -> { - if (proof.cwt == null) - throw OAuth2Exception(Errors.INVALID_PROOF) - .also { Napier.w("credential: client did provide invalid proof: $proof") } - val coseSigned = CoseSigned.deserialize(proof.cwt.decodeToByteArray(Base64UrlStrict)).getOrNull() - ?: throw OAuth2Exception(Errors.INVALID_PROOF) - .also { Napier.w("credential: client did provide invalid proof: $proof") } - - val cwt = coseSigned.payload?.let { CborWebToken.deserialize(it).getOrNull() } - ?: throw OAuth2Exception(Errors.INVALID_PROOF) - .also { Napier.w("credential: client did provide invalid CWT in proof: $proof") } - if (cwt.nonce == null || !authorizationService.verifyAndRemoveClientNonce(cwt.nonce!!.decodeToString())) - throw OAuth2Exception(Errors.INVALID_PROOF) - .also { Napier.w("credential: client did provide invalid nonce in CWT in proof: ${cwt.nonce}") } - val header = coseSigned.protectedHeader.value - if (header.contentType != ProofType.CWT_HEADER_TYPE.stringRepresentation) - throw OAuth2Exception(Errors.INVALID_PROOF) - .also { Napier.w("credential: client did provide invalid header type in CWT in proof: $header") } - if (cwt.audience == null || cwt.audience != publicContext) - throw OAuth2Exception(Errors.INVALID_PROOF) - .also { Napier.w("credential: client did provide invalid audience in CWT in proof: $header") } - header.certificateChain?.let { X509Certificate.decodeFromByteArray(it)?.publicKey } - ?: throw OAuth2Exception(Errors.INVALID_PROOF) - .also { Napier.w("credential: client did provide no valid key in header in CWT in proof: $header") } - } - - else -> { - throw OAuth2Exception(Errors.INVALID_PROOF) - .also { Napier.w("credential: client did provide invalid proof type: ${proof.proofType}") } - } - } + val subjectPublicKey = validateProofExtractSubjectPublicKey(params) val userInfo = authorizationService.getUserInfo(accessToken).getOrNull() ?: throw OAuth2Exception(Errors.INVALID_TOKEN) .also { Napier.w("credential: client did not provide correct token: $accessToken") } - val issuedCredentialResult = when { - params.format != null -> { - val credentialScheme = params.extractCredentialScheme(params.format) - ?: throw OAuth2Exception(Errors.INVALID_REQUEST) - .also { Napier.w("credential: client did not provide correct credential scheme: ${params}") } - issuer.issueCredential( - subjectPublicKey = subjectPublicKey, - credentialScheme = credentialScheme, - representation = params.format.toRepresentation(), - claimNames = params.claims?.map { it.value.keys }?.flatten()?.ifEmpty { null }, - dataProviderOverride = buildIssuerCredentialDataProviderOverride(userInfo) - ) - } - - params.credentialIdentifier != null -> { - val (credentialScheme, representation) = decodeFromCredentialIdentifier(params.credentialIdentifier) - ?: throw OAuth2Exception(Errors.INVALID_REQUEST) - .also { Napier.w("credential: client did not provide correct credential identifier: ${params.credentialIdentifier}") } - issuer.issueCredential( - subjectPublicKey = subjectPublicKey, - credentialScheme = credentialScheme, - representation = representation.toRepresentation(), - claimNames = params.claims?.map { it.value.keys }?.flatten()?.ifEmpty { null }, - dataProviderOverride = buildIssuerCredentialDataProviderOverride(userInfo) - ) - } - - else -> { + val issuedCredentialResult = params.format?.let { format -> + val credentialScheme = params.extractCredentialScheme(format) + ?: throw OAuth2Exception(Errors.INVALID_REQUEST) + .also { Napier.w("credential: client did not provide correct credential scheme: $params") } + issuer.issueCredential( + subjectPublicKey = subjectPublicKey, + credentialScheme = credentialScheme, + representation = params.format!!.toRepresentation(), + claimNames = params.claims?.map { it.value.keys }?.flatten()?.ifEmpty { null }, + dataProviderOverride = buildIssuerCredentialDataProviderOverride(userInfo) + ) + } ?: params.credentialIdentifier?.let { credentialIdentifier -> + val (credentialScheme, representation) = decodeFromCredentialIdentifier(credentialIdentifier) ?: run { + Napier.w("client did not provide correct credential identifier: $credentialIdentifier") throw OAuth2Exception(Errors.INVALID_REQUEST) - .also { Napier.w("credential: client did not provide format or credential identifier in params: $params") } } - } + issuer.issueCredential( + subjectPublicKey = subjectPublicKey, + credentialScheme = credentialScheme, + representation = representation.toRepresentation(), + claimNames = params.claims?.map { it.value.keys }?.flatten()?.ifEmpty { null }, + dataProviderOverride = buildIssuerCredentialDataProviderOverride(userInfo) + ) + } ?: throw OAuth2Exception(Errors.INVALID_REQUEST) + .also { Napier.w("client did not provide format or credential identifier in params: $params") } + val issuedCredential = issuedCredentialResult.getOrElse { throw OAuth2Exception(Errors.INVALID_REQUEST) .also { Napier.w("credential: issuer did not issue credential: $issuedCredentialResult") } } - // TODO Implement Batch Credential Endpoint for more than one credential response issuedCredential.toCredentialResponseParameters() .also { Napier.i("credential returns $it") } } + private suspend fun validateProofExtractSubjectPublicKey(params: CredentialRequestParameters): CryptoPublicKey = + params.proof?.validateProof() + ?: params.proofs?.validateProof() + ?: throw OAuth2Exception(Errors.INVALID_REQUEST) + .also { Napier.w("credential: client did not provide proof of possession") } + + private suspend fun CredentialRequestProof.validateProof() = when (proofType) { + ProofType.JWT -> jwt?.validateJwtProof() + ProofType.CWT -> cwt?.validateCwtProof() + else -> null + } + + private suspend fun CredentialRequestProofContainer.validateProof() = when (proofType) { + ProofType.JWT -> jwt?.map { it.validateJwtProof() }?.toSet()?.singleOrNull() + else -> null + } + + private suspend fun String.validateJwtProof(): CryptoPublicKey { + val jwsSigned = JwsSigned.deserialize(this).getOrNull() + ?: throw OAuth2Exception(Errors.INVALID_PROOF) + .also { Napier.w("client did provide invalid proof: $this") } + val jwt = JsonWebToken.deserialize(jwsSigned.payload.decodeToString()).getOrNull() + ?: throw OAuth2Exception(Errors.INVALID_PROOF) + .also { Napier.w("client did provide invalid JWT in proof: $this") } + if (jwsSigned.header.type != PROOF_JWT_TYPE) + throw OAuth2Exception(Errors.INVALID_PROOF) + .also { Napier.w("client did provide invalid header type in JWT in proof: ${jwsSigned.header}") } + if (authorizationService.supportsClientNonce) + if (jwt.nonce == null || !authorizationService.verifyClientNonce(jwt.nonce!!)) + throw OAuth2Exception(Errors.INVALID_PROOF) + .also { Napier.w("client did provide invalid nonce in JWT in proof: ${jwt.nonce}") } + if (jwt.audience == null || jwt.audience != publicContext) + throw OAuth2Exception(Errors.INVALID_PROOF) + .also { Napier.w("client did provide invalid audience in JWT in proof: ${jwsSigned.header}") } + return jwsSigned.header.publicKey + ?: throw OAuth2Exception(Errors.INVALID_PROOF) + .also { Napier.w("client did provide no valid key in header in JWT in proof: ${jwsSigned.header}") } + } + + /** + * Removed in OID4VCI Draft 14, kept here for a bit of backwards-compatibility + */ + private suspend fun String.validateCwtProof(): CryptoPublicKey { + val coseSigned = CoseSigned.deserialize(decodeToByteArray(Base64UrlStrict)).getOrNull() + ?: throw OAuth2Exception(Errors.INVALID_PROOF) + .also { Napier.w("client did provide invalid proof: $this") } + val cwt = coseSigned.payload?.let { CborWebToken.deserialize(it).getOrNull() } + ?: throw OAuth2Exception(Errors.INVALID_PROOF) + .also { Napier.w("client did provide invalid CWT in proof: $this") } + if (cwt.nonce == null || !authorizationService.verifyClientNonce(cwt.nonce!!.decodeToString())) + throw OAuth2Exception(Errors.INVALID_PROOF) + .also { Napier.w("client did provide invalid nonce in CWT in proof: ${cwt.nonce}") } + val header = coseSigned.protectedHeader.value + if (header.contentType != PROOF_CWT_TYPE) + throw OAuth2Exception(Errors.INVALID_PROOF) + .also { Napier.w("client did provide invalid header type in CWT in proof: $header") } + if (cwt.audience == null || cwt.audience != publicContext) + throw OAuth2Exception(Errors.INVALID_PROOF) + .also { Napier.w("client did provide invalid audience in CWT in proof: $header") } + return header.certificateChain?.let { X509Certificate.decodeFromByteArray(it)?.publicKey } + ?: throw OAuth2Exception(Errors.INVALID_PROOF) + .also { Napier.w("client did provide no valid key in header in CWT in proof: $header") } + } + } @@ -224,9 +244,7 @@ private fun CredentialRequestParameters.extractCredentialScheme(format: Credenti CredentialFormatEnum.JWT_VC -> credentialDefinition?.types?.firstOrNull { it != VERIFIABLE_CREDENTIAL } ?.let { AttributeIndex.resolveAttributeType(it) } - CredentialFormatEnum.VC_SD_JWT, - CredentialFormatEnum.JWT_VC_SD_UNOFFICIAL -> sdJwtVcType?.let { AttributeIndex.resolveSdJwtAttributeType(it) } - + CredentialFormatEnum.VC_SD_JWT -> sdJwtVcType?.let { AttributeIndex.resolveSdJwtAttributeType(it) } CredentialFormatEnum.MSO_MDOC -> docType?.let { AttributeIndex.resolveIsoDoctype(it) } else -> null } diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialOffer.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialOffer.kt deleted file mode 100644 index bcdfe072e..000000000 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialOffer.kt +++ /dev/null @@ -1,172 +0,0 @@ -package at.asitplus.wallet.lib.oidvci - -import at.asitplus.KmmResult -import at.asitplus.KmmResult.Companion.wrap -import at.asitplus.wallet.lib.oidc.jsonSerializer -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.decodeFromJsonElement - -/** - * OID4VCI: The Credential Issuer sends Credential Offer using an HTTP GET request or an HTTP redirect to the Wallet's - * Credential Offer Endpoint defined in Section 11.1.The Credential Offer object, which is a JSON-encoded object with - * the Credential Offer parameters, can be sent by value or by reference. - */ -@Serializable -data class CredentialOfferUrlParameters( - /** - * OID4VCI: Object with the Credential Offer parameters. This MUST NOT be present when the [credentialOfferUrl] - * parameter is present. - */ - @SerialName("credential_offer") - val credentialOffer: JsonObject? = null, - - /** - * OID4VCI: String that is a URL using the `https` scheme referencing a resource containing a JSON object with the - * Credential Offer parameters. This MUST NOT be present when the [credentialOffer] parameter is present. - */ - @SerialName("credential_offer_uri") - val credentialOfferUrl: String? = null, -) { - fun serialize() = jsonSerializer.encodeToString(this) - - companion object { - fun deserialize(input: String): KmmResult = - runCatching { jsonSerializer.decodeFromString(input) }.wrap() - } -} - -@Serializable -data class CredentialOffer( - /** - * OID4VCI: REQUIRED. The URL of the Credential Issuer, as defined in Section 11.2.1, from which the Wallet is - * requested to obtain one or more Credentials. The Wallet uses it to obtain the Credential Issuer's Metadata - * following the steps defined in Section 11.2.2. - */ - @SerialName("credential_issuer") - val credentialIssuer: String, - - /** - * OID4VCI: REQUIRED. Array of unique strings that each identify one of the keys in the name/value pairs stored in - * the `credential_configurations_supported` Credential Issuer metadata. The Wallet uses these string values to - * obtain the respective object that contains information about the Credential being offered as defined in - * Section 11.2.3. For example, these string values can be used to obtain `scope` values to be used in the - * Authorization Request. - */ - @SerialName("credential_configuration_ids") - val configurationIds: Collection, - - /** - * OID4VCI: OPTIONAL. If `grants` is not present or is empty, the Wallet MUST determine the Grant Types the - * Credential Issuer's Authorization Server supports using the respective metadata. When multiple grants are - * present, it is at the Wallet's discretion which one to use. - */ - @SerialName("grants") - val grants: CredentialOfferGrants? = null, -) { - fun serialize() = jsonSerializer.encodeToString(this) - - companion object { - fun deserialize(input: String): KmmResult = - runCatching { jsonSerializer.decodeFromString(input) }.wrap() - fun deserialize(input: JsonElement): KmmResult = - runCatching { jsonSerializer.decodeFromJsonElement(input) }.wrap() - } -} - -/** - * OID4VCI: Object indicating to the Wallet the Grant Types the Credential Issuer's Authorization Server is prepared to - * process for this Credential Offer. Every grant is represented by a name/value pair. The name is the Grant Type - * identifier; the value is an object that contains parameters either determining the way the Wallet MUST use the - * particular grant and/or parameters the Wallet MUST send with the respective request(s). - */ -@Serializable -data class CredentialOfferGrants( - @SerialName("authorization_code") - val authorizationCode: CredentialOfferGrantsAuthCode? = null, - - @SerialName("urn:ietf:params:oauth:grant-type:pre-authorized_code") - val preAuthorizedCode: CredentialOfferGrantsPreAuthCode? = null -) - -@Serializable -data class CredentialOfferGrantsAuthCode( - /** - * OID4VCI: OPTIONAL. String value created by the Credential Issuer and opaque to the Wallet that is used to bind - * the subsequent Authorization Request with the Credential Issuer to a context set up during previous steps. If the - * Wallet decides to use the Authorization Code Flow and received a value for this parameter, it MUST include it in - * the subsequent Authorization Request to the Credential Issuer as the `issuer_state` parameter value. - */ - @SerialName("issuer_state") - val issuerState: String? = null, - - /** - * OID4VCI: OPTIONAL string that the Wallet can use to identify the Authorization Server to use with this grant - * type when `authorization_servers` parameter in the Credential Issuer metadata has multiple entries. It MUST NOT - * be used otherwise. The value of this parameter MUST match with one of the values in the `authorization_servers` - * array obtained from the Credential Issuer metadata. - */ - @SerialName("authorization_server") - val authorizationServer: String? = null, -) - -@Serializable -data class CredentialOfferGrantsPreAuthCode( - /** - * OID4VCI: REQUIRED. The code representing the Credential Issuer's authorization for the Wallet to obtain - * Credentials of a certain type. This code MUST be short lived and single use. If the Wallet decides to use the - * Pre-Authorized Code Flow, this parameter value MUST be included in the subsequent Token Request with the - * Pre-Authorized Code Flow. - */ - @SerialName("pre-authorized_code") - val preAuthorizedCode: String, - - /** - * OID4VCI: OPTIONAL. Object specifying whether the Authorization Server expects presentation of a Transaction Code - * by the End-User along with the Token Request in a Pre-Authorized Code Flow. If the Authorization Server does not - * expect a Transaction Code, this object is absent; this is the default. - */ - @SerialName("tx_code") - val transactionCode: CredentialOfferGrantsPreAuthCodeTransactionCode? = null, - - /** - * OID4VCI: OPTIONAL. The minimum amount of time in seconds that the Wallet SHOULD wait between polling requests to - * the token endpoint. If no value is provided, Wallets MUST use 5 as the default. - */ - @SerialName("interval") - val waitIntervalSeconds: Int? = 5, - - /** - * OID4VCI: OPTIONAL string that the Wallet can use to identify the Authorization Server to use with this grant - * type when `authorization_servers` parameter in the Credential Issuer metadata has multiple entries. - */ - @SerialName("authorization_server") - val authorizationServer: String? = null -) - -@Serializable -data class CredentialOfferGrantsPreAuthCodeTransactionCode( - /** - * OID4VCI: OPTIONAL. String specifying the input character set. Possible values are `numeric` (only digits) and - * `text` (any characters). The default is `numeric`. - */ - @SerialName("input_mode") - val inputMode: String? = "numeric", - - /** - * OID4VCI: OPTIONAL. Integer specifying the length of the Transaction Code. This helps the Wallet to render the - * input screen and improve the user experience. - */ - @SerialName("length") - val length: Int? = null, - - /** - * OID4VCI: OPTIONAL. String containing guidance for the Holder of the Wallet on how to obtain the Transaction - * Code, e.g., describing over which communication channel it is delivered. - */ - @SerialName("description") - val description: String? = null, -) \ No newline at end of file diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/Extensions.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/Extensions.kt index 0ec81ee60..428a8ec64 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/Extensions.kt +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/Extensions.kt @@ -1,6 +1,10 @@ package at.asitplus.wallet.lib.oidvci -import at.asitplus.signum.indispensable.X509SignatureAlgorithm +import at.asitplus.openid.* +import at.asitplus.openid.OpenIdConstants.BINDING_METHOD_COSE_KEY +import at.asitplus.openid.OpenIdConstants.BINDING_METHOD_JWK +import at.asitplus.openid.OpenIdConstants.URN_TYPE_JWK_THUMBPRINT +import at.asitplus.signum.indispensable.SignatureAlgorithm import at.asitplus.signum.indispensable.io.Base64UrlStrict import at.asitplus.signum.indispensable.josef.toJwsAlgorithm import at.asitplus.wallet.lib.agent.Issuer @@ -10,18 +14,19 @@ import at.asitplus.wallet.lib.data.ConstantIndex.supportsIso import at.asitplus.wallet.lib.data.ConstantIndex.supportsSdJwt import at.asitplus.wallet.lib.data.ConstantIndex.supportsVcJwt import at.asitplus.wallet.lib.data.VcDataModelConstants -import at.asitplus.wallet.lib.oidc.OpenIdConstants -import at.asitplus.wallet.lib.oidvci.mdl.RequestedCredentialClaimSpecification import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString -fun ConstantIndex.CredentialScheme.toSupportedCredentialFormat(cryptoAlgorithms: Set): Map { +fun ConstantIndex.CredentialScheme.toSupportedCredentialFormat(cryptoAlgorithms: Set? = null) + : Map { val iso = if (supportsIso) { isoNamespace!! to SupportedCredentialFormat.forIsoMdoc( format = CredentialFormatEnum.MSO_MDOC, scope = isoNamespace!!, docType = isoDocType!!, - supportedBindingMethods = setOf(OpenIdConstants.BINDING_METHOD_COSE_KEY), - supportedSigningAlgorithms = cryptoAlgorithms.mapNotNull { it.toJwsAlgorithm().getOrNull()?.identifier }.toSet(), + supportedBindingMethods = setOf(BINDING_METHOD_JWK, BINDING_METHOD_COSE_KEY), + supportedSigningAlgorithms = cryptoAlgorithms + ?.mapNotNull { it.toJwsAlgorithm().getOrNull()?.identifier } + ?.toSet(), isoClaims = mapOf( isoNamespace!! to claimNames.associateWith { RequestedCredentialClaimSpecification() } ) @@ -32,11 +37,13 @@ fun ConstantIndex.CredentialScheme.toSupportedCredentialFormat(cryptoAlgorithms: format = CredentialFormatEnum.JWT_VC, scope = vcType!!, credentialDefinition = SupportedCredentialFormatDefinition( - types = listOf(VcDataModelConstants.VERIFIABLE_CREDENTIAL, vcType!!), + types = setOf(VcDataModelConstants.VERIFIABLE_CREDENTIAL, vcType!!), credentialSubject = claimNames.associateWith { CredentialSubjectMetadataSingle() } ), - supportedBindingMethods = setOf(OpenIdConstants.PREFIX_DID_KEY, OpenIdConstants.URN_TYPE_JWK_THUMBPRINT), - supportedSigningAlgorithms = cryptoAlgorithms.mapNotNull { it.toJwsAlgorithm().getOrNull()?.identifier }.toSet(), + supportedBindingMethods = setOf(BINDING_METHOD_JWK, URN_TYPE_JWK_THUMBPRINT), + supportedSigningAlgorithms = cryptoAlgorithms + ?.mapNotNull { it.toJwsAlgorithm().getOrNull()?.identifier } + ?.toSet(), ) } else null val sdJwt = if (supportsSdJwt) { @@ -44,35 +51,54 @@ fun ConstantIndex.CredentialScheme.toSupportedCredentialFormat(cryptoAlgorithms: format = CredentialFormatEnum.VC_SD_JWT, scope = sdJwtType!!, sdJwtVcType = sdJwtType!!, - supportedBindingMethods = setOf(OpenIdConstants.PREFIX_DID_KEY, OpenIdConstants.URN_TYPE_JWK_THUMBPRINT), - supportedSigningAlgorithms = cryptoAlgorithms.mapNotNull { it.toJwsAlgorithm().getOrNull()?.identifier }.toSet(), + supportedBindingMethods = setOf(BINDING_METHOD_JWK, URN_TYPE_JWK_THUMBPRINT), + supportedSigningAlgorithms = cryptoAlgorithms + ?.mapNotNull { it.toJwsAlgorithm().getOrNull()?.identifier } + ?.toSet(), sdJwtClaims = claimNames.associateWith { RequestedCredentialClaimSpecification() } ) } else null return listOfNotNull(iso, jwtVc, sdJwt).toMap() } +fun ConstantIndex.CredentialScheme.toCredentialIdentifier() = + listOfNotNull( + if (supportsIso) isoNamespace!! else null, + if (supportsVcJwt) encodeToCredentialIdentifier(vcType!!, CredentialFormatEnum.JWT_VC) else null, + if (supportsSdJwt) encodeToCredentialIdentifier(sdJwtType!!, CredentialFormatEnum.VC_SD_JWT) else null + ) + /** - * Reverse functionality of [decodeFromCredentialIdentifier] + * Reverse functionality of [decodeFromCredentialIdentifier], + * i.e. encodes a credential [type] and [format] to a single string, + * e.g. from [at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023] and [CredentialFormatEnum.JWT_VC] to + * `AtomicAttribute2023#jwt_vc_json` */ private fun encodeToCredentialIdentifier(type: String, format: CredentialFormatEnum) = "$type#${format.text}" /** - * Reverse functionality of [encodeToCredentialIdentifier] + * Reverse functionality of [encodeToCredentialIdentifier], which can also handle ISO namespaces, + * i.e. decodes a single string into a credential scheme and format, + * e.g. from `AtomicAttribute2023#jwt_vc_json` to + * [at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023] and [CredentialFormatEnum.JWT_VC] */ fun decodeFromCredentialIdentifier(input: String): Pair? { - val vcTypeOrSdJwtType = input.substringBeforeLast("#") - val credentialScheme = AttributeIndex.resolveSdJwtAttributeType(vcTypeOrSdJwtType) - ?: AttributeIndex.resolveAttributeType(vcTypeOrSdJwtType) - ?: return null - val format = CredentialFormatEnum.parse(input.substringAfterLast("#")) - ?: return null - return Pair(credentialScheme, format) + if (input.contains("#")) { + val vcTypeOrSdJwtType = input.substringBeforeLast("#") + val credentialScheme = AttributeIndex.resolveSdJwtAttributeType(vcTypeOrSdJwtType) + ?: AttributeIndex.resolveAttributeType(vcTypeOrSdJwtType) + ?: return null + val format = CredentialFormatEnum.parse(input.substringAfterLast("#")) + ?: return null + return Pair(credentialScheme, format) + } else { + return AttributeIndex.resolveIsoNamespace(input) + ?.let { Pair(it, CredentialFormatEnum.MSO_MDOC) } + } } fun CredentialFormatEnum.toRepresentation() = when (this) { - CredentialFormatEnum.JWT_VC_SD_UNOFFICIAL -> ConstantIndex.CredentialRepresentation.SD_JWT CredentialFormatEnum.VC_SD_JWT -> ConstantIndex.CredentialRepresentation.SD_JWT CredentialFormatEnum.MSO_MDOC -> ConstantIndex.CredentialRepresentation.ISO_MDOC else -> ConstantIndex.CredentialRepresentation.PLAIN_JWT diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerMetadata.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerMetadata.kt deleted file mode 100644 index dd59f71bf..000000000 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/IssuerMetadata.kt +++ /dev/null @@ -1,214 +0,0 @@ -package at.asitplus.wallet.lib.oidvci - -import at.asitplus.KmmResult -import at.asitplus.KmmResult.Companion.wrap -import at.asitplus.signum.indispensable.josef.JwsAlgorithm -import at.asitplus.wallet.lib.oidc.IdTokenType -import at.asitplus.wallet.lib.oidc.jsonSerializer -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString - -/** - * To be serialized into `/.well-known/openid-credential-issuer` - */ -@Serializable -data class IssuerMetadata( - /** - * OIDC Discovery: REQUIRED. URL using the https scheme with no query or fragment component that the OP asserts as - * its Issuer Identifier. If Issuer discovery is supported (see Section 2), this value MUST be identical to the - * issuer value returned by WebFinger. This also MUST be identical to the `iss` Claim value in ID Tokens issued - * from this Issuer. - */ - @SerialName("issuer") - val issuer: String? = null, - - /** - * OID4VCI: REQUIRED. The Credential Issuer's identifier. - */ - @SerialName("credential_issuer") - val credentialIssuer: String? = null, - - /** - * OID4VCI: OPTIONAL. Array of strings, where each string is an identifier of the OAuth 2.0 Authorization Server - * (as defined in RFC8414) the Credential Issuer relies on for authorization. If this parameter is omitted, the - * entity providing the Credential Issuer is also acting as the Authorization Server, i.e., the Credential Issuer's - * identifier is used to obtain the Authorization Server metadata. - */ - @SerialName("authorization_servers") - val authorizationServers: Set? = null, - - /** - * OID4VCI: REQUIRED. URL of the Credential Issuer's Credential Endpoint. This URL MUST use the https scheme and - * MAY contain port, path and query parameter components. - */ - @SerialName("credential_endpoint") - val credentialEndpointUrl: String? = null, - - /** - * OIDC Discovery: URL of the OP's OAuth 2.0 Token Endpoint (OpenID.Core). This is REQUIRED unless only the - * Implicit Flow is used. - */ - @SerialName("token_endpoint") - val tokenEndpointUrl: String? = null, - - /** - * OIDC Discovery: REQUIRED. URL of the OP's JSON Web Key Set document. This contains the signing key(s) the RP - * uses to validate signatures from the OP. The JWK Set MAY also contain the Server's encryption key(s), which are - * used by RPs to encrypt requests to the Server. - * - * OIDC SIOPv2: MUST NOT be present in Self-Issued OP Metadata. If it is, the RP MUST ignore it and use the `sub` - * Claim in the ID Token to obtain signing keys to validate the signatures from the Self-Issued OpenID Provider. - */ - @SerialName("jwks_uri") - val jsonWebKeySetUrl: String? = null, - - /** - * OIDC Discovery: REQUIRED. URL of the OP's OAuth 2.0 Authorization Endpoint (OpenID.Core). - * - * OIDC SIOPv2: REQUIRED. URL of the Self-Issued OP used by the RP to perform Authentication of the End-User. - * Can be custom URI scheme, or Universal Links/App links. - */ - @SerialName("authorization_endpoint") - val authorizationEndpointUrl: String? = null, - - /** - * OID4VCI: OPTIONAL. URL of the Credential Issuer's Batch Credential Endpoint, as defined in Section 8. - * This URL MUST use the `https` scheme and MAY contain port, path, and query parameter components. - * If omitted, the Credential Issuer does not support the Batch Credential Endpoint. - */ - @SerialName("batch_credential_endpoint") - val batchCredentialEndpointUrl: String? = null, - - /** - * OID4VCI: OPTIONAL. URL of the Credential Issuer's Deferred Credential Endpoint, as defined in Section 9. - * This URL MUST use the `https` scheme and MAY contain port, path, and query parameter components. - * If omitted, the Credential Issuer does not support the Deferred Credential Endpoint. - */ - @SerialName("deferred_credential_endpoint") - val deferredCredentialEndpointUrl: String? = null, - - /** - * OID4VCI: OPTIONAL. URL of the Credential Issuer's Notification Endpoint, as defined in Section 10. - * This URL MUST use the `https` scheme and MAY contain port, path, and query parameter components. - * If omitted, the Credential Issuer does not support the Notification Endpoint. - */ - @SerialName("notification_endpoint") - val notificationEndpointUrl: String? = null, - - /** - * OID4VCI: OPTIONAL. Object containing information about whether the Credential Issuer supports encryption of the - * Credential and Batch Credential Response on top of TLS. - */ - @SerialName("credential_response_encryption") - val credentialResponseEncryption: SupportedAlgorithmsContainer? = null, - - /** - * OID4VCI: OPTIONAL. Boolean value specifying whether the Credential Issuer supports returning - * [AuthorizationDetails.credentialIdentifiers] in the Token Response parameter, with `true` - * indicating support. If omitted, the default value is `false`. - */ - @SerialName("credential_identifiers_supported") - val supportsCredentialIdentifiers: Boolean? = false, - - /** - * OID4VCI: REQUIRED. Object that describes specifics of the Credential that the Credential Issuer supports - * issuance of. This object contains a list of name/value pairs, where each name is a unique identifier of the - * supported Credential being described. - */ - @SerialName("credential_configurations_supported") - val supportedCredentialConfigurations: Map? = null, - - /** - * OID4VCI: OPTIONAL. An array of objects, where each object contains display properties of a Credential Issuer for - * a certain language. - */ - @SerialName("display") - val displayProperties: Set? = null, - - /** - * OIDC Discovery: REQUIRED. JSON array containing a list of the OAuth 2.0 `response_type` values that this OP - * supports. Dynamic OpenID Providers MUST support the `code`, `id_token`, and the `token id_token` Response Type - * values. - * OIDC SIOPv2: MUST be `id_token`. - */ - @SerialName("response_types_supported") - val responseTypesSupported: Set? = null, - - /** - * OIDC SIOPv2: REQUIRED. A JSON array of strings representing supported scopes. - * MUST support the `openid` scope value. - */ - @SerialName("scopes_supported") - val scopesSupported: Set? = null, - - /** - * OIDC Discovery: REQUIRED. JSON array containing a list of the Subject Identifier types that this OP supports. - * Valid types include `pairwise` and `public`. - */ - @SerialName("subject_types_supported") - val subjectTypesSupported: Set? = null, - - /** - * OIDC Discovery: REQUIRED. A JSON array containing a list of the JWS signing algorithms (`alg` values) supported - * by the OP for the ID Token to encode the Claims in a JWT (RFC7519). - * Valid values include `RS256`, `ES256`, `ES256K`, and `EdDSA`. - */ - @SerialName("id_token_signing_alg_values_supported") - val idTokenSigningAlgorithmsSupported: Set? = null, - - /** - * OIDC SIOPv2: REQUIRED. A JSON array containing a list of the JWS signing algorithms (alg values) supported by the - * OP for Request Objects, which are described in Section 6.1 of OpenID.Core. - * Valid values include `none`, `RS256`, `ES256`, `ES256K`, and `EdDSA`. - */ - @SerialName("request_object_signing_alg_values_supported") - val requestObjectSigningAlgorithmsSupported: Set? = null, - - /** - * OIDC SIOPv2: REQUIRED. A JSON array of strings representing URI scheme identifiers and optionally method names of - * supported Subject Syntax Types. - * Valid values include `urn:ietf:params:oauth:jwk-thumbprint`, `did:example` and others. - */ - @SerialName("subject_syntax_types_supported") - val subjectSyntaxTypesSupported: Set? = null, - - /** - * OIDC SIOPv2: OPTIONAL. A JSON array of strings containing the list of ID Token types supported by the OP, - * the default value is `attester_signed_id_token` (the id token is issued by the party operating the OP, i.e. this - * is the classical id token as defined in OpenID.Core), may also include `subject_signed_id_token` (Self-Issued - * ID Token, i.e. the id token is signed with key material under the end-user's control). - */ - @SerialName("id_token_types_supported") - val idTokenTypesSupported: Set? = null, - - /** - * OID4VP: OPTIONAL. Boolean value specifying whether the Wallet supports the transfer of `presentation_definition` - * by reference, with true indicating support. If omitted, the default value is true. - */ - @SerialName("presentation_definition_uri_supported") - val presentationDefinitionUriSupported: Boolean = true, - - /** - * OID4VP: REQUIRED. An object containing a list of key value pairs, where the key is a string identifying a - * Credential format supported by the Wallet. Valid Credential format identifier values are defined in Annex E - * of OpenID.VCI. Other values may be used when defined in the profiles of this specification. - */ - @SerialName("vp_formats_supported") - val vpFormatsSupported: VpFormatsSupported? = null, - - /** - * OID4VP: OPTIONAL. Array of JSON Strings containing the values of the Client Identifier schemes that the Wallet - * supports. The values defined by this specification are `pre-registered`, `redirect_uri`, `entity_id`, `did`. - * If omitted, the default value is pre-registered. - */ - @SerialName("client_id_schemes_supported") - val clientIdSchemesSupported: Set? = null, -) { - fun serialize() = jsonSerializer.encodeToString(this) - - companion object { - fun deserialize(input: String): KmmResult = - runCatching { jsonSerializer.decodeFromString(input) }.wrap() - } -} \ No newline at end of file diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/MapStore.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/MapStore.kt new file mode 100644 index 000000000..3c015c73c --- /dev/null +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/MapStore.kt @@ -0,0 +1,47 @@ +package at.asitplus.wallet.lib.oidvci + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Provides a simple map of keys of type [T] to values of type [U]. + * Mainly used in OID4VCI to hold state in the [SimpleAuthorizationService] and [WalletService]. + * Can be implemented to provide replication across different instances of the enclosing application. + */ +interface MapStore { + + /** + * Implementers: Associate [key] with [value] + */ + suspend fun put(key: T, value: U) + + /** + * Implementers: Return the value associated with [key] + */ + suspend fun get(key: T): U? + + /** + * Implementers: Return and remove the value associated with [key] + */ + suspend fun remove(key: T): U? + +} + + +/** + * Holds map in memory, protected with a [Mutex], + * to ensure a basic form of thread-safety. + */ +class DefaultMapStore : MapStore { + + private val mutex = Mutex() + private val map = mutableMapOf() + + override suspend fun put(key: T, value: U) { + mutex.withLock { map.put(key, value) } + } + + override suspend fun get(key: T) = map[key] + + override suspend fun remove(key: T): U? = mutex.withLock { map.remove(key) } +} \ No newline at end of file diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/NonceService.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/NonceService.kt index ca06fa229..d861bb82e 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/NonceService.kt +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/NonceService.kt @@ -1,24 +1,47 @@ package at.asitplus.wallet.lib.oidvci import com.benasher44.uuid.uuid4 - +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Provides generation, storage and validation of challenges used throughout the code, e.g. as challenges for + * presentation of credentials. + * Can be implemented to provide replication across different instances of the enclosing application. + */ +// TODO Rename "nonce" to "challenge" in next major release interface NonceService { - fun provideNonce(): String + /** + * Implementers: Generate a new random string, store it for later verification + */ + suspend fun provideNonce(): String + + /** + * Implementers: Verify if the string has been generated by this instance + */ + suspend fun verifyNonce(it: String): Boolean - fun verifyAndRemoveNonce(it: String): Boolean + /** + * Implementers: Verify if the value has been generated by this instance, remove it from the list of valid values + */ + suspend fun verifyAndRemoveNonce(it: String): Boolean } +/** + * Holds valid random values in memory, protected with a [Mutex], + * to ensure a basic form of thread-safety. + */ class DefaultNonceService : NonceService { - private val validNonces = mutableListOf() + // TODO Remove values after a certain timeout + private val mutex = Mutex() + private val values = mutableListOf() + + override suspend fun provideNonce() = uuid4().toString().also { mutex.withLock { values += it } } - override fun provideNonce(): String { - return uuid4().toString().also { validNonces += it } - } + override suspend fun verifyNonce(it: String) = values.contains(it) - override fun verifyAndRemoveNonce(it: String): Boolean { - return validNonces.remove(it) - } + override suspend fun verifyAndRemoveNonce(it: String) = mutex.withLock { values.remove(it) } } \ No newline at end of file diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2AuthorizationServer.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2AuthorizationServer.kt deleted file mode 100644 index 0173b3cae..000000000 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2AuthorizationServer.kt +++ /dev/null @@ -1,34 +0,0 @@ -package at.asitplus.wallet.lib.oidvci - -import at.asitplus.KmmResult - -/** - * Used by [CredentialIssuer] to obtain user data when issuing credentials using OID4VCI. - */ -interface OAuth2AuthorizationServer { - /** - * Used in several fields in [IssuerMetadata], to provide endpoint URLs to clients. - */ - val publicContext: String - - /** - * Provide a pre-authorized code (for flow defined in OID4VCI), to be used by the Wallet implementation - * to load credentials. - */ - suspend fun providePreAuthorizedCode(): String? - - /** - * Get the [OidcUserInfoExtended] (holding [OidcUserInfo]) associated with the [accessToken], - * that was created before at the Authorization Server. - */ - suspend fun getUserInfo(accessToken: String): KmmResult - - // TODO How is this supposed to happen when using an external Authorization Server? - suspend fun verifyAndRemoveClientNonce(nonce: String): Boolean - - /** - * Provide necessary [OAuth2AuthorizationServerMetadata] JSON for a client to be able to authenticate - */ - suspend fun provideMetadata(): KmmResult -} - diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2AuthorizationServerAdapter.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2AuthorizationServerAdapter.kt new file mode 100644 index 000000000..8eab29585 --- /dev/null +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2AuthorizationServerAdapter.kt @@ -0,0 +1,48 @@ +package at.asitplus.wallet.lib.oidvci + +import at.asitplus.KmmResult +import at.asitplus.openid.OAuth2AuthorizationServerMetadata +import at.asitplus.openid.OidcUserInfoExtended + +/** + * Used in OID4VCI by [CredentialIssuer] to obtain user data when issuing credentials using OID4VCI. + * + * Could also be a remote service + */ +interface OAuth2AuthorizationServerAdapter { + + /** + * Used in several fields in [at.asitplus.openid.IssuerMetadata], to provide endpoint URLs to clients. + */ + val publicContext: String + + /** + * Provide a pre-authorized code (for flow defined in OID4VCI), to be used by the Wallet implementation + * to load credentials. + */ + suspend fun providePreAuthorizedCode(user: OidcUserInfoExtended): String + + /** + * Get the [OidcUserInfoExtended] (holding [at.asitplus.openid.OidcUserInfo]) associated with the [accessToken], + * that was created before at the Authorization Server. + */ + suspend fun getUserInfo(accessToken: String): KmmResult + + /** + * Whether this authorization server includes [at.asitplus.openid.TokenResponseParameters.clientNonce] it its + * token response, i.e. whether the [CredentialIssuer] needs to verify it using [verifyClientNonce]. + */ + val supportsClientNonce: Boolean + + /** + * Called by [CredentialIssuer] to verify that nonces contained in proof-of-possession statements from clients + * are indeed valid. + */ + suspend fun verifyClientNonce(nonce: String): Boolean + + /** + * Provide necessary [OAuth2AuthorizationServerMetadata] JSON for a client to be able to authenticate + */ + suspend fun provideMetadata(): KmmResult +} + diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2DataProvider.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2DataProvider.kt index dda72ef22..efd895687 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2DataProvider.kt +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2DataProvider.kt @@ -1,15 +1,15 @@ package at.asitplus.wallet.lib.oidvci -import at.asitplus.wallet.lib.oidc.AuthenticationRequestParameters +import at.asitplus.openid.AuthenticationRequestParameters +import at.asitplus.openid.OidcUserInfoExtended /** - * Interface used in [SimpleAuthorizationService] to actually load user data, converting it into [OidcUserInfo]. + * Interface used in [CredentialAuthorizationServiceStrategy] to actually load user data when client requests + * and authorization code. */ interface OAuth2DataProvider { /** * Load user information (i.e. authenticate the client) with data sent from [request]. - * - * @param request May be null when using pre-authorized code flow (defined in OID4VCI). */ - suspend fun loadUserInfo(request: AuthenticationRequestParameters? = null): OidcUserInfoExtended? + suspend fun loadUserInfo(request: AuthenticationRequestParameters, code: String): OidcUserInfoExtended? } \ No newline at end of file diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2IssuerCredentialDataProvider.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2IssuerCredentialDataProvider.kt index 662df8490..245cda68c 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2IssuerCredentialDataProvider.kt +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/OAuth2IssuerCredentialDataProvider.kt @@ -2,6 +2,7 @@ package at.asitplus.wallet.lib.oidvci import at.asitplus.KmmResult import at.asitplus.catching +import at.asitplus.openid.OidcUserInfoExtended import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.wallet.lib.agent.ClaimToBeIssued import at.asitplus.wallet.lib.agent.CredentialToBeIssued diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/TokenService.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/TokenService.kt deleted file mode 100644 index 8aef1e618..000000000 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/TokenService.kt +++ /dev/null @@ -1,30 +0,0 @@ -package at.asitplus.wallet.lib.oidvci - -import com.benasher44.uuid.uuid4 - -interface TokenService { - - fun provideToken(): String - - fun verifyToken(it: String): Boolean - - fun verifyAndRemoveToken(it: String): Boolean - -} - -class DefaultTokenService : TokenService { - - private val validTokens = mutableListOf() - - override fun provideToken(): String { - return uuid4().toString().also { validTokens += it } - } - - override fun verifyToken(it: String): Boolean { - return validTokens.contains(it) - } - - override fun verifyAndRemoveToken(it: String): Boolean { - return validTokens.remove(it) - } -} \ No newline at end of file diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt index f4598cfe6..87de9e104 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/WalletService.kt @@ -2,9 +2,8 @@ package at.asitplus.wallet.lib.oidvci import at.asitplus.KmmResult import at.asitplus.catching -import at.asitplus.signum.indispensable.cosef.CborWebToken -import at.asitplus.signum.indispensable.cosef.CoseHeader -import at.asitplus.signum.indispensable.cosef.toCoseAlgorithm +import at.asitplus.openid.* +import at.asitplus.openid.OpenIdConstants.Errors import at.asitplus.signum.indispensable.io.Base64UrlStrict import at.asitplus.signum.indispensable.josef.JsonWebKeySet import at.asitplus.signum.indispensable.josef.JsonWebToken @@ -12,10 +11,8 @@ import at.asitplus.signum.indispensable.josef.JwsHeader import at.asitplus.signum.indispensable.josef.toJwsAlgorithm import at.asitplus.wallet.lib.agent.CryptoService import at.asitplus.wallet.lib.agent.DefaultCryptoService -import at.asitplus.wallet.lib.agent.KeyPairAdapter -import at.asitplus.wallet.lib.agent.RandomKeyPairAdapter -import at.asitplus.wallet.lib.cbor.CoseService -import at.asitplus.wallet.lib.cbor.DefaultCoseService +import at.asitplus.wallet.lib.agent.EphemeralKeyWithoutCert +import at.asitplus.wallet.lib.agent.KeyMaterial import at.asitplus.wallet.lib.data.ConstantIndex import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.* @@ -25,32 +22,25 @@ import at.asitplus.wallet.lib.data.ConstantIndex.supportsVcJwt import at.asitplus.wallet.lib.data.VcDataModelConstants.VERIFIABLE_CREDENTIAL import at.asitplus.wallet.lib.iso.sha256 import at.asitplus.wallet.lib.jws.DefaultJwsService +import at.asitplus.wallet.lib.jws.JwsContentTypeConstants import at.asitplus.wallet.lib.jws.JwsService -import at.asitplus.wallet.lib.oidc.AuthenticationRequestParameters +import at.asitplus.wallet.lib.oauth2.OAuth2Client import at.asitplus.wallet.lib.oidc.OidcSiopVerifier.AuthnResponseResult -import at.asitplus.wallet.lib.oidc.OpenIdConstants -import at.asitplus.wallet.lib.oidc.OpenIdConstants.CODE_CHALLENGE_METHOD_SHA256 -import at.asitplus.wallet.lib.oidc.OpenIdConstants.CREDENTIAL_TYPE_OPENID -import at.asitplus.wallet.lib.oidc.OpenIdConstants.Errors -import at.asitplus.wallet.lib.oidc.OpenIdConstants.GRANT_TYPE_AUTHORIZATION_CODE -import at.asitplus.wallet.lib.oidc.OpenIdConstants.GRANT_TYPE_CODE -import at.asitplus.wallet.lib.oidc.OpenIdConstants.GRANT_TYPE_PRE_AUTHORIZED_CODE import at.asitplus.wallet.lib.oidc.RemoteResourceRetrieverFunction -import at.asitplus.wallet.lib.oidvci.mdl.RequestedCredentialClaimSpecification import com.benasher44.uuid.uuid4 import io.github.aakira.napier.Napier import io.ktor.http.* import io.ktor.util.* import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.datetime.Clock import kotlin.random.Random /** - * Client service to retrieve credentials using - * [OpenID for Verifiable Credential Issuance](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html). - * Implemented from Draft `openid-4-verifiable-credential-issuance-1_0-11`, 2023-02-03. + * Client service to retrieve credentials using OID4VCI + * + * Implemented from + * [OpenID for Verifiable Credential Issuance](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html) + * , Draft 14, 2024-08-21. */ class WalletService( /** @@ -66,15 +56,11 @@ class WalletService( * Used to prove possession of the key material to create [CredentialRequestProof], * i.e. the holder key. */ - private val cryptoService: CryptoService = DefaultCryptoService(RandomKeyPairAdapter()), + private val cryptoService: CryptoService = DefaultCryptoService(EphemeralKeyWithoutCert()), /** * Used to prove possession of the key material to create [CredentialRequestProof]. */ private val jwsService: JwsService = DefaultJwsService(cryptoService), - /** - * Used to prove possession of the key material to create [CredentialRequestProof]. - */ - private val coseService: CoseService = DefaultCoseService(cryptoService), /** * Need to implement if resources are defined by reference, i.e. the URL for a [JsonWebKeySet], * or the authentication request itself as `request_uri`, or `presentation_definition_uri`. @@ -82,38 +68,24 @@ class WalletService( * or the HTTP header `Location`, i.e. if the server sends the request object as a redirect. */ private val remoteResourceRetriever: RemoteResourceRetrieverFunction = { null }, + private val stateToCodeStore: MapStore = DefaultMapStore(), ) { - private val stateToCodeChallengeMap = mutableMapOf() - private val codeChallengeMutex = Mutex() - - companion object { - fun newDefaultInstance( - clientId: String, - redirectUrl: String, - keyPairAdapter: KeyPairAdapter, - remoteResourceRetriever: RemoteResourceRetrieverFunction = { null }, - ): WalletService = WalletService( - clientId = clientId, - redirectUrl = redirectUrl, - cryptoService = DefaultCryptoService(keyPairAdapter), - remoteResourceRetriever = remoteResourceRetriever, - ) + val oauth2Client: OAuth2Client = OAuth2Client(clientId, redirectUrl) - fun newDefaultInstance( - clientId: String, - redirectUrl: String, - cryptoService: CryptoService, - remoteResourceRetriever: RemoteResourceRetrieverFunction = { null }, - ): WalletService = WalletService( - clientId = clientId, - redirectUrl = redirectUrl, - cryptoService = cryptoService, - jwsService = DefaultJwsService(cryptoService), - coseService = DefaultCoseService(cryptoService), - remoteResourceRetriever = remoteResourceRetriever, - ) - } + constructor( + clientId: String = "https://wallet.a-sit.at/app", + redirectUrl: String = "$clientId/callback", + keyMaterial: KeyMaterial, + remoteResourceRetriever: RemoteResourceRetrieverFunction = { null }, + stateToCodeStore: MapStore = DefaultMapStore(), + ) : this( + clientId = clientId, + redirectUrl = redirectUrl, + cryptoService = DefaultCryptoService(keyMaterial), + remoteResourceRetriever = remoteResourceRetriever, + stateToCodeStore = stateToCodeStore + ) data class RequestOptions( /** @@ -160,226 +132,43 @@ class WalletService( } /** - * Send the result as parameters (either POST or GET) to the server at `/authorize` (or more specific - * [IssuerMetadata.authorizationEndpointUrl]). + * Build authorization details for use in [OAuth2Client.createAuthRequest]. * - * Sample ktor code: - * ``` - * val credentialConfig = issuerMetadata.supportedCredentialConfigurations!! - * .entries.first { it.key == credentialOffer.configurationIds.first() }.toPair() - * val authnRequest = client.createAuthRequest( - * state = state, - * credential = credentialConfig, - * credentialIssuer = issuerMetadata.credentialIssuer, - * authorizationServers = issuerMetadata.authorizationServers - * ) - * val authnResponse = httpClient.get(issuerMetadata.authorizationEndpointUrl!!) { - * url { - * authnRequest.encodeToParameters().forEach { parameters.append(it.key, it.value) } - * } - * } - * val authn = AuthenticationResponseParameters.deserialize(authnResponse.bodyAsText()).getOrThrow() - * ``` * - * @param credential which credential from [IssuerMetadata.supportedCredentialConfigurations] to request - * @param credentialIssuer from [IssuerMetadata.credentialIssuer] + * @param credentialConfigurationId which credential (the key) from + * [IssuerMetadata.supportedCredentialConfigurations] to request * @param authorizationServers from [IssuerMetadata.authorizationServers] */ - suspend fun createAuthRequest( - state: String, - credential: Pair, - credentialIssuer: String? = null, + suspend fun buildAuthorizationDetails( + credentialConfigurationId: String, authorizationServers: Set? = null, - ) = AuthenticationRequestParameters( - responseType = GRANT_TYPE_CODE, - state = state, - clientId = clientId, - authorizationDetails = setOf(credential.toAuthnDetails(authorizationServers)), - scope = credential.first, - resource = credentialIssuer, - redirectUrl = redirectUrl, - codeChallenge = generateCodeVerifier(state), - codeChallengeMethod = CODE_CHALLENGE_METHOD_SHA256, + ) = setOf( + AuthorizationDetails.OpenIdCredential( + credentialConfigurationId = credentialConfigurationId, + locations = authorizationServers, + // TODO Test in real-world settings, is this correct? + credentialIdentifiers = setOf(credentialConfigurationId) + ) ) /** - * Send the result as parameters (either POST or GET) to the server at `/authorize` (or more specific - * [IssuerMetadata.authorizationEndpointUrl]). - * - * Sample ktor code: - * ``` - * val authnRequest = client.createAuthRequest( - * requestOptions = requestOptions, - * credentialIssuer = issuerMetadata.credentialIssuer, - * ) - * val authnResponse = httpClient.get(issuerMetadata.authorizationEndpointUrl!!) { - * url { - * authnRequest.encodeToParameters().forEach { parameters.append(it.key, it.value) } - * } - * } - * val authn = AuthenticationResponseParameters.deserialize(authnResponse.bodyAsText()).getOrThrow() - * ``` - * - * @param requestOptions which credential in which representation to request - * @param credentialIssuer from [IssuerMetadata.credentialIssuer] + * Build authorization details for use in [OAuth2Client.createAuthRequest]. */ - suspend fun createAuthRequest( - requestOptions: RequestOptions, - credentialIssuer: String? = null, - ) = AuthenticationRequestParameters( - responseType = GRANT_TYPE_CODE, - state = requestOptions.state, - clientId = clientId, - authorizationDetails = requestOptions.toAuthnDetails()?.let { setOf(it) }, - resource = credentialIssuer, - redirectUrl = redirectUrl, - codeChallenge = generateCodeVerifier(requestOptions.state), - codeChallengeMethod = CODE_CHALLENGE_METHOD_SHA256, - ) - - @OptIn(ExperimentalStdlibApi::class) - private suspend fun generateCodeVerifier(state: String): String { - val codeVerifier = Random.nextBytes(32).toHexString(HexFormat.Default) - codeChallengeMutex.withLock { stateToCodeChallengeMap.put(state, codeVerifier) } - return codeVerifier.encodeToByteArray().sha256().encodeToString(Base64UrlStrict) - } + suspend fun buildAuthorizationDetails( + requestOptions: RequestOptions + ) = setOfNotNull(requestOptions.toAuthnDetails()) - sealed class AuthorizationForToken { + sealed class CredentialRequestInput { /** - * Authorization code from an actual OAuth2 Authorization Server, or [SimpleAuthorizationService.authorize] + * @param id from the token response, see [TokenResponseParameters.authorizationDetails] + * and [AuthorizationDetails.OpenIdCredential.credentialConfigurationId] */ - data class Code(val code: String) : AuthorizationForToken() - - /** - * Pre-auth code from [CredentialOfferGrants.preAuthorizedCode] in [CredentialOffer.grants] - */ - data class PreAuthCode(val preAuth: CredentialOfferGrantsPreAuthCode) : AuthorizationForToken() - } - - /** - * Request token with an authorization code, e.g. from [createAuthRequest], or pre-auth code. - * - * Send the result as POST parameters (form-encoded) to the server at `/token` (or more specific - * [IssuerMetadata.tokenEndpointUrl]). - * - * Sample ktor code for authorization code: - * ``` - * val authnRequest = client.createAuthRequest(requestOptions) - * val authnResponse = authorizationService.authorize(authnRequest).getOrThrow() - * val code = authnResponse.params.code - * val tokenRequest = client.createTokenRequestParameters(requestOptions, code = code) - * val tokenResponse = httpClient.submitForm( - * url = issuerMetadata.tokenEndpointUrl!!, - * formParameters = parameters { - * tokenRequest.encodeToParameters().forEach { append(it.key, it.value) } - * } - * ) - * val token = TokenResponseParameters.deserialize(tokenResponse.bodyAsText()).getOrThrow() - * ``` - * - * Sample ktor code for pre-authn code: - * ``` - * val tokenRequest = - * client.createTokenRequestParameters(requestOptions, credentialOffer.grants!!.preAuthorizedCode) - * val tokenResponse = httpClient.submitForm( - * url = issuerMetadata.tokenEndpointUrl!!, - * formParameters = parameters { - * tokenRequest.encodeToParameters().forEach { append(it.key, it.value) } - * } - * ) - * val token = TokenResponseParameters.deserialize(tokenResponse.bodyAsText()).getOrThrow() - * ``` - * - * @param requestOptions which credential in which representation to request - * @param authorization for the token endpoint - */ - suspend fun createTokenRequestParameters( - requestOptions: RequestOptions, - authorization: AuthorizationForToken, - ) = when (authorization) { - is AuthorizationForToken.Code -> TokenRequestParameters( - grantType = GRANT_TYPE_AUTHORIZATION_CODE, - code = authorization.code, - redirectUrl = redirectUrl, - clientId = clientId, - authorizationDetails = requestOptions.toAuthnDetails()?.let { setOf(it) }, - codeVerifier = codeChallengeMutex.withLock { stateToCodeChallengeMap.remove(requestOptions.state) } - ) - - is AuthorizationForToken.PreAuthCode -> TokenRequestParameters( - grantType = GRANT_TYPE_PRE_AUTHORIZED_CODE, - redirectUrl = redirectUrl, - clientId = clientId, - authorizationDetails = (requestOptions.toAuthnDetails())?.let { setOf(it) }, - transactionCode = authorization.preAuth.transactionCode, - preAuthorizedCode = authorization.preAuth.preAuthorizedCode, - codeVerifier = codeChallengeMutex.withLock { stateToCodeChallengeMap.remove(requestOptions.state) } - ) - } - - /** - * Request token with an authorization code, e.g. from [createAuthRequest], or pre-auth code. - * - * Send the result as POST parameters (form-encoded) to the server at `/token` (or more specific - * [IssuerMetadata.tokenEndpointUrl]). - * - * Sample ktor code for authorization code: - * ``` - * val authnRequest = client.createAuthRequest(requestOptions) - * val authnResponse = authorizationService.authorize(authnRequest).getOrThrow() - * val code = authnResponse.params.code - * val tokenRequest = client.createTokenRequestParameters(requestOptions, code = code) - * val tokenResponse = httpClient.submitForm( - * url = issuerMetadata.tokenEndpointUrl!!, - * formParameters = parameters { - * tokenRequest.encodeToParameters().forEach { append(it.key, it.value) } - * } - * ) - * val token = TokenResponseParameters.deserialize(tokenResponse.bodyAsText()).getOrThrow() - * ``` - * - * Sample ktor code for pre-authn code: - * ``` - * val tokenRequest = - * client.createTokenRequestParameters(requestOptions, credentialOffer.grants!!.preAuthorizedCode) - * val tokenResponse = httpClient.submitForm( - * url = issuerMetadata.tokenEndpointUrl!!, - * formParameters = parameters { - * tokenRequest.encodeToParameters().forEach { append(it.key, it.value) } - * } - * ) - * val token = TokenResponseParameters.deserialize(tokenResponse.bodyAsText()).getOrThrow() - * ``` - * - * @param credential which credential from [IssuerMetadata.supportedCredentialConfigurations] to request - * @param requestedAttributes attributes that shall be requested explicitly (selective disclosure) - * @param state used in [createAuthRequest], e.g. when using authorization codes - * @param authorization for the token endpoint - */ - suspend fun createTokenRequestParameters( - credential: SupportedCredentialFormat, - requestedAttributes: Set? = null, - state: String? = null, - authorization: AuthorizationForToken, - ) = when (authorization) { - is AuthorizationForToken.Code -> TokenRequestParameters( - grantType = GRANT_TYPE_AUTHORIZATION_CODE, - code = authorization.code, - redirectUrl = redirectUrl, - clientId = clientId, - authorizationDetails = setOf(credential.toAuthnDetails(requestedAttributes)), - codeVerifier = state?.let { codeChallengeMutex.withLock { stateToCodeChallengeMap.remove(it) } } - ) - - is AuthorizationForToken.PreAuthCode -> TokenRequestParameters( - grantType = GRANT_TYPE_PRE_AUTHORIZED_CODE, - redirectUrl = redirectUrl, - clientId = clientId, - authorizationDetails = setOf(credential.toAuthnDetails(requestedAttributes)), - transactionCode = authorization.preAuth.transactionCode, - preAuthorizedCode = authorization.preAuth.preAuthorizedCode, - codeVerifier = state?.let { codeChallengeMutex.withLock { stateToCodeChallengeMap.remove(it) } } - ) + data class CredentialIdentifier(val id: String) : CredentialRequestInput() + data class RequestOptions(val requestOptions: WalletService.RequestOptions) : CredentialRequestInput() + data class Format( + val supportedCredentialFormat: SupportedCredentialFormat, + val requestedAttributes: Set? = null + ) : CredentialRequestInput() } /** @@ -388,58 +177,16 @@ class WalletService( * * Also send along the [TokenResponseParameters.accessToken] from the token response in HTTP header `Authorization` * as value `Bearer accessTokenValue` (depending on the [TokenResponseParameters.tokenType]). - * See [createTokenRequestParameters]. * - * Sample ktor code: - * ``` - * val credentialRequest = client.createCredentialRequestJwt( - * requestOptions = requestOptions, - * clientNonce = token.clientNonce, - * credentialIssuer = issuerMetadata.credentialIssuer - * ).getOrThrow() - * - * val credentialResponse = httpClient.post(issuerMetadata.credentialEndpointUrl) { - * setBody(credentialRequest) - * headers { - * append(HttpHeaders.Authorization, "Bearer ${token.accessToken}") - * } - * } - * ``` - * - * @param credential which credential from [IssuerMetadata.supportedCredentialConfigurations] to request - * @param requestedAttributes attributes that shall be requested explicitly (selective disclosure) - * @param clientNonce `c_nonce` from the token response, optional string, see [TokenResponseParameters.clientNonce] - * @param credentialIssuer `credential_issuer` from the metadata, see [IssuerMetadata.credentialIssuer] - */ - suspend fun createCredentialRequest( - credential: SupportedCredentialFormat, - requestedAttributes: Set? = null, - clientNonce: String?, - credentialIssuer: String?, - ): KmmResult = catching { - val cwtProofType = OpenIdConstants.ProofType.CWT.stringRepresentation - val isCwt = credential.supportedProofTypes?.containsKey(cwtProofType) == true - || credential.format == CredentialFormatEnum.MSO_MDOC - val proof = if (isCwt) { - createCredentialRequestCwt(null, clientNonce, credentialIssuer) - } else { - createCredentialRequestJwt(null, clientNonce, credentialIssuer) - } - credential.toCredentialRequestParameters(requestedAttributes, proof) - .also { Napier.i("createCredentialRequest returns $it") } - } - - /** - * Send the result as JSON-serialized content to the server at `/credential` (or more specific - * [IssuerMetadata.credentialEndpointUrl]). + * Be sure to include a DPoP header if [TokenResponseParameters.tokenType] is `DPoP`, + * see [JwsService.buildDPoPHeader]. * - * Also send along the [TokenResponseParameters.accessToken] from the token response in HTTP header `Authorization` - * as value `Bearer accessTokenValue` (depending on the [TokenResponseParameters.tokenType]). - * See [createTokenRequestParameters]. + * See [OAuth2Client.createTokenRequestParameters]. * * Sample ktor code: * ``` - * val credentialRequest = client.createCredentialRequestJwt( + * val token = ... + * val credentialRequest = client.createCredentialRequest( * requestOptions = requestOptions, * clientNonce = token.clientNonce, * credentialIssuer = issuerMetadata.credentialIssuer @@ -453,39 +200,47 @@ class WalletService( * } * ``` * - * @param requestOptions which credential in which representation to request + * @param input which credential to request, see subclasses of [CredentialRequestInput] * @param clientNonce `c_nonce` from the token response, optional string, see [TokenResponseParameters.clientNonce] * @param credentialIssuer `credential_issuer` from the metadata, see [IssuerMetadata.credentialIssuer] */ suspend fun createCredentialRequest( - requestOptions: RequestOptions, + input: CredentialRequestInput, clientNonce: String?, credentialIssuer: String?, ): KmmResult = catching { - val proof = if (requestOptions.representation == ISO_MDOC) { - createCredentialRequestCwt(requestOptions, clientNonce, credentialIssuer) - } else { - createCredentialRequestJwt(requestOptions, clientNonce, credentialIssuer) - } - requestOptions.toCredentialRequestParameters(proof) - .also { Napier.i("createCredentialRequest returns $it") } + val clock = (input as? CredentialRequestInput.RequestOptions)?.requestOptions?.clock ?: Clock.System + when (input) { + is CredentialRequestInput.CredentialIdentifier -> + CredentialRequestParameters(credentialIdentifier = input.id) + + is CredentialRequestInput.Format -> + input.supportedCredentialFormat.toCredentialRequestParameters(input.requestedAttributes) + + is CredentialRequestInput.RequestOptions -> with(input.requestOptions) { + credentialScheme.toCredentialRequestParameters(representation, requestedAttributes) + } + }.copy( + proof = createCredentialRequestProof(clientNonce, credentialIssuer, clock) + ).also { Napier.i("createCredentialRequest returns $it") } } - private suspend fun createCredentialRequestJwt( - requestOptions: RequestOptions?, + + internal suspend fun createCredentialRequestProof( clientNonce: String?, credentialIssuer: String?, + clock: Clock = Clock.System, ): CredentialRequestProof = CredentialRequestProof( proofType = OpenIdConstants.ProofType.JWT, jwt = jwsService.createSignedJwsAddingParams( header = JwsHeader( - algorithm = cryptoService.keyPairAdapter.signingAlgorithm.toJwsAlgorithm().getOrThrow(), - type = OpenIdConstants.ProofType.JWT_HEADER_TYPE.stringRepresentation, + algorithm = cryptoService.keyMaterial.signatureAlgorithm.toJwsAlgorithm().getOrThrow(), + type = OpenIdConstants.PROOF_JWT_TYPE ), payload = JsonWebToken( issuer = clientId, // omit when token was pre-authn? audience = credentialIssuer, - issuedAt = requestOptions?.clock?.now() ?: Clock.System.now(), + issuedAt = clock.now(), nonce = clientNonce, ).serialize().encodeToByteArray(), addKeyId = false, @@ -494,61 +249,12 @@ class WalletService( ).getOrThrow().serialize() ) - private suspend fun createCredentialRequestCwt( - requestOptions: RequestOptions?, - clientNonce: String?, - credentialIssuer: String?, - ) = CredentialRequestProof( - proofType = OpenIdConstants.ProofType.CWT, - cwt = coseService.createSignedCose( - protectedHeader = CoseHeader( - algorithm = cryptoService.keyPairAdapter.signingAlgorithm.toCoseAlgorithm().getOrThrow(), - contentType = OpenIdConstants.ProofType.CWT_HEADER_TYPE.stringRepresentation, - certificateChain = cryptoService.keyPairAdapter.certificate?.encodeToDerOrNull() - ), - payload = CborWebToken( - issuer = clientId, // omit when token was pre-authn? - audience = credentialIssuer, - issuedAt = requestOptions?.clock?.now() ?: Clock.System.now(), - nonce = clientNonce?.encodeToByteArray(), - ).serialize(), - addKeyId = false, - ).getOrThrow().serialize().encodeToString(Base64UrlStrict), - ) - - private fun RequestOptions.toCredentialRequestParameters(proof: CredentialRequestProof) = - representation.toCredentialRequestParameters(credentialScheme, requestedAttributes, proof) - - private fun SupportedCredentialFormat.toAuthnDetails(requestedAttributes: Set?) = when (this.format) { - CredentialFormatEnum.JWT_VC -> AuthorizationDetails( - type = CREDENTIAL_TYPE_OPENID, - format = format, - credentialDefinition = credentialDefinition - ) - - CredentialFormatEnum.VC_SD_JWT -> AuthorizationDetails( - type = CREDENTIAL_TYPE_OPENID, - format = format, - sdJwtVcType = sdJwtVcType, - claims = requestedAttributes?.toRequestedClaimsSdJwt(sdJwtVcType!!), - ) - - CredentialFormatEnum.MSO_MDOC -> AuthorizationDetails( - type = CREDENTIAL_TYPE_OPENID, - format = format, - docType = docType, - claims = requestedAttributes?.toRequestedClaimsIso(isoClaims?.keys?.firstOrNull() ?: docType!!) - ) - - else -> throw IllegalArgumentException("Credential format $format not supported for AuthorizationDetails") - } - private fun RequestOptions.toAuthnDetails() = representation.toAuthorizationDetails(credentialScheme, requestedAttributes) private fun CredentialRepresentation.toAuthorizationDetails( scheme: ConstantIndex.CredentialScheme, - requestedAttributes: Set? + requestedAttributes: Set?, ) = when (this) { PLAIN_JWT -> scheme.toJwtAuthn(toFormat()) SD_JWT -> scheme.toSdJwtAuthn(toFormat(), requestedAttributes) @@ -556,22 +262,20 @@ class WalletService( } private fun ConstantIndex.CredentialScheme.toJwtAuthn( - format: CredentialFormatEnum + format: CredentialFormatEnum, ) = if (supportsVcJwt) - AuthorizationDetails( - type = CREDENTIAL_TYPE_OPENID, + AuthorizationDetails.OpenIdCredential( format = format, credentialDefinition = SupportedCredentialFormatDefinition( - types = listOf(VERIFIABLE_CREDENTIAL, vcType!!), + types = setOf(VERIFIABLE_CREDENTIAL, vcType!!), ), ) else null private fun ConstantIndex.CredentialScheme.toSdJwtAuthn( format: CredentialFormatEnum, - requestedAttributes: Set? + requestedAttributes: Set?, ) = if (supportsSdJwt) - AuthorizationDetails( - type = CREDENTIAL_TYPE_OPENID, + AuthorizationDetails.OpenIdCredential( format = format, sdJwtVcType = sdJwtType!!, claims = requestedAttributes?.toRequestedClaimsSdJwt(sdJwtType!!), @@ -579,84 +283,64 @@ class WalletService( private fun ConstantIndex.CredentialScheme.toIsoAuthn( format: CredentialFormatEnum, - requestedAttributes: Set? + requestedAttributes: Set?, ) = if (supportsIso) - AuthorizationDetails( - type = CREDENTIAL_TYPE_OPENID, + AuthorizationDetails.OpenIdCredential( format = format, docType = isoDocType, claims = requestedAttributes?.toRequestedClaimsIso(isoNamespace!!) ) else null - private fun CredentialRepresentation.toCredentialRequestParameters( - credentialScheme: ConstantIndex.CredentialScheme, + private fun ConstantIndex.CredentialScheme.toCredentialRequestParameters( + credentialRepresentation: CredentialRepresentation, requestedAttributes: Set?, - proof: CredentialRequestProof ) = when { - this == PLAIN_JWT && credentialScheme.supportsVcJwt -> CredentialRequestParameters( - format = toFormat(), + credentialRepresentation == PLAIN_JWT && supportsVcJwt -> CredentialRequestParameters( + format = CredentialFormatEnum.JWT_VC, credentialDefinition = SupportedCredentialFormatDefinition( - types = listOf(VERIFIABLE_CREDENTIAL) + credentialScheme.vcType!!, + types = setOf(VERIFIABLE_CREDENTIAL, vcType!!), ), - proof = proof ) - this == SD_JWT && credentialScheme.supportsSdJwt -> CredentialRequestParameters( - format = toFormat(), - sdJwtVcType = credentialScheme.sdJwtType!!, - claims = requestedAttributes?.toRequestedClaimsSdJwt(credentialScheme.sdJwtType!!), - proof = proof + credentialRepresentation == SD_JWT && supportsSdJwt -> CredentialRequestParameters( + format = CredentialFormatEnum.VC_SD_JWT, + sdJwtVcType = sdJwtType!!, + claims = requestedAttributes?.toRequestedClaimsSdJwt(sdJwtType!!), ) - this == ISO_MDOC && credentialScheme.supportsIso -> CredentialRequestParameters( - format = toFormat(), - docType = credentialScheme.isoDocType, - claims = requestedAttributes?.toRequestedClaimsIso(credentialScheme.isoNamespace!!), - proof = proof + credentialRepresentation == ISO_MDOC && supportsIso -> CredentialRequestParameters( + format = CredentialFormatEnum.MSO_MDOC, + docType = isoDocType, + claims = requestedAttributes?.toRequestedClaimsIso(isoNamespace!!), ) - else -> throw IllegalArgumentException("format $this not applicable to $credentialScheme") + else -> throw IllegalArgumentException("format $credentialRepresentation not applicable to $this") } private fun SupportedCredentialFormat.toCredentialRequestParameters( requestedAttributes: Set?, - proof: CredentialRequestProof ) = when (format) { CredentialFormatEnum.JWT_VC -> CredentialRequestParameters( format = format, credentialDefinition = credentialDefinition, - proof = proof ) CredentialFormatEnum.VC_SD_JWT -> CredentialRequestParameters( format = format, sdJwtVcType = sdJwtVcType, claims = requestedAttributes?.toRequestedClaimsSdJwt(sdJwtVcType!!), - proof = proof ) CredentialFormatEnum.MSO_MDOC -> CredentialRequestParameters( format = format, docType = docType, claims = requestedAttributes?.toRequestedClaimsIso(isoClaims?.keys?.firstOrNull() ?: docType!!), - proof = proof ) else -> throw IllegalArgumentException("format $format not applicable to create credential request") } } -private fun Pair.toAuthnDetails(authorizationServers: Set?) - : AuthorizationDetails = AuthorizationDetails( - type = "openid_credential", - credentialConfigurationId = first, - format = second.format, - docType = second.docType, - sdJwtVcType = second.sdJwtVcType, - credentialDefinition = second.credentialDefinition, - locations = authorizationServers -) - private fun Collection.toRequestedClaimsSdJwt(sdJwtType: String) = mapOf(sdJwtType to this.associateWith { RequestedCredentialClaimSpecification() }) @@ -669,3 +353,28 @@ private fun CredentialRepresentation.toFormat() = when (this) { SD_JWT -> CredentialFormatEnum.VC_SD_JWT ISO_MDOC -> CredentialFormatEnum.MSO_MDOC } + +/** + * To be set as header `DPoP` in making request to [url], + * see [RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449) + */ +suspend fun JwsService.buildDPoPHeader( + url: String, + httpMethod: String = "POST", + accessToken: String? = null +) = createSignedJwsAddingParams( + header = JwsHeader( + algorithm = algorithm, + type = JwsContentTypeConstants.DPOP_JWT + ), + payload = JsonWebToken( + jwtId = Random.nextBytes(12).encodeToString(Base64UrlStrict), + httpMethod = httpMethod, + httpTargetUrl = url, + accessTokenHash = accessToken?.encodeToByteArray()?.sha256()?.encodeToString(Base64UrlStrict), + issuedAt = Clock.System.now(), + ).serialize().encodeToByteArray(), + addKeyId = false, + addJsonWebKey = true, + addX5c = false, +).getOrThrow().serialize() diff --git a/vck-openid/src/commonTest/kotlin/KotestConfig.kt b/vck-openid/src/commonTest/kotlin/KotestConfig.kt index c638781e0..0e2972e47 100644 --- a/vck-openid/src/commonTest/kotlin/KotestConfig.kt +++ b/vck-openid/src/commonTest/kotlin/KotestConfig.kt @@ -6,7 +6,7 @@ class KotestConfig : AbstractProjectConfig() { init { Napier.takeLogarithm() Napier.base(DebugAntilog()) - at.asitplus.wallet.mdl.Initializer.initWithVcLib() - at.asitplus.wallet.eupid.Initializer.initWithVcLib() + at.asitplus.wallet.mdl.Initializer.initWithVCK() + at.asitplus.wallet.eupid.Initializer.initWithVCK() } } \ No newline at end of file diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oauth2/OAuth2ClientTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oauth2/OAuth2ClientTest.kt new file mode 100644 index 000000000..5eba83369 --- /dev/null +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oauth2/OAuth2ClientTest.kt @@ -0,0 +1,77 @@ +package at.asitplus.wallet.lib.oauth2 + +import at.asitplus.openid.OidcUserInfo +import at.asitplus.openid.OidcUserInfoExtended +import at.asitplus.wallet.lib.data.ConstantIndex +import at.asitplus.wallet.lib.oidc.AuthenticationResponseResult +import at.asitplus.wallet.lib.oidc.DummyOAuth2DataProvider +import at.asitplus.wallet.lib.oidvci.CredentialAuthorizationServiceStrategy +import at.asitplus.wallet.mdl.MobileDrivingLicenceScheme +import com.benasher44.uuid.uuid4 +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import kotlinx.serialization.json.JsonObject + +class OAuth2ClientTest : FunSpec({ + + lateinit var server: SimpleAuthorizationService + lateinit var client: OAuth2Client + + beforeEach { + client = OAuth2Client() + server = SimpleAuthorizationService( + strategy = CredentialAuthorizationServiceStrategy( + DummyOAuth2DataProvider, + setOf(ConstantIndex.AtomicAttribute2023, MobileDrivingLicenceScheme) + ), + ) + } + + test("process with pre-authorized code") { + val user = OidcUserInfoExtended(OidcUserInfo("sub"), JsonObject(mapOf())) + val preAuth = server.providePreAuthorizedCode(user) + .shouldNotBeNull() + val state = uuid4().toString() + val tokenRequest = client.createTokenRequestParameters( + state = state, + authorization = OAuth2Client.AuthorizationForToken.PreAuthCode(preAuth), + ) + val token = server.token(tokenRequest).getOrThrow() + token.authorizationDetails.shouldBeNull() + } + + test("process with pre-authorized code, can't use it twice") { + val user = OidcUserInfoExtended(OidcUserInfo("sub"), JsonObject(mapOf())) + val preAuth = server.providePreAuthorizedCode(user) + .shouldNotBeNull() + val state = uuid4().toString() + val tokenRequest = client.createTokenRequestParameters( + state = state, + authorization = OAuth2Client.AuthorizationForToken.PreAuthCode(preAuth), + ) + server.token(tokenRequest).isSuccess shouldBe true + server.token(tokenRequest).isFailure shouldBe true + } + + test("process with authorization code flow") { + val state = uuid4().toString() + val authnRequest = client.createAuthRequest( + state = state, + ) + val authnResponse = server.authorize(authnRequest).getOrThrow() + .shouldBeInstanceOf() + val code = authnResponse.params.code + .shouldNotBeNull() + + val tokenRequest = client.createTokenRequestParameters( + state = state, + authorization = OAuth2Client.AuthorizationForToken.Code(code), + ) + val token = server.token(tokenRequest).getOrThrow() + token.authorizationDetails.shouldBeNull() + } + +}) \ No newline at end of file diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationRequestParameterFromSerializerTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationRequestParameterFromSerializerTest.kt index c8933d353..7f877af55 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationRequestParameterFromSerializerTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationRequestParameterFromSerializerTest.kt @@ -1,7 +1,8 @@ package at.asitplus.wallet.lib.oidc +import at.asitplus.openid.AuthenticationRequestParameters +import at.asitplus.wallet.lib.agent.EphemeralKeyWithoutCert import at.asitplus.wallet.lib.agent.HolderAgent -import at.asitplus.wallet.lib.agent.RandomKeyPairAdapter import at.asitplus.wallet.lib.agent.VerifierAgent import at.asitplus.wallet.lib.data.ConstantIndex import at.asitplus.wallet.lib.oidvci.decodeFromUrlQuery @@ -10,24 +11,21 @@ import io.kotest.core.spec.style.FreeSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf import io.ktor.http.* -import kotlinx.serialization.encodeToString class AuthenticationRequestParameterFromSerializerTest : FreeSpec({ val relyingPartyUrl = "https://example.com/rp/${uuid4()}" val walletUrl = "https://example.com/wallet/${uuid4()}" - val responseUrl = "https://example.com/rp/${uuid4()}" - val holderKeyPair = RandomKeyPairAdapter() - val oidcSiopWallet = OidcSiopWallet.newDefaultInstance( - keyPairAdapter = holderKeyPair, - holder = HolderAgent(holderKeyPair), + val holderKeyMaterial = EphemeralKeyWithoutCert() + val oidcSiopWallet = OidcSiopWallet( + keyMaterial = holderKeyMaterial, + holder = HolderAgent(holderKeyMaterial), ) - val verifierSiop = OidcSiopVerifier.newInstance( - verifier = VerifierAgent(RandomKeyPairAdapter()), + val verifierSiop = OidcSiopVerifier( + verifier = VerifierAgent(EphemeralKeyWithoutCert()), relyingPartyUrl = relyingPartyUrl, - responseUrl = responseUrl, ) val representations = listOf( @@ -39,8 +37,11 @@ class AuthenticationRequestParameterFromSerializerTest : FreeSpec({ representations.forEach { representation -> val reqOptions = OidcSiopVerifier.RequestOptions( - credentialScheme = ConstantIndex.AtomicAttribute2023, - representation = representation, + credentials = setOf( + OidcSiopVerifier.RequestOptionsCredential( + ConstantIndex.AtomicAttribute2023, representation + ) + ) ) "URL test $representation" { diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/CredentialJsonInteropTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/CredentialJsonInteropTest.kt index 8f1d7c821..754011ead 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/CredentialJsonInteropTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/CredentialJsonInteropTest.kt @@ -1,15 +1,7 @@ package at.asitplus.wallet.lib.oidc import at.asitplus.jsonpath.JsonPath -import at.asitplus.wallet.lib.agent.Holder -import at.asitplus.wallet.lib.agent.HolderAgent -import at.asitplus.wallet.lib.agent.InMemorySubjectCredentialStore -import at.asitplus.wallet.lib.agent.Issuer -import at.asitplus.wallet.lib.agent.IssuerAgent -import at.asitplus.wallet.lib.agent.KeyPairAdapter -import at.asitplus.wallet.lib.agent.RandomKeyPairAdapter -import at.asitplus.wallet.lib.agent.SubjectCredentialStore -import at.asitplus.wallet.lib.agent.toStoreCredentialInput +import at.asitplus.wallet.lib.agent.* import at.asitplus.wallet.lib.data.ConstantIndex import at.asitplus.wallet.lib.data.CredentialToJsonConverter import io.kotest.core.spec.style.FreeSpec @@ -17,26 +9,24 @@ import io.kotest.matchers.shouldNotBe import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive -@Suppress("unused") class CredentialJsonInteropTest : FreeSpec({ - lateinit var holderKeyPair: KeyPairAdapter - + lateinit var holderKeyMaterial: KeyMaterial lateinit var issuerAgent: Issuer lateinit var subjectCredentialStore: SubjectCredentialStore lateinit var holderAgent: Holder beforeEach { - holderKeyPair = RandomKeyPairAdapter() + holderKeyMaterial = EphemeralKeyWithoutCert() subjectCredentialStore = InMemorySubjectCredentialStore() - holderAgent = HolderAgent(holderKeyPair, subjectCredentialStore) - issuerAgent = IssuerAgent(RandomKeyPairAdapter(), DummyCredentialDataProvider()) + holderAgent = HolderAgent(holderKeyMaterial, subjectCredentialStore) + issuerAgent = IssuerAgent(EphemeralKeyWithSelfSignedCert(), DummyCredentialDataProvider()) } "Plain jwt credential path resolving" { holderAgent.storeCredential( issuerAgent.issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT ).getOrThrow().toStoreCredentialInput() @@ -56,7 +46,7 @@ class CredentialJsonInteropTest : FreeSpec({ "SD jwt credential path resolving" { holderAgent.storeCredential( issuerAgent.issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.SD_JWT, ConstantIndex.AtomicAttribute2023.claimNames @@ -73,7 +63,7 @@ class CredentialJsonInteropTest : FreeSpec({ "ISO credential path resolving" { holderAgent.storeCredential( issuerAgent.issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.ISO_MDOC, ConstantIndex.AtomicAttribute2023.claimNames diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/DummyCredentialDataProvider.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/DummyCredentialDataProvider.kt index bdfde5df7..4234443f3 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/DummyCredentialDataProvider.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/DummyCredentialDataProvider.kt @@ -10,6 +10,10 @@ import at.asitplus.wallet.lib.agent.CredentialToBeIssued import at.asitplus.wallet.lib.agent.IssuerCredentialDataProvider import at.asitplus.wallet.lib.data.AtomicAttribute2023 import at.asitplus.wallet.lib.data.ConstantIndex +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_DATE_OF_BIRTH +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_FAMILY_NAME +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_GIVEN_NAME +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_PORTRAIT import at.asitplus.wallet.lib.iso.IssuerSignedItem import at.asitplus.wallet.mdl.DrivingPrivilege import at.asitplus.wallet.mdl.DrivingPrivilegeCode @@ -48,9 +52,10 @@ class DummyCredentialDataProvider( if (credentialScheme == ConstantIndex.AtomicAttribute2023) { val subjectId = subjectPublicKey.didEncoded val claims = listOfNotNull( - optionalClaim(claimNames, "given_name", "Susanne"), - optionalClaim(claimNames, "family_name", "Meier"), - optionalClaim(claimNames, "date_of_birth", "1990-01-01"), + optionalClaim(claimNames, CLAIM_GIVEN_NAME, "Susanne"), + optionalClaim(claimNames, CLAIM_FAMILY_NAME, "Meier"), + optionalClaim(claimNames, CLAIM_DATE_OF_BIRTH, LocalDate.parse("1990-01-01")), + optionalClaim(claimNames, CLAIM_PORTRAIT, Random.nextBytes(32)), ) when (representation) { ConstantIndex.CredentialRepresentation.SD_JWT -> CredentialToBeIssued.VcSd( @@ -59,7 +64,7 @@ class DummyCredentialDataProvider( ) ConstantIndex.CredentialRepresentation.PLAIN_JWT -> CredentialToBeIssued.VcJwt( - subject = AtomicAttribute2023(subjectId, "given_name", "Susanne"), + subject = AtomicAttribute2023(subjectId, CLAIM_GIVEN_NAME, "Susanne"), expiration = expiration, ) diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/DummyOAuth2IssuerCredentialDataProvider.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/DummyOAuth2IssuerCredentialDataProvider.kt index 5308cfa71..d4bd0ec68 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/DummyOAuth2IssuerCredentialDataProvider.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/DummyOAuth2IssuerCredentialDataProvider.kt @@ -2,6 +2,9 @@ package at.asitplus.wallet.lib.oidc import at.asitplus.KmmResult import at.asitplus.catching +import at.asitplus.openid.AuthenticationRequestParameters +import at.asitplus.openid.OidcUserInfo +import at.asitplus.openid.OidcUserInfoExtended import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.wallet.eupid.EuPidCredential import at.asitplus.wallet.eupid.EuPidScheme @@ -10,21 +13,27 @@ import at.asitplus.wallet.lib.agent.CredentialToBeIssued import at.asitplus.wallet.lib.agent.IssuerCredentialDataProvider import at.asitplus.wallet.lib.data.AtomicAttribute2023 import at.asitplus.wallet.lib.data.ConstantIndex +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_DATE_OF_BIRTH +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_FAMILY_NAME +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_GIVEN_NAME +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_PORTRAIT import at.asitplus.wallet.lib.iso.IssuerSignedItem import at.asitplus.wallet.lib.oidvci.OAuth2DataProvider -import at.asitplus.wallet.lib.oidvci.OidcUserInfo -import at.asitplus.wallet.lib.oidvci.OidcUserInfoExtended import at.asitplus.wallet.mdl.MobileDrivingLicenceDataElements.DOCUMENT_NUMBER import at.asitplus.wallet.mdl.MobileDrivingLicenceDataElements.EXPIRY_DATE import at.asitplus.wallet.mdl.MobileDrivingLicenceDataElements.FAMILY_NAME import at.asitplus.wallet.mdl.MobileDrivingLicenceDataElements.GIVEN_NAME import at.asitplus.wallet.mdl.MobileDrivingLicenceDataElements.ISSUE_DATE import at.asitplus.wallet.mdl.MobileDrivingLicenceScheme +import io.matthewnelson.encoding.base64.Base64 +import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray +import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString import kotlinx.datetime.Clock import kotlinx.datetime.LocalDate import kotlin.random.Random import kotlin.time.Duration.Companion.minutes + class DummyOAuth2IssuerCredentialDataProvider( private val userInfo: OidcUserInfoExtended, private val clock: Clock = Clock.System, @@ -57,16 +66,25 @@ class DummyOAuth2IssuerCredentialDataProvider( val givenName = userInfo.userInfo.givenName val subjectId = subjectPublicKey.didEncoded val claims = listOfNotNull( - givenName?.let { optionalClaim(claimNames, "given_name", it) }, - familyName?.let { optionalClaim(claimNames, "family_name", it) }, - userInfo.userInfo.birthDate?.let { optionalClaim(claimNames, "date_of_birth", it) }, + givenName?.let { + optionalClaim(claimNames, CLAIM_GIVEN_NAME, it) + }, + familyName?.let { + optionalClaim(claimNames, CLAIM_FAMILY_NAME, it) + }, + userInfo.userInfo.birthDate?.let { + optionalClaim(claimNames, CLAIM_DATE_OF_BIRTH, LocalDate.parse(it)) + }, + userInfo.userInfo.picture?.let { + optionalClaim(claimNames, CLAIM_PORTRAIT, it.decodeToByteArray(Base64())) + }, ) return when (representation) { ConstantIndex.CredentialRepresentation.SD_JWT -> CredentialToBeIssued.VcSd(claims, expiration) ConstantIndex.CredentialRepresentation.PLAIN_JWT -> CredentialToBeIssued.VcJwt( - AtomicAttribute2023(subjectId, "given_name", givenName ?: "no value"), expiration, + AtomicAttribute2023(subjectId, GIVEN_NAME, givenName ?: "no value"), expiration, ) ConstantIndex.CredentialRepresentation.ISO_MDOC -> CredentialToBeIssued.Iso( @@ -154,7 +172,6 @@ class DummyOAuth2IssuerCredentialDataProvider( private fun optionalClaim(claimNames: Collection?, name: String, value: Any) = if (claimNames.isNullOrContains(name)) ClaimToBeIssued(name, value) else null - private fun issuerSignedItem(name: String, value: Any, digestId: UInt) = IssuerSignedItem( digestId = digestId, random = Random.nextBytes(16), @@ -164,12 +181,15 @@ class DummyOAuth2IssuerCredentialDataProvider( } object DummyOAuth2DataProvider : OAuth2DataProvider { - override suspend fun loadUserInfo(request: AuthenticationRequestParameters?) = - OidcUserInfoExtended.fromOidcUserInfo( - OidcUserInfo( - subject = "subject", - givenName = "Erika", - familyName = "Musterfrau" - ) - ).getOrThrow() -} \ No newline at end of file + val user = OidcUserInfoExtended.fromOidcUserInfo( + OidcUserInfo( + subject = "subject", + givenName = "Susanne", + familyName = "Meier", + picture = Random.nextBytes(64).encodeToString(Base64()), + birthDate = "1990-01-01" + ) + ).getOrThrow() + + override suspend fun loadUserInfo(request: AuthenticationRequestParameters, code: String) = user +} diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopCombinedProtocolTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopCombinedProtocolTest.kt index d7e170b47..9daf29595 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopCombinedProtocolTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopCombinedProtocolTest.kt @@ -1,132 +1,95 @@ package at.asitplus.wallet.lib.oidc -import at.asitplus.wallet.lib.agent.Holder -import at.asitplus.wallet.lib.agent.HolderAgent -import at.asitplus.wallet.lib.agent.IssuerAgent -import at.asitplus.wallet.lib.agent.KeyPairAdapter -import at.asitplus.wallet.lib.agent.RandomKeyPairAdapter -import at.asitplus.wallet.lib.agent.Verifier -import at.asitplus.wallet.lib.agent.VerifierAgent -import at.asitplus.wallet.lib.agent.toStoreCredentialInput +import at.asitplus.wallet.eupid.EuPidScheme +import at.asitplus.wallet.lib.agent.* import at.asitplus.wallet.lib.data.AtomicAttribute2023 import at.asitplus.wallet.lib.data.ConstantIndex -import at.asitplus.wallet.lib.data.dif.FormatHolder +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_DATE_OF_BIRTH +import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation import at.asitplus.wallet.lib.oidvci.OAuth2Exception import at.asitplus.wallet.mdl.MobileDrivingLicenceScheme import com.benasher44.uuid.uuid4 -import io.github.aakira.napier.Napier +import io.kotest.assertions.fail import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FreeSpec import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.collections.shouldNotBeEmpty +import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf -import kotlinx.coroutines.runBlocking -@Suppress("unused") class OidcSiopCombinedProtocolTest : FreeSpec({ lateinit var relyingPartyUrl: String - lateinit var holderKeyPair: KeyPairAdapter - lateinit var verifierKeyPair: KeyPairAdapter + lateinit var holderKeyMaterial: KeyMaterial + lateinit var verifierKeyMaterial: KeyMaterial lateinit var holderAgent: Holder - lateinit var verifierAgent: Verifier lateinit var holderSiop: OidcSiopWallet lateinit var verifierSiop: OidcSiopVerifier beforeEach { - holderKeyPair = RandomKeyPairAdapter() - verifierKeyPair = RandomKeyPairAdapter() + holderKeyMaterial = EphemeralKeyWithoutCert() + verifierKeyMaterial = EphemeralKeyWithoutCert() relyingPartyUrl = "https://example.com/rp/${uuid4()}" - holderAgent = HolderAgent(holderKeyPair) - verifierAgent = VerifierAgent(verifierKeyPair) + holderAgent = HolderAgent(holderKeyMaterial) - holderSiop = OidcSiopWallet.newDefaultInstance( - keyPairAdapter = holderKeyPair, + holderSiop = OidcSiopWallet( + keyMaterial = holderKeyMaterial, holder = holderAgent, ) - verifierSiop = OidcSiopVerifier.newInstance( - verifier = verifierAgent, + verifierSiop = OidcSiopVerifier( + keyMaterial = verifierKeyMaterial, relyingPartyUrl = relyingPartyUrl, ) } - "test support for format holder specification" - { + "support for format holder specification" - { - "test support for plain jwt credential request" - { + "support for plain jwt credential request" - { "if not available despite others with correct format or correct attribute, but not both" { - runBlocking { - holderAgent.storeJwtCredential(holderKeyPair, MobileDrivingLicenceScheme) - holderAgent.storeSdJwtCredential(holderKeyPair, ConstantIndex.AtomicAttribute2023) - holderAgent.storeIsoCredential(holderKeyPair, ConstantIndex.AtomicAttribute2023) - } - - verifierSiop = OidcSiopVerifier.newInstance( - verifier = verifierAgent, - relyingPartyUrl = relyingPartyUrl, - ) + holderAgent.storeJwtCredential(holderKeyMaterial, MobileDrivingLicenceScheme) + holderAgent.storeSdJwtCredential(holderKeyMaterial, ConstantIndex.AtomicAttribute2023) + holderAgent.storeIsoCredential(holderKeyMaterial, ConstantIndex.AtomicAttribute2023) val authnRequest = verifierSiop.createAuthnRequest( requestOptions = OidcSiopVerifier.RequestOptions( - credentialScheme = ConstantIndex.AtomicAttribute2023, - representation = ConstantIndex.CredentialRepresentation.PLAIN_JWT, - ) - ).let { request -> - request.copy( - clientMetadata = request.clientMetadata?.let { clientMetadata -> - clientMetadata.copy( - vpFormats = FormatHolder( - // only allow plain jwt - jwtVp = clientMetadata.vpFormats?.jwtVp - ) + credentials = setOf( + OidcSiopVerifier.RequestOptionsCredential( + ConstantIndex.AtomicAttribute2023, CredentialRepresentation.PLAIN_JWT ) - } + ) ) - } - + ) shouldThrow { holderSiop.createAuthnResponse(authnRequest.serialize()).getOrThrow() } } "if available despite others" { - runBlocking { - holderAgent.storeJwtCredential(holderKeyPair, ConstantIndex.AtomicAttribute2023) - holderAgent.storeJwtCredential(holderKeyPair, MobileDrivingLicenceScheme) - holderAgent.storeSdJwtCredential(holderKeyPair, ConstantIndex.AtomicAttribute2023) - holderAgent.storeIsoCredential(holderKeyPair, ConstantIndex.AtomicAttribute2023) - } - verifierSiop = OidcSiopVerifier.newInstance( - verifier = verifierAgent, - relyingPartyUrl = relyingPartyUrl, - ) + holderAgent.storeJwtCredential(holderKeyMaterial, ConstantIndex.AtomicAttribute2023) + holderAgent.storeJwtCredential(holderKeyMaterial, MobileDrivingLicenceScheme) + holderAgent.storeSdJwtCredential(holderKeyMaterial, ConstantIndex.AtomicAttribute2023) + holderAgent.storeIsoCredential(holderKeyMaterial, ConstantIndex.AtomicAttribute2023) val authnRequest = verifierSiop.createAuthnRequest( requestOptions = OidcSiopVerifier.RequestOptions( - credentialScheme = ConstantIndex.AtomicAttribute2023, - ) - ).let { request -> - request.copy( - clientMetadata = request.clientMetadata?.let { clientMetadata -> - clientMetadata.copy( - vpFormats = FormatHolder( - // only allow plain jwt - jwtVp = clientMetadata.vpFormats?.jwtVp - ) + credentials = setOf( + OidcSiopVerifier.RequestOptionsCredential( + ConstantIndex.AtomicAttribute2023, + CredentialRepresentation.PLAIN_JWT ) - } + ) ) - } + ) val authnResponse = holderSiop.createAuthnResponse(authnRequest.serialize()).getOrThrow() - authnResponse.shouldBeInstanceOf() - .also { println(it) } + .shouldBeInstanceOf() val result = verifierSiop.validateAuthnResponse(authnResponse.url) - result.shouldBeInstanceOf() + .shouldBeInstanceOf() result.vp.verifiableCredentials.shouldNotBeEmpty() result.vp.verifiableCredentials.forEach { it.vc.credentialSubject.shouldBeInstanceOf() @@ -134,275 +97,210 @@ class OidcSiopCombinedProtocolTest : FreeSpec({ } } - "test support for sd jwt credential request" - { + "support for sd jwt credential request" - { "if not available despite others with correct format or correct attribute, but not both" { - runBlocking { - holderAgent.storeJwtCredential(holderKeyPair, ConstantIndex.AtomicAttribute2023) - holderAgent.storeSdJwtCredential(holderKeyPair, MobileDrivingLicenceScheme) - holderAgent.storeIsoCredential(holderKeyPair, ConstantIndex.AtomicAttribute2023) - } - - verifierSiop = OidcSiopVerifier.newInstance( - verifier = verifierAgent, - relyingPartyUrl = relyingPartyUrl, - ) + holderAgent.storeJwtCredential(holderKeyMaterial, ConstantIndex.AtomicAttribute2023) + holderAgent.storeSdJwtCredential(holderKeyMaterial, MobileDrivingLicenceScheme) + holderAgent.storeIsoCredential(holderKeyMaterial, ConstantIndex.AtomicAttribute2023) val authnRequest = verifierSiop.createAuthnRequest( requestOptions = OidcSiopVerifier.RequestOptions( - credentialScheme = ConstantIndex.AtomicAttribute2023, - representation = ConstantIndex.CredentialRepresentation.SD_JWT, - ) - ).let { request -> - request.copy( - presentationDefinition = request.presentationDefinition?.let { presentationDefinition -> - presentationDefinition.copy( - formats = FormatHolder( - // only support SD_JWT here - jwtSd = presentationDefinition.formats?.jwtSd, - ), - inputDescriptors = presentationDefinition.inputDescriptors.map { inputDescriptor -> - inputDescriptor.copy( - format = null - ) - } + credentials = setOf( + OidcSiopVerifier.RequestOptionsCredential( + ConstantIndex.AtomicAttribute2023, CredentialRepresentation.SD_JWT ) - }, + ) ) - } - + ) shouldThrow { holderSiop.createAuthnResponse(authnRequest.serialize()).getOrThrow() } } "if available despite others with correct format or correct attribute, but not both" { - runBlocking { - holderAgent.storeJwtCredential(holderKeyPair, ConstantIndex.AtomicAttribute2023) - holderAgent.storeSdJwtCredential(holderKeyPair, ConstantIndex.AtomicAttribute2023) - holderAgent.storeSdJwtCredential(holderKeyPair, MobileDrivingLicenceScheme) - holderAgent.storeIsoCredential(holderKeyPair, ConstantIndex.AtomicAttribute2023) - } - - verifierSiop = OidcSiopVerifier.newInstance( - verifier = verifierAgent, - relyingPartyUrl = relyingPartyUrl, - ) + holderAgent.storeJwtCredential(holderKeyMaterial, ConstantIndex.AtomicAttribute2023) + holderAgent.storeSdJwtCredential(holderKeyMaterial, ConstantIndex.AtomicAttribute2023) + holderAgent.storeSdJwtCredential(holderKeyMaterial, MobileDrivingLicenceScheme) + holderAgent.storeIsoCredential(holderKeyMaterial, ConstantIndex.AtomicAttribute2023) val authnRequest = verifierSiop.createAuthnRequest( requestOptions = OidcSiopVerifier.RequestOptions( - credentialScheme = ConstantIndex.AtomicAttribute2023, - representation = ConstantIndex.CredentialRepresentation.SD_JWT, - ) - ).let { request -> - request.copy( - presentationDefinition = request.presentationDefinition?.let { presentationDefinition -> - presentationDefinition.copy( - formats = FormatHolder( - // only support SD_JWT here - jwtSd = presentationDefinition.formats?.jwtSd, - ), - inputDescriptors = presentationDefinition.inputDescriptors.map { inputDescriptor -> - inputDescriptor.copy( - format = null - ) - } + credentials = setOf( + OidcSiopVerifier.RequestOptionsCredential( + ConstantIndex.AtomicAttribute2023, CredentialRepresentation.SD_JWT ) - }, + ) ) - } - + ) val authnResponse = holderSiop.createAuthnResponse(authnRequest.serialize()).getOrThrow() - authnResponse.shouldBeInstanceOf() - .also { println(it) } + .shouldBeInstanceOf() val result = verifierSiop.validateAuthnResponse(authnResponse.url) - result.shouldBeInstanceOf() + .shouldBeInstanceOf() result.sdJwt.type?.shouldContain(ConstantIndex.AtomicAttribute2023.vcType) } } - "test support for mso credential request" - { + "support for mso credential request" - { "if not available despite others with correct format or correct attribute, but not both" { - runBlocking { - holderAgent.storeJwtCredential(holderKeyPair, ConstantIndex.AtomicAttribute2023) - holderAgent.storeSdJwtCredential(holderKeyPair, ConstantIndex.AtomicAttribute2023) - holderAgent.storeIsoCredential(holderKeyPair, MobileDrivingLicenceScheme) - } - - verifierSiop = OidcSiopVerifier.newInstance( - verifier = verifierAgent, - relyingPartyUrl = relyingPartyUrl, - ) + holderAgent.storeJwtCredential(holderKeyMaterial, ConstantIndex.AtomicAttribute2023) + holderAgent.storeSdJwtCredential(holderKeyMaterial, ConstantIndex.AtomicAttribute2023) + holderAgent.storeIsoCredential(holderKeyMaterial, MobileDrivingLicenceScheme) val authnRequest = verifierSiop.createAuthnRequest( requestOptions = OidcSiopVerifier.RequestOptions( - credentialScheme = ConstantIndex.AtomicAttribute2023, - representation = ConstantIndex.CredentialRepresentation.ISO_MDOC, - ), - ).let { request -> - request.copy( - presentationDefinition = request.presentationDefinition?.let { presentationDefinition -> - presentationDefinition.copy( - // only support msoMdoc here - formats = FormatHolder( - msoMdoc = presentationDefinition.formats?.msoMdoc - ), - inputDescriptors = presentationDefinition.inputDescriptors.map { inputDescriptor -> - inputDescriptor.copy( - format = null - ) - } + credentials = setOf( + OidcSiopVerifier.RequestOptionsCredential( + ConstantIndex.AtomicAttribute2023, CredentialRepresentation.ISO_MDOC ) - }, - ) - } - Napier.d("Create response") - + ) + ), + ) shouldThrow { - holderSiop.createAuthnResponse(authnRequest.serialize()).getOrThrow().also { - Napier.d("response: $it") - } + holderSiop.createAuthnResponse(authnRequest.serialize()).getOrThrow() } } "if available despite others with correct format or correct attribute, but not both" { - runBlocking { - holderAgent.storeJwtCredential(holderKeyPair, ConstantIndex.AtomicAttribute2023) - holderAgent.storeSdJwtCredential(holderKeyPair, ConstantIndex.AtomicAttribute2023) - holderAgent.storeIsoCredential(holderKeyPair, ConstantIndex.AtomicAttribute2023) - holderAgent.storeIsoCredential(holderKeyPair, MobileDrivingLicenceScheme) - } - - verifierSiop = OidcSiopVerifier.newInstance( - verifier = verifierAgent, - relyingPartyUrl = relyingPartyUrl, - ) + holderAgent.storeJwtCredential(holderKeyMaterial, ConstantIndex.AtomicAttribute2023) + holderAgent.storeSdJwtCredential(holderKeyMaterial, ConstantIndex.AtomicAttribute2023) + holderAgent.storeIsoCredential(holderKeyMaterial, ConstantIndex.AtomicAttribute2023) + holderAgent.storeIsoCredential(holderKeyMaterial, MobileDrivingLicenceScheme) val authnRequest = verifierSiop.createAuthnRequest( requestOptions = OidcSiopVerifier.RequestOptions( - credentialScheme = ConstantIndex.AtomicAttribute2023, - representation = ConstantIndex.CredentialRepresentation.ISO_MDOC, - ), - ).let { request -> - request.copy( - presentationDefinition = request.presentationDefinition?.let { presentationDefinition -> - presentationDefinition.copy( - formats = FormatHolder( - // only support msoMdoc here - msoMdoc = presentationDefinition.formats?.msoMdoc - ), - inputDescriptors = presentationDefinition.inputDescriptors.map { inputDescriptor -> - inputDescriptor.copy( - format = null - ) - } + credentials = setOf( + OidcSiopVerifier.RequestOptionsCredential( + ConstantIndex.AtomicAttribute2023, + CredentialRepresentation.ISO_MDOC ) - }, - ) - } - - Napier.d("request: $authnRequest") + ) + ), + ) val authnResponse = holderSiop.createAuthnResponse(authnRequest.serialize()).getOrThrow() - authnResponse.shouldBeInstanceOf() - .also { println(it) } + .shouldBeInstanceOf() - val result = verifierSiop.validateAuthnResponse(authnResponse.url) - result.shouldBeInstanceOf() + verifierSiop.validateAuthnResponse(authnResponse.url) + .shouldBeInstanceOf() } } } - "test presentation of multiple credentials with different formats" { - runBlocking { - holderAgent.storeJwtCredential(holderKeyPair, ConstantIndex.AtomicAttribute2023) - holderAgent.storeIsoCredential(holderKeyPair, MobileDrivingLicenceScheme) - } - - verifierSiop = OidcSiopVerifier.newInstance( - verifier = verifierAgent, - relyingPartyUrl = relyingPartyUrl, - ) + "presentation of multiple credentials with different formats in one request/response" { + holderAgent.storeJwtCredential(holderKeyMaterial, ConstantIndex.AtomicAttribute2023) + holderAgent.storeIsoCredential(holderKeyMaterial, MobileDrivingLicenceScheme) - val authnRequest1 = verifierSiop.createAuthnRequest( + val authnRequest = verifierSiop.createAuthnRequest( requestOptions = OidcSiopVerifier.RequestOptions( - credentialScheme = ConstantIndex.AtomicAttribute2023, - representation = ConstantIndex.CredentialRepresentation.PLAIN_JWT, - ), - ) - val authnRequest2 = verifierSiop.createAuthnRequest( - requestOptions = OidcSiopVerifier.RequestOptions( - credentialScheme = MobileDrivingLicenceScheme, - representation = ConstantIndex.CredentialRepresentation.ISO_MDOC, - ), - ) - val inputDescriptors2 = authnRequest2.presentationDefinition?.inputDescriptors ?: listOf() - - val authnRequest = authnRequest1.copy( - presentationDefinition = authnRequest1.presentationDefinition?.let { presentationDefinition -> - presentationDefinition.copy( - inputDescriptors = presentationDefinition.inputDescriptors.map { - it.copy(format = presentationDefinition.formats) - } + inputDescriptors2.map { - it.copy(format = authnRequest2.presentationDefinition?.formats) - }, - formats = null, + credentials = setOf( + OidcSiopVerifier.RequestOptionsCredential( + ConstantIndex.AtomicAttribute2023, CredentialRepresentation.PLAIN_JWT + ), + OidcSiopVerifier.RequestOptionsCredential( + MobileDrivingLicenceScheme, CredentialRepresentation.ISO_MDOC + ) ) - } + ), ) - val authnResponse = holderSiop.createAuthnResponse(authnRequest.serialize()).getOrThrow() - authnResponse.shouldBeInstanceOf() - .also { println(it) } + .shouldBeInstanceOf() val validationResults = verifierSiop.validateAuthnResponse(authnResponse.url) - validationResults.shouldBeInstanceOf() + .shouldBeInstanceOf() validationResults.validationResults.size shouldBe 2 } + + "presentation of multiple SD-JWT credentials in one request/response" { + holderAgent.storeSdJwtCredential(holderKeyMaterial, EuPidScheme) + holderAgent.storeSdJwtCredential(holderKeyMaterial, ConstantIndex.AtomicAttribute2023) + + val requestOptions = OidcSiopVerifier.RequestOptions( + credentials = setOf( + OidcSiopVerifier.RequestOptionsCredential( + credentialScheme = ConstantIndex.AtomicAttribute2023, + representation = CredentialRepresentation.SD_JWT, + requestedAttributes = listOf(CLAIM_DATE_OF_BIRTH), + ), + OidcSiopVerifier.RequestOptionsCredential( + credentialScheme = EuPidScheme, + representation = CredentialRepresentation.SD_JWT, + requestedAttributes = listOf( + EuPidScheme.Attributes.FAMILY_NAME, + EuPidScheme.Attributes.GIVEN_NAME + ), + ) + ) + ) + val authnRequest = verifierSiop.createAuthnRequest(requestOptions) + + val authnResponse = holderSiop.createAuthnResponse(authnRequest.serialize()).getOrThrow() + .shouldBeInstanceOf() + + val groupedResult = verifierSiop.validateAuthnResponse(authnResponse.url) + .shouldBeInstanceOf() + groupedResult.validationResults.size shouldBe 2 + groupedResult.validationResults.forEach { result -> + result.shouldBeInstanceOf() + result.disclosures.shouldNotBeEmpty() + when (result.sdJwt.verifiableCredentialType) { + EuPidScheme.sdJwtType -> { + result.disclosures.firstOrNull { it.claimName == EuPidScheme.Attributes.FAMILY_NAME }.shouldNotBeNull() + result.disclosures.firstOrNull { it.claimName == EuPidScheme.Attributes.GIVEN_NAME }.shouldNotBeNull() + } + ConstantIndex.AtomicAttribute2023.sdJwtType -> { + result.disclosures.firstOrNull() { it.claimName == CLAIM_DATE_OF_BIRTH }.shouldNotBeNull() + } + else -> { + fail("Unexpected SD-JWT type: ${result.sdJwt.verifiableCredentialType}") + } + } + } + } }) private suspend fun Holder.storeJwtCredential( - holderKeyPair: KeyPairAdapter, + holderKeyMaterial: KeyMaterial, credentialScheme: ConstantIndex.CredentialScheme, ) { storeCredential( IssuerAgent( - RandomKeyPairAdapter(), + EphemeralKeyWithoutCert(), DummyCredentialDataProvider(), ).issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, credentialScheme, - ConstantIndex.CredentialRepresentation.PLAIN_JWT, + CredentialRepresentation.PLAIN_JWT, ).getOrThrow().toStoreCredentialInput() ) } private suspend fun Holder.storeSdJwtCredential( - holderKeyPair: KeyPairAdapter, + holderKeyMaterial: KeyMaterial, credentialScheme: ConstantIndex.CredentialScheme, ) { storeCredential( IssuerAgent( - RandomKeyPairAdapter(), + EphemeralKeyWithoutCert(), DummyCredentialDataProvider(), ).issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, credentialScheme, - ConstantIndex.CredentialRepresentation.SD_JWT, + CredentialRepresentation.SD_JWT, ).getOrThrow().toStoreCredentialInput() ) } private suspend fun Holder.storeIsoCredential( - holderKeyPair: KeyPairAdapter, + holderKeyMaterial: KeyMaterial, credentialScheme: ConstantIndex.CredentialScheme, ) = storeCredential( IssuerAgent( - RandomKeyPairAdapter(), + EphemeralKeyWithSelfSignedCert(), DummyCredentialDataProvider(), ).issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, credentialScheme, - ConstantIndex.CredentialRepresentation.ISO_MDOC, + CredentialRepresentation.ISO_MDOC, ).getOrThrow().toStoreCredentialInput() ) diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopCombinedProtocolTwoStepTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopCombinedProtocolTwoStepTest.kt index 17ce6c92d..9bf6f58eb 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopCombinedProtocolTwoStepTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopCombinedProtocolTwoStepTest.kt @@ -1,17 +1,7 @@ package at.asitplus.wallet.lib.oidc -import at.asitplus.wallet.lib.agent.CredentialSubmission -import at.asitplus.wallet.lib.agent.Holder -import at.asitplus.wallet.lib.agent.HolderAgent -import at.asitplus.wallet.lib.agent.IssuerAgent -import at.asitplus.wallet.lib.agent.KeyPairAdapter -import at.asitplus.wallet.lib.agent.RandomKeyPairAdapter -import at.asitplus.wallet.lib.agent.SubjectCredentialStore -import at.asitplus.wallet.lib.agent.Verifier -import at.asitplus.wallet.lib.agent.VerifierAgent -import at.asitplus.wallet.lib.agent.toStoreCredentialInput +import at.asitplus.wallet.lib.agent.* import at.asitplus.wallet.lib.data.ConstantIndex -import at.asitplus.wallet.lib.data.dif.FormatHolder import com.benasher44.uuid.uuid4 import io.kotest.assertions.throwables.shouldNotThrowAny import io.kotest.assertions.throwables.shouldThrowAny @@ -19,79 +9,58 @@ import io.kotest.core.spec.style.FreeSpec import io.kotest.matchers.maps.shouldHaveSize import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.types.shouldBeInstanceOf -import kotlinx.coroutines.runBlocking class OidcSiopCombinedProtocolTwoStepTest : FreeSpec({ lateinit var relyingPartyUrl: String - lateinit var holderKeyPair: KeyPairAdapter - lateinit var verifierKeyPair: KeyPairAdapter + lateinit var holderKeyMaterial: KeyMaterial + lateinit var verifierKeyMaterial: KeyMaterial lateinit var holderAgent: Holder - lateinit var verifierAgent: Verifier lateinit var holderSiop: OidcSiopWallet lateinit var verifierSiop: OidcSiopVerifier beforeEach { - holderKeyPair = RandomKeyPairAdapter() - verifierKeyPair = RandomKeyPairAdapter() + holderKeyMaterial = EphemeralKeyWithoutCert() + verifierKeyMaterial = EphemeralKeyWithoutCert() relyingPartyUrl = "https://example.com/rp/${uuid4()}" - holderAgent = HolderAgent(holderKeyPair) - verifierAgent = VerifierAgent(verifierKeyPair) + holderAgent = HolderAgent(holderKeyMaterial) - holderSiop = OidcSiopWallet.newDefaultInstance( - keyPairAdapter = holderKeyPair, + holderSiop = OidcSiopWallet( + keyMaterial = holderKeyMaterial, holder = holderAgent, ) - verifierSiop = OidcSiopVerifier.newInstance( - verifier = verifierAgent, + verifierSiop = OidcSiopVerifier( + keyMaterial = verifierKeyMaterial, relyingPartyUrl = relyingPartyUrl, ) } "test credential matching" - { "only credentials of the correct format are matched" { - runBlocking { - holderAgent.storeIsoCredential(holderKeyPair, ConstantIndex.AtomicAttribute2023) - holderAgent.storeIsoCredential(holderKeyPair, ConstantIndex.AtomicAttribute2023) - holderAgent.storeSdJwtCredential(holderKeyPair, ConstantIndex.AtomicAttribute2023) - } - - verifierSiop = OidcSiopVerifier.newInstance( - verifier = verifierAgent, - relyingPartyUrl = relyingPartyUrl, - ) + holderAgent.storeIsoCredential(holderKeyMaterial, ConstantIndex.AtomicAttribute2023) + holderAgent.storeIsoCredential(holderKeyMaterial, ConstantIndex.AtomicAttribute2023) + holderAgent.storeSdJwtCredential(holderKeyMaterial, ConstantIndex.AtomicAttribute2023) val authnRequest = verifierSiop.createAuthnRequest( requestOptions = OidcSiopVerifier.RequestOptions( - credentialScheme = ConstantIndex.AtomicAttribute2023, - representation = ConstantIndex.CredentialRepresentation.ISO_MDOC, - ) - ).let { request -> - request.copy( - presentationDefinition = request.presentationDefinition?.let { presentationDefinition -> - presentationDefinition.copy( - // only support msoMdoc here - formats = FormatHolder( - msoMdoc = presentationDefinition.formats?.msoMdoc - ), - inputDescriptors = presentationDefinition.inputDescriptors.map { inputDescriptor -> - inputDescriptor.copy(format = null) - } + credentials = setOf( + OidcSiopVerifier.RequestOptionsCredential( + ConstantIndex.AtomicAttribute2023, + ConstantIndex.CredentialRepresentation.ISO_MDOC ) - }, + ) ) - } + ) val preparationState = holderSiop.startAuthorizationResponsePreparation(authnRequest.serialize()) .getOrThrow() val presentationDefinition = preparationState.presentationDefinition.shouldNotBeNull() val inputDescriptorId = presentationDefinition.inputDescriptors.first().id val matches = holderAgent.matchInputDescriptorsAgainstCredentialStore( - presentationDefinition.inputDescriptors, - presentationDefinition.formats, + presentationDefinition.inputDescriptors ).getOrThrow() val inputDescriptorMatches = matches[inputDescriptorId].shouldNotBeNull() inputDescriptorMatches shouldHaveSize 2 @@ -102,49 +71,29 @@ class OidcSiopCombinedProtocolTwoStepTest : FreeSpec({ } "test credential submission" - { - "submission requirements need to macth" - { + "submission requirements need to match" - { "all credentials matching an input descriptor should be presentable" { - runBlocking { - holderAgent.storeIsoCredential(holderKeyPair, ConstantIndex.AtomicAttribute2023) - holderAgent.storeIsoCredential(holderKeyPair, ConstantIndex.AtomicAttribute2023) - holderAgent.storeSdJwtCredential(holderKeyPair, ConstantIndex.AtomicAttribute2023) - } - - verifierSiop = OidcSiopVerifier.newInstance( - verifier = verifierAgent, - relyingPartyUrl = relyingPartyUrl, - ) + holderAgent.storeIsoCredential(holderKeyMaterial, ConstantIndex.AtomicAttribute2023) + holderAgent.storeIsoCredential(holderKeyMaterial, ConstantIndex.AtomicAttribute2023) + holderAgent.storeSdJwtCredential(holderKeyMaterial, ConstantIndex.AtomicAttribute2023) val authnRequest = verifierSiop.createAuthnRequest( requestOptions = OidcSiopVerifier.RequestOptions( - credentialScheme = ConstantIndex.AtomicAttribute2023, - representation = ConstantIndex.CredentialRepresentation.ISO_MDOC, - ) - ).let { request -> - request.copy( - presentationDefinition = request.presentationDefinition?.let { presentationDefinition -> - presentationDefinition.copy( - // only support msoMdoc here - formats = FormatHolder( - msoMdoc = presentationDefinition.formats?.msoMdoc - ), - inputDescriptors = presentationDefinition.inputDescriptors.map { inputDescriptor -> - inputDescriptor.copy( - format = null - ) - } + credentials = setOf( + OidcSiopVerifier.RequestOptionsCredential( + ConstantIndex.AtomicAttribute2023, + ConstantIndex.CredentialRepresentation.ISO_MDOC ) - }, + ) ) - } + ) val params = holderSiop.parseAuthenticationRequestParameters(authnRequest.serialize()).getOrThrow() val preparationState = holderSiop.startAuthorizationResponsePreparation(params).getOrThrow() val presentationDefinition = preparationState.presentationDefinition.shouldNotBeNull() val inputDescriptorId = presentationDefinition.inputDescriptors.first().id val matches = holderAgent.matchInputDescriptorsAgainstCredentialStore( - presentationDefinition.inputDescriptors, - presentationDefinition.formats, + presentationDefinition.inputDescriptors ).getOrThrow().also { it shouldHaveSize 1 } @@ -172,39 +121,20 @@ class OidcSiopCombinedProtocolTwoStepTest : FreeSpec({ } } "credentials not matching an input descriptor should not yield a valid submission" { - runBlocking { - holderAgent.storeIsoCredential(holderKeyPair, ConstantIndex.AtomicAttribute2023) - holderAgent.storeIsoCredential(holderKeyPair, ConstantIndex.AtomicAttribute2023) - holderAgent.storeSdJwtCredential(holderKeyPair, ConstantIndex.AtomicAttribute2023) - } - - verifierSiop = OidcSiopVerifier.newInstance( - verifier = verifierAgent, - relyingPartyUrl = relyingPartyUrl, - ) + holderAgent.storeIsoCredential(holderKeyMaterial, ConstantIndex.AtomicAttribute2023) + holderAgent.storeIsoCredential(holderKeyMaterial, ConstantIndex.AtomicAttribute2023) + holderAgent.storeSdJwtCredential(holderKeyMaterial, ConstantIndex.AtomicAttribute2023) val sdJwtMatches = run { val authnRequestSdJwt = verifierSiop.createAuthnRequest( requestOptions = OidcSiopVerifier.RequestOptions( - credentialScheme = ConstantIndex.AtomicAttribute2023, - representation = ConstantIndex.CredentialRepresentation.SD_JWT, - ) - ).let { request -> - request.copy( - presentationDefinition = request.presentationDefinition?.let { presentationDefinition -> - presentationDefinition.copy( - // only support msoMdoc here - inputDescriptors = presentationDefinition.inputDescriptors.map { inputDescriptor -> - inputDescriptor.copy( - format = FormatHolder( - jwtSd = presentationDefinition.formats?.jwtSd - ), - ) - } + credentials = setOf( + OidcSiopVerifier.RequestOptionsCredential( + ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.SD_JWT ) - }, + ) ) - } + ) val preparationStateSdJwt = holderSiop.startAuthorizationResponsePreparation( holderSiop.parseAuthenticationRequestParameters(authnRequestSdJwt.serialize()).getOrThrow() @@ -213,7 +143,6 @@ class OidcSiopCombinedProtocolTwoStepTest : FreeSpec({ holderAgent.matchInputDescriptorsAgainstCredentialStore( presentationDefinitionSdJwt.inputDescriptors, - presentationDefinitionSdJwt.formats, ).getOrThrow().also { it.shouldHaveSize(1) it.entries.first().value.let { @@ -228,25 +157,13 @@ class OidcSiopCombinedProtocolTwoStepTest : FreeSpec({ val authnRequest = verifierSiop.createAuthnRequest( requestOptions = OidcSiopVerifier.RequestOptions( - credentialScheme = ConstantIndex.AtomicAttribute2023, - representation = ConstantIndex.CredentialRepresentation.ISO_MDOC, - ) - ).let { request -> - request.copy( - presentationDefinition = request.presentationDefinition?.let { presentationDefinition -> - presentationDefinition.copy( - // only support msoMdoc here - inputDescriptors = presentationDefinition.inputDescriptors.map { inputDescriptor -> - inputDescriptor.copy( - format = FormatHolder( - msoMdoc = presentationDefinition.formats?.msoMdoc - ), - ) - } + credentials = setOf( + OidcSiopVerifier.RequestOptionsCredential( + ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.ISO_MDOC ) - }, + ) ) - } + ) val params = holderSiop.parseAuthenticationRequestParameters(authnRequest.serialize()).getOrThrow() val preparationState = holderSiop.startAuthorizationResponsePreparation(params).getOrThrow() @@ -255,7 +172,6 @@ class OidcSiopCombinedProtocolTwoStepTest : FreeSpec({ val matches = holderAgent.matchInputDescriptorsAgainstCredentialStore( presentationDefinition.inputDescriptors, - presentationDefinition.formats, ).getOrThrow().also { it shouldHaveSize 1 } @@ -286,15 +202,15 @@ class OidcSiopCombinedProtocolTwoStepTest : FreeSpec({ }) private suspend fun Holder.storeSdJwtCredential( - holderKeyPair: KeyPairAdapter, + holderKeyMaterial: KeyMaterial, credentialScheme: ConstantIndex.CredentialScheme, ) { storeCredential( IssuerAgent( - RandomKeyPairAdapter(), + EphemeralKeyWithoutCert(), DummyCredentialDataProvider(), ).issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, credentialScheme, ConstantIndex.CredentialRepresentation.SD_JWT, ).getOrThrow().toStoreCredentialInput() @@ -302,14 +218,14 @@ private suspend fun Holder.storeSdJwtCredential( } private suspend fun Holder.storeIsoCredential( - holderKeyPair: KeyPairAdapter, + holderKeyMaterial: KeyMaterial, credentialScheme: ConstantIndex.CredentialScheme, ) = storeCredential( IssuerAgent( - RandomKeyPairAdapter(), + EphemeralKeyWithSelfSignedCert(), DummyCredentialDataProvider(), ).issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, credentialScheme, ConstantIndex.CredentialRepresentation.ISO_MDOC, ).getOrThrow().toStoreCredentialInput() diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopInteropTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopInteropTest.kt index dddf02d14..4a96c6e74 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopInteropTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopInteropTest.kt @@ -1,6 +1,12 @@ package at.asitplus.wallet.lib.oidc -import at.asitplus.signum.indispensable.asn1.* +import at.asitplus.openid.AuthenticationRequestParameters +import at.asitplus.openid.OpenIdConstants +import at.asitplus.signum.indispensable.asn1.Asn1EncapsulatingOctetString +import at.asitplus.signum.indispensable.asn1.Asn1Primitive +import at.asitplus.signum.indispensable.asn1.Asn1String +import at.asitplus.signum.indispensable.asn1.KnownOIDs +import at.asitplus.signum.indispensable.asn1.encoding.Asn1 import at.asitplus.signum.indispensable.josef.JweAlgorithm import at.asitplus.signum.indispensable.josef.JweEncryption import at.asitplus.signum.indispensable.josef.JwsAlgorithm @@ -9,11 +15,12 @@ import at.asitplus.signum.indispensable.pki.X509CertificateExtension import at.asitplus.wallet.eupid.EuPidScheme import at.asitplus.wallet.lib.agent.* import at.asitplus.wallet.lib.data.ConstantIndex +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_FAMILY_NAME +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_GIVEN_NAME import at.asitplus.wallet.lib.jws.DefaultJwsService import at.asitplus.wallet.lib.oidvci.decode import at.asitplus.wallet.lib.oidvci.decodeFromUrlQuery import com.benasher44.uuid.uuid4 -import io.github.aakira.napier.Napier import io.kotest.core.spec.style.FreeSpec import io.kotest.matchers.collections.shouldBeSingleton import io.kotest.matchers.collections.shouldHaveSingleElement @@ -30,23 +37,22 @@ import kotlinx.datetime.Instant */ class OidcSiopInteropTest : FreeSpec({ - lateinit var holderKeyPair: KeyPairAdapter + lateinit var holderKeyMaterial: KeyMaterial lateinit var holderAgent: Holder lateinit var holderSiop: OidcSiopWallet - lateinit var verifierKeyPair: KeyPairAdapter - lateinit var verifierAgent: Verifier + lateinit var verifierKeyMaterial: KeyMaterial lateinit var verifierSiop: OidcSiopVerifier beforeEach { - holderKeyPair = RandomKeyPairAdapter() - holderAgent = HolderAgent(holderKeyPair) + holderKeyMaterial = EphemeralKeyWithoutCert() + holderAgent = HolderAgent(holderKeyMaterial) val issuerAgent = IssuerAgent( - RandomKeyPairAdapter(), + EphemeralKeyWithoutCert(), DummyCredentialDataProvider(), ) holderAgent.storeCredential( issuerAgent.issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, EuPidScheme, ConstantIndex.CredentialRepresentation.SD_JWT, EuPidScheme.requiredClaimNames @@ -54,13 +60,13 @@ class OidcSiopInteropTest : FreeSpec({ ) holderAgent.storeCredential( issuerAgent.issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.SD_JWT, - listOf("family_name", "given_name") + listOf(CLAIM_FAMILY_NAME, CLAIM_GIVEN_NAME) ).getOrThrow().toStoreCredentialInput() ) - holderSiop = OidcSiopWallet.newDefaultInstance(holderKeyPair, holderAgent) + holderSiop = OidcSiopWallet(holderKeyMaterial, holderAgent) } "EUDI from URL 2024-05-17" { @@ -162,8 +168,7 @@ class OidcSiopInteropTest : FreeSpec({ "https://verifier-backend.eudiw.dev/wallet/jarm/Vu3g2FXDeqday-wS0Xmty0bYzzq3MeVGrPSGTdk3Y60tWNLHkr_bg9WJMK3xktNsqWpEXPsDgBw5g3r80MQyTw/jwks.json" val requestUrl = "https://verifier-backend.eudiw.dev/wallet/request.jwt/Vu3g2FXDeqday-wS0Xmty0bYzzq3MeVGrPSGTdk3Y60tWNLHkr_bg9WJMK3xktNsqWpEXPsDgBw5g3r80MQyTw" - holderSiop = OidcSiopWallet.newDefaultInstance( - keyPairAdapter = holderKeyPair, + holderSiop = OidcSiopWallet( holder = holderAgent, remoteResourceRetriever = { if (it == jwksUrl) jwkset else if (it == requestUrl) requestObject else null @@ -171,7 +176,6 @@ class OidcSiopInteropTest : FreeSpec({ ) holderSiop.parseAuthenticationRequestParameters(url).getOrThrow() - .also { println(it) } } "EUDI AuthnRequest can be parsed" { @@ -279,7 +283,7 @@ class OidcSiopInteropTest : FreeSpec({ "Request in request URI" { val input = "mdoc-openid4vp://?request_uri=https%3A%2F%2Fexample.com%2Fd15b5b6f-7821-4031-9a18-ebe491b720a6" - val jws = DefaultJwsService(DefaultCryptoService(RandomKeyPairAdapter())).createSignedJwsAddingParams( + val jws = DefaultJwsService(DefaultCryptoService(EphemeralKeyWithoutCert())).createSignedJwsAddingParams( payload = AuthenticationRequestParameters( nonce = "RjEQKQeG8OUaKT4ij84E8mCvry6pVSgDyqRBMW5eBTPItP4DIfbKaT6M6v6q2Dvv8fN7Im7Ifa6GI2j6dHsJaQ==", state = "ef391e30-bacc-4441-af5d-7f42fb682e02", @@ -289,7 +293,7 @@ class OidcSiopInteropTest : FreeSpec({ addX5c = false ).getOrThrow().serialize() - val wallet = OidcSiopWallet.newDefaultInstance( + val wallet = OidcSiopWallet( remoteResourceRetriever = { url -> if (url == "https://example.com/d15b5b6f-7821-4031-9a18-ebe491b720a6") jws else null } @@ -321,13 +325,11 @@ class OidcSiopInteropTest : FreeSpec({ ) } )))) - verifierKeyPair = RandomKeyPairAdapter(extensions) - verifierAgent = VerifierAgent(verifierKeyPair) - verifierSiop = OidcSiopVerifier.newInstance( - verifier = verifierAgent, + verifierKeyMaterial = EphemeralKeyWithSelfSignedCert(extensions = extensions) + verifierSiop = OidcSiopVerifier( + keyMaterial = verifierKeyMaterial, relyingPartyUrl = "https://example.com/rp", - responseUrl = "https://example.com/response", - x5c = listOf(verifierKeyPair.certificate!!) + clientIdScheme = OidcSiopVerifier.ClientIdScheme.CertificateSanDns(listOf(verifierKeyMaterial.getCertificate()!!)), ) val nonce = uuid4().toString() val requestUrl = "https://example.com/request/$nonce" @@ -336,26 +338,29 @@ class OidcSiopInteropTest : FreeSpec({ requestUrl = requestUrl, requestOptions = OidcSiopVerifier.RequestOptions( responseMode = OpenIdConstants.ResponseMode.DIRECT_POST, - representation = ConstantIndex.CredentialRepresentation.SD_JWT, - credentialScheme = ConstantIndex.AtomicAttribute2023, - requestedAttributes = listOf("family_name", "given_name") + responseUrl = "https://example.com/response", + credentials = setOf( + OidcSiopVerifier.RequestOptionsCredential( + ConstantIndex.AtomicAttribute2023, + ConstantIndex.CredentialRepresentation.SD_JWT, + listOf(CLAIM_FAMILY_NAME, CLAIM_GIVEN_NAME) + ) + ) ) - ).getOrThrow().also { println(it) } + ).getOrThrow() - holderSiop = OidcSiopWallet.newDefaultInstance( - holderKeyPair, + holderSiop = OidcSiopWallet( + holderKeyMaterial, holderAgent, remoteResourceRetriever = { if (it == requestUrl) requestUrlForWallet.second else null }) val parameters = holderSiop.parseAuthenticationRequestParameters(requestUrlForWallet.first).getOrThrow() - val stae = holderSiop.startAuthorizationResponsePreparation(parameters).getOrThrow() - val response = holderSiop.finalizeAuthorizationResponse(parameters, stae).getOrThrow() + val preparation = holderSiop.startAuthorizationResponsePreparation(parameters).getOrThrow() + val response = holderSiop.finalizeAuthorizationResponse(parameters, preparation).getOrThrow() .shouldBeInstanceOf() - .also { println(it) } verifierSiop.validateAuthnResponse(params = response.params.decode()) .shouldBeInstanceOf() - .also { println(it) } } }) diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopIsoProtocolTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopIsoProtocolTest.kt index 0e6031b13..70c2f286f 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopIsoProtocolTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopIsoProtocolTest.kt @@ -1,14 +1,9 @@ package at.asitplus.wallet.lib.oidc -import at.asitplus.wallet.lib.agent.Holder -import at.asitplus.wallet.lib.agent.HolderAgent -import at.asitplus.wallet.lib.agent.IssuerAgent -import at.asitplus.wallet.lib.agent.KeyPairAdapter -import at.asitplus.wallet.lib.agent.RandomKeyPairAdapter -import at.asitplus.wallet.lib.agent.Verifier -import at.asitplus.wallet.lib.agent.VerifierAgent -import at.asitplus.wallet.lib.agent.toStoreCredentialInput +import at.asitplus.openid.OpenIdConstants +import at.asitplus.wallet.lib.agent.* import at.asitplus.wallet.lib.data.ConstantIndex +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_GIVEN_NAME import at.asitplus.wallet.lib.data.IsoDocumentParsed import at.asitplus.wallet.lib.oidvci.formUrlEncode import at.asitplus.wallet.mdl.MobileDrivingLicenceDataElements @@ -27,63 +22,63 @@ class OidcSiopIsoProtocolTest : FreeSpec({ lateinit var relyingPartyUrl: String lateinit var walletUrl: String - lateinit var holderKeyPair: KeyPairAdapter - lateinit var verifierKeyPair: KeyPairAdapter + lateinit var holderKeyMaterial: KeyMaterial + lateinit var verifierKeyMaterial: KeyMaterial lateinit var holderAgent: Holder - lateinit var verifierAgent: Verifier lateinit var holderSiop: OidcSiopWallet lateinit var verifierSiop: OidcSiopVerifier beforeEach { - holderKeyPair = RandomKeyPairAdapter() - verifierKeyPair = RandomKeyPairAdapter() + holderKeyMaterial = EphemeralKeyWithoutCert() + verifierKeyMaterial = EphemeralKeyWithoutCert() relyingPartyUrl = "https://example.com/rp/${uuid4()}" walletUrl = "https://example.com/wallet/${uuid4()}" - holderAgent = HolderAgent(holderKeyPair) - verifierAgent = VerifierAgent(verifierKeyPair) + holderAgent = HolderAgent(holderKeyMaterial) val issuerAgent = IssuerAgent( - RandomKeyPairAdapter(), + EphemeralKeyWithSelfSignedCert(), DummyCredentialDataProvider(), ) holderAgent.storeCredential( issuerAgent.issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, MobileDrivingLicenceScheme, ConstantIndex.CredentialRepresentation.ISO_MDOC, ).getOrThrow().toStoreCredentialInput() ) holderAgent.storeCredential( issuerAgent.issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.ISO_MDOC, ).getOrThrow().toStoreCredentialInput() ) - holderSiop = OidcSiopWallet.newDefaultInstance( - keyPairAdapter = holderKeyPair, + holderSiop = OidcSiopWallet( holder = holderAgent, + keyMaterial = holderKeyMaterial ) } "test with Fragment for mDL" { - verifierSiop = OidcSiopVerifier.newInstance( - verifier = verifierAgent, + verifierSiop = OidcSiopVerifier( + keyMaterial = verifierKeyMaterial, relyingPartyUrl = relyingPartyUrl, ) val document = runProcess( verifierSiop, walletUrl, OidcSiopVerifier.RequestOptions( - representation = ConstantIndex.CredentialRepresentation.ISO_MDOC, - credentialScheme = MobileDrivingLicenceScheme, - requestedAttributes = listOf( - MobileDrivingLicenceDataElements.GIVEN_NAME - ), + credentials = setOf( + OidcSiopVerifier.RequestOptionsCredential( + MobileDrivingLicenceScheme, ConstantIndex.CredentialRepresentation.ISO_MDOC, listOf( + MobileDrivingLicenceDataElements.GIVEN_NAME + ) + ) + ) ), holderSiop ) @@ -93,17 +88,21 @@ class OidcSiopIsoProtocolTest : FreeSpec({ } "test with Fragment for custom attributes" { - verifierSiop = OidcSiopVerifier.newInstance( - verifier = verifierAgent, + verifierSiop = OidcSiopVerifier( + keyMaterial = verifierKeyMaterial, relyingPartyUrl = relyingPartyUrl, ) val document = runProcess( verifierSiop, walletUrl, OidcSiopVerifier.RequestOptions( - representation = ConstantIndex.CredentialRepresentation.ISO_MDOC, - credentialScheme = ConstantIndex.AtomicAttribute2023, - requestedAttributes = listOf("given_name"), + credentials = setOf( + OidcSiopVerifier.RequestOptionsCredential( + ConstantIndex.AtomicAttribute2023, + ConstantIndex.CredentialRepresentation.ISO_MDOC, + listOf(CLAIM_GIVEN_NAME) + ) + ) ), holderSiop ) @@ -114,17 +113,21 @@ class OidcSiopIsoProtocolTest : FreeSpec({ "Selective Disclosure with mDL" { val requestedClaim = MobileDrivingLicenceDataElements.FAMILY_NAME - verifierSiop = OidcSiopVerifier.newInstance( - verifier = verifierAgent, + verifierSiop = OidcSiopVerifier( + keyMaterial = verifierKeyMaterial, relyingPartyUrl = relyingPartyUrl, ) val document = runProcess( verifierSiop, walletUrl, OidcSiopVerifier.RequestOptions( - representation = ConstantIndex.CredentialRepresentation.ISO_MDOC, - credentialScheme = MobileDrivingLicenceScheme, - requestedAttributes = listOf(requestedClaim), + credentials = setOf( + OidcSiopVerifier.RequestOptionsCredential( + MobileDrivingLicenceScheme, + ConstantIndex.CredentialRepresentation.ISO_MDOC, + listOf(requestedClaim) + ) + ) ), holderSiop, ) @@ -137,22 +140,24 @@ class OidcSiopIsoProtocolTest : FreeSpec({ "Selective Disclosure with mDL and encryption" { val requestedClaim = MobileDrivingLicenceDataElements.FAMILY_NAME - verifierSiop = OidcSiopVerifier.newInstance( - verifier = verifierAgent, + verifierSiop = OidcSiopVerifier( + keyMaterial = verifierKeyMaterial, relyingPartyUrl = relyingPartyUrl, - responseUrl = relyingPartyUrl + "/${uuid4()}" ) val requestOptions = OidcSiopVerifier.RequestOptions( - representation = ConstantIndex.CredentialRepresentation.ISO_MDOC, - credentialScheme = MobileDrivingLicenceScheme, - requestedAttributes = listOf(requestedClaim), + credentials = setOf( + OidcSiopVerifier.RequestOptionsCredential( + MobileDrivingLicenceScheme, ConstantIndex.CredentialRepresentation.ISO_MDOC, listOf(requestedClaim) + ) + ), responseMode = OpenIdConstants.ResponseMode.DIRECT_POST_JWT, + responseUrl = "https://example.com/response", encryption = true ) val authnRequest = verifierSiop.createAuthnRequestUrl( walletUrl = walletUrl, requestOptions = requestOptions - ).also { println(it) } + ) val authnResponse = holderSiop.createAuthnResponse(authnRequest).getOrThrow() authnResponse.shouldBeInstanceOf() @@ -169,17 +174,21 @@ class OidcSiopIsoProtocolTest : FreeSpec({ } "Selective Disclosure with mDL JSON Path syntax" { - verifierSiop = OidcSiopVerifier.newInstance( - verifier = verifierAgent, + verifierSiop = OidcSiopVerifier( + keyMaterial = verifierKeyMaterial, relyingPartyUrl = relyingPartyUrl, ) val document = runProcess( verifierSiop, walletUrl, OidcSiopVerifier.RequestOptions( - representation = ConstantIndex.CredentialRepresentation.ISO_MDOC, - credentialScheme = MobileDrivingLicenceScheme, - requestedAttributes = listOf(MobileDrivingLicenceDataElements.FAMILY_NAME) + credentials = setOf( + OidcSiopVerifier.RequestOptionsCredential( + MobileDrivingLicenceScheme, + ConstantIndex.CredentialRepresentation.ISO_MDOC, + listOf(MobileDrivingLicenceDataElements.FAMILY_NAME) + ) + ) ), holderSiop, ) @@ -201,12 +210,12 @@ private suspend fun runProcess( val authnRequest = verifierSiop.createAuthnRequestUrl( walletUrl = walletUrl, requestOptions = requestOptions - ).also { println(it) } + ) val authnResponse = holderSiop.createAuthnResponse(authnRequest).getOrThrow() - authnResponse.shouldBeInstanceOf().also { println(it) } + authnResponse.shouldBeInstanceOf() val result = verifierSiop.validateAuthnResponse(authnResponse.url) result.shouldBeInstanceOf() - return result.document.also { println(it) } + return result.document } diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopProtocolTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopProtocolTest.kt index 38ce349b7..ac6d45c5b 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopProtocolTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopProtocolTest.kt @@ -1,30 +1,18 @@ package at.asitplus.wallet.lib.oidc -import at.asitplus.signum.indispensable.io.Base64UrlStrict -import at.asitplus.signum.indispensable.josef.JsonWebKey -import at.asitplus.signum.indispensable.josef.JsonWebToken -import at.asitplus.signum.indispensable.josef.JwsHeader -import at.asitplus.signum.indispensable.josef.JwsSigned -import at.asitplus.signum.indispensable.josef.toJwsAlgorithm -import at.asitplus.wallet.lib.agent.DefaultCryptoService -import at.asitplus.wallet.lib.agent.Holder -import at.asitplus.wallet.lib.agent.HolderAgent -import at.asitplus.wallet.lib.agent.IssuerAgent -import at.asitplus.wallet.lib.agent.KeyPairAdapter -import at.asitplus.wallet.lib.agent.RandomKeyPairAdapter -import at.asitplus.wallet.lib.agent.Verifier -import at.asitplus.wallet.lib.agent.VerifierAgent -import at.asitplus.wallet.lib.agent.toStoreCredentialInput +import at.asitplus.openid.AuthenticationRequestParameters +import at.asitplus.openid.AuthenticationResponseParameters +import at.asitplus.openid.OpenIdConstants +import at.asitplus.openid.OpenIdConstants.ID_TOKEN +import at.asitplus.openid.OpenIdConstants.VP_TOKEN +import at.asitplus.signum.indispensable.josef.* +import at.asitplus.wallet.lib.agent.* import at.asitplus.wallet.lib.data.AtomicAttribute2023 import at.asitplus.wallet.lib.data.ConstantIndex import at.asitplus.wallet.lib.jws.DefaultJwsService import at.asitplus.wallet.lib.jws.DefaultVerifierJwsService import at.asitplus.wallet.lib.oidc.OidcSiopVerifier.RequestOptions -import at.asitplus.wallet.lib.oidvci.OAuth2Exception -import at.asitplus.wallet.lib.oidvci.decodeFromPostBody -import at.asitplus.wallet.lib.oidvci.decodeFromUrlQuery -import at.asitplus.wallet.lib.oidvci.encodeToParameters -import at.asitplus.wallet.lib.oidvci.formUrlEncode +import at.asitplus.wallet.lib.oidvci.* import com.benasher44.uuid.uuid4 import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FreeSpec @@ -38,66 +26,56 @@ import io.kotest.matchers.string.shouldNotContain import io.kotest.matchers.string.shouldStartWith import io.kotest.matchers.types.shouldBeInstanceOf import io.ktor.http.* -import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString import kotlinx.datetime.Clock -import kotlin.random.Random import kotlin.time.Duration.Companion.seconds @Suppress("unused") class OidcSiopProtocolTest : FreeSpec({ lateinit var relyingPartyUrl: String - lateinit var responseUrl: String lateinit var walletUrl: String - lateinit var holderKeyPair: KeyPairAdapter - lateinit var verifierKeyPair: KeyPairAdapter + lateinit var holderKeyMaterial: KeyMaterial + lateinit var verifierKeyMaterial: KeyMaterial lateinit var holderAgent: Holder - lateinit var verifierAgent: Verifier lateinit var holderSiop: OidcSiopWallet lateinit var verifierSiop: OidcSiopVerifier beforeEach { - holderKeyPair = RandomKeyPairAdapter() - verifierKeyPair = RandomKeyPairAdapter() + holderKeyMaterial = EphemeralKeyWithoutCert() + verifierKeyMaterial = EphemeralKeyWithoutCert() val rpUUID = uuid4() relyingPartyUrl = "https://example.com/rp/$rpUUID" - responseUrl = "https://example.com/rp/$rpUUID" walletUrl = "https://example.com/wallet/${uuid4()}" - holderAgent = HolderAgent(holderKeyPair) - verifierAgent = VerifierAgent(verifierKeyPair) + holderAgent = HolderAgent(holderKeyMaterial) holderAgent.storeCredential( IssuerAgent( - RandomKeyPairAdapter(), + EphemeralKeyWithoutCert(), DummyCredentialDataProvider(), ).issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT, ).getOrThrow().toStoreCredentialInput() ) - holderSiop = OidcSiopWallet.newDefaultInstance( - keyPairAdapter = holderKeyPair, + holderSiop = OidcSiopWallet( holder = holderAgent, ) - verifierSiop = OidcSiopVerifier.newInstance( - verifier = verifierAgent, + verifierSiop = OidcSiopVerifier( + keyMaterial = verifierKeyMaterial, relyingPartyUrl = relyingPartyUrl, - responseUrl = responseUrl, ) } "test with Fragment" { - val authnRequest = verifierSiop.createAuthnRequestUrl(walletUrl = walletUrl) - .also { println(it) } + val authnRequest = verifierSiop.createAuthnRequestUrl(walletUrl, defaultRequestOptions) val authnResponse = holderSiop.createAuthnResponse(authnRequest).getOrThrow() authnResponse.shouldBeInstanceOf() - .also { println(it) } authnResponse.url.shouldNotContain("?") authnResponse.url.shouldContain("#") @@ -110,6 +88,50 @@ class OidcSiopProtocolTest : FreeSpec({ verifySecondProtocolRun(verifierSiop, walletUrl, holderSiop) } + "wrong client nonce in id_token should lead to error" { + verifierSiop = OidcSiopVerifier( + keyMaterial = verifierKeyMaterial, + relyingPartyUrl = relyingPartyUrl, + nonceService = object : NonceService { + override suspend fun provideNonce() = uuid4().toString() + override suspend fun verifyNonce(it: String) = false + override suspend fun verifyAndRemoveNonce(it: String) = false + } + ) + val requestOptions = RequestOptions( + credentials = setOf(OidcSiopVerifier.RequestOptionsCredential(ConstantIndex.AtomicAttribute2023)), + responseType = "$ID_TOKEN $VP_TOKEN" + ) + val authnRequest = verifierSiop.createAuthnRequestUrl(walletUrl, requestOptions) + + val authnResponse = holderSiop.createAuthnResponse(authnRequest).getOrThrow() + authnResponse.shouldBeInstanceOf() + + val result = verifierSiop.validateAuthnResponse(authnResponse.url) + result.shouldBeInstanceOf() + result.field shouldBe "idToken" + } + + "wrong client nonce in vp_token should lead to error" { + verifierSiop = OidcSiopVerifier( + keyMaterial = verifierKeyMaterial, + relyingPartyUrl = relyingPartyUrl, + stateToNonceStore = object : MapStore { + override suspend fun put(key: String, value: String) {} + override suspend fun get(key: String): String? = null + override suspend fun remove(key: String): String? = null + }, + ) + val authnRequest = verifierSiop.createAuthnRequestUrl(walletUrl, defaultRequestOptions) + + val authnResponse = holderSiop.createAuthnResponse(authnRequest).getOrThrow() + authnResponse.shouldBeInstanceOf() + + val result = verifierSiop.validateAuthnResponse(authnResponse.url) + result.shouldBeInstanceOf() + result.field shouldBe "state" + } + "test with QR Code" { val metadataUrlNonce = uuid4().toString() val metadataUrl = "https://example.com/$metadataUrlNonce" @@ -120,17 +142,16 @@ class OidcSiopProtocolTest : FreeSpec({ qrcode shouldContain requestUrlNonce val metadataObject = verifierSiop.createSignedMetadata().getOrThrow() - .also { println(it) } DefaultVerifierJwsService().verifyJwsObject(metadataObject).shouldBeTrue() - val authnRequestUrl = - verifierSiop.createAuthnRequestUrlWithRequestObject(walletUrl).getOrThrow() + val authnRequestUrl = verifierSiop.createAuthnRequestUrlWithRequestObject(walletUrl, defaultRequestOptions) + .getOrThrow() val authnRequest: AuthenticationRequestParameters = Url(authnRequestUrl).encodedQuery.decodeFromUrlQuery() authnRequest.clientId shouldBe relyingPartyUrl val jar = authnRequest.request jar.shouldNotBeNull() - DefaultVerifierJwsService().verifyJwsObject(JwsSigned.parse(jar).getOrThrow()).shouldBeTrue() + DefaultVerifierJwsService().verifyJwsObject(JwsSigned.deserialize(jar).getOrThrow()).shouldBeTrue() val authnResponse = holderSiop.createAuthnResponse(jar).getOrThrow() authnResponse.shouldBeInstanceOf() @@ -142,12 +163,15 @@ class OidcSiopProtocolTest : FreeSpec({ "test with direct_post" { val authnRequest = verifierSiop.createAuthnRequestUrl( walletUrl = walletUrl, - requestOptions = RequestOptions(responseMode = OpenIdConstants.ResponseMode.DIRECT_POST) - ).also { println(it) } + requestOptions = RequestOptions( + credentials = setOf(OidcSiopVerifier.RequestOptionsCredential(ConstantIndex.AtomicAttribute2023)), + responseMode = OpenIdConstants.ResponseMode.DIRECT_POST, + responseUrl = relyingPartyUrl, + ) + ) val authnResponse = holderSiop.createAuthnResponse(authnRequest).getOrThrow() authnResponse.shouldBeInstanceOf() - .also { println(it) } authnResponse.url.shouldBe(relyingPartyUrl) val result = @@ -159,16 +183,19 @@ class OidcSiopProtocolTest : FreeSpec({ "test with direct_post_jwt" { val authnRequest = verifierSiop.createAuthnRequestUrl( walletUrl = walletUrl, - requestOptions = RequestOptions(responseMode = OpenIdConstants.ResponseMode.DIRECT_POST_JWT) - ).also { println(it) } + requestOptions = RequestOptions( + credentials = setOf(OidcSiopVerifier.RequestOptionsCredential(ConstantIndex.AtomicAttribute2023)), + responseMode = OpenIdConstants.ResponseMode.DIRECT_POST_JWT, + responseUrl = relyingPartyUrl, + ) + ) val authnResponse = holderSiop.createAuthnResponse(authnRequest).getOrThrow() authnResponse.shouldBeInstanceOf() - .also { println(it) } authnResponse.url.shouldBe(relyingPartyUrl) - authnResponse.params.shouldHaveSize(1) - val jarmResponse = authnResponse.params.values.first() - DefaultVerifierJwsService().verifyJwsObject(JwsSigned.parse(jarmResponse).getOrThrow()).shouldBeTrue() + authnResponse.params.shouldHaveSize(2) + val jarmResponse = authnResponse.params.entries.first { it.key == "response" }.value + DefaultVerifierJwsService().verifyJwsObject(JwsSigned.deserialize(jarmResponse).getOrThrow()).shouldBeTrue() val result = verifierSiop.validateAuthnResponseFromPost(authnResponse.params.formUrlEncode()) @@ -181,14 +208,14 @@ class OidcSiopProtocolTest : FreeSpec({ val authnRequest = verifierSiop.createAuthnRequestUrl( walletUrl = walletUrl, requestOptions = RequestOptions( + credentials = setOf(OidcSiopVerifier.RequestOptionsCredential(ConstantIndex.AtomicAttribute2023)), responseMode = OpenIdConstants.ResponseMode.QUERY, state = expectedState ) - ).also { println(it) } + ) val authnResponse = holderSiop.createAuthnResponse(authnRequest).getOrThrow() authnResponse.shouldBeInstanceOf() - .also { println(it) } authnResponse.url.shouldContain("?") authnResponse.url.shouldNotContain("#") @@ -201,9 +228,8 @@ class OidcSiopProtocolTest : FreeSpec({ } "test with deserializing" { - val authnRequest = verifierSiop.createAuthnRequest() - val authnRequestUrlParams = - authnRequest.encodeToParameters().formUrlEncode().also { println(it) } + val authnRequest = verifierSiop.createAuthnRequest(defaultRequestOptions) + val authnRequestUrlParams = authnRequest.encodeToParameters().formUrlEncode() val parsedAuthnRequest: AuthenticationRequestParameters = authnRequestUrlParams.decodeFromUrlQuery() @@ -213,8 +239,7 @@ class OidcSiopProtocolTest : FreeSpec({ parsedAuthnRequest ) ).getOrThrow().params - val authnResponseParams = - authnResponse.encodeToParameters().formUrlEncode().also { println(it) } + val authnResponseParams = authnResponse.encodeToParameters().formUrlEncode() val parsedAuthnResponse: AuthenticationResponseParameters = authnResponseParams.decodeFromPostBody() @@ -226,12 +251,11 @@ class OidcSiopProtocolTest : FreeSpec({ "test specific credential" { val authnRequest = verifierSiop.createAuthnRequestUrl( walletUrl = walletUrl, - requestOptions = RequestOptions(credentialScheme = ConstantIndex.AtomicAttribute2023) - ).also { println(it) } + requestOptions = requestOptionsAtomicAttribute() + ) val authnResponse = holderSiop.createAuthnResponse(authnRequest).getOrThrow() authnResponse.shouldBeInstanceOf() - .also { println(it) } val result = verifierSiop.validateAuthnResponse(authnResponse.url) result.shouldBeInstanceOf() @@ -244,13 +268,11 @@ class OidcSiopProtocolTest : FreeSpec({ "test with request object" { val authnRequestWithRequestObject = verifierSiop.createAuthnRequestUrlWithRequestObject( walletUrl = walletUrl, - requestOptions = RequestOptions(credentialScheme = ConstantIndex.AtomicAttribute2023) - ).getOrThrow().also { println(it) } + requestOptions = requestOptionsAtomicAttribute() + ).getOrThrow() - val authnResponse = - holderSiop.createAuthnResponse(authnRequestWithRequestObject).getOrThrow() + val authnResponse = holderSiop.createAuthnResponse(authnRequestWithRequestObject).getOrThrow() authnResponse.shouldBeInstanceOf() - .also { println(it) } val result = verifierSiop.validateAuthnResponse(authnResponse.url) result.shouldBeInstanceOf() @@ -261,28 +283,24 @@ class OidcSiopProtocolTest : FreeSpec({ } "test with request object and Attestation JWT" { - val sprsCryptoService = DefaultCryptoService(RandomKeyPairAdapter()) - val attestationJwt = buildAttestationJwt(sprsCryptoService, relyingPartyUrl, verifierKeyPair) - verifierSiop = OidcSiopVerifier.newInstance( - verifier = verifierAgent, + val sprsCryptoService = DefaultCryptoService(EphemeralKeyWithoutCert()) + val attestationJwt = buildAttestationJwt(sprsCryptoService, relyingPartyUrl, verifierKeyMaterial) + verifierSiop = OidcSiopVerifier( + keyMaterial = verifierKeyMaterial, relyingPartyUrl = relyingPartyUrl, - attestationJwt = attestationJwt + clientIdScheme = OidcSiopVerifier.ClientIdScheme.VerifierAttestation(attestationJwt), ) val authnRequestWithRequestObject = verifierSiop.createAuthnRequestUrlWithRequestObject( walletUrl = walletUrl, - requestOptions = RequestOptions(credentialScheme = ConstantIndex.AtomicAttribute2023) - ).getOrThrow().also { println(it) } + requestOptions = requestOptionsAtomicAttribute() + ).getOrThrow() - - holderSiop = OidcSiopWallet.newDefaultInstance( - keyPairAdapter = holderKeyPair, + holderSiop = OidcSiopWallet( holder = holderAgent, - requestObjectJwsVerifier = verifierAttestationVerifier(sprsCryptoService.keyPairAdapter.jsonWebKey) + requestObjectJwsVerifier = verifierAttestationVerifier(sprsCryptoService.keyMaterial.jsonWebKey) ) - val authnResponse = - holderSiop.createAuthnResponse(authnRequestWithRequestObject).getOrThrow() + val authnResponse = holderSiop.createAuthnResponse(authnRequestWithRequestObject).getOrThrow() authnResponse.shouldBeInstanceOf() - .also { println(it) } val result = verifierSiop.validateAuthnResponse(authnResponse.url) result.shouldBeInstanceOf() @@ -292,23 +310,22 @@ class OidcSiopProtocolTest : FreeSpec({ } } "test with request object and invalid Attestation JWT" { - val sprsCryptoService = DefaultCryptoService(RandomKeyPairAdapter()) - val attestationJwt = buildAttestationJwt(sprsCryptoService, relyingPartyUrl, verifierKeyPair) + val sprsCryptoService = DefaultCryptoService(EphemeralKeyWithoutCert()) + val attestationJwt = buildAttestationJwt(sprsCryptoService, relyingPartyUrl, verifierKeyMaterial) - verifierSiop = OidcSiopVerifier.newInstance( - verifier = verifierAgent, + verifierSiop = OidcSiopVerifier( + keyMaterial = verifierKeyMaterial, relyingPartyUrl = relyingPartyUrl, - attestationJwt = attestationJwt + clientIdScheme = OidcSiopVerifier.ClientIdScheme.VerifierAttestation(attestationJwt) ) val authnRequestWithRequestObject = verifierSiop.createAuthnRequestUrlWithRequestObject( walletUrl = walletUrl, - requestOptions = RequestOptions(credentialScheme = ConstantIndex.AtomicAttribute2023) - ).getOrThrow().also { println(it) } + requestOptions = requestOptionsAtomicAttribute() + ).getOrThrow() - holderSiop = OidcSiopWallet.newDefaultInstance( - keyPairAdapter = holderKeyPair, + holderSiop = OidcSiopWallet( holder = holderAgent, - requestObjectJwsVerifier = verifierAttestationVerifier(RandomKeyPairAdapter().jsonWebKey) + requestObjectJwsVerifier = verifierAttestationVerifier(EphemeralKeyWithoutCert().jsonWebKey) ) shouldThrow { holderSiop.createAuthnResponse(authnRequestWithRequestObject).getOrThrow() @@ -318,19 +335,18 @@ class OidcSiopProtocolTest : FreeSpec({ "test with request object from request_uri as URL query parameters" { val authnRequest = verifierSiop.createAuthnRequestUrl( walletUrl = walletUrl, - requestOptions = RequestOptions(credentialScheme = ConstantIndex.AtomicAttribute2023) - ).also { println(it) } + requestOptions = requestOptionsAtomicAttribute() + ) val clientId = Url(authnRequest).parameters["client_id"]!! - val requestUrl = "https://www.example.com/request/${Random.nextBytes(32).encodeToString(Base64UrlStrict)}" + val requestUrl = "https://www.example.com/request/${uuid4()}" val authRequestUrlWithRequestUri = URLBuilder(walletUrl).apply { parameters.append("client_id", clientId) parameters.append("request_uri", requestUrl) }.buildString() - holderSiop = OidcSiopWallet.newDefaultInstance( - keyPairAdapter = holderKeyPair, + holderSiop = OidcSiopWallet( holder = holderAgent, remoteResourceRetriever = { if (it == requestUrl) authnRequest else null @@ -339,7 +355,6 @@ class OidcSiopProtocolTest : FreeSpec({ val authnResponse = holderSiop.createAuthnResponse(authRequestUrlWithRequestUri).getOrThrow() authnResponse.shouldBeInstanceOf() - .also { println(it) } val result = verifierSiop.validateAuthnResponse(authnResponse.url) result.shouldBeInstanceOf() @@ -351,17 +366,16 @@ class OidcSiopProtocolTest : FreeSpec({ "test with request object from request_uri as JWS" { val jar = verifierSiop.createAuthnRequestAsSignedRequestObject( - requestOptions = RequestOptions(credentialScheme = ConstantIndex.AtomicAttribute2023) - ).getOrThrow().also { println(it.serialize()) } + requestOptions = requestOptionsAtomicAttribute() + ).getOrThrow() - val requestUrl = "https://www.example.com/request/${Random.nextBytes(32).encodeToString(Base64UrlStrict)}" + val requestUrl = "https://www.example.com/request/${uuid4()}" val authRequestUrlWithRequestUri = URLBuilder(walletUrl).apply { parameters.append("client_id", relyingPartyUrl) parameters.append("request_uri", requestUrl) }.buildString() - holderSiop = OidcSiopWallet.newDefaultInstance( - keyPairAdapter = holderKeyPair, + holderSiop = OidcSiopWallet( holder = holderAgent, remoteResourceRetriever = { if (it == requestUrl) jar.serialize() else null @@ -370,7 +384,6 @@ class OidcSiopProtocolTest : FreeSpec({ val authnResponse = holderSiop.createAuthnResponse(authRequestUrlWithRequestUri).getOrThrow() authnResponse.shouldBeInstanceOf() - .also { println(it) } val result = verifierSiop.validateAuthnResponse(authnResponse.url) result.shouldBeInstanceOf() @@ -379,19 +392,19 @@ class OidcSiopProtocolTest : FreeSpec({ it.vc.credentialSubject.shouldBeInstanceOf() } } + "test with request object not verified" { val jar = verifierSiop.createAuthnRequestAsSignedRequestObject( - requestOptions = RequestOptions(credentialScheme = ConstantIndex.AtomicAttribute2023) - ).getOrThrow().also { println(it.serialize()) } + requestOptions = requestOptionsAtomicAttribute() + ).getOrThrow() - val requestUrl = "https://www.example.com/request/${Random.nextBytes(32).encodeToString(Base64UrlStrict)}" + val requestUrl = "https://www.example.com/request/${uuid4()}" val authRequestUrlWithRequestUri = URLBuilder(walletUrl).apply { parameters.append("client_id", relyingPartyUrl) parameters.append("request_uri", requestUrl) }.buildString() - holderSiop = OidcSiopWallet.newDefaultInstance( - keyPairAdapter = holderKeyPair, + holderSiop = OidcSiopWallet( holder = holderAgent, remoteResourceRetriever = { if (it == requestUrl) jar.serialize() else null @@ -405,13 +418,21 @@ class OidcSiopProtocolTest : FreeSpec({ } }) +private fun requestOptionsAtomicAttribute() = RequestOptions( + credentials = setOf( + OidcSiopVerifier.RequestOptionsCredential( + ConstantIndex.AtomicAttribute2023, + ) + ), +) + private suspend fun buildAttestationJwt( sprsCryptoService: DefaultCryptoService, relyingPartyUrl: String, - verifierKeyPair: KeyPairAdapter + verifierKeyMaterial: KeyMaterial ): JwsSigned = DefaultJwsService(sprsCryptoService).createSignedJws( header = JwsHeader( - algorithm = sprsCryptoService.keyPairAdapter.signingAlgorithm.toJwsAlgorithm().getOrThrow(), + algorithm = sprsCryptoService.keyMaterial.signatureAlgorithm.toJwsAlgorithm().getOrThrow(), ), payload = JsonWebToken( issuer = "sprs", // allows Wallet to determine the issuer's key @@ -419,20 +440,21 @@ private suspend fun buildAttestationJwt( issuedAt = Clock.System.now(), expiration = Clock.System.now().plus(10.seconds), notBefore = Clock.System.now(), - confirmationKey = verifierKeyPair.jsonWebKey, + confirmationClaim = ConfirmationClaim(jsonWebKey = verifierKeyMaterial.jsonWebKey), ).serialize().encodeToByteArray() ).getOrThrow() private fun verifierAttestationVerifier(trustedKey: JsonWebKey) = object : RequestObjectJwsVerifier { override fun invoke(jws: JwsSigned, authnRequest: AuthenticationRequestParameters): Boolean { - val attestationJwt = jws.header.attestationJwt?.let { JwsSigned.parse(it).getOrThrow() } + val attestationJwt = jws.header.attestationJwt?.let { JwsSigned.deserialize(it).getOrThrow() } ?: return false val verifierJwsService = DefaultVerifierJwsService() if (!verifierJwsService.verifyJws(attestationJwt, trustedKey)) return false val verifierPublicKey = JsonWebToken.deserialize(attestationJwt.payload.decodeToString()) - .getOrNull()?.confirmationKey ?: return false + .getOrNull()?.confirmationClaim?.jsonWebKey + ?: return false return verifierJwsService.verifyJws(jws, verifierPublicKey) } } @@ -442,10 +464,16 @@ private suspend fun verifySecondProtocolRun( walletUrl: String, holderSiop: OidcSiopWallet ) { - val authnRequestUrl = verifierSiop.createAuthnRequestUrl(walletUrl = walletUrl) + val authnRequestUrl = verifierSiop.createAuthnRequestUrl(walletUrl, defaultRequestOptions) val authnResponse = holderSiop.createAuthnResponse(authnRequestUrl) val validation = verifierSiop.validateAuthnResponse( (authnResponse.getOrThrow() as AuthenticationResponseResult.Redirect).url ) validation.shouldBeInstanceOf() -} \ No newline at end of file +} + +private val defaultRequestOptions = RequestOptions( + credentials = setOf( + OidcSiopVerifier.RequestOptionsCredential(ConstantIndex.AtomicAttribute2023) + ) +) \ No newline at end of file diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopSdJwtProtocolTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopSdJwtProtocolTest.kt index a692a1ee8..3eba3ae9d 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopSdJwtProtocolTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopSdJwtProtocolTest.kt @@ -1,14 +1,8 @@ package at.asitplus.wallet.lib.oidc -import at.asitplus.wallet.lib.agent.Holder -import at.asitplus.wallet.lib.agent.HolderAgent -import at.asitplus.wallet.lib.agent.IssuerAgent -import at.asitplus.wallet.lib.agent.KeyPairAdapter -import at.asitplus.wallet.lib.agent.RandomKeyPairAdapter -import at.asitplus.wallet.lib.agent.Verifier -import at.asitplus.wallet.lib.agent.VerifierAgent -import at.asitplus.wallet.lib.agent.toStoreCredentialInput +import at.asitplus.wallet.lib.agent.* import at.asitplus.wallet.lib.data.ConstantIndex +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_GIVEN_NAME import at.asitplus.wallet.lib.oidc.OidcSiopVerifier.RequestOptions import com.benasher44.uuid.uuid4 import io.kotest.core.spec.style.FreeSpec @@ -24,8 +18,8 @@ class OidcSiopSdJwtProtocolTest : FreeSpec({ lateinit var relyingPartyUrl: String lateinit var walletUrl: String - lateinit var holderKeyPair: KeyPairAdapter - lateinit var verifierKeyPair: KeyPairAdapter + lateinit var holderKeyMaterial: KeyMaterial + lateinit var verifierKeyMaterial: KeyMaterial lateinit var holderAgent: Holder lateinit var verifierAgent: Verifier @@ -34,96 +28,62 @@ class OidcSiopSdJwtProtocolTest : FreeSpec({ lateinit var verifierSiop: OidcSiopVerifier beforeEach { - holderKeyPair = RandomKeyPairAdapter() - verifierKeyPair = RandomKeyPairAdapter() + holderKeyMaterial = EphemeralKeyWithoutCert() + verifierKeyMaterial = EphemeralKeyWithoutCert() relyingPartyUrl = "https://example.com/rp/${uuid4()}" walletUrl = "https://example.com/wallet/${uuid4()}" - holderAgent = HolderAgent(holderKeyPair) - verifierAgent = VerifierAgent(verifierKeyPair) + holderAgent = HolderAgent(holderKeyMaterial) + verifierAgent = VerifierAgent(verifierKeyMaterial) holderAgent.storeCredential( IssuerAgent( - RandomKeyPairAdapter(), + EphemeralKeyWithoutCert(), DummyCredentialDataProvider(), ).issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.SD_JWT, ).getOrThrow().toStoreCredentialInput() ) - holderSiop = OidcSiopWallet.newDefaultInstance( - keyPairAdapter = holderKeyPair, + holderSiop = OidcSiopWallet( holder = holderAgent, ) - verifierSiop = OidcSiopVerifier.newInstance( - verifier = verifierAgent, + verifierSiop = OidcSiopVerifier( + keyMaterial = verifierKeyMaterial, relyingPartyUrl = relyingPartyUrl, ) } - "test with Fragment" { - val authnRequest = verifierSiop.createAuthnRequestUrl( - walletUrl = walletUrl, - requestOptions = RequestOptions( - representation = ConstantIndex.CredentialRepresentation.SD_JWT, - credentialScheme = ConstantIndex.AtomicAttribute2023, - requestedAttributes = listOf("given_name") - ), - ).also { println(it) } - - val authnResponse = holderSiop.createAuthnResponse(authnRequest).getOrThrow() - authnResponse.shouldBeInstanceOf().also { println(it) } - - val result = verifierSiop.validateAuthnResponse(authnResponse.url) - result.shouldBeInstanceOf() - result.disclosures.shouldNotBeEmpty() - - assertSecondRun(verifierSiop, holderSiop, walletUrl) - } - "Selective Disclosure with custom credential" { - val requestedClaim = "given_name" - verifierSiop = OidcSiopVerifier.newInstance( - verifier = verifierAgent, + val requestedClaim = CLAIM_GIVEN_NAME + verifierSiop = OidcSiopVerifier( + keyMaterial = verifierKeyMaterial, relyingPartyUrl = relyingPartyUrl, ) val authnRequest = verifierSiop.createAuthnRequestUrl( walletUrl = walletUrl, requestOptions = RequestOptions( - representation = ConstantIndex.CredentialRepresentation.SD_JWT, - credentialScheme = ConstantIndex.AtomicAttribute2023, - requestedAttributes = listOf(requestedClaim) + credentials = setOf( + OidcSiopVerifier.RequestOptionsCredential( + ConstantIndex.AtomicAttribute2023, + ConstantIndex.CredentialRepresentation.SD_JWT, + listOf(requestedClaim) + ) + ) ) - ).also { println(it) } + ) authnRequest shouldContain requestedClaim val authnResponse = holderSiop.createAuthnResponse(authnRequest).getOrThrow() - authnResponse.shouldBeInstanceOf().also { println(it) } + authnResponse.shouldBeInstanceOf() val result = verifierSiop.validateAuthnResponse(authnResponse.url) result.shouldBeInstanceOf() - val sdJwt = result.sdJwt.also { println(it) } - + result.sdJwt.shouldNotBeNull() result.disclosures.shouldNotBeEmpty() result.disclosures.shouldBeSingleton() result.disclosures.shouldHaveSingleElement { it.claimName == requestedClaim } - sdJwt.shouldNotBeNull() } }) - -private suspend fun assertSecondRun( - verifierSiop: OidcSiopVerifier, - holderSiop: OidcSiopWallet, - walletUrl: String -) { - val authnRequestUrl = verifierSiop.createAuthnRequestUrl( - walletUrl = walletUrl, - requestOptions = RequestOptions(representation = ConstantIndex.CredentialRepresentation.SD_JWT) - ) - val authnResponse = holderSiop.createAuthnResponse(authnRequestUrl) - val url = (authnResponse.getOrThrow() as AuthenticationResponseResult.Redirect).url - val validation = verifierSiop.validateAuthnResponse(url) - validation.shouldBeInstanceOf() -} \ No newline at end of file diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopWalletScopeSupportTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopWalletScopeSupportTest.kt index 314dd4035..67b876226 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopWalletScopeSupportTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopWalletScopeSupportTest.kt @@ -1,21 +1,13 @@ package at.asitplus.wallet.lib.oidc +import at.asitplus.dif.Constraint +import at.asitplus.dif.ConstraintField +import at.asitplus.dif.DifInputDescriptor +import at.asitplus.dif.PresentationDefinition import at.asitplus.jsonpath.core.NormalizedJsonPath import at.asitplus.jsonpath.core.NormalizedJsonPathSegment -import at.asitplus.wallet.lib.agent.Holder -import at.asitplus.wallet.lib.agent.HolderAgent -import at.asitplus.wallet.lib.agent.IssuerAgent -import at.asitplus.wallet.lib.agent.KeyPairAdapter -import at.asitplus.wallet.lib.agent.RandomKeyPairAdapter -import at.asitplus.wallet.lib.agent.Verifier -import at.asitplus.wallet.lib.agent.VerifierAgent -import at.asitplus.wallet.lib.agent.toStoreCredentialInput +import at.asitplus.wallet.lib.agent.* import at.asitplus.wallet.lib.data.ConstantIndex -import at.asitplus.wallet.lib.data.dif.Constraint -import at.asitplus.wallet.lib.data.dif.ConstraintField -import at.asitplus.wallet.lib.data.dif.InputDescriptor -import at.asitplus.wallet.lib.data.dif.PresentationDefinition -import at.asitplus.wallet.lib.data.dif.SchemaReference import at.asitplus.wallet.lib.oidvci.OAuth2Exception import at.asitplus.wallet.mdl.MobileDrivingLicenceDataElements import at.asitplus.wallet.mdl.MobileDrivingLicenceScheme @@ -37,18 +29,18 @@ class OidcSiopWalletScopeSupportTest : FreeSpec({ "test scopes" - { val testScopes = object { - val EmptyPresentationRequest: String = "emptyPresentationRequest" - val MdocMdlWithGivenName: String = "mdocMdlWithGivenName" + val emptyPresentationRequest: String = "emptyPresentationRequest" + val mdocMdlWithGivenName: String = "mdocMdlWithGivenName" } val testScopePresentationDefinitionRetriever = mapOf( - testScopes.EmptyPresentationRequest to PresentationDefinition( + testScopes.emptyPresentationRequest to PresentationDefinition( id = uuid4().toString(), inputDescriptors = listOf() ), - testScopes.MdocMdlWithGivenName to PresentationDefinition( + testScopes.mdocMdlWithGivenName to PresentationDefinition( id = uuid4().toString(), inputDescriptors = listOf( - InputDescriptor( + DifInputDescriptor( id = MobileDrivingLicenceScheme.isoDocType, constraints = Constraint( fields = listOf( @@ -62,9 +54,6 @@ class OidcSiopWalletScopeSupportTest : FreeSpec({ ) ) ), - schema = listOf( - SchemaReference(MobileDrivingLicenceScheme.schemaUri) - ) ) ) ), @@ -72,8 +61,8 @@ class OidcSiopWalletScopeSupportTest : FreeSpec({ lateinit var relyingPartyUrl: String - lateinit var holderKeyPair: KeyPairAdapter - lateinit var verifierKeyPair: KeyPairAdapter + lateinit var holderKeyMaterial: KeyMaterial + lateinit var verifierKeyMaterial: KeyMaterial lateinit var holderAgent: Holder lateinit var verifierAgent: Verifier @@ -82,45 +71,45 @@ class OidcSiopWalletScopeSupportTest : FreeSpec({ lateinit var verifierSiop: OidcSiopVerifier beforeEach { - holderKeyPair = RandomKeyPairAdapter() - verifierKeyPair = RandomKeyPairAdapter() + holderKeyMaterial = EphemeralKeyWithoutCert() + verifierKeyMaterial = EphemeralKeyWithoutCert() relyingPartyUrl = "https://example.com/rp/${uuid4()}" - holderAgent = HolderAgent(holderKeyPair) - verifierAgent = VerifierAgent(verifierKeyPair) + holderAgent = HolderAgent(holderKeyMaterial) + verifierAgent = VerifierAgent(verifierKeyMaterial) - holderSiop = OidcSiopWallet.newDefaultInstance( - keyPairAdapter = holderKeyPair, + holderSiop = OidcSiopWallet( + keyMaterial = holderKeyMaterial, holder = holderAgent, scopePresentationDefinitionRetriever = testScopePresentationDefinitionRetriever ) - verifierSiop = OidcSiopVerifier.newInstance( - verifier = verifierAgent, + verifierSiop = OidcSiopVerifier( + keyMaterial = verifierKeyMaterial, relyingPartyUrl = relyingPartyUrl, ) } "get empty scope works even without available credentials" { val issuerAgent = IssuerAgent( - RandomKeyPairAdapter(), + EphemeralKeyWithSelfSignedCert(), DummyCredentialDataProvider(), ) holderAgent.storeCredential( issuerAgent.issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.ISO_MDOC, ).getOrThrow().toStoreCredentialInput() ) - val authnRequest = verifierSiop.createAuthnRequest().let { request -> + val authnRequest = verifierSiop.createAuthnRequest(defaultRequestOptions).let { request -> request.copy( presentationDefinition = null, - scope = request.scope + " " + testScopes.EmptyPresentationRequest + scope = request.scope + " " + testScopes.emptyPresentationRequest ) } val authnResponse = holderSiop.createAuthnResponse(authnRequest.serialize()).getOrThrow() - authnResponse.shouldBeInstanceOf().also { println(it) } + authnResponse.shouldBeInstanceOf() val result = verifierSiop.validateAuthnResponse(authnResponse.url) result.shouldBeInstanceOf() @@ -128,10 +117,10 @@ class OidcSiopWalletScopeSupportTest : FreeSpec({ } "get MdocMdlWithGivenName scope without available credentials fails" { - val authnRequest = verifierSiop.createAuthnRequest().let { request -> + val authnRequest = verifierSiop.createAuthnRequest(defaultRequestOptions).let { request -> request.copy( presentationDefinition = null, - scope = request.scope + " " + testScopes.MdocMdlWithGivenName + scope = request.scope + " " + testScopes.mdocMdlWithGivenName ) } @@ -144,31 +133,37 @@ class OidcSiopWalletScopeSupportTest : FreeSpec({ "get MdocMdlWithGivenName scope with available credentials succeeds" { val issuerAgent = IssuerAgent( - RandomKeyPairAdapter(), + EphemeralKeyWithSelfSignedCert(), DummyCredentialDataProvider(), ) holderAgent.storeCredential( issuerAgent.issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, MobileDrivingLicenceScheme, ConstantIndex.CredentialRepresentation.ISO_MDOC, ).getOrThrow().toStoreCredentialInput() ) - val authnRequest = verifierSiop.createAuthnRequest().let { request -> + val authnRequest = verifierSiop.createAuthnRequest(defaultRequestOptions).let { request -> request.copy( presentationDefinition = null, - scope = request.scope + " " + testScopes.MdocMdlWithGivenName + scope = request.scope + " " + testScopes.mdocMdlWithGivenName ) } val authnResponse = holderSiop.createAuthnResponse(authnRequest.serialize()).getOrThrow() - authnResponse.shouldBeInstanceOf().also { println(it) } + authnResponse.shouldBeInstanceOf() val result = verifierSiop.validateAuthnResponse(authnResponse.url) result.shouldBeInstanceOf() result.document.validItems.shouldNotBeEmpty() } } -}) \ No newline at end of file +}) + +private val defaultRequestOptions = OidcSiopVerifier.RequestOptions( + credentials = setOf( + OidcSiopVerifier.RequestOptionsCredential(ConstantIndex.AtomicAttribute2023) + ) +) \ No newline at end of file diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopX509SanDnsTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopX509SanDnsTest.kt index 725992079..ecc892996 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopX509SanDnsTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopX509SanDnsTest.kt @@ -1,38 +1,28 @@ package at.asitplus.wallet.lib.oidc -import at.asitplus.signum.indispensable.asn1.Asn1 +import at.asitplus.openid.OpenIdConstants import at.asitplus.signum.indispensable.asn1.Asn1EncapsulatingOctetString import at.asitplus.signum.indispensable.asn1.Asn1Primitive import at.asitplus.signum.indispensable.asn1.Asn1String import at.asitplus.signum.indispensable.asn1.KnownOIDs +import at.asitplus.signum.indispensable.asn1.encoding.Asn1 import at.asitplus.signum.indispensable.pki.SubjectAltNameImplicitTags import at.asitplus.signum.indispensable.pki.X509CertificateExtension -import at.asitplus.wallet.lib.agent.Holder -import at.asitplus.wallet.lib.agent.HolderAgent -import at.asitplus.wallet.lib.agent.IssuerAgent -import at.asitplus.wallet.lib.agent.KeyPairAdapter -import at.asitplus.wallet.lib.agent.RandomKeyPairAdapter -import at.asitplus.wallet.lib.agent.Verifier -import at.asitplus.wallet.lib.agent.VerifierAgent -import at.asitplus.wallet.lib.agent.toStoreCredentialInput +import at.asitplus.wallet.lib.agent.* import at.asitplus.wallet.lib.data.ConstantIndex +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_GIVEN_NAME import at.asitplus.wallet.lib.oidc.OidcSiopVerifier.RequestOptions import at.asitplus.wallet.lib.oidvci.formUrlEncode -import com.benasher44.uuid.uuid4 import io.kotest.core.spec.style.FreeSpec import io.kotest.matchers.collections.shouldNotBeEmpty import io.kotest.matchers.types.shouldBeInstanceOf class OidcSiopX509SanDnsTest : FreeSpec({ - lateinit var responseUrl: String - lateinit var walletUrl: String - - lateinit var holderKeyPair: KeyPairAdapter - lateinit var verifierKeyPair: KeyPairAdapter + lateinit var holderKeyMaterial: KeyMaterial + lateinit var verifierKeyMaterial: KeyMaterial lateinit var holderAgent: Holder - lateinit var verifierAgent: Verifier lateinit var holderSiop: OidcSiopWallet lateinit var verifierSiop: OidcSiopVerifier @@ -49,46 +39,47 @@ class OidcSiopX509SanDnsTest : FreeSpec({ ) } )))) - holderKeyPair = RandomKeyPairAdapter() - verifierKeyPair = RandomKeyPairAdapter(extensions) - responseUrl = "https://example.com" - walletUrl = "https://example.com/wallet/${uuid4()}" - holderAgent = HolderAgent(holderKeyPair) - verifierAgent = VerifierAgent(verifierKeyPair) + holderKeyMaterial = EphemeralKeyWithoutCert() + verifierKeyMaterial = EphemeralKeyWithSelfSignedCert(extensions = extensions) + holderAgent = HolderAgent(holderKeyMaterial) holderAgent.storeCredential( IssuerAgent( - RandomKeyPairAdapter(), + EphemeralKeyWithoutCert(), DummyCredentialDataProvider(), ).issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.SD_JWT, ).getOrThrow().toStoreCredentialInput() ) - holderSiop = OidcSiopWallet.newDefaultInstance( - keyPairAdapter = holderKeyPair, + holderSiop = OidcSiopWallet( + keyMaterial = holderKeyMaterial, holder = holderAgent, ) - verifierSiop = OidcSiopVerifier.newInstance( - verifier = verifierAgent, - relyingPartyUrl = null, - responseUrl = responseUrl, - x5c = listOf(verifierKeyPair.certificate!!) + verifierSiop = OidcSiopVerifier( + keyMaterial = verifierKeyMaterial, + clientIdScheme = OidcSiopVerifier.ClientIdScheme.CertificateSanDns(listOf(verifierKeyMaterial.getCertificate()!!)), ) } "test with Fragment" { val authnRequest = verifierSiop.createAuthnRequestAsSignedRequestObject( requestOptions = RequestOptions( - representation = ConstantIndex.CredentialRepresentation.SD_JWT, + credentials = setOf( + OidcSiopVerifier.RequestOptionsCredential( + ConstantIndex.AtomicAttribute2023, + ConstantIndex.CredentialRepresentation.SD_JWT, + listOf(CLAIM_GIVEN_NAME) + ) + ), responseMode = OpenIdConstants.ResponseMode.DIRECT_POST_JWT, - requestedAttributes = listOf("given_name") + responseUrl = "https://example.com/response", ) - ).also { println(it) }.getOrThrow() + ).getOrThrow() val authnResponse = holderSiop.createAuthnResponse(authnRequest.serialize()).getOrThrow() - authnResponse.shouldBeInstanceOf().also { println(it) } + authnResponse.shouldBeInstanceOf() val result = verifierSiop.validateAuthnResponseFromPost(authnResponse.params.formUrlEncode()) result.shouldBeInstanceOf() diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/DeserializationTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/DeserializationTest.kt index 3548ebff5..0d6ad4580 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/DeserializationTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/DeserializationTest.kt @@ -1,24 +1,21 @@ package at.asitplus.wallet.lib.oidvci -import at.asitplus.wallet.lib.data.VcDataModelConstants -import at.asitplus.wallet.lib.oidc.AuthenticationRequestParameters -import at.asitplus.wallet.lib.oidc.OpenIdConstants +import at.asitplus.openid.AuthenticationRequestParameters +import at.asitplus.openid.CredentialFormatEnum +import at.asitplus.openid.IssuerMetadata import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.booleans.shouldBeTrue import io.kotest.matchers.maps.shouldNotBeEmpty import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe -import io.kotest.matchers.string.shouldContain -import io.ktor.http.* -import kotlinx.serialization.encodeToString -import kotlin.random.Random -import kotlin.time.Duration.Companion.seconds class DeserializationTest : FunSpec({ test("OID4VCI A.1.1. VC Signed as a JWT, Not Using JSON-LD ") { val input = """ { + "credential_issuer": "test", + "credential_endpoint": "test", "credential_configurations_supported": { "UniversityDegreeCredential": { "format": "jwt_vc_json", @@ -96,6 +93,8 @@ class DeserializationTest : FunSpec({ test("OID4VCI A.2. ISO mDL ") { val input = """ { + "credential_issuer": "test", + "credential_endpoint": "test", "credential_configurations_supported": { "org.iso.18013.5.1.mDL": { "format": "mso_mdoc", @@ -181,6 +180,8 @@ class DeserializationTest : FunSpec({ test("OID4VCI A.3. IETF SD-JWT VC") { val input = """ { + "credential_issuer": "test", + "credential_endpoint": "test", "credential_configurations_supported": { "SD_JWT_VC_example_in_OpenID4VCI": { "format": "vc+sd-jwt", diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidcUserInfoSerializationTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidcUserInfoSerializationTest.kt index 637ca0600..6028a85c0 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidcUserInfoSerializationTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidcUserInfoSerializationTest.kt @@ -1,5 +1,6 @@ package at.asitplus.wallet.lib.oidvci +import at.asitplus.openid.OidcUserInfoExtended import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciInteropTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciInteropTest.kt index d930d37b7..43a241623 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciInteropTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciInteropTest.kt @@ -1,21 +1,25 @@ package at.asitplus.wallet.lib.oidvci +import at.asitplus.openid.AuthorizationDetails +import at.asitplus.openid.CredentialFormatEnum +import at.asitplus.openid.IssuerMetadata import at.asitplus.signum.indispensable.josef.JweAlgorithm import at.asitplus.wallet.lib.agent.IssuerAgent import at.asitplus.wallet.lib.data.ConstantIndex +import at.asitplus.wallet.lib.oauth2.OAuth2Client +import at.asitplus.wallet.lib.oauth2.SimpleAuthorizationService import at.asitplus.wallet.lib.oidc.AuthenticationResponseResult import at.asitplus.wallet.lib.oidc.DummyOAuth2DataProvider import at.asitplus.wallet.lib.oidc.DummyOAuth2IssuerCredentialDataProvider -import at.asitplus.wallet.lib.oidc.OpenIdConstants.PATH_WELL_KNOWN_CREDENTIAL_ISSUER import at.asitplus.wallet.mdl.MobileDrivingLicenceScheme import com.benasher44.uuid.uuid4 import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldContainAll import io.kotest.matchers.collections.shouldHaveSingleElement +import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf -import io.ktor.http.* class OidvciInteropTest : FunSpec({ @@ -24,8 +28,10 @@ class OidvciInteropTest : FunSpec({ beforeEach { authorizationService = SimpleAuthorizationService( - dataProvider = DummyOAuth2DataProvider, - credentialSchemes = setOf(ConstantIndex.AtomicAttribute2023, MobileDrivingLicenceScheme) + strategy = CredentialAuthorizationServiceStrategy( + DummyOAuth2DataProvider, + setOf(ConstantIndex.AtomicAttribute2023, MobileDrivingLicenceScheme) + ), ) issuer = CredentialIssuer( authorizationService = authorizationService, @@ -37,13 +43,12 @@ class OidvciInteropTest : FunSpec({ test("Parse EUDIW URL") { val url = - "eudi-openid4ci://credentialsOffer?credential_offer=%7B%22credential_issuer%22:%22https://localhost/pid-issuer%22,%22credential_configuration_ids%22:[%22eu.europa.ec.eudiw.pid_vc_sd_jwt%22],%22grants%22:%7B%22authorization_code%22:%7B%22authorization_server%22:%22https://localhost/idp/realms/pid-issuer-realm%22%7D%7D%7D" + "openid-credential-offer://?credential_offer=%7B%22credential_issuer%22%3A%22https%3A%2F%2Flocalhost%2Fpid-issuer%22%2C%22credential_configuration_ids%22%3A%5B%22eu.europa.ec.eudi.pid_vc_sd_jwt%22%5D%2C%22grants%22%3A%7B%22authorization_code%22%3A%7B%22authorization_server%22%3A%22https%3A%2F%2Flocalhost%2Fidp%2Frealms%2Fpid-issuer-realm%22%7D%7D%7D" - val client = WalletService() + val credentialOffer = WalletService().parseCredentialOffer(url).getOrThrow() + credentialOffer.grants?.authorizationCode.shouldNotBeNull() + credentialOffer.credentialIssuer shouldBe "https://localhost/pid-issuer" - val credentialOffer = client.parseCredentialOffer(url).getOrThrow() - .also { println(it) } - //val credentialIssuerMetadataUrl = credentialOffer.credentialIssuer + PATH_WELL_KNOWN_CREDENTIAL_ISSUER val credentialIssuerMetadataString = """ { "credential_issuer": "https://localhost/pid-issuer", @@ -64,9 +69,47 @@ class OidvciInteropTest : FunSpec({ }, "credential_identifiers_supported": true, "credential_configurations_supported": { - "eu.europa.ec.eudiw.pid_vc_sd_jwt": { + "eu.europa.ec.eudi.pid_mso_mdoc": { + "format": "mso_mdoc", + "scope": "eu.europa.ec.eudi.pid_mso_mdoc", + "proof_types_supported": { + "jwt": { + "proof_signing_alg_values_supported": [ + "ES256" + ] + } + }, + "doctype": "eu.europa.ec.eudi.pid.1", + "display": [ + { + "name": "PID", + "locale": "en", + "logo": { + "uri": "https://examplestate.com/public/mdl.png", + "alt_text": "A square figure of a PID" + } + } + ], + "policy": { + "one_time_use": true + }, + "claims": { + "eu.europa.ec.eudi.pid.1": { + "family_name": { + "mandatory": true, + "display": [ + { + "name": "Current Family Name", + "locale": "en" + } + ] + } + } + } + }, + "eu.europa.ec.eudi.pid_vc_sd_jwt": { "format": "vc+sd-jwt", - "scope": "eu.europa.ec.eudiw.pid_vc_sd_jwt", + "scope": "eu.europa.ec.eudi.pid_vc_sd_jwt", "cryptographic_binding_methods_supported": [ "jwk" ], @@ -81,7 +124,7 @@ class OidvciInteropTest : FunSpec({ ] } }, - "vct": "eu.europa.ec.eudiw.pid.1", + "vct": "eu.europa.ec.eudi.pid.1", "display": [ { "name": "PID", @@ -92,8 +135,6 @@ class OidvciInteropTest : FunSpec({ } } ], - "credential_definition": { - "type": ["eu.europa.ec.eudiw.pid.1"], "claims": { "family_name": { "mandatory": false, @@ -103,9 +144,40 @@ class OidvciInteropTest : FunSpec({ "locale": "en" } ] - }, - "issuance_date": { - "mandatory": true + } + } + }, + "org.iso.18013.5.1.mDL": { + "format": "mso_mdoc", + "scope": "org.iso.18013.5.1.mDL", + "proof_types_supported": { + "jwt": { + "proof_signing_alg_values_supported": [ + "ES256" + ] + } + }, + "doctype": "org.iso.18013.5.1.mDL", + "display": [ + { + "name": "Mobile Driving Licence", + "locale": "en" + } + ], + "policy": { + "one_time_use": false, + "batch_size": 2 + }, + "claims": { + "org.iso.18013.5.1": { + "family_name": { + "mandatory": true, + "display": [ + { + "name": "Last name, surname, or primary identifier of the mDL holder.", + "locale": "en" + } + ] } } } @@ -123,86 +195,119 @@ class OidvciInteropTest : FunSpec({ issuerMetadata.credentialResponseEncryption!!.supportedAlgorithms .shouldHaveSingleElement(JweAlgorithm.RSA_OAEP_256) issuerMetadata.credentialResponseEncryption!!.encryptionRequired shouldBe true - issuerMetadata.supportsCredentialIdentifiers shouldBe true - // select correct credential config by using a configurationId from the offer it self + val credentialConfig = issuerMetadata.supportedCredentialConfigurations!! .entries.first { it.key == credentialOffer.configurationIds.first() }.toPair() val credential = credentialConfig.second credential.format shouldBe CredentialFormatEnum.VC_SD_JWT - credential.scope shouldBe "eu.europa.ec.eudiw.pid_vc_sd_jwt" + credential.scope shouldBe "eu.europa.ec.eudi.pid_vc_sd_jwt" credential.supportedBindingMethods!!.shouldHaveSingleElement("jwk") credential.supportedSigningAlgorithms!!.shouldHaveSingleElement("ES256") credential.supportedProofTypes!!["jwt"]!!.supportedSigningAlgorithms.shouldContainAll("RS256", "ES256") - credential.sdJwtVcType shouldBe "eu.europa.ec.eudiw.pid.1" - // TODO this is wrong in EUDIW's metadata? Should be an array! credentialConfig.credentialDefinition!!.types - credential.credentialDefinition!!.claims!!.firstNotNullOfOrNull { it.key == "family_name" } - .shouldNotBeNull() + credential.sdJwtVcType shouldBe "eu.europa.ec.eudi.pid.1" + // this is still wrong in EUDIW's metadata: + // Should be an array: credentialConfig.credentialDefinition!!.types, + // but is a single string + } - val authorizationServerMetadataUrl = - issuerMetadata.authorizationServers?.firstOrNull()?.plus("/.well-known/openid-configuration") - // need to get from URL and parse ... - val authorizationServerMetadata = IssuerMetadata( - issuer = "https://localhiost/idp/realms/pid-issuer-realm", - authorizationEndpointUrl = "https://localhost/idp/realms/pid-issuer-realm/protocol/openid-connect/auth" - ) - val authorizationEndpoint = issuerMetadata.authorizationEndpointUrl - ?: authorizationServerMetadata.authorizationEndpointUrl - authorizationEndpoint.shouldNotBeNull() - - // selection of end-user, which credential to get - // would also need to parse from authorizationServerMetadata if `request_parameter_supported` is true and so on ... - val authnRequest = client.createAuthRequest( - uuid4().toString(), - credentialConfig, - issuerMetadata.credentialIssuer, - issuerMetadata.authorizationServers - ) - println(URLBuilder(authorizationEndpoint) - .apply { - authnRequest.encodeToParameters().forEach { - this.parameters.append(it.key, it.value) - } - } - .buildString() + test("process with pre-authorized code, credential offer, and authorization details") { + val client = WalletService() + val credentialOffer = issuer.credentialOfferWithPreAuthnForUser(DummyOAuth2DataProvider.user) + val credentialIssuerMetadata = issuer.metadata + val credentialIdToRequest = credentialOffer.configurationIds.first() + val state = uuid4().toString() + + val preAuth = credentialOffer.grants?.preAuthorizedCode.shouldNotBeNull() + val tokenRequest = client.oauth2Client.createTokenRequestParameters( + state = state, + authorization = OAuth2Client.AuthorizationForToken.PreAuthCode(preAuth.preAuthorizedCode), + authorizationDetails = client.buildAuthorizationDetails( + credentialIdToRequest, + credentialIssuerMetadata.authorizationServers + ) ) - // Clients may also need to push the authorization request, which is a FORM POST 5.1.4 + val token = authorizationService.token(tokenRequest).getOrThrow() + token.authorizationDetails.shouldNotBeNull() + val first = token.authorizationDetails!!.first().shouldBeInstanceOf() + val credentialRequest = client.createCredentialRequest( + input = WalletService.CredentialRequestInput.CredentialIdentifier(first.credentialConfigurationId!!), + clientNonce = token.clientNonce, + credentialIssuer = credentialIssuerMetadata.credentialIssuer + ).getOrThrow() + + val credential = issuer.credential(token.accessToken, credentialRequest) + .getOrThrow() + credential.credential.shouldNotBeNull() } - test("process with pre-authorized code and credential offer") { + test("process with authorization code flow and request options") { val client = WalletService() - val credentialOffer = issuer.credentialOffer() - val credentialIssuerMetadata = issuer.metadata - val credentialConfig = credentialIssuerMetadata.supportedCredentialConfigurations!! - .entries.first { it.key == credentialOffer.configurationIds.first() }.toPair() val state = uuid4().toString() - val authnRequest = client.createAuthRequest( - state, - credentialConfig, - credentialIssuerMetadata.credentialIssuer, - credentialIssuerMetadata.authorizationServers + val requestOptions = WalletService.RequestOptions( + credentialScheme = ConstantIndex.AtomicAttribute2023, + representation = ConstantIndex.CredentialRepresentation.PLAIN_JWT, + ) + val authorizationDetails = client.buildAuthorizationDetails(requestOptions) + val authnRequest = client.oauth2Client.createAuthRequest( + state = state, + authorizationDetails = authorizationDetails, + resource = issuer.metadata.credentialIssuer ) val authnResponse = authorizationService.authorize(authnRequest).getOrThrow() - authnResponse.shouldBeInstanceOf() + .shouldBeInstanceOf() val code = authnResponse.params.code - code.shouldNotBeNull() - // TODO Provide a way to authenticate the client ... - // but how? see `token_endpoint_auth_method` in Client Metadata, RFC 6749 - val preAuth = credentialOffer.grants?.preAuthorizedCode.shouldNotBeNull() - val tokenRequest = client.createTokenRequestParameters( - credential = credentialConfig.second, + .shouldNotBeNull() + + val tokenRequest = client.oauth2Client.createTokenRequestParameters( state = state, - authorization = WalletService.AuthorizationForToken.PreAuthCode(preAuth), + authorization = OAuth2Client.AuthorizationForToken.Code(code), + authorizationDetails = authorizationDetails ) val token = authorizationService.token(tokenRequest).getOrThrow() + token.authorizationDetails.shouldNotBeNull() val credentialRequest = client.createCredentialRequest( - credential = credentialConfig.second, + input = WalletService.CredentialRequestInput.RequestOptions(requestOptions), clientNonce = token.clientNonce, - credentialIssuer = credentialIssuerMetadata.credentialIssuer + credentialIssuer = issuer.metadata.credentialIssuer ).getOrThrow() + val credential = issuer.credential(token.accessToken, credentialRequest) + .getOrThrow() + credential.credential.shouldNotBeNull() + } - credential.shouldNotBeNull() + test("process with pre-authorized code, credential offer, and scope") { + val client = WalletService() + val credentialOffer = issuer.credentialOfferWithPreAuthnForUser(DummyOAuth2DataProvider.user) + val credentialIdToRequest = credentialOffer.configurationIds.first() + // OID4VCI 5.1.2 Using scope Parameter to Request Issuance of a Credential + val supportedCredentialFormat = issuer.metadata.supportedCredentialConfigurations?.get(credentialIdToRequest) + .shouldNotBeNull() + val scope = supportedCredentialFormat.scope + .shouldNotBeNull() + val state = uuid4().toString() + + val preAuth = credentialOffer.grants?.preAuthorizedCode + .shouldNotBeNull() + val tokenRequest = client.oauth2Client.createTokenRequestParameters( + state = state, + authorization = OAuth2Client.AuthorizationForToken.PreAuthCode(preAuth.preAuthorizedCode), + scope = scope, + resource = issuer.metadata.credentialIssuer, + ) + val token = authorizationService.token(tokenRequest).getOrThrow() + token.authorizationDetails.shouldBeNull() + + val credentialRequest = client.createCredentialRequest( + input = WalletService.CredentialRequestInput.Format(supportedCredentialFormat), + clientNonce = token.clientNonce, + credentialIssuer = issuer.metadata.credentialIssuer + ).getOrThrow() + + val credential = issuer.credential(token.accessToken, credentialRequest) + .getOrThrow() + credential.credential.shouldNotBeNull() } }) \ No newline at end of file diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt index 29e10c038..76c3816d7 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciProcessTest.kt @@ -1,17 +1,25 @@ package at.asitplus.wallet.lib.oidvci +import at.asitplus.openid.* import at.asitplus.signum.indispensable.josef.JwsSigned +import at.asitplus.wallet.lib.agent.EphemeralKeyWithSelfSignedCert import at.asitplus.wallet.lib.agent.IssuerAgent import at.asitplus.wallet.lib.data.AtomicAttribute2023 import at.asitplus.wallet.lib.data.ConstantIndex +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_FAMILY_NAME +import at.asitplus.wallet.lib.data.VcDataModelConstants.VERIFIABLE_CREDENTIAL import at.asitplus.wallet.lib.data.VerifiableCredentialJws import at.asitplus.wallet.lib.data.VerifiableCredentialSdJwt import at.asitplus.wallet.lib.iso.IssuerSigned +import at.asitplus.wallet.lib.oauth2.OAuth2Client +import at.asitplus.wallet.lib.oauth2.SimpleAuthorizationService import at.asitplus.wallet.lib.oidc.AuthenticationResponseResult import at.asitplus.wallet.lib.oidc.DummyOAuth2DataProvider import at.asitplus.wallet.lib.oidc.DummyOAuth2IssuerCredentialDataProvider import at.asitplus.wallet.mdl.MobileDrivingLicenceDataElements import at.asitplus.wallet.mdl.MobileDrivingLicenceScheme +import com.benasher44.uuid.uuid4 +import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.ints.shouldBeGreaterThan import io.kotest.matchers.nulls.shouldNotBeNull @@ -20,39 +28,111 @@ import io.kotest.matchers.types.shouldBeInstanceOf import io.matthewnelson.encoding.base64.Base64 import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray - class OidvciProcessTest : FunSpec({ - val authorizationService = SimpleAuthorizationService( - dataProvider = DummyOAuth2DataProvider, - credentialSchemes = setOf(ConstantIndex.AtomicAttribute2023) - ) - val issuer = CredentialIssuer( - authorizationService = authorizationService, - issuer = IssuerAgent(), - credentialSchemes = setOf(ConstantIndex.AtomicAttribute2023), - buildIssuerCredentialDataProviderOverride = ::DummyOAuth2IssuerCredentialDataProvider - ) - val client = WalletService() + lateinit var authorizationService: SimpleAuthorizationService + lateinit var issuer: CredentialIssuer + lateinit var client: WalletService + + beforeEach { + authorizationService = SimpleAuthorizationService( + strategy = CredentialAuthorizationServiceStrategy( + DummyOAuth2DataProvider, + setOf(ConstantIndex.AtomicAttribute2023) + ), + ) + issuer = CredentialIssuer( + authorizationService = authorizationService, + issuer = IssuerAgent(), + credentialSchemes = setOf(ConstantIndex.AtomicAttribute2023), + buildIssuerCredentialDataProviderOverride = ::DummyOAuth2IssuerCredentialDataProvider + ) + client = WalletService( + clientId = "https://wallet.a-sit.at/app", + redirectUrl = "https://wallet.a-sit.at/callback", + keyMaterial = EphemeralKeyWithSelfSignedCert() + ) + } test("process with W3C VC JWT") { - val credential = runProcess( - authorizationService, - issuer, - client, - WalletService.RequestOptions( - ConstantIndex.AtomicAttribute2023, - representation = ConstantIndex.CredentialRepresentation.PLAIN_JWT - ) + val requestOptions = WalletService.RequestOptions( + ConstantIndex.AtomicAttribute2023, + representation = ConstantIndex.CredentialRepresentation.PLAIN_JWT ) + val credential = runProcess(authorizationService, issuer, client, requestOptions) credential.format shouldBe CredentialFormatEnum.JWT_VC val serializedCredential = credential.credential.shouldNotBeNull() - val jws = JwsSigned.parse(serializedCredential).getOrThrow() + val jws = JwsSigned.deserialize(serializedCredential).getOrThrow() val vcJws = VerifiableCredentialJws.deserialize(jws.payload.decodeToString()).getOrThrow() vcJws.vc.credentialSubject.shouldBeInstanceOf() } + test("process with W3C VC JWT, proof over different keys") { + val requestOptions = WalletService.RequestOptions( + ConstantIndex.AtomicAttribute2023, + representation = ConstantIndex.CredentialRepresentation.PLAIN_JWT + ) + val authnRequest = client.oauth2Client.createAuthRequest( + requestOptions.state, + client.buildAuthorizationDetails(requestOptions), + ) + val authnResponse = authorizationService.authorize(authnRequest).getOrThrow() + val code = authnResponse.params.code.shouldNotBeNull() + val tokenRequest = client.oauth2Client.createTokenRequestParameters( + state = requestOptions.state, + authorization = OAuth2Client.AuthorizationForToken.Code(code), + authorizationDetails = client.buildAuthorizationDetails(requestOptions) + ) + val token = authorizationService.token(tokenRequest).getOrThrow() + val proof = client.createCredentialRequestProof( + clientNonce = token.clientNonce, + credentialIssuer = issuer.metadata.credentialIssuer, + clock = requestOptions.clock + ) + val differentProof = WalletService().createCredentialRequestProof( + clientNonce = token.clientNonce, + credentialIssuer = issuer.metadata.credentialIssuer, + clock = requestOptions.clock + ) + val credentialRequest = CredentialRequestParameters( + format = CredentialFormatEnum.JWT_VC, + credentialDefinition = SupportedCredentialFormatDefinition( + types = setOf(VERIFIABLE_CREDENTIAL, ConstantIndex.AtomicAttribute2023.vcType), + ), + proofs = CredentialRequestProofContainer( + proofType = OpenIdConstants.ProofType.JWT, + jwt = setOf(proof.jwt!!, differentProof.jwt!!) + ) + ) + + val credential = issuer.credential(token.accessToken, credentialRequest) + credential.isFailure shouldBe true + credential.exceptionOrNull().shouldBeInstanceOf() + } + + test("process with W3C VC JWT, authorizationService with defect mapstore") { + authorizationService = SimpleAuthorizationService( + codeToUserInfoStore = defectMapStore(), + strategy = CredentialAuthorizationServiceStrategy( + DummyOAuth2DataProvider, + setOf(ConstantIndex.AtomicAttribute2023) + ), + ) + issuer = CredentialIssuer( + authorizationService = authorizationService, + issuer = IssuerAgent(), + credentialSchemes = setOf(ConstantIndex.AtomicAttribute2023), + buildIssuerCredentialDataProviderOverride = ::DummyOAuth2IssuerCredentialDataProvider + ) + val requestOptions = WalletService.RequestOptions( + ConstantIndex.AtomicAttribute2023, + representation = ConstantIndex.CredentialRepresentation.PLAIN_JWT + ) + + shouldThrow { runProcess(authorizationService, issuer, client, requestOptions) } + } + test("process with W3C VC SD-JWT") { val credential = runProcess( authorizationService, @@ -66,7 +146,7 @@ class OidvciProcessTest : FunSpec({ credential.format shouldBe CredentialFormatEnum.VC_SD_JWT val serializedCredential = credential.credential.shouldNotBeNull() - val jws = JwsSigned.parse(serializedCredential.substringBefore("~")).getOrThrow() + val jws = JwsSigned.deserialize(serializedCredential.substringBefore("~")).getOrThrow() val sdJwt = VerifiableCredentialSdJwt.deserialize(jws.payload.decodeToString()).getOrThrow() sdJwt.disclosureDigests.shouldNotBeNull() @@ -74,32 +154,31 @@ class OidvciProcessTest : FunSpec({ } test("process with W3C VC SD-JWT, credential offer, pre-authn") { - val offer = issuer.credentialOffer() - .also { println(it.serialize()) } - val metadata = issuer.metadata - .also { println(it.serialize()) } - - val selectedCredentialConfigurationId = "AtomicAttribute2023#vc+sd-jwt" - val selectedCredential = metadata.supportedCredentialConfigurations!![selectedCredentialConfigurationId]!! - val tokenRequest = client.createTokenRequestParameters( - authorization = WalletService.AuthorizationForToken.PreAuthCode(offer.grants!!.preAuthorizedCode!!), - credential = selectedCredential, - ).also { println(it.serialize()) } + val offer = issuer.credentialOfferWithPreAuthnForUser(DummyOAuth2DataProvider.user) + val state = uuid4().toString() + val credentialIdToRequest = "AtomicAttribute2023#vc+sd-jwt" + val preAuth = offer.grants!!.preAuthorizedCode!! + val tokenRequest = client.oauth2Client.createTokenRequestParameters( + state = state, + authorization = OAuth2Client.AuthorizationForToken.PreAuthCode(preAuth.preAuthorizedCode), + authorizationDetails = setOf( + AuthorizationDetails.OpenIdCredential(credentialConfigurationId = credentialIdToRequest) + ) + ) val token = authorizationService.token(tokenRequest).getOrThrow() - .also { println(it.serialize()) } + token.authorizationDetails.shouldNotBeNull() + val first = token.authorizationDetails!!.first().shouldBeInstanceOf() val credentialRequest = client.createCredentialRequest( - credential = selectedCredential, + input = WalletService.CredentialRequestInput.CredentialIdentifier(first.credentialConfigurationId!!), clientNonce = token.clientNonce, credentialIssuer = issuer.metadata.credentialIssuer ).getOrThrow() - .also { println(it.serialize()) } val credential = issuer.credential(token.accessToken, credentialRequest).getOrThrow() - .also { println(it.serialize()) } credential.format shouldBe CredentialFormatEnum.VC_SD_JWT val serializedCredential = credential.credential.shouldNotBeNull() - val jws = JwsSigned.parse(serializedCredential.substringBefore("~")).getOrThrow() + val jws = JwsSigned.deserialize(serializedCredential.substringBefore("~")).getOrThrow() val sdJwt = VerifiableCredentialSdJwt.deserialize(jws.payload.decodeToString()).getOrThrow() sdJwt.disclosureDigests.shouldNotBeNull() @@ -114,13 +193,13 @@ class OidvciProcessTest : FunSpec({ WalletService.RequestOptions( ConstantIndex.AtomicAttribute2023, representation = ConstantIndex.CredentialRepresentation.SD_JWT, - requestedAttributes = setOf("family_name") + requestedAttributes = setOf(CLAIM_FAMILY_NAME) ) ) credential.format shouldBe CredentialFormatEnum.VC_SD_JWT val serializedCredential = credential.credential.shouldNotBeNull() - val jws = JwsSigned.parse(serializedCredential.substringBeforeLast("~")).getOrThrow() + val jws = JwsSigned.deserialize(serializedCredential.substringBeforeLast("~")).getOrThrow() val sdJwt = VerifiableCredentialSdJwt.deserialize(jws.payload.decodeToString()).getOrThrow() sdJwt.disclosureDigests.shouldNotBeNull() @@ -192,23 +271,33 @@ class OidvciProcessTest : FunSpec({ }) +private fun defectMapStore() = object : MapStore { + override suspend fun put(key: String, value: OidcUserInfoExtended) = Unit + override suspend fun get(key: String): OidcUserInfoExtended? = null + override suspend fun remove(key: String): OidcUserInfoExtended? = null +} + private suspend fun runProcess( authorizationService: SimpleAuthorizationService, issuer: CredentialIssuer, client: WalletService, requestOptions: WalletService.RequestOptions, ): CredentialResponseParameters { - val authnRequest = client.createAuthRequest(requestOptions) + val authnRequest = client.oauth2Client.createAuthRequest( + requestOptions.state, + client.buildAuthorizationDetails(requestOptions), + ) val authnResponse = authorizationService.authorize(authnRequest).getOrThrow() authnResponse.shouldBeInstanceOf() val code = authnResponse.params.code.shouldNotBeNull() - val tokenRequest = client.createTokenRequestParameters( - requestOptions = requestOptions, - authorization = WalletService.AuthorizationForToken.Code(code) + val tokenRequest = client.oauth2Client.createTokenRequestParameters( + state = requestOptions.state, + authorization = OAuth2Client.AuthorizationForToken.Code(code), + authorizationDetails = client.buildAuthorizationDetails(requestOptions) ) val token = authorizationService.token(tokenRequest).getOrThrow() val credentialRequest = client.createCredentialRequest( - requestOptions, + WalletService.CredentialRequestInput.RequestOptions(requestOptions), token.clientNonce, issuer.metadata.credentialIssuer ).getOrThrow() diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/SerializationTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/SerializationTest.kt index 5775d5ef6..33292010d 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/SerializationTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/SerializationTest.kt @@ -1,11 +1,10 @@ package at.asitplus.wallet.lib.oidvci +import at.asitplus.openid.* +import at.asitplus.openid.OpenIdConstants.GRANT_TYPE_AUTHORIZATION_CODE +import at.asitplus.openid.OpenIdConstants.GRANT_TYPE_CODE +import at.asitplus.openid.OpenIdConstants.TOKEN_TYPE_BEARER import at.asitplus.wallet.lib.data.VcDataModelConstants.VERIFIABLE_CREDENTIAL -import at.asitplus.wallet.lib.oidc.AuthenticationRequestParameters -import at.asitplus.wallet.lib.oidc.OpenIdConstants -import at.asitplus.wallet.lib.oidc.OpenIdConstants.GRANT_TYPE_AUTHORIZATION_CODE -import at.asitplus.wallet.lib.oidc.OpenIdConstants.GRANT_TYPE_CODE -import at.asitplus.wallet.lib.oidc.OpenIdConstants.TOKEN_TYPE_BEARER import at.asitplus.wallet.lib.oidc.jsonSerializer import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe @@ -21,11 +20,10 @@ class SerializationTest : FunSpec({ responseType = GRANT_TYPE_CODE, clientId = randomString(), authorizationDetails = setOf( - AuthorizationDetails( - type = randomString(), + AuthorizationDetails.OpenIdCredential( format = CredentialFormatEnum.JWT_VC, credentialDefinition = SupportedCredentialFormatDefinition( - types = listOf(VERIFIABLE_CREDENTIAL, randomString()), + types = setOf(VERIFIABLE_CREDENTIAL, randomString()), ) ) ), @@ -60,7 +58,7 @@ class SerializationTest : FunSpec({ fun createCredentialRequest() = CredentialRequestParameters( format = CredentialFormatEnum.JWT_VC, credentialDefinition = SupportedCredentialFormatDefinition( - types = listOf(randomString(), randomString()), + types = setOf(randomString(), randomString()), ), proof = CredentialRequestProof( proofType = OpenIdConstants.ProofType.OTHER(randomString()), @@ -82,7 +80,6 @@ class SerializationTest : FunSpec({ val intermediateMap = params.encodeToParameters() val url = "$baseUrl?${intermediateMap.formUrlEncode()}" - println(url) url shouldContain baseUrl url shouldContain "response_type=${params.responseType}" @@ -95,7 +92,7 @@ class SerializationTest : FunSpec({ val params = createAuthorizationRequest() val intermediateMap = params.encodeToParameters() val formEncoded = intermediateMap.formUrlEncode() - println(formEncoded) + formEncoded shouldContain "response_type=${params.responseType}" formEncoded shouldContain "client_id=${params.clientId}" formEncoded shouldContain "authorization_details=" + "[{\"type\":".encodeURLParameter() @@ -109,7 +106,7 @@ class SerializationTest : FunSpec({ val params = createTokenRequest() val intermediateMap = params.encodeToParameters() val formEncoded = intermediateMap.formUrlEncode() - println(formEncoded) + val parsed: TokenRequestParameters = intermediateMap.decode() parsed shouldBe params val parsedToo: TokenRequestParameters = formEncoded.decodeFromPostBody() @@ -118,8 +115,9 @@ class SerializationTest : FunSpec({ test("createTokenResponse as JSON") { val params = createTokenResponse() + val json = jsonSerializer.encodeToString(params) - println(json) + json shouldContain "\"access_token\":" json shouldContain "\"token_type\":" json shouldContain "\"expires_in\":" @@ -131,8 +129,9 @@ class SerializationTest : FunSpec({ test("createCredentialRequest as JSON") { val params = createCredentialRequest() + val json = jsonSerializer.encodeToString(params) - println(json) + json shouldContain "\"type\":[" json shouldContain "\"${params.credentialDefinition?.types?.first()}\"" val parsed: CredentialRequestParameters = @@ -142,8 +141,9 @@ class SerializationTest : FunSpec({ test("createCredentialResponse as JSON") { val params = createCredentialResponse() + val json = jsonSerializer.encodeToString(params) - println(json) + json shouldContain "\"format\":" val parsed = jsonSerializer.decodeFromString(json) parsed shouldBe params diff --git a/vck/build.gradle.kts b/vck/build.gradle.kts index c9e3d5931..70958d21b 100644 --- a/vck/build.gradle.kts +++ b/vck/build.gradle.kts @@ -1,7 +1,10 @@ +import at.asitplus.gradle.* + import at.asitplus.gradle.commonImplementationAndApiDependencies -import at.asitplus.gradle.commonIosExports import at.asitplus.gradle.exportIosFramework import at.asitplus.gradle.setupDokka +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree.Companion.test plugins { kotlin("multiplatform") @@ -18,19 +21,32 @@ group = "at.asitplus.wallet" version = artifactVersion - kotlin { + jvm() + androidTarget { + publishLibraryVariants("release") + @OptIn(ExperimentalKotlinGradlePluginApi::class) + instrumentedTestVariant.sourceSetTree.set(test) + } iosArm64() iosSimulatorArm64() iosX64() sourceSets { commonMain { dependencies { + api(project(":dif-data-classes")) commonImplementationAndApiDependencies() } } + commonTest { + dependencies { + implementation("io.arrow-kt:arrow-core:1.2.4") //to make arrow's nonFatalOrThrow work in tests + implementation(kotlin("reflect")) + } + } + jvmMain { dependencies { @@ -40,6 +56,8 @@ kotlin { jvmTest { dependencies { implementation(signum.jose) + implementation(kotlin("reflect")) + implementation("io.arrow-kt:arrow-core-jvm:1.2.4") //to make arrow's nonFatalOrThrow work in tests implementation("org.json:json:${VcLibVersions.Jvm.json}") implementation("com.authlete:cbor:${VcLibVersions.Jvm.`authlete-cbor`}") } @@ -47,10 +65,14 @@ kotlin { } } + +setupAndroid() + exportIosFramework( name = "VckKmm", - static = false, - *commonIosExports() + transitiveExports = false, + project(":dif-data-classes"), + "at.asitplus.signum:supreme:${VcLibVersions.supreme}" ) val javadocJar = setupDokka( @@ -91,23 +113,6 @@ publishing { } } } - //REMOVE ME AFTER REBRANDED ARTIFACT HAS BEEN PUBLISHED - create("relocation") { - pom { - // Old artifact coordinates - artifactId = "vclib" - version = artifactVersion - - distributionManagement { - relocation { - // New artifact coordinates - artifactId = "vck" - version = artifactVersion - message = " artifactId have been changed" - } - } - } - } } repositories { mavenLocal { diff --git a/vck/src/androidMain/kotlin/at/asitplus/wallet/lib/DefaultZlibService.kt b/vck/src/androidMain/kotlin/at/asitplus/wallet/lib/DefaultZlibService.kt new file mode 100644 index 000000000..f41fc69c8 --- /dev/null +++ b/vck/src/androidMain/kotlin/at/asitplus/wallet/lib/DefaultZlibService.kt @@ -0,0 +1,50 @@ +package at.asitplus.wallet.lib + +import java.io.ByteArrayOutputStream +import java.io.OutputStream +import java.util.zip.Deflater +import java.util.zip.DeflaterInputStream +import java.util.zip.InflaterInputStream + +actual class DefaultZlibService actual constructor() : ZlibService { + + /** + * 5 MB seems to be safe for a max. inflated byte array + */ + private val MAX_DECOMPRESSED_SIZE = 5 * 1024 * 1024 + + actual override fun compress(input: ByteArray): ByteArray? { + return DeflaterInputStream(input.inputStream(), Deflater(Deflater.DEFAULT_COMPRESSION)).readBytes() + } + + /** + * Safely decompresses ZLIB encoded bytes, with max size [MAX_DECOMPRESSED_SIZE] + */ + actual override fun decompress(input: ByteArray): ByteArray? { + return InflaterInputStream(input.inputStream()).readBytes().also { + val inflaterStream = InflaterInputStream(input.inputStream()) + val outputStream = ByteArrayOutputStream(DEFAULT_BUFFER_SIZE) + inflaterStream.copyTo(outputStream) + outputStream.toByteArray() + } + } + + // Adapted from kotlin-stdblib's kotlin.io.IOStreams.kt + private fun InflaterInputStream.copyTo(out: OutputStream, bufferSize: Int = DEFAULT_BUFFER_SIZE): Long { + var bytesCopied: Long = 0 + val buffer = ByteArray(bufferSize) + var bytes = read(buffer) + while (bytes >= 0) { + out.write(buffer, 0, bytes) + bytesCopied += bytes + bytes = read(buffer) + // begin patch + if (bytesCopied > MAX_DECOMPRESSED_SIZE) { + throw IllegalArgumentException("Decompression exceeded $MAX_DECOMPRESSED_SIZE bytes, is: $bytesCopied! Input must be invalid.") + } + // end patch + } + return bytesCopied + } + +} \ No newline at end of file diff --git a/vck/src/androidMain/kotlin/at/asitplus/wallet/lib/agent/CryptoService.android.kt b/vck/src/androidMain/kotlin/at/asitplus/wallet/lib/agent/CryptoService.android.kt new file mode 100644 index 000000000..419628091 --- /dev/null +++ b/vck/src/androidMain/kotlin/at/asitplus/wallet/lib/agent/CryptoService.android.kt @@ -0,0 +1,93 @@ +package at.asitplus.wallet.lib.agent + +import at.asitplus.KmmResult +import at.asitplus.KmmResult.Companion.wrap +import at.asitplus.signum.indispensable.josef.* +import javax.crypto.Cipher +import javax.crypto.Mac +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +actual open class PlatformCryptoShim actual constructor(actual val keyMaterial: KeyMaterial) { + + actual open fun encrypt( + key: ByteArray, + iv: ByteArray, + aad: ByteArray, + input: ByteArray, + algorithm: JweEncryption + ): KmmResult = runCatching { + val jcaCiphertext = Cipher.getInstance(algorithm.jcaName).also { + if (algorithm.isAuthenticatedEncryption) { + it.init( + Cipher.ENCRYPT_MODE, + SecretKeySpec(key, algorithm.jcaKeySpecName), + GCMParameterSpec(algorithm.ivLengthBits, iv) + ) + it.updateAAD(aad) + } else { + it.init( + Cipher.ENCRYPT_MODE, + SecretKeySpec(key, algorithm.jcaKeySpecName), + ) + } + }.doFinal(input) + if (algorithm.isAuthenticatedEncryption) { + val ciphertext = jcaCiphertext.dropLast(algorithm.ivLengthBits / 8).toByteArray() + val authtag = jcaCiphertext.takeLast(algorithm.ivLengthBits / 8).toByteArray() + AuthenticatedCiphertext(ciphertext, authtag) + } else { + AuthenticatedCiphertext(jcaCiphertext, byteArrayOf()) + } + }.wrap() + + + actual open suspend fun decrypt( + key: ByteArray, + iv: ByteArray, + aad: ByteArray, + input: ByteArray, + authTag: ByteArray, + algorithm: JweEncryption + ): KmmResult = runCatching { + Cipher.getInstance(algorithm.jcaName).also { + if (algorithm.isAuthenticatedEncryption) { + it.init( + Cipher.DECRYPT_MODE, + SecretKeySpec(key, algorithm.jcaKeySpecName), + GCMParameterSpec(algorithm.ivLengthBits, iv) + ) + it.updateAAD(aad) + } else { + it.init( + Cipher.DECRYPT_MODE, + SecretKeySpec(key, algorithm.jcaKeySpecName), + ) + } + }.doFinal(input + authTag) + }.wrap() + + actual open fun performKeyAgreement( + ephemeralKey: EphemeralKeyHolder, + recipientKey: JsonWebKey, + algorithm: JweAlgorithm + ): KmmResult { + return KmmResult.success("sharedSecret-${algorithm.identifier}".encodeToByteArray()) + } + + actual open fun performKeyAgreement(ephemeralKey: JsonWebKey, algorithm: JweAlgorithm): KmmResult { + return KmmResult.success("sharedSecret-${algorithm.identifier}".encodeToByteArray()) + } + + actual open fun hmac( + key: ByteArray, + algorithm: JweEncryption, + input: ByteArray, + ): KmmResult = runCatching { + Mac.getInstance(algorithm.jcaHmacName).also { + it.init(SecretKeySpec(key, algorithm.jcaKeySpecName)) + }.doFinal(input) + }.wrap() + +} + diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/LibraryInitializer.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/LibraryInitializer.kt index 27cf123d9..955cf9457 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/LibraryInitializer.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/LibraryInitializer.kt @@ -9,9 +9,6 @@ import at.asitplus.wallet.lib.data.ConstantIndex.supportsVcJwt import at.asitplus.wallet.lib.data.JsonCredentialSerializer import at.asitplus.wallet.lib.iso.CborCredentialSerializer import kotlinx.serialization.KSerializer -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.CompositeDecoder -import kotlinx.serialization.encoding.CompositeEncoder import kotlinx.serialization.json.JsonElement import kotlinx.serialization.modules.SerializersModule @@ -20,41 +17,22 @@ import kotlinx.serialization.modules.SerializersModule */ object LibraryInitializer { - @Deprecated(message = "Please use methods that do not use this data class") - data class ExtensionLibraryInfo( - /** - * Implementation of [at.asitplus.wallet.lib.data.ConstantIndex.CredentialScheme]. - */ - val credentialScheme: ConstantIndex.CredentialScheme, - /** - * Definition of a polymorphic serializers module in this form: - * ``` - * kotlinx.serialization.modules.SerializersModule { - * kotlinx.serialization.modules.polymorphic(CredentialSubject::class) { - * kotlinx.serialization.modules.subclass(YourCredential::class) - * } - * } - * ``` - */ - val serializersModule: SerializersModule, - ) - - /** - * Register the extension library with information from [data]. - */ - @Deprecated( - message = "Please use methods not using the data class", - replaceWith = ReplaceWith("registerExtensionLibrary(credentialScheme, serializersModule)") - ) - fun registerExtensionLibrary(@Suppress("DEPRECATION") data: ExtensionLibraryInfo) { - registerExtensionLibrary(data.credentialScheme, data.serializersModule) - } - /** * Register [credentialScheme] to be used with this library, e.g. in OpenID protocol implementations. * * Specify [serializersModule] if the credential scheme supports [ConstantIndex.CredentialRepresentation.PLAIN_JWT], * i.e. it implements a subclass of [at.asitplus.wallet.lib.data.CredentialSubject] that needs to be de/serialized. + * + * Implement `serializersModule` in this form: + * ``` + * kotlinx.serialization.modules.SerializersModule { + * kotlinx.serialization.modules.polymorphic(CredentialSubject::class) { + * kotlinx.serialization.modules.subclass(YourCredential::class) + * } + * } + * ``` + * + * @param serializersModule Definition of a polymorphic serializers module, see example in function doc. */ fun registerExtensionLibrary( credentialScheme: ConstantIndex.CredentialScheme, @@ -70,78 +48,75 @@ object LibraryInitializer { * Register [credentialScheme] to be used with this library, e.g. in OpenID protocol implementations. * Used for credentials supporting [at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.ISO_MDOC], * which need to specify several functions to allow encoding any values - * in [at.asitplus.wallet.lib.iso.IssuerSignedItem]. See the function typealiases for implementation notes. + * in [at.asitplus.wallet.lib.iso.IssuerSignedItem]. + * See the function typealiases in [JsonValueEncoder] and [ElementIdentifierToItemValueSerializerMap] + * for implementation notes. * - * @param serializerLookup used to build the serializer descriptor for [at.asitplus.wallet.lib.iso.IssuerSignedItem] - * @param itemValueEncoder used to actually serialize the element value in [at.asitplus.wallet.lib.iso.IssuerSignedItemSerializer] - * @param itemValueDecoder used to actually deserialize `Any` object in [at.asitplus.wallet.lib.iso.IssuerSignedItemSerializer] + * + * Example for [serializersModule]: + * ``` + * kotlinx.serialization.modules.SerializersModule { + * kotlinx.serialization.modules.polymorphic(CredentialSubject::class) { + * kotlinx.serialization.modules.subclass(YourCredential::class) + * } + * } + * ``` + * + * Example for [jsonValueEncoder]: + * ``` + * when (it) { + * is DrivingPrivilege -> vckJsonSerializer.encodeToJsonElement(it) + * is LocalDate -> vckJsonSerializer.encodeToJsonElement(it) + * is UInt -> vckJsonSerializer.encodeToJsonElement(it) + * else -> null + * } + * ``` + * + * Example for [itemValueSerializerMap]: + * ``` + * mapOf( + * MobileDrivingLicenceDataElements.BIRTH_DATE to LocalDate.serializer(), + * MobileDrivingLicenceDataElements.PORTRAIT to ByteArraySerializer(), + * ) + * ``` + * + * @param serializersModule needed if supporting [ConstantIndex.CredentialRepresentation.PLAIN_JWT], + * i.e. it implements a subclass of [at.asitplus.wallet.lib.data.CredentialSubject] that needs to be de/serialized. * @param jsonValueEncoder used to describe the credential in input descriptors used in verifiable presentations, - * e.g. when used in SIOPv2 + * e.g. when used in SIOPv2 + * @param itemValueSerializerMap used to actually serialize and deserialize `Any` object in + * [at.asitplus.wallet.lib.iso.IssuerSignedItemSerializer], with `elementIdentifier` as the key */ fun registerExtensionLibrary( credentialScheme: ConstantIndex.CredentialScheme, serializersModule: SerializersModule? = null, - serializerLookup: SerializerLookup, - itemValueEncoder: ItemValueEncoder, - itemValueDecoder: ItemValueDecoder, jsonValueEncoder: JsonValueEncoder, + itemValueSerializerMap: ElementIdentifierToItemValueSerializerMap = emptyMap(), ) { registerExtensionLibrary(credentialScheme, serializersModule) - CborCredentialSerializer.register(serializerLookup) - CborCredentialSerializer.register(itemValueEncoder) - CborCredentialSerializer.register(itemValueDecoder) JsonCredentialSerializer.register(jsonValueEncoder) + credentialScheme.isoNamespace?.let { CborCredentialSerializer.register(itemValueSerializerMap, it) } } } /** - * Implementation may be + * Used to encode any value into a [JsonElement], implementation may be * ``` - * if (value is Array<*> && value.isNotEmpty() && value.all { it is DrivingPrivilege }) { - * true.also { - * compositeEncoder.encodeSerializableElement( - * descriptor, - * index, - * ArraySerializer(DrivingPrivilege.serializer()), - * value as Array - * ) - * } - * } else { - * false + * when (it) { + * is DrivingPrivilege -> vckJsonSerializer.encodeToJsonElement(it) + * is LocalDate -> vckJsonSerializer.encodeToJsonElement(it) + * is UInt -> vckJsonSerializer.encodeToJsonElement(it) + * else -> null * } * ``` */ -typealias ItemValueEncoder - = (descriptor: SerialDescriptor, index: Int, compositeEncoder: CompositeEncoder, value: Any) -> Boolean - -/** - * Implementation may be - * ``` - * compositeDecoder.decodeSerializableElement( - * descriptor, - * index, - * ArraySerializer(DrivingPrivilege.serializer()) - * ) - * ``` - */ -typealias ItemValueDecoder - = (descriptor: SerialDescriptor, index: Int, compositeDecoder: CompositeDecoder) -> Any - -/** - * Implementation may be - * ``` - * if (it is Array<*>) ArraySerializer(DrivingPrivilege.serializer()) else null - * ``` - */ -typealias SerializerLookup - = (element: Any) -> KSerializer<*>? +typealias JsonValueEncoder + = (value: Any) -> JsonElement? /** - * Implementation may be - * ``` - * if (it is DrivingPrivilege) jsonSerializer.encodeToJsonElement(it) else null - * ``` + * Maps from [at.asitplus.wallet.lib.iso.IssuerSignedItem.elementIdentifier] (the claim name) to its corresponding + * [KSerializer]. */ -typealias JsonValueEncoder - = (value: Any) -> JsonElement? \ No newline at end of file +typealias ElementIdentifierToItemValueSerializerMap + = Map> \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/CryptoService.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/CryptoService.kt index 6735fd4e8..e27efba4c 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/CryptoService.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/CryptoService.kt @@ -3,28 +3,19 @@ package at.asitplus.wallet.lib.agent import at.asitplus.KmmResult -import at.asitplus.signum.indispensable.CryptoPublicKey -import at.asitplus.signum.indispensable.CryptoSignature -import at.asitplus.signum.indispensable.Digest -import at.asitplus.signum.indispensable.ECCurve -import at.asitplus.signum.indispensable.X509SignatureAlgorithm +import at.asitplus.signum.indispensable.* import at.asitplus.signum.indispensable.josef.JsonWebKey import at.asitplus.signum.indispensable.josef.JweAlgorithm import at.asitplus.signum.indispensable.josef.JweEncryption +import at.asitplus.signum.supreme.SignatureResult +import at.asitplus.signum.supreme.hash.digest +import at.asitplus.signum.supreme.sign.SignatureInput +import at.asitplus.signum.supreme.sign.Verifier +import at.asitplus.signum.supreme.sign.verifierFor interface CryptoService { - suspend fun sign(input: ByteArray): KmmResult = - doSign(input).map { - when (it) { - is CryptoSignature.RawByteEncodable -> it - is CryptoSignature.NotRawByteEncodable -> when (it) { - is CryptoSignature.EC.IndefiniteLength -> it.withCurve((keyPairAdapter.publicKey as CryptoPublicKey.EC).curve) - } - } - } - - suspend fun doSign(input: ByteArray): KmmResult + suspend fun sign(input: ByteArray): SignatureResult fun encrypt( key: ByteArray, @@ -43,7 +34,7 @@ interface CryptoService { algorithm: JweEncryption ): KmmResult - fun generateEphemeralKeyPair(ecCurve: ECCurve): KmmResult + fun generateEphemeralKeyPair(ecCurve: ECCurve): EphemeralKeyHolder fun performKeyAgreement( ephemeralKey: EphemeralKeyHolder, @@ -53,9 +44,15 @@ interface CryptoService { fun performKeyAgreement(ephemeralKey: JsonWebKey, algorithm: JweAlgorithm): KmmResult - fun messageDigest(input: ByteArray, digest: Digest): KmmResult + fun messageDigest(input: ByteArray, digest: Digest): ByteArray + + fun hmac( + key: ByteArray, + algorithm: JweEncryption, + input: ByteArray + ): KmmResult - val keyPairAdapter: KeyPairAdapter + val keyMaterial: KeyMaterial } @@ -71,7 +68,7 @@ interface VerifierCryptoService { signature: CryptoSignature, algorithm: X509SignatureAlgorithm, publicKey: CryptoPublicKey, - ): KmmResult + ): KmmResult } @@ -96,13 +93,11 @@ data class AuthenticatedCiphertext(val ciphertext: ByteArray, val authtag: ByteA } } -interface EphemeralKeyHolder { - val publicJsonWebKey: JsonWebKey? -} +expect open class PlatformCryptoShim(keyMaterial: KeyMaterial) { -expect class DefaultCryptoService : CryptoService { - override suspend fun doSign(input: ByteArray): KmmResult - override fun encrypt( + val keyMaterial: KeyMaterial + + open fun encrypt( key: ByteArray, iv: ByteArray, aad: ByteArray, @@ -110,7 +105,7 @@ expect class DefaultCryptoService : CryptoService { algorithm: JweEncryption ): KmmResult - override suspend fun decrypt( + open suspend fun decrypt( key: ByteArray, iv: ByteArray, aad: ByteArray, @@ -119,33 +114,82 @@ expect class DefaultCryptoService : CryptoService { algorithm: JweEncryption ): KmmResult - override fun generateEphemeralKeyPair(ecCurve: ECCurve): KmmResult - override fun performKeyAgreement( + open fun performKeyAgreement( ephemeralKey: EphemeralKeyHolder, recipientKey: JsonWebKey, algorithm: JweAlgorithm ): KmmResult - override fun performKeyAgreement( + open fun performKeyAgreement( ephemeralKey: JsonWebKey, algorithm: JweAlgorithm ): KmmResult + open fun hmac( + key: ByteArray, + algorithm: JweEncryption, + input: ByteArray + ): KmmResult +} + +open class DefaultCryptoService( + override val keyMaterial: KeyMaterial +) : CryptoService { + + private val platformCryptoShim by lazy { PlatformCryptoShim(keyMaterial) } + + override suspend fun sign(input: ByteArray) = keyMaterial.sign(input) + + + override fun encrypt( + key: ByteArray, + iv: ByteArray, + aad: ByteArray, + input: ByteArray, + algorithm: JweEncryption + ): KmmResult = + platformCryptoShim.encrypt(key, iv, aad, input, algorithm) + + override suspend fun decrypt( + key: ByteArray, + iv: ByteArray, + aad: ByteArray, + input: ByteArray, + authTag: ByteArray, + algorithm: JweEncryption + ): KmmResult = + platformCryptoShim.decrypt(key, iv, aad, input, authTag, algorithm) + + override fun generateEphemeralKeyPair(ecCurve: ECCurve) = DefaultEphemeralKeyHolder(ecCurve) + + override fun performKeyAgreement( + ephemeralKey: EphemeralKeyHolder, + recipientKey: JsonWebKey, + algorithm: JweAlgorithm + ) = platformCryptoShim.performKeyAgreement(ephemeralKey, recipientKey, algorithm) + + override fun performKeyAgreement(ephemeralKey: JsonWebKey, algorithm: JweAlgorithm) = + platformCryptoShim.performKeyAgreement(ephemeralKey, algorithm) + + override fun hmac(key: ByteArray, algorithm: JweEncryption, input: ByteArray): KmmResult = + platformCryptoShim.hmac(key, algorithm, input) + override fun messageDigest( input: ByteArray, digest: Digest - ): KmmResult - - override val keyPairAdapter: KeyPairAdapter - constructor(keyPairAdapter: KeyPairAdapter) + ) = digest.digest(input) } -expect class DefaultVerifierCryptoService() : VerifierCryptoService { - override val supportedAlgorithms: List +open class DefaultVerifierCryptoService : VerifierCryptoService { + override val supportedAlgorithms: List = + listOf(X509SignatureAlgorithm.ES256) + override fun verify( input: ByteArray, signature: CryptoSignature, algorithm: X509SignatureAlgorithm, publicKey: CryptoPublicKey - ): KmmResult + ): KmmResult = algorithm.algorithm.verifierFor(publicKey).transform { + it.verify(SignatureInput(input), signature) + } } diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Holder.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Holder.kt index 88c9eeaca..89d9b288f 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Holder.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Holder.kt @@ -5,11 +5,11 @@ import at.asitplus.jsonpath.core.NodeList import at.asitplus.jsonpath.core.NormalizedJsonPath import at.asitplus.wallet.lib.data.ConstantIndex import at.asitplus.wallet.lib.data.VerifiablePresentation -import at.asitplus.wallet.lib.data.dif.ConstraintField -import at.asitplus.wallet.lib.data.dif.FormatHolder -import at.asitplus.wallet.lib.data.dif.InputDescriptor -import at.asitplus.wallet.lib.data.dif.PresentationDefinition -import at.asitplus.wallet.lib.data.dif.PresentationSubmission +import at.asitplus.dif.ConstraintField +import at.asitplus.dif.FormatHolder +import at.asitplus.dif.InputDescriptor +import at.asitplus.dif.PresentationDefinition +import at.asitplus.dif.PresentationSubmission import at.asitplus.wallet.lib.iso.IssuerSigned import kotlinx.serialization.Serializable @@ -31,7 +31,7 @@ interface Holder { /** * The public key for this agent, i.e. the "holder key" that the credentials get bound to. */ - val keyPair: KeyPairAdapter + val keyPair: KeyMaterial /** * Sets the revocation list ot use for further processing of Verifiable Credentials diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/HolderAgent.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/HolderAgent.kt index e9b8fd6e8..924a781e8 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/HolderAgent.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/HolderAgent.kt @@ -3,20 +3,15 @@ package at.asitplus.wallet.lib.agent import at.asitplus.KmmResult import at.asitplus.KmmResult.Companion.wrap import at.asitplus.catching +import at.asitplus.dif.* +import at.asitplus.jsonpath.core.NormalizedJsonPath import at.asitplus.signum.indispensable.cosef.CoseKey import at.asitplus.signum.indispensable.cosef.toCoseKey import at.asitplus.signum.indispensable.pki.X509Certificate -import at.asitplus.jsonpath.core.NormalizedJsonPath import at.asitplus.wallet.lib.cbor.CoseService import at.asitplus.wallet.lib.cbor.DefaultCoseService import at.asitplus.wallet.lib.data.CredentialToJsonConverter -import at.asitplus.wallet.lib.data.dif.ClaimFormatEnum -import at.asitplus.wallet.lib.data.dif.FormatHolder -import at.asitplus.wallet.lib.data.dif.InputDescriptor import at.asitplus.wallet.lib.data.dif.InputEvaluator -import at.asitplus.wallet.lib.data.dif.PresentationDefinition -import at.asitplus.wallet.lib.data.dif.PresentationSubmission -import at.asitplus.wallet.lib.data.dif.PresentationSubmissionDescriptor import at.asitplus.wallet.lib.data.dif.PresentationSubmissionValidator import at.asitplus.wallet.lib.jws.DefaultJwsService import at.asitplus.wallet.lib.jws.JwsService @@ -29,11 +24,11 @@ import io.github.aakira.napier.Napier * and present credentials to other agents. */ class HolderAgent( - private val validator: Validator = Validator.newDefaultInstance(), + private val validator: Validator = Validator(), private val subjectCredentialStore: SubjectCredentialStore = InMemorySubjectCredentialStore(), private val jwsService: JwsService, private val coseService: CoseService, - override val keyPair: KeyPairAdapter, + override val keyPair: KeyMaterial, private val verifiablePresentationFactory: VerifiablePresentationFactory = VerifiablePresentationFactory( jwsService = jwsService, coseService = coseService, @@ -43,14 +38,14 @@ class HolderAgent( ) : Holder { constructor( - keyPairAdapter: KeyPairAdapter, + keyMaterial: KeyMaterial, subjectCredentialStore: SubjectCredentialStore = InMemorySubjectCredentialStore() ) : this( - validator = Validator.newDefaultInstance(DefaultVerifierCryptoService(), Parser()), + validator = Validator(), subjectCredentialStore = subjectCredentialStore, - jwsService = DefaultJwsService(DefaultCryptoService(keyPairAdapter)), - coseService = DefaultCoseService(DefaultCryptoService(keyPairAdapter)), - keyPair = keyPairAdapter + jwsService = DefaultJwsService(DefaultCryptoService(keyMaterial)), + coseService = DefaultCoseService(DefaultCryptoService(keyMaterial)), + keyPair = keyMaterial ) /** @@ -141,17 +136,18 @@ class HolderAgent( /** * Gets a list of all valid stored credentials sorted by preference */ - private suspend fun getValidCredentialsByPriority() = getCredentials()?.filter { - it.status != Validator.RevocationStatus.REVOKED - }?.map { it.storeEntry }?.sortedBy { - // prefer iso credentials and sd jwt credentials over plain vc credentials - // -> they support selective disclosure! - when (it) { - is SubjectCredentialStore.StoreEntry.Vc -> 2 - is SubjectCredentialStore.StoreEntry.SdJwt -> 1 - is SubjectCredentialStore.StoreEntry.Iso -> 1 + private suspend fun getValidCredentialsByPriority() = getCredentials() + ?.filter { it.status != Validator.RevocationStatus.REVOKED } + ?.map { it.storeEntry } + ?.sortedBy { + // prefer iso credentials and sd jwt credentials over plain vc credentials + // -> they support selective disclosure! + when (it) { + is SubjectCredentialStore.StoreEntry.Vc -> 2 + is SubjectCredentialStore.StoreEntry.SdJwt -> 1 + is SubjectCredentialStore.StoreEntry.Iso -> 1 + } } - } override suspend fun createPresentation( diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Issuer.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Issuer.kt index 2418c7c72..f0e08e186 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Issuer.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Issuer.kt @@ -2,7 +2,7 @@ package at.asitplus.wallet.lib.agent import at.asitplus.KmmResult import at.asitplus.signum.indispensable.CryptoPublicKey -import at.asitplus.signum.indispensable.X509SignatureAlgorithm +import at.asitplus.signum.indispensable.SignatureAlgorithm import at.asitplus.wallet.lib.data.ConstantIndex import at.asitplus.wallet.lib.iso.IssuerSigned import kotlinx.datetime.Instant @@ -48,13 +48,13 @@ interface Issuer { /** * The public key for this agent, i.e. the public part of the key that signs issued credentials. */ - val keyPair: KeyPairAdapter + val keyMaterial: KeyMaterial /** * The cryptographic algorithms supported by this issuer, i.e. the ones from its cryptographic service, * used to sign credentials. */ - val cryptoAlgorithms: Set + val cryptoAlgorithms: Set /** * Issues credentials for some [credentialScheme] to the subject specified with its public diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt index 0a0af5a26..9a33c2878 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt @@ -3,7 +3,7 @@ package at.asitplus.wallet.lib.agent import at.asitplus.KmmResult import at.asitplus.catching import at.asitplus.signum.indispensable.CryptoPublicKey -import at.asitplus.signum.indispensable.X509SignatureAlgorithm +import at.asitplus.signum.indispensable.SignatureAlgorithm import at.asitplus.signum.indispensable.cosef.toCoseKey import at.asitplus.signum.indispensable.io.Base64Strict import at.asitplus.signum.indispensable.io.BitSet @@ -13,22 +13,10 @@ import at.asitplus.wallet.lib.DefaultZlibService import at.asitplus.wallet.lib.ZlibService import at.asitplus.wallet.lib.cbor.CoseService import at.asitplus.wallet.lib.cbor.DefaultCoseService -import at.asitplus.wallet.lib.data.ConstantIndex -import at.asitplus.wallet.lib.data.CredentialStatus -import at.asitplus.wallet.lib.data.RevocationListSubject -import at.asitplus.wallet.lib.data.SelectiveDisclosureItem +import at.asitplus.wallet.lib.data.* import at.asitplus.wallet.lib.data.SelectiveDisclosureItem.Companion.hashDisclosure import at.asitplus.wallet.lib.data.VcDataModelConstants.REVOCATION_LIST_MIN_SIZE -import at.asitplus.wallet.lib.data.VerifiableCredential -import at.asitplus.wallet.lib.data.VerifiableCredentialJws -import at.asitplus.wallet.lib.data.VerifiableCredentialSdJwt -import at.asitplus.wallet.lib.iso.DeviceKeyInfo -import at.asitplus.wallet.lib.iso.IssuerSigned -import at.asitplus.wallet.lib.iso.IssuerSignedList -import at.asitplus.wallet.lib.iso.MobileSecurityObject -import at.asitplus.wallet.lib.iso.ValidityInfo -import at.asitplus.wallet.lib.iso.ValueDigest -import at.asitplus.wallet.lib.iso.ValueDigestList +import at.asitplus.wallet.lib.iso.* import at.asitplus.wallet.lib.jws.DefaultJwsService import at.asitplus.wallet.lib.jws.JwsContentTypeConstants import at.asitplus.wallet.lib.jws.JwsService @@ -54,35 +42,35 @@ class IssuerAgent( private val jwsService: JwsService, private val coseService: CoseService, private val clock: Clock = Clock.System, - override val keyPair: KeyPairAdapter, - override val cryptoAlgorithms: Set, + override val keyMaterial: KeyMaterial, + override val cryptoAlgorithms: Set = setOf(keyMaterial.signatureAlgorithm), private val timePeriodProvider: TimePeriodProvider = FixedTimePeriodProvider, ) : Issuer { constructor( - keyPairAdapter: KeyPairAdapter = RandomKeyPairAdapter(), + keyMaterial: KeyMaterial = EphemeralKeyWithoutCert(), dataProvider: IssuerCredentialDataProvider = EmptyCredentialDataProvider, ) : this( - validator = Validator.newDefaultInstance(), - jwsService = DefaultJwsService(DefaultCryptoService(keyPairAdapter)), - coseService = DefaultCoseService(DefaultCryptoService(keyPairAdapter)), + validator = Validator(), + jwsService = DefaultJwsService(DefaultCryptoService(keyMaterial)), + coseService = DefaultCoseService(DefaultCryptoService(keyMaterial)), dataProvider = dataProvider, - keyPair = keyPairAdapter, - cryptoAlgorithms = setOf(keyPairAdapter.signingAlgorithm), + keyMaterial = keyMaterial, + cryptoAlgorithms = setOf(keyMaterial.signatureAlgorithm), ) constructor( - keyPairAdapter: KeyPairAdapter = RandomKeyPairAdapter(), + keyMaterial: KeyMaterial = EphemeralKeyWithoutCert(), issuerCredentialStore: IssuerCredentialStore = InMemoryIssuerCredentialStore(), dataProvider: IssuerCredentialDataProvider = EmptyCredentialDataProvider, ) : this( - validator = Validator.newDefaultInstance(), + validator = Validator(), issuerCredentialStore = issuerCredentialStore, - jwsService = DefaultJwsService(DefaultCryptoService(keyPairAdapter)), - coseService = DefaultCoseService(DefaultCryptoService(keyPairAdapter)), + jwsService = DefaultJwsService(DefaultCryptoService(keyMaterial)), + coseService = DefaultCoseService(DefaultCryptoService(keyMaterial)), dataProvider = dataProvider, - keyPair = keyPairAdapter, - cryptoAlgorithms = setOf(keyPairAdapter.signingAlgorithm), + keyMaterial = keyMaterial, + cryptoAlgorithms = setOf(keyMaterial.signatureAlgorithm), ) /** @@ -146,7 +134,7 @@ class IssuerAgent( digestAlgorithm = "SHA-256", valueDigests = mapOf( scheme.isoNamespace!! to ValueDigestList(credential.issuerSignedItems.map { - ValueDigest.fromIssuerSigned(it) + ValueDigest.fromIssuerSignedItem(it, scheme.isoNamespace!!) }) ), deviceKeyInfo = deviceKeyInfo, @@ -157,15 +145,13 @@ class IssuerAgent( validUntil = expirationDate, ) ) - val issuerSigned = IssuerSigned( - namespaces = mapOf( - scheme.isoNamespace!! to IssuerSignedList.withItems(credential.issuerSignedItems) - ), + val issuerSigned = IssuerSigned.fromIssuerSignedItems( + namespacedItems = mapOf(scheme.isoNamespace!! to credential.issuerSignedItems), issuerAuth = coseService.createSignedCose( payload = mso.serializeForIssuerAuth(), addKeyId = false, addCertificate = true, - ).getOrThrow() + ).getOrThrow(), ) return Issuer.IssuedCredential.Iso(issuerSigned, scheme) } @@ -190,7 +176,7 @@ class IssuerAgent( val credentialStatus = CredentialStatus(getRevocationListUrlFor(timePeriod), statusListIndex) val vc = VerifiableCredential( id = vcId, - issuer = keyPair.identifier, + issuer = keyMaterial.identifier, issuanceDate = issuanceDate, expirationDate = expirationDate, credentialStatus = credentialStatus, @@ -230,7 +216,7 @@ class IssuerAgent( val jwsPayload = VerifiableCredentialSdJwt( subject = subjectId, notBefore = issuanceDate, - issuer = keyPair.identifier, + issuer = keyMaterial.identifier, expiration = expirationDate, issuedAt = issuanceDate, jwtId = vcId, @@ -260,7 +246,7 @@ class IssuerAgent( val subject = RevocationListSubject("$revocationListUrl#list", revocationList) val credential = VerifiableCredential( id = revocationListUrl, - issuer = keyPair.identifier, + issuer = keyMaterial.identifier, issuanceDate = clock.now(), lifetime = revocationListLifetime, credentialSubject = subject diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/KeyMaterial.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/KeyMaterial.kt new file mode 100644 index 000000000..4fd7df26d --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/KeyMaterial.kt @@ -0,0 +1,108 @@ +package at.asitplus.wallet.lib.agent + +import at.asitplus.signum.indispensable.Digest +import at.asitplus.signum.indispensable.ECCurve +import at.asitplus.signum.indispensable.josef.JsonWebKey +import at.asitplus.signum.indispensable.josef.toJsonWebKey +import at.asitplus.signum.indispensable.nativeDigest +import at.asitplus.signum.indispensable.pki.X509Certificate +import at.asitplus.signum.indispensable.pki.X509CertificateExtension +import at.asitplus.signum.indispensable.toX509SignatureAlgorithm +import at.asitplus.signum.supreme.asKmmResult +import at.asitplus.signum.supreme.sign.EphemeralKey +import at.asitplus.signum.supreme.sign.Signer +import io.github.aakira.napier.Napier +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Abstracts the management of key material away from [CryptoService]. + */ +interface KeyMaterial : Signer { + val identifier: String + + fun getUnderLyingSigner(): Signer + + /** + * May be used in [at.asitplus.wallet.lib.cbor.CoseService] to transport the signing key for a COSE structure. + * a `null` value signifies that raw public keys are used and no certificate is present + */ + suspend fun getCertificate(): X509Certificate? + + val jsonWebKey: JsonWebKey get() = publicKey.toJsonWebKey(identifier) +} + +abstract class KeyWithSelfSignedCert( + private val extensions: List +) : KeyMaterial { + override val identifier: String get() = publicKey.didEncoded + private val crtMut = Mutex() + private var _certificate: X509Certificate? = null + + override suspend fun getCertificate(): X509Certificate? { + crtMut.withLock { + if (_certificate == null) _certificate = X509Certificate.generateSelfSignedCertificate( + publicKey, + signatureAlgorithm.toX509SignatureAlgorithm().getOrThrow(), + extensions + ) { + sign(it).asKmmResult() + }.onFailure { Napier.e("Could not self-sign Cert", it) }.getOrNull() + } + return _certificate + } +} + +/** + * Generate a new key pair adapter with a random key, e.g. used in tests + */ +class EphemeralKeyWithSelfSignedCert( + val key: EphemeralKey = EphemeralKey { + ec { + curve = ECCurve.SECP_256_R_1 + digests = setOf(Digest.SHA256) + } + }.getOrThrow(), extensions: List = listOf() +) : KeyWithSelfSignedCert(extensions), Signer by key.signer().getOrThrow() { + override fun getUnderLyingSigner(): Signer = key.signer().getOrThrow() +} + +/** + * Generate a new key pair adapter with a random key, e.g. used in tests + */ +class EphemeralKeyWithoutCert( + val key: EphemeralKey = EphemeralKey { + ec { + curve = ECCurve.SECP_256_R_1 + digests = setOf(Digest.SHA256) + } + }.getOrThrow() +) : KeyMaterial, Signer by key.signer().getOrThrow() { + override val identifier: String = publicKey.didEncoded + override fun getUnderLyingSigner(): Signer = key.signer().getOrThrow() + override suspend fun getCertificate(): X509Certificate? = null +} + +interface EphemeralKeyHolder { + val publicJsonWebKey: JsonWebKey? + val key: EphemeralKey +} + +open class DefaultEphemeralKeyHolder(val crv: ECCurve) : EphemeralKeyHolder { + override val key: EphemeralKey = EphemeralKey { + ec { + curve = crv + digests = setOf(crv.nativeDigest) + } + }.getOrThrow() + + override val publicJsonWebKey: JsonWebKey? + get() = key.publicKey.toJsonWebKey() + +} + +abstract class SignerBasedKeyMaterial(val signer: Signer): KeyMaterial, Signer by signer{ + override val identifier = signer.publicKey.didEncoded + + override fun getUnderLyingSigner() = signer +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/KeyPairAdapter.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/KeyPairAdapter.kt deleted file mode 100644 index c02886450..000000000 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/KeyPairAdapter.kt +++ /dev/null @@ -1,30 +0,0 @@ -package at.asitplus.wallet.lib.agent - -import at.asitplus.signum.indispensable.CryptoPublicKey -import at.asitplus.signum.indispensable.X509SignatureAlgorithm -import at.asitplus.signum.indispensable.cosef.CoseKey -import at.asitplus.signum.indispensable.josef.JsonWebKey -import at.asitplus.signum.indispensable.pki.X509Certificate -import at.asitplus.signum.indispensable.pki.X509CertificateExtension - -/** - * Abstracts the management of key material away from [CryptoService]. - */ -interface KeyPairAdapter { - val publicKey: CryptoPublicKey - val identifier: String - val signingAlgorithm: X509SignatureAlgorithm - - /** - * May be used in [at.asitplus.wallet.lib.cbor.CoseService] to transport the signing key for a COSE structure. - * a `null` value signifies that raw public keys are used and no certificate is present - */ - val certificate: X509Certificate? - val jsonWebKey: JsonWebKey - val coseKey: CoseKey -} - -/** - * Generate a new key pair adapter with a random key, e.g. used in tests - */ -expect fun RandomKeyPairAdapter(extensions: List = listOf()): KeyPairAdapter diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Parser.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Parser.kt index 2e77db616..3446afa9b 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Parser.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Parser.kt @@ -36,7 +36,7 @@ class Parser( */ fun parseVpJws(input: String, challenge: String, publicKey: CryptoPublicKey): ParseVpResult { Napier.d("Parsing VP $input") - val jws = JwsSigned.parse(input).getOrNull() ?: return ParseVpResult.InvalidStructure(input) + val jws = JwsSigned.deserialize(input).getOrNull() ?: return ParseVpResult.InvalidStructure(input) .also { Napier.w("Could not parse JWS") } val payload = jws.payload.decodeToString() val kid = jws.header.keyId diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Validator.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Validator.kt index 63ef7aafe..df1660147 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Validator.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Validator.kt @@ -2,6 +2,7 @@ package at.asitplus.wallet.lib.agent import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.signum.indispensable.cosef.CoseKey +import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper import at.asitplus.signum.indispensable.cosef.toCoseKey import at.asitplus.signum.indispensable.equalsCryptographically import at.asitplus.signum.indispensable.io.Base64Strict @@ -23,7 +24,6 @@ import io.github.aakira.napier.Napier import io.matthewnelson.encoding.base16.Base16 import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArrayOrNull import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString -import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper /** @@ -38,21 +38,16 @@ class Validator( private val zlibService: ZlibService = DefaultZlibService(), ) { - companion object { - fun newDefaultInstance( - cryptoService: VerifierCryptoService, - parser: Parser = Parser() - ) = Validator( - verifierJwsService = DefaultVerifierJwsService(cryptoService = cryptoService), - verifierCoseService = DefaultVerifierCoseService(cryptoService = cryptoService), - parser = parser - ) - - /** - * Explicitly empty argument list to use it in Swift - */ - fun newDefaultInstance() = Validator() - } + constructor( + cryptoService: VerifierCryptoService, + parser: Parser = Parser(), + zlibService: ZlibService = DefaultZlibService(), + ) : this( + verifierJwsService = DefaultVerifierJwsService(cryptoService = cryptoService), + verifierCoseService = DefaultVerifierCoseService(cryptoService = cryptoService), + parser = parser, + zlibService = zlibService + ) private var revocationList: BitSet? = null @@ -64,7 +59,7 @@ class Validator( */ fun setRevocationList(it: String): Boolean { Napier.d("setRevocationList: Loading $it") - val jws = JwsSigned.parse(it).getOrNull() + val jws = JwsSigned.deserialize(it).getOrNull() ?: return false .also { Napier.w("Revocation List: Could not parse JWS") } if (!verifierJwsService.verifyJwsObject(jws)) @@ -154,7 +149,7 @@ class Validator( publicKey: CryptoPublicKey ): Verifier.VerifyPresentationResult { Napier.d("Verifying VP $input") - val jws = JwsSigned.parse(input).getOrNull() + val jws = JwsSigned.deserialize(input).getOrNull() ?: return Verifier.VerifyPresentationResult.InvalidStructure(input) .also { Napier.w("VP: Could not parse JWS") } if (!verifierJwsService.verifyJwsObject(jws)) @@ -262,12 +257,12 @@ class Validator( } ?: return Verifier.VerifyPresentationResult.InvalidStructure(docSerialized) .also { Napier.w("Got no issuer key in $issuerAuth") } - if (verifierCoseService.verifyCose(issuerAuth, issuerKey).getOrNull() != true) { + if (verifierCoseService.verifyCose(issuerAuth, issuerKey).isFailure) { return Verifier.VerifyPresentationResult.InvalidStructure(docSerialized) .also { Napier.w("IssuerAuth not verified: $issuerAuth") } } - val mso = issuerSigned.getIssuerAuthPayloadAsMso() + val mso = issuerSigned.getIssuerAuthPayloadAsMso().getOrNull() ?: return Verifier.VerifyPresentationResult.InvalidStructure(docSerialized) .also { Napier.w("MSO is null: ${issuerAuth.payload?.encodeToString(Base16(strict = true))}") } if (mso.docType != doc.docType) { @@ -279,7 +274,7 @@ class Validator( ?: return Verifier.VerifyPresentationResult.InvalidStructure(docSerialized) .also { Napier.w("DeviceSignature is null: ${doc.deviceSigned.deviceAuth}") } - if (verifierCoseService.verifyCose(deviceSignature, walletKey).getOrNull() != true) { + if (verifierCoseService.verifyCose(deviceSignature, walletKey).isFailure) { return Verifier.VerifyPresentationResult.InvalidStructure(docSerialized) .also { Napier.w("DeviceSignature not verified") } } @@ -304,17 +299,20 @@ class Validator( } } return Verifier.VerifyPresentationResult.SuccessIso( - IsoDocumentParsed(validItems = validItems, invalidItems = invalidItems) + IsoDocumentParsed(mso = mso, validItems = validItems, invalidItems = invalidItems) ) } + /** + * Verify that calculated digests equal the corresponding digest values in the MSO. + * + * See ISO/IEC 18013-5:2021, 9.3.1 Inspection procedure for issuer data authentication + */ private fun ByteStringWrapper.verify(mdlItems: ValueDigestList?): Boolean { - val issuerHash = mdlItems?.entries?.firstOrNull { it.key == value.digestId } ?: return false - // TODO analyze usages of tag wrapping + val issuerHash = mdlItems?.entries?.firstOrNull { it.key == value.digestId } + ?: return false val verifierHash = serialized.wrapInCborTag(24).sha256() - if (!verifierHash.encodeToString(Base16(strict = true)) - .contentEquals(issuerHash.value.encodeToString(Base16(strict = true))) - ) { + if (!verifierHash.contentEquals(issuerHash.value)) { Napier.w("Could not verify hash of value for ${value.elementIdentifier}") return false } @@ -329,7 +327,7 @@ class Validator( */ fun verifyVcJws(input: String, publicKey: CryptoPublicKey?): Verifier.VerifyCredentialResult { Napier.d("Verifying VC-JWS $input") - val jws = JwsSigned.parse(input).getOrNull() + val jws = JwsSigned.deserialize(input).getOrNull() ?: return Verifier.VerifyCredentialResult.InvalidStructure(input) .also { Napier.w("VC: Could not parse JWS") } if (!verifierJwsService.verifyJwsObject(jws)) @@ -427,7 +425,7 @@ class Validator( ) } val result = verifierCoseService.verifyCose(it.issuerAuth, issuerKey) - if (result.getOrNull() != true) { + if (result.isFailure) { Napier.w("ISO: Could not verify credential", result.exceptionOrNull()) return Verifier.VerifyCredentialResult.InvalidStructure( it.serialize().encodeToString(Base16(strict = true)) diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/VerifiablePresentationFactory.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/VerifiablePresentationFactory.kt index e8609277d..1946a6548 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/VerifiablePresentationFactory.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/VerifiablePresentationFactory.kt @@ -2,20 +2,16 @@ package at.asitplus.wallet.lib.agent import at.asitplus.KmmResult import at.asitplus.KmmResult.Companion.wrap -import at.asitplus.signum.indispensable.josef.JwsHeader -import at.asitplus.signum.indispensable.josef.JwsSigned import at.asitplus.jsonpath.core.NormalizedJsonPath import at.asitplus.jsonpath.core.NormalizedJsonPathSegment +import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper +import at.asitplus.signum.indispensable.josef.JwsHeader +import at.asitplus.signum.indispensable.josef.JwsSigned import at.asitplus.wallet.lib.cbor.CoseService import at.asitplus.wallet.lib.data.KeyBindingJws import at.asitplus.wallet.lib.data.SelectiveDisclosureItem import at.asitplus.wallet.lib.data.VerifiablePresentation -import at.asitplus.wallet.lib.iso.DeviceAuth -import at.asitplus.wallet.lib.iso.DeviceSigned -import at.asitplus.wallet.lib.iso.Document -import at.asitplus.wallet.lib.iso.IssuerSigned -import at.asitplus.wallet.lib.iso.IssuerSignedList -import at.asitplus.wallet.lib.iso.sha256 +import at.asitplus.wallet.lib.iso.* import at.asitplus.wallet.lib.jws.JwsContentTypeConstants import at.asitplus.wallet.lib.jws.JwsService import at.asitplus.wallet.lib.jws.SdJwtSigned @@ -94,22 +90,26 @@ class VerifiablePresentationFactory( val disclosedItems = namespaceToAttributesMap.mapValues { namespaceToAttributeNamesEntry -> val namespace = namespaceToAttributeNamesEntry.key val attributeNames = namespaceToAttributeNamesEntry.value - IssuerSignedList(attributeNames.map { attributeName -> + attributeNames.map { attributeName -> credential.issuerSigned.namespaces?.get( namespace )?.entries?.find { it.value.elementIdentifier == attributeName - } + }?.value ?: throw PresentationException("Attribute not available in credential: $['$namespace']['$attributeName']") - }) + } } return Holder.CreatePresentationResult.Document( Document( - docType = credential.scheme.isoDocType!!, issuerSigned = IssuerSigned( - namespaces = disclosedItems, issuerAuth = credential.issuerSigned.issuerAuth - ), deviceSigned = DeviceSigned( - namespaces = byteArrayOf(), deviceAuth = DeviceAuth( + docType = credential.scheme.isoDocType!!, + issuerSigned = IssuerSigned.fromIssuerSignedItems( + namespacedItems = disclosedItems, + issuerAuth = credential.issuerSigned.issuerAuth + ), + deviceSigned = DeviceSigned( + namespaces = ByteStringWrapper(DeviceNameSpaces(mapOf())), + deviceAuth = DeviceAuth( deviceSignature = deviceSignature ) ) @@ -140,7 +140,7 @@ class VerifiablePresentationFactory( val issuerJwtPlusDisclosures = SdJwtSigned.sdHashInput(validSdJwtCredential, filteredDisclosures) val keyBinding = createKeyBindingJws(audienceId, challenge, issuerJwtPlusDisclosures) - val jwsFromIssuer = JwsSigned.parse(validSdJwtCredential.vcSerialized.substringBefore("~")).getOrElse { + val jwsFromIssuer = JwsSigned.deserialize(validSdJwtCredential.vcSerialized.substringBefore("~")).getOrElse { Napier.w("Could not re-create JWS from stored SD-JWT", it) throw PresentationException(it) } @@ -199,4 +199,4 @@ class VerifiablePresentationFactory( throw PresentationException(it) }.serialize() ) -} \ No newline at end of file +} diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Verifier.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Verifier.kt index 6a53ddd16..692da5841 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Verifier.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Verifier.kt @@ -22,7 +22,7 @@ interface Verifier { /** * The public key for this agent, i.e. the one used to validate the audience of a VP against */ - val keyPair: KeyPairAdapter + val keyMaterial: KeyMaterial /** * Set the revocation list to use for validating VCs (from [Issuer.issueRevocationListCredential]) diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/VerifierAgent.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/VerifierAgent.kt index fc45616e6..3c224fb80 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/VerifierAgent.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/VerifierAgent.kt @@ -15,17 +15,17 @@ import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArrayOrNull */ class VerifierAgent private constructor( private val validator: Validator, - override val keyPair: KeyPairAdapter, + override val keyMaterial: KeyMaterial, ) : Verifier { - constructor(keyPairAdapter: KeyPairAdapter) : this( - validator = Validator.newDefaultInstance(), - keyPair = keyPairAdapter, + constructor(keyPairAdapter: KeyMaterial) : this( + validator = Validator(), + keyMaterial = keyPairAdapter, ) constructor(): this( - validator = Validator.newDefaultInstance(), - keyPair = RandomKeyPairAdapter(), + validator = Validator(), + keyMaterial = EphemeralKeyWithoutCert(), ) override fun setRevocationList(it: String): Boolean { @@ -38,11 +38,11 @@ class VerifierAgent private constructor( override fun verifyPresentation(it: String, challenge: String): Verifier.VerifyPresentationResult { val sdJwtSigned = runCatching { SdJwtSigned.parse(it) }.getOrNull() if (sdJwtSigned != null) { - return validator.verifyVpSdJwt(it, challenge, keyPair.publicKey) + return validator.verifyVpSdJwt(it, challenge, keyMaterial.publicKey) } - val jwsSigned = JwsSigned.parse(it).getOrNull() + val jwsSigned = JwsSigned.deserialize(it).getOrNull() if (jwsSigned != null) { - return validator.verifyVpJws(it, challenge, keyPair.publicKey) + return validator.verifyVpJws(it, challenge, keyMaterial.publicKey) } val document = it.decodeToByteArrayOrNull(Base16(strict = true)) ?.let { bytes -> Document.deserialize(bytes).getOrNull() } @@ -68,7 +68,7 @@ class VerifierAgent private constructor( } override fun verifyVcJws(it: String): Verifier.VerifyCredentialResult { - return validator.verifyVcJws(it, keyPair.publicKey) + return validator.verifyVcJws(it, keyMaterial.publicKey) } } diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/X509CertificateExtension.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/X509CertificateExtension.kt index d82192068..3f32ef37a 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/X509CertificateExtension.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/X509CertificateExtension.kt @@ -3,6 +3,7 @@ package at.asitplus.wallet.lib.agent import at.asitplus.KmmResult import at.asitplus.KmmResult.Companion.wrap +import at.asitplus.catching import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.signum.indispensable.CryptoSignature import at.asitplus.signum.indispensable.X509SignatureAlgorithm @@ -13,36 +14,55 @@ import at.asitplus.signum.indispensable.pki.RelativeDistinguishedName import at.asitplus.signum.indispensable.pki.TbsCertificate import at.asitplus.signum.indispensable.pki.X509Certificate import at.asitplus.signum.indispensable.pki.X509CertificateExtension -import kotlinx.coroutines.runBlocking +import io.github.aakira.napier.Napier import kotlinx.datetime.Clock import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.plus import kotlin.random.Random -fun X509Certificate.Companion.generateSelfSignedCertificate( +suspend fun X509Certificate.Companion.generateSelfSignedCertificate( publicKey: CryptoPublicKey, algorithm: X509SignatureAlgorithm, extensions: List = listOf(), signer: suspend (ByteArray) -> KmmResult, -): X509Certificate { +): KmmResult = catching { + Napier.d { "Generating self-signed Certificate" } val notBeforeDate = Clock.System.now() val notAfterDate = notBeforeDate.plus(30, DateTimeUnit.SECOND) val tbsCertificate = TbsCertificate( version = 2, serialNumber = Random.nextBytes(8), - issuerName = listOf(RelativeDistinguishedName(AttributeTypeAndValue.CommonName(Asn1String.UTF8("Default")))), + issuerName = listOf( + RelativeDistinguishedName( + AttributeTypeAndValue.CommonName( + Asn1String.UTF8( + "Default" + ) + ) + ) + ), validFrom = Asn1Time(notBeforeDate), validUntil = Asn1Time(notAfterDate), signatureAlgorithm = algorithm, - subjectName = listOf(RelativeDistinguishedName(AttributeTypeAndValue.CommonName(Asn1String.UTF8("Default")))), + subjectName = listOf( + RelativeDistinguishedName( + AttributeTypeAndValue.CommonName( + Asn1String.UTF8( + "Default" + ) + ) + ) + ), publicKey = publicKey, extensions = extensions ) - val signature = runBlocking { - runCatching { tbsCertificate.encodeToDer() } - .wrap() - .transform { signer(it) } - .getOrThrow() - } - return X509Certificate(tbsCertificate, algorithm, signature) + + signer(tbsCertificate.encodeToDer()).map { signature -> + X509Certificate( + tbsCertificate, + algorithm, + signature + ) + }.getOrThrow() + } \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseService.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseService.kt index 1523c36bf..6e576c951 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseService.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseService.kt @@ -14,6 +14,8 @@ import at.asitplus.wallet.lib.agent.DefaultVerifierCryptoService import at.asitplus.wallet.lib.agent.VerifierCryptoService import io.github.aakira.napier.Napier import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper +import at.asitplus.signum.supreme.asKmmResult +import at.asitplus.signum.supreme.sign.Verifier /** * Creates and parses COSE objects. @@ -44,7 +46,7 @@ interface CoseService { interface VerifierCoseService { - fun verifyCose(coseSigned: CoseSigned, signer: CoseKey): KmmResult + fun verifyCose(coseSigned: CoseSigned, signer: CoseKey): KmmResult } @@ -55,7 +57,7 @@ private const val SIGNATURE1_STRING = "Signature1" class DefaultCoseService(private val cryptoService: CryptoService) : CoseService { - override val algorithm: CoseAlgorithm = cryptoService.keyPairAdapter.signingAlgorithm.toCoseAlgorithm().getOrThrow() + override val algorithm: CoseAlgorithm = cryptoService.keyMaterial.signatureAlgorithm.toCoseAlgorithm().getOrThrow() override suspend fun createSignedCose( protectedHeader: CoseHeader?, @@ -67,11 +69,11 @@ class DefaultCoseService(private val cryptoService: CryptoService) : CoseService var copyProtectedHeader = protectedHeader?.copy(algorithm = algorithm) ?: CoseHeader(algorithm = algorithm) if (addKeyId) copyProtectedHeader = - copyProtectedHeader.copy(kid = cryptoService.keyPairAdapter.publicKey.didEncoded.encodeToByteArray()) + copyProtectedHeader.copy(kid = cryptoService.keyMaterial.publicKey.didEncoded.encodeToByteArray()) - val copyUnprotectedHeader = if (addCertificate && cryptoService.keyPairAdapter.certificate != null) { + val copyUnprotectedHeader = if (addCertificate && cryptoService.keyMaterial.getCertificate() != null) { (unprotectedHeader - ?: CoseHeader()).copy(certificateChain = cryptoService.keyPairAdapter.certificate!!.encodeToDer()) + ?: CoseHeader()).copy(certificateChain = cryptoService.keyMaterial.getCertificate()!!.encodeToDer()) } else { unprotectedHeader } @@ -83,7 +85,7 @@ class DefaultCoseService(private val cryptoService: CryptoService) : CoseService payload = payload, ).serialize() - val signature = cryptoService.sign(signatureInput).getOrElse { + val signature = cryptoService.sign(signatureInput).asKmmResult().getOrElse { Napier.w("No signature from native code", it) throw it } diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/ConstantIndex.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/ConstantIndex.kt index f9204cb47..46c95f86e 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/ConstantIndex.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/ConstantIndex.kt @@ -68,12 +68,21 @@ object ConstantIndex { } object AtomicAttribute2023 : CredentialScheme { + const val CLAIM_GIVEN_NAME = "given_name" + const val CLAIM_FAMILY_NAME = "family_name" + const val CLAIM_DATE_OF_BIRTH = "date_of_birth" + const val CLAIM_PORTRAIT = "portrait" override val schemaUri: String = "https://wallet.a-sit.at/schemas/1.0.0/AtomicAttribute2023.json" override val vcType: String = "AtomicAttribute2023" override val sdJwtType: String = "AtomicAttribute2023" override val isoNamespace: String = "at.a-sit.wallet.atomic-attribute-2023" override val isoDocType: String = "at.a-sit.wallet.atomic-attribute-2023.iso" - override val claimNames: Collection = listOf("given_name", "family_name", "date_of_birth") + override val claimNames: Collection = listOf( + CLAIM_GIVEN_NAME, + CLAIM_FAMILY_NAME, + CLAIM_DATE_OF_BIRTH, + CLAIM_PORTRAIT + ) } val CredentialScheme.supportsSdJwt diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialToJsonConverter.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialToJsonConverter.kt index 43365f5a3..9fe8b41ed 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialToJsonConverter.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialToJsonConverter.kt @@ -4,13 +4,7 @@ import at.asitplus.signum.indispensable.io.Base64Strict import at.asitplus.wallet.lib.agent.SubjectCredentialStore import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString import kotlinx.datetime.LocalDate -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonArray -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.encodeToJsonElement -import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.* object CredentialToJsonConverter { // in openid4vp, the claims to be presented are described using a JSONPath, so compiling this to a JsonElement seems reasonable @@ -31,14 +25,8 @@ object CredentialToJsonConverter { } is SubjectCredentialStore.StoreEntry.SdJwt -> { - val pairs = credential.disclosures.map { - it.value?.let { - it.claimName to when (val value = it.claimValue) { - is Boolean -> JsonPrimitive(value) - is Number -> JsonPrimitive(value) - else -> JsonPrimitive(it.claimValue.toString()) - } - } + val pairs = credential.disclosures.map { entry -> + entry.value?.let { it.claimName to it.claimValue } }.filterNotNull().toMap() buildJsonObject { put("vct", JsonPrimitive(credential.scheme.sdJwtType ?: credential.scheme.vcType)) diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/InstantLongSerializer.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/InstantLongSerializer.kt deleted file mode 100644 index 9ccbcc622..000000000 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/InstantLongSerializer.kt +++ /dev/null @@ -1,23 +0,0 @@ -package at.asitplus.wallet.lib.data - -import kotlinx.datetime.Instant -import kotlinx.serialization.KSerializer -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder - -class InstantLongSerializer : KSerializer { - - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("InstantLongSerializer", PrimitiveKind.LONG) - - override fun deserialize(decoder: Decoder): Instant { - return Instant.fromEpochSeconds(decoder.decodeLong()) - } - - override fun serialize(encoder: Encoder, value: Instant) { - encoder.encodeLong(value.epochSeconds) - } - -} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/IsoDocumentParsed.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/IsoDocumentParsed.kt index 5528cec69..396e45cc1 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/IsoDocumentParsed.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/IsoDocumentParsed.kt @@ -1,12 +1,14 @@ package at.asitplus.wallet.lib.data import at.asitplus.wallet.lib.iso.IssuerSignedItem +import at.asitplus.wallet.lib.iso.MobileSecurityObject /** * Intermediate class used by [at.asitplus.wallet.lib.agent.Validator.verifyDocument] when parsing an ISO document, * and also in [at.asitplus.wallet.lib.agent.VerifierAgent.verifyPresentation]. */ data class IsoDocumentParsed( + val mso: MobileSecurityObject, val validItems: List = listOf(), val invalidItems: List = listOf(), ) \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/Json.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/Json.kt index f61468a58..b49db5a41 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/Json.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/Json.kt @@ -26,9 +26,6 @@ internal object JsonCredentialSerializer { } -@Deprecated("use vckJsonSerializer", replaceWith = ReplaceWith("vckJsonSerializer")) -val jsonSerializer get() = vckJsonSerializer - val vckJsonSerializer by lazy { Json { prettyPrint = false diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/KeyBindingJws.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/KeyBindingJws.kt index a257ab4e4..2267a51e3 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/KeyBindingJws.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/KeyBindingJws.kt @@ -2,6 +2,7 @@ package at.asitplus.wallet.lib.data import at.asitplus.KmmResult.Companion.wrap import at.asitplus.signum.indispensable.io.ByteArrayBase64UrlSerializer +import at.asitplus.signum.indispensable.josef.io.InstantLongSerializer import kotlinx.datetime.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/SelectiveDisclosureItem.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/SelectiveDisclosureItem.kt index 8fbb3d98c..4a10b5f13 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/SelectiveDisclosureItem.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/SelectiveDisclosureItem.kt @@ -6,9 +6,10 @@ import at.asitplus.wallet.lib.iso.sha256 import at.asitplus.wallet.lib.jws.SelectiveDisclosureItemSerializer import io.matthewnelson.encoding.base64.Base64 import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString -import kotlinx.serialization.Contextual +import kotlinx.datetime.LocalDate import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.JsonPrimitive /** * Selective Disclosure item in SD-JWT format @@ -17,10 +18,12 @@ import kotlinx.serialization.encodeToString data class SelectiveDisclosureItem( val salt: ByteArray, val claimName: String, - @Contextual - val claimValue: Any, + val claimValue: JsonPrimitive, ) { + constructor(salt: ByteArray, claimName: String, claimValue: Any) + : this(salt, claimName, claimValue.toJsonPrimitive()) + fun serialize() = vckJsonSerializer.encodeToString(this) /** @@ -57,7 +60,6 @@ data class SelectiveDisclosureItem( ")" } - companion object { fun deserialize(it: String) = kotlin.runCatching { vckJsonSerializer.decodeFromString(it) @@ -70,4 +72,16 @@ data class SelectiveDisclosureItem( fun String.hashDisclosure() = encodeToByteArray().sha256().encodeToString(Base64UrlStrict) } +} +private fun Any.toJsonPrimitive() = when (this) { + is Boolean -> JsonPrimitive(this) + is Number -> JsonPrimitive(this) + is String -> JsonPrimitive(this) + is ByteArray -> JsonPrimitive(encodeToString(Base64UrlStrict)) + is LocalDate -> JsonPrimitive(this.toString()) + is UByte -> JsonPrimitive(this) + is UShort -> JsonPrimitive(this) + is UInt -> JsonPrimitive(this) + is ULong -> JsonPrimitive(this) + else -> JsonPrimitive(toString()) } \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/VerifiableCredentialJws.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/VerifiableCredentialJws.kt index 91ff0c74c..b95d32911 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/VerifiableCredentialJws.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/VerifiableCredentialJws.kt @@ -2,6 +2,7 @@ package at.asitplus.wallet.lib.data import at.asitplus.KmmResult.Companion.wrap import kotlinx.datetime.Instant +import at.asitplus.signum.indispensable.josef.io.InstantLongSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/InputDescriptor.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/InputDescriptor.kt deleted file mode 100644 index 8ae6f96b2..000000000 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/InputDescriptor.kt +++ /dev/null @@ -1,34 +0,0 @@ -package at.asitplus.wallet.lib.data.dif - -import com.benasher44.uuid.uuid4 -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * Data class for - * [DIF Presentation Exchange v1.0.0](https://identity.foundation/presentation-exchange/spec/v1.0.0/#presentation-definition) - */ -@Serializable -data class InputDescriptor( - @SerialName("id") - val id: String, - @SerialName("group") - val group: String? = null, - @SerialName("name") - val name: String? = null, - @SerialName("purpose") - val purpose: String? = null, - @SerialName("format") - val format: FormatHolder? = null, - @SerialName("schema") - val schema: Collection? = null, - @SerialName("constraints") - val constraints: Constraint? = null, -) { - constructor(name: String, schema: SchemaReference, constraints: Constraint? = null) : this( - id = uuid4().toString(), - name = name, - schema = listOf(schema), - constraints = constraints, - ) -} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/InputEvaluator.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/InputEvaluator.kt index 2fe095a22..94e0b001d 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/InputEvaluator.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/InputEvaluator.kt @@ -2,18 +2,15 @@ package at.asitplus.wallet.lib.data.dif import at.asitplus.KmmResult import at.asitplus.KmmResult.Companion.wrap +import at.asitplus.dif.ConstraintField +import at.asitplus.dif.ConstraintFilter +import at.asitplus.dif.InputDescriptor +import at.asitplus.dif.RequirementEnum import at.asitplus.jsonpath.JsonPath import at.asitplus.jsonpath.core.NodeList import at.asitplus.jsonpath.core.NormalizedJsonPath import io.github.aakira.napier.Napier -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.booleanOrNull -import kotlinx.serialization.json.doubleOrNull -import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.* /** * Specification: https://identity.foundation/presentation-exchange/spec/v2.0.0/#input-evaluation @@ -61,7 +58,8 @@ internal fun JsonElement.satisfiesConstraintFilter(filter: ConstraintFilter): Bo // source: https://json-schema.org/draft-07/schema# // this currently is only a tentative implementation val typeMatchesElement = when (this) { - is JsonArray -> filter.type == "array" // TODO: need recursive type check; Need element count check (minItems = 1) for root, need check for unique items at root (whatever that means) + // TODO: need recursive type check; Need element count check (minItems = 1) for root, need check for unique items at root (whatever that means) + is JsonArray -> filter.type == "array" is JsonObject -> filter.type == "object" is JsonPrimitive -> when (filter.type) { "string" -> this.isString diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/PresentationSubmissionValidator.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/PresentationSubmissionValidator.kt index 221f1f1ed..2de71411c 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/PresentationSubmissionValidator.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/PresentationSubmissionValidator.kt @@ -1,8 +1,10 @@ package at.asitplus.wallet.lib.data.dif import at.asitplus.KmmResult -import at.asitplus.KmmResult.Companion.wrap import at.asitplus.catching +import at.asitplus.dif.InputDescriptor +import at.asitplus.dif.PresentationDefinition +import at.asitplus.dif.SubmissionRequirement import kotlinx.serialization.Serializable @Serializable diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceAuth.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceAuth.kt new file mode 100644 index 000000000..b658efc6d --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceAuth.kt @@ -0,0 +1,16 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.signum.indispensable.cosef.CoseSigned +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) + */ +@Serializable +data class DeviceAuth( + @SerialName("deviceSignature") + val deviceSignature: CoseSigned? = null, + @SerialName("deviceMac") + val deviceMac: CoseSigned? = null, // TODO is COSE_Mac0 +) \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceKeyInfo.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceKeyInfo.kt new file mode 100644 index 000000000..f0453f132 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceKeyInfo.kt @@ -0,0 +1,30 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.KmmResult.Companion.wrap +import at.asitplus.signum.indispensable.cosef.CoseKey +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray + +/** + * Part of the ISO/IEC 18013-5:2021 standard: Data structure for MSO (9.1.2.4) + */ +@Serializable +data class DeviceKeyInfo( + @SerialName("deviceKey") + val deviceKey: CoseKey, + @SerialName("keyAuthorizations") + val keyAuthorizations: KeyAuthorization? = null, + @SerialName("keyInfo") + val keyInfo: Map? = null, +) { + + fun serialize() = vckCborSerializer.encodeToByteArray(this) + + companion object { + fun deserialize(it: ByteArray) = kotlin.runCatching { + vckCborSerializer.decodeFromByteArray(it) + }.wrap() + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceNameSpaces.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceNameSpaces.kt new file mode 100644 index 000000000..d69a5f8a9 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceNameSpaces.kt @@ -0,0 +1,120 @@ +package at.asitplus.wallet.lib.iso + +import kotlinx.serialization.* +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.* + +/** + * Convenience class with a custom serializer ([DeviceNameSpacesSerializer]) to prevent + * usage of the type `ByteStringWrapper>>` in [DeviceSigned.namespaces]. + */ +@Serializable(with = DeviceNameSpacesSerializer::class) +data class DeviceNameSpaces( + val entries: Map +) + +/** + * Serializes [DeviceNameSpaces.entries] as a map with an "inline list", + * having the usual key as key, + * but serialized instances of [DeviceSignedItemList] as the values. + */ +object DeviceNameSpacesSerializer : KSerializer { + + override val descriptor: SerialDescriptor = mapSerialDescriptor( + PrimitiveSerialDescriptor("key", PrimitiveKind.STRING), + DeviceSignedItemListSerializer.descriptor, + ) + + override fun serialize(encoder: Encoder, value: DeviceNameSpaces) { + encoder.encodeStructure(descriptor) { + var index = 0 + value.entries.forEach { + encodeStringElement(descriptor, index++, it.key) + encodeSerializableElement(descriptor, index++, DeviceSignedItemList.serializer(), it.value) + } + } + } + + override fun deserialize(decoder: Decoder): DeviceNameSpaces { + val entries = mutableMapOf() + decoder.decodeStructure(descriptor) { + lateinit var key: String + var value: DeviceSignedItemList + while (true) { + val index = decodeElementIndex(descriptor) + if (index == CompositeDecoder.DECODE_DONE) { + break + } else if (index % 2 == 0) { + key = decodeStringElement(descriptor, index) + } else if (index % 2 == 1) { + value = decodeSerializableElement(descriptor, index, DeviceSignedItemList.serializer()) + entries[key] = value + } + } + } + return DeviceNameSpaces(entries) + } +} + + +/** + * Convenience class with a custom serializer ([DeviceSignedItemListSerializer]) to prevent + * usage of the type `Map>` in [DeviceNameSpaces.entries]. + */ +@Serializable(with = DeviceSignedItemListSerializer::class) +data class DeviceSignedItemList( + val entries: List +) + +/** + * Serializes [DeviceSignedItemList.entries] as an "inline list", + * having serialized instances of [DeviceSignedItem] as the values. + */ +object DeviceSignedItemListSerializer : KSerializer { + + override val descriptor: SerialDescriptor = mapSerialDescriptor( + PrimitiveSerialDescriptor("key", PrimitiveKind.STRING), + PrimitiveSerialDescriptor("value", PrimitiveKind.STRING) // TODO Change to `Any` + ) + + override fun serialize(encoder: Encoder, value: DeviceSignedItemList) { + encoder.encodeStructure(descriptor) { + var index = 0 + value.entries.forEach { + this.encodeStringElement(descriptor, index++, it.key) + this.encodeStringElement(descriptor, index++, it.value) + } + } + } + + override fun deserialize(decoder: Decoder): DeviceSignedItemList { + val entries = mutableListOf() + decoder.decodeStructure(descriptor) { + lateinit var key: String + var value: String + while (true) { + val index = decodeElementIndex(descriptor) + if (index == CompositeDecoder.DECODE_DONE) { + break + } else if (index % 2 == 0) { + key = decodeStringElement(descriptor, index) + } else if (index % 2 == 1) { + value = decodeStringElement(descriptor, index) + entries += DeviceSignedItem(key, value) + } + } + } + return DeviceSignedItemList(entries) + } +} + + +/** + * Convenience class (getting serialized in [DeviceSignedItemListSerializer]) to prevent + * usage of the type `List>` in [DeviceSignedItemList.entries]. + */ +data class DeviceSignedItem( + val key: String, + // TODO Make this `Any`, but based on the credential serializer + val value: String, +) diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceRequest.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceRequest.kt index 5d2ed652a..3229a4d58 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceRequest.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceRequest.kt @@ -3,34 +3,7 @@ package at.asitplus.wallet.lib.iso import at.asitplus.KmmResult.Companion.wrap -import at.asitplus.signum.indispensable.cosef.CoseSigned -import io.matthewnelson.encoding.base16.Base16 -import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.KSerializer -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.builtins.ByteArraySerializer -import kotlinx.serialization.builtins.serializer -import kotlinx.serialization.cbor.ByteString -import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper -import kotlinx.serialization.cbor.ValueTags -import kotlinx.serialization.decodeFromByteArray -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.descriptors.SerialKind -import kotlinx.serialization.descriptors.StructureKind -import kotlinx.serialization.descriptors.listSerialDescriptor -import kotlinx.serialization.descriptors.mapSerialDescriptor -import kotlinx.serialization.encodeToByteArray -import kotlinx.serialization.encoding.CompositeDecoder -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.encoding.decodeStructure -import kotlinx.serialization.encoding.encodeCollection -import kotlinx.serialization.encoding.encodeStructure -import okio.ByteString.Companion.toByteString +import kotlinx.serialization.* /** * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) @@ -68,404 +41,3 @@ data class DeviceRequest( } } -/** - * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) - */ -@Serializable -data class DocRequest( - @SerialName("itemsRequest") - @Serializable(with = ByteStringWrapperItemsRequestSerializer::class) - @ValueTags(24U) - val itemsRequest: ByteStringWrapper, - @SerialName("readerAuth") - val readerAuth: CoseSigned? = null, -) { - override fun toString(): String { - return "DocRequest(itemsRequest=${itemsRequest.value}, readerAuth=$readerAuth)" - } - -} - -/** - * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) - */ -@Serializable -data class ItemsRequest( - @SerialName("docType") - val docType: String, - @SerialName("nameSpaces") - val namespaces: Map, - @SerialName("requestInfo") - val requestInfo: Map? = null, -) - - -/** - * Convenience class with a custom serializer ([ItemsRequestListSerializer]) to prevent - * usage of the type `Map>` in [ItemsRequest.namespaces]. - */ -@Serializable(with = ItemsRequestListSerializer::class) -data class ItemsRequestList( - val entries: List -) - -/** - * Convenience class with a custom serializer ([ItemsRequestListSerializer]) to prevent - * usage of the type `Map>` in [ItemsRequest.namespaces]. - */ -data class SingleItemsRequest( - val key: String, - val value: Boolean, -) - -/** - * Serializes [ItemsRequestList.entries] as an "inline map", - * having [SingleItemsRequest.key] as the map key and [SingleItemsRequest.value] as the map value, - * for the map represented by [ItemsRequestList]. - */ -object ItemsRequestListSerializer : KSerializer { - - override val descriptor: SerialDescriptor = mapSerialDescriptor( - keyDescriptor = PrimitiveSerialDescriptor("key", PrimitiveKind.INT), - valueDescriptor = listSerialDescriptor(), - ) - - override fun serialize(encoder: Encoder, value: ItemsRequestList) { - encoder.encodeStructure(descriptor) { - var index = 0 - value.entries.forEach { - this.encodeStringElement(descriptor, index++, it.key) - this.encodeBooleanElement(descriptor, index++, it.value) - } - } - } - - override fun deserialize(decoder: Decoder): ItemsRequestList { - val entries = mutableListOf() - decoder.decodeStructure(descriptor) { - lateinit var key: String - var value: Boolean - while (true) { - val index = decodeElementIndex(descriptor) - if (index == CompositeDecoder.DECODE_DONE) { - break - } else if (index % 2 == 0) { - key = decodeStringElement(descriptor, index) - } else if (index % 2 == 1) { - value = decodeBooleanElement(descriptor, index) - entries += SingleItemsRequest(key, value) - } - } - } - return ItemsRequestList(entries) - } -} - - -/** - * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) - */ -@Serializable -data class DeviceResponse( - @SerialName("version") - val version: String, - @SerialName("documents") - val documents: Array? = null, - @SerialName("documentErrors") - val documentErrors: Array>? = null, - @SerialName("status") - val status: UInt, -) { - fun serialize() = vckCborSerializer.encodeToByteArray(this) - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as DeviceResponse - - if (version != other.version) return false - if (documents != null) { - if (other.documents == null) return false - if (!documents.contentEquals(other.documents)) return false - } else if (other.documents != null) return false - if (documentErrors != null) { - if (other.documentErrors == null) return false - if (!documentErrors.contentEquals(other.documentErrors)) return false - } else if (other.documentErrors != null) return false - return status == other.status - } - - override fun hashCode(): Int { - var result = version.hashCode() - result = 31 * result + (documents?.contentHashCode() ?: 0) - result = 31 * result + (documentErrors?.contentHashCode() ?: 0) - result = 31 * result + status.hashCode() - return result - } - - companion object { - fun deserialize(it: ByteArray) = kotlin.runCatching { - vckCborSerializer.decodeFromByteArray(it) - }.wrap() - } -} - -/** - * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) - */ -@Serializable -data class Document( - @SerialName("docType") - val docType: String, - @SerialName("issuerSigned") - val issuerSigned: IssuerSigned, - @SerialName("deviceSigned") - val deviceSigned: DeviceSigned, - @SerialName("errors") - val errors: Map>? = null, -) { - - fun serialize() = vckCborSerializer.encodeToByteArray(this) - - companion object { - fun deserialize(it: ByteArray) = kotlin.runCatching { - vckCborSerializer.decodeFromByteArray(it) - }.wrap() - } -} - -/** - * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) - */ -@Serializable -data class IssuerSigned( - @SerialName("nameSpaces") - val namespaces: Map? = null, - @SerialName("issuerAuth") - val issuerAuth: CoseSigned, -) { - - fun getIssuerAuthPayloadAsMso() = issuerAuth.payload?.stripCborTag(24) - ?.let { vckCborSerializer.decodeFromByteArray(ByteStringWrapperMobileSecurityObjectSerializer, it).value } - - fun serialize() = vckCborSerializer.encodeToByteArray(this) - - companion object { - fun deserialize(it: ByteArray) = kotlin.runCatching { - vckCborSerializer.decodeFromByteArray(it) - }.wrap() - } -} - - -/** - * Convenience class with a custom serializer ([IssuerSignedListSerializer]) to prevent - * usage of the type `Map>>` in [IssuerSigned.namespaces]. - */ -@Serializable(with = IssuerSignedListSerializer::class) -data class IssuerSignedList( - val entries: List> -) { - override fun toString(): String { - return "IssuerSignedList(entries=${entries.map { it.value }})" - } - - companion object { - fun withItems(list: List) = IssuerSignedList( - // TODO verify serialization of this - list.map { ByteStringWrapper(it, vckCborSerializer.encodeToByteArray(it).wrapInCborTag(24)) } - ) - } -} - -/** - * Serializes [IssuerSignedList.entries] as an "inline list", - * having serialized instances of [IssuerSignedItem] as the values. - */ -object IssuerSignedListSerializer : KSerializer { - - override val descriptor: SerialDescriptor = object : SerialDescriptor { - @ExperimentalSerializationApi - override val elementsCount: Int = 1 - - @ExperimentalSerializationApi - override val kind: SerialKind = StructureKind.LIST - - @ExperimentalSerializationApi - override val serialName: String = "kotlin.collections.ArrayList" - - @ExperimentalSerializationApi - override fun getElementAnnotations(index: Int): List { - return listOf(ValueTags(24U)) - } - - @ExperimentalSerializationApi - override fun getElementDescriptor(index: Int): SerialDescriptor { - return Byte.serializer().descriptor - } - - @ExperimentalSerializationApi - override fun getElementIndex(name: String): Int { - return name.toInt() - } - - @ExperimentalSerializationApi - override fun getElementName(index: Int): String { - return index.toString() - } - - @ExperimentalSerializationApi - override fun isElementOptional(index: Int): Boolean { - return false - } - } - - - override fun serialize(encoder: Encoder, value: IssuerSignedList) { - var index = 0 - encoder.encodeCollection(descriptor, value.entries.size) { - value.entries.forEach { - encodeSerializableElement(descriptor, index++, ByteArraySerializer(), it.value.serialize()) - } - } - } - - override fun deserialize(decoder: Decoder): IssuerSignedList { - val entries = mutableListOf>() - decoder.decodeStructure(descriptor) { - while (true) { - val index = decodeElementIndex(descriptor) - if (index == CompositeDecoder.DECODE_DONE) { - break - } else { - val readBytes = decoder.decodeSerializableValue(ByteArraySerializer()) - entries += ByteStringWrapper( - value = IssuerSignedItem.deserialize(readBytes).getOrThrow(), - serialized = readBytes - ) - } - } - } - return IssuerSignedList(entries) - } -} - -/** - * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) - */ -@Serializable(with = IssuerSignedItemSerializer::class) -data class IssuerSignedItem( - @SerialName("digestID") - val digestId: UInt, - @SerialName("random") - @ByteString - val random: ByteArray, - @SerialName("elementIdentifier") - val elementIdentifier: String, - @SerialName("elementValue") - val elementValue: Any, -) { - - fun serialize() = vckCborSerializer.encodeToByteArray(this) - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as IssuerSignedItem - - if (digestId != other.digestId) return false - if (!random.contentEquals(other.random)) return false - if (elementIdentifier != other.elementIdentifier) return false - return elementValue == other.elementValue - } - - override fun hashCode(): Int { - var result = digestId.hashCode() - result = 31 * result + random.contentHashCode() - result = 31 * result + elementIdentifier.hashCode() - result = 31 * result + elementValue.hashCode() - return result - } - - override fun toString(): String { - return "IssuerSignedItem(digestId=$digestId," + - " random=${random.encodeToString(Base16(strict = true))}," + - " elementIdentifier='$elementIdentifier'," + - " elementValue=$elementValue)" - } - - companion object { - fun deserialize(it: ByteArray) = kotlin.runCatching { - vckCborSerializer.decodeFromByteArray(it) - }.wrap() - } -} - - -/** - * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) - */ -@Serializable -data class DeviceSigned( - @SerialName("nameSpaces") - @ByteString - @ValueTags(24U) - val namespaces: ByteArray, - @SerialName("deviceAuth") - val deviceAuth: DeviceAuth, -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as DeviceSigned - - if (!namespaces.contentEquals(other.namespaces)) return false - return deviceAuth == other.deviceAuth - } - - override fun hashCode(): Int { - var result = namespaces.contentHashCode() - result = 31 * result + deviceAuth.hashCode() - return result - } - -} - - -/** - * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) - */ -@Serializable -data class DeviceAuth( - @SerialName("deviceSignature") - val deviceSignature: CoseSigned? = null, - @SerialName("deviceMac") - val deviceMac: CoseSigned? = null, // TODO is COSE_Mac0 -) - - -object ByteStringWrapperItemsRequestSerializer : KSerializer> { - - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("ByteStringWrapperItemsRequestSerializer", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: ByteStringWrapper) { - val bytes = vckCborSerializer.encodeToByteArray(value.value) - encoder.encodeSerializableValue(ByteArraySerializer(), bytes) - } - - override fun deserialize(decoder: Decoder): ByteStringWrapper { - val bytes = decoder.decodeSerializableValue(ByteArraySerializer()) - return ByteStringWrapper(vckCborSerializer.decodeFromByteArray(bytes), bytes) - } - -} - -fun ByteArray.stripCborTag(tag: Byte) = this.dropWhile { it == 0xd8.toByte() }.dropWhile { it == tag }.toByteArray() - -fun ByteArray.wrapInCborTag(tag: Byte) = byteArrayOf(0xd8.toByte()) + byteArrayOf(tag) + this - -fun ByteArray.sha256(): ByteArray = toByteString().sha256().toByteArray() diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceResponse.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceResponse.kt new file mode 100644 index 000000000..4214c704c --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceResponse.kt @@ -0,0 +1,56 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.KmmResult.Companion.wrap +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray + +/** + * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) + */ +@Serializable +data class DeviceResponse( + @SerialName("version") + val version: String, + @SerialName("documents") + val documents: Array? = null, + @SerialName("documentErrors") + val documentErrors: Array>? = null, + @SerialName("status") + val status: UInt, +) { + fun serialize() = vckCborSerializer.encodeToByteArray(this) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as DeviceResponse + + if (version != other.version) return false + if (documents != null) { + if (other.documents == null) return false + if (!documents.contentEquals(other.documents)) return false + } else if (other.documents != null) return false + if (documentErrors != null) { + if (other.documentErrors == null) return false + if (!documentErrors.contentEquals(other.documentErrors)) return false + } else if (other.documentErrors != null) return false + return status == other.status + } + + override fun hashCode(): Int { + var result = version.hashCode() + result = 31 * result + (documents?.contentHashCode() ?: 0) + result = 31 * result + (documentErrors?.contentHashCode() ?: 0) + result = 31 * result + status.hashCode() + return result + } + + companion object { + fun deserialize(it: ByteArray) = kotlin.runCatching { + vckCborSerializer.decodeFromByteArray(it) + }.wrap() + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceSigned.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceSigned.kt new file mode 100644 index 000000000..b950fd847 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceSigned.kt @@ -0,0 +1,38 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.cbor.ValueTags + +/** + * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) + */ +@OptIn(ExperimentalUnsignedTypes::class) +@Serializable +data class DeviceSigned( + @SerialName("nameSpaces") + @ValueTags(24U) + val namespaces: ByteStringWrapper, + @SerialName("deviceAuth") + val deviceAuth: DeviceAuth, +) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as DeviceSigned + + if (namespaces != other.namespaces) return false + if (deviceAuth != other.deviceAuth) return false + + return true + } + + override fun hashCode(): Int { + var result = namespaces.hashCode() + result = 31 * result + deviceAuth.hashCode() + return result + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DocRequest.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DocRequest.kt new file mode 100644 index 000000000..9812b375e --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DocRequest.kt @@ -0,0 +1,25 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.signum.indispensable.cosef.CoseSigned +import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.cbor.ValueTags + +/** + * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) + */ +@OptIn(ExperimentalUnsignedTypes::class) +@Serializable +data class DocRequest( + @SerialName("itemsRequest") + @ValueTags(24U) + val itemsRequest: ByteStringWrapper, + @SerialName("readerAuth") + val readerAuth: CoseSigned? = null, +) { + override fun toString(): String { + return "DocRequest(itemsRequest=${itemsRequest.value}, readerAuth=$readerAuth)" + } + +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/Document.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/Document.kt new file mode 100644 index 000000000..0b3106197 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/Document.kt @@ -0,0 +1,50 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.KmmResult.Companion.wrap +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray + +/** + * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) + */ +@Serializable +data class Document( + @SerialName("docType") + val docType: String, + @SerialName("issuerSigned") + val issuerSigned: IssuerSigned, + @SerialName("deviceSigned") + val deviceSigned: DeviceSigned, + @SerialName("errors") + val errors: Map>? = null, +) { + + fun serialize() = vckCborSerializer.encodeToByteArray(this) + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Document) return false + + if (docType != other.docType) return false + if (issuerSigned != other.issuerSigned) return false + if (deviceSigned != other.deviceSigned) return false + if (errors != other.errors) return false + + return true + } + + override fun hashCode(): Int { + var result = docType.hashCode() + result = 31 * result + issuerSigned.hashCode() + result = 31 * result + deviceSigned.hashCode() + result = 31 * result + (errors?.hashCode() ?: 0) + return result + } + + companion object { + fun deserialize(it: ByteArray) = kotlin.runCatching { + vckCborSerializer.decodeFromByteArray(it) + }.wrap() + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSigned.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSigned.kt new file mode 100644 index 000000000..bd1596bbd --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSigned.kt @@ -0,0 +1,68 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.KmmResult.Companion.wrap +import at.asitplus.catching +import at.asitplus.signum.indispensable.cosef.CoseSigned +import kotlinx.serialization.* + +/** + * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) + */ +@Serializable +data class IssuerSigned private constructor( + @SerialName("nameSpaces") + @Serializable(with = NamespacedIssuerSignedListSerializer::class) + val namespaces: Map? = null, + @SerialName("issuerAuth") + val issuerAuth: CoseSigned, +) { + fun getIssuerAuthPayloadAsMso() = catching { + MobileSecurityObject.deserializeFromIssuerAuth(issuerAuth.payload!!).getOrThrow() + } + + fun serialize() = vckCborSerializer.encodeToByteArray(this) + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is IssuerSigned) return false + + if (issuerAuth != other.issuerAuth) return false + if (namespaces != other.namespaces) return false + + return true + } + + override fun hashCode(): Int { + var result = issuerAuth.hashCode() + result = 31 * result + (namespaces?.hashCode() ?: 0) + return result + } + + companion object { + fun deserialize(it: ByteArray) = kotlin.runCatching { + vckCborSerializer.decodeFromByteArray(it) + }.wrap() + + // Note: Can't be a secondary constructor, because it would have the same JVM signature as the primary one. + /** + * Ensures the serialization of this structure in [Document.issuerSigned]: + * ``` + * IssuerNameSpaces = { ; Returned data elements for each namespace + * + NameSpace => [ + IssuerSignedItemBytes ] + * } + * IssuerSignedItemBytes = #6.24(bstr .cbor IssuerSignedItem) + * ``` + * + * See ISO/IEC 18013-5:2021, 8.3.2.1.2.2 Device retrieval mdoc response + */ + fun fromIssuerSignedItems( + namespacedItems: Map>, + issuerAuth: CoseSigned, + ): IssuerSigned = IssuerSigned( + namespaces = namespacedItems.map { (namespace, value) -> + namespace to IssuerSignedList.fromIssuerSignedItems(value, namespace) + }.toMap(), + issuerAuth = issuerAuth, + ) + + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSignedItem.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSignedItem.kt new file mode 100644 index 000000000..423dd2d6a --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSignedItem.kt @@ -0,0 +1,73 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.KmmResult.Companion.wrap +import io.matthewnelson.encoding.base16.Base16 +import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString +import kotlinx.serialization.SerialName +import kotlinx.serialization.cbor.ByteString + +/** + * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) + */ +data class IssuerSignedItem( + @SerialName(PROP_DIGEST_ID) + val digestId: UInt, + @SerialName(PROP_RANDOM) + @ByteString + val random: ByteArray, + @SerialName(PROP_ELEMENT_ID) + val elementIdentifier: String, + @SerialName(PROP_ELEMENT_VALUE) + val elementValue: Any, +) { + + fun serialize(namespace: String) = vckCborSerializer.encodeToByteArray(IssuerSignedItemSerializer(namespace), this) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as IssuerSignedItem + + if (digestId != other.digestId) return false + if (!random.contentEquals(other.random)) return false + if (elementIdentifier != other.elementIdentifier) return false + if (elementValue is ByteArray && other.elementValue is ByteArray) return elementValue.contentEquals(other.elementValue) + if (elementValue is IntArray && other.elementValue is IntArray) return elementValue.contentEquals(other.elementValue) + if (elementValue is BooleanArray && other.elementValue is BooleanArray) return elementValue.contentEquals(other.elementValue) + if (elementValue is CharArray && other.elementValue is CharArray) return elementValue.contentEquals(other.elementValue) + if (elementValue is ShortArray && other.elementValue is ShortArray) return elementValue.contentEquals(other.elementValue) + if (elementValue is LongArray && other.elementValue is LongArray) return elementValue.contentEquals(other.elementValue) + if (elementValue is FloatArray && other.elementValue is FloatArray) return elementValue.contentEquals(other.elementValue) + if (elementValue is DoubleArray && other.elementValue is DoubleArray) return elementValue.contentEquals(other.elementValue) + return if (elementValue is Array<*> && other.elementValue is Array<*>) elementValue.contentDeepEquals(other.elementValue) + //It was time for Thomas to leave. He had seen everything. + else elementValue == other.elementValue + } + + override fun hashCode(): Int { + var result = digestId.hashCode() + result = 31 * result + random.contentHashCode() + result = 31 * result + elementIdentifier.hashCode() + result = 31 * result + elementValue.hashCode() + return result + } + + override fun toString(): String { + return "IssuerSignedItem(digestId=$digestId," + + " random=${random.encodeToString(Base16(strict = true))}," + + " elementIdentifier='$elementIdentifier'," + + " elementValue=$elementValue)" + } + + companion object { + fun deserialize(it: ByteArray, namespace: String) = kotlin.runCatching { + vckCborSerializer.decodeFromByteArray(IssuerSignedItemSerializer(namespace), it) + }.wrap() + + internal const val PROP_DIGEST_ID = "digestID" + internal const val PROP_RANDOM = "random" + internal const val PROP_ELEMENT_ID = "elementIdentifier" + internal const val PROP_ELEMENT_VALUE = "elementValue" + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSignedItemSerializer.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSignedItemSerializer.kt index fbf229ea5..b1abe717d 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSignedItemSerializer.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSignedItemSerializer.kt @@ -1,27 +1,28 @@ package at.asitplus.wallet.lib.iso import at.asitplus.wallet.lib.data.InstantStringSerializer +import at.asitplus.wallet.lib.iso.IssuerSignedItem.Companion.PROP_DIGEST_ID +import at.asitplus.wallet.lib.iso.IssuerSignedItem.Companion.PROP_ELEMENT_ID +import at.asitplus.wallet.lib.iso.IssuerSignedItem.Companion.PROP_ELEMENT_VALUE +import at.asitplus.wallet.lib.iso.IssuerSignedItem.Companion.PROP_RANDOM +import io.github.aakira.napier.Napier import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate import kotlinx.serialization.KSerializer import kotlinx.serialization.builtins.ByteArraySerializer import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.cbor.ValueTags import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildClassSerialDescriptor -import kotlinx.serialization.encoding.CompositeDecoder -import kotlinx.serialization.encoding.CompositeEncoder -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.encoding.decodeStructure -import kotlinx.serialization.encoding.encodeStructure +import kotlinx.serialization.encoding.* -object IssuerSignedItemSerializer : KSerializer { +open class IssuerSignedItemSerializer(private val namespace: String) : KSerializer { override val descriptor: SerialDescriptor = buildClassSerialDescriptor("IssuerSignedItem") { - element("digestID", Long.serializer().descriptor) - element("random", ByteArraySerializer().descriptor) - element("elementIdentifier", String.serializer().descriptor) - element("elementValue", String.serializer().descriptor) + element(PROP_DIGEST_ID, Long.serializer().descriptor) + element(PROP_RANDOM, ByteArraySerializer().descriptor) + element(PROP_ELEMENT_ID, String.serializer().descriptor) + element(PROP_ELEMENT_VALUE, String.serializer().descriptor) } override fun serialize(encoder: Encoder, value: IssuerSignedItem) { @@ -34,35 +35,54 @@ object IssuerSignedItemSerializer : KSerializer { } private fun CompositeEncoder.encodeAnything(value: IssuerSignedItem, index: Int) { + val elementValueSerializer = + buildElementValueSerializer(namespace, value.elementValue, value.elementIdentifier) val descriptor = buildClassSerialDescriptor("IssuerSignedItem") { - element("digestID", Long.serializer().descriptor) - element("random", ByteArraySerializer().descriptor) - element("elementIdentifier", String.serializer().descriptor) - element("elementValue", buildElementValueSerializer(value.elementValue).descriptor) + element(PROP_DIGEST_ID, Long.serializer().descriptor) + element(PROP_RANDOM, ByteArraySerializer().descriptor) + element(PROP_ELEMENT_ID, String.serializer().descriptor) + element( + elementName = PROP_ELEMENT_VALUE, + descriptor = elementValueSerializer.descriptor, + annotations = value.elementValue.annotations() + ) } when (val it = value.elementValue) { is String -> encodeStringElement(descriptor, index, it) is Int -> encodeIntElement(descriptor, index, it) - // TODO write tag 1004 + is Long -> encodeLongElement(descriptor, index, it) is LocalDate -> encodeSerializableElement(descriptor, index, LocalDate.serializer(), it) - // TODO write tag 1004 is Instant -> encodeSerializableElement(descriptor, index, InstantStringSerializer(), it) is Boolean -> encodeBooleanElement(descriptor, index, it) is ByteArray -> encodeSerializableElement(descriptor, index, ByteArraySerializer(), it) - else -> CborCredentialSerializer.encode(descriptor, index, this, it) + else -> CborCredentialSerializer.encode(namespace, value.elementIdentifier, descriptor, index, this, it) } } - private inline fun buildElementValueSerializer(element: T) = when (element) { + private fun Any.annotations() = + if (this is LocalDate || this is Instant) { + @OptIn(ExperimentalUnsignedTypes::class) + listOf(ValueTags(1004uL)) + } else { + emptyList() + } + + private inline fun buildElementValueSerializer( + namespace: String, + elementValue: T, + elementIdentifier: String + ) = when (elementValue) { is String -> String.serializer() is Int -> Int.serializer() + is Long -> Long.serializer() is LocalDate -> LocalDate.serializer() is Instant -> InstantStringSerializer() is Boolean -> Boolean.serializer() is ByteArray -> ByteArraySerializer() - is Any -> CborCredentialSerializer.lookupSerializer(element) ?: error("descriptor not found for $element") - else -> error("descriptor not found for $element") + is Any -> CborCredentialSerializer.lookupSerializer(namespace, elementIdentifier) + ?: error("serializer not found for $elementIdentifier, with value $elementValue") + else -> error("serializer not found for $elementIdentifier, with value $elementValue") } @@ -74,12 +94,13 @@ object IssuerSignedItemSerializer : KSerializer { decoder.decodeStructure(descriptor) { while (true) { val name = decodeStringElement(descriptor, 0) + // Don't call decodeElementIndex, as it would check for tags. this would break decodeAnything val index = descriptor.getElementIndex(name) when (name) { - "digestID" -> digestId = decodeLongElement(descriptor, index).toUInt() - "random" -> random = decodeSerializableElement(descriptor, index, ByteArraySerializer()) - "elementIdentifier" -> elementIdentifier = decodeStringElement(descriptor, index) - "elementValue" -> elementValue = decodeAnything(index) + PROP_DIGEST_ID -> digestId = decodeLongElement(descriptor, index).toUInt() + PROP_RANDOM -> random = decodeSerializableElement(descriptor, index, ByteArraySerializer()) + PROP_ELEMENT_ID -> elementIdentifier = decodeStringElement(descriptor, index) + PROP_ELEMENT_VALUE -> elementValue = decodeAnything(index, elementIdentifier) } if (index == 3) break } @@ -92,16 +113,28 @@ object IssuerSignedItemSerializer : KSerializer { ) } - private fun CompositeDecoder.decodeAnything(index: Int): Any { - runCatching { return decodeStringElement(descriptor, index) } - runCatching { return decodeSerializableElement(descriptor, index, ByteArraySerializer()) } - runCatching { return decodeBooleanElement(descriptor, index) } - runCatching { return decodeSerializableElement(descriptor, index, LocalDate.serializer()) } - runCatching { return decodeSerializableElement(descriptor, index, InstantStringSerializer()) } + private fun CompositeDecoder.decodeAnything(index: Int, elementIdentifier: String): Any { + if (namespace.isBlank()) Napier.w { "This decoder is not namespace-aware! Unspeakable things may happen…" } + + // Tags are not read out here but skipped because `decodeElementIndex` is never called, so we cannot + // discriminate technically, this should be a good thing though, because otherwise we'd consume more from the + // input runCatching { - return CborCredentialSerializer.decode(descriptor, index, this) - ?: throw IllegalArgumentException("Could not decode value at $index") + CborCredentialSerializer.decode(descriptor, index, this, elementIdentifier, namespace) + ?.let { return it } + ?: Napier.w { + "Could not find a registered decoder for namespace $namespace and elementIdentifier" + + " $elementIdentifier. Falling back to defaults" + } } + + // These are the ones that map to different CBOR data types, the rest don't, so if it is not registered, we'll + // lose type information. No others must be added here, as they could consume data from the underlying bytes + runCatching { return decodeStringElement(descriptor, index) } + runCatching { return decodeLongElement(descriptor, index) } + runCatching { return decodeDoubleElement(descriptor, index) } + runCatching { return decodeBooleanElement(descriptor, index) } + throw IllegalArgumentException("Could not decode value at $index") } -} +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSignedList.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSignedList.kt new file mode 100644 index 000000000..eda2f6599 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSignedList.kt @@ -0,0 +1,46 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper + +/** + * Convenience class with a custom serializer ([IssuerSignedListSerializer]) to prevent + * usage of the type `Map>>` in [IssuerSigned.namespaces]. + */ +data class IssuerSignedList( + val entries: List> +) { + override fun toString(): String { + return "IssuerSignedList(entries=${entries.map { it.value }})" + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is IssuerSignedList) return false + + if (entries != other.entries) return false + + return true + } + + override fun hashCode(): Int { + return 31 * entries.hashCode() + } + + companion object { + /** + * Ensures the serialization of this structure in [Document.issuerSigned]: + * ``` + * IssuerNameSpaces = { ; Returned data elements for each namespace + * + NameSpace => [ + IssuerSignedItemBytes ] + * } + * IssuerSignedItemBytes = #6.24(bstr .cbor IssuerSignedItem) + * ``` + * + * See ISO/IEC 18013-5:2021, 8.3.2.1.2.2 Device retrieval mdoc response + */ + fun fromIssuerSignedItems(items: List, namespace: String) = + IssuerSignedList(items.map { item -> + ByteStringWrapper(item, item.serialize(namespace).wrapInCborTag(24)) + }) + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSignedListSerializer.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSignedListSerializer.kt new file mode 100644 index 000000000..480d1f2ce --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSignedListSerializer.kt @@ -0,0 +1,82 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ByteArraySerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.cbor.ValueTags +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.SerialKind +import kotlinx.serialization.descriptors.StructureKind +import kotlinx.serialization.encoding.* + +/** + * Serializes [IssuerSignedList.entries] as an "inline list", + * having serialized instances of [IssuerSignedItem] as the values. + */ +open class IssuerSignedListSerializer(private val namespace: String) : KSerializer { + + override val descriptor: SerialDescriptor = object : SerialDescriptor { + @ExperimentalSerializationApi + override val elementsCount: Int = 1 + + @ExperimentalSerializationApi + override val kind: SerialKind = StructureKind.LIST + + @ExperimentalSerializationApi + override val serialName: String = "kotlin.collections.ArrayList" + + @ExperimentalSerializationApi + override fun getElementAnnotations(index: Int): List { + @OptIn(ExperimentalUnsignedTypes::class) + return listOf(ValueTags(24U)) + } + + @ExperimentalSerializationApi + override fun getElementDescriptor(index: Int): SerialDescriptor { + return Byte.serializer().descriptor + } + + @ExperimentalSerializationApi + override fun getElementIndex(name: String): Int { + return name.toInt() + } + + @ExperimentalSerializationApi + override fun getElementName(index: Int): String { + return index.toString() + } + + @ExperimentalSerializationApi + override fun isElementOptional(index: Int): Boolean { + return false + } + } + + + override fun serialize(encoder: Encoder, value: IssuerSignedList) { + var index = 0 + encoder.encodeCollection(descriptor, value.entries.size) { + value.entries.forEach { + encodeSerializableElement(descriptor, index++, ByteArraySerializer(), it.value.serialize(namespace)) + } + } + } + + override fun deserialize(decoder: Decoder): IssuerSignedList { + val entries = mutableListOf>() + decoder.decodeStructure(descriptor) { + while (true) { + val index = decodeElementIndex(descriptor) + if (index == CompositeDecoder.DECODE_DONE) { + break + } + val readBytes = decoder.decodeSerializableValue(ByteArraySerializer()) + val issuerSignedItem = IssuerSignedItem.deserialize(readBytes, namespace).getOrThrow() + entries += ByteStringWrapper(issuerSignedItem, readBytes) + } + } + return IssuerSignedList(entries) + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ItemsRequest.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ItemsRequest.kt new file mode 100644 index 000000000..4452cd523 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ItemsRequest.kt @@ -0,0 +1,17 @@ +package at.asitplus.wallet.lib.iso + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) + */ +@Serializable +data class ItemsRequest( + @SerialName("docType") + val docType: String, + @SerialName("nameSpaces") + val namespaces: Map, + @SerialName("requestInfo") + val requestInfo: Map? = null, +) \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ItemsRequestList.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ItemsRequestList.kt new file mode 100644 index 000000000..b8a587c69 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ItemsRequestList.kt @@ -0,0 +1,12 @@ +package at.asitplus.wallet.lib.iso + +import kotlinx.serialization.Serializable + +/** + * Convenience class with a custom serializer ([ItemsRequestListSerializer]) to prevent + * usage of the type `Map>` in [ItemsRequest.namespaces]. + */ +@Serializable(with = ItemsRequestListSerializer::class) +data class ItemsRequestList( + val entries: List +) \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ItemsRequestListSerializer.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ItemsRequestListSerializer.kt new file mode 100644 index 000000000..c019e73c6 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ItemsRequestListSerializer.kt @@ -0,0 +1,48 @@ +package at.asitplus.wallet.lib.iso + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.* + +/** + * Serializes [ItemsRequestList.entries] as an "inline map", + * having [SingleItemsRequest.key] as the map key and [SingleItemsRequest.value] as the map value, + * for the map represented by [ItemsRequestList]. + */ +object ItemsRequestListSerializer : KSerializer { + + override val descriptor: SerialDescriptor = mapSerialDescriptor( + keyDescriptor = PrimitiveSerialDescriptor("key", PrimitiveKind.INT), + valueDescriptor = listSerialDescriptor(), + ) + + override fun serialize(encoder: Encoder, value: ItemsRequestList) { + encoder.encodeStructure(descriptor) { + var index = 0 + value.entries.forEach { + this.encodeStringElement(descriptor, index++, it.key) + this.encodeBooleanElement(descriptor, index++, it.value) + } + } + } + + override fun deserialize(decoder: Decoder): ItemsRequestList { + val entries = mutableListOf() + decoder.decodeStructure(descriptor) { + lateinit var key: String + var value: Boolean + while (true) { + val index = decodeElementIndex(descriptor) + if (index == CompositeDecoder.DECODE_DONE) { + break + } else if (index % 2 == 0) { + key = decodeStringElement(descriptor, index) + } else if (index % 2 == 1) { + value = decodeBooleanElement(descriptor, index) + entries += SingleItemsRequest(key, value) + } + } + } + return ItemsRequestList(entries) + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/KeyAuthorization.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/KeyAuthorization.kt new file mode 100644 index 000000000..9ae57d28b --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/KeyAuthorization.kt @@ -0,0 +1,46 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.KmmResult.Companion.wrap +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray + +/** + * Part of the ISO/IEC 18013-5:2021 standard: Data structure for MSO (9.1.2.4) + */ +@Serializable +data class KeyAuthorization( + @SerialName("nameSpaces") + val namespaces: Array? = null, + @SerialName("dataElements") + val dataElements: Map>? = null, +) { + + fun serialize() = vckCborSerializer.encodeToByteArray(this) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as KeyAuthorization + + if (namespaces != null) { + if (other.namespaces == null) return false + if (!namespaces.contentEquals(other.namespaces)) return false + } else if (other.namespaces != null) return false + return dataElements == other.dataElements + } + + override fun hashCode(): Int { + var result = namespaces?.contentHashCode() ?: 0 + result = 31 * result + (dataElements?.hashCode() ?: 0) + return result + } + + companion object { + fun deserialize(it: ByteArray) = kotlin.runCatching { + vckCborSerializer.decodeFromByteArray(it) + }.wrap() + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/MobileSecurityObject.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/MobileSecurityObject.kt index 67923b4d6..a0ce114a1 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/MobileSecurityObject.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/MobileSecurityObject.kt @@ -3,28 +3,9 @@ package at.asitplus.wallet.lib.iso import at.asitplus.KmmResult.Companion.wrap -import at.asitplus.signum.indispensable.cosef.CoseKey -import io.matthewnelson.encoding.base16.Base16 -import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString -import kotlinx.datetime.Instant -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.KSerializer -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.builtins.ByteArraySerializer import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper -import kotlinx.serialization.decodeFromByteArray -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.descriptors.listSerialDescriptor -import kotlinx.serialization.descriptors.mapSerialDescriptor -import kotlinx.serialization.encodeToByteArray -import kotlinx.serialization.encoding.CompositeDecoder -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.encoding.decodeStructure -import kotlinx.serialization.encoding.encodeStructure +import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapperSerializer +import kotlinx.serialization.* /** * Part of the ISO/IEC 18013-5:2021 standard: Data structure for MSO (9.1.2.4) @@ -47,14 +28,32 @@ data class MobileSecurityObject( fun serialize() = vckCborSerializer.encodeToByteArray(this) - fun serializeForIssuerAuth() = - vckCborSerializer.encodeToByteArray(ByteStringWrapperMobileSecurityObjectSerializer, ByteStringWrapper(this)) - .wrapInCborTag(24) + /** + * Ensures serialization of this structure in [IssuerSigned.issuerAuth]: + * ``` + * IssuerAuth = COSE_Sign1 ; The payload is MobileSecurityObjectBytes + * MobileSecurityObjectBytes = #6.24(bstr .cbor MobileSecurityObject) + * ``` + * + * See ISO/IEC 18013-5:2021, 9.1.2.4 Signing method and structure for MSO + */ + fun serializeForIssuerAuth() = vckCborSerializer.encodeToByteArray( + ByteStringWrapperSerializer(serializer()), ByteStringWrapper(this) + ).wrapInCborTag(24) companion object { + /** + * Deserializes the structure from the [IssuerSigned.issuerAuth] is deserialized: + * ``` + * IssuerAuth = COSE_Sign1 ; The payload is MobileSecurityObjectBytes + * MobileSecurityObjectBytes = #6.24(bstr .cbor MobileSecurityObject) + * ``` + * + * See ISO/IEC 18013-5:2021, 9.1.2.4 Signing method and structure for MSO + */ fun deserializeFromIssuerAuth(it: ByteArray) = kotlin.runCatching { vckCborSerializer.decodeFromByteArray( - ByteStringWrapperMobileSecurityObjectSerializer, + ByteStringWrapperSerializer(serializer()), it.stripCborTag(24) ).value }.wrap() @@ -65,194 +64,4 @@ data class MobileSecurityObject( } } -/** - * Convenience class with a custom serializer ([ValueDigestListSerializer]) to prevent - * usage of the type `Map>` in [MobileSecurityObject.valueDigests]. - */ -@Serializable(with = ValueDigestListSerializer::class) -data class ValueDigestList( - val entries: List -) - -/** - * Convenience class with a custom serializer ([ValueDigestListSerializer]) to prevent - * usage of the type `Map>` in [MobileSecurityObject.valueDigests]. - */ -data class ValueDigest( - val key: UInt, - val value: ByteArray, -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as ValueDigest - - if (key != other.key) return false - return value.contentEquals(other.value) - } - - override fun hashCode(): Int { - var result = key.hashCode() - result = 31 * result + value.contentHashCode() - return result - } - - override fun toString(): String { - return "MobileSecurityObjectNamespaceEntry(key=$key, value=${value.encodeToString(Base16(strict = true))})" - } - - companion object { - fun fromIssuerSigned(value: IssuerSignedItem) = ValueDigest( - value.digestId, - value.serialize().wrapInCborTag(24).sha256() - ) - } -} - -/** - * Serialized the [ValueDigestList.entries] as an "inline map", - * meaning [ValueDigest.key] is the map key and [ValueDigest.value] the map value, - * for the map represented by [ValueDigestList] - */ -object ValueDigestListSerializer : KSerializer { - - override val descriptor: SerialDescriptor = mapSerialDescriptor( - keyDescriptor = PrimitiveSerialDescriptor("key", PrimitiveKind.INT), - valueDescriptor = listSerialDescriptor(), - ) - - override fun serialize(encoder: Encoder, value: ValueDigestList) { - encoder.encodeStructure(descriptor) { - var index = 0 - value.entries.forEach { - this.encodeIntElement(descriptor, index++, it.key.toInt()) - // TODO Values need to be tagged with 24 ... resulting in prefix D818 - this.encodeSerializableElement(descriptor, index++, ByteArraySerializer(), it.value) - } - } - } - - override fun deserialize(decoder: Decoder): ValueDigestList { - val entries = mutableListOf() - decoder.decodeStructure(descriptor) { - var key = 0 - var value: ByteArray - while (true) { - val index = decodeElementIndex(descriptor) - if (index == CompositeDecoder.DECODE_DONE) { - break - } else if (index % 2 == 0) { - key = decodeIntElement(descriptor, index) - } else if (index % 2 == 1) { - value = decodeSerializableElement(descriptor, index, ByteArraySerializer()) - entries += ValueDigest(key.toUInt(), value) - } - } - } - return ValueDigestList(entries) - } -} - -/** - * Part of the ISO/IEC 18013-5:2021 standard: Data structure for MSO (9.1.2.4) - */ -@Serializable -data class DeviceKeyInfo( - @SerialName("deviceKey") - val deviceKey: CoseKey, - @SerialName("keyAuthorizations") - val keyAuthorizations: KeyAuthorization? = null, - @SerialName("keyInfo") - val keyInfo: Map? = null, -) { - - fun serialize() = vckCborSerializer.encodeToByteArray(this) - - companion object { - fun deserialize(it: ByteArray) = kotlin.runCatching { - vckCborSerializer.decodeFromByteArray(it) - }.wrap() - } -} - -/** - * Part of the ISO/IEC 18013-5:2021 standard: Data structure for MSO (9.1.2.4) - */ -@Serializable -data class KeyAuthorization( - @SerialName("nameSpaces") - val namespaces: Array? = null, - @SerialName("dataElements") - val dataElements: Map>? = null, -) { - - fun serialize() = vckCborSerializer.encodeToByteArray(this) - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as KeyAuthorization - - if (namespaces != null) { - if (other.namespaces == null) return false - if (!namespaces.contentEquals(other.namespaces)) return false - } else if (other.namespaces != null) return false - return dataElements == other.dataElements - } - override fun hashCode(): Int { - var result = namespaces?.contentHashCode() ?: 0 - result = 31 * result + (dataElements?.hashCode() ?: 0) - return result - } - - companion object { - fun deserialize(it: ByteArray) = kotlin.runCatching { - vckCborSerializer.decodeFromByteArray(it) - }.wrap() - } -} - -/** - * Part of the ISO/IEC 18013-5:2021 standard: Data structure for MSO (9.1.2.4) - */ -@Serializable -data class ValidityInfo( - @SerialName("signed") - val signed: Instant, - @SerialName("validFrom") - val validFrom: Instant, - @SerialName("validUntil") - val validUntil: Instant, - @SerialName("expectedUpdate") - val expectedUpdate: Instant? = null, -) { - - fun serialize() = vckCborSerializer.encodeToByteArray(this) - - companion object { - fun deserialize(it: ByteArray) = kotlin.runCatching { - vckCborSerializer.decodeFromByteArray(it) - }.wrap() - } -} - - -object ByteStringWrapperMobileSecurityObjectSerializer : KSerializer> { - - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("ByteStringWrapperMobileSecurityObjectSerializer", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: ByteStringWrapper) { - val bytes = vckCborSerializer.encodeToByteArray(value.value) - encoder.encodeSerializableValue(ByteArraySerializer(), bytes) - } - - override fun deserialize(decoder: Decoder): ByteStringWrapper { - val bytes = decoder.decodeSerializableValue(ByteArraySerializer()) - return ByteStringWrapper(vckCborSerializer.decodeFromByteArray(bytes), bytes) - } - -} diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/NamespacedIssuerSignedListSerializer.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/NamespacedIssuerSignedListSerializer.kt new file mode 100644 index 000000000..bec5c1471 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/NamespacedIssuerSignedListSerializer.kt @@ -0,0 +1,57 @@ +package at.asitplus.wallet.lib.iso + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +object NamespacedIssuerSignedListSerializer : KSerializer> { + + private val mapSerializer = MapSerializer(String.serializer(), object : IssuerSignedListSerializer("") {}) + + override val descriptor = mapSerializer.descriptor + + override fun deserialize(decoder: Decoder): Map = NamespacedMapEntryDeserializer().let { + MapSerializer(it.namespaceSerializer, it.itemSerializer).deserialize(decoder) + } + + class NamespacedMapEntryDeserializer { + lateinit var key: String + + val namespaceSerializer = NamespaceSerializer() + val itemSerializer = IssuerSignedListSerializer() + + inner class NamespaceSerializer internal constructor() : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("ISO namespace", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): String = decoder.decodeString().apply { key = this } + + override fun serialize(encoder: Encoder, value: String) { + encoder.encodeString(value).also { key = value } + } + + } + + inner class IssuerSignedListSerializer internal constructor() : KSerializer { + override val descriptor = mapSerializer.descriptor + + override fun deserialize(decoder: Decoder): IssuerSignedList = + decoder.decodeSerializableValue(IssuerSignedListSerializer(key)) + + + override fun serialize(encoder: Encoder, value: IssuerSignedList) { + encoder.encodeSerializableValue(IssuerSignedListSerializer(key), value) + } + + } + } + + override fun serialize(encoder: Encoder, value: Map) = + NamespacedMapEntryDeserializer().let { + MapSerializer(it.namespaceSerializer, it.itemSerializer).serialize(encoder, value) + } + +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ServerItemsRequest.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ServerItemsRequest.kt new file mode 100644 index 000000000..cceaa4f24 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ServerItemsRequest.kt @@ -0,0 +1,17 @@ +package at.asitplus.wallet.lib.iso + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Part of the ISO/IEC 18013-5:2021 standard: Data structure for Server retrieval mdoc request (8.3.2.2.2.1) + */ +@Serializable +data class ServerItemsRequest( + @SerialName("docType") + val docType: String, + @SerialName("nameSpaces") + val namespaces: Map>, + @SerialName("requestInfo") + val requestInfo: Map? = null, +) \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ServerRequest.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ServerRequest.kt index 5d88507df..602cdc270 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ServerRequest.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ServerRequest.kt @@ -44,57 +44,3 @@ data class ServerRequest( } } -/** - * Part of the ISO/IEC 18013-5:2021 standard: Data structure for Server retrieval mdoc request (8.3.2.2.2.1) - */ -@Serializable -data class ServerItemsRequest( - @SerialName("docType") - val docType: String, - @SerialName("nameSpaces") - val namespaces: Map>, - @SerialName("requestInfo") - val requestInfo: Map? = null, -) - -/** - * Part of the ISO/IEC 18013-5:2021 standard: Data structure for Server retrieval mdoc response (8.3.2.2.2.2) - */ -@Serializable -data class ServerResponse( - @SerialName("version") - val version: String, - /** - * A single document is a [JwsSigned], whose payload may be a `MobileDrivingLicenceJws` - */ - @SerialName("documents") - val documents: Array, - @SerialName("documentErrors") - val documentErrors: Map? = null, -) { - fun serialize() = vckJsonSerializer.encodeToString(this) - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as ServerResponse - - if (version != other.version) return false - if (!documents.contentEquals(other.documents)) return false - return documentErrors == other.documentErrors - } - - override fun hashCode(): Int { - var result = version.hashCode() - result = 31 * result + documents.contentHashCode() - result = 31 * result + (documentErrors?.hashCode() ?: 0) - return result - } - - companion object { - fun deserialize(it: String) = kotlin.runCatching { - vckJsonSerializer.decodeFromString(it) - }.wrap() - } -} diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ServerResponse.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ServerResponse.kt new file mode 100644 index 000000000..60b70c349 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ServerResponse.kt @@ -0,0 +1,49 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.KmmResult.Companion.wrap +import at.asitplus.wallet.lib.data.vckJsonSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString + +/** + * Part of the ISO/IEC 18013-5:2021 standard: Data structure for Server retrieval mdoc response (8.3.2.2.2.2) + */ +@Serializable +data class ServerResponse( + @SerialName("version") + val version: String, + /** + * A single document is a [JwsSigned], whose payload may be a `MobileDrivingLicenceJws` + */ + @SerialName("documents") + val documents: Array, + @SerialName("documentErrors") + val documentErrors: Map? = null, +) { + fun serialize() = vckJsonSerializer.encodeToString(this) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as ServerResponse + + if (version != other.version) return false + if (!documents.contentEquals(other.documents)) return false + return documentErrors == other.documentErrors + } + + override fun hashCode(): Int { + var result = version.hashCode() + result = 31 * result + documents.contentHashCode() + result = 31 * result + (documentErrors?.hashCode() ?: 0) + return result + } + + companion object { + fun deserialize(it: String) = kotlin.runCatching { + vckJsonSerializer.decodeFromString(it) + }.wrap() + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/SingleItemsRequest.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/SingleItemsRequest.kt new file mode 100644 index 000000000..006e9d080 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/SingleItemsRequest.kt @@ -0,0 +1,10 @@ +package at.asitplus.wallet.lib.iso + +/** + * Convenience class with a custom serializer ([ItemsRequestListSerializer]) to prevent + * usage of the type `Map>` in [ItemsRequest.namespaces]. + */ +data class SingleItemsRequest( + val key: String, + val value: Boolean, +) \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ValidityInfo.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ValidityInfo.kt new file mode 100644 index 000000000..ee6bf4c87 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ValidityInfo.kt @@ -0,0 +1,38 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.KmmResult.Companion.wrap +import kotlinx.datetime.Instant +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.cbor.ValueTags +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray + +/** + * Part of the ISO/IEC 18013-5:2021 standard: Data structure for MSO (9.1.2.4) + */ +@OptIn(ExperimentalUnsignedTypes::class) +@Serializable +data class ValidityInfo( + @SerialName("signed") + @ValueTags(0u) + val signed: Instant, + @SerialName("validFrom") + @ValueTags(0u) + val validFrom: Instant, + @SerialName("validUntil") + @ValueTags(0u) + val validUntil: Instant, + @SerialName("expectedUpdate") + @ValueTags(0u) + val expectedUpdate: Instant? = null, +) { + + fun serialize() = vckCborSerializer.encodeToByteArray(this) + + companion object { + fun deserialize(it: ByteArray) = kotlin.runCatching { + vckCborSerializer.decodeFromByteArray(it) + }.wrap() + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ValueDigest.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ValueDigest.kt new file mode 100644 index 000000000..650aed49d --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ValueDigest.kt @@ -0,0 +1,47 @@ +package at.asitplus.wallet.lib.iso + +import io.matthewnelson.encoding.base16.Base16 +import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString + +/** + * Convenience class with a custom serializer ([ValueDigestListSerializer]) to prevent + * usage of the type `Map>` in [MobileSecurityObject.valueDigests]. + */ +data class ValueDigest( + val key: UInt, + val value: ByteArray, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as ValueDigest + + if (key != other.key) return false + return value.contentEquals(other.value) + } + + override fun hashCode(): Int { + var result = key.hashCode() + result = 31 * result + value.contentHashCode() + return result + } + + override fun toString(): String { + return "ValueDigest(key=$key, value=${value.encodeToString(Base16(strict = true))})" + } + + companion object { + /** + * Input for digest calculation is this structure: + * `IssuerSignedItemBytes = #6.24(bstr .cbor IssuerSignedItem)` + * + * See ISO/IEC 18013-5:2021, 9.1.2.5 Message digest function + */ + fun fromIssuerSignedItem(value: IssuerSignedItem, namespace: String): ValueDigest = + ValueDigest( + value.digestId, + value.serialize(namespace).wrapInCborTag(24).sha256() + ) + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ValueDigestList.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ValueDigestList.kt new file mode 100644 index 000000000..5de213070 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ValueDigestList.kt @@ -0,0 +1,12 @@ +package at.asitplus.wallet.lib.iso + +import kotlinx.serialization.Serializable + +/** + * Convenience class with a custom serializer ([ValueDigestListSerializer]) to prevent + * usage of the type `Map>` in [MobileSecurityObject.valueDigests]. + */ +@Serializable(with = ValueDigestListSerializer::class) +data class ValueDigestList( + val entries: List +) \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ValueDigestListSerializer.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ValueDigestListSerializer.kt new file mode 100644 index 000000000..05487a5aa --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ValueDigestListSerializer.kt @@ -0,0 +1,49 @@ +package at.asitplus.wallet.lib.iso + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ByteArraySerializer +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.* + +/** + * Serialized the [ValueDigestList.entries] as an "inline map", + * meaning [ValueDigest.key] is the map key and [ValueDigest.value] the map value, + * for the map represented by [ValueDigestList] + */ +object ValueDigestListSerializer : KSerializer { + + override val descriptor: SerialDescriptor = mapSerialDescriptor( + keyDescriptor = PrimitiveSerialDescriptor("key", PrimitiveKind.INT), + valueDescriptor = listSerialDescriptor(), + ) + + override fun serialize(encoder: Encoder, value: ValueDigestList) { + encoder.encodeStructure(descriptor) { + var index = 0 + value.entries.forEach { + this.encodeIntElement(descriptor, index++, it.key.toInt()) + this.encodeSerializableElement(descriptor, index++, ByteArraySerializer(), it.value) + } + } + } + + override fun deserialize(decoder: Decoder): ValueDigestList { + val entries = mutableListOf() + decoder.decodeStructure(descriptor) { + var key = 0 + var value: ByteArray + while (true) { + val index = decodeElementIndex(descriptor) + if (index == CompositeDecoder.DECODE_DONE) { + break + } else if (index % 2 == 0) { + key = decodeIntElement(descriptor, index) + } else if (index % 2 == 1) { + value = decodeSerializableElement(descriptor, index, ByteArraySerializer()) + entries += ValueDigest(key.toUInt(), value) + } + } + } + return ValueDigestList(entries) + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/cborSerializer.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/cborSerializer.kt index 753cc5eef..de53e03b3 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/cborSerializer.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/cborSerializer.kt @@ -1,45 +1,65 @@ package at.asitplus.wallet.lib.iso -import at.asitplus.wallet.lib.ItemValueDecoder -import at.asitplus.wallet.lib.ItemValueEncoder -import at.asitplus.wallet.lib.SerializerLookup import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.cbor.Cbor import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.CompositeDecoder import kotlinx.serialization.encoding.CompositeEncoder +import okio.ByteString.Companion.toByteString internal object CborCredentialSerializer { - private val serializerLookupFunctions = mutableSetOf() - private val encoderFunctions = mutableSetOf() - private val decoderFunctions = mutableSetOf() + private val decoderMap = mutableMapOf>() + private val encoderMap = mutableMapOf>() + private val serializerLookupMap = mutableMapOf>>() - fun register(function: SerializerLookup) { - serializerLookupFunctions += function + fun register(serializerMap: Map>, isoNamespace: String) { + decoderMap[isoNamespace] = + serializerMap.map { (k, ser) -> + k to decodeFun(ser) + }.toMap() + encoderMap[isoNamespace] = + serializerMap.map { (k, ser) -> + @Suppress("UNCHECKED_CAST") + k to encodeFun(ser as KSerializer) + }.toMap() + serializerLookupMap[isoNamespace] = serializerMap } - fun register(function: ItemValueEncoder) { - encoderFunctions += function - } + private fun decodeFun(ser: KSerializer<*>) = + { descriptor: SerialDescriptor, index: Int, compositeDecoder: CompositeDecoder -> + compositeDecoder.decodeSerializableElement(descriptor, index, ser)!! + } - fun register(function: ItemValueDecoder) { - decoderFunctions += function - } + private fun encodeFun(ser: KSerializer) = + { descriptor: SerialDescriptor, index: Int, compositeEncoder: CompositeEncoder, value: Any -> + compositeEncoder.encodeSerializableElement(descriptor, index, ser, value) + } - fun lookupSerializer(element: Any): KSerializer<*>? { - return serializerLookupFunctions.firstNotNullOfOrNull { it.invoke(element) } - } + fun lookupSerializer(namespace: String, elementIdentifier: String): KSerializer<*>? = + serializerLookupMap[namespace]?.get(elementIdentifier) - fun encode(descriptor: SerialDescriptor, index: Int, compositeEncoder: CompositeEncoder, value: Any) { - encoderFunctions.firstOrNull { it.invoke(descriptor, index, compositeEncoder, value) } + fun encode( + namespace: String, + elementIdentifier: String, + descriptor: SerialDescriptor, + index: Int, + compositeEncoder: CompositeEncoder, + value: Any + ) { + encoderMap[namespace]?.get(elementIdentifier)?.invoke(descriptor, index, compositeEncoder, value) } - fun decode(descriptor: SerialDescriptor, index: Int, compositeDecoder: CompositeDecoder): Any? = - decoderFunctions.firstNotNullOfOrNull { - runCatching { it.invoke(descriptor, index, compositeDecoder) }.getOrNull() - } + fun decode( + descriptor: SerialDescriptor, + index: Int, + compositeDecoder: CompositeDecoder, + elementIdentifier: String, + isoNamespace: String, + ): Any? = decoderMap[isoNamespace]?.get(elementIdentifier)?.let { + runCatching { it.invoke(descriptor, index, compositeDecoder) }.getOrNull() + } } @Deprecated("use vckCborSerializer instead", replaceWith = ReplaceWith("vckCborSerializer")) @@ -52,4 +72,25 @@ val vckCborSerializer by lazy { alwaysUseByteString = true encodeDefaults = false } -} \ No newline at end of file +} + + +fun ByteArray.stripCborTag(tag: Byte): ByteArray { + val tagBytes = byteArrayOf(0xd8.toByte(), tag) + return if (this.take(tagBytes.size).toByteArray().contentEquals(tagBytes)) { + this.drop(tagBytes.size).toByteArray() + } else { + this + } +} + +fun ByteArray.wrapInCborTag(tag: Byte) = byteArrayOf(0xd8.toByte()) + byteArrayOf(tag) + this + +fun ByteArray.sha256(): ByteArray = toByteString().sha256().toByteArray() + + +private typealias ItemValueEncoder + = (descriptor: SerialDescriptor, index: Int, compositeEncoder: CompositeEncoder, value: Any) -> Unit + +private typealias ItemValueDecoder + = (descriptor: SerialDescriptor, index: Int, compositeDecoder: CompositeDecoder) -> Any diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/jws/JwsContentTypeConstants.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/jws/JwsContentTypeConstants.kt index f87747556..904562a35 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/jws/JwsContentTypeConstants.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/jws/JwsContentTypeConstants.kt @@ -5,6 +5,8 @@ object JwsContentTypeConstants { const val JWT = "jwt" const val SD_JWT = "vc+sd-jwt" const val KB_JWT = "kb+jwt" + /** RFC 9449 */ + const val DPOP_JWT = "dpop+jwt" const val DIDCOMM_PLAIN_JSON = "didcomm-plain+json" const val DIDCOMM_SIGNED_JSON = "didcomm-signed+json" const val DIDCOMM_ENCRYPTED_JSON = "didcomm-encrypted+json" diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/jws/JwsService.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/jws/JwsService.kt index 1ad0894d6..36140c7ca 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/jws/JwsService.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/jws/JwsService.kt @@ -4,22 +4,14 @@ import at.asitplus.KmmResult import at.asitplus.catching import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.signum.indispensable.Digest -import at.asitplus.signum.indispensable.asn1.encodeTo4Bytes +import at.asitplus.signum.indispensable.asn1.encoding.encodeTo4Bytes +import at.asitplus.signum.indispensable.asn1.encoding.encodeTo8Bytes import at.asitplus.signum.indispensable.io.Base64UrlStrict -import at.asitplus.signum.indispensable.josef.JsonWebKey -import at.asitplus.signum.indispensable.josef.JsonWebKeySet -import at.asitplus.signum.indispensable.josef.JweAlgorithm -import at.asitplus.signum.indispensable.josef.JweDecrypted -import at.asitplus.signum.indispensable.josef.JweEncrypted -import at.asitplus.signum.indispensable.josef.JweEncryption -import at.asitplus.signum.indispensable.josef.JweHeader -import at.asitplus.signum.indispensable.josef.JwsAlgorithm +import at.asitplus.signum.indispensable.josef.* import at.asitplus.signum.indispensable.josef.JwsExtensions.prependWith4BytesSize -import at.asitplus.signum.indispensable.josef.JwsHeader -import at.asitplus.signum.indispensable.josef.JwsSigned import at.asitplus.signum.indispensable.josef.JwsSigned.Companion.prepareJwsSignatureInput -import at.asitplus.signum.indispensable.josef.toJwsAlgorithm import at.asitplus.signum.indispensable.toX509SignatureAlgorithm +import at.asitplus.signum.supreme.asKmmResult import at.asitplus.wallet.lib.agent.CryptoService import at.asitplus.wallet.lib.agent.DefaultVerifierCryptoService import at.asitplus.wallet.lib.agent.EphemeralKeyHolder @@ -86,7 +78,10 @@ interface JwsService { jweEncryption: JweEncryption ): KmmResult - suspend fun decryptJweObject(jweObject: JweEncrypted, serialized: String): KmmResult + suspend fun decryptJweObject( + jweObject: JweEncrypted, + serialized: String + ): KmmResult } @@ -102,7 +97,8 @@ interface VerifierJwsService { class DefaultJwsService(private val cryptoService: CryptoService) : JwsService { - override val algorithm: JwsAlgorithm = cryptoService.keyPairAdapter.signingAlgorithm.toJwsAlgorithm().getOrThrow() + override val algorithm: JwsAlgorithm = + cryptoService.keyMaterial.signatureAlgorithm.toJwsAlgorithm().getOrThrow() // TODO: Get from crypto service override val encryptionAlgorithm: JweAlgorithm = JweAlgorithm.ECDH_ES @@ -116,22 +112,23 @@ class DefaultJwsService(private val cryptoService: CryptoService) : JwsService { contentType: String? ): KmmResult = createSignedJws( JwsHeader( - algorithm = cryptoService.keyPairAdapter.signingAlgorithm.toJwsAlgorithm().getOrThrow(), - keyId = cryptoService.keyPairAdapter.publicKey.didEncoded, + algorithm = cryptoService.keyMaterial.signatureAlgorithm.toJwsAlgorithm().getOrThrow(), + keyId = cryptoService.keyMaterial.publicKey.didEncoded, type = type, contentType = contentType ), payload ) override suspend fun createSignedJws(header: JwsHeader, payload: ByteArray) = catching { - if (header.algorithm != cryptoService.keyPairAdapter.signingAlgorithm.toJwsAlgorithm().getOrThrow() - || header.jsonWebKey?.let { it != cryptoService.keyPairAdapter.jsonWebKey } == true + if (header.algorithm != cryptoService.keyMaterial.signatureAlgorithm.toJwsAlgorithm() + .getOrThrow() + || header.jsonWebKey?.let { it != cryptoService.keyMaterial.jsonWebKey } == true ) { throw IllegalArgumentException("Algorithm or JSON Web Key not matching to cryptoService") } val plainSignatureInput = prepareJwsSignatureInput(header, payload) - val signature = cryptoService.sign(plainSignatureInput.encodeToByteArray()).getOrThrow() + val signature = cryptoService.sign(plainSignatureInput.encodeToByteArray()).asKmmResult().getOrThrow() JwsSigned(header, payload, signature, plainSignatureInput) } @@ -142,14 +139,20 @@ class DefaultJwsService(private val cryptoService: CryptoService) : JwsService { addJsonWebKey: Boolean, addX5c: Boolean ): KmmResult = catching { - var copy = header?.copy(algorithm = cryptoService.keyPairAdapter.signingAlgorithm.toJwsAlgorithm().getOrThrow()) - ?: JwsHeader(algorithm = cryptoService.keyPairAdapter.signingAlgorithm.toJwsAlgorithm().getOrThrow()) + var copy = header?.copy( + algorithm = cryptoService.keyMaterial.signatureAlgorithm.toJwsAlgorithm().getOrThrow() + ) + ?: JwsHeader( + algorithm = cryptoService.keyMaterial.signatureAlgorithm.toJwsAlgorithm() + .getOrThrow() + ) if (addKeyId) - copy = copy.copy(keyId = cryptoService.keyPairAdapter.jsonWebKey.keyId) + copy = copy.copy(keyId = cryptoService.keyMaterial.jsonWebKey.keyId) if (addJsonWebKey) - copy = copy.copy(jsonWebKey = cryptoService.keyPairAdapter.jsonWebKey) + copy = copy.copy(jsonWebKey = cryptoService.keyMaterial.jsonWebKey) + // Null pointer is a controlled error case inside the catching block if (addX5c) - copy = copy.copy(certificateChain = listOf(cryptoService.keyPairAdapter.certificate!!)) //TODO cleanup/nullchecks + copy = copy.copy(certificateChain = listOf(cryptoService.keyMaterial.getCertificate()!!)) createSignedJws(copy, payload).getOrThrow() } @@ -165,13 +168,27 @@ class DefaultJwsService(private val cryptoService: CryptoService) : JwsService { val epk = header.ephemeralKeyPair ?: throw IllegalArgumentException("No epk in JWE header") val z = cryptoService.performKeyAgreement(epk, alg).getOrThrow() - val kdfInput = prependWithAdditionalInfo(z, enc, header.agreementPartyUInfo, header.agreementPartyVInfo) - val key = cryptoService.messageDigest(kdfInput, Digest.SHA256).getOrThrow() + val intermediateKey = concatKdf( + z, + enc, + header.agreementPartyUInfo, + header.agreementPartyVInfo, + enc.encryptionKeyLength + ) + val key = compositeKey(enc, intermediateKey) val iv = jweObject.iv val aad = jweObject.headerAsParsed.encodeToByteArray(Base64UrlStrict) val ciphertext = jweObject.ciphertext val authTag = jweObject.authTag - val plaintext = cryptoService.decrypt(key, iv, aad, ciphertext, authTag, enc).getOrThrow() + val plaintext = cryptoService.decrypt(key.aesKey, iv, aad, ciphertext, authTag, enc).getOrThrow() + key.hmacKey?.let { hmacKey -> + val expectedAuthTag = cryptoService.hmac(hmacKey, enc, hmacInput(aad, iv, ciphertext)) + .getOrThrow() + .take(enc.macLength!!).toByteArray() + if (!expectedAuthTag.contentEquals(authTag)) { + throw IllegalArgumentException("Authtag mismatch") + } + } JweDecrypted(header, plaintext) } @@ -184,11 +201,11 @@ class DefaultJwsService(private val cryptoService: CryptoService) : JwsService { ): KmmResult = catching { val crv = recipientKey.curve ?: throw IllegalArgumentException("No curve in recipient key") - val ephemeralKeyPair = cryptoService.generateEphemeralKeyPair(crv).getOrThrow() + val ephemeralKeyPair = cryptoService.generateEphemeralKeyPair(crv) val jweHeader = (header ?: JweHeader(jweAlgorithm, jweEncryption, type = null)).copy( algorithm = jweAlgorithm, encryption = jweEncryption, - jsonWebKey = cryptoService.keyPairAdapter.jsonWebKey, + jsonWebKey = cryptoService.keyMaterial.jsonWebKey, ephemeralKeyPair = ephemeralKeyPair.publicJsonWebKey ) encryptJwe(ephemeralKeyPair, recipientKey, jweAlgorithm, jweEncryption, jweHeader, payload) @@ -204,11 +221,11 @@ class DefaultJwsService(private val cryptoService: CryptoService) : JwsService { ): KmmResult = catching { val crv = recipientKey.curve ?: throw IllegalArgumentException("No curve in recipient key") - val ephemeralKeyPair = cryptoService.generateEphemeralKeyPair(crv).getOrThrow() + val ephemeralKeyPair = cryptoService.generateEphemeralKeyPair(crv) val jweHeader = JweHeader( algorithm = jweAlgorithm, encryption = jweEncryption, - jsonWebKey = cryptoService.keyPairAdapter.jsonWebKey, + jsonWebKey = cryptoService.keyMaterial.jsonWebKey, type = type, contentType = contentType, ephemeralKeyPair = ephemeralKeyPair.publicJsonWebKey @@ -224,31 +241,73 @@ class DefaultJwsService(private val cryptoService: CryptoService) : JwsService { jweHeader: JweHeader, payload: ByteArray ): JweEncrypted { - val z = cryptoService.performKeyAgreement(ephemeralKeyPair, recipientKey, jweAlgorithm).getOrThrow() - val kdf = - prependWithAdditionalInfo(z, jweEncryption, jweHeader.agreementPartyUInfo, jweHeader.agreementPartyVInfo) - val key = cryptoService.messageDigest(kdf, Digest.SHA256).getOrThrow() + val z = cryptoService.performKeyAgreement(ephemeralKeyPair, recipientKey, jweAlgorithm) + .getOrThrow() + val intermediateKey = concatKdf( + z, + jweEncryption, + jweHeader.agreementPartyUInfo, + jweHeader.agreementPartyVInfo, + jweEncryption.encryptionKeyLength + ) + val key = compositeKey(jweEncryption, intermediateKey) val iv = Random.nextBytes(jweEncryption.ivLengthBits / 8) val headerSerialized = jweHeader.serialize() val aad = headerSerialized.encodeToByteArray() val aadForCipher = aad.encodeToByteArray(Base64UrlStrict) - val ciphertext = cryptoService.encrypt(key, iv, aadForCipher, payload, jweEncryption).getOrThrow() - return JweEncrypted(jweHeader, aad, null, iv, ciphertext.ciphertext, ciphertext.authtag) + val ciphertext = cryptoService.encrypt(key.aesKey, iv, aadForCipher, payload, jweEncryption).getOrThrow() + val authTag = key.hmacKey?.let { hmacKey -> + cryptoService.hmac(hmacKey, jweEncryption, hmacInput(aadForCipher, iv, ciphertext.ciphertext)) + .getOrThrow() + .take(jweEncryption.macLength!!).toByteArray() + } ?: ciphertext.authtag + return JweEncrypted(jweHeader, aad, null, iv, ciphertext.ciphertext, authTag) } - private fun prependWithAdditionalInfo( + /** + * Derives the key, for use in content encryption in JWE, + * per [RFC 7518](https://datatracker.ietf.org/doc/html/rfc7518#section-5.2.2.1) + */ + private fun compositeKey(jweEncryption: JweEncryption, key: ByteArray) = + if (jweEncryption.macLength != null) { + CompositeKey(key.drop(key.size / 2).toByteArray(), key.take(key.size / 2).toByteArray()) + } else { + CompositeKey(key) + } + + /** + * Input for HMAC calculation in JWE, when not using authenticated encryption, + * per [RFC 7518](https://datatracker.ietf.org/doc/html/rfc7518#section-5.2.2.1) + */ + private fun hmacInput( + aadForCipher: ByteArray, + iv: ByteArray, + ciphertext: ByteArray + ) = aadForCipher + iv + ciphertext + (aadForCipher.size * 8L).encodeTo8Bytes() + + /** + * Concat KDF for use in ECDH-ES in JWE, + * per [RFC 7518](https://datatracker.ietf.org/doc/html/rfc7518#section-4.6), + * and [NIST.800-56A](http://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-56Ar2.pdf) + */ + private fun concatKdf( z: ByteArray, jweEncryption: JweEncryption, apu: ByteArray?, - apv: ByteArray? + apv: ByteArray?, + encryptionKeyLengthBits: Int ): ByteArray { - val counterValue = 1.encodeTo4Bytes() // it depends ... + val digest = Digest.SHA256 + val repetitions = (encryptionKeyLengthBits.toUInt() + digest.outputLength.bits - 1U) / digest.outputLength.bits val algId = jweEncryption.text.encodeToByteArray().prependWith4BytesSize() val apuEncoded = apu?.prependWith4BytesSize() ?: 0.encodeTo4Bytes() val apvEncoded = apv?.prependWith4BytesSize() ?: 0.encodeTo4Bytes() val keyLength = jweEncryption.encryptionKeyLength.encodeTo4Bytes() val otherInfo = algId + apuEncoded + apvEncoded + keyLength + byteArrayOf() - return counterValue + z + otherInfo + val output = (1..repetitions.toInt()).fold(byteArrayOf()) { acc, step -> + acc + cryptoService.messageDigest(step.encodeTo4Bytes() + z + otherInfo, digest) + } + return output.take(encryptionKeyLengthBits / 8).toByteArray() } } @@ -266,7 +325,8 @@ class DefaultVerifierJwsService( private val jwkSetRetriever: JwkSetRetrieverFunction = { null }, ) : VerifierJwsService { - override val supportedAlgorithms: List = cryptoService.supportedAlgorithms.map { it.toJwsAlgorithm().getOrThrow() } + override val supportedAlgorithms: List = + cryptoService.supportedAlgorithms.map { it.toJwsAlgorithm().getOrThrow() } /** * Verifies the signature of [jwsObject], by extracting the public key from [JwsHeader.publicKey], @@ -282,7 +342,8 @@ class DefaultVerifierJwsService( } private fun retrieveJwkFromKeySetUrl(jku: String, header: JwsHeader) = - jwkSetRetriever(jku)?.keys?.firstOrNull { it.keyId == header.keyId }?.toCryptoPublicKey()?.getOrNull() + jwkSetRetriever(jku)?.keys?.firstOrNull { it.keyId == header.keyId }?.toCryptoPublicKey() + ?.getOrNull() /** * Verifiers the signature of [jwsObject] by using [signer]. @@ -301,11 +362,39 @@ class DefaultVerifierJwsService( algorithm = jwsObject.header.algorithm.toX509SignatureAlgorithm().getOrThrow(), publicKey = publicKey, ).getOrThrow() - }.getOrElse { + }.fold(onSuccess = { true }, onFailure = { Napier.w("No verification from native code", it) false + }) +} + + +private data class CompositeKey( + val aesKey: ByteArray, + val hmacKey: ByteArray? = null, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as CompositeKey + + if (!aesKey.contentEquals(other.aesKey)) return false + if (hmacKey != null) { + if (other.hmacKey == null) return false + if (!hmacKey.contentEquals(other.hmacKey)) return false + } else if (other.hmacKey != null) return false + + return true + } + + override fun hashCode(): Int { + var result = aesKey.contentHashCode() + result = 31 * result + (hmacKey?.contentHashCode() ?: 0) + return result } } + diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/jws/SdJwtSigned.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/jws/SdJwtSigned.kt index fca2cce7c..2742094a5 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/jws/SdJwtSigned.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/jws/SdJwtSigned.kt @@ -52,7 +52,7 @@ data class SdJwtSigned( val stringList = input.replace("[^A-Za-z0-9-_.~]".toRegex(), "").split("~") if (stringList.isEmpty()) return null.also { Napier.w("Could not parse SD-JWT: $input") } - val jws = JwsSigned.parse(stringList.first()).getOrNull() + val jws = JwsSigned.deserialize(stringList.first()).getOrNull() ?: return null.also { Napier.w("Could not parse JWS from SD-JWT: $input") } val stringListWithoutJws = stringList.drop(1) val rawDisclosures = stringListWithoutJws @@ -67,7 +67,7 @@ data class SdJwtSigned( } } val keyBindingString = stringList.drop(1 + rawDisclosures.size).firstOrNull() - val keyBindingJws = keyBindingString?.let { JwsSigned.parse(it).getOrNull() } + val keyBindingJws = keyBindingString?.let { JwsSigned.deserialize(it).getOrNull() } return SdJwtSigned(jws, disclosures, keyBindingJws, rawDisclosures) } diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/jws/SelectiveDisclosureItemSerializer.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/jws/SelectiveDisclosureItemSerializer.kt index da6184885..19f22fd48 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/jws/SelectiveDisclosureItemSerializer.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/jws/SelectiveDisclosureItemSerializer.kt @@ -10,11 +10,6 @@ import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.booleanOrNull -import kotlinx.serialization.json.doubleOrNull -import kotlinx.serialization.json.floatOrNull -import kotlinx.serialization.json.intOrNull -import kotlinx.serialization.json.longOrNull /** * Encodes [SelectiveDisclosureItem] as needed by SD-JWT spec, @@ -29,17 +24,12 @@ object SelectiveDisclosureItemSerializer : KSerializer override val descriptor: SerialDescriptor = listSerializer.descriptor override fun serialize(encoder: Encoder, value: SelectiveDisclosureItem) { - val valueElement = when (val value = value.claimValue) { - is Boolean -> JsonPrimitive(value) - is Number -> JsonPrimitive(value) - else -> JsonPrimitive(value.toString()) - } encoder.encodeSerializableValue( listSerializer, listOf( JsonPrimitive(value.salt.encodeToString(Base64UrlStrict)), JsonPrimitive(value.claimName), - valueElement + value.claimValue ) ) } @@ -47,15 +37,11 @@ object SelectiveDisclosureItemSerializer : KSerializer override fun deserialize(decoder: Decoder): SelectiveDisclosureItem { val items = decoder.decodeSerializableValue(listSerializer) if (items.count() != 3) throw IllegalArgumentException() + val (firstElement, secondElement, thirdElement) = items return SelectiveDisclosureItem( - salt = items[0].content.decodeToByteArray(Base64UrlStrict), - claimName = items[1].content, - claimValue = items[2].booleanOrNull - ?: items[2].longOrNull - ?: items[2].intOrNull - ?: items[2].doubleOrNull - ?: items[2].floatOrNull - ?: items[2].content + salt = firstElement.content.decodeToByteArray(Base64UrlStrict), + claimName = secondElement.content, + claimValue = thirdElement ) } diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/AgentRevocationTest.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/AgentRevocationTest.kt index 794a560ff..4d1124a87 100644 --- a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/AgentRevocationTest.kt +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/AgentRevocationTest.kt @@ -14,7 +14,6 @@ import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf import io.ktor.util.* -import io.matthewnelson.component.base64.decodeBase64ToArray import kotlinx.datetime.Clock import kotlin.random.Random import kotlin.time.Duration.Companion.seconds @@ -23,19 +22,19 @@ class AgentRevocationTest : FreeSpec({ lateinit var issuerCredentialStore: IssuerCredentialStore lateinit var verifier: Verifier - lateinit var verifierKeyPair: KeyPairAdapter + lateinit var verifierKeyMaterial: KeyMaterial lateinit var issuer: Issuer lateinit var expectedRevokedIndexes: List beforeEach { issuerCredentialStore = InMemoryIssuerCredentialStore() issuer = IssuerAgent( - RandomKeyPairAdapter(), + EphemeralKeyWithoutCert(), issuerCredentialStore, DummyCredentialDataProvider() ) - verifierKeyPair = RandomKeyPairAdapter() - verifier = VerifierAgent(verifierKeyPair) + verifierKeyMaterial = EphemeralKeyWithoutCert() + verifier = VerifierAgent(verifierKeyMaterial) expectedRevokedIndexes = issuerCredentialStore.revokeRandomCredentials() } @@ -56,7 +55,7 @@ class AgentRevocationTest : FreeSpec({ "credentials should contain status information" { val result = issuer.issueCredential( - verifierKeyPair.publicKey, + verifierKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT, ).getOrElse { @@ -73,7 +72,7 @@ class AgentRevocationTest : FreeSpec({ "encoding to a known value works" { issuerCredentialStore = InMemoryIssuerCredentialStore() - issuer = IssuerAgent(RandomKeyPairAdapter(), issuerCredentialStore) + issuer = IssuerAgent(EphemeralKeyWithoutCert(), issuerCredentialStore) expectedRevokedIndexes = listOf(1, 2, 4, 6, 7, 9, 10, 12, 13, 14) issuerCredentialStore.revokeCredentialsWithIndexes(expectedRevokedIndexes) @@ -89,7 +88,7 @@ class AgentRevocationTest : FreeSpec({ "decoding a known value works" { issuerCredentialStore = InMemoryIssuerCredentialStore() - issuer = IssuerAgent(RandomKeyPairAdapter(), issuerCredentialStore) + issuer = IssuerAgent(EphemeralKeyWithoutCert(), issuerCredentialStore) expectedRevokedIndexes = listOf(1, 2, 4, 6, 7, 9, 10, 12, 13, 14) issuerCredentialStore.revokeCredentialsWithIndexes(expectedRevokedIndexes) @@ -128,7 +127,7 @@ private fun IssuerCredentialStore.revokeCredentialsWithIndexes(revokedIndexes: L val vcId = uuid4().toString() val revListIndex = storeGetNextIndex( credential = IssuerCredentialStore.Credential.VcJwt(vcId, cred, ConstantIndex.AtomicAttribute2023), - subjectPublicKey = RandomKeyPairAdapter().publicKey, + subjectPublicKey = EphemeralKeyWithoutCert().publicKey, issuanceDate = issuanceDate, expirationDate = expirationDate, timePeriod = FixedTimePeriodProvider.timePeriod @@ -148,7 +147,7 @@ private fun IssuerCredentialStore.revokeRandomCredentials(): MutableList { val vcId = uuid4().toString() val revListIndex = storeGetNextIndex( credential = IssuerCredentialStore.Credential.VcJwt(vcId, cred, ConstantIndex.AtomicAttribute2023), - subjectPublicKey = RandomKeyPairAdapter().publicKey, + subjectPublicKey = EphemeralKeyWithoutCert().publicKey, issuanceDate = issuanceDate, expirationDate = expirationDate, timePeriod = FixedTimePeriodProvider.timePeriod diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/AgentSdJwtTest.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/AgentSdJwtTest.kt index 1b5c114a0..55a2347dd 100644 --- a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/AgentSdJwtTest.kt +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/AgentSdJwtTest.kt @@ -1,13 +1,15 @@ package at.asitplus.wallet.lib.agent +import at.asitplus.dif.Constraint +import at.asitplus.dif.ConstraintField +import at.asitplus.dif.DifInputDescriptor +import at.asitplus.dif.PresentationDefinition import at.asitplus.signum.indispensable.josef.JwsHeader import at.asitplus.signum.indispensable.josef.JwsSigned import at.asitplus.wallet.lib.data.ConstantIndex +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_DATE_OF_BIRTH +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_GIVEN_NAME import at.asitplus.wallet.lib.data.KeyBindingJws -import at.asitplus.wallet.lib.data.dif.Constraint -import at.asitplus.wallet.lib.data.dif.ConstraintField -import at.asitplus.wallet.lib.data.dif.InputDescriptor -import at.asitplus.wallet.lib.data.dif.PresentationDefinition import at.asitplus.wallet.lib.iso.sha256 import at.asitplus.wallet.lib.jws.DefaultJwsService import at.asitplus.wallet.lib.jws.JwsContentTypeConstants @@ -29,95 +31,78 @@ class AgentSdJwtTest : FreeSpec({ lateinit var verifier: Verifier lateinit var issuerCredentialStore: IssuerCredentialStore lateinit var holderCredentialStore: SubjectCredentialStore - lateinit var holderKeyPair: KeyPairAdapter + lateinit var holderKeyMaterial: KeyMaterial lateinit var challenge: String beforeEach { issuerCredentialStore = InMemoryIssuerCredentialStore() holderCredentialStore = InMemorySubjectCredentialStore() issuer = IssuerAgent( - RandomKeyPairAdapter(), + EphemeralKeyWithoutCert(), issuerCredentialStore, DummyCredentialDataProvider(), ) - holderKeyPair = RandomKeyPairAdapter() - holder = HolderAgent(holderKeyPair, holderCredentialStore) + holderKeyMaterial = EphemeralKeyWithSelfSignedCert() + holder = HolderAgent(holderKeyMaterial, holderCredentialStore) verifier = VerifierAgent() challenge = uuid4().toString() holder.storeCredential( issuer.issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.SD_JWT, ).getOrThrow().toStoreCredentialInput() ) } - val givenNamePresentationDefinition = PresentationDefinition( - id = uuid4().toString(), - inputDescriptors = listOf( - InputDescriptor( - id = uuid4().toString(), - constraints = Constraint( - fields = listOf( - ConstraintField( - path = listOf("$['given_name']") - ) - ) - ) - ) - ) - ) - "simple walk-through success" { - val presentationParameters = holder.createPresentation( - challenge, - verifier.keyPair.identifier, - presentationDefinition = givenNamePresentationDefinition + challenge = challenge, + audienceId = verifier.keyMaterial.identifier, + presentationDefinition = buildPresentationDefinition(CLAIM_GIVEN_NAME, CLAIM_DATE_OF_BIRTH) ).getOrThrow() val vp = presentationParameters.presentationResults.firstOrNull() .shouldBeInstanceOf() - .also { println("Presentation: ${it.sdJwt}") } val verified = verifier.verifyPresentation(vp.sdJwt, challenge) .shouldBeInstanceOf() - verified.disclosures shouldHaveSize 1 - verified.disclosures.forAll { it.claimName shouldBe "given_name" } + verified.disclosures shouldHaveSize 2 + + verified.disclosures.first { it.claimName == CLAIM_GIVEN_NAME }.claimValue.content shouldBe "Susanne" + verified.disclosures.first { it.claimName == CLAIM_DATE_OF_BIRTH }.claimValue.content shouldBe "1990-01-01" verified.isRevoked shouldBe false } "keyBindingJws contains more JWK attributes, still verifies" { + val credential = holderCredentialStore.getCredentials().getOrThrow() + .filterIsInstance().first() val sdJwt = createSdJwtPresentation( - DefaultJwsService(DefaultCryptoService(holderKeyPair)), - verifier.keyPair.identifier, - challenge, - holderCredentialStore.getCredentials().getOrThrow() - .filterIsInstance().first(), - "given_name" + jwsService = DefaultJwsService(DefaultCryptoService(holderKeyMaterial)), + audienceId = verifier.keyMaterial.identifier, + challenge = challenge, + validSdJwtCredential = credential, + claimName = CLAIM_GIVEN_NAME ).sdJwt val verified = verifier.verifyPresentation(sdJwt, challenge) .shouldBeInstanceOf() verified.disclosures shouldHaveSize 1 - verified.disclosures.forAll { it.claimName shouldBe "given_name" } + verified.disclosures.forAll { it.claimName shouldBe CLAIM_GIVEN_NAME } verified.isRevoked shouldBe false } "wrong key binding jwt" { val presentationParameters = holder.createPresentation( - challenge, - verifier.keyPair.identifier, - givenNamePresentationDefinition + challenge = challenge, + audienceId = verifier.keyMaterial.identifier, + presentationDefinition = buildPresentationDefinition(CLAIM_GIVEN_NAME) ).getOrThrow() val vp = presentationParameters.presentationResults.firstOrNull() .shouldBeInstanceOf() // replace key binding of original vp.sdJwt (i.e. the part after the last `~`) - val malformedVpSdJwt = vp.sdJwt.replaceAfterLast( - "~", - createFreshSdJwtKeyBinding(challenge, verifier.keyPair.identifier).substringAfterLast("~") - ) + val freshKbJwt = createFreshSdJwtKeyBinding(challenge, verifier.keyMaterial.identifier) + val malformedVpSdJwt = vp.sdJwt.replaceAfterLast("~", freshKbJwt.substringAfterLast("~")) verifier.verifyPresentation(malformedVpSdJwt, challenge) .shouldBeInstanceOf() @@ -126,9 +111,9 @@ class AgentSdJwtTest : FreeSpec({ "wrong challenge in key binding jwt" { val malformedChallenge = challenge.reversed() val presentationParameters = holder.createPresentation( - malformedChallenge, - verifier.keyPair.identifier, - presentationDefinition = givenNamePresentationDefinition + challenge = malformedChallenge, + audienceId = verifier.keyMaterial.identifier, + presentationDefinition = buildPresentationDefinition(CLAIM_GIVEN_NAME) ).getOrThrow() val vp = presentationParameters.presentationResults.firstOrNull() @@ -140,18 +125,18 @@ class AgentSdJwtTest : FreeSpec({ "revoked sd jwt" { val presentationParameters = holder.createPresentation( - challenge, - verifier.keyPair.identifier, - presentationDefinition = givenNamePresentationDefinition + challenge = challenge, + audienceId = verifier.keyMaterial.identifier, + presentationDefinition = buildPresentationDefinition(CLAIM_GIVEN_NAME) ).getOrThrow() val vp = presentationParameters.presentationResults.firstOrNull() .shouldBeInstanceOf() - issuer.revokeCredentialsWithId( - holderCredentialStore.getCredentials().getOrThrow() - .filterIsInstance() - .associate { it.sdJwt.jwtId!! to it.sdJwt.notBefore!! }) shouldBe true + val listOfJwtId = holderCredentialStore.getCredentials().getOrThrow() + .filterIsInstance() + .associate { it.sdJwt.jwtId!! to it.sdJwt.notBefore!! } + issuer.revokeCredentialsWithId(listOfJwtId) shouldBe true verifier.setRevocationList(issuer.issueRevocationListCredential()!!) shouldBe true val verified = verifier.verifyPresentation(vp.sdJwt, challenge) .shouldBeInstanceOf() @@ -160,13 +145,25 @@ class AgentSdJwtTest : FreeSpec({ }) +private fun buildPresentationDefinition(vararg attributeName: String) = PresentationDefinition( + id = uuid4().toString(), + inputDescriptors = listOf( + DifInputDescriptor( + id = uuid4().toString(), + constraints = Constraint( + fields = attributeName.map { ConstraintField(path = listOf("$['$it']")) } + ) + ) + ) +) + suspend fun createFreshSdJwtKeyBinding(challenge: String, verifierId: String): String { - val issuer = IssuerAgent(RandomKeyPairAdapter(), DummyCredentialDataProvider()) - val holderKeyPair = RandomKeyPairAdapter() - val holder = HolderAgent(holderKeyPair) + val issuer = IssuerAgent(EphemeralKeyWithoutCert(), DummyCredentialDataProvider()) + val holderKeyMaterial = EphemeralKeyWithoutCert() + val holder = HolderAgent(holderKeyMaterial) holder.storeCredential( issuer.issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.SD_JWT, ).getOrThrow().toStoreCredentialInput() @@ -176,10 +173,11 @@ suspend fun createFreshSdJwtKeyBinding(challenge: String, verifierId: String): S audienceId = verifierId, presentationDefinition = PresentationDefinition( id = uuid4().toString(), - inputDescriptors = listOf(InputDescriptor(id = uuid4().toString())) + inputDescriptors = listOf(DifInputDescriptor(id = uuid4().toString())) ), ).getOrThrow() - return (presentationResult.presentationResults.first() as Holder.CreatePresentationResult.SdJwt).sdJwt + val sdJwt = presentationResult.presentationResults.first() as Holder.CreatePresentationResult.SdJwt + return sdJwt.sdJwt } private suspend fun createSdJwtPresentation( @@ -189,17 +187,15 @@ private suspend fun createSdJwtPresentation( validSdJwtCredential: SubjectCredentialStore.StoreEntry.SdJwt, claimName: String, ): Holder.CreatePresentationResult.SdJwt { - val filteredDisclosures = validSdJwtCredential.disclosures.filter { it.value!!.claimName == claimName }.keys - val issuerJwtPlusDisclosures = - SdJwtSigned.sdHashInput(validSdJwtCredential, filteredDisclosures) + val filteredDisclosures = validSdJwtCredential.disclosures + .filter { it.value!!.claimName == claimName }.keys + val issuerJwtPlusDisclosures = SdJwtSigned.sdHashInput(validSdJwtCredential, filteredDisclosures) val keyBinding = createKeyBindingJws(jwsService, audienceId, challenge, issuerJwtPlusDisclosures) - val jwsFromIssuer = - JwsSigned.parse(validSdJwtCredential.vcSerialized.substringBefore("~")).getOrElse { - Napier.w("Could not re-create JWS from stored SD-JWT", it) - throw PresentationException(it) - } - val sdJwt = - SdJwtSigned.serializePresentation(jwsFromIssuer, filteredDisclosures, keyBinding) + val jwsFromIssuer = JwsSigned.deserialize(validSdJwtCredential.vcSerialized.substringBefore("~")).getOrElse { + Napier.w("Could not re-create JWS from stored SD-JWT", it) + throw PresentationException(it) + } + val sdJwt = SdJwtSigned.serializePresentation(jwsFromIssuer, filteredDisclosures, keyBinding) return Holder.CreatePresentationResult.SdJwt(sdJwt) } diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/AgentTest.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/AgentTest.kt index f4387d956..1911f0f5f 100644 --- a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/AgentTest.kt +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/AgentTest.kt @@ -2,9 +2,9 @@ package at.asitplus.wallet.lib.agent +import at.asitplus.dif.DifInputDescriptor import at.asitplus.wallet.lib.data.ConstantIndex -import at.asitplus.wallet.lib.data.dif.InputDescriptor -import at.asitplus.wallet.lib.data.dif.PresentationDefinition +import at.asitplus.dif.PresentationDefinition import com.benasher44.uuid.uuid4 import io.kotest.core.spec.style.FreeSpec import io.kotest.matchers.collections.shouldBeEmpty @@ -16,13 +16,13 @@ import io.kotest.matchers.types.shouldBeInstanceOf class AgentTest : FreeSpec({ val singularPresentationDefinition = PresentationDefinition( id = uuid4().toString(), - inputDescriptors = listOf(InputDescriptor(id = uuid4().toString())) + inputDescriptors = listOf(DifInputDescriptor(id = uuid4().toString())) ) lateinit var issuer: Issuer lateinit var holder: Holder lateinit var verifier: Verifier - lateinit var holderKeyPair: KeyPairAdapter + lateinit var holderKeyMaterial: KeyMaterial lateinit var issuerCredentialStore: IssuerCredentialStore lateinit var holderCredentialStore: SubjectCredentialStore lateinit var challenge: String @@ -31,20 +31,20 @@ class AgentTest : FreeSpec({ issuerCredentialStore = InMemoryIssuerCredentialStore() holderCredentialStore = InMemorySubjectCredentialStore() issuer = IssuerAgent( - RandomKeyPairAdapter(), + EphemeralKeyWithoutCert(), issuerCredentialStore, DummyCredentialDataProvider(), ) - holderKeyPair = RandomKeyPairAdapter() - holder = HolderAgent(holderKeyPair, holderCredentialStore) - verifier = VerifierAgent(holderKeyPair) + holderKeyMaterial = EphemeralKeyWithoutCert() + holder = HolderAgent(holderKeyMaterial, holderCredentialStore) + verifier = VerifierAgent(holderKeyMaterial) challenge = uuid4().toString() } "simple walk-through success" { holder.storeCredential( issuer.issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT, ).getOrThrow().toStoreCredentialInput() @@ -52,7 +52,7 @@ class AgentTest : FreeSpec({ val presentationParameters = holder.createPresentation( challenge, - verifier.keyPair.identifier, + verifier.keyMaterial.identifier, presentationDefinition = singularPresentationDefinition, ).getOrNull() presentationParameters.shouldNotBeNull() @@ -66,7 +66,7 @@ class AgentTest : FreeSpec({ "wrong keyId in presentation leads to InvalidStructure" { holder.storeCredential( issuer.issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT, ).getOrThrow().toStoreCredentialInput() @@ -74,7 +74,7 @@ class AgentTest : FreeSpec({ val presentationParameters = holder.createPresentation( challenge = challenge, - audienceId = issuer.keyPair.identifier, + audienceId = issuer.keyMaterial.identifier, presentationDefinition = singularPresentationDefinition, ).getOrNull() presentationParameters.shouldNotBeNull() @@ -87,7 +87,7 @@ class AgentTest : FreeSpec({ "revoked credentials must not be validated" { val credentials = issuer.issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT, ).getOrThrow() @@ -107,7 +107,7 @@ class AgentTest : FreeSpec({ "when setting a revocation list before storing credentials" { val credentials = issuer.issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT, ).getOrThrow() @@ -124,7 +124,7 @@ class AgentTest : FreeSpec({ "and when setting a revocation list after storing credentials" { val credentials = issuer.issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT, ).getOrThrow() @@ -141,7 +141,7 @@ class AgentTest : FreeSpec({ holder.createPresentation( challenge = challenge, - audienceId = verifier.keyPair.identifier, + audienceId = verifier.keyMaterial.identifier, presentationDefinition = singularPresentationDefinition, ).getOrNull() shouldBe null } @@ -157,7 +157,7 @@ class AgentTest : FreeSpec({ "when they are valid" - { val credentials = issuer.issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT, ).getOrThrow() @@ -190,7 +190,7 @@ class AgentTest : FreeSpec({ "when the issuer has revoked them" { val credentials = issuer.issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT, ).getOrThrow() @@ -216,21 +216,21 @@ class AgentTest : FreeSpec({ "building presentation without necessary credentials" { holder.createPresentation( challenge = challenge, - audienceId = verifier.keyPair.identifier, + audienceId = verifier.keyMaterial.identifier, presentationDefinition = singularPresentationDefinition, ).getOrNull() shouldBe null } "valid presentation is valid" { val credentials = issuer.issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT, ).getOrThrow() holder.storeCredential(credentials.toStoreCredentialInput()) val presentationParameters = holder.createPresentation( challenge = challenge, - audienceId = verifier.keyPair.identifier, + audienceId = verifier.keyMaterial.identifier, presentationDefinition = singularPresentationDefinition, ).getOrNull() presentationParameters.shouldNotBeNull() @@ -246,14 +246,14 @@ class AgentTest : FreeSpec({ "valid presentation is valid -- some other attributes revoked" { val credentials = issuer.issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT, ).getOrThrow() holder.storeCredential(credentials.toStoreCredentialInput()) val presentationParameters = holder.createPresentation( challenge = challenge, - audienceId = verifier.keyPair.identifier, + audienceId = verifier.keyMaterial.identifier, presentationDefinition = singularPresentationDefinition, ).getOrNull() presentationParameters.shouldNotBeNull() @@ -262,7 +262,7 @@ class AgentTest : FreeSpec({ vp.shouldBeInstanceOf() val credentialsToRevoke = issuer.issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT, ).getOrThrow() diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/DummyCredentialDataProvider.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/DummyCredentialDataProvider.kt index c5222a99e..ff2d13535 100644 --- a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/DummyCredentialDataProvider.kt +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/DummyCredentialDataProvider.kt @@ -5,8 +5,13 @@ import at.asitplus.catching import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.wallet.lib.data.AtomicAttribute2023 import at.asitplus.wallet.lib.data.ConstantIndex +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_DATE_OF_BIRTH +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_FAMILY_NAME +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_GIVEN_NAME +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_PORTRAIT import at.asitplus.wallet.lib.iso.IssuerSignedItem import kotlinx.datetime.Clock +import kotlinx.datetime.LocalDate import kotlin.random.Random import kotlin.time.Duration.Companion.minutes @@ -26,9 +31,10 @@ class DummyCredentialDataProvider( val claims = claimNames?.map { ClaimToBeIssued(it, "${it}_DUMMY_VALUE") } ?: listOf( - ClaimToBeIssued("given_name", "Susanne"), - ClaimToBeIssued("family_name", "Meier"), - ClaimToBeIssued("date_of_birth", "1990-01-01"), + ClaimToBeIssued(CLAIM_GIVEN_NAME, "Susanne"), + ClaimToBeIssued(CLAIM_FAMILY_NAME, "Meier"), + ClaimToBeIssued(CLAIM_DATE_OF_BIRTH, LocalDate.parse("1990-01-01")), + ClaimToBeIssued(CLAIM_PORTRAIT, Random.nextBytes(32)), ) val subjectId = subjectPublicKey.didEncoded when (representation) { @@ -38,7 +44,7 @@ class DummyCredentialDataProvider( ) ConstantIndex.CredentialRepresentation.PLAIN_JWT -> CredentialToBeIssued.VcJwt( - subject = AtomicAttribute2023(subjectId, "given_name", "Susanne"), + subject = AtomicAttribute2023(subjectId, CLAIM_GIVEN_NAME, "Susanne"), expiration = expiration, ) diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/ValidatorVcTest.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/ValidatorVcTest.kt index fb33c8b8a..8a6cfef39 100644 --- a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/ValidatorVcTest.kt +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/ValidatorVcTest.kt @@ -4,6 +4,7 @@ import at.asitplus.signum.indispensable.io.Base64UrlStrict import at.asitplus.signum.indispensable.josef.JwsAlgorithm import at.asitplus.signum.indispensable.josef.JwsHeader import at.asitplus.signum.indispensable.josef.JwsSigned +import at.asitplus.signum.supreme.signature import at.asitplus.wallet.lib.data.AtomicAttribute2023 import at.asitplus.wallet.lib.data.ConstantIndex import at.asitplus.wallet.lib.data.CredentialStatus @@ -28,9 +29,9 @@ class ValidatorVcTest : FreeSpec() { private lateinit var issuer: Issuer private lateinit var issuerCredentialStore: IssuerCredentialStore private lateinit var issuerJwsService: JwsService - private lateinit var issuerKeyPair: KeyPairAdapter + private lateinit var issuerKeyMaterial: KeyMaterial private lateinit var verifier: Verifier - private lateinit var verifierKeyPair: KeyPairAdapter + private lateinit var verifierKeyMaterial: KeyMaterial private val dataProvider: IssuerCredentialDataProvider = DummyCredentialDataProvider() private val revocationListUrl: String = "https://wallet.a-sit.at/backend/credentials/status/1" @@ -38,16 +39,16 @@ class ValidatorVcTest : FreeSpec() { init { beforeEach { issuerCredentialStore = InMemoryIssuerCredentialStore() - issuerKeyPair = RandomKeyPairAdapter() - issuer = IssuerAgent(issuerKeyPair, issuerCredentialStore, dataProvider) - issuerJwsService = DefaultJwsService(DefaultCryptoService(issuerKeyPair)) - verifierKeyPair = RandomKeyPairAdapter() - verifier = VerifierAgent(verifierKeyPair) + issuerKeyMaterial = EphemeralKeyWithoutCert() + issuer = IssuerAgent(issuerKeyMaterial, issuerCredentialStore, dataProvider) + issuerJwsService = DefaultJwsService(DefaultCryptoService(issuerKeyMaterial)) + verifierKeyMaterial = EphemeralKeyWithoutCert() + verifier = VerifierAgent(verifierKeyMaterial) } "credentials are valid for" { val credential = issuer.issueCredential( - verifierKeyPair.publicKey, + verifierKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT, ).getOrThrow() @@ -58,7 +59,7 @@ class ValidatorVcTest : FreeSpec() { "revoked credentials are not valid" { val credential = issuer.issueCredential( - verifierKeyPair.publicKey, + verifierKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT, ).getOrThrow() @@ -74,14 +75,14 @@ class ValidatorVcTest : FreeSpec() { verifier.verifyVcJws(credential.vcJws) .shouldBeInstanceOf() - val defaultValidator = Validator.newDefaultInstance(DefaultVerifierCryptoService()) + val defaultValidator = Validator() defaultValidator.setRevocationList(revocationListCredential) shouldBe true defaultValidator.checkRevocationStatus(value.jws.vc.credentialStatus!!.index) shouldBe Validator.RevocationStatus.REVOKED } "wrong subject keyId is not be valid" { val credential = issuer.issueCredential( - RandomKeyPairAdapter().publicKey, + EphemeralKeyWithoutCert().publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT, ).getOrThrow() @@ -93,7 +94,7 @@ class ValidatorVcTest : FreeSpec() { "credential with invalid JWS format is not valid" { val credential = issuer.issueCredential( - verifierKeyPair.publicKey, + verifierKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT, ).getOrThrow() @@ -105,7 +106,7 @@ class ValidatorVcTest : FreeSpec() { "Manually created and valid credential is valid" - { dataProvider.getCredential( - verifierKeyPair.publicKey, + verifierKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT ).getOrThrow().let { @@ -120,7 +121,7 @@ class ValidatorVcTest : FreeSpec() { "Wrong key ends in wrong signature is not valid" - { dataProvider.getCredential( - verifierKeyPair.publicKey, + verifierKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT ).getOrThrow().let { @@ -136,7 +137,7 @@ class ValidatorVcTest : FreeSpec() { "Invalid sub in credential is not valid" - { dataProvider.getCredential( - verifierKeyPair.publicKey, + verifierKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT ).getOrThrow().let { @@ -152,7 +153,7 @@ class ValidatorVcTest : FreeSpec() { "Invalid issuer in credential is not valid" - { dataProvider.getCredential( - verifierKeyPair.publicKey, + verifierKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT ).getOrThrow().let { @@ -167,7 +168,7 @@ class ValidatorVcTest : FreeSpec() { "Invalid jwtId in credential is not valid" - { dataProvider.getCredential( - verifierKeyPair.publicKey, + verifierKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT ).getOrThrow().let { @@ -183,7 +184,7 @@ class ValidatorVcTest : FreeSpec() { "Invalid expiration in credential is not valid" - { dataProvider.getCredential( - verifierKeyPair.publicKey, + verifierKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT ).getOrThrow().let { @@ -191,7 +192,7 @@ class ValidatorVcTest : FreeSpec() { .let { VerifiableCredentialJws( vc = it, - subject = verifier.keyPair.identifier, + subject = verifier.keyMaterial.identifier, notBefore = it.issuanceDate, issuer = it.issuer, expiration = Clock.System.now() + 1.hours, @@ -208,7 +209,7 @@ class ValidatorVcTest : FreeSpec() { "No expiration date is valid" - { dataProvider.getCredential( - verifierKeyPair.publicKey, + verifierKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT ).getOrThrow().let { @@ -223,7 +224,7 @@ class ValidatorVcTest : FreeSpec() { "Invalid jws-expiration in credential is not valid" - { dataProvider.getCredential( - verifierKeyPair.publicKey, + verifierKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT ).getOrThrow().let { @@ -239,7 +240,7 @@ class ValidatorVcTest : FreeSpec() { "Expiration not matching in credential is not valid" - { dataProvider.getCredential( - verifierKeyPair.publicKey, + verifierKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT ).getOrThrow().let { @@ -255,7 +256,7 @@ class ValidatorVcTest : FreeSpec() { "Invalid NotBefore in credential is not valid" - { dataProvider.getCredential( - verifierKeyPair.publicKey, + verifierKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT ).getOrThrow().let { @@ -271,7 +272,7 @@ class ValidatorVcTest : FreeSpec() { "Invalid issuance date in credential is not valid" - { dataProvider.getCredential( - verifierKeyPair.publicKey, + verifierKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT ).getOrThrow().let { @@ -287,7 +288,7 @@ class ValidatorVcTest : FreeSpec() { "Issuance date and not before not matching is not valid" - { dataProvider.getCredential( - verifierKeyPair.publicKey, + verifierKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT ).getOrThrow().let { @@ -315,7 +316,7 @@ class ValidatorVcTest : FreeSpec() { val exp = expirationDate ?: (Clock.System.now() + 60.seconds) val statusListIndex = issuerCredentialStore.storeGetNextIndex( credential = IssuerCredentialStore.Credential.VcJwt(vcId, sub, ConstantIndex.AtomicAttribute2023), - subjectPublicKey = issuerKeyPair.publicKey, + subjectPublicKey = issuerKeyMaterial.publicKey, issuanceDate = issuanceDate, expirationDate = exp, timePeriod = FixedTimePeriodProvider.timePeriod @@ -323,7 +324,7 @@ class ValidatorVcTest : FreeSpec() { val credentialStatus = CredentialStatus(revocationListUrl, statusListIndex) return VerifiableCredential( id = vcId, - issuer = issuer.keyPair.identifier, + issuer = issuer.keyMaterial.identifier, credentialStatus = credentialStatus, credentialSubject = sub, credentialType = type, @@ -334,7 +335,7 @@ class ValidatorVcTest : FreeSpec() { private fun wrapVcInJws( it: VerifiableCredential, - subject: String = verifier.keyPair.identifier, + subject: String = verifier.keyMaterial.identifier, issuer: String = it.issuer, jwtId: String = it.id, issuanceDate: Instant = it.issuanceDate, @@ -357,7 +358,7 @@ class ValidatorVcTest : FreeSpec() { private suspend fun wrapVcInJwsWrongKey(vcJws: VerifiableCredentialJws): String? { val jwsHeader = JwsHeader( algorithm = JwsAlgorithm.ES256, - keyId = verifier.keyPair.identifier, + keyId = verifier.keyMaterial.identifier, type = JwsContentTypeConstants.JWT ) val jwsPayload = vcJws.serialize().encodeToByteArray() @@ -365,8 +366,7 @@ class ValidatorVcTest : FreeSpec() { jwsHeader.serialize().encodeToByteArray().encodeToString(Base64UrlStrict) + "." + jwsPayload.encodeToString(Base64UrlStrict) val signatureInputBytes = signatureInput.encodeToByteArray() - val signature = DefaultCryptoService(issuerKeyPair).sign(signatureInputBytes) - .getOrElse { return null } + val signature = DefaultCryptoService(issuerKeyMaterial).sign(signatureInputBytes).signature return JwsSigned(jwsHeader, jwsPayload, signature, signatureInput).serialize() } diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/ValidatorVpTest.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/ValidatorVpTest.kt index adc5c7e1b..a8396ed3c 100644 --- a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/ValidatorVpTest.kt +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/ValidatorVpTest.kt @@ -2,11 +2,11 @@ package at.asitplus.wallet.lib.agent +import at.asitplus.dif.DifInputDescriptor import at.asitplus.wallet.lib.data.ConstantIndex import at.asitplus.wallet.lib.data.VerifiablePresentation import at.asitplus.wallet.lib.data.VerifiablePresentationJws -import at.asitplus.wallet.lib.data.dif.InputDescriptor -import at.asitplus.wallet.lib.data.dif.PresentationDefinition +import at.asitplus.dif.PresentationDefinition import at.asitplus.wallet.lib.jws.DefaultJwsService import at.asitplus.wallet.lib.jws.JwsContentTypeConstants import at.asitplus.wallet.lib.jws.JwsService @@ -21,7 +21,7 @@ import io.kotest.matchers.types.shouldBeInstanceOf class ValidatorVpTest : FreeSpec({ val singularPresentationDefinition = PresentationDefinition( id = uuid4().toString(), - inputDescriptors = listOf(InputDescriptor(id = uuid4().toString())) + inputDescriptors = listOf(DifInputDescriptor(id = uuid4().toString())) ) lateinit var validator: Validator @@ -30,28 +30,28 @@ class ValidatorVpTest : FreeSpec({ lateinit var holder: HolderAgent lateinit var holderCredentialStore: SubjectCredentialStore lateinit var holderJwsService: JwsService - lateinit var holderKeyPair: KeyPairAdapter + lateinit var holderKeyMaterial: KeyMaterial lateinit var verifier: Verifier lateinit var challenge: String beforeEach { - validator = Validator.newDefaultInstance(DefaultVerifierCryptoService()) + validator = Validator() issuerCredentialStore = InMemoryIssuerCredentialStore() issuer = IssuerAgent( - RandomKeyPairAdapter(), + EphemeralKeyWithoutCert(), issuerCredentialStore, DummyCredentialDataProvider(), ) holderCredentialStore = InMemorySubjectCredentialStore() - holderKeyPair = RandomKeyPairAdapter() - holder = HolderAgent(holderKeyPair, holderCredentialStore) - holderJwsService = DefaultJwsService(DefaultCryptoService(holderKeyPair)) + holderKeyMaterial = EphemeralKeyWithoutCert() + holder = HolderAgent(holderKeyMaterial, holderCredentialStore) + holderJwsService = DefaultJwsService(DefaultCryptoService(holderKeyMaterial)) verifier = VerifierAgent() challenge = uuid4().toString() holder.storeCredential( issuer.issueCredential( - holderKeyPair.publicKey, + holderKeyMaterial.publicKey, ConstantIndex.AtomicAttribute2023, ConstantIndex.CredentialRepresentation.PLAIN_JWT, ).getOrThrow().toStoreCredentialInput() @@ -61,7 +61,7 @@ class ValidatorVpTest : FreeSpec({ "correct challenge in VP leads to Success" { val presentationParameters = holder.createPresentation( challenge = challenge, - audienceId = verifier.keyPair.identifier, + audienceId = verifier.keyMaterial.identifier, presentationDefinition = singularPresentationDefinition, ).getOrNull() presentationParameters.shouldNotBeNull() @@ -79,7 +79,7 @@ class ValidatorVpTest : FreeSpec({ .filterIsInstance() .map { it.storeEntry.vcSerialized } .map { it.reversed() } - val vp = holder.createVcPresentation(holderVcSerialized, challenge, verifier.keyPair.identifier).getOrNull() + val vp = holder.createVcPresentation(holderVcSerialized, challenge, verifier.keyMaterial.identifier).getOrNull() vp.shouldNotBeNull() vp.shouldBeInstanceOf() @@ -93,7 +93,7 @@ class ValidatorVpTest : FreeSpec({ "wrong challenge in VP leads to InvalidStructure" { val presentationParameters = holder.createPresentation( challenge = "challenge", - audienceId = verifier.keyPair.identifier, + audienceId = verifier.keyMaterial.identifier, presentationDefinition = singularPresentationDefinition, ).getOrNull() presentationParameters.shouldNotBeNull() @@ -120,7 +120,7 @@ class ValidatorVpTest : FreeSpec({ "valid parsed presentation should separate revoked and valid credentials" { val presentationResults = holder.createPresentation( challenge = challenge, - audienceId = verifier.keyPair.identifier, + audienceId = verifier.keyMaterial.identifier, presentationDefinition = singularPresentationDefinition, ).getOrNull() presentationResults.shouldNotBeNull() @@ -160,7 +160,7 @@ class ValidatorVpTest : FreeSpec({ val vpSerialized = vp.toJws( challenge = challenge, issuerId = holder.keyPair.identifier, - audienceId = verifier.keyPair.identifier, + audienceId = verifier.keyMaterial.identifier, ).serialize() val jwsPayload = vpSerialized.encodeToByteArray() val vpJws = @@ -181,8 +181,8 @@ class ValidatorVpTest : FreeSpec({ val vpSerialized = VerifiablePresentationJws( vp = vp, challenge = challenge, - issuer = verifier.keyPair.identifier, - audience = verifier.keyPair.identifier, + issuer = verifier.keyMaterial.identifier, + audience = verifier.keyMaterial.identifier, jwtId = vp.id, ).serialize() val jwsPayload = vpSerialized.encodeToByteArray() @@ -204,7 +204,7 @@ class ValidatorVpTest : FreeSpec({ vp = vp, challenge = challenge, issuer = holder.keyPair.identifier, - audience = verifier.keyPair.identifier, + audience = verifier.keyMaterial.identifier, jwtId = "wrong_jwtId", ).serialize() val jwsPayload = vpSerialized.encodeToByteArray() @@ -230,7 +230,7 @@ class ValidatorVpTest : FreeSpec({ val vpSerialized = vp.toJws( challenge = challenge, issuerId = holder.keyPair.identifier, - audienceId = verifier.keyPair.identifier, + audienceId = verifier.keyMaterial.identifier, ).serialize() val jwsPayload = vpSerialized.encodeToByteArray() val vpJws = diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/cbor/CoseServiceTest.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/cbor/CoseServiceTest.kt index 0a3708843..bbf9f4ef1 100644 --- a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/cbor/CoseServiceTest.kt +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/cbor/CoseServiceTest.kt @@ -3,15 +3,14 @@ package at.asitplus.wallet.lib.cbor import at.asitplus.signum.indispensable.cosef.CoseAlgorithm import at.asitplus.signum.indispensable.cosef.CoseHeader import at.asitplus.signum.indispensable.cosef.CoseSigned +import at.asitplus.signum.indispensable.cosef.toCoseKey import at.asitplus.wallet.lib.agent.CryptoService import at.asitplus.wallet.lib.agent.DefaultCryptoService -import at.asitplus.wallet.lib.agent.RandomKeyPairAdapter +import at.asitplus.wallet.lib.agent.EphemeralKeyWithoutCert import io.kotest.core.spec.style.FreeSpec import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe -import io.matthewnelson.encoding.base16.Base16 -import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString import kotlin.random.Random class CoseServiceTest : FreeSpec({ @@ -22,7 +21,8 @@ class CoseServiceTest : FreeSpec({ lateinit var randomPayload: ByteArray beforeEach { - cryptoService = DefaultCryptoService(RandomKeyPairAdapter()) + val keyMaterial = EphemeralKeyWithoutCert() + cryptoService = DefaultCryptoService(keyMaterial) coseService = DefaultCoseService(cryptoService) verifierCoseService = DefaultVerifierCoseService() randomPayload = Random.nextBytes(32) @@ -35,16 +35,15 @@ class CoseServiceTest : FreeSpec({ addKeyId = true ).getOrThrow() signed.shouldNotBeNull() - println(signed.serialize().encodeToString(Base16(strict = true))) signed.payload shouldBe randomPayload signed.signature.shouldNotBeNull() val parsed = CoseSigned.deserialize(signed.serialize()).getOrThrow() - cryptoService.keyPairAdapter.coseKey shouldNotBe null - val result = verifierCoseService.verifyCose(parsed, cryptoService.keyPairAdapter.coseKey).getOrThrow() - result shouldBe true + cryptoService.keyMaterial.publicKey.toCoseKey().getOrNull() shouldNotBe null + val result = verifierCoseService.verifyCose(parsed, cryptoService.keyMaterial.publicKey.toCoseKey().getOrThrow()) + result.isSuccess shouldBe true } "signed object without payload can be verified" { @@ -54,16 +53,15 @@ class CoseServiceTest : FreeSpec({ addKeyId = true ).getOrThrow() signed.shouldNotBeNull() - println(signed.serialize().encodeToString(Base16(strict = true))) signed.payload shouldBe null signed.signature.shouldNotBeNull() val parsed = CoseSigned.deserialize(signed.serialize()).getOrThrow() - cryptoService.keyPairAdapter.coseKey shouldNotBe null - val result = verifierCoseService.verifyCose(parsed, cryptoService.keyPairAdapter.coseKey).getOrThrow() - result shouldBe true + cryptoService.keyMaterial.publicKey.toCoseKey().getOrNull() shouldNotBe null + val result = verifierCoseService.verifyCose(parsed, cryptoService.keyMaterial.publicKey.toCoseKey().getOrThrow()) + result.isSuccess shouldBe true } }) diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/cbor/IssuerSignedItemSerializationTest.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/cbor/IssuerSignedItemSerializationTest.kt new file mode 100644 index 000000000..2aa6883e9 --- /dev/null +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/cbor/IssuerSignedItemSerializationTest.kt @@ -0,0 +1,73 @@ +package at.asitplus.wallet.lib.cbor + +import at.asitplus.signum.indispensable.cosef.CoseHeader +import at.asitplus.signum.indispensable.cosef.CoseSigned +import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper +import at.asitplus.wallet.lib.iso.* +import com.benasher44.uuid.uuid4 +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldNotContain +import io.matthewnelson.encoding.base16.Base16 +import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString +import kotlinx.serialization.builtins.ByteArraySerializer +import kotlin.random.Random +import kotlin.random.nextUInt + +class IssuerSignedItemSerializationTest : FreeSpec({ + + "serialization with String" { + val item = IssuerSignedItem( + digestId = Random.nextUInt(), + random = Random.nextBytes(16), + elementIdentifier = uuid4().toString(), + elementValue = uuid4().toString(), + ) + + val serialized = item.serialize("foobar") + serialized.encodeToString(Base16(true)).shouldNotContain("D903EC") + + val parsed = IssuerSignedItem.deserialize(serialized, "").getOrThrow() + parsed shouldBe item + } + + "document serialization with ByteArray" { + + val elementId = uuid4().toString() + val namespace = uuid4().toString() + + + CborCredentialSerializer.register( + mapOf(elementId to ByteArraySerializer()), + namespace + + ) + val item = IssuerSignedItem( + digestId = Random.nextUInt(), + random = Random.nextBytes(16), + elementIdentifier = elementId, + elementValue = Random.nextBytes(32), + ) + + val protectedHeader = ByteStringWrapper(CoseHeader(), CoseHeader().serialize()) + val issuerAuth = CoseSigned(protectedHeader, null, null, byteArrayOf()) + val doc = Document( + docType = uuid4().toString(), + issuerSigned = IssuerSigned.fromIssuerSignedItems( + mapOf(namespace to listOf(item)), + issuerAuth + ), + deviceSigned = DeviceSigned( + ByteStringWrapper(DeviceNameSpaces(mapOf())), + DeviceAuth() + ) + ) + + val serialized = doc.serialize() + + serialized.encodeToString(Base16(true)).shouldNotContain("D903EC") + val parsed = Document.deserialize(serialized).getOrThrow() + parsed shouldBe doc + } + +}) diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/data/SubmissionRequirementsTest.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/data/SubmissionRequirementsTest.kt index 4fed70d64..ad1c40b30 100644 --- a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/data/SubmissionRequirementsTest.kt +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/data/SubmissionRequirementsTest.kt @@ -1,7 +1,7 @@ package at.asitplus.wallet.lib.data -import at.asitplus.wallet.lib.data.dif.SubmissionRequirement -import at.asitplus.wallet.lib.data.dif.SubmissionRequirementRuleEnum +import at.asitplus.dif.SubmissionRequirement +import at.asitplus.dif.SubmissionRequirementRuleEnum import io.kotest.core.spec.style.FreeSpec import io.kotest.matchers.shouldBe diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/data/TransactionDataInterop.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/data/TransactionDataInterop.kt new file mode 100644 index 000000000..82608f589 --- /dev/null +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/data/TransactionDataInterop.kt @@ -0,0 +1,179 @@ +package at.asitplus.wallet.lib.data + +import at.asitplus.dif.InputDescriptor +import at.asitplus.dif.InputDescriptorSerializer +import at.asitplus.dif.PresentationDefinition +import at.asitplus.dif.QesInputDescriptor +import at.asitplus.dif.rqes.Base64URLTransactionDataSerializer +import at.asitplus.dif.rqes.TransactionDataEntry +import at.asitplus.signum.indispensable.asn1.ObjectIdentifier +import at.asitplus.signum.indispensable.io.Base64UrlStrict +import at.asitplus.signum.indispensable.io.ByteArrayBase64Serializer +import io.github.aakira.napier.Napier +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.ktor.util.* +import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.encodeToJsonElement + +/** + * Test vectors taken from "Transaction Data entries as defined in D3.1: UC Specification WP3" + */ +class TransactionDataInterop : FreeSpec({ + val presentationDefinitionAsJsonString = """ + { + "id": "d76c51b7-ea90-49bb-8368-6b3d194fc131", + "input_descriptors": [ + { + "id": "IdentityCredential", + "format": { + "vc+sd-jwt": {} + }, + "constraints": { + "limit_disclosure": "required", + "fields": [ + { + "path": ["$.vct"], + "filter": { + "type": "string", + "const": "IdentityCredential" + } + }, + { + "path": ["$.family_name"] + }, + { + "path": ["$.given_name"] + } + ] + }, + "transaction_data": [ + "ewogICJ0eXBlIjogInFlc19hdXRob3JpemF0aW9uIiwKICAic2lnbmF0dXJlUXVhbGlmaWVyIjogImV1X2VpZGFzX3FlcyIsCiAgImNyZWRlbnRpYWxJRCI6ICJvRW92QzJFSEZpRUZyRHBVeDhtUjBvN3llR0hrMmg3NGIzWHl3a05nQkdvPSIsCiAgImRvY3VtZW50RGlnZXN0cyI6IFsKICAgIHsKICAgICAgImxhYmVsIjogIkV4YW1wbGUgQ29udHJhY3QiLAogICAgICAiaGFzaCI6ICJzVE9nd09tKzQ3NGdGajBxMHgxaVNOc3BLcWJjc2U0SWVpcWxEZy9IV3VJPSIsCiAgICAgICJoYXNoQWxnb3JpdGhtT0lEIjogIjIuMTYuODQwLjEuMTAxLjMuNC4yLjEiLAogICAgICAiZG9jdW1lbnRMb2NhdGlvbl91cmkiOiAiaHR0cHM6Ly9wcm90ZWN0ZWQucnAuZXhhbXBsZS9jb250cmFjdC0wMS5wZGY_dG9rZW49SFM5bmFKS1d3cDkwMWhCY0szNDhJVUhpdUg4Mzc0IiwKICAgICAgImRvY3VtZW50TG9jYXRpb25fbWV0aG9kIjogewogICAgICAgICJtZXRob2QiOiB7CiAgICAgICAgICAidHlwZSI6ICJwdWJsaWMiCiAgICAgICAgfQogICAgICB9LAogICAgICAiZHRic3IiOiAiVllEbDRvVGVKNVRtSVBDWEtkVFgxTVNXUkxJOUNLWWN5TVJ6NnhsYUdnIiwKICAgICAgImR0YnNySGFzaEFsZ29yaXRobU9JRCI6ICIyLjE2Ljg0MC4xLjEwMS4zLjQuMi4xIgogICAgfQogIF0sCiAgInByb2Nlc3NJRCI6ICJlT1o2VXdYeWVGTEs5OERvNTF4MzNmbXV2NE9xQXo1WmM0bHNoS050RWdRPSIKfQ", + "ewogICJ0eXBlIjogInFjZXJ0X2NyZWF0aW9uX2FjY2VwdGFuY2UiLAogICJRQ190ZXJtc19jb25kaXRpb25zX3VyaSI6ICJodHRwczovL2V4YW1wbGUuY29tL3RvcyIsCiAgIlFDX2hhc2giOiAia1hBZ3dEY2RBZTNvYnhwbzhVb0RrQytEK2I3T0NyRG84SU9HWmpTWDgvTT0iLAogICJRQ19oYXNoQWxnb3JpdGhtT0lEIjogIjIuMTYuODQwLjEuMTAxLjMuNC4yLjEiCn0=" + ] + } + ] + } + """.trimIndent().replace("\n", "").replace("\r", "").replace(" ", "") + + val transactionDataTest = TransactionDataEntry.QCertCreationAcceptance( + qcTermsConditionsUri = "abc", + qcHash = "cde".decodeBase64Bytes(), + qcHashAlgorithmOID = ObjectIdentifier("2.16.840.1.101.3.4.2.1") + ) + + "Serialization is stable" { + val test = vckJsonSerializer.encodeToString(transactionDataTest) + val test2 = vckJsonSerializer.decodeFromString(test) + test2 shouldBe transactionDataTest + } + + "Inputdesriptor serialize" { + val test = QesInputDescriptor( + id = "123", + transactionData = listOf(transactionDataTest) + ) + val serialized = vckJsonSerializer.encodeToString(test) + val deserialized = vckJsonSerializer.decodeFromString(serialized) + deserialized shouldBe test + } + + "TransactionDataEntry.QesAuthorization can be parsed" { + val testVector = + "ewogICJ0eXBlIjogInFlc19hdXRob3JpemF0aW9uIiwKICAic2lnbmF0dXJlUXVhbGlmaWVyIjogImV1X2VpZGFzX3FlcyIsCiAgImNyZWRlbnRpYWxJRCI6ICJvRW92QzJFSEZpRUZyRHBVeDhtUjBvN3llR0hrMmg3NGIzWHl3a05nQkdvPSIsCiAgImRvY3VtZW50RGlnZXN0cyI6IFsKICAgIHsKICAgICAgImxhYmVsIjogIkV4YW1wbGUgQ29udHJhY3QiLAogICAgICAiaGFzaCI6ICJzVE9nd09tKzQ3NGdGajBxMHgxaVNOc3BLcWJjc2U0SWVpcWxEZy9IV3VJPSIsCiAgICAgICJoYXNoQWxnb3JpdGhtT0lEIjogIjIuMTYuODQwLjEuMTAxLjMuNC4yLjEiLAogICAgICAiZG9jdW1lbnRMb2NhdGlvbl91cmkiOiAiaHR0cHM6Ly9wcm90ZWN0ZWQucnAuZXhhbXBsZS9jb250cmFjdC0wMS5wZGY_dG9rZW49SFM5bmFKS1d3cDkwMWhCY0szNDhJVUhpdUg4Mzc0IiwKICAgICAgImRvY3VtZW50TG9jYXRpb25fbWV0aG9kIjogewogICAgICAgICJtZXRob2QiOiB7CiAgICAgICAgICAidHlwZSI6ICJwdWJsaWMiCiAgICAgICAgfQogICAgICB9LAogICAgICAiZHRic3IiOiAiVllEbDRvVGVKNVRtSVBDWEtkVFgxTVNXUkxJOUNLWWN5TVJ6NnhsYUdnIiwKICAgICAgImR0YnNySGFzaEFsZ29yaXRobU9JRCI6ICIyLjE2Ljg0MC4xLjEwMS4zLjQuMi4xIgogICAgfQogIF0sCiAgInByb2Nlc3NJRCI6ICJlT1o2VXdYeWVGTEs5OERvNTF4MzNmbXV2NE9xQXo1WmM0bHNoS050RWdRPSIKfQ" + val transactionData = runCatching { + vckJsonSerializer.decodeFromString( + Base64URLTransactionDataSerializer, + vckJsonSerializer.encodeToString(testVector) + ) + }.getOrNull() + transactionData shouldNotBe null + val expected = vckJsonSerializer.decodeFromString( + testVector.decodeToByteArray(Base64UrlStrict).decodeToString() + ).canonicalize() as JsonObject + val actual = vckJsonSerializer.encodeToJsonElement(transactionData).canonicalize() as JsonObject + + //Manual comparison of every member to deal with Base64 encoding below + actual["credentialID"] shouldBe expected["credentialID"] + actual["processID"] shouldBe expected["processID"] + actual["signatureQualifier"] shouldBe expected["signatureQualifier"] + actual["type"] shouldBe expected["type"] + + val expectedDocumentDigest = (expected["documentDigests"] as JsonArray).first() as JsonObject + val actualDocumentDigest = (actual["documentDigests"] as JsonArray).first() as JsonObject + + actualDocumentDigest["documentLocation_method"] shouldBe expectedDocumentDigest["documentLocation_method"] + actualDocumentDigest["documentLocation_uri"] shouldBe expectedDocumentDigest["documentLocation_uri"] + + //In order to deal with padding we deserialize and compare the bytearrays + actualDocumentDigest["dtbsr"]?.let { + vckJsonSerializer.decodeFromJsonElement( + ByteArrayBase64Serializer, + it + ) + } shouldBe expectedDocumentDigest["dtbsr"]?.let { + vckJsonSerializer.decodeFromJsonElement( + ByteArrayBase64Serializer, + it + ) + } + actualDocumentDigest["dtbsrHashAlgorithmOID"] shouldBe expectedDocumentDigest["dtbsrHashAlgorithmOID"] + //In order to deal with padding we deserialize and compare the bytearrays + actualDocumentDigest["hash"]?.let { + vckJsonSerializer.decodeFromJsonElement( + ByteArrayBase64Serializer, + it + ) + } shouldBe expectedDocumentDigest["hash"]?.let { + vckJsonSerializer.decodeFromJsonElement( + ByteArrayBase64Serializer, + it + ) + } + actualDocumentDigest["hashHashAlgorithmOID"] shouldBe expectedDocumentDigest["hashHashAlgorithmOID"] + + } + + "TransactionDataEntry.QCertCreationAcceptance can be parsed" { + val testVector = + "ewogICJ0eXBlIjogInFjZXJ0X2NyZWF0aW9uX2FjY2VwdGFuY2UiLAogICJRQ190ZXJtc19jb25kaXRpb25zX3VyaSI6ICJodHRwczovL2V4YW1wbGUuY29tL3RvcyIsCiAgIlFDX2hhc2giOiAia1hBZ3dEY2RBZTNvYnhwbzhVb0RrQytEK2I3T0NyRG84SU9HWmpTWDgvTT0iLAogICJRQ19oYXNoQWxnb3JpdGhtT0lEIjogIjIuMTYuODQwLjEuMTAxLjMuNC4yLjEiCn0=" + val transactionData = runCatching { + vckJsonSerializer.decodeFromString( + Base64URLTransactionDataSerializer, + vckJsonSerializer.encodeToString(testVector) + ) + }.getOrNull() + transactionData shouldNotBe null + val expected = vckJsonSerializer.decodeFromString( + testVector.decodeToByteArray(Base64UrlStrict).decodeToString() + ).canonicalize() + val actual = vckJsonSerializer.encodeToJsonElement(transactionData).canonicalize() + actual shouldBe expected + } + + "The presentation Definition can be parsed" { + val presentationDefinition = + runCatching { vckJsonSerializer.decodeFromString(presentationDefinitionAsJsonString) }.getOrNull() + Napier.d(presentationDefinition.toString()) + presentationDefinition shouldNotBe null + (presentationDefinition?.inputDescriptors?.first() as QesInputDescriptor).transactionData shouldNotBe null + } +}) + +/** + * Sorts all entries of the JsonElement which is necessary in case we want to compare two objects + */ +fun JsonElement.canonicalize(): JsonElement = + when (this) { + is JsonObject -> JsonObject(this.entries.sortedBy { it.key }.associate { it.key to it.value.canonicalize() }) + is JsonArray -> JsonArray(this.map { it.canonicalize() }.sortedBy { vckJsonSerializer.encodeToString(it) }) + is JsonPrimitive -> this + JsonNull -> this + } diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/iso/IsoProcessTest.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/iso/IsoProcessTest.kt new file mode 100644 index 000000000..aba97656c --- /dev/null +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/iso/IsoProcessTest.kt @@ -0,0 +1,239 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.signum.indispensable.cosef.CoseHeader +import at.asitplus.signum.indispensable.cosef.CoseKey +import at.asitplus.signum.indispensable.cosef.CoseSigned +import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper +import at.asitplus.signum.indispensable.cosef.toCoseKey +import at.asitplus.wallet.lib.agent.DefaultCryptoService +import at.asitplus.wallet.lib.agent.EphemeralKeyWithoutCert +import at.asitplus.wallet.lib.cbor.DefaultCoseService +import at.asitplus.wallet.lib.cbor.DefaultVerifierCoseService +import at.asitplus.wallet.lib.data.ConstantIndex +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_FAMILY_NAME +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_GIVEN_NAME +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.matthewnelson.encoding.base16.Base16 +import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString +import kotlinx.datetime.Clock +import kotlin.random.Random + +class IsoProcessTest : FreeSpec({ + + "issue, store, present, verify" { + val wallet = Wallet() + val verifier = Verifier() + val issuer = Issuer() + + val deviceResponse = issuer.buildDeviceResponse(wallet.deviceKeyInfo) + wallet.storeMdl(deviceResponse) + + val verifierRequest = verifier.buildDeviceRequest() + val walletResponse = wallet.buildDeviceResponse(verifierRequest) + verifier.verifyResponse(walletResponse, issuer.cryptoService.keyMaterial.publicKey.toCoseKey().getOrThrow()) + } + +}) + +class Wallet { + + private val cryptoService = DefaultCryptoService(EphemeralKeyWithoutCert()) + private val coseService = DefaultCoseService(cryptoService) + + val deviceKeyInfo = DeviceKeyInfo(cryptoService.keyMaterial.publicKey.toCoseKey().getOrThrow()) + private var storedIssuerAuth: CoseSigned? = null + private var storedMdlItems: IssuerSignedList? = null + + fun storeMdl(deviceResponse: DeviceResponse) { + val document = deviceResponse.documents?.first().shouldNotBeNull() + document.docType shouldBe ConstantIndex.AtomicAttribute2023.isoDocType + val issuerAuth = document.issuerSigned.issuerAuth + this.storedIssuerAuth = issuerAuth + + issuerAuth.payload.shouldNotBeNull() + val mso = document.issuerSigned.getIssuerAuthPayloadAsMso().getOrThrow() + + val mdlItems = document.issuerSigned.namespaces?.get(ConstantIndex.AtomicAttribute2023.isoNamespace) + .shouldNotBeNull() + this.storedMdlItems = mdlItems + mso.valueDigests[ConstantIndex.AtomicAttribute2023.isoNamespace].shouldNotBeNull() + + extractDataString(mdlItems, CLAIM_GIVEN_NAME).shouldNotBeNull() + extractDataString(mdlItems, CLAIM_FAMILY_NAME).shouldNotBeNull() + } + + suspend fun buildDeviceResponse(verifierRequest: DeviceRequest): DeviceResponse { + val itemsRequest = verifierRequest.docRequests[0].itemsRequest + val isoNamespace = itemsRequest.value.namespaces[ConstantIndex.AtomicAttribute2023.isoNamespace] + .shouldNotBeNull() + val requestedKeys = isoNamespace.entries.filter { it.value }.map { it.key } + return DeviceResponse( + version = "1.0", + documents = arrayOf( + Document( + docType = ConstantIndex.AtomicAttribute2023.isoDocType, + issuerSigned = IssuerSigned.fromIssuerSignedItems( + namespacedItems = mapOf( + ConstantIndex.AtomicAttribute2023.isoNamespace to storedMdlItems!!.entries.filter { + it.value.elementIdentifier in requestedKeys + }.map { it.value } + ), + issuerAuth = storedIssuerAuth!! + ), + deviceSigned = DeviceSigned( + namespaces = ByteStringWrapper(DeviceNameSpaces(mapOf())), + deviceAuth = DeviceAuth( + deviceSignature = coseService.createSignedCose( + payload = null, + addKeyId = false + ).getOrThrow() + ) + ) + ) + ), + status = 0U, + ) + } + +} + +class Issuer { + + val cryptoService = DefaultCryptoService(EphemeralKeyWithoutCert()) + private val coseService = DefaultCoseService(cryptoService) + + suspend fun buildDeviceResponse(walletKeyInfo: DeviceKeyInfo): DeviceResponse { + val issuerSigned = listOf( + buildIssuerSignedItem(CLAIM_FAMILY_NAME, "Meier", 0U), + buildIssuerSignedItem(CLAIM_GIVEN_NAME, "Susanne", 1U), + ) + + val mso = MobileSecurityObject( + version = "1.0", + digestAlgorithm = "SHA-256", + valueDigests = mapOf( + ConstantIndex.AtomicAttribute2023.isoNamespace to ValueDigestList(entries = issuerSigned.map { + ValueDigest.fromIssuerSignedItem(it, ConstantIndex.AtomicAttribute2023.isoNamespace) + }) + ), + deviceKeyInfo = walletKeyInfo, + docType = ConstantIndex.AtomicAttribute2023.isoDocType, + validityInfo = ValidityInfo( + signed = Clock.System.now(), + validFrom = Clock.System.now(), + validUntil = Clock.System.now(), + ) + ) + + return DeviceResponse( + version = "1.0", + documents = arrayOf( + Document( + docType = ConstantIndex.AtomicAttribute2023.isoDocType, + issuerSigned = IssuerSigned.fromIssuerSignedItems( + namespacedItems = mapOf( + ConstantIndex.AtomicAttribute2023.isoNamespace to issuerSigned + ), + issuerAuth = coseService.createSignedCose( + payload = mso.serializeForIssuerAuth(), + addKeyId = false, + addCertificate = true, + ).getOrThrow() + ), + deviceSigned = DeviceSigned( + namespaces = ByteStringWrapper(DeviceNameSpaces(mapOf())), + deviceAuth = DeviceAuth() + ) + ) + ), + status = 0U, + ) + } +} + +class Verifier { + + private val cryptoService = DefaultCryptoService(EphemeralKeyWithoutCert()) + private val coseService = DefaultCoseService(cryptoService) + private val verifierCoseService = DefaultVerifierCoseService() + + suspend fun buildDeviceRequest() = DeviceRequest( + version = "1.0", + docRequests = arrayOf( + DocRequest( + itemsRequest = ByteStringWrapper( + value = ItemsRequest( + docType = ConstantIndex.AtomicAttribute2023.isoDocType, + namespaces = mapOf( + ConstantIndex.AtomicAttribute2023.isoNamespace to ItemsRequestList( + listOf( + SingleItemsRequest(CLAIM_FAMILY_NAME, true), + SingleItemsRequest(CLAIM_GIVEN_NAME, true), + ) + ) + ) + ) + ), + readerAuth = coseService.createSignedCose( + unprotectedHeader = CoseHeader(), + payload = null, + addKeyId = false, + ).getOrThrow() + ) + ) + ) + + fun verifyResponse(deviceResponse: DeviceResponse, issuerKey: CoseKey) { + val documents = deviceResponse.documents.shouldNotBeNull() + val doc = documents.first() + doc.docType shouldBe ConstantIndex.AtomicAttribute2023.isoDocType + doc.errors.shouldBeNull() + val issuerSigned = doc.issuerSigned + val issuerAuth = issuerSigned.issuerAuth + verifierCoseService.verifyCose(issuerAuth, issuerKey).isSuccess shouldBe true + issuerAuth.payload.shouldNotBeNull() + val mso = issuerSigned.getIssuerAuthPayloadAsMso().getOrThrow() + + mso.docType shouldBe ConstantIndex.AtomicAttribute2023.isoDocType + val mdlItems = mso.valueDigests[ConstantIndex.AtomicAttribute2023.isoNamespace].shouldNotBeNull() + + val walletKey = mso.deviceKeyInfo.deviceKey + val deviceSignature = doc.deviceSigned.deviceAuth.deviceSignature.shouldNotBeNull() + verifierCoseService.verifyCose(deviceSignature, walletKey).isSuccess shouldBe true + val namespaces = issuerSigned.namespaces.shouldNotBeNull() + val issuerSignedItems = namespaces[ConstantIndex.AtomicAttribute2023.isoNamespace].shouldNotBeNull() + + extractAndVerifyData(issuerSignedItems, mdlItems, CLAIM_FAMILY_NAME) + extractAndVerifyData(issuerSignedItems, mdlItems, CLAIM_GIVEN_NAME) + } + + private fun extractAndVerifyData( + issuerSignedItems: IssuerSignedList, + mdlItems: ValueDigestList, + key: String + ) { + val issuerSignedItem = issuerSignedItems.entries.first { it.value.elementIdentifier == key } + //val elementValue = issuerSignedItem.value.elementValue.toString().shouldNotBeNull() + val issuerHash = mdlItems.entries.first { it.key == issuerSignedItem.value.digestId }.shouldNotBeNull() + val verifierHash = issuerSignedItem.serialized.sha256() + verifierHash.encodeToString(Base16(true)) shouldBe issuerHash.value.encodeToString(Base16(true)) + } +} + +private fun extractDataString( + mdlItems: IssuerSignedList, + key: String +): String { + val element = mdlItems.entries.first { it.value.elementIdentifier == key } + return element.value.elementValue.toString().shouldNotBeNull() +} + +fun buildIssuerSignedItem(elementIdentifier: String, elementValue: Any, digestId: UInt) = IssuerSignedItem( + digestId = digestId, + random = Random.nextBytes(16), + elementIdentifier = elementIdentifier, + elementValue = elementValue +) diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/iso/Tag0SerializationTest.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/iso/Tag0SerializationTest.kt new file mode 100644 index 000000000..2d1e9dc81 --- /dev/null +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/iso/Tag0SerializationTest.kt @@ -0,0 +1,40 @@ +package at.asitplus.wallet.lib.iso + +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.matthewnelson.encoding.base16.Base16 +import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString +import kotlinx.datetime.Clock +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray + +/** + * Test correct appending tag 0 (in hex `C0`) for certain data elements, + * as defined by ISO/IEC 18013-5:2021 + */ +@OptIn(ExperimentalSerializationApi::class) +class Tag0SerializationTest : FreeSpec({ + + "ValidityInfo" { + val input = ValidityInfo( + signed = Clock.System.now(), + validFrom = Clock.System.now(), + validUntil = Clock.System.now(), + expectedUpdate = Clock.System.now(), + ) + + val serialized = vckCborSerializer.encodeToByteArray(input) + + val text = "78" // COSE "text" for text value, i.e. the serialized Instant + val tag0 = "C0$text" // COSE tag 0 plus "text" + val hexEncoded = serialized.encodeToString(Base16(true)) + hexEncoded.shouldContain("7369676E6564$tag0") // "signed" + hexEncoded.shouldContain("76616C696446726F6D$tag0") // "validFrom" + hexEncoded.shouldContain("76616C6964556E74696C$tag0") // "validUntil" + hexEncoded.shouldContain("6578706563746564557064617465$tag0") // "expectedUpdate" + vckCborSerializer.decodeFromByteArray(serialized) shouldBe input + } + +}) diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/iso/Tag24SerializationTest.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/iso/Tag24SerializationTest.kt new file mode 100644 index 000000000..39055143c --- /dev/null +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/iso/Tag24SerializationTest.kt @@ -0,0 +1,139 @@ +package at.asitplus.wallet.lib.iso + +import arrow.core.fold +import at.asitplus.signum.indispensable.cosef.* +import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper +import at.asitplus.wallet.lib.agent.DummyCredentialDataProvider +import at.asitplus.wallet.lib.agent.EphemeralKeyWithSelfSignedCert +import at.asitplus.wallet.lib.agent.Issuer +import at.asitplus.wallet.lib.agent.IssuerAgent +import at.asitplus.wallet.lib.data.ConstantIndex +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.maps.shouldNotBeEmpty +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContainOnlyOnce +import io.kotest.matchers.string.shouldStartWith +import io.kotest.matchers.types.shouldBeInstanceOf +import io.matthewnelson.encoding.base16.Base16 +import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString +import kotlinx.datetime.Clock +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray +import kotlin.random.Random + +/** + * Test correct appending tag 24 (in hex `D818`) for certain data structures, + * as defined by ISO/IEC 18013-5:2021 + */ +@OptIn(ExperimentalSerializationApi::class) +class Tag24SerializationTest : FreeSpec({ + + "DeviceSigned" { + val input = DeviceSigned( + namespaces = ByteStringWrapper( + DeviceNameSpaces( + mapOf( + "iso.namespace" to DeviceSignedItemList( + listOf( + DeviceSignedItem("name", "foo"), + DeviceSignedItem("date", "bar") + ) + ) + ) + ) + ), + deviceAuth = DeviceAuth( + deviceSignature = issuerAuth() + ) + ) + + val serialized = vckCborSerializer.encodeToByteArray(input) + + serialized.encodeToString(Base16(true)).shouldContainOnlyOnce("D818") + vckCborSerializer.decodeFromByteArray(serialized) shouldBe input + } + + "DocRequest" { + val input = DocRequest( + itemsRequest = ByteStringWrapper(ItemsRequest("docType", mapOf(), null)), + ) + + val serialized = vckCborSerializer.encodeToByteArray(input) + + serialized.encodeToString(Base16(true)).shouldContainOnlyOnce("D818") + vckCborSerializer.decodeFromByteArray(serialized) shouldBe input + } + + "IssuerSigned" { + val input = IssuerSigned.fromIssuerSignedItems( + namespacedItems = mapOf( + "org.iso.something" to listOf(issuerSignedItem()) + ), + issuerAuth = issuerAuth() + ) + + val serialized = vckCborSerializer.encodeToByteArray(input) + + serialized.encodeToString(Base16(true)).shouldContainOnlyOnce("D818") + vckCborSerializer.decodeFromByteArray(serialized) shouldBe input + } + + "IssuerSigned from IssuerAgent" { + val issuerAgent = IssuerAgent(dataProvider = DummyCredentialDataProvider()) + val holderKeyMaterial = EphemeralKeyWithSelfSignedCert() + val issuedCredential = issuerAgent.issueCredential( + holderKeyMaterial.publicKey, + ConstantIndex.AtomicAttribute2023, + ConstantIndex.CredentialRepresentation.ISO_MDOC + ).getOrThrow().shouldBeInstanceOf() + + issuedCredential.issuerSigned.namespaces!!.shouldNotBeEmpty() + val numberOfClaims = issuedCredential.issuerSigned.namespaces!!.fold(0) { acc, entry -> + acc + entry.value.entries.size + } + val serialized = issuedCredential.issuerSigned.serialize().encodeToString(Base16(true)) + "D818".toRegex().findAll(serialized).toList().shouldHaveSize(numberOfClaims + 1) + // add 1 for MSO in IssuerAuth + } + + "IssuerAuth" { + val mso = MobileSecurityObject( + version = "1.0", + digestAlgorithm = "SHA-256", + valueDigests = mapOf("foo" to ValueDigestList(listOf(ValueDigest(0U, byteArrayOf())))), + deviceKeyInfo = deviceKeyInfo(), + docType = "docType", + validityInfo = ValidityInfo(Clock.System.now(), Clock.System.now(), Clock.System.now()) + ) + val serializedMso = mso.serializeForIssuerAuth() + val input = CoseSigned( + protectedHeader = ByteStringWrapper(CoseHeader()), + unprotectedHeader = null, + payload = serializedMso, + rawSignature = byteArrayOf() + ) + + val serialized = vckCborSerializer.encodeToByteArray(input) + + serialized.encodeToString(Base16(true)).shouldContainOnlyOnce("D818") + serializedMso.encodeToString(Base16(true)).shouldStartWith("D818") + vckCborSerializer.decodeFromByteArray(serialized) shouldBe input + MobileSecurityObject.deserializeFromIssuerAuth(serializedMso).getOrThrow() shouldBe mso + } + + +}) + +private fun deviceKeyInfo() = + DeviceKeyInfo(CoseKey(CoseKeyType.EC2, keyParams = CoseKeyParams.EcYBoolParams(CoseEllipticCurve.P256))) + +private fun issuerAuth() = CoseSigned( + protectedHeader = ByteStringWrapper(CoseHeader()), + unprotectedHeader = null, + payload = byteArrayOf(), + rawSignature = byteArrayOf() +) + +private fun issuerSignedItem() = IssuerSignedItem(0u, Random.nextBytes(16), "identifier", "value") diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/jws/JwsServiceTest.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/jws/JwsServiceTest.kt index 0b2b9c09b..ca7e6ce9b 100644 --- a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/jws/JwsServiceTest.kt +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/jws/JwsServiceTest.kt @@ -1,16 +1,10 @@ package at.asitplus.wallet.lib.jws import at.asitplus.signum.indispensable.io.Base64UrlStrict -import at.asitplus.signum.indispensable.josef.JsonWebKeySet -import at.asitplus.signum.indispensable.josef.JweAlgorithm -import at.asitplus.signum.indispensable.josef.JweEncrypted -import at.asitplus.signum.indispensable.josef.JweEncryption -import at.asitplus.signum.indispensable.josef.JwsAlgorithm -import at.asitplus.signum.indispensable.josef.JwsHeader -import at.asitplus.signum.indispensable.josef.JwsSigned +import at.asitplus.signum.indispensable.josef.* import at.asitplus.wallet.lib.agent.CryptoService import at.asitplus.wallet.lib.agent.DefaultCryptoService -import at.asitplus.wallet.lib.agent.RandomKeyPairAdapter +import at.asitplus.wallet.lib.agent.EphemeralKeyWithoutCert import at.asitplus.wallet.lib.data.vckJsonSerializer import com.benasher44.uuid.uuid4 import io.kotest.core.spec.style.FreeSpec @@ -29,7 +23,8 @@ class JwsServiceTest : FreeSpec({ lateinit var randomPayload: String beforeEach { - cryptoService = DefaultCryptoService(RandomKeyPairAdapter()) + val keyPairAdapter = EphemeralKeyWithoutCert() + cryptoService = DefaultCryptoService(keyPairAdapter) jwsService = DefaultJwsService(cryptoService) verifierJwsService = DefaultVerifierJwsService() randomPayload = uuid4().toString() @@ -48,7 +43,7 @@ class JwsServiceTest : FreeSpec({ val signed = jwsService.createSignedJwt(JwsContentTypeConstants.JWT, payload).getOrThrow().serialize() signed.shouldNotBeNull() - val parsed = JwsSigned.parse(signed).getOrThrow() + val parsed = JwsSigned.deserialize(signed).getOrThrow() parsed.serialize() shouldBe signed parsed.payload shouldBe payload @@ -75,7 +70,7 @@ class JwsServiceTest : FreeSpec({ "signed object with jsonWebKey can be verified" { val payload = randomPayload.encodeToByteArray() - val header = JwsHeader(algorithm = JwsAlgorithm.ES256, jsonWebKey = cryptoService.keyPairAdapter.jsonWebKey) + val header = JwsHeader(algorithm = JwsAlgorithm.ES256, jsonWebKey = cryptoService.keyMaterial.jsonWebKey) val signed = jwsService.createSignedJws(header, payload).getOrThrow() signed.shouldNotBeNull() val result = verifierJwsService.verifyJwsObject(signed) @@ -88,7 +83,7 @@ class JwsServiceTest : FreeSpec({ val jku = "https://example.com/" + Random.nextBytes(16).encodeToString(Base64UrlStrict) val header = JwsHeader(algorithm = JwsAlgorithm.ES256, keyId = kid, jsonWebKeySetUrl = jku) val signed = jwsService.createSignedJws(header, payload).getOrThrow() - val validKey = cryptoService.keyPairAdapter.jsonWebKey.copy(keyId = kid) + val validKey = cryptoService.keyMaterial.jsonWebKey.copy(keyId = kid) val jwkSetRetriever: JwkSetRetrieverFunction = { JsonWebKeySet(keys = listOf(validKey)) } verifierJwsService = DefaultVerifierJwsService(jwkSetRetriever = jwkSetRetriever) verifierJwsService.verifyJwsObject(signed) shouldBe true @@ -100,7 +95,7 @@ class JwsServiceTest : FreeSpec({ val jku = "https://example.com/" + Random.nextBytes(16).encodeToString(Base64UrlStrict) val header = JwsHeader(algorithm = JwsAlgorithm.ES256, keyId = kid, jsonWebKeySetUrl = jku) val signed = jwsService.createSignedJws(header, payload).getOrThrow() - val invalidKey = RandomKeyPairAdapter().jsonWebKey + val invalidKey = EphemeralKeyWithoutCert().jsonWebKey val jwkSetRetriever: JwkSetRetrieverFunction = { JsonWebKeySet(keys = listOf(invalidKey)) } verifierJwsService = DefaultVerifierJwsService(jwkSetRetriever = jwkSetRetriever) verifierJwsService.verifyJwsObject(signed) shouldBe false @@ -111,13 +106,13 @@ class JwsServiceTest : FreeSpec({ val encrypted = jwsService.encryptJweObject( JwsContentTypeConstants.DIDCOMM_ENCRYPTED_JSON, stringPayload.encodeToByteArray(), - cryptoService.keyPairAdapter.jsonWebKey, + cryptoService.keyMaterial.jsonWebKey, JwsContentTypeConstants.DIDCOMM_PLAIN_JSON, JweAlgorithm.ECDH_ES, JweEncryption.A256GCM, ).getOrThrow().serialize() encrypted.shouldNotBeNull() - val parsed = JweEncrypted.parse(encrypted).getOrThrow() + val parsed = JweEncrypted.deserialize(encrypted).getOrThrow() val result = jwsService.decryptJweObject(parsed, encrypted).getOrThrow() result.payload.decodeToString() shouldBe stringPayload diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/jws/SdJwtSerializationTest.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/jws/SdJwtSerializationTest.kt index 827f933bb..d6fbb67be 100644 --- a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/jws/SdJwtSerializationTest.kt +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/jws/SdJwtSerializationTest.kt @@ -11,6 +11,7 @@ import io.matthewnelson.encoding.base64.Base64 import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString import kotlin.random.Random +import kotlin.random.nextUInt class SdJwtSerializationTest : FreeSpec({ @@ -20,7 +21,7 @@ class SdJwtSerializationTest : FreeSpec({ val value = Random.nextBytes(16).encodeToString(Base64()) val item = SelectiveDisclosureItem(salt, name, value) - val serialized = item.serialize().also { println(it) } + val serialized = item.serialize() serialized shouldContain "[" serialized shouldContain """"${salt.encodeToString(Base64UrlStrict)}"""" @@ -32,13 +33,31 @@ class SdJwtSerializationTest : FreeSpec({ deserialized shouldBe item } + "Serialization is correct for ByteArray" { + val salt = Random.nextBytes(32) + val name = Random.nextBytes(16).encodeToString(Base64()) + val value = Random.nextBytes(16) + val item = SelectiveDisclosureItem(salt, name, value) + + val serialized = item.serialize() + + serialized shouldContain "[" + serialized shouldContain """"${salt.encodeToString(Base64UrlStrict)}"""" + serialized shouldContain """"$name"""" + serialized shouldContain """"${value.encodeToString(Base64UrlStrict)}"""" + serialized shouldContain "]" + + val deserialized = SelectiveDisclosureItem.deserialize(serialized).getOrThrow() + deserialized shouldBe item + } + "Serialization is correct for Boolean" { val salt = Random.nextBytes(32) val name = Random.nextBytes(16).encodeToString(Base64()) val value = true val item = SelectiveDisclosureItem(salt, name, value) - val serialized = item.serialize().also { println(it) } + val serialized = item.serialize() serialized shouldContain "[" serialized shouldContain """$value""" @@ -49,13 +68,30 @@ class SdJwtSerializationTest : FreeSpec({ deserialized shouldBe item } - "Serialization is correct for Number" { + "Serialization is correct for Long" { val salt = Random.nextBytes(32) val name = Random.nextBytes(16).encodeToString(Base64()) val value = Random.nextLong() val item = SelectiveDisclosureItem(salt, name, value) - val serialized = item.serialize().also { println(it) } + val serialized = item.serialize() + + serialized shouldContain "[" + serialized shouldContain """$value""" + serialized shouldNotContain """"$value"""" + serialized shouldContain "]" + + val deserialized = SelectiveDisclosureItem.deserialize(serialized).getOrThrow() + deserialized shouldBe item + } + + "Serialization is correct for UInt" { + val salt = Random.nextBytes(32) + val name = Random.nextBytes(16).encodeToString(Base64()) + val value = Random.nextUInt() + val item = SelectiveDisclosureItem(salt, name, value) + + val serialized = item.serialize() serialized shouldContain "[" serialized shouldContain """$value""" diff --git a/vck/src/iosMain/kotlin/at/asitplus/wallet/lib/DefaultZlibService.kt b/vck/src/iosMain/kotlin/at/asitplus/wallet/lib/DefaultZlibService.kt index ef1d61335..eb8170d3b 100644 --- a/vck/src/iosMain/kotlin/at/asitplus/wallet/lib/DefaultZlibService.kt +++ b/vck/src/iosMain/kotlin/at/asitplus/wallet/lib/DefaultZlibService.kt @@ -2,16 +2,8 @@ package at.asitplus.wallet.lib -import at.asitplus.wallet.lib.agent.toByteArray -import at.asitplus.wallet.lib.agent.toData -import kotlinx.cinterop.ObjCObjectVar -import kotlinx.cinterop.alloc -import kotlinx.cinterop.memScoped -import kotlinx.cinterop.ptr -import platform.Foundation.NSDataCompressionAlgorithmZlib -import platform.Foundation.NSError -import platform.Foundation.compressedDataUsingAlgorithm -import platform.Foundation.decompressedDataUsingAlgorithm +import kotlinx.cinterop.* +import platform.Foundation.* actual class DefaultZlibService actual constructor() : ZlibService { @@ -69,4 +61,18 @@ actual class DefaultZlibService actual constructor() : ZlibService { } } -} \ No newline at end of file +} + +inline fun MemScope.toData(array: ByteArray): NSData = + NSData.create( + bytes = allocArrayOf(array), + length = array.size.toULong() + ) + +// from https://github.com/mirego/trikot.foundation/pull/41/files +public fun NSData.toByteArray(): ByteArray { + return this.bytes?.let { + val dataPointer: CPointer = it.reinterpret() + ByteArray(this.length.toInt()) { index -> dataPointer[index] } + } ?: ByteArray(0) +} diff --git a/vck/src/iosMain/kotlin/at/asitplus/wallet/lib/agent/DefaultCryptoService.kt b/vck/src/iosMain/kotlin/at/asitplus/wallet/lib/agent/DefaultCryptoService.kt index 2642c16bf..75b0e460e 100644 --- a/vck/src/iosMain/kotlin/at/asitplus/wallet/lib/agent/DefaultCryptoService.kt +++ b/vck/src/iosMain/kotlin/at/asitplus/wallet/lib/agent/DefaultCryptoService.kt @@ -3,26 +3,13 @@ package at.asitplus.wallet.lib.agent import at.asitplus.KmmResult -import at.asitplus.signum.indispensable.CryptoPublicKey -import at.asitplus.signum.indispensable.CryptoSignature -import at.asitplus.signum.indispensable.ECCurve -import at.asitplus.signum.indispensable.X509SignatureAlgorithm -import at.asitplus.signum.indispensable.cosef.CoseKey -import at.asitplus.signum.indispensable.cosef.toCoseAlgorithm -import at.asitplus.signum.indispensable.cosef.toCoseKey import at.asitplus.signum.indispensable.io.Base64Strict -import at.asitplus.signum.indispensable.josef.* -import at.asitplus.signum.indispensable.pki.X509Certificate -import at.asitplus.signum.indispensable.pki.X509CertificateExtension -import at.asitplus.wallet.lib.agent.DefaultCryptoService.Companion.signInt +import at.asitplus.signum.indispensable.josef.JsonWebKey +import at.asitplus.signum.indispensable.josef.JweAlgorithm +import at.asitplus.signum.indispensable.josef.JweEncryption import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString -import kotlinx.cinterop.* -import platform.CoreFoundation.CFDataRef -import platform.CoreFoundation.CFDictionaryCreateMutable -import platform.Foundation.* -import platform.Security.* +import kotlinx.cinterop.ExperimentalForeignApi import kotlin.experimental.ExperimentalNativeApi -import platform.CoreFoundation.CFDictionaryAddValue as CFDictionaryAddValue1 /** @@ -33,23 +20,9 @@ import platform.CoreFoundation.CFDictionaryAddValue as CFDictionaryAddValue1 * Beware: It does **not** implement encryption, decryption, key agreement and message digest correctly. */ @Suppress("UNCHECKED_CAST") -actual class DefaultCryptoService : CryptoService { +actual open class PlatformCryptoShim actual constructor(actual val keyMaterial: KeyMaterial) { - actual override val keyPairAdapter: KeyPairAdapter - private val iosKeyPairAdapter: IosKeyPairAdapter - - actual constructor(keyPairAdapter: KeyPairAdapter) { - assert(keyPairAdapter is IosKeyPairAdapter) - keyPairAdapter as IosKeyPairAdapter - this.keyPairAdapter = keyPairAdapter - this.iosKeyPairAdapter = keyPairAdapter - } - - actual override suspend fun doSign(input: ByteArray): KmmResult { - return KmmResult.success(CryptoSignature.decodeFromDer(signInt(input, iosKeyPairAdapter.secPrivateKey))) - } - - actual override fun encrypt( + actual open fun encrypt( key: ByteArray, iv: ByteArray, aad: ByteArray, @@ -64,7 +37,7 @@ actual class DefaultCryptoService : CryptoService { ) } - actual override suspend fun decrypt( + actual open suspend fun decrypt( key: ByteArray, iv: ByteArray, aad: ByteArray, @@ -78,19 +51,7 @@ actual class DefaultCryptoService : CryptoService { KmmResult.failure(IllegalArgumentException()) } - actual override fun generateEphemeralKeyPair(ecCurve: ECCurve): KmmResult { - val query = CFDictionaryCreateMutable(null, 2, null, null).apply { - CFDictionaryAddValue1(this, kSecAttrKeyType, kSecAttrKeyTypeEC) - CFDictionaryAddValue1(this, kSecAttrKeySizeInBits, CFBridgingRetain(NSNumber(256))) - } - val privateKey = SecKeyCreateRandomKey(query, null) - ?: return KmmResult.failure(Exception("Cannot create in-memory private key")) - val publicKey = SecKeyCopyPublicKey(privateKey) - ?: return KmmResult.failure(Exception("Cannot create public key")) - return KmmResult.success(DefaultEphemeralKeyHolder(ecCurve, publicKey, privateKey)) - } - - actual override fun performKeyAgreement( + actual open fun performKeyAgreement( ephemeralKey: EphemeralKeyHolder, recipientKey: JsonWebKey, algorithm: JweAlgorithm @@ -98,132 +59,12 @@ actual class DefaultCryptoService : CryptoService { return KmmResult.success("sharedSecret-${algorithm.identifier}".encodeToByteArray()) } - actual override fun performKeyAgreement(ephemeralKey: JsonWebKey, algorithm: JweAlgorithm): KmmResult { + actual open fun performKeyAgreement(ephemeralKey: JsonWebKey, algorithm: JweAlgorithm): KmmResult { return KmmResult.success("sharedSecret-${algorithm.identifier}".encodeToByteArray()) } - actual override fun messageDigest( - input: ByteArray, - digest: at.asitplus.signum.indispensable.Digest - ): KmmResult { - return KmmResult.success(input) - } - - companion object { - fun signInt(input: ByteArray, privateKeyRef: SecKeyRef): ByteArray { - memScoped { - val inputData = CFBridgingRetain(toData(input)) as CFDataRef - val signature = - SecKeyCreateSignature(privateKeyRef, kSecKeyAlgorithmECDSASignatureMessageX962SHA256, inputData, null) - val data = CFBridgingRelease(signature) as NSData - return data.toByteArray() - } - } - } - -} - -actual fun RandomKeyPairAdapter(extensions: List): KeyPairAdapter { - val query = CFDictionaryCreateMutable(null, 2, null, null).apply { - CFDictionaryAddValue1(this, kSecAttrKeyType, kSecAttrKeyTypeEC) - CFDictionaryAddValue1(this, kSecAttrKeySizeInBits, CFBridgingRetain(NSNumber(256))) - } - val secPrivateKey = SecKeyCreateRandomKey(query, null)!! - val secPublicKey = SecKeyCopyPublicKey(secPrivateKey)!! - val publicKeyData = SecKeyCopyExternalRepresentation(secPublicKey, null) - val data = CFBridgingRelease(publicKeyData) as NSData - val publicKey = CryptoPublicKey.EC.fromAnsiX963Bytes(ECCurve.SECP_256_R_1, data.toByteArray()) - val signingAlgorithm = X509SignatureAlgorithm.ES256 - val certificate = X509Certificate.generateSelfSignedCertificate(publicKey, signingAlgorithm, extensions) { - val intSign = signInt(it, secPrivateKey) - KmmResult.success(CryptoSignature.decodeFromDer(intSign)) - } - return IosKeyPairAdapter(secPrivateKey, secPublicKey, signingAlgorithm, certificate) -} - -class IosKeyPairAdapter( - val secPrivateKey: SecKeyRef, - val secPublicKey: SecKeyRef, - override val signingAlgorithm: X509SignatureAlgorithm, - override val certificate: X509Certificate? -) : KeyPairAdapter { - private val publicKeyData = SecKeyCopyExternalRepresentation(secPublicKey, null) - private val data = CFBridgingRelease(publicKeyData) as NSData - override val publicKey: CryptoPublicKey = - CryptoPublicKey.EC.fromAnsiX963Bytes(ECCurve.SECP_256_R_1, data.toByteArray()) - override val identifier: String = publicKey.didEncoded - override val jsonWebKey: JsonWebKey - get() = publicKey.toJsonWebKey() - override val coseKey: CoseKey - get() = publicKey.toCoseKey(signingAlgorithm.toCoseAlgorithm().getOrThrow()).getOrThrow() - -} - -@Suppress("UNCHECKED_CAST") -actual class DefaultVerifierCryptoService : VerifierCryptoService { - - actual override val supportedAlgorithms: List = - X509SignatureAlgorithm.entries.filter { it.isEc } - - actual override fun verify( - input: ByteArray, - signature: CryptoSignature, - algorithm: X509SignatureAlgorithm, - publicKey: CryptoPublicKey - ): KmmResult { - // TODO RSA - if (publicKey !is CryptoPublicKey.EC) { - return KmmResult.failure(IllegalArgumentException("Public key is not an EC key")) - } - memScoped { - val ansix962 = publicKey.iosEncoded - val keyData = CFBridgingRetain(toData(ansix962)) as CFDataRef - val attributes = CFDictionaryCreateMutable(null, 3, null, null).apply { - CFDictionaryAddValue1(this, kSecAttrKeyClass, kSecAttrKeyClassPublic) - CFDictionaryAddValue1(this, kSecAttrKeyType, kSecAttrKeyTypeEC) - CFDictionaryAddValue1(this, kSecAttrKeySizeInBits, CFBridgingRetain(NSNumber(256))) - } - val secKey = SecKeyCreateWithData(keyData, attributes, null) - ?: return KmmResult.failure(IllegalArgumentException()) - val inputData = CFBridgingRetain(toData(input)) as CFDataRef - val signatureData = CFBridgingRetain(toData(signature.encodeToDer())) as CFDataRef - val verified = SecKeyVerifySignature( - key = secKey, - algorithm = kSecKeyAlgorithmECDSASignatureMessageX962SHA256, - signedData = inputData, - signature = signatureData, - error = null - ) - return KmmResult.success(verified) - } + actual open fun hmac(key: ByteArray, algorithm: JweEncryption, input: ByteArray): KmmResult { + return KmmResult.success("hmac".encodeToByteArray() + key) } } - -data class DefaultEphemeralKeyHolder(val crv: ECCurve, val publicKey: SecKeyRef, val privateKey: SecKeyRef? = null) : - EphemeralKeyHolder { - - override val publicJsonWebKey = CryptoPublicKey.EC.fromAnsiX963Bytes( - crv, - (CFBridgingRelease( - SecKeyCopyExternalRepresentation( - publicKey, - null - ) - ) as NSData).toByteArray() - ).toJsonWebKey() -} - -inline fun MemScope.toData(array: ByteArray): NSData = - NSData.create( - bytes = allocArrayOf(array), - length = array.size.toULong() - ) - -// from https://github.com/mirego/trikot.foundation/pull/41/files -public fun NSData.toByteArray(): ByteArray { - return this.bytes?.let { - val dataPointer: CPointer = it.reinterpret() - ByteArray(this.length.toInt()) { index -> dataPointer[index] } - } ?: ByteArray(0) -} diff --git a/vck/src/jvmMain/kotlin/at/asitplus/wallet/lib/agent/DefaultCryptoService.kt b/vck/src/jvmMain/kotlin/at/asitplus/wallet/lib/agent/DefaultCryptoService.kt index 3ea7e65b4..a42f48552 100644 --- a/vck/src/jvmMain/kotlin/at/asitplus/wallet/lib/agent/DefaultCryptoService.kt +++ b/vck/src/jvmMain/kotlin/at/asitplus/wallet/lib/agent/DefaultCryptoService.kt @@ -1,66 +1,29 @@ -@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") - package at.asitplus.wallet.lib.agent import at.asitplus.KmmResult import at.asitplus.KmmResult.Companion.wrap -import at.asitplus.signum.indispensable.* -import at.asitplus.signum.indispensable.ECCurve.SECP_256_R_1 -import at.asitplus.signum.indispensable.cosef.CoseKey -import at.asitplus.signum.indispensable.cosef.toCoseAlgorithm -import at.asitplus.signum.indispensable.cosef.toCoseKey +import at.asitplus.signum.indispensable.getJcaPublicKey import at.asitplus.signum.indispensable.josef.* -import at.asitplus.signum.indispensable.pki.X509Certificate -import at.asitplus.signum.indispensable.pki.X509CertificateExtension -import org.bouncycastle.jce.ECNamedCurveTable -import org.bouncycastle.jce.provider.JCEECPublicKey -import org.bouncycastle.jce.spec.ECPublicKeySpec -import java.math.BigInteger -import java.security.KeyPair -import java.security.KeyPairGenerator -import java.security.MessageDigest +import at.asitplus.signum.supreme.HazardousMaterials +import at.asitplus.signum.supreme.hazmat.jcaPrivateKey +import io.github.aakira.napier.Napier +import org.bouncycastle.jce.provider.BouncyCastleProvider +import java.security.Security import javax.crypto.Cipher import javax.crypto.KeyAgreement -import javax.crypto.spec.GCMParameterSpec +import javax.crypto.Mac +import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec - -actual open class DefaultCryptoService : CryptoService { - - actual final override val keyPairAdapter: KeyPairAdapter - - private val jvmKeyPairAdapter: JvmKeyPairAdapter - - actual constructor(keyPairAdapter: KeyPairAdapter) { - assert(keyPairAdapter is JvmKeyPairAdapter) - keyPairAdapter as JvmKeyPairAdapter - this.jvmKeyPairAdapter = keyPairAdapter - this.keyPairAdapter = keyPairAdapter - } - - /** - * Constructor which allows all public keys implemented in `KMP-Crypto` - * Because RSA needs the algorithm parameter to be useful (as it cannot be inferred from the key) - * it's mandatory - * Also used for custom certificates - */ - constructor( - keyPair: KeyPair, - algorithm: X509SignatureAlgorithm, - ) { - this.jvmKeyPairAdapter = JvmKeyPairAdapter(keyPair, algorithm, null) - this.keyPairAdapter = jvmKeyPairAdapter +actual open class PlatformCryptoShim actual constructor(actual val keyMaterial: KeyMaterial) { + companion object { + init { + Napier.d { "Adding BC" } + Security.addProvider(BouncyCastleProvider()) + } } - actual override suspend fun doSign(input: ByteArray): KmmResult = runCatching { - val sig = keyPairAdapter.signingAlgorithm.algorithm.getJCASignatureInstance().getOrThrow().apply { - initSign(jvmKeyPairAdapter.keyPair.private) - update(input) - }.sign() - CryptoSignature.parseFromJca(sig, keyPairAdapter.signingAlgorithm) - }.wrap() - - actual override fun encrypt( + actual open fun encrypt( key: ByteArray, iv: ByteArray, aad: ByteArray, @@ -68,18 +31,13 @@ actual open class DefaultCryptoService : CryptoService { algorithm: JweEncryption ): KmmResult = runCatching { val jcaCiphertext = Cipher.getInstance(algorithm.jcaName).also { + it.init( + Cipher.ENCRYPT_MODE, + SecretKeySpec(key, algorithm.jcaKeySpecName), + IvParameterSpec(iv) + ) if (algorithm.isAuthenticatedEncryption) { - it.init( - Cipher.ENCRYPT_MODE, - SecretKeySpec(key, algorithm.jcaKeySpecName), - GCMParameterSpec(algorithm.ivLengthBits, iv) - ) it.updateAAD(aad) - } else { - it.init( - Cipher.ENCRYPT_MODE, - SecretKeySpec(key, algorithm.jcaKeySpecName), - ) } }.doFinal(input) if (algorithm.isAuthenticatedEncryption) { @@ -91,8 +49,7 @@ actual open class DefaultCryptoService : CryptoService { } }.wrap() - - actual override suspend fun decrypt( + actual open suspend fun decrypt( key: ByteArray, iv: ByteArray, aad: ByteArray, @@ -100,124 +57,53 @@ actual open class DefaultCryptoService : CryptoService { authTag: ByteArray, algorithm: JweEncryption ): KmmResult = runCatching { + val wholeInput = input + if (algorithm.isAuthenticatedEncryption) authTag else byteArrayOf() Cipher.getInstance(algorithm.jcaName).also { + it.init( + Cipher.DECRYPT_MODE, + SecretKeySpec(key, algorithm.jcaKeySpecName), + IvParameterSpec(iv) + ) if (algorithm.isAuthenticatedEncryption) { - it.init( - Cipher.DECRYPT_MODE, - SecretKeySpec(key, algorithm.jcaKeySpecName), - GCMParameterSpec(algorithm.ivLengthBits, iv) - ) it.updateAAD(aad) - } else { - it.init( - Cipher.DECRYPT_MODE, - SecretKeySpec(key, algorithm.jcaKeySpecName), - ) } - }.doFinal(input + authTag) + }.doFinal(wholeInput) + }.wrap() + + actual open fun hmac( + key: ByteArray, + algorithm: JweEncryption, + input: ByteArray, + ): KmmResult = runCatching { + Mac.getInstance(algorithm.jcaHmacName).also { + it.init(SecretKeySpec(key, algorithm.jcaKeySpecName)) + }.doFinal(input) }.wrap() - actual override fun performKeyAgreement( + actual open fun performKeyAgreement( ephemeralKey: EphemeralKeyHolder, recipientKey: JsonWebKey, algorithm: JweAlgorithm ): KmmResult = runCatching { - require(ephemeralKey is JvmEphemeralKeyHolder) { "JVM Type expected" } - val jvmKey = recipientKey.toCryptoPublicKey().transform { it1 -> it1.getJcaPublicKey() }.getOrThrow() + val jvmKey = recipientKey.toCryptoPublicKey().getOrThrow().getJcaPublicKey().getOrThrow() KeyAgreement.getInstance(algorithm.jcaName).also { - it.init(ephemeralKey.keyPair.private) - it.doPhase( - jvmKey, true - ) + @OptIn(HazardousMaterials::class) + it.init(ephemeralKey.key.jcaPrivateKey) + it.doPhase(jvmKey, true) }.generateSecret() }.wrap() - actual override fun performKeyAgreement( + actual open fun performKeyAgreement( ephemeralKey: JsonWebKey, algorithm: JweAlgorithm ): KmmResult = runCatching { - val parameterSpec = ECNamedCurveTable.getParameterSpec(ephemeralKey.curve?.jcaName) - val xBigInteger = BigInteger(1, ephemeralKey.x) - val yBigInteger = BigInteger(1, ephemeralKey.y) - val ecPoint = parameterSpec.curve.validatePoint(xBigInteger, yBigInteger) - val ecPublicKeySpec = ECPublicKeySpec(ecPoint, parameterSpec) - val publicKey = JCEECPublicKey("EC", ecPublicKeySpec) - + val publicKey = ephemeralKey.toCryptoPublicKey().getOrThrow().getJcaPublicKey().getOrThrow() KeyAgreement.getInstance(algorithm.jcaName).also { - it.init(jvmKeyPairAdapter.keyPair.private) + @OptIn(HazardousMaterials::class) + it.init(keyMaterial.getUnderLyingSigner().jcaPrivateKey) it.doPhase(publicKey, true) }.generateSecret() }.wrap() - actual override fun generateEphemeralKeyPair(ecCurve: ECCurve): KmmResult = - KmmResult.success(JvmEphemeralKeyHolder(ecCurve)) - - actual override fun messageDigest(input: ByteArray, digest: Digest): KmmResult = runCatching { - MessageDigest.getInstance(digest.jcaName).digest(input) - }.wrap() - -} - -class JvmKeyPairAdapter( - val keyPair: KeyPair, - override val signingAlgorithm: X509SignatureAlgorithm, - override val certificate: X509Certificate? -) : KeyPairAdapter { - override val publicKey: CryptoPublicKey - get() = CryptoPublicKey.fromJcaPublicKey(keyPair.public).getOrThrow() - override val identifier: String - get() = publicKey.didEncoded - override val jsonWebKey: JsonWebKey - get() = publicKey.toJsonWebKey() - override val coseKey: CoseKey - get() = publicKey.toCoseKey(signingAlgorithm.toCoseAlgorithm().getOrThrow()).getOrThrow() -} - -actual fun RandomKeyPairAdapter(extensions: List): KeyPairAdapter { - val keyPair = genEc256KeyPair() - val signingAlgorithm = X509SignatureAlgorithm.ES256 - val publicKey = CryptoPublicKey.fromJcaPublicKey(keyPair.public).getOrThrow() - val certificate = X509Certificate.generateSelfSignedCertificate(publicKey, signingAlgorithm, extensions) { - runCatching { - CryptoSignature.parseFromJca(signingAlgorithm.getJCASignatureInstance().getOrThrow().apply { - initSign(keyPair.private) - update(it) - }.sign(), signingAlgorithm) - }.wrap() - } - return JvmKeyPairAdapter(keyPair, signingAlgorithm, certificate) -} - -private fun genEc256KeyPair(): KeyPair = - KeyPairGenerator.getInstance("EC") - .also { it.initialize(SECP_256_R_1.keyLengthBits.toInt()) } - .genKeyPair() - -actual open class DefaultVerifierCryptoService : VerifierCryptoService { - - actual override val supportedAlgorithms: List = - X509SignatureAlgorithm.entries.filter { it.isEc } - - actual override fun verify( - input: ByteArray, - signature: CryptoSignature, - algorithm: X509SignatureAlgorithm, - publicKey: CryptoPublicKey, - ): KmmResult = runCatching { - algorithm.getJCASignatureInstance().getOrThrow().apply { - initVerify(publicKey.getJcaPublicKey().getOrThrow()) - update(input) - }.verify(signature.jcaSignatureBytes) - }.wrap() } -open class JvmEphemeralKeyHolder(private val ecCurve: ECCurve) : EphemeralKeyHolder { - - val keyPair: KeyPair = - KeyPairGenerator.getInstance("EC").also { it.initialize(ecCurve.keyLengthBits.toInt()) }.genKeyPair() - - override val publicJsonWebKey: JsonWebKey? by lazy { - CryptoPublicKey.fromJcaPublicKey(keyPair.public).map { it.toJsonWebKey() }.getOrNull() - } - -} diff --git a/vck/src/jvmMain/kotlin/at/asitplus/wallet/lib/agent/KeyMaterial.jvm.kt b/vck/src/jvmMain/kotlin/at/asitplus/wallet/lib/agent/KeyMaterial.jvm.kt new file mode 100644 index 000000000..5681d55db --- /dev/null +++ b/vck/src/jvmMain/kotlin/at/asitplus/wallet/lib/agent/KeyMaterial.jvm.kt @@ -0,0 +1,35 @@ +package at.asitplus.wallet.lib.agent + +import at.asitplus.signum.indispensable.pki.X509Certificate +import at.asitplus.signum.supreme.os.JKSProvider +import kotlinx.coroutines.runBlocking +import java.security.KeyStore + +/** + * [KeyMaterial] based on an initialized, loaded [KeyStore] object. + * @param keyAlias non-null alias of the private key used for signing + * @param providerName nullable. Can be used to optionally specify a provider + * @param privateKeyPassword optional (i.e. nullable) private key password + * @param certAlias optional(i.e. nullable) alias for the certificate to return when invoking [getCertificate] + * + */ +class KeyStoreMaterial +@JvmOverloads constructor( + private val keyStore: KeyStore, + keyAlias: String, + privateKeyPassword: CharArray, + providerName: String? = null, + private val certAlias: String? = null +) : SignerBasedKeyMaterial( + runBlocking { + JKSProvider { + withBackingObject { store = keyStore } + }.getOrThrow().getSignerForKey(keyAlias) { + this.privateKeyPassword = privateKeyPassword + provider = providerName + }.getOrThrow() + }) { + override suspend fun getCertificate(): X509Certificate? = + certAlias?.let { X509Certificate.decodeFromByteArray(keyStore.getCertificate(it).encoded) } + +} \ No newline at end of file diff --git a/vck/src/jvmTest/kotlin/at/asitplus/wallet/lib/KeyStoreMaterialTest.kt b/vck/src/jvmTest/kotlin/at/asitplus/wallet/lib/KeyStoreMaterialTest.kt new file mode 100644 index 000000000..b0c066a2a --- /dev/null +++ b/vck/src/jvmTest/kotlin/at/asitplus/wallet/lib/KeyStoreMaterialTest.kt @@ -0,0 +1,38 @@ +package at.asitplus.wallet.lib + +import at.asitplus.signum.supreme.SignatureResult +import at.asitplus.wallet.lib.agent.KeyStoreMaterial +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.types.shouldBeInstanceOf +import org.bouncycastle.jce.provider.BouncyCastleProvider +import java.security.KeyStore +import java.security.Security + +class KeyStoreMaterialTest: FreeSpec( { + + val ks = KeyStore.getInstance("JKS") + ks.load(KeyStoreMaterial::class.java.getResourceAsStream("/pw_bar_kpw_foo_alias_foo.jks"), "bar".toCharArray()) + "Without Cert" { + val material = KeyStoreMaterial(ks, keyAlias = "foo", privateKeyPassword = "foo".toCharArray()) + material.sign(byteArrayOf()).shouldBeInstanceOf>() + + material.getCertificate().shouldBeNull() + } + "With Cert" { + val material = KeyStoreMaterial(ks, keyAlias = "foo", privateKeyPassword = "foo".toCharArray(), certAlias = "foo") + material.sign(byteArrayOf()).shouldBeInstanceOf>() + + material.getCertificate().shouldNotBeNull() + } + + "With BC Prov and Cert" { + Security.addProvider(BouncyCastleProvider()) + val material = KeyStoreMaterial(ks, keyAlias = "foo", privateKeyPassword = "foo".toCharArray(), certAlias = "foo", providerName = "BC") + material.sign(byteArrayOf()).shouldBeInstanceOf>() + + material.getCertificate().shouldNotBeNull() + } + +}) \ No newline at end of file diff --git a/vck/src/jvmTest/kotlin/at/asitplus/wallet/lib/cbor/CoseServiceJvmTest.kt b/vck/src/jvmTest/kotlin/at/asitplus/wallet/lib/cbor/CoseServiceJvmTest.kt index 57c9552ac..d8382ce34 100644 --- a/vck/src/jvmTest/kotlin/at/asitplus/wallet/lib/cbor/CoseServiceJvmTest.kt +++ b/vck/src/jvmTest/kotlin/at/asitplus/wallet/lib/cbor/CoseServiceJvmTest.kt @@ -1,24 +1,18 @@ package at.asitplus.wallet.lib.cbor -import at.asitplus.signum.indispensable.CryptoPublicKey -import at.asitplus.signum.indispensable.CryptoSignature -import at.asitplus.signum.indispensable.X509SignatureAlgorithm -import at.asitplus.signum.indispensable.cosef.CoseHeader -import at.asitplus.signum.indispensable.cosef.CoseSignatureInput -import at.asitplus.signum.indispensable.cosef.CoseSigned -import at.asitplus.signum.indispensable.cosef.toCoseAlgorithm -import at.asitplus.signum.indispensable.cosef.toCoseKey -import at.asitplus.signum.indispensable.fromJcaPublicKey +import at.asitplus.signum.indispensable.* +import at.asitplus.signum.indispensable.cosef.* +import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper +import at.asitplus.signum.supreme.HazardousMaterials +import at.asitplus.signum.supreme.hazmat.jcaPrivateKey +import at.asitplus.signum.supreme.sign.EphemeralKey import at.asitplus.wallet.lib.agent.DefaultCryptoService +import at.asitplus.wallet.lib.agent.EphemeralKeyWithSelfSignedCert +import at.asitplus.wallet.lib.agent.EphemeralKeyWithoutCert import com.authlete.cbor.CBORByteArray import com.authlete.cbor.CBORDecoder import com.authlete.cbor.CBORTaggedItem -import com.authlete.cose.COSEProtectedHeaderBuilder -import com.authlete.cose.COSESign1 -import com.authlete.cose.COSESign1Builder -import com.authlete.cose.COSESigner -import com.authlete.cose.COSEVerifier -import com.authlete.cose.SigStructureBuilder +import com.authlete.cose.* import com.authlete.cose.constants.COSEAlgorithms import com.benasher44.uuid.uuid4 import io.kotest.assertions.withClue @@ -27,8 +21,6 @@ import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf import io.matthewnelson.encoding.base16.Base16 import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString -import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper -import java.security.KeyPairGenerator import java.security.interfaces.ECPrivateKey import java.security.interfaces.ECPublicKey @@ -43,10 +35,6 @@ class CoseServiceJvmTest : FreeSpec({ configurations.forEach { thisConfiguration -> repeat(2) { number -> - val keyPair = KeyPairGenerator.getInstance(thisConfiguration.first).apply { - initialize(thisConfiguration.second) - }.genKeyPair() - val sigAlgo = when (thisConfiguration.first) { "EC" -> when (thisConfiguration.second) { 256 -> X509SignatureAlgorithm.ES256 @@ -57,6 +45,19 @@ class CoseServiceJvmTest : FreeSpec({ else -> throw IllegalArgumentException("Unknown Key Type") // -||- } + val ephemeralKey = EphemeralKey { + ec { + curve = when (thisConfiguration.second) { + 256 -> ECCurve.SECP_256_R_1 + 384 -> ECCurve.SECP_384_R_1 + 521 -> ECCurve.SECP_521_R_1 + else -> throw IllegalArgumentException("Unknown EC Curve size") // necessary(compiler), but otherwise redundant else-branch + } + digests = setOf(curve.nativeDigest) + } + }.getOrThrow() + + val coseAlgorithm = sigAlgo.toCoseAlgorithm().getOrThrow() val extLibAlgorithm = when (sigAlgo) { X509SignatureAlgorithm.ES256 -> COSEAlgorithms.ES256 @@ -65,13 +66,17 @@ class CoseServiceJvmTest : FreeSpec({ else -> throw IllegalArgumentException("Unknown JweAlgorithm") } - val extLibVerifier = COSEVerifier(keyPair.public as ECPublicKey) - val extLibSigner = COSESigner(keyPair.private as ECPrivateKey) + val extLibVerifier = COSEVerifier(ephemeralKey.publicKey.getJcaPublicKey().getOrThrow() as ECPublicKey) + + @OptIn(HazardousMaterials::class) + val extLibSigner = COSESigner(ephemeralKey.jcaPrivateKey as ECPrivateKey) + - val cryptoService = DefaultCryptoService(keyPair, sigAlgo) + val keyMaterial = EphemeralKeyWithoutCert(ephemeralKey) + val cryptoService = DefaultCryptoService(keyMaterial) val coseService = DefaultCoseService(cryptoService) val verifierCoseService = DefaultVerifierCoseService() - val coseKey = CryptoPublicKey.fromJcaPublicKey(keyPair.public).getOrThrow().toCoseKey().getOrThrow() + val coseKey = ephemeralKey.publicKey.toCoseKey().getOrThrow() val randomPayload = uuid4().toString() @@ -85,8 +90,8 @@ class CoseServiceJvmTest : FreeSpec({ ).getOrThrow() withClue("$sigAlgo: Signature: ${signed.signature.encodeToTlv().toDerHexString()}") { - verifierCoseService.verifyCose(signed, cryptoService.keyPairAdapter.coseKey) - .getOrThrow() shouldBe true + verifierCoseService.verifyCose(signed, cryptoService.keyMaterial.publicKey.toCoseKey().getOrThrow()) + .isSuccess shouldBe true } } @@ -128,7 +133,7 @@ class CoseServiceJvmTest : FreeSpec({ signedSerialized.length shouldBe extLibSerialized.length withClue("$sigAlgo: Signature: ${parsedSig}") { - verifierCoseService.verifyCose(coseSigned, coseKey).getOrThrow() shouldBe true + verifierCoseService.verifyCose(coseSigned, coseKey).isSuccess shouldBe true } } @@ -153,7 +158,8 @@ class CoseServiceJvmTest : FreeSpec({ val signature = parsedDefLengthSignature.rawByteArray.encodeToString(Base16()) parsedSignature shouldBe signature - val extLibSigInput = SigStructureBuilder().sign1(parsedCoseSign1).build().encode().encodeToString(Base16()) + val extLibSigInput = + SigStructureBuilder().sign1(parsedCoseSign1).build().encode().encodeToString(Base16()) val signatureInput = CoseSignatureInput( contextString = "Signature1", protectedHeader = ByteStringWrapper(CoseHeader(algorithm = coseAlgorithm)), diff --git a/vck/src/jvmTest/kotlin/at/asitplus/wallet/lib/jws/JsonWebKeyJvmTest.kt b/vck/src/jvmTest/kotlin/at/asitplus/wallet/lib/jws/JsonWebKeyJvmTest.kt index b0dea1083..156ae48fb 100644 --- a/vck/src/jvmTest/kotlin/at/asitplus/wallet/lib/jws/JsonWebKeyJvmTest.kt +++ b/vck/src/jvmTest/kotlin/at/asitplus/wallet/lib/jws/JsonWebKeyJvmTest.kt @@ -1,7 +1,7 @@ package at.asitplus.wallet.lib.jws import at.asitplus.signum.indispensable.ECCurve -import at.asitplus.signum.indispensable.asn1.ensureSize +import at.asitplus.signum.indispensable.io.ensureSize import at.asitplus.signum.indispensable.josef.JsonWebKey import io.kotest.core.spec.style.FreeSpec import io.kotest.matchers.nulls.shouldNotBeNull diff --git a/vck/src/jvmTest/kotlin/at/asitplus/wallet/lib/jws/JweServiceJvmTest.kt b/vck/src/jvmTest/kotlin/at/asitplus/wallet/lib/jws/JweServiceJvmTest.kt new file mode 100644 index 000000000..5ea35e02c --- /dev/null +++ b/vck/src/jvmTest/kotlin/at/asitplus/wallet/lib/jws/JweServiceJvmTest.kt @@ -0,0 +1,109 @@ +package at.asitplus.wallet.lib.jws + +import at.asitplus.signum.indispensable.ECCurve +import at.asitplus.signum.indispensable.ECCurve.* +import at.asitplus.signum.indispensable.getJcaPublicKey +import at.asitplus.signum.indispensable.josef.JweAlgorithm +import at.asitplus.signum.indispensable.josef.JweEncrypted +import at.asitplus.signum.indispensable.josef.JweEncryption +import at.asitplus.signum.indispensable.josef.JweEncryption.* +import at.asitplus.signum.indispensable.nativeDigest +import at.asitplus.signum.supreme.HazardousMaterials +import at.asitplus.signum.supreme.hazmat.jcaPrivateKey +import at.asitplus.signum.supreme.sign.EphemeralKey +import at.asitplus.wallet.lib.agent.DefaultCryptoService +import at.asitplus.wallet.lib.agent.EphemeralKeyWithoutCert +import at.asitplus.wallet.lib.data.vckJsonSerializer +import com.benasher44.uuid.uuid4 +import com.nimbusds.jose.* +import com.nimbusds.jose.crypto.ECDHDecrypter +import com.nimbusds.jose.crypto.ECDHEncrypter +import com.nimbusds.jose.jwk.JWK +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import kotlinx.serialization.encodeToString +import java.security.interfaces.ECPrivateKey +import java.security.interfaces.ECPublicKey + +@OptIn(HazardousMaterials::class) +class JweServiceJvmTest : FreeSpec({ + + val configurations: List = + listOf( + Configuration(SECP_256_R_1, listOf(A128CBC_HS256, A128GCM)), + Configuration(SECP_384_R_1, listOf(A192CBC_HS384, A192GCM)), + Configuration(SECP_521_R_1, listOf(A256CBC_HS512, A256GCM)), + ) + + configurations.forEach { config -> + val ephemeralKey = EphemeralKey { + ec { + curve = config.curve + digests = setOf(curve.nativeDigest) + } + }.getOrThrow() + + val jweAlgorithm = JweAlgorithm.ECDH_ES + val jvmEncrypter = ECDHEncrypter(ephemeralKey.publicKey.getJcaPublicKey().getOrThrow() as ECPublicKey) + val jvmDecrypter = ECDHDecrypter(ephemeralKey.jcaPrivateKey as ECPrivateKey) + + val keyPairAdapter = EphemeralKeyWithoutCert(ephemeralKey) + val cryptoService = DefaultCryptoService(keyPairAdapter) + val jwsService = DefaultJwsService(cryptoService) + val randomPayload = uuid4().toString() + + config.encryption.forEach { encryptionMethod -> + "${config.curve}, ${encryptionMethod}:" - { + "Encrypted object from ext. library can be decrypted with int. library" { + val stringPayload = vckJsonSerializer.encodeToString(randomPayload) + val libJweHeader = + JWEHeader.Builder(JWEAlgorithm(jweAlgorithm.identifier), encryptionMethod.joseAlgorithm) + .type(JOSEObjectType(JwsContentTypeConstants.DIDCOMM_ENCRYPTED_JSON)) + .jwk(JWK.parse(cryptoService.keyMaterial.jsonWebKey.serialize())) + .contentType(JwsContentTypeConstants.DIDCOMM_PLAIN_JSON) + .build() + val libJweObject = JWEObject(libJweHeader, Payload(stringPayload)) + .apply { encrypt(jvmEncrypter) } + val encryptedJwe = libJweObject.serialize() + + val parsedJwe = JweEncrypted.deserialize(encryptedJwe).getOrThrow() + val result = jwsService.decryptJweObject(parsedJwe, encryptedJwe).getOrThrow() + result.payload.decodeToString() shouldBe stringPayload + } + + "Encrypted object from int. library can be decrypted with ext. library" { + val stringPayload = vckJsonSerializer.encodeToString(randomPayload) + val encrypted = jwsService.encryptJweObject( + JwsContentTypeConstants.DIDCOMM_ENCRYPTED_JSON, + stringPayload.encodeToByteArray(), + cryptoService.keyMaterial.jsonWebKey, + JwsContentTypeConstants.DIDCOMM_PLAIN_JSON, + jweAlgorithm, + encryptionMethod, + ).getOrThrow().serialize() + + val parsed = JWEObject.parse(encrypted).shouldNotBeNull() + + parsed.decrypt(jvmDecrypter) + parsed.payload.toString() shouldBe stringPayload + } + } + } + } +}) + +private data class Configuration( + val curve: ECCurve, + val encryption: Collection, +) + +private val JweEncryption.joseAlgorithm: EncryptionMethod + get() = when (this) { + A128GCM -> EncryptionMethod.A128GCM + A192GCM -> EncryptionMethod.A192GCM + A256GCM -> EncryptionMethod.A256GCM + A128CBC_HS256 -> EncryptionMethod.A128CBC_HS256 + A192CBC_HS384 -> EncryptionMethod.A192CBC_HS384 + A256CBC_HS512 -> EncryptionMethod.A256CBC_HS512 + } diff --git a/vck/src/jvmTest/kotlin/at/asitplus/wallet/lib/jws/JwsServiceJvmTest.kt b/vck/src/jvmTest/kotlin/at/asitplus/wallet/lib/jws/JwsServiceJvmTest.kt index efb80776a..e74aa8a38 100644 --- a/vck/src/jvmTest/kotlin/at/asitplus/wallet/lib/jws/JwsServiceJvmTest.kt +++ b/vck/src/jvmTest/kotlin/at/asitplus/wallet/lib/jws/JwsServiceJvmTest.kt @@ -1,46 +1,35 @@ package at.asitplus.wallet.lib.jws +import at.asitplus.signum.indispensable.ECCurve import at.asitplus.signum.indispensable.X509SignatureAlgorithm +import at.asitplus.signum.indispensable.getJcaPublicKey import at.asitplus.signum.indispensable.io.Base64UrlStrict import at.asitplus.signum.indispensable.josef.JweAlgorithm -import at.asitplus.signum.indispensable.josef.JweEncrypted -import at.asitplus.signum.indispensable.josef.JweEncryption import at.asitplus.signum.indispensable.josef.JwsSigned +import at.asitplus.signum.indispensable.nativeDigest +import at.asitplus.signum.supreme.HazardousMaterials +import at.asitplus.signum.supreme.hazmat.jcaPrivateKey +import at.asitplus.signum.supreme.sign.EphemeralKey import at.asitplus.wallet.lib.agent.DefaultCryptoService +import at.asitplus.wallet.lib.agent.EphemeralKeyWithoutCert import at.asitplus.wallet.lib.data.vckJsonSerializer import com.benasher44.uuid.uuid4 -import com.nimbusds.jose.EncryptionMethod -import com.nimbusds.jose.JOSEObjectType -import com.nimbusds.jose.JWEAlgorithm -import com.nimbusds.jose.JWEHeader -import com.nimbusds.jose.JWEObject -import com.nimbusds.jose.JWSAlgorithm -import com.nimbusds.jose.JWSHeader -import com.nimbusds.jose.JWSObject -import com.nimbusds.jose.Payload -import com.nimbusds.jose.crypto.ECDHDecrypter -import com.nimbusds.jose.crypto.ECDHEncrypter -import com.nimbusds.jose.crypto.ECDSASigner -import com.nimbusds.jose.crypto.ECDSAVerifier -import com.nimbusds.jose.crypto.RSADecrypter -import com.nimbusds.jose.crypto.RSAEncrypter -import com.nimbusds.jose.crypto.RSASSASigner -import com.nimbusds.jose.crypto.RSASSAVerifier +import com.nimbusds.jose.* +import com.nimbusds.jose.crypto.* import com.nimbusds.jose.jwk.JWK import io.kotest.assertions.withClue import io.kotest.core.spec.style.FreeSpec -import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString import kotlinx.serialization.encodeToString -import java.security.KeyPairGenerator import java.security.interfaces.ECPrivateKey import java.security.interfaces.ECPublicKey import java.security.interfaces.RSAPrivateKey import java.security.interfaces.RSAPublicKey import kotlin.random.Random +@OptIn(HazardousMaterials::class) class JwsServiceJvmTest : FreeSpec({ val configurations: List> = @@ -65,9 +54,6 @@ class JwsServiceJvmTest : FreeSpec({ configurations.forEach { thisConfiguration -> repeat(2) { number -> - val keyPair = KeyPairGenerator.getInstance(thisConfiguration.first).apply { - initialize(thisConfiguration.second) - }.genKeyPair() val algo = when (thisConfiguration.first) { "EC" -> when (thisConfiguration.second) { @@ -85,6 +71,23 @@ class JwsServiceJvmTest : FreeSpec({ else -> throw IllegalArgumentException("Unknown Key Type") // -||- } + val ephemeralKey = EphemeralKey { + if (algo.isEc) + ec { + curve = when (thisConfiguration.second) { + 256 -> ECCurve.SECP_256_R_1 + 384 -> ECCurve.SECP_384_R_1 + 521 -> ECCurve.SECP_521_R_1 + else -> throw IllegalArgumentException("Unknown EC Curve size") // necessary(compiler), but otherwise redundant else-branch + } + digests = setOf(curve.nativeDigest) + } + else + rsa { + this.bits = thisConfiguration.second + } + }.getOrThrow() + val jweAlgorithm = when (algo) { X509SignatureAlgorithm.ES256, X509SignatureAlgorithm.ES384, X509SignatureAlgorithm.ES512 -> JweAlgorithm.ECDH_ES X509SignatureAlgorithm.RS256, X509SignatureAlgorithm.PS256 -> JweAlgorithm.RSA_OAEP_256 @@ -94,19 +97,21 @@ class JwsServiceJvmTest : FreeSpec({ } val jvmVerifier = - if (algo.isEc) ECDSAVerifier(keyPair.public as ECPublicKey) - else RSASSAVerifier(keyPair.public as RSAPublicKey) + if (algo.isEc) ECDSAVerifier(ephemeralKey.publicKey.getJcaPublicKey().getOrThrow() as ECPublicKey) + else RSASSAVerifier(ephemeralKey.publicKey.getJcaPublicKey().getOrThrow() as RSAPublicKey) val jvmSigner = - if (algo.isEc) ECDSASigner(keyPair.private as ECPrivateKey) - else RSASSASigner(keyPair.private as RSAPrivateKey) + if (algo.isEc) ECDSASigner(ephemeralKey.jcaPrivateKey as ECPrivateKey) + else RSASSASigner(ephemeralKey.jcaPrivateKey as RSAPrivateKey) val jvmEncrypter = - if (algo.isEc) ECDHEncrypter(keyPair.public as ECPublicKey) - else RSAEncrypter(keyPair.public as RSAPublicKey) + if (algo.isEc) ECDHEncrypter(ephemeralKey.publicKey.getJcaPublicKey().getOrThrow() as ECPublicKey) + else RSAEncrypter(ephemeralKey.publicKey.getJcaPublicKey().getOrThrow() as RSAPublicKey) val jvmDecrypter = - if (algo.isEc) ECDHDecrypter(keyPair.private as ECPrivateKey) - else RSADecrypter(keyPair.private as RSAPrivateKey) + if (algo.isEc) ECDHDecrypter(ephemeralKey.jcaPrivateKey as ECPrivateKey) + else RSADecrypter(ephemeralKey.jcaPrivateKey as RSAPrivateKey) - val cryptoService = DefaultCryptoService(keyPair, algo) + + val keyPairAdapter = EphemeralKeyWithoutCert(ephemeralKey) + val cryptoService = DefaultCryptoService(keyPairAdapter) val jwsService = DefaultJwsService(cryptoService) val verifierJwsService = DefaultVerifierJwsService() val randomPayload = uuid4().toString() @@ -131,7 +136,7 @@ class JwsServiceJvmTest : FreeSpec({ val stringPayload = vckJsonSerializer.encodeToString(randomPayload) val libHeader = JWSHeader.Builder(JWSAlgorithm(algo.name)) .type(JOSEObjectType("JWT")) - .jwk(JWK.parse(cryptoService.keyPairAdapter.jsonWebKey.serialize())) + .jwk(JWK.parse(cryptoService.keyMaterial.jsonWebKey.serialize())) .build() val libObject = JWSObject(libHeader, Payload(stringPayload)).also { it.sign(jvmSigner) @@ -140,7 +145,7 @@ class JwsServiceJvmTest : FreeSpec({ // Parsing to our structure verifying payload val signedLibObject = libObject.serialize() - val parsedJwsSigned = JwsSigned.parse(signedLibObject).getOrThrow() + val parsedJwsSigned = JwsSigned.deserialize(signedLibObject).getOrThrow() parsedJwsSigned.payload.decodeToString() shouldBe stringPayload val parsedSig = parsedJwsSigned.signature.rawByteArray.encodeToString(Base64UrlStrict) @@ -174,50 +179,6 @@ class JwsServiceJvmTest : FreeSpec({ result shouldBe true } } - - /** - * Encryption is currently only supported for EC-Keys see issue `https://github.com/a-sit-plus/kmm-vc-library/issues/29` - */ - if (thisConfiguration.first == "EC") { - "Encrypted object from ext. library can be decrypted with int. library" { - val stringPayload = vckJsonSerializer.encodeToString(randomPayload) - val libJweHeader = - JWEHeader.Builder(JWEAlgorithm(jweAlgorithm.identifier), EncryptionMethod.A256GCM) - .type(JOSEObjectType(JwsContentTypeConstants.DIDCOMM_ENCRYPTED_JSON)) - .jwk(JWK.parse(cryptoService.keyPairAdapter.jsonWebKey.serialize())) - .contentType(JwsContentTypeConstants.DIDCOMM_PLAIN_JSON) - .build() - val libJweObject = JWEObject(libJweHeader, Payload(stringPayload)).also { - it.encrypt(jvmEncrypter) - } - val encryptedJwe = libJweObject.serialize() - - val parsedJwe = JweEncrypted.parse(encryptedJwe).getOrThrow() - - val result = jwsService.decryptJweObject(parsedJwe, encryptedJwe).getOrThrow() - - result.payload.decodeToString() shouldBe stringPayload - } - - "Encrypted object from int. library can be decrypted with ext. library" { - val stringPayload = vckJsonSerializer.encodeToString(randomPayload) - val encrypted = jwsService.encryptJweObject( - JwsContentTypeConstants.DIDCOMM_ENCRYPTED_JSON, - stringPayload.encodeToByteArray(), - cryptoService.keyPairAdapter.jsonWebKey, - JwsContentTypeConstants.DIDCOMM_PLAIN_JSON, - jweAlgorithm, - JweEncryption.A256GCM, - ).getOrThrow().serialize() - - val parsed = JWEObject.parse(encrypted) - parsed.shouldNotBeNull() - parsed.payload.shouldBeNull() - - parsed.decrypt(jvmDecrypter) - parsed.payload.toString() shouldBe stringPayload - } - } } } } diff --git a/vck/src/jvmTest/resources/pw_bar_kpw_foo_alias_foo.jks b/vck/src/jvmTest/resources/pw_bar_kpw_foo_alias_foo.jks new file mode 100644 index 000000000..6e21f73a6 Binary files /dev/null and b/vck/src/jvmTest/resources/pw_bar_kpw_foo_alias_foo.jks differ