diff --git a/deploy/deploy.sh b/deploy/deploy.sh index a6c7e0d2..68d583ef 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -31,8 +31,7 @@ if [ "$DEPLOYMENT_ENVIRONMENT" == "devtunnel" ]; then N=$(echo $DEVELOPMENT_TEAM | wc -w) if [[ $N -lt 1 ]] ; then echo Cannot find a DEVELOPMENT_TEAM - echo "Please edit your .env file and fill in your DEVELOPMENT_TEAM" - exit + echo "Please edit your .env file and fill in your DEVELOPMENT_TEAM, if you want to build the iOS examples." fi if [[ $N -gt 1 ]] ; then echo You have more than one Team ID @@ -49,7 +48,7 @@ fi # stop and remove any running containers as they may need to be restarted echo "### removing any running containers" -docker compose stop +docker compose stop || { echo "$(tput bold)Please start a docker machine.$(tput sgr0)"; exit; } docker compose rm # copy sources so they can be copied into docker images @@ -125,6 +124,10 @@ if [ "$DEPLOYMENT_ENVIRONMENT" == "devtunnel" ]; then -e "s#^API_BASE_URI[= ].*#API_BASE_URI = $HOST-8080.$REGION#" \ -e "s#^RP_ID[= ].*#RP_ID = $hostname#" \ ../examples/clients/mobile/iOS/PawsKey/Constants.xcconfig + sed -i '' \ + -e "s#^API_BASE_URI[=].*#API_BASE_URI=$HOST-8080.$REGION#" \ + -e "s#^RELYING_PARTY_ID[=].*#RELYING_PARTY_ID=$hostname#" \ + ../examples/clients/mobile/android/PawsKey/gradle.properties echo "### editing iOS BankApp sources" sed -i '' \ @@ -132,7 +135,6 @@ if [ "$DEPLOYMENT_ENVIRONMENT" == "devtunnel" ]; then -e "s#^BANK_API_DOMAIN[= ].*#BANK_API_DOMAIN = $HOST-8082.$REGION#" \ ../examples/clients/mobile/iOS/PKBank/Constants.xcconfig - # TODO: instead of editing source files, make endpoints configurable sed -i '' "s#http://host.docker.internal#https://$hostname#;s#http://localhost#https://$hostname#" keycloak/source/src/main/java/com/yubicolabs/PasskeyAuthenticator/PasskeyAuthenticator.java sed -i '' "s#http://host.docker.internal#https://$hostname#;s#http://localhost#https://$hostname#" keycloak/source/src/main/java/com/yubicolabs/PasskeyAuthenticator/PasskeyRegistrationAuthenticator.java @@ -154,11 +156,50 @@ if [ "$DEPLOYMENT_ENVIRONMENT" == "devtunnel" ]; then echo fi - echo "### starting devtunnel. Type ^C to stop the tunnel and take down all containers" - devtunnel host $TUNNELID > /dev/null + if (echo $DEPLOYMENT_CLIENTS | tr ',' '\n' | grep -Fqx android); then + echo "### building android examples" + + if pushd ../examples/clients/mobile/android/PawsKey/ > /dev/null; then + if [[ $(command -v adb) ]]; then + ADB="adb" + elif [[ -e ${ANDROID_HOME}/platform-tools/adb ]]; then + ADB="${ANDROID_HOME}/platform-tools/adb" + else + ADB="" + fi + + if [[ -n ${ADB} ]] && [[ $(${ADB} devices | wc -l) -gt 2 ]]; then + echo found a connected phone, installing app + ./gradlew installDebug + + echo your android application is deployed to + echo + echo "$(tput bold) a connected phone $(tput sgr0)" + echo + + ${ADB} -d shell am start -n io.yubicolabs.pawskey/io.yubicolabs.pawskey.MainActivity || { echo "Android app could not be launched. See above for details."; } + + else + echo no phone found, building app without installing + ./gradlew assembleDebug + + echo your android application ia deployed here: + echo + echo "$(tput bold) ../examples/clients/mobile/android/PawsKey/app/build/outputs/apk/debug/app-debug.apk $(tput sgr0)" + echo + fi + + popd > /dev/null + else + echo android example folder not found. + fi + fi + + echo "### starting devtunnel. Type ^C to stop the tunnel and take down all containers" + devtunnel host $TUNNELID > /dev/null - docker compose down - exit + docker compose down + exit fi # default is deploy on localhost diff --git a/docs/.gitignore b/docs/.gitignore index b2d6de30..4d860457 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -18,3 +18,4 @@ npm-debug.log* yarn-debug.log* yarn-error.log* +yarn.lock diff --git a/docs/docs/mobile-clients/_category_.json b/docs/docs/mobile-clients/_category_.json new file mode 100644 index 00000000..a094ecde --- /dev/null +++ b/docs/docs/mobile-clients/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "Mobile clients", + "position": 6, + "link": { + "type": "generated-index", + "description": "This section contains mobile client examples." + } +} diff --git a/docs/docs/mobile-clients/android/_category_.json b/docs/docs/mobile-clients/android/_category_.json new file mode 100644 index 00000000..64793d7a --- /dev/null +++ b/docs/docs/mobile-clients/android/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "Android", + "position": 1, + "link": { + "type": "generated-index", + "description": "Find a detailed description on how to build several android apps to use WebAuthn." + } +} diff --git a/docs/docs/mobile-clients/android/advanced/_category_.json b/docs/docs/mobile-clients/android/advanced/_category_.json new file mode 100644 index 00000000..df28cf05 --- /dev/null +++ b/docs/docs/mobile-clients/android/advanced/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "Advanced", + "position": 100, + "link": { + "type": "generated-index", + "description": "Advanced topics not covered elsewhere." + } +} diff --git a/docs/docs/mobile-clients/android/advanced/getting-started.md b/docs/docs/mobile-clients/android/advanced/getting-started.md new file mode 100644 index 00000000..ad1f3f36 --- /dev/null +++ b/docs/docs/mobile-clients/android/advanced/getting-started.md @@ -0,0 +1,8 @@ +--- +sidebar_position: 1 +--- + +# Complex Use Cases + +:::tip +Add content here \ No newline at end of file diff --git a/docs/docs/mobile-clients/android/advanced/platform-credential-manager.md b/docs/docs/mobile-clients/android/advanced/platform-credential-manager.md new file mode 100644 index 00000000..bf06aa0d --- /dev/null +++ b/docs/docs/mobile-clients/android/advanced/platform-credential-manager.md @@ -0,0 +1,13 @@ +--- +sidebar_position: 2 +--- + +# Credential Manager on Android + +## Signing / Uploading Key Fingerprinting + +:::tip +Automate deploy script to include apk fingerprinting + +:::tip +Add why credential manager on google play services \ No newline at end of file diff --git a/docs/docs/mobile-clients/android/app-authenticate-user.md b/docs/docs/mobile-clients/android/app-authenticate-user.md new file mode 100644 index 00000000..bb2e35e5 --- /dev/null +++ b/docs/docs/mobile-clients/android/app-authenticate-user.md @@ -0,0 +1,8 @@ +--- +sidebar_position: 4 +--- + +# Authentication + +:::tip +How to authenticate, ui, api, sdk \ No newline at end of file diff --git a/docs/docs/mobile-clients/android/app-polishing.md b/docs/docs/mobile-clients/android/app-polishing.md new file mode 100644 index 00000000..8e6be87a --- /dev/null +++ b/docs/docs/mobile-clients/android/app-polishing.md @@ -0,0 +1,8 @@ +--- +sidebar_position: 5 +--- + +# Polishing + +:::tip +Think about simple gotchas to be added before starting production of this sample. \ No newline at end of file diff --git a/docs/docs/mobile-clients/android/app-register-user.md b/docs/docs/mobile-clients/android/app-register-user.md new file mode 100644 index 00000000..565d279e --- /dev/null +++ b/docs/docs/mobile-clients/android/app-register-user.md @@ -0,0 +1,25 @@ +--- +sidebar_position: 3 +--- + +# Registration + +In this section we will explain how our app can use register users using a yubikey and the relying party. In the +following +graphic we see the 'authenticator' (i.e. the Yubikey creating credentials), the client (i.e. out android app mediating) +and the relying party called 'application'. + + + +So in order to register the user, we need the Android application to implement the communication to the relying party +next. + +## Register with Relying Party + +## Create Credentials on Authenticator + +## Provide Public Key to Relying Party diff --git a/docs/docs/mobile-clients/android/app-to-rp.md b/docs/docs/mobile-clients/android/app-to-rp.md new file mode 100644 index 00000000..97aec8f0 --- /dev/null +++ b/docs/docs/mobile-clients/android/app-to-rp.md @@ -0,0 +1,180 @@ +--- +sidebar_position: 2 +--- + +# Connecting to the Relying Party + +We will cover the backend part, called relying party, only briefly and for a more indepth discussion, please follow +the [Relying Party](/passkey-workshop/docs/category/relying-party) section. + +We'll use the Relying Party as an API to register a new user using their Yubikey. The idea is that we build an Android +App that communicates via REST to the Relying Party and the Yubikey to register a new user. + +In the later section [banking app example](advanced/getting-started.md) we will use that basis for a more complex +banking example, complete with several activities, user flows and more. + +In order to build the connection, we need to understand how to build the Relying Party backend service, make it run, and +connect the Android App to it. + + + +## Deploying the Relying Party + +Summarizing from [Relying Party](/passkey-workshop/docs/category/relying-party) what we need to start the backend, is to +execute the `./deploy.sh` (or `./deploy.ps1` on windows) script from the `deploy` folder. + +To call the deploy script, you need `docker` and a `virtual machine`. Either can be fulfilled by either +installing [docker desktop](https://docs.docker.com/desktop/) +or [docker with compose](https://docs.docker.com/compose/install/) and [podman](https://podman.io/docs/installation). +Please see the appropriate documentation, since this will not be covered here. + +:::tip Running Virtual Machines +Please remember to run a virtual machine for docker, i.e. `podman machine start` if you receive errors like +```Cannot connect to the Docker daemon at unix:///var/run/docker.sock.``` +::: + +Running the deploy script will start the server on the localhost using http. Feel free to explore the web frontend +running on your machine here [http://localhost:3000](http://localhost:3000). This will be the part we are reimplementing +in Android, so feel free to click around, register and get familiar. + +## Android and Secure HTTP Traffic + +Since [Android 9 (API Level 27)](https://developer.android.com/privacy-and-security/security-config#CleartextTrafficPermitted) +all internet traffic needs to be secured from eavesdropping using secure transport. This means we cannot use our local +server running on `localhost` to connect to our Android App. So we need to secure our traffic. + +The solution is to use a service like [devtunnel](https://learn.microsoft.com/en-gb/azure/developer/dev-tunnels/) to +expose a local web service to the internet. + +### Installation of devtunnel + +Please install devtunnel like so: + +* `brew install devtunnel` (mac) or +* `curl -sL https://aka.ms/DevTunnelCliInstall | bash` (linux) or +* `winget install Microsoft.devtunnel` (windows) + +Once installed, we need to tell the deploy script to use devtunnel. Please copy the [default.enf](/deploy/default.env) +default configuration file to [.env](/deploy/.env) file in the deploy folder. Now that we have copied the default +configuration file, please update this line + +``` +DEPLOYMENT_ENVIRONMENT=localhost +``` + +to set the deployment environment to be the devtunnel: + +``` +DEPLOYMENT_ENVIRONMENT=devtunnel +``` + +:::tip +Please remember to login to devtunnel: `devtunnel user login`. (Using 'other' for the microsoft login, you can use +GitHub and will not need to create a new microsoft account.) +::: + +To start the deployment to the devtunnel, please execute the `./deploy.sh` (mac or linux) or `./deploy.ps1`(windows). + +## Validating deployment + +If everything went smoothly, you should see an output similar to + +```shell +[...] +your demo application will be deployed here: + + https://XXXXXXXXXXXXXX-3000.euw.devtunnels.ms/test_panel + +your bank application will be deployed here: + + https://XXXXXXXXXXXXXX-3002.euw.devtunnels.ms/ + +### starting devtunnel. Type ^C to stop the tunnel and take down all containers +``` + +To verify, let us browse to the status page of the just deployed webservice. Therefore, you need to take the url the +devtunnel is running on and update it. From the example above, please take the +``` https://XXXXXXXXXXXXXX-3000.euw.devtunnels.ms/test_panel``` url and replace the `3000` with the port the service is +running on, `8080` in our case. + +You should be able to visit the relying party running on ```https://XXXXXXXXXXXXXX-8080.euw.devtunnels.ms/``` (please +replace the X with your endpoint) and be greeted with a similar image then this screenshot: + + + +The security check is to make sure that we know what we are doing, and since the url is under our control it is fine to +accept the connection and hit `continue`. + +If everything is running as planned, you will see a message like this + +```json +{ + "status": "ok" +} +``` + +This means our local server is available through the devtunnel, and works as expected. You can now also browse through +the other components, securely transmitting data from your local machine through the tunnel to the local browser. +Now that we know how to [deploy and run our backend](relying-party.md), let us connect our +[Android app](https://github.com/YubicoLabs/passkey-workshop/tree/main/examples/clients/mobile/android) to it. + +## Deploy script + +The deploy script sets up the backend relying party to be deployed on docker and then exposed through `devtunnel` from +your local machine to the broad internet. Using TLS it is able to allow a connection from your phone securely to your +laptop. + +Once this connection is established, the script finds the Android examples and modifies the configuration +in [gradle.properties](../../../../examples/clients/mobile/android/PawsKey/gradle.properties) to reflect the newly +created tunnel. This way if the tunnel changes, a call to the deploy script will also update that configuration. You +might want to change the configuration manually to contain the endpoint your relying party is running on. + +Additionally the deploy script also updates the relying party id, as in the id used to identify which credentials to +select. You will also need to update that configuration, if you update the endpoint. + +Once the configuration is updated, the script builds the android examples using `gradle`. With the source, which we will +go through in detail soon, we try to reflect current best practices and will not emphasize common ways of working. +Please follow linked literature if you want to get up to speed in everything from MVVM[1], Jetpack Compose[2] and DI +using Hilt[3]. + +## Dependencies + +Additionally, the mentioned libraries and architectures the android examples depend on retrofit and kotlinx +serialization: Those are used in order to communicate with the relying +parties: [RelyingPartyService.kt](../../../../examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/RelyingPartyService.kt) +implements the Retrofit Service to communicate with the relying +party. [PassKeyService.kt](../../../../examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/PassKeyService.kt) +is used for communicating with the passkeys, may it be from the platform perspective or the Yubico SDK point of view. + +All of this is tied together by the [MainViewModel.kt][5], that abstracts from the service implementations to expose the +view relevant details. Finally, the [MainActivity.kt][4] displays relevant details to the user. And gets called when she +wants to sign in / log in or interact otherwise with the app. + +## Hello Relying Party Server + +Let us follow the flow of the example app to communicate with the relying party with the example of requesting a status +from it's `/v1/status` endpoint. Once the user starts the app, the [MainActivity][4] initializes and creates the +[MainViewModel][5]. Once the [MainViewModel][5] is created, it asks its [Repository for RelyingParty][6] to fetch it's +status. Fetching the status has two effects: A) we are making sure the network is working. And B) that we are connecting +to a well known backend, before we are starting more complex interactions. + + +[1]: https://developer.android.com/topic/architecture + +[2]: https://developer.android.com/compose + +[3]: https://developer.android.com/training/dependency-injection/hilt-android + +[4]: ../../../../examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/MainActivity.kt + +[5]: ../../../../examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/MainViewModel.kt + +[6]: ../../../../examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/RelyingPartyService.kt \ No newline at end of file diff --git a/docs/docs/mobile-clients/android/getting-started.md b/docs/docs/mobile-clients/android/getting-started.md new file mode 100644 index 00000000..8e0f9469 --- /dev/null +++ b/docs/docs/mobile-clients/android/getting-started.md @@ -0,0 +1,64 @@ +--- +sidebar_position: 1 +--- + +# Getting Started Guide + +This section will get you started with a running the Android Pawskey example, as soon as possible. It requires you to +have + +1. a running docker installation (including a running docker virtual machine), +2. a devtunnel setup and being logged in there and +3. the ability to build Android apps through `gradle` (i.e. the `ANDROID_HOME` set to your SDK installation home.) + +If you are unsure about either installation or you want to understand the setup steps needed in detail, we recommend +reading the [getting started section](getting-started.md) first. + +## Set Configuration to Android + +Assuming everything is setup, please checkout this passkeys repository from `github.com/yubicolabs/passkey-workshop` on +your machine and browse to the root of that repository in your `terminal` of choice. + +Once arrived there, we need to change into the deploy folder like so: + +```shell +cd deploy +``` + +Since we would like to build the Android example apps, we need to update the default configuration. Please copy the +default environment configuration file `default.env` to a new file called `.env`: + +```shell +cp default.env .env +``` + +To update the configuration you need to open the newly created `.env` file and add `android` to the `DEPLOYMENT_CLIENTS` +and make sure that we deploy the web components to the `devtunnel` environment. Please assigning `devtunnel` to the +`DEPLOYMENT_ENVIRONMENT` configuration. + +Once done, the `.env` file should contain these changes, when compared to the `default.env`: + +```diff +-DEPLOYMENT_ENVIRONMENT=localhost ++DEPLOYMENT_ENVIRONMENT=devtunnel + +-DEPLOYMENT_CLIENTS=bank,demo ++DEPLOYMENT_CLIENTS=bank,demo,android +``` + +## Deploy *Everything* + +With the configuration changes done, a call to the main deploy script, like shown below, should do the trick. Please +connect a phone in order to automatically deploy the simple Android example App to that phone. Additionally, the script +will automatically connect your the docker services on your local machine via a tunnel through to a public, tls secured +domain we can access from the Android example app. + +```shell +./deploy.sh +``` + +## Troubleshooting + +We build the deploy script in such a way that it reports errors and missing setup on your machine in an easy to correct +manner. If you still feel stuck somewhere, or want a deeper understanding, please feel free to follow the more indepth +steps starting with the [next section](app-to-rp.md) and the sections following. diff --git a/docs/docs/mobile-client/_category_.json b/docs/docs/mobile-clients/ios/_category_.json similarity index 85% rename from docs/docs/mobile-client/_category_.json rename to docs/docs/mobile-clients/ios/_category_.json index 476808a7..a4897dc7 100644 --- a/docs/docs/mobile-client/_category_.json +++ b/docs/docs/mobile-clients/ios/_category_.json @@ -1,6 +1,6 @@ { - "label": "Mobile client", - "position": 6, + "label": "iOS", + "position": 2, "link": { "type": "generated-index", "description": "This section will cover the mobile iOS client passkey application. We are going to discuss the different user flows, implementation examples, and best practices for creating the application." diff --git a/docs/docs/mobile-clients/ios/advanced/_category_.json b/docs/docs/mobile-clients/ios/advanced/_category_.json new file mode 100644 index 00000000..df28cf05 --- /dev/null +++ b/docs/docs/mobile-clients/ios/advanced/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "Advanced", + "position": 100, + "link": { + "type": "generated-index", + "description": "Advanced topics not covered elsewhere." + } +} diff --git a/docs/docs/mobile-clients/ios/advanced/complex-use-cases.md b/docs/docs/mobile-clients/ios/advanced/complex-use-cases.md new file mode 100644 index 00000000..3e7ee660 --- /dev/null +++ b/docs/docs/mobile-clients/ios/advanced/complex-use-cases.md @@ -0,0 +1,8 @@ +--- +sidebar_position: 1 +--- + +# Complex Use Cases + +:::tip +Add content here. \ No newline at end of file diff --git a/docs/docs/mobile-clients/ios/app-authenticate-user.md b/docs/docs/mobile-clients/ios/app-authenticate-user.md new file mode 100644 index 00000000..0cd02c60 --- /dev/null +++ b/docs/docs/mobile-clients/ios/app-authenticate-user.md @@ -0,0 +1,8 @@ +--- +sidebar_position: 4 +--- + +# Authentication + +:::tip +{{How to authenticate a user using yubikeys}} \ No newline at end of file diff --git a/docs/docs/mobile-clients/ios/app-polishing.md b/docs/docs/mobile-clients/ios/app-polishing.md new file mode 100644 index 00000000..8e6be87a --- /dev/null +++ b/docs/docs/mobile-clients/ios/app-polishing.md @@ -0,0 +1,8 @@ +--- +sidebar_position: 5 +--- + +# Polishing + +:::tip +Think about simple gotchas to be added before starting production of this sample. \ No newline at end of file diff --git a/docs/docs/mobile-clients/ios/app-register-user.md b/docs/docs/mobile-clients/ios/app-register-user.md new file mode 100644 index 00000000..8e63867a --- /dev/null +++ b/docs/docs/mobile-clients/ios/app-register-user.md @@ -0,0 +1,8 @@ +--- +sidebar_position: 3 +--- + +# Registration + +:::tip +{{ How to register on iOS? }} diff --git a/docs/docs/mobile-clients/ios/app-to-rp.md b/docs/docs/mobile-clients/ios/app-to-rp.md new file mode 100644 index 00000000..382fc2f4 --- /dev/null +++ b/docs/docs/mobile-clients/ios/app-to-rp.md @@ -0,0 +1,9 @@ +--- +sidebar_position: 2 +--- + +# Connecting to the Relying Party + +:::tip +{{ How to check the rp is running }} +{{ How to connect iOS to RP?}} \ No newline at end of file diff --git a/docs/docs/mobile-clients/ios/getting-started.md b/docs/docs/mobile-clients/ios/getting-started.md new file mode 100644 index 00000000..ac2dee44 --- /dev/null +++ b/docs/docs/mobile-clients/ios/getting-started.md @@ -0,0 +1,8 @@ +--- +sidebar_position: 1 +--- + +# Getting Started + +:::tip +{{ use deploy.sh }} \ No newline at end of file diff --git a/docs/docs/mobile-clients/ios/legacy/_category_.json b/docs/docs/mobile-clients/ios/legacy/_category_.json new file mode 100644 index 00000000..4061866b --- /dev/null +++ b/docs/docs/mobile-clients/ios/legacy/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "~Legacy~", + "position": 100, + "link": { + "type": "generated-index", + "description": "The legacy documentation can be found here." + } +} diff --git a/docs/docs/mobile-client/getting-started.md b/docs/docs/mobile-clients/ios/legacy/getting-started.md similarity index 90% rename from docs/docs/mobile-client/getting-started.md rename to docs/docs/mobile-clients/ios/legacy/getting-started.md index fca68189..028571bf 100644 --- a/docs/docs/mobile-client/getting-started.md +++ b/docs/docs/mobile-clients/ios/legacy/getting-started.md @@ -8,7 +8,7 @@ Instructions for making the local environment available for testing from your mo ## Prerequisites -1. Ensure you have completed the [deploy project](../deploy) section in the passkey workshop by navigating to the relying party API documentation running locally in your environment: [http://localhost:8080](http://localhost:8080) +1. Ensure you have completed the [deploy project](../../../deploy) section in the passkey workshop by navigating to the relying party API documentation running locally in your environment: [http://localhost:8080](http://localhost:8080) 2. An Apple Developer account with permission to create Associated Domains Entitlements 3. Install Xcode 14.3 or newer 4. Have a physical iPhone running iOS 16.4 or newer @@ -16,7 +16,7 @@ Instructions for making the local environment available for testing from your mo ## PawsKey iOS app -Once you have [cloned](../deploy#clone-the-repository) the passkey workshop, navigate into the Pawskey iOS project folder +Once you have [cloned](../../deploy#clone-the-repository) the passkey workshop, navigate into the Pawskey iOS project folder ```javascript cd passkey-workshop/clients/mobile/iOS/Pawskey diff --git a/docs/static/img/mobile/android/relying-party-architecture.jpg b/docs/static/img/mobile/android/relying-party-architecture.jpg new file mode 100644 index 00000000..b52aa5c8 Binary files /dev/null and b/docs/static/img/mobile/android/relying-party-architecture.jpg differ diff --git a/docs/static/img/mobile/android/relying-party-status-hint.png b/docs/static/img/mobile/android/relying-party-status-hint.png new file mode 100644 index 00000000..cb6a17f6 Binary files /dev/null and b/docs/static/img/mobile/android/relying-party-status-hint.png differ diff --git a/examples/clients/mobile/android/PawsKey/.gitignore b/examples/clients/mobile/android/PawsKey/.gitignore new file mode 100644 index 00000000..aa724b77 --- /dev/null +++ b/examples/clients/mobile/android/PawsKey/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/examples/clients/mobile/android/PawsKey/app/.gitignore b/examples/clients/mobile/android/PawsKey/app/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/examples/clients/mobile/android/PawsKey/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/examples/clients/mobile/android/PawsKey/app/build.gradle.kts b/examples/clients/mobile/android/PawsKey/app/build.gradle.kts new file mode 100644 index 00000000..d6ce1aaa --- /dev/null +++ b/examples/clients/mobile/android/PawsKey/app/build.gradle.kts @@ -0,0 +1,104 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.dagger.hilt) + alias(libs.plugins.jetbrains.compose.compiler) + alias(libs.plugins.jetbrains.kotlin.android) + alias(libs.plugins.kotlinx.serialization) + alias(libs.plugins.ksp) +} + +fun getPropOrFail(name: String): String { + val property = providers.gradleProperty(name) + return if (property.isPresent && !property.get().startsWith("replace-with-your-")) { + "\"${property.get()}\"" + } else { + throw GradleException("Gradle property '$name' invalid. Did you update it in '/gradle.properties'?") + } +} + +android { + namespace = "io.yubicolabs.pawskey" + compileSdk = 35 + + defaultConfig { + applicationId = "io.yubicolabs.pawskey" + minSdk = 31 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + + all { + buildConfigField("String", "API_BASE_URI", getPropOrFail("API_BASE_URI")) + buildConfigField("String", "RELYING_PARTY_ID", getPropOrFail("RELYING_PARTY_ID")) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + compose = true + buildConfig = true + } + + composeOptions { + kotlinCompilerExtensionVersion = + libs.versions.androidx.compose.compiler + .get() + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.yubico.yubikit) + implementation(libs.yubico.yubikit.fido) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.material3) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.dagger.hilt) + implementation(libs.kotlinx.serialization) + implementation(libs.log4j) + implementation(libs.retrofit) + implementation(libs.retrofit.kotlinx.serialization) + + ksp(libs.dagger.hilt.compiler) + + testImplementation(libs.coroutines.test) + testImplementation(libs.junit) + + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.ui.test.junit4) + + debugImplementation(libs.androidx.ui.test.manifest) + debugImplementation(libs.androidx.ui.tooling) +} \ No newline at end of file diff --git a/examples/clients/mobile/android/PawsKey/app/proguard-rules.pro b/examples/clients/mobile/android/PawsKey/app/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/examples/clients/mobile/android/PawsKey/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/AndroidManifest.xml b/examples/clients/mobile/android/PawsKey/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..6e1e05aa --- /dev/null +++ b/examples/clients/mobile/android/PawsKey/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/assets/logback.xml b/examples/clients/mobile/android/PawsKey/app/src/main/assets/logback.xml new file mode 100644 index 00000000..fd590840 --- /dev/null +++ b/examples/clients/mobile/android/PawsKey/app/src/main/assets/logback.xml @@ -0,0 +1,18 @@ + + + + %logger{12} + + + [%-20thread] %msg + + + + + + + \ No newline at end of file diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ActivityProvider.kt b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ActivityProvider.kt new file mode 100644 index 00000000..72ac5a46 --- /dev/null +++ b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ActivityProvider.kt @@ -0,0 +1,18 @@ +package io.yubicolabs.pawskey + +/** + * Provide the last used Activity. + */ +interface ActivityProvider { + fun getActivity(): MainActivity +} + +/** + * Implementation for Pawskeys + */ +class PawskeyActivityProvider : ActivityProvider { + override fun getActivity(): MainActivity { + val lastActivity = PawskeyApplication.instance.mainActivity // TODO: 👀👀👀👀 + return lastActivity + } +} diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ApiModule.kt b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ApiModule.kt new file mode 100644 index 00000000..ce25a175 --- /dev/null +++ b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ApiModule.kt @@ -0,0 +1,101 @@ +package io.yubicolabs.pawskey + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import com.yubico.yubikit.android.YubiKitManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.android.qualifiers.ApplicationContext +import io.yubicolabs.pawskey.net.DebugInterceptor +import kotlinx.serialization.json.Json +import okhttp3.MediaType +import okhttp3.OkHttpClient +import retrofit2.Converter +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory + +@InstallIn(ViewModelComponent::class) +@Module +object ApiModule { + @Provides + fun provideRelyingPartyService( + relyingPartyHttpService: RelyingPartyHttpService, + ): RelyingPartyService = + RelyingPartyService( + relyingPartyHttpService, + ) + + @Provides + fun provideRelyingPartyHttpService( + okHttpClient: OkHttpClient, + jsonConverter: Converter.Factory, + ): RelyingPartyHttpService = + Retrofit.Builder() + .baseUrl("https://${BuildConfig.API_BASE_URI}/") + .client(okHttpClient) + .addConverterFactory(jsonConverter) + .build() + .create(RelyingPartyHttpService::class.java) + + @Provides + fun provideOkHttpClient(): OkHttpClient { + val builder = OkHttpClient() + .newBuilder() + + if (BuildConfig.DEBUG) { + builder.addInterceptor( + DebugInterceptor() + ) + } + + return builder.build() + } + + @Provides + fun providesJson(): Json = Json { + encodeDefaults = true + ignoreUnknownKeys = true + prettyPrint = BuildConfig.DEBUG + } + + @Provides + fun providesJsonFactory(json: Json): Converter.Factory = json + .asConverterFactory( + MediaType.get("application/json") + ) + + @Provides + fun provideClipboard( + @ApplicationContext + context: Context + ): ClipboardProvider = object : ClipboardProvider { + override fun setContent(message: String) { + val manager = context.getSystemService(ClipboardManager::class.java) + manager.setPrimaryClip( + ClipData.newPlainText("Message from $tagForLog.", message) + ) + } + } + + @Provides + fun provideActivityProvider(): ActivityProvider = PawskeyActivityProvider() + + @Provides + fun providePasskeyService( + yubiKitManager: YubiKitManager, + activityProvider: ActivityProvider, + ): PassKeyService = PassKeyService( + yubico = yubiKitManager, + activityProvider = activityProvider + ) + + @Provides + fun provideYubiManager( + @ApplicationContext + context: Context + ): YubiKitManager = + YubiKitManager(context) +} \ No newline at end of file diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/Application.kt b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/Application.kt new file mode 100644 index 00000000..f7cd1c45 --- /dev/null +++ b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/Application.kt @@ -0,0 +1,22 @@ +package io.yubicolabs.pawskey + +import android.app.Activity +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class PawskeyApplication : Application() { + companion object { + private lateinit var application: PawskeyApplication + + val instance: PawskeyApplication + get() = application + } + + lateinit var mainActivity: MainActivity + + override fun onCreate() { + super.onCreate() + application = this + } +} diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ClipboardProvider.kt b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ClipboardProvider.kt new file mode 100644 index 00000000..83d18d44 --- /dev/null +++ b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ClipboardProvider.kt @@ -0,0 +1,8 @@ +package io.yubicolabs.pawskey + +/** + * Abstraction from clipboard + */ +interface ClipboardProvider { + fun setContent(message: String) +} \ No newline at end of file diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/Extensions.kt b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/Extensions.kt new file mode 100644 index 00000000..58ba3b28 --- /dev/null +++ b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/Extensions.kt @@ -0,0 +1,8 @@ +package io.yubicolabs.pawskey + +/** + * @warn pollutes all classes. + * @return name for this class, or a placeholder. + */ +inline val Any.tagForLog: String + get() = javaClass.simpleName ?: "YubiSample" \ No newline at end of file diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/MainActivity.kt b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/MainActivity.kt new file mode 100644 index 00000000..2d997325 --- /dev/null +++ b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/MainActivity.kt @@ -0,0 +1,428 @@ +package io.yubicolabs.pawskey + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import dagger.hilt.android.AndroidEntryPoint +import io.yubicolabs.pawskey.Message.AttestationOptionsReceived +import io.yubicolabs.pawskey.Message.KeysFound +import io.yubicolabs.pawskey.Message.PublicKeyGenerated +import io.yubicolabs.pawskey.Message.ServerOkay +import io.yubicolabs.pawskey.UserInteraction.AttestationError +import io.yubicolabs.pawskey.UserInteraction.ConnectKey +import io.yubicolabs.pawskey.UserInteraction.CreationError +import io.yubicolabs.pawskey.UserInteraction.EnterPin +import io.yubicolabs.pawskey.UserInteraction.UserNameWrong +import io.yubicolabs.pawskey.UserInteraction.WaitForRP +import io.yubicolabs.pawskey.ui.theme.PawsKeyTheme +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + private val vm: MainViewModel by viewModels() + + @OptIn(ExperimentalMaterial3Api::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val log: Logger = LoggerFactory.getLogger(MainActivity::class.java) + log.info("hello world") + + + /// TODO Don't look to closely: NFC Needs the main activity. + PawskeyApplication.instance.mainActivity = this + + enableEdgeToEdge() + setContent { + PawsKeyTheme { + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(id = R.string.app_name) + ) + } + ) + } + ) { innerPadding -> + val state by vm.uiState.collectAsState() + Box { + PawskeyUi( + modifier = Modifier.padding(innerPadding), + uiState = state, + checkServer = vm::checkApiStatus, + register = vm::registerUser, + copyToClipboard = { message -> + vm.copyToClipboard(message.toString()) + }, + delete = { message -> + vm.deleteMessage(message) + } + ) + + state.userInteractionNeeded?.let { interaction -> + UserInteractionModal( + interaction, + setPin = { vm.pin = it }, + cancelled = vm::interactionCancelled, + copy = { vm.copyToClipboard(it) }, + cancelAllInteractions = vm::cancelAllInteractions + ) + } + } + } + } + } + } +} + +@Composable +@Preview +fun UserInteractionModal( + interaction: UserInteraction = EnterPin, + setPin: (pin: String) -> Unit = {}, + cancelled: () -> Unit = {}, + copy: (message: String) -> Unit = {}, + cancelAllInteractions: () -> Unit = {}, +) { + Column( + modifier = Modifier + .fillMaxSize() + .background( + MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + when (interaction) { + is EnterPin -> EnterPinModal( + success = setPin, + cancelled = cancelled + ) + + is WaitForRP -> WaitForRPModal( + cancelled = cancelled + ) + + is UserNameWrong -> ErrorMessage( + message = stringResource(id = R.string.error_wrong_user_name), + confirm = cancelled, + dismiss = cancelled, + copy = copy, + ) + + is AttestationError -> ErrorMessage( + message = stringResource(id = R.string.error_no_attestatation), + confirm = cancelled, + dismiss = cancelled, + copy = copy + ) + + is CreationError -> ErrorMessage( + message = stringResource(id = R.string.error_not_created), + confirm = cancelled, + dismiss = cancelled, + copy = copy + ) + + is ConnectKey -> ConnectKeyMessage(cancelled = cancelAllInteractions) + } + + } +} + +@Composable +@Preview +private fun ConnectKeyMessage( + cancelled: () -> Unit = {} +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.4f)), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Card( + modifier = Modifier.padding(32.dp) + ) { + Column( + modifier = Modifier.padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + modifier = Modifier.padding(bottom = 16.dp), + style = MaterialTheme.typography.headlineMedium, + text = "Please connect a key." + ) + CircularProgressIndicator() + Row { + Spacer(modifier = Modifier.weight(1.0f)) + Button(onClick = cancelled) { + Text(text = stringResource(id = android.R.string.cancel)) + } + } + } + } + } +} + +@Composable +@Preview +private fun WaitForRPModal( + cancelled: () -> Unit = {} +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.4f)), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Card( + modifier = Modifier.padding(32.dp) + ) { + Column( + modifier = Modifier.padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + modifier = Modifier.padding(bottom = 16.dp), + style = MaterialTheme.typography.headlineMedium, + text = "Waiting for Relying Party." + ) + CircularProgressIndicator() + Row { + Spacer(modifier = Modifier.weight(1.0f)) + Button(onClick = cancelled) { + Text(text = stringResource(id = android.R.string.cancel)) + } + } + } + } + } +} + +@Composable +@Preview +private fun ErrorMessage( + message: String = "Something went wrong.", + confirm: () -> Unit = {}, + dismiss: () -> Unit = {}, + copy: (message: String) -> Unit = {}, +) { + AlertDialog( + onDismissRequest = dismiss, + confirmButton = { + Button(onClick = confirm) { + Text(text = stringResource(id = android.R.string.ok)) + } + }, + title = { Text(stringResource(id = R.string.dialog_title_error)) }, + text = { Text(message) }, + dismissButton = { + IconButton(onClick = { copy(message) }) { + Icon( + painter = painterResource(id = R.drawable.baseline_content_copy_24), + contentDescription = null + ) + } + }, + ) +} + +@Composable +@Preview +private fun EnterPinModal( + cancelled: () -> Unit = {}, + success: (pin: String) -> Unit = {} +) { + // TODO Alert Dialog? + // TODO CUSTOM VM? + var pin: String by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = cancelled, + confirmButton = { + Button(onClick = { success(pin) }) { + Text(text = stringResource(id = android.R.string.ok)) + } + }, + title = { Text(stringResource(id = R.string.dialog_title_pin_needed)) }, + text = { + Column { + Text( + modifier = Modifier.padding(vertical = 5.dp), + text = stringResource(R.string.pin_dialog_need) + ) + TextField( + modifier = Modifier.fillMaxWidth(), + value = pin, + label = { Text("Pin") }, + onValueChange = { pin = it } + ) + } + }, + dismissButton = { + Button(onClick = cancelled) { + Text(text = stringResource(id = android.R.string.cancel)) + } + } + ) +} + +@Composable +@Preview +fun PawskeyUi( + modifier: Modifier = Modifier, + uiState: UiState = UiState( + userMessages = listOf( + ServerOkay(), + PublicKeyGenerated("id", "type"), + AttestationOptionsReceived("id", "key") + ) + ), + checkServer: () -> Unit = {}, + register: (userName: String, pin: String) -> Unit = { _, _ -> + }, + copyToClipboard: (message: Message) -> Unit = {}, + delete: (message: Message) -> Unit = {}, +) { + var userName: String by remember { mutableStateOf("Android Test User") } + var pin: String by remember { mutableStateOf("123456") } + + Column( + modifier = modifier + ) { + Card { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + TextField( + value = userName, + label = { + Text( + stringResource(id = R.string.user_name_label) + ) + }, + onValueChange = { userName = it } + ) + TextField( + value = pin, + label = { + Text( + stringResource(id = R.string.pin_label) + ) + }, + onValueChange = { pin = it } + ) + Row { + Button(onClick = checkServer) { + Text( + text = "Status" + ) + } + Button(onClick = { register(userName, pin) }) { + Text( + text = "Register" + ) + } + } + } + } + + Card { + LazyColumn { + items(uiState.userMessages.reversed()) { message -> + UserMessage( + message, + { copyToClipboard(message) }, + { delete(message) } + ) + } + } + } + } +} + +@Composable +@Preview +private fun UserMessage( + message: Message = ServerOkay(), + copyToClipboard: () -> Unit = {}, + delete: () -> Unit = {}, +) { + Row( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1.0f), + style = MaterialTheme.typography.bodyLarge, + text = message.toString() + ) + Icon( + modifier = Modifier + .size(24.dp) + .clickable { delete() }, + painter = painterResource(id = R.drawable.baseline_outline_delete_24), + contentDescription = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + modifier = Modifier + .size(20.dp) + .clickable { copyToClipboard() }, + painter = painterResource(id = R.drawable.baseline_content_copy_24), + contentDescription = null + ) + } +} diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/MainViewModel.kt b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/MainViewModel.kt new file mode 100644 index 00000000..30507385 --- /dev/null +++ b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/MainViewModel.kt @@ -0,0 +1,354 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package io.yubicolabs.pawskey + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.yubico.yubikit.fido.client.BasicWebAuthnClient +import com.yubico.yubikit.fido.webauthn.AuthenticatorSelectionCriteria +import com.yubico.yubikit.fido.webauthn.PublicKeyCredential +import com.yubico.yubikit.fido.webauthn.PublicKeyCredentialCreationOptions +import com.yubico.yubikit.fido.webauthn.PublicKeyCredentialDescriptor +import com.yubico.yubikit.fido.webauthn.PublicKeyCredentialParameters +import com.yubico.yubikit.fido.webauthn.PublicKeyCredentialRpEntity +import com.yubico.yubikit.fido.webauthn.PublicKeyCredentialUserEntity +import dagger.hilt.android.lifecycle.HiltViewModel +import io.yubicolabs.pawskey.Message.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import retrofit2.HttpException +import java.net.ConnectException +import java.net.SocketTimeoutException +import javax.inject.Inject +import kotlin.time.ExperimentalTime +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +sealed class UserInteraction { + data object WaitForRP : UserInteraction() + + data object EnterPin : UserInteraction() + + data object UserNameWrong : UserInteraction() + + data object ConnectKey : UserInteraction() + + data object AttestationError : UserInteraction() + + data object CreationError : UserInteraction() +} + +sealed class Message( + val uuid: Uuid = Uuid.random() +) { + data class ServerOkay( + val additionalInfo: String? = null + ) : Message() + + data class ServerNotOkay( + val th: Throwable? = null + ) : Message() + + data class ServerNotConnected( + val th: Throwable + ) : Message() + + data class ServerTimeout( + val additionalInfo: String? = null + ) : Message() + + data class UserNameWrong( + val additionalInfo: String? = null + ) : Message() + + data class PublicKeyGenerated( + val id: String, + val type: String, + ) : Message() + + data class AttestationOptionsReceived( + val id: String, + val key: String, + ) : Message() + + data class KeysFound( + val keys: List + ) : Message() + + data class ServerError( + val message: String + ) : Message() + + data class KeyCreationFailure( + val th: Throwable + ) : Message() +} + +data class UiState( + val userMessages: List = emptyList(), + val userInteractionNeeded: UserInteraction? = null, +) + +@HiltViewModel +class MainViewModel @Inject constructor( + private val relyingPartyService: RelyingPartyService, + private val passKeyService: PassKeyService, + private val clipboard: ClipboardProvider, + private val json: Json, +) : ViewModel() { + + private val _uiState: MutableStateFlow = MutableStateFlow( + UiState() + ) + val uiState: StateFlow = _uiState.asStateFlow() + + var pin: String? = null + + var userName: String? = null + var attestation: AttestationOptionsResponse? = null + var publicKey: PublicKeyCredential? = null + + fun checkApiStatus() { + showUserInteraction( + UserInteraction.WaitForRP + ) + + viewModelScope.launch { + val message = try { + val result = relyingPartyService.getStatus() + + if (result) { + ServerOkay() + } else { + ServerNotOkay() + } + + } catch (_: SocketTimeoutException) { + ServerTimeout() + } catch (exception: HttpException) { + ServerError("${exception.code()}: ${exception.message()}") + } + + showUserInteraction(null) + showUserMessage(message) + } + } + + fun registerUser(userName: String, pin: String) { + if (userName.isEmpty()) { + showUserMessage(UserNameWrong()) + showUserInteraction(UserInteraction.UserNameWrong) + } else if (pin.isEmpty()) { + showUserInteraction(UserInteraction.EnterPin) + } else { + this.userName = userName + this.pin = pin + + viewModelScope.launch(Dispatchers.IO) { + showUserInteraction(UserInteraction.WaitForRP) + attestation = getAttestationOptionsFromRP() + + if (attestation == null) { + showUserInteraction(UserInteraction.AttestationError) + } else { + createKey(attestation!!) + } + } + } + } + + private fun createKey(attestationOptions: AttestationOptionsResponse) { + if (userName.isNullOrEmpty()) { + showUserInteraction(UserInteraction.UserNameWrong) + } else if (pin.isNullOrEmpty()) { + showUserInteraction(UserInteraction.EnterPin) + } else { + viewModelScope.launch(Dispatchers.IO) { + _uiState.update { + it.copy(userInteractionNeeded = UserInteraction.ConnectKey) + } + + passKeyService.addWebauthnClientListener( + object : PassKeyService.Listener { + override fun invoke(webAuthnClient: BasicWebAuthnClient) { + showUserInteraction(UserInteraction.WaitForRP) + + createKeyOnDevice(attestationOptions, webAuthnClient) + } + }, + updateWithCurrentState = true, + ) + passKeyService.startDeviceDiscovery() + } + } + } + + private fun PassKeyService.Listener.createKeyOnDevice( + attestationOptions: AttestationOptionsResponse, + webAuthnClient: BasicWebAuthnClient + ) { + try { + Log.i(tagForLog, "Client $this found.") + + val clientDataJson = attestationOptions.toClientDataJsonByteArray() + val options = attestationOptions.publicKey.toOptions() + val effectiveDomain = attestationOptions.publicKey.rp.id + + publicKey = webAuthnClient.makeCredential( + clientDataJson, + options, + effectiveDomain, + pin!!.toCharArray(), + null, + null, + ) + + if (publicKey != null) { + showUserInteraction(null) + showUserMessage(PublicKeyGenerated(publicKey!!.id, publicKey!!.type)) + } else { + showUserInteraction(UserInteraction.CreationError) + } + } catch (throwable: Throwable) { + val message = KeyCreationFailure(throwable) + Log.e(tagForLog, message.toString(), throwable) + + attestation = null + + showUserMessage(message) + showUserInteraction(null) + } + } + + private suspend fun getAttestationOptionsFromRP(): AttestationOptionsResponse? { + return try { + val result = relyingPartyService.getAttestationOptions( + userName ?: "Not set" + ) + + if (result.requestId.isNotBlank()) { + val hint = StringBuilder() + val user = result.publicKey.user + hint.append(json.encodeToString(user)) + + showUserMessage( + AttestationOptionsReceived( + result.requestId, + result.publicKey.toString(), + ) + ) + result + } else { + showUserMessage(ServerNotOkay()) + null + } + } catch (e: ConnectException) { + showUserMessage(ServerNotConnected(e)) + null + } catch (_: SocketTimeoutException) { + showUserMessage(ServerTimeout()) + null + } catch (exception: HttpException) { + showUserMessage(ServerNotOkay(exception)) + null + } + } + + @OptIn(ExperimentalTime::class) + private fun showUserMessage( + message: Message + ) { + _uiState.update { + it.copy(userMessages = it.userMessages + message) + } + } + + private fun showUserInteraction( + interaction: UserInteraction? + ) { + _uiState.update { + it.copy(userInteractionNeeded = interaction) + } + } + + fun copyToClipboard(message: String) { + clipboard.setContent(message) + } + + fun deleteMessage(message: Message) { + _uiState.update { + it.copy( + userMessages = it.userMessages.filter { + it.uuid != message.uuid + } + ) + } + } + + fun interactionCancelled() { + _uiState.update { + it.copy( + userInteractionNeeded = null + ) + } + } + + fun cancelAllInteractions() { + passKeyService.stopDeviceDiscovery() + + _uiState.update { + it.copy( + userInteractionNeeded = null + ) + } + } +} + +private fun AttestationOptionsResponse.toClientDataJsonByteArray(): ByteArray = """ + { + type: "webauthn.create", + challenge: "${publicKey.challenge}", + origin: "${publicKey.rp.id}" + } + """.trimIndent().trimIndent().toByteArray() + +private fun AttestationOptionsResponse.PublicKey.toOptions(): PublicKeyCredentialCreationOptions = + PublicKeyCredentialCreationOptions( + PublicKeyCredentialRpEntity( + rp.name, + rp.id + ), + PublicKeyCredentialUserEntity( + user.name, + user.id.toByteArray(), + user.displayName + ), + challenge.toByteArray(), + pubKeyCredParams.map { + PublicKeyCredentialParameters( + it.type, + it.alg + ) + }, + 180000, + excludeCredentials.map { + PublicKeyCredentialDescriptor( + it.type, + it.id.toByteArray() + ) + }, + AuthenticatorSelectionCriteria( + authenticatorSelection.authenticatorAttachment, + authenticatorSelection.residentKey, + authenticatorSelection.userVerification, + ), + attestation, + null + ) diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/PassKeyService.kt b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/PassKeyService.kt new file mode 100644 index 00000000..635d4102 --- /dev/null +++ b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/PassKeyService.kt @@ -0,0 +1,137 @@ +package io.yubicolabs.pawskey + +import android.util.Log +import com.yubico.yubikit.android.YubiKitManager +import com.yubico.yubikit.android.transport.nfc.NfcConfiguration +import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice +import com.yubico.yubikit.android.transport.usb.UsbConfiguration +import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice +import com.yubico.yubikit.core.YubiKeyDevice +import com.yubico.yubikit.core.util.Callback +import com.yubico.yubikit.fido.client.BasicWebAuthnClient +import com.yubico.yubikit.fido.ctap.Ctap2Session +import kotlinx.coroutines.delay +import javax.inject.Inject + + +class PassKeyService @Inject constructor( + private val yubico: YubiKitManager, + private val activityProvider: ActivityProvider, +) { + interface Listener { + operator fun invoke(webAuthnClient: BasicWebAuthnClient) + } + + private val usbConfig: UsbConfiguration = UsbConfiguration().handlePermissions(true) + private val usbListener: Callback = Callback { device -> + onDeviceConnected(device) + } + + private val nfcConfig: NfcConfiguration = NfcConfiguration() + .disableNfcDiscoverySound(false) + .handleUnavailableNfc(true) + .timeout(3_000) + .skipNdefCheck(false) + private val nfcListener: Callback = Callback { device -> + onDeviceConnected(device) + } + + private val authnClients: MutableList = mutableListOf() + private val clientListeners: MutableList = mutableListOf() + + fun startDeviceDiscovery() { + yubico.startUsbDiscovery( + usbConfig, + usbListener + ) + + // FIXME ACTIVITY INJECTION + + yubico.startNfcDiscovery( + nfcConfig, + activityProvider.getActivity(), + nfcListener + ) + } + + private fun onDeviceConnected(device: YubiKeyDevice) { + Log.i(tagForLog, "Connected to device $device.") + + Ctap2Session.create(device) { result -> + if (!result.isSuccess) { + Log.e(tagForLog, "Couldn't create session for device $device: ${result.error}") + } else { + val session = result.value + val authnClient = BasicWebAuthnClient(session) + Log.i(tagForLog, "Created session: $session and authnclient $authnClient. Informing all listeners.") + + authnClients.add(authnClient) + informListeners(authnClient) + } + } + } + + /** + * Get informed when a new device gets connected and we were able to establish a session for a webauthn client. + * + * @param listener the callback invoked with every new session created + * @param updateWithCurrentState when set to true calls the new listener with the current active clients + */ + fun addWebauthnClientListener(listener: Listener, updateWithCurrentState: Boolean): Boolean { + if (updateWithCurrentState) { + authnClients.forEach { listener(it) } + } + + return clientListeners.add(listener) + } + + /** + * Remove a listener for webauthn clients once not needed anymore. + */ + fun removeClientListener(listener: Listener) = clientListeners.remove(listener) + + fun forAllWebAuthnClients(block: BasicWebAuthnClient.() -> Unit): Int { + authnClients.forEach { + it.block() + } + + return authnClients.size + } + + val connectedKeyCount: Int + get() = authnClients.size + + private fun informListeners(webAuthnClient: BasicWebAuthnClient) { + clientListeners.forEach { it(webAuthnClient) } + } + + data class Key( + val id: String, + ) + + suspend fun findConnectedKeys(): List? { + // TODO Replace with sdk / platform impl + + delay(500) + + return listOf( + Key("mocked") + ) + } + + fun stopDeviceDiscovery() { + yubico.stopUsbDiscovery() + yubico.stopNfcDiscovery(activityProvider.getActivity()) + + clientListeners.clear() + } +} + + +private val com.yubico.yubikit.core.util.Result.error: Throwable + get() = try { + this.value + IllegalStateException("No error found.") + } catch (th: Throwable) { + th + } \ No newline at end of file diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/RelyingPartyService.kt b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/RelyingPartyService.kt new file mode 100644 index 00000000..99ced127 --- /dev/null +++ b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/RelyingPartyService.kt @@ -0,0 +1,132 @@ +package io.yubicolabs.pawskey + +import android.util.Log +import io.yubicolabs.pawskey.AttestationOptionsRequest.AuthenticatorSelection +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import retrofit2.HttpException +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import javax.inject.Inject + +@Serializable +data class Status( + @SerialName("status") + val status: String +) + +@Serializable +data class AttestationOptionsRequest( + val userName: String, + val displayName: String, + val authenticatorSelection: AuthenticatorSelection, + val attestation: String? = null, +) { + @Serializable + data class AuthenticatorSelection( + val residentKey: String, + val authenticatorAttachment: String? = null, + val userVerification: String? = null, + ) +} + +@Serializable +data class AttestationOptionsResponse( + val requestId: String, + val publicKey: PublicKey, +) { + @Serializable + data class PublicKey( + val rp: RelyingParty, + val user: User, + val challenge: String, + val pubKeyCredParams: List, + val timeout: Long, + val excludeCredentials: List, + val authenticatorSelection: AuthenticatorSelection, + val attestation: String + ) { + @Serializable + data class RelyingParty( + val name: String, + val id: String, + ) + + @Serializable + data class User( + val id: String, + val name: String, + val displayName: String, + ) + + @Serializable + data class CredentialParameter( + val type: String, + val alg: Int, + ) + + @Serializable + data class ExcludeCredential( + val id: String, + val type: String, + ) + } +} + +interface RelyingPartyHttpService { + @GET("v1/status") + suspend fun getStatus(): Response + + @POST("v1/attestation/options") + suspend fun getAttestationOptions( + @Body options: AttestationOptionsRequest + ): Response +} + +class RelyingPartyService @Inject constructor( + private val httpService: RelyingPartyHttpService, +) { + /** + * Status: Is the server alive and kicking? + * + * @throws retrofit2.HttpException on error code response (not 2xx http codes) + * @throws java.net.SocketTimeoutException on timeout + */ + suspend fun getStatus(): Boolean { + val response = httpService.getStatus() + val body = response.body() + + if (response.isSuccessful && body != null) { + return body.status == "ok" + } else { + val ex = HttpException(response) + Log.e(tagForLog, "Status endpoint returned error. Response was $response.", ex) + throw ex + } + } + + suspend fun getAttestationOptions( + userName: String + ): AttestationOptionsResponse { + val response = httpService.getAttestationOptions( + AttestationOptionsRequest( + userName = userName, + displayName = userName, + authenticatorSelection = AuthenticatorSelection( + residentKey = "preferred" + ) + ) + ) + + val body = response.body() + + if (response.isSuccessful && body != null) { + return body + } else { + throw HttpException(response) + } + } +} + diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/net/DebugInterceptor.kt b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/net/DebugInterceptor.kt new file mode 100644 index 00000000..a40260b3 --- /dev/null +++ b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/net/DebugInterceptor.kt @@ -0,0 +1,28 @@ +package io.yubicolabs.pawskey.net + +import android.util.Log +import io.yubicolabs.pawskey.tagForLog +import okhttp3.Interceptor +import okhttp3.Response + +/** + * This interceptor logs all incoming traffic. + */ +class DebugInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response = try { + val request = chain.request().newBuilder().header("accept","*/*").build() + Log.d(tagForLog, request.toString()) + Log.d(tagForLog, "> Headers: ${request.headers().toString().replace("\n", "; ")}") + + val response = chain.proceed(request) + val body = response.peekBody(Long.MAX_VALUE) + Log.d(tagForLog, response.toString()) + Log.d(tagForLog, "< Headers: ${response.headers().toString().replace("\n", "; ")}") + Log.d(tagForLog, "< Body: ${body.string()}") + + response + } catch (th: Throwable) { + Log.e(tagForLog, "Interceptor intercepted error", th) + throw th + } +} diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ui/theme/Color.kt b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ui/theme/Color.kt new file mode 100644 index 00000000..a9c1af5d --- /dev/null +++ b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ui/theme/Color.kt @@ -0,0 +1,8 @@ +package io.yubicolabs.pawskey.ui.theme + +import androidx.compose.ui.graphics.Color + +val YubiGreen = Color(0xFF8BCC00) +val YubiBlue = Color(0xff8bccff) +val YubiGray = Color(0xffabc280) +val YubiOrange = Color(0xffffcc00) \ No newline at end of file diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ui/theme/Theme.kt b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ui/theme/Theme.kt new file mode 100644 index 00000000..b9b0910a --- /dev/null +++ b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ui/theme/Theme.kt @@ -0,0 +1,20 @@ +package io.yubicolabs.pawskey.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +@Composable +fun PawsKeyTheme( + content: @Composable () -> Unit +) { + val context = LocalContext.current + val colorScheme = dynamicDarkColorScheme(context) + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ui/theme/Type.kt b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ui/theme/Type.kt new file mode 100644 index 00000000..7c28fcbe --- /dev/null +++ b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package io.yubicolabs.pawskey.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/drawable/baseline_content_copy_24.xml b/examples/clients/mobile/android/PawsKey/app/src/main/res/drawable/baseline_content_copy_24.xml new file mode 100644 index 00000000..942aeb96 --- /dev/null +++ b/examples/clients/mobile/android/PawsKey/app/src/main/res/drawable/baseline_content_copy_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/drawable/baseline_outline_delete_24.xml b/examples/clients/mobile/android/PawsKey/app/src/main/res/drawable/baseline_outline_delete_24.xml new file mode 100644 index 00000000..3fb404dc --- /dev/null +++ b/examples/clients/mobile/android/PawsKey/app/src/main/res/drawable/baseline_outline_delete_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/drawable/ic_launcher_background.xml b/examples/clients/mobile/android/PawsKey/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..ca3826a4 --- /dev/null +++ b/examples/clients/mobile/android/PawsKey/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/drawable/ic_launcher_foreground.xml b/examples/clients/mobile/android/PawsKey/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..2b068d11 --- /dev/null +++ b/examples/clients/mobile/android/PawsKey/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..c4a603d4 --- /dev/null +++ b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..6f3b755b --- /dev/null +++ b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..11216ee9 Binary files /dev/null and b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 00000000..b2dfe3d1 Binary files /dev/null and b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..2e4f725b Binary files /dev/null and b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 00000000..62b611da Binary files /dev/null and b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..e8b039d8 Binary files /dev/null and b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..1b9a6956 Binary files /dev/null and b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..7aab0e36 Binary files /dev/null and b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..9287f508 Binary files /dev/null and b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..d451fec2 Binary files /dev/null and b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..9126ae37 Binary files /dev/null and b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/values/colors.xml b/examples/clients/mobile/android/PawsKey/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..f8c6127d --- /dev/null +++ b/examples/clients/mobile/android/PawsKey/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/values/strings.xml b/examples/clients/mobile/android/PawsKey/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..a999c0b0 --- /dev/null +++ b/examples/clients/mobile/android/PawsKey/app/src/main/res/values/strings.xml @@ -0,0 +1,18 @@ + + PawsKey + Connected to %s. + Server returned unexpected status. + Connecting to server resulted in an error with message \'%s\'. + Could not connect to server. Is it running and do you have internet? + Received options with id %s. Requesting passkey credentials matching this:\n%s. + These keys are found: %s. + User Name + Error + 📋 + Not a valid response from relying party received. + The user name you provided is wrong. + Enter Pin + A pin is needed + Could not create credential. + Pin + \ No newline at end of file diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/values/themes.xml b/examples/clients/mobile/android/PawsKey/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..ebf2fa5d --- /dev/null +++ b/examples/clients/mobile/android/PawsKey/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +