Skip to content

Commit c32c180

Browse files
authored
Implement Play Integrity attestation flow. (#3618)
* Add method to call the `GeneratePlayIntegrityChallenge` endpoint to `NetworkClient`. * Implement Play Integrity attestation flow. * Add project number to IntegrityTokenRequest. * Fix `PlayIntegrityAppCheckProviderTest`s. * Add unit tests. * Address review comments.
1 parent 295c8f5 commit c32c180

File tree

8 files changed

+491
-32
lines changed

8 files changed

+491
-32
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright 2022 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package com.google.firebase.appcheck.playintegrity.internal;
16+
17+
import androidx.annotation.NonNull;
18+
import org.json.JSONObject;
19+
20+
/**
21+
* Client-side model of the GeneratePlayIntegrityChallengeRequest payload from the Firebase App
22+
* Check Token Exchange API.
23+
*/
24+
public class GeneratePlayIntegrityChallengeRequest {
25+
26+
public GeneratePlayIntegrityChallengeRequest() {}
27+
28+
@NonNull
29+
public String toJsonString() {
30+
JSONObject jsonObject = new JSONObject();
31+
32+
// GeneratePlayIntegrityChallenge takes an empty POST body since the app ID is in the URL.
33+
return jsonObject.toString();
34+
}
35+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright 2022 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package com.google.firebase.appcheck.playintegrity.internal;
16+
17+
import static com.google.android.gms.common.internal.Preconditions.checkNotNull;
18+
19+
import androidx.annotation.NonNull;
20+
import androidx.annotation.VisibleForTesting;
21+
import org.json.JSONException;
22+
import org.json.JSONObject;
23+
24+
/**
25+
* Client-side model of the GeneratePlayIntegrityChallengeResponse payload from the Firebase App
26+
* Check Token Exchange API.
27+
*/
28+
public class GeneratePlayIntegrityChallengeResponse {
29+
30+
@VisibleForTesting static final String CHALLENGE_KEY = "challenge";
31+
@VisibleForTesting static final String TIME_TO_LIVE_KEY = "ttl";
32+
33+
private String challenge;
34+
private String timeToLive;
35+
36+
@NonNull
37+
public static GeneratePlayIntegrityChallengeResponse fromJsonString(@NonNull String jsonString)
38+
throws JSONException {
39+
JSONObject jsonObject = new JSONObject(jsonString);
40+
String challenge = jsonObject.optString(CHALLENGE_KEY, null);
41+
String timeToLive = jsonObject.optString(TIME_TO_LIVE_KEY, null);
42+
return new GeneratePlayIntegrityChallengeResponse(challenge, timeToLive);
43+
}
44+
45+
private GeneratePlayIntegrityChallengeResponse(
46+
@NonNull String challenge, @NonNull String timeToLive) {
47+
checkNotNull(challenge);
48+
checkNotNull(timeToLive);
49+
this.challenge = challenge;
50+
this.timeToLive = timeToLive;
51+
}
52+
53+
@NonNull
54+
public String getChallenge() {
55+
return challenge;
56+
}
57+
58+
@NonNull
59+
public String getTimeToLive() {
60+
return timeToLive;
61+
}
62+
}

appcheck/firebase-appcheck-playintegrity/src/main/java/com/google/firebase/appcheck/playintegrity/internal/PlayIntegrityAppCheckProvider.java

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919
import com.google.android.gms.tasks.Continuation;
2020
import com.google.android.gms.tasks.Task;
2121
import com.google.android.gms.tasks.Tasks;
22+
import com.google.android.play.core.integrity.IntegrityManager;
23+
import com.google.android.play.core.integrity.IntegrityManagerFactory;
24+
import com.google.android.play.core.integrity.IntegrityTokenRequest;
25+
import com.google.android.play.core.integrity.IntegrityTokenResponse;
2226
import com.google.firebase.FirebaseApp;
2327
import com.google.firebase.appcheck.AppCheckProvider;
2428
import com.google.firebase.appcheck.AppCheckToken;
@@ -33,19 +37,30 @@ public class PlayIntegrityAppCheckProvider implements AppCheckProvider {
3337

3438
private static final String UTF_8 = "UTF-8";
3539

40+
private final String projectNumber;
41+
private final IntegrityManager integrityManager;
3642
private final NetworkClient networkClient;
3743
private final ExecutorService backgroundExecutor;
3844
private final RetryManager retryManager;
3945

4046
public PlayIntegrityAppCheckProvider(@NonNull FirebaseApp firebaseApp) {
41-
this(new NetworkClient(firebaseApp), Executors.newCachedThreadPool(), new RetryManager());
47+
this(
48+
firebaseApp.getOptions().getGcmSenderId(),
49+
IntegrityManagerFactory.create(firebaseApp.getApplicationContext()),
50+
new NetworkClient(firebaseApp),
51+
Executors.newCachedThreadPool(),
52+
new RetryManager());
4253
}
4354

4455
@VisibleForTesting
4556
PlayIntegrityAppCheckProvider(
57+
@NonNull String projectNumber,
58+
@NonNull IntegrityManager integrityManager,
4659
@NonNull NetworkClient networkClient,
4760
@NonNull ExecutorService backgroundExecutor,
4861
@NonNull RetryManager retryManager) {
62+
this.projectNumber = projectNumber;
63+
this.integrityManager = integrityManager;
4964
this.networkClient = networkClient;
5065
this.backgroundExecutor = backgroundExecutor;
5166
this.retryManager = retryManager;
@@ -54,24 +69,61 @@ public PlayIntegrityAppCheckProvider(@NonNull FirebaseApp firebaseApp) {
5469
@NonNull
5570
@Override
5671
public Task<AppCheckToken> getToken() {
57-
// TODO(rosalyntan): Obtain the Play Integrity challenge nonce.
58-
ExchangePlayIntegrityTokenRequest request =
59-
new ExchangePlayIntegrityTokenRequest("placeholder");
60-
Task<AppCheckTokenResponse> networkTask =
72+
return getPlayIntegrityAttestation()
73+
.continueWithTask(
74+
new Continuation<IntegrityTokenResponse, Task<AppCheckTokenResponse>>() {
75+
@Override
76+
public Task<AppCheckTokenResponse> then(@NonNull Task<IntegrityTokenResponse> task) {
77+
if (task.isSuccessful()) {
78+
ExchangePlayIntegrityTokenRequest request =
79+
new ExchangePlayIntegrityTokenRequest(task.getResult().token());
80+
return Tasks.call(
81+
backgroundExecutor,
82+
() ->
83+
networkClient.exchangeAttestationForAppCheckToken(
84+
request.toJsonString().getBytes(UTF_8),
85+
NetworkClient.PLAY_INTEGRITY,
86+
retryManager));
87+
}
88+
return Tasks.forException(task.getException());
89+
}
90+
})
91+
.continueWithTask(
92+
new Continuation<AppCheckTokenResponse, Task<AppCheckToken>>() {
93+
@Override
94+
public Task<AppCheckToken> then(@NonNull Task<AppCheckTokenResponse> task) {
95+
if (task.isSuccessful()) {
96+
return Tasks.forResult(
97+
DefaultAppCheckToken.constructFromAppCheckTokenResponse(task.getResult()));
98+
}
99+
// TODO: Surface more error details.
100+
return Tasks.forException(task.getException());
101+
}
102+
});
103+
}
104+
105+
@NonNull
106+
private Task<IntegrityTokenResponse> getPlayIntegrityAttestation() {
107+
GeneratePlayIntegrityChallengeRequest generateChallengeRequest =
108+
new GeneratePlayIntegrityChallengeRequest();
109+
Task<GeneratePlayIntegrityChallengeResponse> generateChallengeTask =
61110
Tasks.call(
62111
backgroundExecutor,
63112
() ->
64-
networkClient.exchangeAttestationForAppCheckToken(
65-
request.toJsonString().getBytes(UTF_8),
66-
NetworkClient.PLAY_INTEGRITY,
67-
retryManager));
68-
return networkTask.continueWithTask(
69-
new Continuation<AppCheckTokenResponse, Task<AppCheckToken>>() {
113+
GeneratePlayIntegrityChallengeResponse.fromJsonString(
114+
networkClient.generatePlayIntegrityChallenge(
115+
generateChallengeRequest.toJsonString().getBytes(UTF_8), retryManager)));
116+
return generateChallengeTask.continueWithTask(
117+
new Continuation<GeneratePlayIntegrityChallengeResponse, Task<IntegrityTokenResponse>>() {
70118
@Override
71-
public Task<AppCheckToken> then(@NonNull Task<AppCheckTokenResponse> task) {
119+
public Task<IntegrityTokenResponse> then(
120+
@NonNull Task<GeneratePlayIntegrityChallengeResponse> task) {
72121
if (task.isSuccessful()) {
73-
return Tasks.forResult(
74-
DefaultAppCheckToken.constructFromAppCheckTokenResponse(task.getResult()));
122+
return integrityManager.requestIntegrityToken(
123+
IntegrityTokenRequest.builder()
124+
.setCloudProjectNumber(Long.parseLong(projectNumber))
125+
.setNonce(task.getResult().getChallenge())
126+
.build());
75127
}
76128
// TODO: Surface more error details.
77129
return Tasks.forException(task.getException());
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Copyright 2022 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package com.google.firebase.appcheck.playintegrity.internal;
16+
17+
import static com.google.common.truth.Truth.assertThat;
18+
19+
import org.junit.Test;
20+
import org.junit.runner.RunWith;
21+
import org.robolectric.RobolectricTestRunner;
22+
import org.robolectric.annotation.Config;
23+
24+
/** Tests for {@link GeneratePlayIntegrityChallengeRequest}. */
25+
@RunWith(RobolectricTestRunner.class)
26+
@Config(manifest = Config.NONE)
27+
public class GeneratePlayIntegrityChallengeRequestTest {
28+
private static final String EMPTY_JSON = "{}";
29+
30+
@Test
31+
public void toJsonString_expectSerialized() throws Exception {
32+
GeneratePlayIntegrityChallengeRequest generatePlayIntegrityChallengeRequest =
33+
new GeneratePlayIntegrityChallengeRequest();
34+
35+
assertThat(generatePlayIntegrityChallengeRequest.toJsonString()).isEqualTo(EMPTY_JSON);
36+
}
37+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright 2022 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package com.google.firebase.appcheck.playintegrity.internal;
16+
17+
import static com.google.common.truth.Truth.assertThat;
18+
import static org.junit.Assert.assertThrows;
19+
20+
import org.json.JSONObject;
21+
import org.junit.Test;
22+
import org.junit.runner.RunWith;
23+
import org.robolectric.RobolectricTestRunner;
24+
import org.robolectric.annotation.Config;
25+
26+
/** Tests for {@link GeneratePlayIntegrityChallengeResponse}. */
27+
@RunWith(RobolectricTestRunner.class)
28+
@Config(manifest = Config.NONE)
29+
public class GeneratePlayIntegrityChallengeResponseTest {
30+
private static final String CHALLENGE = "testChallenge";
31+
private static final String TIME_TO_LIVE = "3600s";
32+
33+
@Test
34+
public void fromJsonString_expectDeserialized() throws Exception {
35+
JSONObject jsonObject = new JSONObject();
36+
jsonObject.put(GeneratePlayIntegrityChallengeResponse.CHALLENGE_KEY, CHALLENGE);
37+
jsonObject.put(GeneratePlayIntegrityChallengeResponse.TIME_TO_LIVE_KEY, TIME_TO_LIVE);
38+
39+
GeneratePlayIntegrityChallengeResponse generatePlayIntegrityChallengeResponse =
40+
GeneratePlayIntegrityChallengeResponse.fromJsonString(jsonObject.toString());
41+
assertThat(generatePlayIntegrityChallengeResponse.getChallenge()).isEqualTo(CHALLENGE);
42+
assertThat(generatePlayIntegrityChallengeResponse.getTimeToLive()).isEqualTo(TIME_TO_LIVE);
43+
}
44+
45+
@Test
46+
public void fromJsonString_nullChallenge_throwsException() throws Exception {
47+
JSONObject jsonObject = new JSONObject();
48+
jsonObject.put(GeneratePlayIntegrityChallengeResponse.TIME_TO_LIVE_KEY, TIME_TO_LIVE);
49+
50+
assertThrows(
51+
NullPointerException.class,
52+
() -> GeneratePlayIntegrityChallengeResponse.fromJsonString(jsonObject.toString()));
53+
}
54+
55+
@Test
56+
public void fromJsonString_nullTimeToLive_throwsException() throws Exception {
57+
JSONObject jsonObject = new JSONObject();
58+
jsonObject.put(GeneratePlayIntegrityChallengeResponse.CHALLENGE_KEY, CHALLENGE);
59+
60+
assertThrows(
61+
NullPointerException.class,
62+
() -> GeneratePlayIntegrityChallengeResponse.fromJsonString(jsonObject.toString()));
63+
}
64+
}

0 commit comments

Comments
 (0)