Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions src/main/kotlin/GoogleRevocationList.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:JvmName("GoogleRevocationList")

package com.android.keyattestation.verifier

import com.google.gson.Gson
import java.io.IOException
import java.io.InputStream
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URI
import java.net.URL

/**
* Fetches Google's revocation status list from the web.
*
* This function will fail-closed if it cannot download or parse the revocation list, by throwing an
* exception.
*
* @return A set of revoked serial numbers.
*/
fun getGoogleRevocationStatusFromWeb(): Set<String> =
getRevocationStatusFromWeb(
URI.create("https://android.googleapis.com/attestation/status").toURL()
)

/**
* Fetches a revocation status list from the web.
*
* This function will fail-closed if it cannot download or parse the revocation list, by throwing an
* exception.
*
* @return A set of revoked serial numbers.
*/
fun getRevocationStatusFromWeb(
url: URL,
connectionProvider: (URL) -> HttpURLConnection = {
(it.openConnection() as? HttpURLConnection)
?: throw IllegalArgumentException("Could not open HttpURLConnection to $it")
},
): Set<String> {
val connection = connectionProvider(url)
try {
connection.requestMethod = "GET"
connection.connect()

if (connection.responseCode != HttpURLConnection.HTTP_OK) {
throw IOException(
"Failed to fetch revocation list from $url: HTTP ${connection.responseCode}"
)
}
return parseAttestationStatus(connection.inputStream)
} finally {
connection.disconnect()
}
}

/**
* Parses a revocation status list from an input stream.
*
* This function will fail-closed if it cannot parse the revocation list, by throwing an exception.
*
* @return A set of revoked serial numbers.
*/
fun parseAttestationStatus(input: InputStream): Set<String> {
data class StatusEntry(val status: String)
data class StatusFile(val entries: Map<String, StatusEntry>)

return Gson()
.fromJson(InputStreamReader(input), StatusFile::class.java)
.entries
.filterValues { it.status == "REVOKED" }
.keys
}
152 changes: 152 additions & 0 deletions src/test/kotlin/GoogleRevocationListTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.android.keyattestation.verifier

import com.google.common.truth.Truth.assertThat
import java.io.IOException
import java.io.InputStream
import java.net.HttpURLConnection
import java.net.URI
import java.net.URL
import kotlin.test.assertFailsWith
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4

@RunWith(JUnit4::class)
class GoogleRevocationListTest {
@Test
fun getGoogleRevocationStatusFromWeb_success() {
val json =
"""
{
"entries": {
"abc": { "status": "REVOKED" },
"def": { "status": "OK" }
}
}
"""
.trimIndent()
val uri = URI("http://localhost")
val result =
getRevocationStatusFromWeb(uri.toURL()) {
FakeHttpURLConnection(uri.toURL(), HttpURLConnection.HTTP_OK, json)
}

assertThat(result).containsExactly("abc")
}

@Test
fun getRevocationStatusFromWeb_httpError_throwsIOException() {
val uri = URI("http://localhost")
assertFailsWith<IOException> {
getRevocationStatusFromWeb(uri.toURL()) {
FakeHttpURLConnection(uri.toURL(), HttpURLConnection.HTTP_NOT_FOUND)
}
}
}

@Test
fun parseAttestationStatus_emptyList() {
val json = """{"entries": {}}"""
val result = parseAttestationStatus(json.byteInputStream())
assertThat(result).isEmpty()
}

@Test
fun parseAttestationStatus_revokedAndOkEntries() {
val json =
"""
{
"entries": {
"abc": {
"status": "REVOKED",
"reason": "KEY_COMPROMISE"
},
"def": {
"status": "OK"
},
"123": {
"status": "REVOKED",
"reason": "SUPERSEDED"
}
}
}
"""
.trimIndent()
val result = parseAttestationStatus(json.byteInputStream())
assertThat(result).containsExactly("abc", "123")
}

@Test
fun parseAttestationStatus_onlyOkEntries() {
val json =
"""
{
"entries": {
"def": {
"status": "OK"
},
"456": {
"status": "OK"
}
}
}
"""
.trimIndent()
val result = parseAttestationStatus(json.byteInputStream())
assertThat(result).isEmpty()
}

@Test
fun parseAttestationStatus_onlyRevokedEntries() {
val json =
"""
{
"entries": {
"abc": {
"status": "REVOKED",
"reason": "KEY_COMPROMISE"
},
"123": {
"status": "REVOKED",
"reason": "SUPERSEDED"
}
}
}
"""
.trimIndent()
val result = parseAttestationStatus(json.byteInputStream())
assertThat(result).containsExactly("abc", "123")
}
}

private class FakeHttpURLConnection(
url: URL,
private val fakeResponseCode: Int,
val responseBody: String = "",
) : HttpURLConnection(url) {
override fun connect() {}

override fun disconnect() {}

override fun getInputStream(): InputStream = responseBody.byteInputStream()

override fun getResponseCode() = fakeResponseCode

override fun usingProxy() = false
}