WebAuthnCredentialManager.java (17938B)
1 /* -*- Mode: Java; c-basic-offset: 2; tab-width: 2; indent-tabs-mode: nil -*- */ 2 /* vim: set ts=2 et sw=2 tw=100: */ 3 /* This Source Code Form is subject to the terms of the Mozilla Public 4 * License, v. 2.0. If a copy of the MPL was not distributed with this 5 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 6 7 package org.mozilla.gecko; 8 9 import android.annotation.SuppressLint; 10 import android.content.Context; 11 import android.content.pm.PackageManager; 12 import android.credentials.CreateCredentialException; 13 import android.credentials.CreateCredentialRequest; 14 import android.credentials.CreateCredentialResponse; 15 import android.credentials.CredentialManager; 16 import android.credentials.CredentialOption; 17 import android.credentials.GetCredentialException; 18 import android.credentials.GetCredentialRequest; 19 import android.credentials.GetCredentialResponse; 20 import android.credentials.PrepareGetCredentialResponse; 21 import android.os.Build; 22 import android.os.Bundle; 23 import android.os.OutcomeReceiver; 24 import android.util.Base64; 25 import android.util.Log; 26 import org.json.JSONException; 27 import org.json.JSONObject; 28 import org.mozilla.gecko.util.GeckoBundle; 29 import org.mozilla.gecko.util.WebAuthnUtils; 30 import org.mozilla.geckoview.GeckoResult; 31 32 // Credential Manager implementation that is introduced from Android 14. 33 public class WebAuthnCredentialManager { 34 private static final String LOGTAG = "WebAuthnCredMan"; 35 private static final boolean DEBUG = false; 36 37 // This defines are from androidx.credentials. 38 // https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:credentials/credentials/src/main/java/androidx/credentials/ 39 private static final String CM_PREFIX = "androidx.credentials."; 40 private static final String TYPE_PUBLIC_KEY_CREDENTIAL = CM_PREFIX + "TYPE_PUBLIC_KEY_CREDENTIAL"; 41 private static final String BUNDLE_KEY_CLIENT_DATA_HASH = 42 CM_PREFIX + "BUNDLE_KEY_CLIENT_DATA_HASH"; 43 private static final String BUNDLE_KEY_REGISTRATION_RESPONSE_JSON = 44 CM_PREFIX + "BUNDLE_KEY_REGISTRATION_RESPONSE_JSON"; 45 private static final String BUNDLE_KEY_AUTHENTICATION_RESPONSE_JSON = 46 CM_PREFIX + "BUNDLE_KEY_AUTHENTICATION_RESPONSE_JSON"; 47 private static final String BUNDLE_KEY_REQUEST_JSON = CM_PREFIX + "BUNDLE_KEY_REQUEST_JSON"; 48 private static final String BUNDLE_KEY_REQUEST_DISPLAY_INFO = 49 CM_PREFIX + "BUNDLE_KEY_REQUEST_DISPLAY_INFO"; 50 private static final String BUNDLE_KEY_SUBTYPE = CM_PREFIX + "BUNDLE_KEY_SUBTYPE"; 51 private static final String BUNDLE_KEY_USER_DISPLAY_NAME = 52 CM_PREFIX + "BUNDLE_KEY_USER_DISPLAY_NAME"; 53 private static final String BUNDLE_KEY_USER_ID = CM_PREFIX + "BUNDLE_KEY_USER_ID"; 54 private static final String BUNDLE_VALUE_SUBTYPE_CREATE_PUBLIC_KEY_CREDENTIAL_REQUEST = 55 CM_PREFIX + "BUNDLE_VALUE_SUBTYPE_CREATE_PUBLIC_KEY_CREDENTIAL_REQUEST"; 56 private static final String BUNDLE_VALUE_SUBTYPE_GET_PUBLIC_KEY_CREDENTIAL_OPTION = 57 CM_PREFIX + "BUNDLE_VALUE_SUBTYPE_GET_PUBLIC_KEY_CREDENTIAL_OPTION"; 58 private static final String BUNDLE_KEY_IS_AUTO_SELECT_ALLOWED = 59 CM_PREFIX + "BUNDLE_KEY_IS_AUTO_SELECT_ALLOWED"; 60 private static final String BUNDLE_KEY_PREFER_IMMEDIATELY_AVAILABLE_CREDENTIALS = 61 CM_PREFIX + "BUNDLE_KEY_PREFER_IMMEDIATELY_AVAILABLE_CREDENTIALS"; 62 63 private static Bundle getRequestBundle(final String requestJSON, final byte[] clientDataHash) { 64 final Bundle requestBundle = new Bundle(); 65 requestBundle.putString(BUNDLE_KEY_REQUEST_JSON, requestJSON); 66 requestBundle.putByteArray(BUNDLE_KEY_CLIENT_DATA_HASH, clientDataHash); 67 68 return requestBundle; 69 } 70 71 private static Bundle getRequestBundleForMakeCredential( 72 final GeckoBundle credentialBundle, 73 final byte[] userId, 74 final byte[] challenge, 75 final int[] algs, 76 final WebAuthnUtils.WebAuthnPublicCredential[] excludeList, 77 final GeckoBundle authenticatorSelection, 78 final GeckoBundle extensions, 79 final byte[] clientDataHash) { 80 try { 81 final JSONObject requestJSON = 82 WebAuthnUtils.getJSONObjectForMakeCredential( 83 credentialBundle, 84 userId, 85 challenge, 86 algs, 87 excludeList, 88 authenticatorSelection, 89 extensions); 90 final Bundle bundle = getRequestBundle(requestJSON.toString(), clientDataHash); 91 if (bundle == null) { 92 return null; 93 } 94 95 final Bundle displayInfoBundle = new Bundle(); 96 displayInfoBundle.putCharSequence( 97 BUNDLE_KEY_USER_ID, 98 Base64.encodeToString(userId, Base64.URL_SAFE | Base64.NO_PADDING | Base64.NO_WRAP)); 99 final GeckoBundle userBundle = credentialBundle.getBundle("user"); 100 displayInfoBundle.putString( 101 BUNDLE_KEY_USER_DISPLAY_NAME, userBundle.getString("displayName", "")); 102 bundle.putBundle(BUNDLE_KEY_REQUEST_DISPLAY_INFO, displayInfoBundle); 103 104 bundle.putString( 105 BUNDLE_KEY_SUBTYPE, BUNDLE_VALUE_SUBTYPE_CREATE_PUBLIC_KEY_CREDENTIAL_REQUEST); 106 return bundle; 107 } catch (final JSONException e) { 108 Log.e(LOGTAG, "Couldn't generate JSON object for request", e); 109 } 110 return null; 111 } 112 113 private static Bundle getRequestBundleForGetAssertion( 114 final byte[] challenge, 115 final WebAuthnUtils.WebAuthnPublicCredential[] allowList, 116 final GeckoBundle assertionBundle, 117 final GeckoBundle extensionsBundle, 118 final byte[] clientDataHash) { 119 try { 120 final JSONObject requestJSON = 121 WebAuthnUtils.getJSONObjectForGetAssertion( 122 challenge, allowList, assertionBundle, extensionsBundle); 123 final Bundle bundle = getRequestBundle(requestJSON.toString(), clientDataHash); 124 if (bundle == null) { 125 return null; 126 } 127 bundle.putString(BUNDLE_KEY_SUBTYPE, BUNDLE_VALUE_SUBTYPE_GET_PUBLIC_KEY_CREDENTIAL_OPTION); 128 return bundle; 129 } catch (final JSONException e) { 130 Log.e(LOGTAG, "Couldn't generate JSON object for request", e); 131 } 132 return null; 133 } 134 135 @SuppressLint("MissingPermission") 136 public static GeckoResult<WebAuthnUtils.MakeCredentialResponse> makeCredential( 137 final GeckoBundle credentialBundle, 138 final byte[] userId, 139 final byte[] challenge, 140 final int[] algs, 141 final WebAuthnUtils.WebAuthnPublicCredential[] excludeList, 142 final GeckoBundle authenticatorSelection, 143 final GeckoBundle extensions, 144 final byte[] clientDataHash) { 145 146 // We use Credential Manager first. If it doesn't work, we use GMS FIDO2. 147 // Credential manager may support non-discoverable keys, 148 // Else, following the specifications, `residentKey=discouraged` allows discoverable keys too 149 // but prefer non-discoverable keys 150 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { 151 return GeckoResult.fromException(new WebAuthnUtils.Exception("NOT_SUPPORTED_ERR")); 152 } 153 final Context context = GeckoAppShell.getApplicationContext(); 154 // Some vendors disabled Credential Manager on Android 14+ devices. 155 // https://issuetracker.google.com/issues/349310440 156 if (!context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_CREDENTIALS)) { 157 Log.w(LOGTAG, "Credential Manager is disabled on this device"); 158 return GeckoResult.fromException(new WebAuthnUtils.Exception("NOT_SUPPORTED_ERR")); 159 } 160 161 final Bundle requestBundle = 162 getRequestBundleForMakeCredential( 163 credentialBundle, 164 userId, 165 challenge, 166 algs, 167 excludeList, 168 authenticatorSelection, 169 extensions, 170 clientDataHash); 171 if (requestBundle == null) { 172 return GeckoResult.fromException(new WebAuthnUtils.Exception("UNKNOWN_ERR")); 173 } 174 175 requestBundle.putBoolean(BUNDLE_KEY_PREFER_IMMEDIATELY_AVAILABLE_CREDENTIALS, false); 176 requestBundle.putBoolean(BUNDLE_KEY_IS_AUTO_SELECT_ALLOWED, false); 177 178 final CreateCredentialRequest request = 179 new CreateCredentialRequest.Builder( 180 TYPE_PUBLIC_KEY_CREDENTIAL, requestBundle, requestBundle) 181 .setAlwaysSendAppInfoToProvider(true) 182 .setOrigin(credentialBundle.getString("origin")) 183 .build(); 184 185 final CredentialManager manager = 186 (CredentialManager) context.getSystemService(Context.CREDENTIAL_SERVICE); 187 if (manager == null) { 188 return GeckoResult.fromException(new WebAuthnUtils.Exception("UNKNOWN_ERR")); 189 } 190 final GeckoResult<WebAuthnUtils.MakeCredentialResponse> result = new GeckoResult<>(); 191 try { 192 manager.createCredential( 193 context, 194 request, 195 null, 196 context.getMainExecutor(), 197 new OutcomeReceiver<CreateCredentialResponse, CreateCredentialException>() { 198 @Override 199 public void onResult(final CreateCredentialResponse createCredentialResponse) { 200 final Bundle data = createCredentialResponse.getData(); 201 final String responseJson = data.getString(BUNDLE_KEY_REGISTRATION_RESPONSE_JSON); 202 if (responseJson == null) { 203 result.completeExceptionally(new WebAuthnUtils.Exception("DATA_ERR")); 204 return; 205 } 206 if (DEBUG) { 207 Log.d(LOGTAG, "Response JSON: " + responseJson); 208 } 209 try { 210 result.complete(WebAuthnUtils.getMakeCredentialResponse(responseJson)); 211 } catch (final IllegalArgumentException e) { 212 Log.e(LOGTAG, "Invalid response", e); 213 result.completeExceptionally(new WebAuthnUtils.Exception("DATA_ERR")); 214 } catch (final JSONException e) { 215 Log.e(LOGTAG, "Couldn't parse response JSON", e); 216 result.completeExceptionally(new WebAuthnUtils.Exception("DATA_ERR")); 217 } 218 } 219 220 @Override 221 public void onError(final CreateCredentialException exception) { 222 final String errorType = exception.getType(); 223 if (DEBUG) { 224 Log.d(LOGTAG, "Couldn't create credential. errorType=" + errorType); 225 } 226 if (errorType.equals(CreateCredentialException.TYPE_USER_CANCELED)) { 227 result.completeExceptionally(new WebAuthnUtils.Exception("ABORT_ERR")); 228 return; 229 } 230 if (errorType.equals(CreateCredentialException.TYPE_NO_CREATE_OPTIONS)) { 231 result.completeExceptionally(new WebAuthnUtils.Exception("NOT_SUPPORTED_ERR")); 232 return; 233 } 234 result.completeExceptionally(new WebAuthnUtils.Exception("UNKNOWN_ERR")); 235 } 236 }); 237 } catch (final SecurityException e) { 238 // We might be no permission for Credential Manager 239 return GeckoResult.fromException(new WebAuthnUtils.Exception("NOT_SUPPORTED_ERR")); 240 } catch (final java.lang.Exception e) { 241 Log.w(LOGTAG, "Couldn't make credential", e); 242 return GeckoResult.fromException(new WebAuthnUtils.Exception("UNKNOWN_ERR")); 243 } 244 return result; 245 } 246 247 @SuppressLint("MissingPermission") 248 public static GeckoResult<PrepareGetCredentialResponse.PendingGetCredentialHandle> 249 prepareGetAssertion( 250 final byte[] challenge, 251 final WebAuthnUtils.WebAuthnPublicCredential[] allowList, 252 final GeckoBundle assertionBundle, 253 final GeckoBundle extensions, 254 final byte[] clientDataHash) { 255 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { 256 // No credential manager. Relay to GMS FIDO2 257 return GeckoResult.fromValue(null); 258 } 259 final Bundle requestBundle = 260 getRequestBundleForGetAssertion( 261 challenge, allowList, assertionBundle, extensions, clientDataHash); 262 if (requestBundle == null) { 263 return GeckoResult.fromValue(null); 264 } 265 266 requestBundle.putBoolean(BUNDLE_KEY_IS_AUTO_SELECT_ALLOWED, allowList.length > 0); 267 268 final CredentialOption credentialOption = 269 new CredentialOption.Builder(TYPE_PUBLIC_KEY_CREDENTIAL, requestBundle, requestBundle) 270 .build(); 271 final Bundle bundle = new Bundle(); 272 final GetCredentialRequest request = 273 new GetCredentialRequest.Builder(requestBundle) 274 .addCredentialOption(credentialOption) 275 .setAlwaysSendAppInfoToProvider(true) 276 .setOrigin(assertionBundle.getString("origin")) 277 .build(); 278 279 final Context context = GeckoAppShell.getApplicationContext(); 280 final CredentialManager manager = 281 (CredentialManager) context.getSystemService(Context.CREDENTIAL_SERVICE); 282 if (manager == null) { 283 // No credential manager. Relay to GMS FIDO2 284 return GeckoResult.fromValue(null); 285 } 286 287 final GeckoResult<PrepareGetCredentialResponse.PendingGetCredentialHandle> result = 288 new GeckoResult<>(); 289 try { 290 manager.prepareGetCredential( 291 request, 292 null, 293 context.getMainExecutor(), 294 new OutcomeReceiver<PrepareGetCredentialResponse, GetCredentialException>() { 295 @Override 296 public void onResult(final PrepareGetCredentialResponse prepareGetCredentialResponse) { 297 final boolean hasPublicKeyCredentials = 298 prepareGetCredentialResponse.hasCredentialResults(TYPE_PUBLIC_KEY_CREDENTIAL); 299 final boolean hasAuthenticationResults = 300 prepareGetCredentialResponse.hasAuthenticationResults(); 301 302 if (DEBUG) { 303 Log.d( 304 LOGTAG, 305 "prepareGetCredential: hasPublicKeyCredentials=" 306 + hasPublicKeyCredentials 307 + ", hasAuthenticationResults=" 308 + hasAuthenticationResults); 309 } 310 if (!hasPublicKeyCredentials && !hasAuthenticationResults) { 311 // No passkey and result. Relay to GMS FIDO2. 312 result.complete(null); 313 return; 314 } 315 316 result.complete(prepareGetCredentialResponse.getPendingGetCredentialHandle()); 317 } 318 319 @Override 320 public void onError(final GetCredentialException exception) { 321 if (DEBUG) { 322 final String errorType = exception.getType(); 323 Log.d(LOGTAG, "Couldn't get credential. errorType=" + errorType); 324 } 325 result.completeExceptionally(new WebAuthnUtils.Exception("UNKNOWN_ERR")); 326 } 327 }); 328 } catch (final SecurityException e) { 329 // We might be no permission for Credential Manager. Use FIDO2 API 330 return GeckoResult.fromValue(null); 331 } catch (final java.lang.Exception e) { 332 Log.e(LOGTAG, "prepareGetCredential throws an error", e); 333 return GeckoResult.fromValue(null); 334 } 335 return result; 336 } 337 338 public static GeckoResult<WebAuthnUtils.GetAssertionResponse> getAssertion( 339 final PrepareGetCredentialResponse.PendingGetCredentialHandle pendingHandle) { 340 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { 341 return GeckoResult.fromException(new WebAuthnUtils.Exception("NOT_SUPPORTED_ERR")); 342 } 343 final GeckoResult<WebAuthnUtils.GetAssertionResponse> result = new GeckoResult<>(); 344 final Context context = GeckoAppShell.getApplicationContext(); 345 final CredentialManager manager = 346 (CredentialManager) context.getSystemService(Context.CREDENTIAL_SERVICE); 347 if (manager == null) { 348 return GeckoResult.fromException(new WebAuthnUtils.Exception("UNKNOWN_ERR")); 349 } 350 try { 351 manager.getCredential( 352 context, 353 pendingHandle, 354 null, 355 context.getMainExecutor(), 356 new OutcomeReceiver<GetCredentialResponse, GetCredentialException>() { 357 @Override 358 public void onResult(final GetCredentialResponse getCredentialResponse) { 359 final Bundle data = getCredentialResponse.getCredential().getData(); 360 final String responseJson = data.getString(BUNDLE_KEY_AUTHENTICATION_RESPONSE_JSON); 361 if (responseJson == null) { 362 result.completeExceptionally(new WebAuthnUtils.Exception("DATA_ERR")); 363 return; 364 } 365 if (DEBUG) { 366 Log.d(LOGTAG, "Response JSON: " + responseJson); 367 } 368 try { 369 result.complete(WebAuthnUtils.getGetAssertionResponse(responseJson)); 370 } catch (final IllegalArgumentException e) { 371 Log.e(LOGTAG, "Invalid response", e); 372 result.completeExceptionally(new WebAuthnUtils.Exception("DATA_ERR")); 373 } catch (final JSONException e) { 374 Log.e(LOGTAG, "Couldn't parse response JSON", e); 375 result.completeExceptionally(new WebAuthnUtils.Exception("DATA_ERR")); 376 } 377 } 378 379 @Override 380 public void onError(final GetCredentialException exception) { 381 final String errorType = exception.getType(); 382 if (DEBUG) { 383 Log.d(LOGTAG, "Couldn't get credential. errorType=" + errorType); 384 } 385 if (errorType.equals(GetCredentialException.TYPE_USER_CANCELED)) { 386 result.completeExceptionally(new WebAuthnUtils.Exception("ABORT_ERR")); 387 return; 388 } 389 result.completeExceptionally(new WebAuthnUtils.Exception("UNKNOWN_ERR")); 390 } 391 }); 392 } catch (final SecurityException e) { 393 // We might be no permission for Credential Manager. 394 return GeckoResult.fromException(new WebAuthnUtils.Exception("NOT_SUPPORTED_ERR")); 395 } catch (final java.lang.Exception e) { 396 Log.w(LOGTAG, "Couldn't get assertion", e); 397 return GeckoResult.fromException(new WebAuthnUtils.Exception("UNKNOWN_ERR")); 398 } 399 return result; 400 } 401 }