1 /* 2 * Copyright (C) 2021 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.libraries.entitlement.eapaka; 18 19 import static com.android.libraries.entitlement.ServiceEntitlementException.ERROR_EAP_AKA_FAILURE; 20 import static com.android.libraries.entitlement.ServiceEntitlementException.ERROR_EAP_AKA_SYNCHRONIZATION_FAILURE; 21 import static com.android.libraries.entitlement.ServiceEntitlementException.ERROR_MALFORMED_HTTP_RESPONSE; 22 23 import android.content.Context; 24 import android.net.Uri; 25 import android.telephony.TelephonyManager; 26 import android.text.TextUtils; 27 import android.util.Log; 28 29 import androidx.annotation.Nullable; 30 import androidx.annotation.VisibleForTesting; 31 32 import com.android.libraries.entitlement.CarrierConfig; 33 import com.android.libraries.entitlement.EsimOdsaOperation; 34 import com.android.libraries.entitlement.ServiceEntitlementException; 35 import com.android.libraries.entitlement.ServiceEntitlementRequest; 36 import com.android.libraries.entitlement.http.HttpClient; 37 import com.android.libraries.entitlement.http.HttpConstants.ContentType; 38 import com.android.libraries.entitlement.http.HttpConstants.RequestMethod; 39 import com.android.libraries.entitlement.http.HttpRequest; 40 import com.android.libraries.entitlement.http.HttpResponse; 41 42 import com.google.common.collect.ImmutableList; 43 import com.google.common.net.HttpHeaders; 44 45 import org.json.JSONException; 46 import org.json.JSONObject; 47 48 import java.util.List; 49 50 public class EapAkaApi { 51 private static final String TAG = "ServiceEntitlement"; 52 53 public static final String EAP_CHALLENGE_RESPONSE = "eap-relay-packet"; 54 55 private static final String VERS = "vers"; 56 private static final String ENTITLEMENT_VERSION = "entitlement_version"; 57 private static final String TERMINAL_ID = "terminal_id"; 58 private static final String TERMINAL_VENDOR = "terminal_vendor"; 59 private static final String TERMINAL_MODEL = "terminal_model"; 60 private static final String TERMIAL_SW_VERSION = "terminal_sw_version"; 61 private static final String APP = "app"; 62 private static final String EAP_ID = "EAP_ID"; 63 private static final String IMSI = "IMSI"; 64 private static final String TOKEN = "token"; 65 private static final String TEMPORARY_TOKEN = "temporary_token"; 66 private static final String NOTIF_ACTION = "notif_action"; 67 private static final String NOTIF_TOKEN = "notif_token"; 68 private static final String APP_VERSION = "app_version"; 69 private static final String APP_NAME = "app_name"; 70 71 private static final String OPERATION = "operation"; 72 private static final String OPERATION_TYPE = "operation_type"; 73 private static final String OPERATION_TARGETS = "operation_targets"; 74 private static final String COMPANION_TERMINAL_ID = "companion_terminal_id"; 75 private static final String COMPANION_TERMINAL_VENDOR = "companion_terminal_vendor"; 76 private static final String COMPANION_TERMINAL_MODEL = "companion_terminal_model"; 77 private static final String COMPANION_TERMINAL_SW_VERSION = "companion_terminal_sw_version"; 78 private static final String COMPANION_TERMINAL_FRIENDLY_NAME = 79 "companion_terminal_friendly_name"; 80 private static final String COMPANION_TERMINAL_SERVICE = "companion_terminal_service"; 81 private static final String COMPANION_TERMINAL_ICCID = "companion_terminal_iccid"; 82 private static final String COMPANION_TERMINAL_EID = "companion_terminal_eid"; 83 84 private static final String TERMINAL_ICCID = "terminal_iccid"; 85 private static final String TERMINAL_EID = "terminal_eid"; 86 87 private static final String TARGET_TERMINAL_ID = "target_terminal_id"; 88 private static final String TARGET_TERMINAL_ICCID = "target_terminal_iccid"; 89 private static final String TARGET_TERMINAL_EID = "target_terminal_eid"; 90 91 private static final String OLD_TERMINAL_ID = "old_terminal_id"; 92 private static final String OLD_TERMINAL_ICCID = "old_terminal_iccid"; 93 94 private static final String BOOST_TYPE = "boost_type"; 95 96 // In case of EAP-AKA synchronization failure or another challenge, we try to authenticate for 97 // at most three times. 98 private static final int MAX_EAP_AKA_ATTEMPTS = 3; 99 100 private final Context mContext; 101 private final int mSimSubscriptionId; 102 private final HttpClient mHttpClient; 103 private final String mBypassEapAkaResponse; 104 EapAkaApi( Context context, int simSubscriptionId, boolean saveHistory, String bypassEapAkaResponse)105 public EapAkaApi( 106 Context context, 107 int simSubscriptionId, 108 boolean saveHistory, 109 String bypassEapAkaResponse) { 110 this(context, simSubscriptionId, new HttpClient(saveHistory), bypassEapAkaResponse); 111 } 112 113 @VisibleForTesting EapAkaApi( Context context, int simSubscriptionId, HttpClient httpClient, String bypassEapAkaResponse)114 EapAkaApi( 115 Context context, 116 int simSubscriptionId, 117 HttpClient httpClient, 118 String bypassEapAkaResponse) { 119 this.mContext = context; 120 this.mSimSubscriptionId = simSubscriptionId; 121 this.mHttpClient = httpClient; 122 this.mBypassEapAkaResponse = bypassEapAkaResponse; 123 } 124 125 /** 126 * Retrieves raw entitlement configuration doc though EAP-AKA authentication. 127 * 128 * <p>Implementation based on GSMA TS.43-v5.0 2.6.1. 129 * 130 * @throws ServiceEntitlementException when getting an unexpected http response. 131 */ 132 @Nullable queryEntitlementStatus(ImmutableList<String> appIds, CarrierConfig carrierConfig, ServiceEntitlementRequest request)133 public String queryEntitlementStatus(ImmutableList<String> appIds, 134 CarrierConfig carrierConfig, ServiceEntitlementRequest request) 135 throws ServiceEntitlementException { 136 Uri.Builder urlBuilder = Uri.parse(carrierConfig.serverUrl()).buildUpon(); 137 appendParametersForServiceEntitlementRequest(urlBuilder, appIds, request); 138 if (!TextUtils.isEmpty(request.authenticationToken())) { 139 // Fast Re-Authentication flow with pre-existing auth token 140 Log.d(TAG, "Fast Re-Authentication"); 141 return httpGet( 142 urlBuilder.toString(), carrierConfig, request.acceptContentType()).body(); 143 } else { 144 // Full Authentication flow 145 Log.d(TAG, "Full Authentication"); 146 HttpResponse challengeResponse = 147 httpGet( 148 urlBuilder.toString(), 149 carrierConfig, 150 ServiceEntitlementRequest.ACCEPT_CONTENT_TYPE_JSON); 151 String eapAkaChallenge = getEapAkaChallenge(challengeResponse); 152 if (eapAkaChallenge == null) { 153 throw new ServiceEntitlementException( 154 ERROR_MALFORMED_HTTP_RESPONSE, 155 "Failed to parse EAP-AKA challenge: " + challengeResponse.body()); 156 } 157 return respondToEapAkaChallenge( 158 carrierConfig, 159 eapAkaChallenge, 160 challengeResponse.cookies(), 161 MAX_EAP_AKA_ATTEMPTS, 162 request.acceptContentType()) 163 .body(); 164 } 165 } 166 167 /** 168 * Sends a follow-up HTTP request to the HTTP {@code response} using the same cookie, and 169 * returns the follow-up HTTP response. 170 * 171 * <p>The {@code eapAkaChallenge} should be the EAP-AKA challenge from server, and the follow-up 172 * request could contain: 173 * 174 * <ul> 175 * <li>The EAP-AKA response message, and the follow-up response should contain the service 176 * entitlement configuration, or another EAP-AKA challenge in which case the method calls 177 * if {@code remainingAttempts} is greater than zero (If {@code remainingAttempts} reaches 178 * 0, the method will throw ServiceEntitlementException) ; or 179 * <li>The EAP-AKA synchronization failure message, and the follow-up response should contain 180 * the new EAP-AKA challenge. Then this method calls itself to follow-up the new challenge 181 * and return a new response, as long as {@code remainingAttempts} is greater than zero. 182 * </ul> 183 * 184 * @param response Challenge response from server which its content type is JSON 185 */ respondToEapAkaChallenge( CarrierConfig carrierConfig, String eapAkaChallenge, ImmutableList<String> cookies, int remainingAttempts, String contentType)186 private HttpResponse respondToEapAkaChallenge( 187 CarrierConfig carrierConfig, 188 String eapAkaChallenge, 189 ImmutableList<String> cookies, 190 int remainingAttempts, 191 String contentType) 192 throws ServiceEntitlementException { 193 if (!mBypassEapAkaResponse.isEmpty()) { 194 return challengeResponse(mBypassEapAkaResponse, carrierConfig, cookies, contentType); 195 } 196 197 EapAkaChallenge challenge = EapAkaChallenge.parseEapAkaChallenge(eapAkaChallenge); 198 EapAkaResponse eapAkaResponse = 199 EapAkaResponse.respondToEapAkaChallenge(mContext, mSimSubscriptionId, challenge); 200 // This could be a successful authentication, another challenge, or synchronization failure. 201 if (eapAkaResponse.response() != null) { 202 HttpResponse response = 203 challengeResponse( 204 eapAkaResponse.response(), carrierConfig, cookies, contentType); 205 String nextEapAkaChallenge = getEapAkaChallenge(response); 206 // successful authentication 207 if (nextEapAkaChallenge == null) { 208 return response; 209 } 210 // another challenge 211 Log.d(TAG, "Received another challenge"); 212 if (remainingAttempts > 0) { 213 return respondToEapAkaChallenge( 214 carrierConfig, 215 nextEapAkaChallenge, 216 cookies, 217 remainingAttempts - 1, 218 contentType); 219 } else { 220 throw new ServiceEntitlementException( 221 ERROR_EAP_AKA_FAILURE, "Unable to EAP-AKA authenticate"); 222 } 223 } else if (eapAkaResponse.synchronizationFailureResponse() != null) { 224 Log.d(TAG, "synchronization failure"); 225 HttpResponse newChallenge = 226 challengeResponse( 227 eapAkaResponse.synchronizationFailureResponse(), 228 carrierConfig, 229 cookies, 230 ServiceEntitlementRequest.ACCEPT_CONTENT_TYPE_JSON); 231 String nextEapAkaChallenge = getEapAkaChallenge(newChallenge); 232 if (nextEapAkaChallenge == null) { 233 throw new ServiceEntitlementException( 234 ERROR_MALFORMED_HTTP_RESPONSE, 235 "Failed to parse EAP-AKA challenge: " + newChallenge.body()); 236 } 237 if (remainingAttempts > 0) { 238 return respondToEapAkaChallenge( 239 carrierConfig, 240 nextEapAkaChallenge, 241 cookies, 242 remainingAttempts - 1, 243 contentType); 244 } else { 245 throw new ServiceEntitlementException( 246 ERROR_EAP_AKA_SYNCHRONIZATION_FAILURE, 247 "Unable to recover from EAP-AKA synchroinization failure"); 248 } 249 } else { // not possible 250 throw new AssertionError("EapAkaResponse invalid."); 251 } 252 } 253 challengeResponse( String eapAkaChallengeResponse, CarrierConfig carrierConfig, ImmutableList<String> cookies, String contentType)254 private HttpResponse challengeResponse( 255 String eapAkaChallengeResponse, 256 CarrierConfig carrierConfig, 257 ImmutableList<String> cookies, 258 String contentType) 259 throws ServiceEntitlementException { 260 Log.d(TAG, "challengeResponse"); 261 JSONObject postData = new JSONObject(); 262 try { 263 postData.put(EAP_CHALLENGE_RESPONSE, eapAkaChallengeResponse); 264 } catch (JSONException jsonException) { 265 throw new ServiceEntitlementException( 266 ERROR_MALFORMED_HTTP_RESPONSE, "Failed to put post data", jsonException); 267 } 268 HttpRequest request = 269 HttpRequest.builder() 270 .setUrl(carrierConfig.serverUrl()) 271 .setRequestMethod(RequestMethod.POST) 272 .setPostData(postData) 273 .addRequestProperty(HttpHeaders.ACCEPT, contentType) 274 .addRequestProperty( 275 HttpHeaders.CONTENT_TYPE, 276 ServiceEntitlementRequest.ACCEPT_CONTENT_TYPE_JSON) 277 .addRequestProperty(HttpHeaders.COOKIE, cookies) 278 .setTimeoutInSec(carrierConfig.timeoutInSec()) 279 .setNetwork(carrierConfig.network()) 280 .build(); 281 return mHttpClient.request(request); 282 } 283 284 /** 285 * Retrieves raw doc of performing ODSA operations. For operation type, see {@link 286 * EsimOdsaOperation}. 287 * 288 * <p>Implementation based on GSMA TS.43-v5.0 6.1. 289 */ performEsimOdsaOperation(String appId, CarrierConfig carrierConfig, ServiceEntitlementRequest request, EsimOdsaOperation odsaOperation)290 public String performEsimOdsaOperation(String appId, CarrierConfig carrierConfig, 291 ServiceEntitlementRequest request, EsimOdsaOperation odsaOperation) 292 throws ServiceEntitlementException { 293 Uri.Builder urlBuilder = Uri.parse(carrierConfig.serverUrl()).buildUpon(); 294 appendParametersForServiceEntitlementRequest(urlBuilder, ImmutableList.of(appId), request); 295 appendParametersForEsimOdsaOperation(urlBuilder, odsaOperation); 296 297 if (!TextUtils.isEmpty(request.authenticationToken()) 298 || !TextUtils.isEmpty(request.temporaryToken())) { 299 // Fast Re-Authentication flow with pre-existing auth token 300 Log.d(TAG, "Fast Re-Authentication"); 301 return httpGet( 302 urlBuilder.toString(), carrierConfig, request.acceptContentType()).body(); 303 } else { 304 // Full Authentication flow 305 Log.d(TAG, "Full Authentication"); 306 HttpResponse challengeResponse = 307 httpGet( 308 urlBuilder.toString(), 309 carrierConfig, 310 ServiceEntitlementRequest.ACCEPT_CONTENT_TYPE_JSON); 311 String eapAkaChallenge = getEapAkaChallenge(challengeResponse); 312 if (eapAkaChallenge == null) { 313 throw new ServiceEntitlementException( 314 ERROR_MALFORMED_HTTP_RESPONSE, 315 "Failed to parse EAP-AKA challenge: " + challengeResponse.body()); 316 } 317 return respondToEapAkaChallenge( 318 carrierConfig, 319 eapAkaChallenge, 320 challengeResponse.cookies(), 321 MAX_EAP_AKA_ATTEMPTS, 322 request.acceptContentType()) 323 .body(); 324 } 325 } 326 appendParametersForServiceEntitlementRequest( Uri.Builder urlBuilder, ImmutableList<String> appIds, ServiceEntitlementRequest request)327 private void appendParametersForServiceEntitlementRequest( 328 Uri.Builder urlBuilder, ImmutableList<String> appIds, 329 ServiceEntitlementRequest request) { 330 TelephonyManager telephonyManager = mContext.getSystemService( 331 TelephonyManager.class).createForSubscriptionId(mSimSubscriptionId); 332 if (!TextUtils.isEmpty(request.authenticationToken())) { 333 // IMSI and token required for fast AuthN. 334 urlBuilder 335 .appendQueryParameter(IMSI, telephonyManager.getSubscriberId()) 336 .appendQueryParameter(TOKEN, request.authenticationToken()); 337 } else if (!TextUtils.isEmpty(request.temporaryToken())) { 338 // temporary_token required for fast AuthN. 339 urlBuilder.appendQueryParameter(TEMPORARY_TOKEN, request.temporaryToken()); 340 } else { 341 // EAP_ID required for initial AuthN 342 urlBuilder.appendQueryParameter( 343 EAP_ID, 344 getImsiEap(telephonyManager.getSimOperator(), 345 telephonyManager.getSubscriberId())); 346 } 347 348 if (!TextUtils.isEmpty(request.notificationToken())) { 349 urlBuilder 350 .appendQueryParameter(NOTIF_ACTION, 351 Integer.toString(request.notificationAction())) 352 .appendQueryParameter(NOTIF_TOKEN, request.notificationToken()); 353 } 354 355 // Assign terminal ID with device IMEI if not set. 356 if (TextUtils.isEmpty(request.terminalId())) { 357 urlBuilder.appendQueryParameter(TERMINAL_ID, telephonyManager.getImei()); 358 } else { 359 urlBuilder.appendQueryParameter(TERMINAL_ID, request.terminalId()); 360 } 361 362 // Optional query parameters, append them if not empty 363 appendOptionalQueryParameter(urlBuilder, APP_VERSION, request.appVersion()); 364 appendOptionalQueryParameter(urlBuilder, APP_NAME, request.appName()); 365 appendOptionalQueryParameter(urlBuilder, BOOST_TYPE, request.boostType()); 366 367 for (String appId : appIds) { 368 urlBuilder.appendQueryParameter(APP, appId); 369 } 370 371 urlBuilder 372 // Identity and Authentication parameters 373 .appendQueryParameter(TERMINAL_VENDOR, request.terminalVendor()) 374 .appendQueryParameter(TERMINAL_MODEL, request.terminalModel()) 375 .appendQueryParameter(TERMIAL_SW_VERSION, request.terminalSoftwareVersion()) 376 // General Service parameters 377 .appendQueryParameter(VERS, Integer.toString(request.configurationVersion())) 378 .appendQueryParameter(ENTITLEMENT_VERSION, request.entitlementVersion()); 379 } 380 appendParametersForEsimOdsaOperation( Uri.Builder urlBuilder, EsimOdsaOperation odsaOperation)381 private void appendParametersForEsimOdsaOperation( 382 Uri.Builder urlBuilder, EsimOdsaOperation odsaOperation) { 383 urlBuilder.appendQueryParameter(OPERATION, odsaOperation.operation()); 384 if (odsaOperation.operationType() != EsimOdsaOperation.OPERATION_TYPE_NOT_SET) { 385 urlBuilder.appendQueryParameter(OPERATION_TYPE, 386 Integer.toString(odsaOperation.operationType())); 387 } 388 appendOptionalQueryParameter( 389 urlBuilder, 390 OPERATION_TARGETS, 391 TextUtils.join(",", odsaOperation.operationTargets())); 392 appendOptionalQueryParameter(urlBuilder, COMPANION_TERMINAL_ID, 393 odsaOperation.companionTerminalId()); 394 appendOptionalQueryParameter(urlBuilder, COMPANION_TERMINAL_VENDOR, 395 odsaOperation.companionTerminalVendor()); 396 appendOptionalQueryParameter(urlBuilder, COMPANION_TERMINAL_MODEL, 397 odsaOperation.companionTerminalModel()); 398 appendOptionalQueryParameter(urlBuilder, COMPANION_TERMINAL_SW_VERSION, 399 odsaOperation.companionTerminalSoftwareVersion()); 400 appendOptionalQueryParameter(urlBuilder, COMPANION_TERMINAL_FRIENDLY_NAME, 401 odsaOperation.companionTerminalFriendlyName()); 402 appendOptionalQueryParameter(urlBuilder, COMPANION_TERMINAL_SERVICE, 403 odsaOperation.companionTerminalService()); 404 appendOptionalQueryParameter(urlBuilder, COMPANION_TERMINAL_ICCID, 405 odsaOperation.companionTerminalIccid()); 406 appendOptionalQueryParameter(urlBuilder, COMPANION_TERMINAL_EID, 407 odsaOperation.companionTerminalEid()); 408 appendOptionalQueryParameter(urlBuilder, TERMINAL_ICCID, 409 odsaOperation.terminalIccid()); 410 appendOptionalQueryParameter(urlBuilder, TERMINAL_EID, odsaOperation.terminalEid()); 411 appendOptionalQueryParameter(urlBuilder, TARGET_TERMINAL_ID, 412 odsaOperation.targetTerminalId()); 413 appendOptionalQueryParameter(urlBuilder, TARGET_TERMINAL_ICCID, 414 odsaOperation.targetTerminalIccid()); 415 appendOptionalQueryParameter(urlBuilder, TARGET_TERMINAL_EID, 416 odsaOperation.targetTerminalEid()); 417 appendOptionalQueryParameter(urlBuilder, OLD_TERMINAL_ICCID, 418 odsaOperation.oldTerminalIccid()); 419 appendOptionalQueryParameter(urlBuilder, OLD_TERMINAL_ID, 420 odsaOperation.oldTerminalId()); 421 } 422 httpGet(String url, CarrierConfig carrierConfig, String contentType)423 private HttpResponse httpGet(String url, CarrierConfig carrierConfig, String contentType) 424 throws ServiceEntitlementException { 425 HttpRequest httpRequest = 426 HttpRequest.builder() 427 .setUrl(url) 428 .setRequestMethod(RequestMethod.GET) 429 .addRequestProperty(HttpHeaders.ACCEPT, contentType) 430 .setTimeoutInSec(carrierConfig.timeoutInSec()) 431 .setNetwork(carrierConfig.network()) 432 .build(); 433 return mHttpClient.request(httpRequest); 434 } 435 appendOptionalQueryParameter(Uri.Builder urlBuilder, String key, String value)436 private void appendOptionalQueryParameter(Uri.Builder urlBuilder, String key, String value) { 437 if (!TextUtils.isEmpty(value)) { 438 urlBuilder.appendQueryParameter(key, value); 439 } 440 } 441 442 @Nullable getEapAkaChallenge(HttpResponse response)443 private String getEapAkaChallenge(HttpResponse response) throws ServiceEntitlementException { 444 String eapAkaChallenge = null; 445 String responseBody = response.body(); 446 if (response.contentType() == ContentType.JSON) { 447 try { 448 eapAkaChallenge = 449 new JSONObject(responseBody).optString(EAP_CHALLENGE_RESPONSE, null); 450 } catch (JSONException jsonException) { 451 throw new ServiceEntitlementException( 452 ERROR_MALFORMED_HTTP_RESPONSE, 453 "Failed to parse json object", 454 jsonException); 455 } 456 } else if (response.contentType() == ContentType.XML) { 457 // TODO: possibly support parsing eap-relay-packet in XML format 458 return null; 459 } else { 460 throw new ServiceEntitlementException( 461 ERROR_MALFORMED_HTTP_RESPONSE, "Unknown HTTP content type"); 462 } 463 return eapAkaChallenge; 464 } 465 466 /** 467 * Returns the IMSI EAP value. The resulting realm part of the Root NAI in 3GPP TS 23.003 clause 468 * 19.3.2 will be in the form: 469 * 470 * <p>{@code 0<IMSI>@nai.epc.mnc<MNC>.mcc<MCC>.3gppnetwork.org} 471 */ 472 @Nullable getImsiEap(@ullable String mccmnc, @Nullable String imsi)473 public static String getImsiEap(@Nullable String mccmnc, @Nullable String imsi) { 474 if (mccmnc == null || mccmnc.length() < 5 || imsi == null) { 475 return null; 476 } 477 478 String mcc = mccmnc.substring(0, 3); 479 String mnc = mccmnc.substring(3); 480 if (mnc.length() == 2) { 481 mnc = "0" + mnc; 482 } 483 return "0" + imsi + "@nai.epc.mnc" + mnc + ".mcc" + mcc + ".3gppnetwork.org"; 484 } 485 486 /** 487 * Retrieves the history of past HTTP request and responses. 488 */ getHistory()489 public List<String> getHistory() { 490 return mHttpClient.getHistory(); 491 } 492 493 /** 494 * Clears the history of past HTTP request and responses. 495 */ clearHistory()496 public void clearHistory() { 497 mHttpClient.clearHistory(); 498 } 499 } 500