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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
.externalNativeBuild
.cxx
/*.jks
.kotlin
99 changes: 99 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

<permission
android:name="${applicationId}.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION"
android:protectionLevel="signature"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,38 @@
package io.github.vvb2060.keyattestation.attestation;

import android.content.Context;
import android.content.SharedPreferences;
import android.os.Build;
import android.util.Log;

import org.json.JSONException;
import org.json.JSONObject;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigInteger;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Locale;

import io.github.vvb2060.keyattestation.AppApplication;
import io.github.vvb2060.keyattestation.R;

public record RevocationList(String status, String reason) {
private static final JSONObject data = getStatus();
private static final String TAG = "RevocationList";
private static final String STATUS_FILE = "revocation_status.json";
private static final String PREFS_NAME = "revocation_list";
private static final String KEY_LAST_UPDATE = "last_update";
private static final String KEY_PUBLISH_TIME = "publish_time";

private static JSONObject data = null;
private static long lastUpdate = 0;
private static String publishTime = null;

private static String toString(InputStream input) throws IOException {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand All @@ -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()
Expand Down
Loading