|
| 1 | +package com.leecam.credmanwebtest.webhandler |
| 2 | + |
| 3 | +import android.app.Activity |
| 4 | +import android.net.Uri |
| 5 | +import android.util.Log |
| 6 | +import android.webkit.WebView |
| 7 | +import android.widget.Toast |
| 8 | +import androidx.annotation.UiThread |
| 9 | +import androidx.credentials.PublicKeyCredential |
| 10 | +import androidx.credentials.exceptions.CreateCredentialException |
| 11 | +import androidx.credentials.exceptions.GetCredentialException |
| 12 | +import androidx.webkit.JavaScriptReplyProxy |
| 13 | +import androidx.webkit.WebMessageCompat |
| 14 | +import androidx.webkit.WebViewCompat |
| 15 | +import kotlinx.coroutines.CoroutineScope |
| 16 | +import kotlinx.coroutines.launch |
| 17 | +import org.json.JSONArray |
| 18 | +import org.json.JSONObject |
| 19 | + |
| 20 | + |
| 21 | +/** |
| 22 | +This web listener looks for the 'postMessage()' call on the javascript web code, and when it |
| 23 | +receives it, it will handle it in the manner dictated in this local codebase. This allows for |
| 24 | +javascript on the web to interact with the local setup on device that contains more complex logic. |
| 25 | +
|
| 26 | +The embedded javascript can be found in CredentialManagerWebView/javascript/encode.js. |
| 27 | +It can be modified depending on the use case. If you wish to minify, please use the following command |
| 28 | +to call the toptal minifier API. |
| 29 | +``` |
| 30 | +cat encode.js | grep -v '^let __webauthn_interface__;$' | \ |
| 31 | +curl -X POST --data-urlencode input@- \ |
| 32 | +https://www.toptal.com/developers/javascript-minifier/api/raw | tr '"' "'" | pbcopy |
| 33 | +``` |
| 34 | +pbpaste should output the proper minimized code. In linux, you may have to alias as follows: |
| 35 | +``` |
| 36 | +alias pbcopy='xclip -selection clipboard' |
| 37 | +alias pbpaste='xclip -selection clipboard -o' |
| 38 | +``` |
| 39 | +in your bashrc. |
| 40 | + */ |
| 41 | +class PasskeyWebListener( |
| 42 | + private val activity: Activity, |
| 43 | + private val coroutineScope: CoroutineScope, |
| 44 | + private val credentialManagerHandler: CredentialManagerHandler |
| 45 | +) : WebViewCompat.WebMessageListener { |
| 46 | + |
| 47 | + /** havePendingRequest is true if there is an outstanding WebAuthn request. There is only ever |
| 48 | + one request outstanding at a time.*/ |
| 49 | + private var havePendingRequest = false |
| 50 | + |
| 51 | + /** pendingRequestIsDoomed is true if the WebView has navigated since starting a request. The |
| 52 | + fido module cannot be cancelled, but the response will never be delivered in this case.*/ |
| 53 | + private var pendingRequestIsDoomed = false |
| 54 | + |
| 55 | + /** replyChannel is the port that the page is listening for a response on. It |
| 56 | + is valid if `havePendingRequest` is true.*/ |
| 57 | + private var replyChannel: ReplyChannel? = null |
| 58 | + |
| 59 | + /** Called by the page when it wants to do a WebAuthn `get` or 'post' request. */ |
| 60 | + @UiThread |
| 61 | + override fun onPostMessage( |
| 62 | + view: WebView, |
| 63 | + message: WebMessageCompat, |
| 64 | + sourceOrigin: Uri, |
| 65 | + isMainFrame: Boolean, |
| 66 | + replyProxy: JavaScriptReplyProxy, |
| 67 | + ) { |
| 68 | + Log.i(TAG, "In Post Message : $message source: $sourceOrigin"); |
| 69 | + val messageData = message.data ?: return |
| 70 | + onRequest(messageData, sourceOrigin, isMainFrame, JavaScriptReplyChannel(replyProxy)) |
| 71 | + } |
| 72 | + |
| 73 | + private fun onRequest( |
| 74 | + msg: String, |
| 75 | + sourceOrigin: Uri, |
| 76 | + isMainFrame: Boolean, |
| 77 | + reply: ReplyChannel, |
| 78 | + ) { |
| 79 | + msg.let { |
| 80 | + val jsonObj = JSONObject(msg); |
| 81 | + val type = jsonObj.getString(TYPE_KEY) |
| 82 | + val message = jsonObj.getString(REQUEST_KEY) |
| 83 | + |
| 84 | + if (havePendingRequest) { |
| 85 | + postErrorMessage(reply, "request already in progress", type) |
| 86 | + return |
| 87 | + } |
| 88 | + replyChannel = reply |
| 89 | + if (!isMainFrame) { |
| 90 | + reportFailure("requests from subframes are not supported", type) |
| 91 | + return |
| 92 | + } |
| 93 | + |
| 94 | + val originScheme = sourceOrigin.scheme |
| 95 | + if (originScheme == null || originScheme.lowercase() != "https") { |
| 96 | + reportFailure("WebAuthn not permitted for current URL", type) |
| 97 | + return |
| 98 | + } |
| 99 | + |
| 100 | + // Verify that origin belongs to your website, |
| 101 | + // it's because the unknown origin may gain credential info. |
| 102 | + if (isUnknownOrigin(originScheme)) { |
| 103 | + return |
| 104 | + } |
| 105 | + |
| 106 | + havePendingRequest = true |
| 107 | + pendingRequestIsDoomed = false |
| 108 | + |
| 109 | + // Let’s use a temporary “replyCurrent” variable to send the data back, while resetting |
| 110 | + // the main “replyChannel” variable to null so it’s ready for the next request. |
| 111 | + val replyCurrent = replyChannel |
| 112 | + if (replyCurrent == null) { |
| 113 | + Log.i(TAG, "reply channel was null, cannot continue") |
| 114 | + return; |
| 115 | + } |
| 116 | + |
| 117 | + when (type) { |
| 118 | + CREATE_UNIQUE_KEY -> |
| 119 | + this.coroutineScope.launch { |
| 120 | + handleCreateFlow(credentialManagerHandler, message, replyCurrent) |
| 121 | + } |
| 122 | + GET_UNIQUE_KEY -> this.coroutineScope.launch { |
| 123 | + handleGetFlow(credentialManagerHandler, message, replyCurrent) |
| 124 | + } |
| 125 | + else -> Log.i(TAG, "Incorrect request json") |
| 126 | + } |
| 127 | + } |
| 128 | + } |
| 129 | + |
| 130 | + // Handles the get flow in a less error-prone way |
| 131 | + private suspend fun handleGetFlow( |
| 132 | + credentialManagerHandler: CredentialManagerHandler, |
| 133 | + message: String, |
| 134 | + reply: ReplyChannel, |
| 135 | + ) { |
| 136 | + try { |
| 137 | + havePendingRequest = false |
| 138 | + pendingRequestIsDoomed = false |
| 139 | + val r = credentialManagerHandler.getPasskey(message) |
| 140 | + val successArray = ArrayList<Any>(); |
| 141 | + successArray.add("success"); |
| 142 | + successArray.add(JSONObject( |
| 143 | + (r.credential as PublicKeyCredential).authenticationResponseJson)) |
| 144 | + successArray.add(GET_UNIQUE_KEY); |
| 145 | + reply.send(JSONArray(successArray).toString()) |
| 146 | + replyChannel = null // setting initial replyChannel for next request given temp 'reply' |
| 147 | + } catch (e: GetCredentialException) { |
| 148 | + reportFailure("Error: ${e.errorMessage} w type: ${e.type} w obj: $e", GET_UNIQUE_KEY) |
| 149 | + } catch (t: Throwable) { |
| 150 | + reportFailure("Error: ${t.message}", GET_UNIQUE_KEY) |
| 151 | + } |
| 152 | + } |
| 153 | + |
| 154 | + // handles the create flow in a less error prone way |
| 155 | + private suspend fun handleCreateFlow( |
| 156 | + credentialManagerHandler: CredentialManagerHandler, |
| 157 | + message: String, |
| 158 | + reply: ReplyChannel, |
| 159 | + ) { |
| 160 | + try { |
| 161 | + havePendingRequest = false |
| 162 | + pendingRequestIsDoomed = false |
| 163 | + val response = credentialManagerHandler.createPasskey(message) |
| 164 | + val successArray = ArrayList<Any>(); |
| 165 | + successArray.add("success"); |
| 166 | + successArray.add(JSONObject(response.registrationResponseJson)); |
| 167 | + successArray.add(CREATE_UNIQUE_KEY); |
| 168 | + reply.send(JSONArray(successArray).toString()) |
| 169 | + replyChannel = null // setting initial replyChannel for next request given temp 'reply' |
| 170 | + } catch (e: CreateCredentialException) { |
| 171 | + reportFailure("Error: ${e.errorMessage} w type: ${e.type} w obj: $e", |
| 172 | + CREATE_UNIQUE_KEY) |
| 173 | + } catch (t: Throwable) { |
| 174 | + reportFailure("Error: ${t.message}", CREATE_UNIQUE_KEY) |
| 175 | + } |
| 176 | + } |
| 177 | + |
| 178 | + /** Invalidates any current request. */ |
| 179 | + fun onPageStarted() { |
| 180 | + if (havePendingRequest) { |
| 181 | + pendingRequestIsDoomed = true |
| 182 | + } |
| 183 | + } |
| 184 | + |
| 185 | + /** Sends an error result to the page. */ |
| 186 | + private fun reportFailure(message: String, type: String) { |
| 187 | + havePendingRequest = false |
| 188 | + pendingRequestIsDoomed = false |
| 189 | + val reply: ReplyChannel = replyChannel!! // verifies non null by throwing NPE |
| 190 | + replyChannel = null |
| 191 | + postErrorMessage(reply, message, type) |
| 192 | + } |
| 193 | + |
| 194 | + private fun postErrorMessage(reply: ReplyChannel, errorMessage: String, type: String) { |
| 195 | + Log.i(TAG, "Sending error message back to the page via replyChannel $errorMessage"); |
| 196 | + val array: MutableList<Any?> = ArrayList() |
| 197 | + array.add("error") |
| 198 | + array.add(errorMessage) |
| 199 | + array.add(type) |
| 200 | + reply.send(JSONArray(array).toString()) |
| 201 | + var toastMsg = errorMessage |
| 202 | + Toast.makeText(this.activity.applicationContext, toastMsg, Toast.LENGTH_SHORT).show() |
| 203 | + } |
| 204 | + |
| 205 | + private class JavaScriptReplyChannel(private val reply: JavaScriptReplyProxy) : |
| 206 | + ReplyChannel { |
| 207 | + override fun send(message: String?) { |
| 208 | + try { |
| 209 | + reply.postMessage(message!!) |
| 210 | + }catch (t: Throwable) { |
| 211 | + Log.i(TAG, "Reply failure due to: " + t.message); |
| 212 | + } |
| 213 | + } |
| 214 | + } |
| 215 | + |
| 216 | + /** ReplyChannel is the interface over which replies to the embedded site are sent. This allows |
| 217 | + for testing because AndroidX bans mocking its objects.*/ |
| 218 | + interface ReplyChannel { |
| 219 | + fun send(message: String?) |
| 220 | + } |
| 221 | + |
| 222 | + companion object { |
| 223 | + /** INTERFACE_NAME is the name of the MessagePort that must be injected into pages. */ |
| 224 | + const val INTERFACE_NAME = "__webauthn_interface__" |
| 225 | + |
| 226 | + const val CREATE_UNIQUE_KEY = "create" |
| 227 | + const val GET_UNIQUE_KEY = "get" |
| 228 | + const val TYPE_KEY = "type" |
| 229 | + const val REQUEST_KEY = "request" |
| 230 | + |
| 231 | + /** INJECTED_VAL is the minified version of the JavaScript code described at this class |
| 232 | + * heading. The non minified form is found at credmanweb/javascript/encode.js.*/ |
| 233 | + const val INJECTED_VAL = """ |
| 234 | + var __webauthn_interface__,__webauthn_hooks__;!function(e){console.log("In the hook."),__webauthn_interface__.addEventListener("message",function e(n){var r=JSON.parse(n.data),t=r[2];"get"===t?o(r):"create"===t?u(r):console.log("Incorrect response format for reply")});var n=null,r=null,t=null,a=null;function o(e){if(null!==n&&null!==t){if("success"!=e[0]){var r=t;n=null,t=null,r(new DOMException(e[1],"NotAllowedError"));return}var a=i(e[1]),o=n;n=null,t=null,o(a)}}function l(e){var n=e.length%4;return Uint8Array.from(atob(e.replace(/-/g,"+").replace(/_/g,"/").padEnd(e.length+(0===n?0:4-n),"=")),function(e){return e.charCodeAt(0)}).buffer}function s(e){return btoa(Array.from(new Uint8Array(e),function(e){return String.fromCharCode(e)}).join("")).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+${'$'}/,"")}function u(e){if(null===r||null===a){console.log("Here: "+r+" and reject: "+a);return}if(console.log("Output back: "+e),"success"!=e[0]){var n=a;r=null,a=null,n(new DOMException(e[1],"NotAllowedError"));return}var t=i(e[1]),o=r;r=null,a=null,o(t)}function i(e){return console.log("Here is the response from credential manager: "+e),e.rawId=l(e.rawId),e.response.clientDataJSON=l(e.response.clientDataJSON),e.response.hasOwnProperty("attestationObject")&&(e.response.attestationObject=l(e.response.attestationObject)),e.response.hasOwnProperty("authenticatorData")&&(e.response.authenticatorData=l(e.response.authenticatorData)),e.response.hasOwnProperty("signature")&&(e.response.signature=l(e.response.signature)),e.response.hasOwnProperty("userHandle")&&(e.response.userHandle=l(e.response.userHandle)),e.getClientExtensionResults=function e(){return{}},e}e.create=function n(t){if(!("publicKey"in t))return e.originalCreateFunction(t);var o=new Promise(function(e,n){r=e,a=n}),l=t.publicKey;if(l.hasOwnProperty("challenge")){var u=s(l.challenge);l.challenge=u}if(l.hasOwnProperty("user")&&l.user.hasOwnProperty("id")){var i=s(l.user.id);l.user.id=i}var c=JSON.stringify({type:"create",request:l});return __webauthn_interface__.postMessage(c),o},e.get=function r(a){if(!("publicKey"in a))return e.originalGetFunction(a);var o=new Promise(function(e,r){n=e,t=r}),l=a.publicKey;if(l.hasOwnProperty("challenge")){var u=s(l.challenge);l.challenge=u}var i=JSON.stringify({type:"get",request:l});return __webauthn_interface__.postMessage(i),o},e.onReplyGet=o,e.CM_base64url_decode=l,e.CM_base64url_encode=s,e.onReplyCreate=u}(__webauthn_hooks__||(__webauthn_hooks__={})),__webauthn_hooks__.originalGetFunction=navigator.credentials.get,__webauthn_hooks__.originalCreateFunction=navigator.credentials.create,navigator.credentials.get=__webauthn_hooks__.get,navigator.credentials.create=__webauthn_hooks__.create,window.PublicKeyCredential=function(){},window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable=function(){return Promise.resolve(!1)}; |
| 235 | + """ |
| 236 | + const val TAG = "PasskeyWebListener" |
| 237 | + } |
| 238 | + |
| 239 | +} |
0 commit comments