Skip to content

Commit ad43504

Browse files
authored
Merge pull request #11 from shoey63/visual-enhancements
Refactor CRL UI, clean up certificate chain, and optimize network fetches
2 parents d3b77a7 + 6520771 commit ad43504

4 files changed

Lines changed: 121 additions & 62 deletions

File tree

app/src/main/java/io/github/vvb2060/keyattestation/attestation/CertificateInfo.java

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,14 @@ private void checkStatus(PublicKey parentKey) {
6868
try {
6969
status = CERT_SIGN;
7070
cert.verify(parentKey);
71+
7172
status = CERT_REVOKED;
7273
var certStatus = RevocationList.get(cert.getSerialNumber());
7374
if (certStatus != null) {
74-
throw new CertificateException("Certificate revocation " + certStatus);
75+
// Just throw the status message (e.g., "REVOKED") without any suffix
76+
throw new CertificateException(certStatus.status());
7577
}
78+
7679
status = CERT_EXPIRED;
7780
cert.checkValidity();
7881
status = CERT_NORMAL;
@@ -86,11 +89,6 @@ private boolean checkAttestation() {
8689
boolean terminate;
8790
try {
8891
attestation = Attestation.loadFromCertificate(cert);
89-
// If key purpose included KeyPurpose::SIGN,
90-
// then it could be used to sign arbitrary data, including any tbsCertificate,
91-
// and so an attestation produced by the key would have no security properties.
92-
// If the parent certificate can attest that the key purpose is only KeyPurpose::ATTEST_KEY,
93-
// then the child certificate can be trusted.
9492
var purposes = attestation.getTeeEnforced().getPurposes();
9593
terminate = purposes == null || !purposes.contains(AuthorizationList.KM_PURPOSE_ATTEST_KEY);
9694
} catch (CertificateParsingException e) {

app/src/main/java/io/github/vvb2060/keyattestation/attestation/RevocationList.java

Lines changed: 90 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,25 @@
2020
import io.github.vvb2060.keyattestation.AppApplication;
2121
import io.github.vvb2060.keyattestation.R;
2222

23-
public record RevocationList(String status, String reason) {
23+
public record RevocationList(String status, String reason, DataSource source) {
24+
public enum DataSource {
25+
NETWORK_UPDATE,
26+
NETWORK_UP_TO_DATE,
27+
CACHE,
28+
BUNDLED
29+
}
30+
2431
private static final String TAG = "RevocationList";
2532
private static final String CACHE_FILE = "revocation_cache.json";
2633
private static final String PREFS_NAME = "revocation_prefs";
2734
private static final String KEY_PUBLISH_TIME = "last_publish_time";
35+
2836
private static JSONObject data = null;
2937
private static Date publishTime = null;
38+
private static DataSource currentSource = DataSource.BUNDLED;
39+
40+
private record StatusResult(JSONObject json, DataSource source) {}
41+
private record NetworkResult(JSONObject json, int responseCode) {}
3042

3143
private static String toString(InputStream input) throws IOException {
3244
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -43,16 +55,15 @@ private static String toString(InputStream input) throws IOException {
4355

4456
private static JSONObject parseStatus(InputStream inputStream) throws IOException {
4557
try {
46-
var statusListJson = new JSONObject(toString(inputStream));
47-
return statusListJson.getJSONObject("entries");
58+
return new JSONObject(toString(inputStream));
4859
} catch (JSONException e) {
4960
throw new IOException(e);
5061
}
5162
}
5263

53-
private static void saveToCache(JSONObject json) {
64+
private static void saveToCache(JSONObject fullJson) {
5465
try (var output = AppApplication.app.openFileOutput(CACHE_FILE, Context.MODE_PRIVATE)) {
55-
output.write(json.toString().getBytes(StandardCharsets.UTF_8));
66+
output.write(fullJson.toString().getBytes(StandardCharsets.UTF_8));
5667
if (publishTime != null) {
5768
var prefs = AppApplication.app.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
5869
prefs.edit().putLong(KEY_PUBLISH_TIME, publishTime.getTime()).apply();
@@ -62,7 +73,7 @@ private static void saveToCache(JSONObject json) {
6273
}
6374
}
6475

65-
private static JSONObject fetchFromNetwork(String statusUrl) {
76+
private static NetworkResult fetchFromNetwork(String statusUrl, long cachedTime) {
6677
HttpURLConnection connection = null;
6778
try {
6879
URL url = new URL(statusUrl);
@@ -72,108 +83,137 @@ private static JSONObject fetchFromNetwork(String statusUrl) {
7283
connection.setReadTimeout(10000);
7384
connection.setRequestProperty("User-Agent", "KeyAttestation");
7485

86+
if (cachedTime != 0) {
87+
connection.setIfModifiedSince(cachedTime);
88+
}
89+
7590
int responseCode = connection.getResponseCode();
91+
92+
if (responseCode == HttpURLConnection.HTTP_NOT_MODIFIED) {
93+
return new NetworkResult(null, responseCode);
94+
}
95+
7696
if (responseCode == HttpURLConnection.HTTP_OK) {
7797
long lastModified = connection.getLastModified();
7898
if (lastModified != 0) {
7999
publishTime = new Date(lastModified);
80-
Log.i(TAG, "Revocation list Last-Modified: " + publishTime);
81100
}
82101

83102
try (var input = connection.getInputStream()) {
84-
return parseStatus(input);
103+
return new NetworkResult(parseStatus(input), responseCode);
85104
}
86-
} else {
87-
Log.w(TAG, "Failed to fetch revocation list from network, HTTP " + responseCode);
88-
return null;
89105
}
106+
return null;
90107
} catch (Exception e) {
91-
Log.w(TAG, "Failed to fetch revocation list from network", e);
108+
Log.w(TAG, "Network fetch failed", e);
92109
return null;
93110
} finally {
94-
if (connection != null) {
95-
connection.disconnect();
96-
}
111+
if (connection != null) connection.disconnect();
97112
}
98113
}
99114

100-
private static JSONObject getStatus() {
115+
private static StatusResult getStatus() {
101116
var statusUrl = "https://android.googleapis.com/attestation/status";
102-
var resName = "android:string/vendor_required_attestation_revocation_list_url";
103117
var res = AppApplication.app.getResources();
104-
// noinspection DiscouragedApi
118+
var resName = "android:string/vendor_required_attestation_revocation_list_url";
105119
var id = res.getIdentifier(resName, null, null);
106120
if (id != 0) {
107121
var url = res.getString(id);
108122
if (!statusUrl.equals(url) && url.toLowerCase(Locale.ROOT).startsWith("https")) {
109123
statusUrl = url;
110124
}
111125
}
126+
127+
var prefs = AppApplication.app.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
128+
long cachedTime = prefs.getLong(KEY_PUBLISH_TIME, 0);
112129

113-
// 1. Try Network
114-
JSONObject networkData = fetchFromNetwork(statusUrl);
115-
if (networkData != null) {
116-
Log.i(TAG, "Successfully fetched revocation list from network");
117-
saveToCache(networkData);
118-
return networkData;
130+
// 1. Network Check
131+
NetworkResult networkResult = fetchFromNetwork(statusUrl, cachedTime);
132+
if (networkResult != null) {
133+
if (networkResult.responseCode() == HttpURLConnection.HTTP_NOT_MODIFIED) {
134+
try (var fis = AppApplication.app.openFileInput(CACHE_FILE)) {
135+
var cacheJson = parseStatus(fis);
136+
publishTime = new Date(cachedTime);
137+
return new StatusResult(cacheJson.getJSONObject("entries"), DataSource.NETWORK_UP_TO_DATE);
138+
} catch (Exception e) {
139+
Log.w(TAG, "Failed to read cache despite 304 response. Falling back.", e);
140+
}
141+
} else if (networkResult.json() != null) {
142+
saveToCache(networkResult.json());
143+
try {
144+
return new StatusResult(networkResult.json().getJSONObject("entries"), DataSource.NETWORK_UPDATE);
145+
} catch (JSONException ignored) {}
146+
}
119147
}
120148

121-
// 2. Try Cache (osm0sis fallback)
149+
// 2. Cache
122150
try (var fis = AppApplication.app.openFileInput(CACHE_FILE)) {
123-
Log.i(TAG, "Using cached revocation list");
124-
var prefs = AppApplication.app.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
125-
long lastTime = prefs.getLong(KEY_PUBLISH_TIME, 0);
126-
if (lastTime != 0) publishTime = new Date(lastTime);
127-
return parseStatus(fis);
128-
} catch (IOException e) {
129-
Log.i(TAG, "No cached revocation list found");
151+
var cacheJson = parseStatus(fis);
152+
if (cachedTime != 0) publishTime = new Date(cachedTime);
153+
return new StatusResult(cacheJson.getJSONObject("entries"), DataSource.CACHE);
154+
} catch (Exception e) {
155+
Log.i(TAG, "Cache unavailable");
130156
}
131157

132-
// 3. Fallback to bundled resource
133-
Log.i(TAG, "Using bundled revocation list");
158+
// 3. Bundled
134159
try (var input = res.openRawResource(R.raw.status)) {
135-
return parseStatus(input);
136-
} catch (IOException e) {
137-
throw new RuntimeException("Failed to parse certificate revocation status", e);
160+
var bundledJson = parseStatus(input);
161+
publishTime = null;
162+
return new StatusResult(bundledJson.getJSONObject("entries"), DataSource.BUNDLED);
163+
} catch (Exception e) {
164+
throw new RuntimeException("Critical: Failed to load revocation data", e);
138165
}
139166
}
140167

141168
public static Date getPublishTime() {
142169
return publishTime;
143170
}
144171

172+
public static DataSource getCurrentSource() {
173+
return currentSource;
174+
}
175+
145176
public static void refresh() {
146177
synchronized (RevocationList.class) {
147-
data = getStatus();
178+
StatusResult result = getStatus();
179+
data = result.json();
180+
181+
// If we successfully fetched a brand new file this session,
182+
// don't let a subsequent UI refresh overwrite our status with a 304!
183+
if (currentSource == DataSource.NETWORK_UPDATE && result.source() == DataSource.NETWORK_UP_TO_DATE) {
184+
Log.i(TAG, "Preserving NETWORK_UPDATE status across multiple refreshes");
185+
} else {
186+
currentSource = result.source();
187+
}
148188
}
149189
}
150190

151191
public static RevocationList get(BigInteger serialNumber) {
152192
if (data == null) {
153193
synchronized (RevocationList.class) {
154194
if (data == null) {
155-
data = getStatus();
195+
StatusResult result = getStatus();
196+
data = result.json();
197+
198+
if (currentSource == DataSource.NETWORK_UPDATE && result.source() == DataSource.NETWORK_UP_TO_DATE) {
199+
Log.i(TAG, "Preserving NETWORK_UPDATE status in get()");
200+
} else {
201+
currentSource = result.source();
202+
}
156203
}
157204
}
158205
}
159-
String serialNumberString = serialNumber.toString(16).toLowerCase();
160-
JSONObject revocationStatus;
206+
String serial = serialNumber.toString(16).toLowerCase();
161207
try {
162-
revocationStatus = data.getJSONObject(serialNumberString);
208+
JSONObject entry = data.getJSONObject(serial);
209+
return new RevocationList(entry.getString("status"), entry.getString("reason"), currentSource);
163210
} catch (JSONException e) {
164211
return null;
165212
}
166-
try {
167-
var status = revocationStatus.getString("status");
168-
var reason = revocationStatus.getString("reason");
169-
return new RevocationList(status, reason);
170-
} catch (JSONException e) {
171-
return new RevocationList("", "");
172-
}
173213
}
174214

175215
@Override
176216
public String toString() {
177-
return "status is " + status + ", reason is " + reason;
217+
return "status: " + status + ", source: " + source;
178218
}
179219
}

app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,17 +94,33 @@ class HomeAdapter(listener: Listener) : IdBasedRecyclerViewAdapter() {
9494
addItem(CommonItemViewHolder.CERT_INFO_CREATOR, certInfo, id++)
9595
}
9696

97-
// Add revocation list information
97+
// Add revocation list information with source status
9898
val publishTime = io.github.vvb2060.keyattestation.attestation.RevocationList.getPublishTime()
99-
val dateStr = if (publishTime != null) {
100-
io.github.vvb2060.keyattestation.attestation.AuthorizationList.formatDate(publishTime)
101-
} else {
102-
null
99+
val source = io.github.vvb2060.keyattestation.attestation.RevocationList.getCurrentSource()
100+
val app = io.github.vvb2060.keyattestation.AppApplication.app
101+
102+
val dateStr = publishTime?.let {
103+
io.github.vvb2060.keyattestation.attestation.AuthorizationList.formatDate(it)
104+
} ?: ""
105+
106+
val statusLine = when (source) {
107+
RevocationList.DataSource.NETWORK_UPDATE -> app.getString(R.string.revocation_status_new_fetch)
108+
RevocationList.DataSource.NETWORK_UP_TO_DATE -> app.getString(R.string.revocation_status_up_to_date)
109+
RevocationList.DataSource.CACHE -> app.getString(R.string.revocation_status_offline_cached)
110+
RevocationList.DataSource.BUNDLED -> app.getString(R.string.revocation_status_offline_bundled)
111+
else -> ""
103112
}
113+
114+
val dateDisplay = if (dateStr.isNotEmpty()) {
115+
"$dateStr\n$statusLine"
116+
} else {
117+
statusLine
118+
}.trim().ifEmpty { null }
119+
104120
addItem(CommonItemViewHolder.COMMON_CREATOR, CommonData(
105121
R.string.revocation_list_publish_time,
106122
R.string.revocation_list_description,
107-
dateStr), ID_REVOCATION_INFO)
123+
dateDisplay), ID_REVOCATION_INFO)
108124

109125
when (baseData) {
110126
is AttestationData -> updateData(baseData)

app/src/main/res/values/strings.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@
5656
<string name="provisioning_info_manufacturer">manufacturer: </string>
5757
<string name="revocation_list_publish_time">Revocation list publish time</string>
5858
<string name="revocation_list_description">The revocation list is used to check if certificates have been revoked. This shows the publication time of the currently used revocation list.</string>
59+
60+
<string name="revocation_status_new_fetch">ℹ️ New CRL Fetched!</string>
61+
<string name="revocation_status_up_to_date">✅ CRL Up-to-date</string>
62+
<string name="revocation_status_offline_cached">⚠️ Device offline - using cached CRL</string>
63+
<string name="revocation_status_offline_bundled">❌ Offline - using hardcoded 27th Feb CRL</string>
5964

6065
<string name="error_message_subtitle">Detailed messages:</string>
6166
<string name="error_unknown">Unknown error</string>

0 commit comments

Comments
 (0)