diff --git a/.gitignore b/.gitignore index f5ca0232..5fe6ef55 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ .externalNativeBuild .cxx /*.jks +.kotlin diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..ed89b832 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,99 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 项目概述 + +这是一个 Android 密钥认证测试应用,支持生成、保存、加载、解析和验证 Android 密钥和 ID 认证数据。该应用用于自测试,因此没有网络权限。 + +## 构建和测试命令 + +构建应用: +```bash +./gradlew assembleDebug # 构建 debug 版本 +./gradlew assembleRelease # 构建 release 版本 +``` + +安装应用: +```bash +./gradlew installDebug # 安装 debug 版本到设备 +``` + +清理构建: +```bash +./gradlew clean # 清理构建输出 +``` + +## 项目结构 + +该项目包含两个模块: +- `app`: 主应用模块 +- `stub`: 编译时依赖的存根库模块 + +### 核心包结构 + +- `io.github.vvb2060.keyattestation.attestation`: 认证核心逻辑 + - `Attestation.java`: 抽象基类,解析认证证书 + - `Asn1Attestation.java`: ASN.1 格式认证实现 + - `EatAttestation.java`: EAT(Entity Attestation Token)格式实现 + - `KnoxAttestation.java`: Samsung Knox 认证实现 + - `AuthorizationList.java`: 授权列表解析 + - `RevocationList.java`: 证书撤销列表验证 + +- `io.github.vvb2060.keyattestation.keystore`: Android KeyStore 交互 + - `KeyStoreManager.java`: KeyStore 管理器 + - `AndroidKeyStore.java`: 本地 KeyStore 实现 + - `RemoteProvisioning.java`: 远程配置支持 + +- `io.github.vvb2060.keyattestation.repository`: 数据仓库层 + - `AttestationRepository.java`: 认证数据仓库,处理证书生成和加载 + +- `io.github.vvb2060.keyattestation.home`: 主界面相关 + - `HomeViewModel.kt`: 主视图模型,管理认证状态和 StrongBox 选项 + - `HomeActivity.kt`: 主活动 + +## 技术要点 + +### 认证类型支持 + +应用支持三种认证格式: +1. ASN.1 格式 (OID: 1.3.6.1.4.1.11129.2.1.17) +2. EAT 格式 (OID: 1.3.6.1.4.1.11129.2.1.25) +3. Knox 格式 (OID: 1.3.6.1.4.1.236.11.3.23.7) + +### 安全级别 + +- `KM_SECURITY_LEVEL_SOFTWARE = 0`: 软件实现 +- `KM_SECURITY_LEVEL_TRUSTED_ENVIRONMENT = 1`: TEE 实现 +- `KM_SECURITY_LEVEL_STRONG_BOX = 2`: StrongBox 实现 + +### 字节码转换 + +应用使用 ASM 进行字节码转换,通过 `ClassVisitorFactory` 重命名以 `_rename` 结尾的类。 + +### 依赖项 + +主要依赖: +- BouncyCastle (bcprov-jdk18on): 加密操作 +- Guava: 通用工具 +- CBOR (co.nstant.in:cbor): CBOR 格式解析 +- Rikka 系列库: Material Design 组件 +- Shizuku API: 系统级权限交互 + +## 开发注意事项 + +- 应用最低 SDK 版本为 24 (Android 7.0) +- 目标 SDK 版本为 35 +- 使用 Java 21 和 Kotlin 2.0 +- Release 构建启用混淆和资源压缩 +- 应用没有网络权限,证书撤销数据嵌入在 APK 中 +- 支持 Samsung 特定的 Key Attestation 功能(samsungkeystoreutils) + +## 版本管理 + +版本代码通过 git commit 数量自动生成: +```bash +git rev-list --count HEAD +``` + +当前版本名: 1.8.4 \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 386349bc..64102a23 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,9 @@ + + + = Build.VERSION_CODES.TIRAMISU) { @@ -34,37 +50,166 @@ private static String toString(InputStream input) throws IOException { private static JSONObject parseStatus(InputStream inputStream) throws IOException { try { var statusListJson = new JSONObject(toString(inputStream)); + // 尝试获取发布时间 + if (statusListJson.has("publishTime")) { + publishTime = statusListJson.getString("publishTime"); + } return statusListJson.getJSONObject("entries"); } catch (JSONException e) { throw new IOException(e); } } - private static JSONObject getStatus() { + private static synchronized JSONObject getStatus() { + if (data != null) { + return data; + } + + var context = AppApplication.app; + var prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + lastUpdate = prefs.getLong(KEY_LAST_UPDATE, 0); + publishTime = prefs.getString(KEY_PUBLISH_TIME, null); + + // 先尝试加载本地缓存的文件 + File statusFile = new File(context.getFilesDir(), STATUS_FILE); + if (statusFile.exists()) { + try (var input = new FileInputStream(statusFile)) { + data = parseStatus(input); + return data; + } catch (IOException e) { + Log.w(TAG, "Failed to load cached status file", e); + } + } + + // 加载内置的状态文件作为后备 + var res = context.getResources(); + try (var input = res.openRawResource(R.raw.status)) { + data = parseStatus(input); + return data; + } catch (IOException e) { + throw new RuntimeException("Failed to parse certificate revocation status", e); + } + } + + /** + * 从网络更新吊销列表 + * @return 是否更新成功 + */ + public static boolean updateFromNetwork() { var statusUrl = "https://android.googleapis.com/attestation/status"; var resName = "android:string/vendor_required_attestation_revocation_list_url"; - var res = AppApplication.app.getResources(); + var context = AppApplication.app; + var res = context.getResources(); + + // 检查是否有自定义的URL // noinspection DiscouragedApi var id = res.getIdentifier(resName, null, null); if (id != 0) { var url = res.getString(id); if (!statusUrl.equals(url) && url.toLowerCase(Locale.ROOT).startsWith("https")) { - // no network permission, waiting for user report - throw new RuntimeException("unknown status url: " + url); + statusUrl = url; } } - try (var input = res.openRawResource(R.raw.status)) { - return parseStatus(input); - } catch (IOException e) { - throw new RuntimeException("Failed to parse certificate revocation status", e); + + HttpURLConnection connection = null; + try { + URL url = new URL(statusUrl); + connection = (HttpURLConnection) url.openConnection(); + connection.setConnectTimeout(10000); + connection.setReadTimeout(10000); + connection.setRequestMethod("GET"); + + int responseCode = connection.getResponseCode(); + if (responseCode == HttpURLConnection.HTTP_OK) { + // 从 HTTP 响应头获取 Last-Modified 时间 + String lastModifiedHeader = connection.getHeaderField("Last-Modified"); + long serverLastModified = 0; + if (lastModifiedHeader != null) { + try { + serverLastModified = connection.getHeaderFieldDate("Last-Modified", 0); + } catch (Exception e) { + Log.w(TAG, "Failed to parse Last-Modified header", e); + } + } + + // 下载并保存到本地 + File statusFile = new File(context.getFilesDir(), STATUS_FILE); + try (var input = connection.getInputStream(); + var output = new FileOutputStream(statusFile)) { + byte[] buffer = new byte[8192]; + int length; + while ((length = input.read(buffer)) != -1) { + output.write(buffer, 0, length); + } + } + + // 重新加载数据 + try (var input = new FileInputStream(statusFile)) { + data = parseStatus(input); + } + + // 如果从响应头获取到了服务器的最后修改时间,优先使用它 + // 否则使用当前时间作为更新时间 + var prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + if (serverLastModified > 0) { + lastUpdate = serverLastModified; + } else { + lastUpdate = System.currentTimeMillis(); + } + + prefs.edit() + .putLong(KEY_LAST_UPDATE, lastUpdate) + .putString(KEY_PUBLISH_TIME, publishTime) + .apply(); + + Log.i(TAG, "Successfully updated revocation list from network. Last-Modified: " + + (serverLastModified > 0 ? new java.util.Date(serverLastModified) : "not available")); + return true; + } else { + Log.w(TAG, "Failed to update revocation list: HTTP " + responseCode); + return false; + } + } catch (Exception e) { + Log.e(TAG, "Failed to update revocation list from network", e); + return false; + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + /** + * 获取上次更新时间 + * @return 时间戳(毫秒),如果从未更新则返回0 + */ + public static long getLastUpdateTime() { + if (lastUpdate == 0) { + var prefs = AppApplication.app.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + lastUpdate = prefs.getLong(KEY_LAST_UPDATE, 0); } + return lastUpdate; + } + + /** + * 获取吊销列表发布时间 + * @return 发布时间字符串,如果未知则返回null + */ + public static String getPublishTime() { + if (publishTime == null && data == null) { + getStatus(); // 确保数据已加载 + } + return publishTime; } public static RevocationList get(BigInteger serialNumber) { + // 确保数据已加载 + JSONObject statusData = getStatus(); + String serialNumberString = serialNumber.toString(16).toLowerCase(); JSONObject revocationStatus; try { - revocationStatus = data.getJSONObject(serialNumberString); + revocationStatus = statusData.getJSONObject(serialNumberString); } catch (JSONException e) { return null; } diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeFragment.kt b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeFragment.kt index df10e546..2e813d34 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeFragment.kt +++ b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeFragment.kt @@ -250,6 +250,12 @@ class HomeFragment : AppFragment(), HomeAdapter.Listener, MenuProvider { R.id.menu_import_attest_key -> { import.launch("text/xml") } + R.id.menu_update_revocation_list -> { + viewModel.updateRevocationList() + } + R.id.menu_revocation_list_info -> { + showRevocationListInfo() + } R.id.menu_about -> { showAboutDialog() } @@ -258,6 +264,17 @@ class HomeFragment : AppFragment(), HomeAdapter.Listener, MenuProvider { return true } + private fun showRevocationListInfo() { + val context = requireContext() + val info = viewModel.getRevocationListInfo() + + AlertDialog.Builder(context) + .setTitle(R.string.revocation_list_info) + .setMessage(info) + .setPositiveButton(android.R.string.ok, null) + .show() + } + private fun showAboutDialog() { val context = requireContext() val text = StringBuilder() diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeViewModel.kt b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeViewModel.kt index 65564d8b..ef5a5936 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeViewModel.kt +++ b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeViewModel.kt @@ -19,6 +19,7 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import com.samsung.android.security.keystore.AttestationUtils import io.github.vvb2060.keyattestation.AppApplication +import io.github.vvb2060.keyattestation.attestation.RevocationList import io.github.vvb2060.keyattestation.keystore.KeyStoreManager import io.github.vvb2060.keyattestation.repository.AttestationRepository import io.github.vvb2060.keyattestation.repository.BaseData @@ -221,4 +222,30 @@ class HomeViewModel( attestationData.postValue(result) } + + fun updateRevocationList() = AppApplication.executor.execute { + val success = RevocationList.updateFromNetwork() + if (success) { + AppApplication.toast("Revocation list updated successfully") + } else { + AppApplication.toast("Failed to update revocation list") + } + } + + fun getRevocationListInfo(): String { + val publishTime = RevocationList.getPublishTime() + val lastUpdate = RevocationList.getLastUpdateTime() + + val sb = StringBuilder() + if (publishTime != null) { + sb.append("Publish time: $publishTime\n") + } + if (lastUpdate > 0) { + val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.getDefault()) + sb.append("Last updated: ${dateFormat.format(java.util.Date(lastUpdate))}") + } else { + sb.append("Using embedded revocation list") + } + return sb.toString() + } } diff --git a/app/src/main/res/menu/home.xml b/app/src/main/res/menu/home.xml index 9f7e3d0e..3830cc11 100644 --- a/app/src/main/res/menu/home.xml +++ b/app/src/main/res/menu/home.xml @@ -82,6 +82,16 @@ android:showAsAction="never" android:title="@string/load_certs" /> + + + + Redefinir Carregar do arquivo Salvar em arquivo + Atualizar lista de revogação + Informações da lista de revogação Sobre Este software é de código aberto sob %2$s (%1$s). Instale o Shizuku para usar o Atestado de ID (%s). diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 914a6dcb..0581aea1 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -15,6 +15,8 @@ 重置 从文件加载 保存到文件 + 更新吊销列表 + 吊销列表信息 关于 此软件是 %2$s 下的开源软件(%1$s)。 安装 Shizuku 使用 ID 认证(%s)。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index be008194..b7167480 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -15,6 +15,8 @@ 重設 從檔案讀取 儲存到檔案 + 更新撤銷清單 + 撤銷清單資訊 關於 此軟體是 %2$s 下的開源軟體(%1$s)。 安裝 Shizuku 使用識別碼認證(%s)。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5620044a..2c36339e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -15,6 +15,8 @@ Reset Load from file Save to file + Update revocation list + Revocation list info About This software is open source under %2$s (%1$s). Install Shizuku to use ID attestation (%s).