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