[DC] Read protocol from wallet response on Android
Wallets have changed their response format to match the expected format of Web DC API [1]. And while doing that, the new format is packaged differently in the response Intent. Ideally, Jetpack libraries will allow reading the formats easily, but since adding the Jetpack dependency increased the binary size beyond the the acceptable threshold, we had to fork the relevant pieces for Jetpack to parse the old and modern response formats. This is CL favors the modern response packaging if it is available, and will read the protocol from it whenever it's returned. Otherwise, it will fallback to the legacy format. As additional context: GMSCore is post-processing the format such that if only legacy format is returned from the wallet, it packages it in addition to the modern format. This is to support Chrome version that understand only legacy format, and break the gap with wallet that send only the legacy format. After the web API as well as the Android API are launched, all backward compatibility should be removed since it will be the expectations of apps and wallets to adhere to the latest specs. [1] https://wicg.github.io/digital-credentials/#the-digitalcredential-interface Bug: 336329411 Change-Id: I301434456bf9ae83ad51538f033d92cbc21fbf44 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/6287374 Commit-Queue: Mohamed Amir Yosef <mamir@chromium.org> Reviewed-by: Christian Biesinger <cbiesinger@chromium.org> Reviewed-by: Peter Conn <peconn@chromium.org> Cr-Commit-Position: refs/heads/main@{#1436223}
This commit is contained in:

committed by
Chromium LUCI CQ

parent
18a8b1c914
commit
587e8887c0
chrome/android/junit
content/public/android/java/src/org/chromium/content/browser/webid
@ -40,6 +40,7 @@ if (is_android) {
|
||||
"$google_play_services_package:google_play_services_cast_framework_java",
|
||||
"$google_play_services_package:google_play_services_cast_java",
|
||||
"$google_play_services_package:google_play_services_gcm_java",
|
||||
"$google_play_services_package:google_play_services_identity_credentials_java",
|
||||
"$google_play_services_package:google_play_services_tasks_java",
|
||||
"//base:base_java",
|
||||
"//base:base_java_test_support",
|
||||
@ -1295,6 +1296,7 @@ if (is_android) {
|
||||
"src/org/chromium/chrome/browser/usage_stats/EventTrackerTest.java",
|
||||
"src/org/chromium/chrome/browser/usage_stats/PageViewObserverTest.java",
|
||||
"src/org/chromium/chrome/browser/webauthn/AuthenticatorIncognitoConfirmationBottomsheetTest.java",
|
||||
"src/org/chromium/content/browser/webid/IdentityCredentialsDelegateTest.java",
|
||||
]
|
||||
|
||||
deps = chrome_junit_tests_deps
|
||||
|
200
chrome/android/junit/src/org/chromium/content/browser/webid/IdentityCredentialsDelegateTest.java
Normal file
200
chrome/android/junit/src/org/chromium/content/browser/webid/IdentityCredentialsDelegateTest.java
Normal file
@ -0,0 +1,200 @@
|
||||
// Copyright 2025 The Chromium Authors
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
package org.chromium.content.browser.webid;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import static org.chromium.content.browser.webid.IdentityCredentialsDelegate.BUNDLE_KEY_IDENTITY_TOKEN;
|
||||
import static org.chromium.content.browser.webid.IdentityCredentialsDelegate.BUNDLE_KEY_PROVIDER_DATA;
|
||||
import static org.chromium.content.browser.webid.IdentityCredentialsDelegate.BUNDLE_KEY_REQUEST_JSON;
|
||||
import static org.chromium.content.browser.webid.IdentityCredentialsDelegate.EXTRA_CREDENTIAL_DATA;
|
||||
import static org.chromium.content.browser.webid.IdentityCredentialsDelegate.EXTRA_GET_CREDENTIAL_RESPONSE;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.credentials.Credential;
|
||||
import android.credentials.GetCredentialResponse;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
|
||||
import com.google.android.gms.identitycredentials.GetCredentialException;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.annotation.Config;
|
||||
|
||||
import org.chromium.base.test.BaseRobolectricTestRunner;
|
||||
import org.chromium.content.browser.webid.IdentityCredentialsDelegate.DigitalCredential;
|
||||
|
||||
/** Unit tests for {@link IdentityCredentialsDelegate}. */
|
||||
@RunWith(BaseRobolectricTestRunner.class)
|
||||
@Config(
|
||||
manifest = Config.NONE,
|
||||
sdk = {Build.VERSION_CODES.TIRAMISU, Build.VERSION_CODES.UPSIDE_DOWN_CAKE})
|
||||
public class IdentityCredentialsDelegateTest {
|
||||
private static final String INTENT_HELPER_EXTRA_CREDENTIAL_TYPE =
|
||||
"androidx.identitycredentials.EXTRA_CREDENTIAL_TYPE";
|
||||
private static final String INTENT_HELPER_EXTRA_CREDENTIAL_DATA =
|
||||
"androidx.identitycredentials.EXTRA_CREDENTIAL_DATA";
|
||||
private static final String JSON_PROTOCOL = "openid4vp";
|
||||
private static final String JSON_DATA = "{\"test_key\":\"test_value\"}";
|
||||
private static final String JSON_WITH_PROTOCOL =
|
||||
"{\"protocol\" : \"openid4vp\", \"data\": {\"test_key\":\"test_value\"}}";
|
||||
private static final String JSON_WITHOUT_PROTOCOL = "{\"data\": {\"test_key\":\"test_value\"}}";
|
||||
private static final byte[] LEGACY_RESPONSE = "{\"legacy_key\":\"legacy_value\"}".getBytes();
|
||||
|
||||
private void packageResponseJsonInLegacyFormat(byte[] response, Intent intent) {
|
||||
Bundle dataBundle = new Bundle();
|
||||
dataBundle.putByteArray(BUNDLE_KEY_IDENTITY_TOKEN, response);
|
||||
|
||||
intent.putExtra(INTENT_HELPER_EXTRA_CREDENTIAL_TYPE, "com.credman.IdentityCredential");
|
||||
intent.putExtra(INTENT_HELPER_EXTRA_CREDENTIAL_DATA, dataBundle);
|
||||
}
|
||||
|
||||
private void packageResponseJsonInNewFormat(String json, Intent intent) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
packageResponseJsonInNewFormatAfter34(json, intent);
|
||||
} else {
|
||||
packageResponseJsonInNewFormatBefore34(json, intent);
|
||||
}
|
||||
}
|
||||
|
||||
private void packageResponseJsonInNewFormatBefore34(String json, Intent intent) {
|
||||
Bundle credentialBundle = new Bundle();
|
||||
credentialBundle.putString(BUNDLE_KEY_REQUEST_JSON, json);
|
||||
|
||||
Bundle dataBundle = new Bundle();
|
||||
dataBundle.putBundle(EXTRA_CREDENTIAL_DATA, credentialBundle);
|
||||
|
||||
intent.putExtra(EXTRA_GET_CREDENTIAL_RESPONSE, dataBundle);
|
||||
}
|
||||
|
||||
private void packageResponseJsonInNewFormatAfter34(String json, Intent intent) {
|
||||
Bundle credentialBundle = new Bundle();
|
||||
credentialBundle.putString(BUNDLE_KEY_REQUEST_JSON, json);
|
||||
|
||||
// Credential and GetCredentialResponse classes don't exist in Robolectric test and hence we
|
||||
// mock them.
|
||||
Credential credential = mock(Credential.class);
|
||||
when(credential.getType()).thenReturn("androidx.credentials.TYPE_DIGITAL_CREDENTIAL");
|
||||
when(credential.getData()).thenReturn(credentialBundle);
|
||||
|
||||
GetCredentialResponse getCredentialResponse = mock(GetCredentialResponse.class);
|
||||
when(getCredentialResponse.getCredential()).thenReturn(credential);
|
||||
|
||||
intent.putExtra(EXTRA_GET_CREDENTIAL_RESPONSE, getCredentialResponse);
|
||||
}
|
||||
|
||||
private Bundle packageIntentInResponseBundle(Intent intent) {
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putParcelable(BUNDLE_KEY_PROVIDER_DATA, intent);
|
||||
return bundle;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testExtractDigitalCredentialFromGetResponse_LegacyFormat()
|
||||
throws JSONException, NullPointerException, GetCredentialException {
|
||||
Intent intent = new Intent();
|
||||
packageResponseJsonInLegacyFormat(LEGACY_RESPONSE, intent);
|
||||
Bundle bundle = packageIntentInResponseBundle(intent);
|
||||
|
||||
DigitalCredential credential =
|
||||
IdentityCredentialsDelegate.extractDigitalCredentialFromResponseBundle(
|
||||
Activity.RESULT_OK, bundle);
|
||||
|
||||
assertNotNull(credential);
|
||||
assertNull(credential.mProtocol);
|
||||
assertEquals(new String(LEGACY_RESPONSE), credential.mData);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testExtractDigitalCredentialFromGetResponse_NewFormat_ProcotolInResponse()
|
||||
throws JSONException, NullPointerException, GetCredentialException {
|
||||
Intent intent = new Intent();
|
||||
packageResponseJsonInNewFormat(JSON_WITH_PROTOCOL, intent);
|
||||
Bundle bundle = packageIntentInResponseBundle(intent);
|
||||
|
||||
DigitalCredential extractedCredential =
|
||||
IdentityCredentialsDelegate.extractDigitalCredentialFromResponseBundle(
|
||||
Activity.RESULT_OK, bundle);
|
||||
|
||||
assertNotNull(extractedCredential);
|
||||
assertEquals(JSON_PROTOCOL, extractedCredential.mProtocol);
|
||||
assertEquals(JSON_DATA, extractedCredential.mData);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testExtractDigitalCredentialFromGetResponse_BothFormats_ProcotolInResponse()
|
||||
throws JSONException, NullPointerException, GetCredentialException {
|
||||
Intent intent = new Intent();
|
||||
packageResponseJsonInNewFormat(JSON_WITH_PROTOCOL, intent);
|
||||
packageResponseJsonInLegacyFormat(LEGACY_RESPONSE, intent);
|
||||
Bundle bundle = packageIntentInResponseBundle(intent);
|
||||
|
||||
DigitalCredential extractedCredential =
|
||||
IdentityCredentialsDelegate.extractDigitalCredentialFromResponseBundle(
|
||||
Activity.RESULT_OK, bundle);
|
||||
|
||||
// Since the modern format contains a protocol, it is preferred.
|
||||
assertNotNull(extractedCredential);
|
||||
assertEquals(JSON_PROTOCOL, extractedCredential.mProtocol);
|
||||
assertEquals(JSON_DATA, extractedCredential.mData);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testExtractDigitalCredentialFromGetResponse_BothFormats_NoProcotolInResponse()
|
||||
throws JSONException, NullPointerException, GetCredentialException {
|
||||
Intent intent = new Intent();
|
||||
packageResponseJsonInNewFormat(JSON_WITHOUT_PROTOCOL, intent);
|
||||
packageResponseJsonInLegacyFormat(LEGACY_RESPONSE, intent);
|
||||
Bundle bundle = packageIntentInResponseBundle(intent);
|
||||
|
||||
DigitalCredential extractedCredential =
|
||||
IdentityCredentialsDelegate.extractDigitalCredentialFromResponseBundle(
|
||||
Activity.RESULT_OK, bundle);
|
||||
|
||||
// Since the modern format doesn't contain a protocol, the full response is considered as
|
||||
// the data, and no protocol is returned.
|
||||
assertNotNull(extractedCredential);
|
||||
assertNull(extractedCredential.mProtocol);
|
||||
assertEquals(new String(JSON_WITHOUT_PROTOCOL), extractedCredential.mData);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testExtractDigitalCredentialFromGetResponse_NullData_Legacy()
|
||||
throws JSONException, NullPointerException, GetCredentialException {
|
||||
Intent intent = new Intent();
|
||||
packageResponseJsonInLegacyFormat(null, intent);
|
||||
Bundle bundle = packageIntentInResponseBundle(intent);
|
||||
|
||||
assertThrows(
|
||||
NullPointerException.class,
|
||||
() -> {
|
||||
IdentityCredentialsDelegate.extractDigitalCredentialFromResponseBundle(
|
||||
Activity.RESULT_OK, bundle);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testExtractDigitalCredentialFromGetResponse_JSONException()
|
||||
throws JSONException, NullPointerException, GetCredentialException {
|
||||
Intent intent = new Intent();
|
||||
packageResponseJsonInNewFormat("{invalidjson", intent);
|
||||
Bundle bundle = packageIntentInResponseBundle(intent);
|
||||
|
||||
assertThrows(
|
||||
JSONException.class,
|
||||
() -> {
|
||||
IdentityCredentialsDelegate.extractDigitalCredentialFromResponseBundle(
|
||||
Activity.RESULT_OK, bundle);
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
file://content/browser/webid/OWNERS
|
135
content/public/android/java/src/org/chromium/content/browser/webid/IdentityCredentialsDelegate.java
135
content/public/android/java/src/org/chromium/content/browser/webid/IdentityCredentialsDelegate.java
@ -7,13 +7,18 @@ package org.chromium.content.browser.webid;
|
||||
import static androidx.core.app.ActivityCompat.startIntentSenderForResult;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentSender.SendIntentException;
|
||||
import android.credentials.GetCredentialResponse;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.os.ResultReceiver;
|
||||
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
|
||||
import com.google.android.gms.identitycredentials.CredentialOption;
|
||||
import com.google.android.gms.identitycredentials.GetCredentialException;
|
||||
import com.google.android.gms.identitycredentials.GetCredentialRequest;
|
||||
@ -21,6 +26,10 @@ import com.google.android.gms.identitycredentials.IdentityCredentialClient;
|
||||
import com.google.android.gms.identitycredentials.IdentityCredentialManager;
|
||||
import com.google.android.gms.identitycredentials.IntentHelper;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import org.chromium.base.IntentUtils;
|
||||
import org.chromium.base.Log;
|
||||
import org.chromium.base.Promise;
|
||||
import org.chromium.base.ServiceLoaderUtil;
|
||||
@ -37,6 +46,26 @@ public class IdentityCredentialsDelegate {
|
||||
// Arbitrary request code that is used when invoking the GMSCore API.
|
||||
private static final int REQUEST_CODE_DIGITAL_CREDENTIALS = 777;
|
||||
|
||||
@VisibleForTesting
|
||||
public static final String BUNDLE_KEY_REQUEST_JSON =
|
||||
"androidx.credentials.BUNDLE_KEY_REQUEST_JSON";
|
||||
|
||||
@VisibleForTesting public static final String DC_API_RESPONSE_PROTOCOL_KEY = "protocol";
|
||||
@VisibleForTesting public static final String DC_API_RESPONSE_DATA_KEY = "data";
|
||||
@VisibleForTesting public static final String BUNDLE_KEY_IDENTITY_TOKEN = "identityToken";
|
||||
|
||||
@VisibleForTesting
|
||||
public static final String BUNDLE_KEY_PROVIDER_DATA =
|
||||
"androidx.identitycredentials.BUNDLE_KEY_PROVIDER_DATA";
|
||||
|
||||
@VisibleForTesting
|
||||
public static final String EXTRA_GET_CREDENTIAL_RESPONSE =
|
||||
"android.service.credentials.extra.GET_CREDENTIAL_RESPONSE";
|
||||
|
||||
@VisibleForTesting
|
||||
public static final String EXTRA_CREDENTIAL_DATA =
|
||||
"androidx.credentials.provider.extra.EXTRA_CREDENTIAL_DATA";
|
||||
|
||||
public static class DigitalCredential {
|
||||
@Nullable public String mProtocol;
|
||||
public String mData;
|
||||
@ -77,15 +106,7 @@ public class IdentityCredentialsDelegate {
|
||||
protected void onReceiveResult(int code, Bundle data) {
|
||||
Log.d(TAG, "Received a response");
|
||||
try {
|
||||
var response = IntentHelper.extractGetCredentialResponse(code, data);
|
||||
var token =
|
||||
response.getCredential()
|
||||
.getData()
|
||||
.getByteArray("identityToken");
|
||||
// TODO(crbug.com/336329411): Extract the protocol from the `response`,
|
||||
// instead of always using null.
|
||||
result.fulfill(
|
||||
new DigitalCredential(null, Objects.requireNonNull(token)));
|
||||
result.fulfill(extractDigitalCredentialFromResponseBundle(code, data));
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, e.toString());
|
||||
|
||||
@ -147,4 +168,100 @@ public class IdentityCredentialsDelegate {
|
||||
}
|
||||
return Promise.rejected();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a DigitalCredential from a response bundle.
|
||||
*
|
||||
* <p>This method attempts to extract a DigitalCredential from the given response bundle. It
|
||||
* first tries to parse the response in the new format. If that fails, it falls back to the
|
||||
* legacy format.
|
||||
*
|
||||
* @param code The result code from the activity.
|
||||
* @param bundle The bundle containing the response data.
|
||||
* @return The extracted DigitalCredential.
|
||||
* @throws JSONException If there is an error parsing the JSON data.
|
||||
* @throws NullPointerException If required data is missing in the legacy format.
|
||||
* @throws GetCredentialException If there is an issue with the credential.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
public static DigitalCredential extractDigitalCredentialFromResponseBundle(
|
||||
int code, Bundle bundle)
|
||||
throws JSONException, NullPointerException, GetCredentialException {
|
||||
// Try to read the new format.
|
||||
var digitalCredential = extractDigitalCredentialFromModernResponse(bundle);
|
||||
if (digitalCredential != null) {
|
||||
return digitalCredential;
|
||||
}
|
||||
// TODO(crbug.com/336329411) Handle the case when the intent doesn't contain the modern
|
||||
// response, but contains the modern exception.
|
||||
|
||||
// Fallback to the legacy format.
|
||||
var response = IntentHelper.extractGetCredentialResponse(code, bundle);
|
||||
var token = response.getCredential().getData().getByteArray(BUNDLE_KEY_IDENTITY_TOKEN);
|
||||
|
||||
return new DigitalCredential(null, Objects.requireNonNull(token));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a DigitalCredential from a response bundle in the modern format.
|
||||
*
|
||||
* @param bundle The bundle containing the response data.
|
||||
* @return The extracted DigitalCredential, or null if the response is not in the modern format.
|
||||
* @throws JSONException If there is an error parsing the JSON data.
|
||||
*/
|
||||
private static @Nullable DigitalCredential extractDigitalCredentialFromModernResponse(
|
||||
Bundle bundle) throws JSONException {
|
||||
Intent intent = IntentUtils.safeGetParcelable(bundle, BUNDLE_KEY_PROVIDER_DATA);
|
||||
if (intent == null) {
|
||||
return null;
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
return extractDigitalCredentialIntentAfter34(intent);
|
||||
}
|
||||
return extractDigitalCredentialIntentBefore34(intent);
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||
private static @Nullable DigitalCredential extractDigitalCredentialIntentAfter34(Intent intent)
|
||||
throws JSONException {
|
||||
GetCredentialResponse response =
|
||||
IntentUtils.safeGetParcelableExtra(intent, EXTRA_GET_CREDENTIAL_RESPONSE);
|
||||
if (response == null) {
|
||||
return null;
|
||||
}
|
||||
return extractDigitalCredentialFromCredentialDataBundle(response.getCredential().getData());
|
||||
}
|
||||
|
||||
private static @Nullable DigitalCredential extractDigitalCredentialIntentBefore34(Intent intent)
|
||||
throws JSONException {
|
||||
Bundle responseBundle =
|
||||
IntentUtils.safeGetBundleExtra(intent, EXTRA_GET_CREDENTIAL_RESPONSE);
|
||||
if (responseBundle == null) {
|
||||
return null;
|
||||
}
|
||||
return extractDigitalCredentialFromCredentialDataBundle(
|
||||
IntentUtils.safeGetBundle(responseBundle, EXTRA_CREDENTIAL_DATA));
|
||||
}
|
||||
|
||||
private static @Nullable DigitalCredential extractDigitalCredentialFromCredentialDataBundle(
|
||||
@Nullable Bundle bundle) throws JSONException {
|
||||
if (bundle == null) {
|
||||
return null;
|
||||
}
|
||||
String credentialJson = bundle.getString(BUNDLE_KEY_REQUEST_JSON);
|
||||
if (credentialJson == null) {
|
||||
return null;
|
||||
}
|
||||
JSONObject credential = new JSONObject(credentialJson);
|
||||
// Unless the json contains the protocol, return null to fallback to the legacy format.
|
||||
if (credential.has(DC_API_RESPONSE_PROTOCOL_KEY)) {
|
||||
String protocol = credential.getString(DC_API_RESPONSE_PROTOCOL_KEY);
|
||||
var data = credential.getJSONObject(DC_API_RESPONSE_DATA_KEY);
|
||||
return new DigitalCredential(protocol, data.toString());
|
||||
}
|
||||
// Otherwise, treat the whole json as the response. This is added for backward compatibility
|
||||
// where GMSCore was setting the modern response with the contents of the legacy response
|
||||
// without a protocol.
|
||||
return new DigitalCredential(null, credentialJson);
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user