From 17708dcc243e271fb14f9a9a55e9efdbf2c1ca3f Mon Sep 17 00:00:00 2001 From: Kofhisho Date: Thu, 2 Jul 2026 23:18:24 +0100 Subject: [PATCH 01/27] feat(RKP): Add DICE chain details --- .../keystore/IAndroidKeyStore.aidl | 1 + .../keyattestation/home/HomeAdapter.kt | 12 +++ .../keystore/AndroidKeyStore.java | 8 ++ .../keystore/RemoteProvisioning.java | 6 ++ .../repository/AttestationRepository.java | 3 +- .../repository/RemoteProvisioningData.java | 74 ++++++++++++++++++- app/src/main/res/values/strings.xml | 6 ++ 7 files changed, 108 insertions(+), 2 deletions(-) diff --git a/app/src/main/aidl/io/github/vvb2060/keyattestation/keystore/IAndroidKeyStore.aidl b/app/src/main/aidl/io/github/vvb2060/keyattestation/keystore/IAndroidKeyStore.aidl index e9d9c3d4..aea52006 100644 --- a/app/src/main/aidl/io/github/vvb2060/keyattestation/keystore/IAndroidKeyStore.aidl +++ b/app/src/main/aidl/io/github/vvb2060/keyattestation/keystore/IAndroidKeyStore.aidl @@ -17,5 +17,6 @@ interface IAndroidKeyStore { String getRkpHostname(); boolean canRemoteProvisioning(boolean useStrongBox); RpcHardwareInfo getHardwareInfo(boolean useStrongBox, out DeviceInfo deviceInfo); + byte[] getDiceChain(boolean useStrongBox); byte[] checkRemoteProvisioning(boolean useStrongBox); } diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt index 0c961898..6bb7dcae 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt +++ b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt @@ -270,6 +270,17 @@ class HomeAdapter(listener: Listener) : IdBasedRecyclerViewAdapter() { rkpData.deviceInfo.forEach { key, value -> addItem(CommonItemViewHolder.SIMPLE_CREATOR, Pair(key, value), id++) } + + if (rkpData.diceChain.isNotEmpty()) { + id = ID_RKP_DICE_START + addItem(SubtitleViewHolder.CREATOR, CommonData( + R.string.rkp_dice_chain, + R.string.rkp_dice_chain_description), id++) + + rkpData.diceChain.forEach { + addItem(CommonItemViewHolder.SIMPLE_CREATOR, it, id++) + } + } } fun updateData(e: AttestationException) { @@ -314,6 +325,7 @@ class HomeAdapter(listener: Listener) : IdBasedRecyclerViewAdapter() { private const val ID_DESCRIPTION_START = 3000L private const val ID_AUTHORIZATION_LIST_START = 4000L private const val ID_KNOX_START = 5000L + private const val ID_RKP_DICE_START = 6000L private const val ID_ERROR_MESSAGE = 100000L private fun createAuthorizationItems(list: AuthorizationList): Array { diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/keystore/AndroidKeyStore.java b/app/src/main/java/io/github/vvb2060/keyattestation/keystore/AndroidKeyStore.java index 2a5bfd37..6f4e1d0e 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/keystore/AndroidKeyStore.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/keystore/AndroidKeyStore.java @@ -373,6 +373,14 @@ public RpcHardwareInfo getHardwareInfo(boolean useStrongBox, DeviceInfo deviceIn } } + @Override + public byte[] getDiceChain(boolean useStrongBox) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + throw new IllegalStateException(); + } + return RemoteProvisioning.getInstance(useStrongBox).getDiceChain(); + } + @Override public byte[] checkRemoteProvisioning(boolean useStrongBox) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/keystore/RemoteProvisioning.java b/app/src/main/java/io/github/vvb2060/keyattestation/keystore/RemoteProvisioning.java index d0506943..3009de81 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/keystore/RemoteProvisioning.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/keystore/RemoteProvisioning.java @@ -67,6 +67,7 @@ class RemoteProvisioning { private final String requestId = UUID.randomUUID().toString(); private final IRemotelyProvisionedComponent binder; private byte[] deviceInfoData; + private byte[] diceChainData; private static class EekResponse { private final byte[] challenge; @@ -136,6 +137,10 @@ public byte[] getDeviceInfo() { return deviceInfoData; } + public byte[] getDiceChain() { + return diceChainData; + } + public byte[] check() throws RuntimeException { try { var eekResponse = fetchEek(); @@ -269,6 +274,7 @@ private byte[] generateCsr(EekResponse eekResponse) var array = (Array) decodeCbor(csrBytes); var deviceInfo = extractDeviceInfoFromV3Csr(array); deviceInfoData = encodeCbor(deviceInfo); + diceChainData = encodeCbor(array.getDataItems().get(2)); return encodeCbor(array.add(unverifiedDeviceInfo)); } } diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationRepository.java b/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationRepository.java index 06a1f497..b237f22f 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationRepository.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationRepository.java @@ -263,7 +263,8 @@ public Resource checkRkp(boolean useStrongBox) { ? keyStore.getRkpHostname() : null; var deviceInfo = new DeviceInfo(); var hw = keyStore.getHardwareInfo(useStrongBox, deviceInfo); - var info = new RemoteProvisioningData(name, hw, deviceInfo); + var diceChain = keyStore.getDiceChain(useStrongBox); + var info = new RemoteProvisioningData(name, hw, deviceInfo, diceChain); try { var data = keyStore.checkRemoteProvisioning(useStrongBox); info.setCerts(factory.generateCertificates(new ByteArrayInputStream(data))); diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/repository/RemoteProvisioningData.java b/app/src/main/java/io/github/vvb2060/keyattestation/repository/RemoteProvisioningData.java index d0d6aeac..a8f5b0ca 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/repository/RemoteProvisioningData.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/repository/RemoteProvisioningData.java @@ -3,6 +3,8 @@ import android.hardware.security.keymint.DeviceInfo; import android.hardware.security.keymint.RpcHardwareInfo; import android.util.ArrayMap; +import android.util.Log; +import android.util.Pair; import com.google.common.io.BaseEncoding; @@ -14,18 +16,33 @@ import co.nstant.in.cbor.CborDecoder; import co.nstant.in.cbor.CborException; +import co.nstant.in.cbor.model.Array; import co.nstant.in.cbor.model.ByteString; +import co.nstant.in.cbor.model.DataItem; import co.nstant.in.cbor.model.Map; +import co.nstant.in.cbor.model.NegativeInteger; +import co.nstant.in.cbor.model.Number; +import co.nstant.in.cbor.model.UnicodeString; +import co.nstant.in.cbor.model.UnsignedInteger; +import io.github.vvb2060.keyattestation.AppApplication; import io.github.vvb2060.keyattestation.attestation.CertificateInfo; public class RemoteProvisioningData extends BaseData { + private static final int SUB = 2; + private static final int CONFIG_DESCRIPTOR = -4670548; + private static final int MODE = -4670551; + private static final int COMPONENT_NAME = -70002; + private static final int COMPONENT_VERSION = -70003; + private static final int SECURITY_VERSION = -70005; + private final String rkpHostname; private final RpcHardwareInfo hardwareInfo; private final java.util.Map deviceInfo = new ArrayMap<>(); + private final List> diceChain = new ArrayList<>(); private Throwable error; public RemoteProvisioningData(String rkpHostname, RpcHardwareInfo hardwareInfo, - DeviceInfo deviceInfoData) throws CborException { + DeviceInfo deviceInfoData, byte[] diceChainData) throws CborException { this.rkpHostname = rkpHostname; this.hardwareInfo = hardwareInfo; var deviceInfo = (Map) CborDecoder.decode(deviceInfoData.deviceInfo).get(0); @@ -39,6 +56,57 @@ public RemoteProvisioningData(String rkpHostname, RpcHardwareInfo hardwareInfo, } this.deviceInfo.put(key.toString(), valueString); } + if (diceChainData != null) { + try { + parseDiceChain(diceChainData); + } catch (Exception e) { + Log.w(AppApplication.TAG, "Parse dice chain error.", e); + diceChain.clear(); + } + } + } + + private void parseDiceChain(byte[] data) throws CborException { + var entries = ((Array) CborDecoder.decode(data).get(0)).getDataItems(); + for (var i = 1; i < entries.size(); i++) { + var payloadData = (ByteString) ((Array) entries.get(i)).getDataItems().get(2); + var payload = (Map) CborDecoder.decode(payloadData.getBytes()).get(0); + String name = null; + var details = ""; + var mode = payload.get(new NegativeInteger(MODE)); + if (mode instanceof ByteString bytes && bytes.getBytes().length == 1) { + details = "mode: " + diceModeToString(bytes.getBytes()[0]); + } else if (mode instanceof Number number) { + details = "mode: " + diceModeToString(number.getValue().intValue()); + } + if (payload.get(new NegativeInteger(CONFIG_DESCRIPTOR)) instanceof ByteString config) { + var descriptor = (Map) CborDecoder.decode(config.getBytes()).get(0); + if (descriptor.get(new NegativeInteger(COMPONENT_NAME)) instanceof UnicodeString s) { + name = s.getString(); + } + details = appendItem(details, "securityVersion: ", descriptor.get(new NegativeInteger(SECURITY_VERSION))); + details = appendItem(details, "componentVersion: ", descriptor.get(new NegativeInteger(COMPONENT_VERSION))); + } + if (name == null && payload.get(new UnsignedInteger(SUB)) instanceof UnicodeString s) { + name = s.getString(); + } + diceChain.add(new Pair<>(name != null ? name : "layer " + i, details)); + } + } + + private static String appendItem(String details, String label, DataItem value) { + if (value == null) return details; + if (details.isEmpty()) return label + value; + return details + '\n' + label + value; + } + + private static String diceModeToString(int mode) { + return switch (mode) { + case 1 -> "normal"; + case 2 -> "debug"; + case 3 -> "recovery"; + default -> "not configured"; + }; } @SuppressWarnings("unchecked") @@ -65,6 +133,10 @@ public java.util.Map getDeviceInfo() { return deviceInfo; } + public List> getDiceChain() { + return diceChain; + } + public Throwable getError() { return error; } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 64d08fce..3c0234e8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -131,6 +131,12 @@ crafted by an IRemotelyProvisionedComponent HAL instance is coming from the expected device based on values initially uploaded during device\'s manufacture at the factory. + DICE certificate chain + + The DICE chain mirrors the boot stages of the device. + Each entry measures the code and configuration of a boot stage and is signed by a key derived by the previous stage. + A degenerate chain contains only a single self-signed entry and carries no boot measurements. + Remote provisioning hostname Remote key provisioning disabled From 996f4c5f9571031ffe4849127698134b159dec8d Mon Sep 17 00:00:00 2001 From: Kofhisho Date: Fri, 3 Jul 2026 01:47:53 +0100 Subject: [PATCH 02/27] feat(Theme): Add dynamic color scheme --- .../keyattestation/util/ColorManager.kt | 43 ++++++++----------- app/src/main/res/values-v31/colors.xml | 5 +++ app/src/main/res/values-v31/themes_custom.xml | 8 ++++ app/src/main/res/values/strings_colors.xml | 1 + 4 files changed, 32 insertions(+), 25 deletions(-) create mode 100644 app/src/main/res/values-v31/colors.xml create mode 100644 app/src/main/res/values-v31/themes_custom.xml diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/util/ColorManager.kt b/app/src/main/java/io/github/vvb2060/keyattestation/util/ColorManager.kt index 923eede5..36e0e530 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/util/ColorManager.kt +++ b/app/src/main/java/io/github/vvb2060/keyattestation/util/ColorManager.kt @@ -3,7 +3,7 @@ package io.github.vvb2060.keyattestation.util import android.app.Activity import android.content.Context import android.content.SharedPreferences -import androidx.annotation.ColorRes +import android.os.Build import androidx.annotation.StyleRes import androidx.appcompat.app.AlertDialog import io.github.vvb2060.keyattestation.R @@ -14,37 +14,30 @@ object ColorManager { private const val KEY_THEME = "theme_id" fun showColorPickerDialog(activity: Activity) { - val colors = arrayOf( - activity.getString(R.string.color_default_gray), - activity.getString(R.string.color_original), - activity.getString(R.string.color_pure_black), - activity.getString(R.string.color_blue), - activity.getString(R.string.color_green), - activity.getString(R.string.color_orange), - activity.getString(R.string.color_pink), - activity.getString(R.string.color_purple), - activity.getString(R.string.color_red) - ) - val themeResIds = intArrayOf( - R.style.Theme_Default_Gray, - R.style.Theme_Original, - R.style.Theme_Pure_Black, - R.style.Theme_Blue, - R.style.Theme_Green, - R.style.Theme_Orange, - R.style.Theme_Pink, - R.style.Theme_Purple, - R.style.Theme_Red - ) + val entries = buildList { + add(R.string.color_default_gray to R.style.Theme_Default_Gray) + add(R.string.color_original to R.style.Theme_Original) + add(R.string.color_pure_black to R.style.Theme_Pure_Black) + add(R.string.color_blue to R.style.Theme_Blue) + add(R.string.color_green to R.style.Theme_Green) + add(R.string.color_orange to R.style.Theme_Orange) + add(R.string.color_pink to R.style.Theme_Pink) + add(R.string.color_purple to R.style.Theme_Purple) + add(R.string.color_red to R.style.Theme_Red) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + add(R.string.color_dynamic to R.style.Theme_Dynamic) + } + } + val colors = Array(entries.size) { activity.getString(entries[it].first) } val prefs = getSharedPreferences(activity) val savedThemeId = prefs.getInt(KEY_THEME, R.style.Theme_Default_Gray) - val savedColorIndex = themeResIds.indexOf(savedThemeId) + val savedColorIndex = entries.indexOfFirst { it.second == savedThemeId } AlertDialog.Builder(activity) .setTitle(R.string.menu_color) .setSingleChoiceItems(colors, savedColorIndex) { dialog, which -> - saveTheme(activity, themeResIds[which]) + saveTheme(activity, entries[which].second) dialog.dismiss() activity.recreate() } diff --git a/app/src/main/res/values-v31/colors.xml b/app/src/main/res/values-v31/colors.xml new file mode 100644 index 00000000..97f40385 --- /dev/null +++ b/app/src/main/res/values-v31/colors.xml @@ -0,0 +1,5 @@ + + + @android:color/system_accent1_800 + @android:color/system_accent1_700 + diff --git a/app/src/main/res/values-v31/themes_custom.xml b/app/src/main/res/values-v31/themes_custom.xml new file mode 100644 index 00000000..12cd952a --- /dev/null +++ b/app/src/main/res/values-v31/themes_custom.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/values/strings_colors.xml b/app/src/main/res/values/strings_colors.xml index 3595f12c..a1316fe1 100644 --- a/app/src/main/res/values/strings_colors.xml +++ b/app/src/main/res/values/strings_colors.xml @@ -9,4 +9,5 @@ Pink Purple Red + Dynamic (Material You) \ No newline at end of file From 296db607370a01e9a85ea23dd17f997dc4e57f58 Mon Sep 17 00:00:00 2001 From: Kofhisho Date: Fri, 3 Jul 2026 03:30:09 +0100 Subject: [PATCH 03/27] feat: Add outdated patch level check --- .../attestation/AuthorizationList.java | 22 +++++++++++++++++++ .../keyattestation/home/HomeAdapter.kt | 8 +++++++ app/src/main/res/values/strings.xml | 2 ++ 3 files changed, 32 insertions(+) diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/AuthorizationList.java b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/AuthorizationList.java index 10b66de5..365bae5a 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/AuthorizationList.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/AuthorizationList.java @@ -19,6 +19,7 @@ import static com.google.common.base.Functions.forMap; import static com.google.common.collect.Collections2.transform; +import android.os.Build; import android.security.keystore.KeyProperties; import android.util.Log; @@ -711,6 +712,27 @@ public Integer getOsPatchLevel() { return osPatchLevel; } + public boolean isPatchLevelOutdated() { + try { + int device = Integer.parseInt( + Build.VERSION.SECURITY_PATCH.substring(0, 7).replace("-", "")); + return isOlder(osPatchLevel, device) + || isOlder(vendorPatchLevel, device) + || isOlder(bootPatchLevel, device); + } catch (RuntimeException e) { + return false; + } + } + + // Normalize YYYYMMDD to YYYYMM + private static int toMonth(int patchLevel) { + return patchLevel > 999999 ? patchLevel / 100 : patchLevel; + } + + private static boolean isOlder(Integer patchLevel, int device) { + return patchLevel != null && toMonth(patchLevel) < device; + } + public AttestationApplicationId getAttestationApplicationId() { return attestationApplicationId; } diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt index 6bb7dcae..92a787e3 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt +++ b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt @@ -145,6 +145,13 @@ class HomeAdapter(listener: Listener) : IdBasedRecyclerViewAdapter() { var id = ID_DESCRIPTION_START val attestation = attestationData.showAttestation ?: return + if (attestation.teeEnforced.isPatchLevelOutdated) { + addItemAt(2, HeaderViewHolder.CREATOR, HeaderData( + R.string.patch_level_outdated, + R.string.patch_level_outdated_summary, + R.drawable.ic_error_outline_24, + rikka.material.R.attr.colorWarning), ID_PATCH_STATUS) + } addItem(CommonItemViewHolder.SECURITY_LEVEL_CREATOR, SecurityLevelData( R.string.attestation, R.string.attestation_version_description, @@ -319,6 +326,7 @@ class HomeAdapter(listener: Listener) : IdBasedRecyclerViewAdapter() { private const val ID_ERROR = 0L private const val ID_CERT_STATUS = 1L private const val ID_BOOT_STATUS = 2L + private const val ID_PATCH_STATUS = 3L private const val ID_CERT_INFO_START = 1000L private const val ID_REVOCATION_INFO = 1900L private const val ID_RKP_HOSTNAME = 2000L diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3c0234e8..e03c6711 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -42,6 +42,8 @@ Knox attestation is signed using Samsung attestation key (SAK). OEM root certificate This device trusts this root certificate, but it may not be trusted by others. + Attestation patch level outdated + The attested patch level is older than the patch level of the running system. This is a common sign of tampering. Certificate chain Certificate chain is a list of certificates used to authenticate a key. The chain starts with the certificate associated with that key, and each certificate is signed by the next in the chain. The chain ends with a root certificate, and the trustworthiness of the attestation depends on the root certificate of the chain. From 1e32c1f2ee7f5e55cafe72835c19b79dc8511a1a Mon Sep 17 00:00:00 2001 From: Kofhisho Date: Fri, 3 Jul 2026 04:10:56 +0100 Subject: [PATCH 04/27] chore: Ignore kotlin compiler session files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f5ca0232..020d8110 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ .externalNativeBuild .cxx /*.jks +/.kotlin/sessions/kotlin-compiler-*.salive From c51d3d9656e0a22021c29c5ab01263dd43e4b201 Mon Sep 17 00:00:00 2001 From: Kofhisho Date: Fri, 3 Jul 2026 04:25:44 +0100 Subject: [PATCH 05/27] feat(RKP): Verify DICE chain signatures --- .../keyattestation/home/HomeAdapter.kt | 32 +++++- .../repository/RemoteProvisioningData.java | 97 +++++++++++++++++++ app/src/main/res/values/strings.xml | 6 ++ 3 files changed, 130 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt index 92a787e3..531305d3 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt +++ b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt @@ -87,11 +87,13 @@ class HomeAdapter(listener: Listener) : IdBasedRecyclerViewAdapter() { } var id = ID_CERT_INFO_START - addItem(SubtitleViewHolder.CREATOR, CommonData( - R.string.cert_chain, - R.string.cert_chain_description), id++) - baseData.certs.forEach { certInfo -> - addItem(CommonItemViewHolder.CERT_INFO_CREATOR, certInfo, id++) + if (baseData.certs.isNotEmpty()) { + addItem(SubtitleViewHolder.CREATOR, CommonData( + R.string.cert_chain, + R.string.cert_chain_description), id++) + baseData.certs.forEach { certInfo -> + addItem(CommonItemViewHolder.CERT_INFO_CREATOR, certInfo, id++) + } } // Add revocation list information with source status @@ -279,6 +281,25 @@ class HomeAdapter(listener: Listener) : IdBasedRecyclerViewAdapter() { } if (rkpData.diceChain.isNotEmpty()) { + val header = when { + !rkpData.isDiceChainValid -> HeaderData( + R.string.dice_chain_invalid, + R.string.dice_chain_invalid_summary, + R.drawable.ic_error_outline_24, + rikka.material.R.attr.colorAlert) + rkpData.isDiceDegenerate -> HeaderData( + R.string.dice_chain_degenerate, + R.string.dice_chain_degenerate_summary, + R.drawable.ic_error_outline_24, + rikka.material.R.attr.colorWarning) + else -> HeaderData( + R.string.dice_chain_valid, + R.string.dice_chain_valid_summary, + R.drawable.ic_trustworthy_24, + rikka.material.R.attr.colorSafe) + } + addItemAt(1, HeaderViewHolder.CREATOR, header, ID_DICE_STATUS) + id = ID_RKP_DICE_START addItem(SubtitleViewHolder.CREATOR, CommonData( R.string.rkp_dice_chain, @@ -327,6 +348,7 @@ class HomeAdapter(listener: Listener) : IdBasedRecyclerViewAdapter() { private const val ID_CERT_STATUS = 1L private const val ID_BOOT_STATUS = 2L private const val ID_PATCH_STATUS = 3L + private const val ID_DICE_STATUS = 4L private const val ID_CERT_INFO_START = 1000L private const val ID_REVOCATION_INFO = 1900L private const val ID_RKP_HOSTNAME = 2000L diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/repository/RemoteProvisioningData.java b/app/src/main/java/io/github/vvb2060/keyattestation/repository/RemoteProvisioningData.java index a8f5b0ca..c370e179 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/repository/RemoteProvisioningData.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/repository/RemoteProvisioningData.java @@ -8,13 +8,25 @@ import com.google.common.io.BaseEncoding; +import org.bouncycastle.asn1.x9.ECNamedCurveTable; +import org.bouncycastle.crypto.params.ECDomainParameters; +import org.bouncycastle.crypto.params.ECPublicKeyParameters; +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; +import org.bouncycastle.crypto.signers.ECDSASigner; +import org.bouncycastle.crypto.signers.Ed25519Signer; + +import java.io.ByteArrayOutputStream; +import java.math.BigInteger; +import java.security.MessageDigest; import java.security.cert.Certificate; import java.security.cert.X509Certificate; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.List; import co.nstant.in.cbor.CborDecoder; +import co.nstant.in.cbor.CborEncoder; import co.nstant.in.cbor.CborException; import co.nstant.in.cbor.model.Array; import co.nstant.in.cbor.model.ByteString; @@ -29,6 +41,7 @@ public class RemoteProvisioningData extends BaseData { private static final int SUB = 2; + private static final int SUBJECT_PUBLIC_KEY = -4670552; private static final int CONFIG_DESCRIPTOR = -4670548; private static final int MODE = -4670551; private static final int COMPONENT_NAME = -70002; @@ -39,6 +52,8 @@ public class RemoteProvisioningData extends BaseData { private final RpcHardwareInfo hardwareInfo; private final java.util.Map deviceInfo = new ArrayMap<>(); private final List> diceChain = new ArrayList<>(); + private boolean diceChainValid; + private boolean diceDegenerate; private Throwable error; public RemoteProvisioningData(String rkpHostname, RpcHardwareInfo hardwareInfo, @@ -68,6 +83,8 @@ public RemoteProvisioningData(String rkpHostname, RpcHardwareInfo hardwareInfo, private void parseDiceChain(byte[] data) throws CborException { var entries = ((Array) CborDecoder.decode(data).get(0)).getDataItems(); + diceDegenerate = entries.size() <= 2; + diceChainValid = verifyDiceChain(entries); for (var i = 1; i < entries.size(); i++) { var payloadData = (ByteString) ((Array) entries.get(i)).getDataItems().get(2); var payload = (Map) CborDecoder.decode(payloadData.getBytes()).get(0); @@ -109,6 +126,78 @@ private static String diceModeToString(int mode) { }; } + // Each entry is a COSE_Sign1 signed by the key of the previous layer (UDS_Pub for the first) + private static boolean verifyDiceChain(List entries) { + try { + var signingKey = (Map) entries.get(0); + for (var i = 1; i < entries.size(); i++) { + var entry = ((Array) entries.get(i)).getDataItems(); + var protectedHeader = ((ByteString) entry.get(0)).getBytes(); + var payloadData = ((ByteString) entry.get(2)).getBytes(); + var signature = ((ByteString) entry.get(3)).getBytes(); + var sigStructure = encodeCbor(new Array() + .add(new UnicodeString("Signature1")) + .add(new ByteString(protectedHeader)) + .add(new ByteString(new byte[0])) + .add(new ByteString(payloadData))); + if (!verifyCoseSign1(signingKey, sigStructure, signature)) { + return false; + } + var payload = (Map) CborDecoder.decode(payloadData).get(0); + if (payload.get(new NegativeInteger(SUBJECT_PUBLIC_KEY)) instanceof ByteString key) { + signingKey = (Map) CborDecoder.decode(key.getBytes()).get(0); + } + } + return true; + } catch (Exception e) { + Log.w(AppApplication.TAG, "Verify dice chain error.", e); + return false; + } + } + + private static boolean verifyCoseSign1(Map coseKey, byte[] message, byte[] signature) + throws Exception { + var keyType = ((Number) coseKey.get(new UnsignedInteger(1))).getValue().intValue(); + var curve = ((Number) coseKey.get(new NegativeInteger(-1))).getValue().intValue(); + var x = ((ByteString) coseKey.get(new NegativeInteger(-2))).getBytes(); + if (keyType == 1) { + var signer = new Ed25519Signer(); + signer.init(false, new Ed25519PublicKeyParameters(x, 0)); + signer.update(message, 0, message.length); + return signer.verifySignature(signature); + } else if (keyType == 2) { + var y = ((ByteString) coseKey.get(new NegativeInteger(-3))).getBytes(); + var name = switch (curve) { + case 2 -> "P-384"; + case 3 -> "P-521"; + default -> "P-256"; + }; + var hash = switch (curve) { + case 2 -> "SHA-384"; + case 3 -> "SHA-512"; + default -> "SHA-256"; + }; + var params = ECNamedCurveTable.getByName(name); + var domain = new ECDomainParameters( + params.getCurve(), params.getG(), params.getN(), params.getH()); + var point = params.getCurve().createPoint(new BigInteger(1, x), new BigInteger(1, y)); + var digest = MessageDigest.getInstance(hash).digest(message); + var signer = new ECDSASigner(); + signer.init(false, new ECPublicKeyParameters(point, domain)); + var half = signature.length / 2; + var r = new BigInteger(1, Arrays.copyOfRange(signature, 0, half)); + var s = new BigInteger(1, Arrays.copyOfRange(signature, half, signature.length)); + return signer.verifySignature(digest, r, s); + } + return false; + } + + private static byte[] encodeCbor(DataItem item) throws CborException { + var buf = new ByteArrayOutputStream(); + new CborEncoder(buf).encode(item); + return buf.toByteArray(); + } + @SuppressWarnings("unchecked") public void setCerts(Collection data) { var infoList = new ArrayList(data.size()); @@ -137,6 +226,14 @@ public List> getDiceChain() { return diceChain; } + public boolean isDiceChainValid() { + return diceChainValid; + } + + public boolean isDiceDegenerate() { + return diceDegenerate; + } + public Throwable getError() { return error; } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e03c6711..4c7ae715 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -139,6 +139,12 @@ Each entry measures the code and configuration of a boot stage and is signed by a key derived by the previous stage. A degenerate chain contains only a single self-signed entry and carries no boot measurements. + DICE chain verified + Every entry in the DICE chain is correctly signed by the key of the previous boot stage. + Degenerate DICE chain + The DICE chain is a single self-signed entry. Its signature is valid but it carries no boot stage measurements. + DICE chain signature invalid + A signature in the DICE chain failed to verify against the previous stage key. The chain may be forged or corrupt. Remote provisioning hostname Remote key provisioning disabled From 251fcf0e688191a8f01d628dc3d3ef1e91cc99c7 Mon Sep 17 00:00:00 2001 From: Kofhisho Date: Fri, 3 Jul 2026 11:56:21 +0100 Subject: [PATCH 06/27] docs: P-384 KeyMint root cert rotation --- .../vvb2060/keyattestation/attestation/RootPublicKey.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/RootPublicKey.java b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/RootPublicKey.java index 2661b2af..6eb96495 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/RootPublicKey.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/RootPublicKey.java @@ -40,6 +40,9 @@ public enum Status { ixPvZtXQpUpuL12ab+9EaDK8Z4RHJYYfCT3Q5vNAXaiWQ+8PTWm2QgBR/bkwSWc+\ NpUFgNPN9PvQi8WEg5UmAGMCAwEAAQ=="""; + // New P-384 KeyMint attestation root (CN=Key Attestation CA1) + // RKP devices use it exclusively + // https://developer.android.com/privacy-and-security/security-key-attestation#root_certificate_rotation private static final String GOOGLE_RKP_ROOT_PUBLIC_KEY = """ MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEI9ojcU7fPlsFCjxy6IRqzgeOoK0b+YsV\ 9FPQywiyw8EQRTkJ9u3qwfnI4DGoSLlBqClTXJfgfCcZvs60FikNMHnu4fkRzObf\ From 7ff496b99f16b92552f6af18b3dbaa2debdfd2de Mon Sep 17 00:00:00 2001 From: Kofhisho Date: Fri, 3 Jul 2026 14:44:38 +0100 Subject: [PATCH 07/27] feat: Increase network timeout --- .../vvb2060/keyattestation/attestation/RevocationList.java | 4 ++-- .../vvb2060/keyattestation/keystore/RemoteProvisioning.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/RevocationList.java b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/RevocationList.java index 80aeb42e..5b9ddc74 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/RevocationList.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/RevocationList.java @@ -95,8 +95,8 @@ private static NetworkResult fetchFromNetwork(String statusUrl, long cachedTime) URL url = new URL(statusUrl); connection = (HttpURLConnection) url.openConnection(); connection.setRequestMethod("GET"); - connection.setConnectTimeout(3000); - connection.setReadTimeout(3000); + connection.setConnectTimeout(10_000); + connection.setReadTimeout(20_000); connection.setRequestProperty("User-Agent", "KeyAttestation"); double rand = Math.round(Math.random() * 1000.0) / 1000.0; diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/keystore/RemoteProvisioning.java b/app/src/main/java/io/github/vvb2060/keyattestation/keystore/RemoteProvisioning.java index 3009de81..9b3eefa3 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/keystore/RemoteProvisioning.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/keystore/RemoteProvisioning.java @@ -212,7 +212,7 @@ private DataItem httpPost(Uri uri, byte[] input) throws IOException, CborExcepti uri = uri.buildUpon().appendQueryParameter("requestId", requestId).build(); var con = (HttpsURLConnection) new URL(uri.toString()).openConnection(); con.setRequestMethod("POST"); - con.setConnectTimeout(2_000); + con.setConnectTimeout(10_000); con.setReadTimeout(20_000); con.setDoOutput(true); con.setFixedLengthStreamingMode(input.length); From fbc7389fb0b891c8f2b900267109854438ce49c9 Mon Sep 17 00:00:00 2001 From: Kofhisho Date: Fri, 3 Jul 2026 14:45:27 +0100 Subject: [PATCH 08/27] fix: Attestation display --- .../vvb2060/keyattestation/attestation/RootOfTrust.java | 9 ++++++--- .../vvb2060/keyattestation/home/CommonItemViewHolder.kt | 6 +++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/RootOfTrust.java b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/RootOfTrust.java index 43331f58..2f09d935 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/RootOfTrust.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/RootOfTrust.java @@ -50,9 +50,12 @@ public RootOfTrust(ASN1Encodable asn1Encodable) throws CertificateParsingExcepti deviceLocked = Asn1Utils.getBooleanFromAsn1(sequence.getObjectAt(DEVICE_LOCKED_INDEX)); verifiedBootState = Asn1Utils.getIntegerFromAsn1(sequence.getObjectAt(VERIFIED_BOOT_STATE_INDEX)); - if (sequence.size() == 3) verifiedBootHash = null; - else verifiedBootHash = - Asn1Utils.getByteArrayFromAsn1(sequence.getObjectAt(VERIFIED_BOOT_HASH_INDEX)); + if (sequence.size() == 3) { + verifiedBootHash = null; + } else { + var hash = Asn1Utils.getByteArrayFromAsn1(sequence.getObjectAt(VERIFIED_BOOT_HASH_INDEX)); + verifiedBootHash = hash.length == 0 ? null : hash; + } } RootOfTrust(byte[] verifiedBootKey, boolean deviceLocked, diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/home/CommonItemViewHolder.kt b/app/src/main/java/io/github/vvb2060/keyattestation/home/CommonItemViewHolder.kt index c0bff9b7..417f9fb1 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/home/CommonItemViewHolder.kt +++ b/app/src/main/java/io/github/vvb2060/keyattestation/home/CommonItemViewHolder.kt @@ -37,7 +37,11 @@ open class CommonItemViewHolder(itemView: View, binding: HomeCommonItemBindin override fun onBind() { binding.title.text = data.first - binding.summary.text = data.second + if (data.second.isEmpty()) { + binding.summary.setText(R.string.empty) + } else { + binding.summary.text = data.second + } } } } From 54ea4a5102f248707b5f4d3d344ac250ef1eefd1 Mon Sep 17 00:00:00 2001 From: Kofhisho Date: Fri, 3 Jul 2026 15:09:06 +0100 Subject: [PATCH 09/27] feat: Attest vbmeta digest against boot hash --- .../keyattestation/keystore/IAndroidKeyStore.aidl | 1 + .../vvb2060/keyattestation/home/HomeAdapter.kt | 10 +++++++++- .../keyattestation/keystore/AndroidKeyStore.java | 5 +++++ .../keyattestation/repository/AttestationData.java | 14 ++++++++++++++ .../repository/AttestationRepository.java | 9 ++++++++- app/src/main/res/values/strings.xml | 2 ++ 6 files changed, 39 insertions(+), 2 deletions(-) diff --git a/app/src/main/aidl/io/github/vvb2060/keyattestation/keystore/IAndroidKeyStore.aidl b/app/src/main/aidl/io/github/vvb2060/keyattestation/keystore/IAndroidKeyStore.aidl index aea52006..790e01a6 100644 --- a/app/src/main/aidl/io/github/vvb2060/keyattestation/keystore/IAndroidKeyStore.aidl +++ b/app/src/main/aidl/io/github/vvb2060/keyattestation/keystore/IAndroidKeyStore.aidl @@ -19,4 +19,5 @@ interface IAndroidKeyStore { RpcHardwareInfo getHardwareInfo(boolean useStrongBox, out DeviceInfo deviceInfo); byte[] getDiceChain(boolean useStrongBox); byte[] checkRemoteProvisioning(boolean useStrongBox); + String getVbmetaDigest(); } diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt index 531305d3..63eda165 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt +++ b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt @@ -154,6 +154,13 @@ class HomeAdapter(listener: Listener) : IdBasedRecyclerViewAdapter() { R.drawable.ic_error_outline_24, rikka.material.R.attr.colorWarning), ID_PATCH_STATUS) } + if (attestationData.isBootHashMismatch) { + addItemAt(2, HeaderViewHolder.CREATOR, HeaderData( + R.string.vbmeta_mismatch, + R.string.vbmeta_mismatch_summary, + R.drawable.ic_error_outline_24, + rikka.material.R.attr.colorAlert), ID_VBMETA_STATUS) + } addItem(CommonItemViewHolder.SECURITY_LEVEL_CREATOR, SecurityLevelData( R.string.attestation, R.string.attestation_version_description, @@ -348,7 +355,8 @@ class HomeAdapter(listener: Listener) : IdBasedRecyclerViewAdapter() { private const val ID_CERT_STATUS = 1L private const val ID_BOOT_STATUS = 2L private const val ID_PATCH_STATUS = 3L - private const val ID_DICE_STATUS = 4L + private const val ID_VBMETA_STATUS = 4L + private const val ID_DICE_STATUS = 5L private const val ID_CERT_INFO_START = 1000L private const val ID_REVOCATION_INFO = 1900L private const val ID_RKP_HOSTNAME = 2000L diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/keystore/AndroidKeyStore.java b/app/src/main/java/io/github/vvb2060/keyattestation/keystore/AndroidKeyStore.java index 6f4e1d0e..8c419d34 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/keystore/AndroidKeyStore.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/keystore/AndroidKeyStore.java @@ -373,6 +373,11 @@ public RpcHardwareInfo getHardwareInfo(boolean useStrongBox, DeviceInfo deviceIn } } + @Override + public String getVbmetaDigest() { + return SystemProperties.get("ro.boot.vbmeta.digest"); + } + @Override public byte[] getDiceChain(boolean useStrongBox) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationData.java b/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationData.java index 17b4cc1e..1302ae10 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationData.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationData.java @@ -3,6 +3,8 @@ import static io.github.vvb2060.keyattestation.attestation.Attestation.KM_SECURITY_LEVEL_SOFTWARE; import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_CANT_PARSE_CERT; +import com.google.common.io.BaseEncoding; + import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.LinkedList; @@ -17,6 +19,7 @@ public class AttestationData extends BaseData { private final RootOfTrust rootOfTrust; private final boolean sw; public Attestation showAttestation; + public String vbmetaDigest; public RootOfTrust getRootOfTrust() { return rootOfTrust; @@ -26,6 +29,17 @@ public boolean isSoftwareLevel() { return sw; } + public boolean isBootHashMismatch() { + if (vbmetaDigest == null || vbmetaDigest.isEmpty() || rootOfTrust == null) { + return false; + } + var hash = rootOfTrust.getVerifiedBootHash(); + if (hash == null) { + return false; + } + return !BaseEncoding.base16().lowerCase().encode(hash).equalsIgnoreCase(vbmetaDigest); + } + private AttestationData(List certs) { init(certs); diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationRepository.java b/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationRepository.java index b237f22f..181e9357 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationRepository.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationRepository.java @@ -191,7 +191,14 @@ public Resource attest(boolean reset, boolean useAttestKey, if (reset) keyStore.deleteAllEntry(); doAttestation(useAttestKey, useStrongBox, includeProps, uniqueIdIncluded, idFlags, keyStoreKeyType, useSak); - return Resource.Companion.success(AttestationData.parseCertificateChain(currentCerts)); + var data = AttestationData.parseCertificateChain(currentCerts); + try { + data.vbmetaDigest = keyStore.getVbmetaDigest(); + } catch (Exception e) { + var cause = e instanceof AttestationException ? e.getCause() : e; + Log.w(AppApplication.TAG, "Get vbmeta digest error.", cause); + } + return Resource.Companion.success(data); } catch (Exception e) { var cause = e instanceof AttestationException ? e.getCause() : e; Log.w(AppApplication.TAG, "Do attestation error.", cause); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4c7ae715..52f8c361 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -44,6 +44,8 @@ This device trusts this root certificate, but it may not be trusted by others. Attestation patch level outdated The attested patch level is older than the patch level of the running system. This is a common sign of tampering. + Verified boot hash mismatch + The attested verified boot hash does not match the running system\'s vbmeta digest. This is a strong sign of a spoofed or replayed attestation. Certificate chain Certificate chain is a list of certificates used to authenticate a key. The chain starts with the certificate associated with that key, and each certificate is signed by the next in the chain. The chain ends with a root certificate, and the trustworthiness of the attestation depends on the root certificate of the chain. From ac895cc2531587b4c29debfcb247d6055da60762 Mon Sep 17 00:00:00 2001 From: Kofhisho Date: Fri, 3 Jul 2026 15:28:38 +0100 Subject: [PATCH 10/27] feat: Check for missing vbmeta digest --- .../io/github/vvb2060/keyattestation/home/HomeAdapter.kt | 8 +++++++- .../keyattestation/repository/AttestationData.java | 7 +++++++ app/src/main/res/values/strings.xml | 2 ++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt index 63eda165..125b4e2e 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt +++ b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt @@ -154,7 +154,13 @@ class HomeAdapter(listener: Listener) : IdBasedRecyclerViewAdapter() { R.drawable.ic_error_outline_24, rikka.material.R.attr.colorWarning), ID_PATCH_STATUS) } - if (attestationData.isBootHashMismatch) { + if (attestationData.isVbmetaDigestMissing) { + addItemAt(2, HeaderViewHolder.CREATOR, HeaderData( + R.string.vbmeta_missing, + R.string.vbmeta_missing_summary, + R.drawable.ic_error_outline_24, + rikka.material.R.attr.colorWarning), ID_VBMETA_STATUS) + } else if (attestationData.isBootHashMismatch) { addItemAt(2, HeaderViewHolder.CREATOR, HeaderData( R.string.vbmeta_mismatch, R.string.vbmeta_mismatch_summary, diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationData.java b/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationData.java index 1302ae10..8eae3b13 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationData.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationData.java @@ -40,6 +40,13 @@ public boolean isBootHashMismatch() { return !BaseEncoding.base16().lowerCase().encode(hash).equalsIgnoreCase(vbmetaDigest); } + public boolean isVbmetaDigestMissing() { + if (vbmetaDigest == null || rootOfTrust == null) { + return false; + } + return vbmetaDigest.isEmpty() || vbmetaDigest.chars().allMatch(chr -> chr == '0'); + } + private AttestationData(List certs) { init(certs); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 52f8c361..9d7eba8d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -46,6 +46,8 @@ The attested patch level is older than the patch level of the running system. This is a common sign of tampering. Verified boot hash mismatch The attested verified boot hash does not match the running system\'s vbmeta digest. This is a strong sign of a spoofed or replayed attestation. + Verified boot hash missing + The device reports an empty or all-zero vbmeta digest, so the boot state was never attested. Certificate chain Certificate chain is a list of certificates used to authenticate a key. The chain starts with the certificate associated with that key, and each certificate is signed by the next in the chain. The chain ends with a root certificate, and the trustworthiness of the attestation depends on the root certificate of the chain. From 275a5f6534762838ee11b1ee896bc3c1896315e0 Mon Sep 17 00:00:00 2001 From: Kofhisho Date: Fri, 3 Jul 2026 16:18:43 +0100 Subject: [PATCH 11/27] feat: Check for software key --- .../github/vvb2060/keyattestation/home/HomeAdapter.kt | 10 +++++++++- app/src/main/res/values/strings.xml | 2 ++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt index 125b4e2e..75a7dea6 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt +++ b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt @@ -147,6 +147,13 @@ class HomeAdapter(listener: Listener) : IdBasedRecyclerViewAdapter() { var id = ID_DESCRIPTION_START val attestation = attestationData.showAttestation ?: return + if (!attestationData.isSoftwareLevel) { + addItemAt(2, HeaderViewHolder.CREATOR, HeaderData( + R.string.software_attestation, + R.string.software_attestation_summary, + R.drawable.ic_error_outline_24, + rikka.material.R.attr.colorWarning), ID_SOFTWARE_STATUS) + } if (attestation.teeEnforced.isPatchLevelOutdated) { addItemAt(2, HeaderViewHolder.CREATOR, HeaderData( R.string.patch_level_outdated, @@ -362,7 +369,8 @@ class HomeAdapter(listener: Listener) : IdBasedRecyclerViewAdapter() { private const val ID_BOOT_STATUS = 2L private const val ID_PATCH_STATUS = 3L private const val ID_VBMETA_STATUS = 4L - private const val ID_DICE_STATUS = 5L + private const val ID_SOFTWARE_STATUS = 5L + private const val ID_DICE_STATUS = 6L private const val ID_CERT_INFO_START = 1000L private const val ID_REVOCATION_INFO = 1900L private const val ID_RKP_HOSTNAME = 2000L diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9d7eba8d..33a5ab0c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -48,6 +48,8 @@ The attested verified boot hash does not match the running system\'s vbmeta digest. This is a strong sign of a spoofed or replayed attestation. Verified boot hash missing The device reports an empty or all-zero vbmeta digest, so the boot state was never attested. + Software attestation + This key is not hardware-backed. The attestation was produced in software with no hardware root of trust. Certificate chain Certificate chain is a list of certificates used to authenticate a key. The chain starts with the certificate associated with that key, and each certificate is signed by the next in the chain. The chain ends with a root certificate, and the trustworthiness of the attestation depends on the root certificate of the chain. From 539dde441fc55775f0ff244c8e6d2dfabe9d35cf Mon Sep 17 00:00:00 2001 From: Kofhisho Date: Fri, 3 Jul 2026 16:20:18 +0100 Subject: [PATCH 12/27] fix(SoftwareAttestation): Forgot to flip check... whoops! --- .../java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt index 75a7dea6..a4239977 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt +++ b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt @@ -147,7 +147,7 @@ class HomeAdapter(listener: Listener) : IdBasedRecyclerViewAdapter() { var id = ID_DESCRIPTION_START val attestation = attestationData.showAttestation ?: return - if (!attestationData.isSoftwareLevel) { + if (attestationData.isSoftwareLevel) { addItemAt(2, HeaderViewHolder.CREATOR, HeaderData( R.string.software_attestation, R.string.software_attestation_summary, From 791df9168b4b02efba0a6692971de8a76ca27b86 Mon Sep 17 00:00:00 2001 From: Kofhisho Date: Mon, 6 Jul 2026 11:33:15 +0100 Subject: [PATCH 13/27] feat: Check for all zero boot key --- .../keyattestation/attestation/RootOfTrust.java | 14 ++++++++++++++ .../vvb2060/keyattestation/home/HomeAdapter.kt | 8 ++++++++ app/src/main/res/values/strings.xml | 2 ++ 3 files changed, 24 insertions(+) diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/RootOfTrust.java b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/RootOfTrust.java index 2f09d935..87467d12 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/RootOfTrust.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/RootOfTrust.java @@ -97,6 +97,20 @@ public byte[] getVerifiedBootHash() { return verifiedBootHash; } + private static boolean isAllZero(byte[] data) { + for (var b : data) { + if (b != 0) return false; + } + return true; + } + + public boolean isVerifiedBootKeySuspicious() { + if (verifiedBootKey == null || !isAllZero(verifiedBootKey)) { + return false; + } + return deviceLocked || verifiedBootState == KM_VERIFIED_BOOT_VERIFIED; + } + @Override public String toString() { StringBuilder sb = new StringBuilder() diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt index a4239977..1b28d502 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt +++ b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt @@ -154,6 +154,13 @@ class HomeAdapter(listener: Listener) : IdBasedRecyclerViewAdapter() { R.drawable.ic_error_outline_24, rikka.material.R.attr.colorWarning), ID_SOFTWARE_STATUS) } + if (attestationData.rootOfTrust?.isVerifiedBootKeySuspicious == true) { + addItemAt(2, HeaderViewHolder.CREATOR, HeaderData( + R.string.verified_boot_key_suspicious, + R.string.verified_boot_key_suspicious_summary, + R.drawable.ic_error_outline_24, + rikka.material.R.attr.colorAlert), ID_BOOT_KEY_STATUS) + } if (attestation.teeEnforced.isPatchLevelOutdated) { addItemAt(2, HeaderViewHolder.CREATOR, HeaderData( R.string.patch_level_outdated, @@ -371,6 +378,7 @@ class HomeAdapter(listener: Listener) : IdBasedRecyclerViewAdapter() { private const val ID_VBMETA_STATUS = 4L private const val ID_SOFTWARE_STATUS = 5L private const val ID_DICE_STATUS = 6L + private const val ID_BOOT_KEY_STATUS = 7L private const val ID_CERT_INFO_START = 1000L private const val ID_REVOCATION_INFO = 1900L private const val ID_RKP_HOSTNAME = 2000L diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 33a5ab0c..3b3ed344 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -50,6 +50,8 @@ The device reports an empty or all-zero vbmeta digest, so the boot state was never attested. Software attestation This key is not hardware-backed. The attestation was produced in software with no hardware root of trust. + Verified boot key is invalid + The verified boot key is only zeros, which is only expected on an unlocked or unverified bootloader. This is a strong sign of tampering. Certificate chain Certificate chain is a list of certificates used to authenticate a key. The chain starts with the certificate associated with that key, and each certificate is signed by the next in the chain. The chain ends with a root certificate, and the trustworthiness of the attestation depends on the root certificate of the chain. From a51fef89200fa4940a92619d1f8ccd9274b7ca84 Mon Sep 17 00:00:00 2001 From: Kofhisho Date: Mon, 6 Jul 2026 11:45:41 +0100 Subject: [PATCH 14/27] style: Better osVersion display --- .../keyattestation/attestation/AuthorizationList.java | 7 +++++++ .../io/github/vvb2060/keyattestation/home/HomeAdapter.kt | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/AuthorizationList.java b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/AuthorizationList.java index 365bae5a..ce0e01f9 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/AuthorizationList.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/AuthorizationList.java @@ -592,6 +592,13 @@ public static String ecCurveAsString(Integer ecCurve) { }; } + public static String osVersionToString(int osVersion) { + int major = osVersion / 10000; + int minor = (osVersion / 100) % 100; + int subMinor = osVersion % 100; + return "Android " + major + "." + minor + "." + subMinor + " (" + osVersion + ")"; + } + public Integer getSecurityLevel() { return securityLevel; } diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt index 1b28d502..24cb4f9b 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt +++ b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt @@ -417,7 +417,7 @@ class HomeAdapter(listener: Listener) : IdBasedRecyclerViewAdapter() { list.origin?.let { AuthorizationList.originToString(it) }, list.rollbackResistant?.toString(), list.rootOfTrust?.toString(), - list.osVersion?.toString(), + list.osVersion?.let { AuthorizationList.osVersionToString(it) }, list.osPatchLevel?.toString(), list.attestationApplicationId?.toString()?.trim(), list.brand, From dca4d5814ca90c3c4e0476c364d2efb9ab21260f Mon Sep 17 00:00:00 2001 From: Kofhisho Date: Mon, 6 Jul 2026 11:57:27 +0100 Subject: [PATCH 15/27] feat(Attestation): Format patch level fields as dates --- .../attestation/AuthorizationList.java | 21 +++++++++++++++++++ .../vvb2060/keyattestation/home/Data.kt | 3 ++- .../keyattestation/home/HeaderViewHolder.kt | 2 +- .../keyattestation/home/HomeAdapter.kt | 11 ++++++---- app/src/main/res/values/strings.xml | 2 +- 5 files changed, 32 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/AuthorizationList.java b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/AuthorizationList.java index ce0e01f9..321e167c 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/AuthorizationList.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/AuthorizationList.java @@ -599,6 +599,15 @@ public static String osVersionToString(int osVersion) { return "Android " + major + "." + minor + "." + subMinor + " (" + osVersion + ")"; } + public static String patchLevelToString(int patchLevel) { + var s = Integer.toString(patchLevel); + return switch (s.length()) { + case 6 -> s.substring(0, 4) + "-" + s.substring(4, 6); + case 8 -> s.substring(0, 4) + "-" + s.substring(4, 6) + "-" + s.substring(6, 8); + default -> s; + }; + } + public Integer getSecurityLevel() { return securityLevel; } @@ -740,6 +749,18 @@ private static boolean isOlder(Integer patchLevel, int device) { return patchLevel != null && toMonth(patchLevel) < device; } + public String getOldestPatchLevel() { + Integer oldest = null; + for (var patchLevel : new Integer[]{osPatchLevel, vendorPatchLevel, bootPatchLevel}) { + if (patchLevel == null) continue; + int month = toMonth(patchLevel); + if (oldest == null || month < oldest) oldest = month; + } + if (oldest == null) return null; + var s = oldest.toString(); + return s.length() == 6 ? s.substring(0, 4) + "-" + s.substring(4) : s; + } + public AttestationApplicationId getAttestationApplicationId() { return attestationApplicationId; } diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/home/Data.kt b/app/src/main/java/io/github/vvb2060/keyattestation/home/Data.kt index 8696b27b..f94e8e9a 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/home/Data.kt +++ b/app/src/main/java/io/github/vvb2060/keyattestation/home/Data.kt @@ -30,7 +30,8 @@ class HeaderData( override val title: Int, override val description: Int, val icon: Int, - val color: Int + val color: Int, + vararg val formatArgs: Any ) : Data() class AuthorizationItemData( diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/home/HeaderViewHolder.kt b/app/src/main/java/io/github/vvb2060/keyattestation/home/HeaderViewHolder.kt index b48ed86f..a2a17ec4 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/home/HeaderViewHolder.kt +++ b/app/src/main/java/io/github/vvb2060/keyattestation/home/HeaderViewHolder.kt @@ -23,7 +23,7 @@ class HeaderViewHolder(itemView: View, binding: HomeHeaderBinding) : HomeViewHol icon.setImageDrawable(context.getDrawable(data.icon)) title.setText(data.title) if (data.description != 0) { - summary.setText(data.description) + summary.text = context.getString(data.description, *data.formatArgs) summary.isVisible = true } else { summary.isVisible = false diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt index 24cb4f9b..9062e56e 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt +++ b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt @@ -1,5 +1,6 @@ package io.github.vvb2060.keyattestation.home +import android.os.Build import android.util.Base64 import android.util.Pair import com.google.common.io.BaseEncoding @@ -166,7 +167,9 @@ class HomeAdapter(listener: Listener) : IdBasedRecyclerViewAdapter() { R.string.patch_level_outdated, R.string.patch_level_outdated_summary, R.drawable.ic_error_outline_24, - rikka.material.R.attr.colorWarning), ID_PATCH_STATUS) + rikka.material.R.attr.colorWarning, + attestation.teeEnforced.oldestPatchLevel, + Build.VERSION.SECURITY_PATCH.take(7)), ID_PATCH_STATUS) } if (attestationData.isVbmetaDigestMissing) { addItemAt(2, HeaderViewHolder.CREATOR, HeaderData( @@ -418,7 +421,7 @@ class HomeAdapter(listener: Listener) : IdBasedRecyclerViewAdapter() { list.rollbackResistant?.toString(), list.rootOfTrust?.toString(), list.osVersion?.let { AuthorizationList.osVersionToString(it) }, - list.osPatchLevel?.toString(), + list.osPatchLevel?.let { AuthorizationList.patchLevelToString(it) }, list.attestationApplicationId?.toString()?.trim(), list.brand, list.device, @@ -429,8 +432,8 @@ class HomeAdapter(listener: Listener) : IdBasedRecyclerViewAdapter() { list.meid, list.manufacturer, list.model, - list.vendorPatchLevel?.toString(), - list.bootPatchLevel?.toString(), + list.vendorPatchLevel?.let { AuthorizationList.patchLevelToString(it) }, + list.bootPatchLevel?.let { AuthorizationList.patchLevelToString(it) }, list.deviceUniqueAttestation?.toString(), list.identityCredentialKey?.toString(), list.moduleHash?.let { BaseEncoding.base16().lowerCase().encode(it) }, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3b3ed344..da54455b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -43,7 +43,7 @@ OEM root certificate This device trusts this root certificate, but it may not be trusted by others. Attestation patch level outdated - The attested patch level is older than the patch level of the running system. This is a common sign of tampering. + The attested patch level is older than the patch level of the running system. This is a common sign of tampering. (attestation: %1$s, system: %2$s) Verified boot hash mismatch The attested verified boot hash does not match the running system\'s vbmeta digest. This is a strong sign of a spoofed or replayed attestation. Verified boot hash missing From 1424b45d533e5ec71f19630e0980a956c78fc082 Mon Sep 17 00:00:00 2001 From: Kofhisho Date: Mon, 6 Jul 2026 11:59:50 +0100 Subject: [PATCH 16/27] chore: Optimized imports --- .../attestation/AuthorizationList.java | 1 - .../keyattestation/attestation/CborUtils.java | 17 +++----- .../attestation/EatAttestation.java | 13 +++--- .../keyattestation/attestation/EatClaim.java | 43 ++++++++++++++++++- .../home/BootStateViewHolder.kt | 2 +- .../home/CommonItemViewHolder.kt | 1 - .../keyattestation/home/HeaderViewHolder.kt | 1 - .../keyattestation/home/HomeFragment.kt | 2 +- .../keystore/AndroidKeyStore.java | 1 - .../repository/AttestationRepository.java | 11 ++++- 10 files changed, 67 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/AuthorizationList.java b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/AuthorizationList.java index 321e167c..31c2786e 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/AuthorizationList.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/AuthorizationList.java @@ -33,7 +33,6 @@ import org.bouncycastle.asn1.ASN1TaggedObject; import java.security.cert.CertificateParsingException; -import java.text.DateFormat; import java.util.Collection; import java.util.Date; import java.util.List; diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/CborUtils.java b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/CborUtils.java index 83867805..36aa4e3e 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/CborUtils.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/CborUtils.java @@ -16,8 +16,14 @@ package io.github.vvb2060.keyattestation.attestation; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + import co.nstant.in.cbor.CborDecoder; -import co.nstant.in.cbor.CborEncoder; import co.nstant.in.cbor.CborException; import co.nstant.in.cbor.model.Array; import co.nstant.in.cbor.model.ByteString; @@ -30,15 +36,6 @@ import co.nstant.in.cbor.model.UnicodeString; import co.nstant.in.cbor.model.UnsignedInteger; -import java.io.ByteArrayOutputStream; -import java.nio.charset.StandardCharsets; - -import java.util.ArrayList; -import java.util.Date; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - class CborUtils { public static Number toNumber(long l) { diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/EatAttestation.java b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/EatAttestation.java index 7ec68ebd..e2bffd4e 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/EatAttestation.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/EatAttestation.java @@ -18,13 +18,6 @@ import android.util.Log; -import co.nstant.in.cbor.CborDecoder; -import co.nstant.in.cbor.CborException; -import co.nstant.in.cbor.model.DataItem; -import co.nstant.in.cbor.model.Map; -import co.nstant.in.cbor.model.Number; -import co.nstant.in.cbor.model.UnicodeString; - import org.bouncycastle.asn1.ASN1Encodable; import java.security.cert.CertificateParsingException; @@ -32,6 +25,12 @@ import java.util.Arrays; import java.util.List; +import co.nstant.in.cbor.CborException; +import co.nstant.in.cbor.model.DataItem; +import co.nstant.in.cbor.model.Map; +import co.nstant.in.cbor.model.Number; +import co.nstant.in.cbor.model.UnicodeString; + public class EatAttestation extends Attestation { static final String TAG = "EatAttestation"; final Map extension; diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/EatClaim.java b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/EatClaim.java index a6977e7a..e9cdaa5b 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/EatClaim.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/EatClaim.java @@ -1,6 +1,47 @@ package io.github.vvb2060.keyattestation.attestation; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.*; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KEYMASTER_TAG_TYPE_MASK; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ACTIVE_DATETIME; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ALGORITHM; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ALLOW_WHILE_ON_BODY; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_APPLICATION_ID; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ATTESTATION_APPLICATION_ID; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ATTESTATION_ID_BRAND; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ATTESTATION_ID_DEVICE; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ATTESTATION_ID_MANUFACTURER; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ATTESTATION_ID_MEID; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ATTESTATION_ID_MODEL; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ATTESTATION_ID_PRODUCT; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ATTESTATION_ID_SERIAL; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_AUTH_TIMEOUT; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_BLOCK_MODE; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_BOOT_PATCHLEVEL; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_CALLER_NONCE; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_DEVICE_UNIQUE_ATTESTATION; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_DIGEST; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_EARLY_BOOT_ONLY; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_EC_CURVE; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_IDENTITY_CREDENTIAL_KEY; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_KDF; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_KEY_SIZE; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_MIN_MAC_LENGTH; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_NO_AUTH_REQUIRED; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ORIGIN; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ORIGINATION_EXPIRE_DATETIME; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_OS_PATCHLEVEL; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_OS_VERSION; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_PADDING; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_PURPOSE; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ROLLBACK_RESISTANCE; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ROLLBACK_RESISTANT; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_RSA_OAEP_MGF_DIGEST; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_RSA_PUBLIC_EXPONENT; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_TRUSTED_CONFIRMATION_REQUIRED; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_TRUSTED_USER_PRESENCE_REQUIRED; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_UNLOCKED_DEVICE_REQUIRED; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_USAGE_EXPIRE_DATETIME; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_USER_AUTH_TYPE; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_VENDOR_PATCHLEVEL; class EatClaim { public static final int IAT = 6; diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/home/BootStateViewHolder.kt b/app/src/main/java/io/github/vvb2060/keyattestation/home/BootStateViewHolder.kt index 5b9203d6..ff6f0cd1 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/home/BootStateViewHolder.kt +++ b/app/src/main/java/io/github/vvb2060/keyattestation/home/BootStateViewHolder.kt @@ -4,9 +4,9 @@ import android.content.res.ColorStateList import android.view.View import androidx.core.view.isVisible import io.github.vvb2060.keyattestation.R -import io.github.vvb2060.keyattestation.repository.AttestationData import io.github.vvb2060.keyattestation.attestation.RootOfTrust import io.github.vvb2060.keyattestation.databinding.HomeHeaderBinding +import io.github.vvb2060.keyattestation.repository.AttestationData import rikka.core.res.resolveColor class BootStateViewHolder(itemView: View, binding: HomeHeaderBinding) : diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/home/CommonItemViewHolder.kt b/app/src/main/java/io/github/vvb2060/keyattestation/home/CommonItemViewHolder.kt index 417f9fb1..5375def2 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/home/CommonItemViewHolder.kt +++ b/app/src/main/java/io/github/vvb2060/keyattestation/home/CommonItemViewHolder.kt @@ -16,7 +16,6 @@ import io.github.vvb2060.keyattestation.attestation.CertificateInfo import io.github.vvb2060.keyattestation.attestation.RootPublicKey import io.github.vvb2060.keyattestation.databinding.HomeCommonItemBinding import rikka.core.res.resolveColorStateList -import rikka.recyclerview.BaseViewHolder.Creator import java.util.Date import javax.security.auth.x500.X500Principal diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/home/HeaderViewHolder.kt b/app/src/main/java/io/github/vvb2060/keyattestation/home/HeaderViewHolder.kt index a2a17ec4..2a17ff0e 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/home/HeaderViewHolder.kt +++ b/app/src/main/java/io/github/vvb2060/keyattestation/home/HeaderViewHolder.kt @@ -4,7 +4,6 @@ import android.view.View import androidx.core.view.isVisible import io.github.vvb2060.keyattestation.databinding.HomeHeaderBinding import rikka.core.res.resolveColorStateList -import rikka.recyclerview.BaseViewHolder.Creator class HeaderViewHolder(itemView: View, binding: HomeHeaderBinding) : HomeViewHolder(itemView, binding) { 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 e6af3e0f..bf7acbea 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 @@ -30,10 +30,10 @@ import io.github.vvb2060.keyattestation.databinding.HomeBinding import io.github.vvb2060.keyattestation.keystore.KeyStoreManager import io.github.vvb2060.keyattestation.lang.AttestationException import io.github.vvb2060.keyattestation.repository.AttestationData -import io.github.vvb2060.keyattestation.util.Status import io.github.vvb2060.keyattestation.util.ColorManager import io.github.vvb2060.keyattestation.util.CrlManager import io.github.vvb2060.keyattestation.util.LocaleManager +import io.github.vvb2060.keyattestation.util.Status import rikka.html.text.HtmlCompat import rikka.html.text.toHtml import rikka.shizuku.Shizuku diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/keystore/AndroidKeyStore.java b/app/src/main/java/io/github/vvb2060/keyattestation/keystore/AndroidKeyStore.java index 8c419d34..c1a57144 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/keystore/AndroidKeyStore.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/keystore/AndroidKeyStore.java @@ -21,7 +21,6 @@ import android.security.keystore.KeyGenParameterSpec_rename; import android.security.keystore.KeyProperties; import android.security.keystore.KeyProtection; -import android.security.keystore2.AndroidKeyStoreProvider; import android.system.Os; import android.util.Log; diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationRepository.java b/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationRepository.java index 181e9357..0cdffbc2 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationRepository.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationRepository.java @@ -3,7 +3,16 @@ import static android.security.KeyStoreException.ERROR_ATTESTATION_KEYS_UNAVAILABLE; import static android.security.KeyStoreException.ERROR_ID_ATTESTATION_FAILURE; import static android.security.KeyStoreException.ERROR_KEYMINT_FAILURE; -import static io.github.vvb2060.keyattestation.lang.AttestationException.*; +import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_CANT_PARSE_CERT; +import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_DEVICEIDS_UNAVAILABLE; +import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_KEYS_NOT_PROVISIONED; +import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_OUT_OF_KEYS; +import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_OUT_OF_KEYS_TRANSIENT; +import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_RKP; +import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_STRONGBOX_UNAVAILABLE; +import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_UNAVAILABLE; +import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_UNAVAILABLE_TRANSIENT; +import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_UNKNOWN; import android.annotation.SuppressLint; import android.hardware.security.keymint.DeviceInfo; From a25a876645a8e74f08d421f1af62bc7907c13c4e Mon Sep 17 00:00:00 2001 From: Kofhisho Date: Mon, 6 Jul 2026 12:50:15 +0100 Subject: [PATCH 17/27] feat(RKP): Show EEK curve in hardware info --- .../vvb2060/keyattestation/home/HomeAdapter.kt | 14 ++++++++++++++ app/src/main/res/values/strings.xml | 5 +++++ 2 files changed, 19 insertions(+) diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt index 9062e56e..0aa13bd4 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt +++ b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt @@ -1,5 +1,6 @@ package io.github.vvb2060.keyattestation.home +import android.hardware.security.keymint.RpcHardwareInfo import android.os.Build import android.util.Base64 import android.util.Pair @@ -301,6 +302,11 @@ class HomeAdapter(listener: Listener) : IdBasedRecyclerViewAdapter() { R.string.rpc_unique_id_description, hardware.uniqueId), id++) + addItem(CommonItemViewHolder.COMMON_CREATOR, CommonData( + R.string.rpc_eek_curve, + R.string.rpc_eek_curve_description, + eekCurveToString(hardware.supportedEekCurve)), id) + id = ID_AUTHORIZATION_LIST_START addItem(SubtitleViewHolder.CREATOR, CommonData( R.string.rkp_device_info, @@ -391,6 +397,14 @@ class HomeAdapter(listener: Listener) : IdBasedRecyclerViewAdapter() { private const val ID_RKP_DICE_START = 6000L private const val ID_ERROR_MESSAGE = 100000L + private fun eekCurveToString(curve: Int): String { + return when (curve) { + RpcHardwareInfo.CURVE_P256 -> "P-256" + RpcHardwareInfo.CURVE_25519 -> "Ed25519" + else -> "None" + } + } + private fun createAuthorizationItems(list: AuthorizationList): Array { return arrayOf( list.purposes?.let { AuthorizationList.purposesToString(it) }, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index da54455b..24dbbbc4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -134,6 +134,11 @@ An opaque identifier for this IRemotelyProvisionedComponent implementation. Each implementation must have a distinct identifier from all other implementations, and it must be consistent across all devices. + EEK curve + + The curve used to validate the Endpoint Encryption Key (EEK) certificate chain and negotiate the GEEK for encrypting ProtectedData. + Only used by the V1/V2 CSR flow; V3 removed the EEK entirely. + Device info Device info contains information about the device that is signed by the IRemotelyProvisionedComponent HAL. From 378b1adb9f19b720309bc11bd29c479214e555d7 Mon Sep 17 00:00:00 2001 From: Kofhisho Date: Mon, 6 Jul 2026 13:22:08 +0100 Subject: [PATCH 18/27] feat: Long-press to copy root-of-trust --- .../home/CommonItemViewHolder.kt | 36 ++++++++----------- app/src/main/res/values-el/strings.xml | 2 +- app/src/main/res/values-it/strings.xml | 2 +- app/src/main/res/values-uk-rUA/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- 5 files changed, 19 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/home/CommonItemViewHolder.kt b/app/src/main/java/io/github/vvb2060/keyattestation/home/CommonItemViewHolder.kt index 5375def2..d950c8fb 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/home/CommonItemViewHolder.kt +++ b/app/src/main/java/io/github/vvb2060/keyattestation/home/CommonItemViewHolder.kt @@ -23,6 +23,18 @@ open class CommonItemViewHolder(itemView: View, binding: HomeCommonItemBindin HomeViewHolder(itemView, binding) { companion object { + private fun copyRootOfTrustHex(context: Context, rootOfTrust: String): Boolean { + if (!rootOfTrust.contains("verifiedBootKey:")) return false + val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboardManager.setPrimaryClip(ClipData.newPlainText("rootOfTrust", rootOfTrust)) + Toast.makeText( + context, + context.getString(R.string.copied_to_clipboard), + Toast.LENGTH_SHORT + ).show() + return true + } + val SIMPLE_CREATOR = Creator> { inflater, parent -> val binding = HomeCommonItemBinding.inflate(inflater, parent, false) object : CommonItemViewHolder>(binding.root, binding) { @@ -100,29 +112,11 @@ open class CommonItemViewHolder(itemView: View, binding: HomeCommonItemBindin init { this.binding.apply { root.setOnClickListener { - if (data.data.contains("verifiedBootHash:")) { - val lines = data.data.lines() - for (line in lines) { - if (line.startsWith("verifiedBootHash:")) { - val verifiedBootHash = line.substringAfter(":").trim() - val clipboardManager = - context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboardManager.setPrimaryClip( - ClipData.newPlainText( - "verifiedBootHash", - verifiedBootHash - ) - ) - Toast.makeText( - context, - context.getString(R.string.copied_verifiedBootHash_to_clipboard), - Toast.LENGTH_SHORT - ).show() - } - } - } listener.onCommonDataClick(data) } + root.setOnLongClickListener { + copyRootOfTrustHex(context, data.data) + } icon.isVisible = false } } diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 77c09311..38506efe 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -180,7 +180,7 @@ Προέλευση Αντίσταση ανατροπής Ρίζα εμπιστοσύνης - Το verifiedBootHash αντιγράφηκε στο πρόχειρο! + Αντιγράφηκε στο πρόχειρο! Έκδοση OS Επίπεδο ενημέρωσης κώδικα OS Αναγνωριστικό Εφαρμογής diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 16016db4..c661c55c 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -180,7 +180,7 @@ Origine Resistente al rollback Root of trust - verifiedBootHash copiato negli appunti! + Copiato negli appunti! Versione OS Livello patch OS ID app diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index b57dc91a..6c897263 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -180,7 +180,7 @@ Походження Стійкий до відкату Корінь довіри - Скопійовано verifiedBootHash до буфера обміну! + Скопійовано до буфера обміну! Версія ОС Рівень патча ОС ID додатка атестації diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 24dbbbc4..8cce76f6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -207,7 +207,7 @@ Origin Rollback resistant Root of trust - Copied verifiedBootHash to clipboard! + Copied to clipboard! OS version OS patch level App ID From 9bd086afc14a5d3c940daf3026ee41d8f68a8d1e Mon Sep 17 00:00:00 2001 From: Vision <25982450+VisionR1@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:01:30 +0300 Subject: [PATCH 19/27] Feature: Load XML file - Improvement AttestationRepository.java and KeyBoxXmlParser.java to load a keybox.xml - Fix crash on Samsung Knox/RKP screens when integrity data is null - General improve for display proper the error messages - Update translation for the new error messages --- .../keyattestation/home/ErrorViewHolder.kt | 18 +- .../keyattestation/home/HomeAdapter.kt | 51 ++++- .../keyattestation/home/HomeViewModel.kt | 2 +- .../keystore/KeyBoxXmlParser.java | 57 +++-- .../lang/AttestationException.kt | 12 +- .../repository/AttestationRepository.java | 198 ++++++++++++++++-- .../keyattestation/repository/KeyboxData.java | 18 ++ app/src/main/res/values-el/strings.xml | 7 + app/src/main/res/values-it/strings.xml | 7 + app/src/main/res/values-pt-rBR/strings.xml | 7 + app/src/main/res/values-uk-rUA/strings.xml | 7 + app/src/main/res/values-zh-rCN/strings.xml | 7 + app/src/main/res/values-zh-rTW/strings.xml | 7 + app/src/main/res/values/strings.xml | 7 + 14 files changed, 360 insertions(+), 45 deletions(-) create mode 100644 app/src/main/java/io/github/vvb2060/keyattestation/repository/KeyboxData.java diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/home/ErrorViewHolder.kt b/app/src/main/java/io/github/vvb2060/keyattestation/home/ErrorViewHolder.kt index ab9e3ab0..7b14396e 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/home/ErrorViewHolder.kt +++ b/app/src/main/java/io/github/vvb2060/keyattestation/home/ErrorViewHolder.kt @@ -23,14 +23,16 @@ class ErrorViewHolder(itemView: View, binding: HomeErrorBinding) : HomeViewHolde val sb = StringBuilder() sb.append(context.getString(data.descriptionResId)).append("

") - sb.append(context.getString(R.string.error_message_subtitle)).append("
") - sb.append("") - var tr = data.cause - while (tr != null) { - sb.append(tr).append("
") - tr = tr.cause - } - sb.append("
") + if (data.hasDetailedMessage) { + sb.append(context.getString(R.string.error_message_subtitle)).append("
") + sb.append("") + var tr = data.cause + while (tr != null) { + sb.append(tr).append("
") + tr = tr.cause + } + sb.append("
") + } text1.text = sb.toHtml(HtmlCompat.FROM_HTML_OPTION_TRIM_WHITESPACE) } } diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt index 0c961898..9cff1e8f 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt +++ b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt @@ -9,6 +9,7 @@ import io.github.vvb2060.keyattestation.lang.AttestationException import io.github.vvb2060.keyattestation.lang.AttestationException.Companion.CODE_RKP import io.github.vvb2060.keyattestation.repository.AttestationData import io.github.vvb2060.keyattestation.repository.BaseData +import io.github.vvb2060.keyattestation.repository.KeyboxData import io.github.vvb2060.keyattestation.repository.RemoteProvisioningData import rikka.recyclerview.IdBasedRecyclerViewAdapter @@ -90,8 +91,45 @@ class HomeAdapter(listener: Listener) : IdBasedRecyclerViewAdapter() { addItem(SubtitleViewHolder.CREATOR, CommonData( R.string.cert_chain, R.string.cert_chain_description), id++) - baseData.certs.forEach { certInfo -> - addItem(CommonItemViewHolder.CERT_INFO_CREATOR, certInfo, id++) + + val certsList = baseData.certs + if (!certsList.isNullOrEmpty()) { + val containsEc = certsList.any { it.cert?.publicKey?.algorithm?.equals("EC", ignoreCase = true) == true || it.cert?.publicKey?.algorithm?.equals("ECDSA", ignoreCase = true) == true } + + // Track unique serial numbers to block duplicates + val seenSerials = mutableSetOf() + + certsList.forEach { certInfo -> + val currentAlgo = certInfo.cert?.publicKey?.algorithm + val serialNumber = certInfo.cert?.serialNumber?.toString() + + // Identify the Root Certificate (A root is always self-signed: Subject == Issuer) + val subjectStr = certInfo.cert?.subjectDN?.toString() + val issuerStr = certInfo.cert?.issuerDN?.toString() + val isRoot = (subjectStr != null && subjectStr == issuerStr) + + // Display only if serial number hasn't been seen before + if (serialNumber == null || !seenSerials.contains(serialNumber)) { + + if (serialNumber != null) { + seenSerials.add(serialNumber) + } + + if (containsEc) { + // 1. Show EC certificates and Root certificate (even if Root is RSA) + if (currentAlgo.equals("EC", ignoreCase = true) || currentAlgo.equals("ECDSA", ignoreCase = true) || isRoot) { + addItem(CommonItemViewHolder.CERT_INFO_CREATOR, certInfo, id++) + } + } else { + // 2. Show RSA certificates (duplicates are blocked) + addItem(CommonItemViewHolder.CERT_INFO_CREATOR, certInfo, id++) + } + } + } + } else { + baseData.certs.forEach { certInfo -> + addItem(CommonItemViewHolder.CERT_INFO_CREATOR, certInfo, id++) + } } // Add revocation list information with source status @@ -135,6 +173,7 @@ class HomeAdapter(listener: Listener) : IdBasedRecyclerViewAdapter() { when (baseData) { is AttestationData -> updateData(baseData) is RemoteProvisioningData -> updateData(baseData) + is KeyboxData -> {} } notifyDataSetChanged() @@ -219,12 +258,12 @@ class HomeAdapter(listener: Listener) : IdBasedRecyclerViewAdapter() { addItem(CommonItemViewHolder.COMMON_CREATOR, CommonData( R.string.knox_integrity, R.string.knox_integrity_description, - attestation.knoxIntegrity.toString()), id++) + attestation.knoxIntegrity?.toString()), id++) addItem(CommonItemViewHolder.COMMON_CREATOR, CommonData( R.string.knox_record_hash, R.string.knox_record_hash_description, - BaseEncoding.base16().lowerCase().encode(attestation.recordHash)), id++) + attestation.recordHash?.let { BaseEncoding.base16().lowerCase().encode(it) }), id++) } } @@ -250,12 +289,12 @@ class HomeAdapter(listener: Listener) : IdBasedRecyclerViewAdapter() { addItem(CommonItemViewHolder.COMMON_CREATOR, CommonData( R.string.rpc_version_number, R.string.rpc_version_number_description, - hardware.versionNumber.toString()), id++) + hardware.versionNumber?.toString()), id++) addItem(CommonItemViewHolder.COMMON_CREATOR, CommonData( R.string.rpc_author_name, R.string.rpc_author_name_description, - hardware.rpcAuthorName.toString()), id++) + hardware.rpcAuthorName?.toString()), id++) addItem(CommonItemViewHolder.COMMON_CREATOR, CommonData( R.string.rpc_unique_id, 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 d146c9b4..c5b3b782 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 @@ -175,7 +175,7 @@ class HomeViewModel( attestationData.postValue(Resource.loading(null)) val result = cr.openFileDescriptor(uri, "r").use { - attestationRepository.loadCerts(it) + attestationRepository.loadCerts(it, preferAttestRsaKey) } attestationData.postValue(result) diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/keystore/KeyBoxXmlParser.java b/app/src/main/java/io/github/vvb2060/keyattestation/keystore/KeyBoxXmlParser.java index b6194af9..f995851e 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/keystore/KeyBoxXmlParser.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/keystore/KeyBoxXmlParser.java @@ -50,38 +50,52 @@ private KeyBoxXmlParser() throws IOException { throw new IOException(e); } } + + public KeyStore.PrivateKeyEntry parse(InputStream in) throws IOException { + return parse(in, false); + } - public KeyStore.PrivateKeyEntry parse(InputStream in) throws IOException { + public KeyStore.PrivateKeyEntry parse(InputStream in, boolean preferRsa) throws IOException { try { parser.setInput(in, StandardCharsets.UTF_8.name()); chain.clear(); privateKey = null; - readAndroidAttestation(); + readAndroidAttestation(preferRsa); } catch (XmlPullParserException e) { throw new IOException(e); } if (privateKey == null || chain.isEmpty()) { - throw new IOException("No key found"); + throw new IOException("No key found matching requested format"); } return new KeyStore.PrivateKeyEntry(privateKey, chain.toArray(new Certificate[0])); } - private void readAndroidAttestation() throws XmlPullParserException, IOException { + private void readAndroidAttestation(boolean preferRsa) throws XmlPullParserException, IOException { while (parser.next() != XmlPullParser.END_DOCUMENT) { if (parser.getEventType() != XmlPullParser.START_TAG) { continue; } var name = parser.getName(); var algorithm = parser.getAttributeValue(null, "algorithm"); - if ("Key".equals(name) && "ecdsa".equals(algorithm)) { - parser.nextTag(); - readECKey(); - break; + + if ("Key".equals(name)) { + if (preferRsa && "ecdsa".equalsIgnoreCase(algorithm)) { + continue; + } + if (!preferRsa && "rsa".equalsIgnoreCase(algorithm)) { + continue; + } + + if ("ecdsa".equalsIgnoreCase(algorithm) || "rsa".equalsIgnoreCase(algorithm)) { + parser.nextTag(); + readKeyBlock(algorithm); + break; + } } } } - private void readECKey() throws XmlPullParserException, IOException { + private void readKeyBlock(String algorithm) throws XmlPullParserException, IOException { while (!(parser.getEventType() == XmlPullParser.END_TAG && "Key".equals(parser.getName()))) { if (parser.getEventType() != XmlPullParser.START_TAG) { parser.next(); @@ -92,7 +106,7 @@ private void readECKey() throws XmlPullParserException, IOException { case "PrivateKey" -> { if ("pem".equals(format)) { parser.next(); - readPrivateKey(parser.getText()); + readPrivateKey(parser.getText(), algorithm); parser.next(); } else { return; @@ -124,15 +138,26 @@ private static byte[] stringToBytes(String text) { return Base64.decode(sb.toString(), 0); } - private void readPrivateKey(String text) throws IOException { + private void readPrivateKey(String text, String algorithm) throws IOException { try { var sequence = ASN1Sequence.getInstance(stringToBytes(text)); - var ecKey = ECPrivateKey.getInstance(sequence); - var id = new AlgorithmIdentifier(X9ObjectIdentifiers.id_ecPublicKey, - ecKey.getParametersObject()); - var data = new PrivateKeyInfo(id, ecKey).getEncoded(); + byte[] data; + String factoryAlgo; + + if ("rsa".equalsIgnoreCase(algorithm)) { + var rsaKey = org.bouncycastle.asn1.pkcs.RSAPrivateKey.getInstance(sequence); + var id = new AlgorithmIdentifier(org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers.rsaEncryption, org.bouncycastle.asn1.DERNull.INSTANCE); + data = new PrivateKeyInfo(id, rsaKey).getEncoded(); + factoryAlgo = "RSA"; + } else { + var ecKey = ECPrivateKey.getInstance(sequence); + var id = new AlgorithmIdentifier(X9ObjectIdentifiers.id_ecPublicKey, ecKey.getParametersObject()); + data = new PrivateKeyInfo(id, ecKey).getEncoded(); + factoryAlgo = "EC"; + } + var keySpec = new PKCS8EncodedKeySpec(data); - var keyFactory = KeyFactory.getInstance("EC"); + var keyFactory = KeyFactory.getInstance(factoryAlgo); privateKey = keyFactory.generatePrivate(keySpec); } catch (GeneralSecurityException e) { throw new IOException(e); diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/lang/AttestationException.kt b/app/src/main/java/io/github/vvb2060/keyattestation/lang/AttestationException.kt index fcdff3b4..4820dbea 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/lang/AttestationException.kt +++ b/app/src/main/java/io/github/vvb2060/keyattestation/lang/AttestationException.kt @@ -2,7 +2,8 @@ package io.github.vvb2060.keyattestation.lang import io.github.vvb2060.keyattestation.R -class AttestationException(code: Int, cause: Throwable) : RuntimeException(cause) { +class AttestationException(code: Int, cause: Throwable?) : RuntimeException(cause) { + val hasDetailedMessage: Boolean = cause != null companion object { const val CODE_UNKNOWN = -1 @@ -15,6 +16,9 @@ class AttestationException(code: Int, cause: Throwable) : RuntimeException(cause const val CODE_UNAVAILABLE_TRANSIENT = 7 const val CODE_KEYS_NOT_PROVISIONED = 8 const val CODE_RKP = 9 + const val CODE_ATTEST_RSA_KEY_EC_ONLY = 10 + const val CODE_ATTEST_EC_KEY_RSA_ONLY = 11 + const val CODE_INVALID_FILE = 12 } val titleResId: Int = when (code) { @@ -27,6 +31,9 @@ class AttestationException(code: Int, cause: Throwable) : RuntimeException(cause CODE_UNAVAILABLE_TRANSIENT -> R.string.error_unavailable_transient CODE_KEYS_NOT_PROVISIONED -> R.string.error_keys_not_provisioned CODE_RKP -> R.string.error_remote_key_provisioning + CODE_ATTEST_RSA_KEY_EC_ONLY -> R.string.error_attest_rsa_key_ec_only + CODE_ATTEST_EC_KEY_RSA_ONLY -> R.string.error_attest_ec_key_rsa_only + CODE_INVALID_FILE -> R.string.error_invalid_file else -> R.string.error_unknown } @@ -40,6 +47,9 @@ class AttestationException(code: Int, cause: Throwable) : RuntimeException(cause CODE_UNAVAILABLE_TRANSIENT -> R.string.error_unavailable_transient_summary CODE_KEYS_NOT_PROVISIONED -> R.string.error_keys_not_provisioned_summary CODE_RKP -> R.string.error_remote_key_provisioning_summary + CODE_ATTEST_RSA_KEY_EC_ONLY -> R.string.error_attest_rsa_key_ec_only_summary + CODE_ATTEST_EC_KEY_RSA_ONLY -> R.string.error_attest_ec_key_rsa_only_summary + CODE_INVALID_FILE -> R.string.error_invalid_file_summary else -> R.string.error_unknown } diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationRepository.java b/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationRepository.java index 06a1f497..b80d15ea 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationRepository.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationRepository.java @@ -31,6 +31,7 @@ import io.github.vvb2060.keyattestation.AppApplication; import io.github.vvb2060.keyattestation.keystore.AndroidKeyStore; import io.github.vvb2060.keyattestation.keystore.IAndroidKeyStore; +import io.github.vvb2060.keyattestation.keystore.KeyBoxXmlParser; import io.github.vvb2060.keyattestation.keystore.KeyStoreManager; import io.github.vvb2060.keyattestation.lang.AttestationException; import io.github.vvb2060.keyattestation.util.Resource; @@ -204,28 +205,199 @@ public Resource attest(boolean reset, boolean useAttestKey, } } - public Resource loadCerts(ParcelFileDescriptor pfd) { + /** + * Load certificates from file, supporting multiple formats (binary, XML, PEM). + * Automatically filters to requested algorithm (RSA or EC). + */ + public Resource loadCerts(ParcelFileDescriptor pfd, boolean preferRsa) { currentCerts.clear(); try { - AttestationData data; - try (var in = new ParcelFileDescriptor.AutoCloseInputStream(pfd); - var channel = in.getChannel()) { - try { - generateCertificates(in); - data = AttestationData.parseCertificateChain(currentCerts); - } catch (CertificateException e) { - channel.position(0); - generateCertPath(in); - data = AttestationData.parseCertificateChain(currentCerts); + // 1. Read raw bytes from file + byte[] bytes; + try (var in = new ParcelFileDescriptor.AutoCloseInputStream(pfd)) { + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int read; + while ((read = in.read(buffer)) != -1) { + baos.write(buffer, 0, read); } + bytes = baos.toByteArray(); } - return Resource.Companion.success(data); + + // Strip a UTF-8 BOM if present -- it trips the plain-ASCII sanity check further down. + if (bytes.length >= 3 && (bytes[0] & 0xFF) == 0xEF && (bytes[1] & 0xFF) == 0xBB && (bytes[2] & 0xFF) == 0xBF) { + bytes = java.util.Arrays.copyOfRange(bytes, 3, bytes.length); + } + + boolean parsedBinary = false; + + // 1a. Skip binary cert parsing for XML input to avoid chain corruption. + int sniffLen = Math.min(bytes.length, 512); + String sniff = new String(bytes, 0, sniffLen, java.nio.charset.StandardCharsets.UTF_8).trim(); + boolean looksLikeKeyboxXml = sniff.startsWith(" certsCollection = factory.generateCertificates(bis); + for (java.security.cert.Certificate cert : certsCollection) { + if (cert instanceof X509Certificate) { + currentCerts.add((X509Certificate) cert); + } + } + if (!currentCerts.isEmpty()) parsedBinary = true; + } catch (Exception ignored) {} + } + } + + // 2a. Filter to RSA certs only + if (parsedBinary && preferRsa && !currentCerts.isEmpty()) { + List rsaCerts = new ArrayList<>(); + for (X509Certificate cert : currentCerts) { + if (cert.getPublicKey().getAlgorithm().equalsIgnoreCase("RSA")) { + rsaCerts.add(cert); + } + } + if (!rsaCerts.isEmpty()) { + currentCerts.clear(); + currentCerts.addAll(rsaCerts); + } + } + + // 3. Parse XML + if (!parsedBinary) { + String xmlContent = new String(bytes, java.nio.charset.StandardCharsets.UTF_8); + for (char c : xmlContent.toCharArray()) { + if (c > 127 && !Character.isWhitespace(c)) { // Non-ASCII except whitespace + throw new AttestationException(CODE_INVALID_FILE, null); + } + } + + boolean parsedViaXmlParser = false; + try (var bis = new java.io.ByteArrayInputStream(bytes)) { + var xmlEntry = KeyBoxXmlParser.getInstance().parse(bis, preferRsa); + java.security.cert.Certificate[] chain = xmlEntry.getCertificateChain(); + if (chain != null) { + for (java.security.cert.Certificate cert : chain) { + if (cert instanceof X509Certificate) { + currentCerts.add((X509Certificate) cert); + } + } + if (!currentCerts.isEmpty()) { + parsedViaXmlParser = true; + } + } + } catch (Exception ignored) {} + + // 3a: Lenient regex parser for unstructured XML/PEM + if (!parsedViaXmlParser) { + String content = new String(bytes, java.nio.charset.StandardCharsets.UTF_8); + List rawBlocks = new ArrayList<>(); + + java.util.regex.Pattern xmlPattern = java.util.regex.Pattern.compile( + "]*>(.*?)", + java.util.regex.Pattern.DOTALL | java.util.regex.Pattern.CASE_INSENSITIVE + ); + java.util.regex.Matcher xmlMatcher = xmlPattern.matcher(content); + while (xmlMatcher.find()) { + String tagContent = xmlMatcher.group(1); + if (tagContent != null) rawBlocks.add(tagContent); + } + + if (rawBlocks.isEmpty()) { + java.util.regex.Pattern pemPattern = java.util.regex.Pattern.compile( + "-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----", + java.util.regex.Pattern.DOTALL + ); + java.util.regex.Matcher pemMatcher = pemPattern.matcher(content); + while (pemMatcher.find()) { + String pemContent = pemMatcher.group(1); + if (pemContent != null) rawBlocks.add(pemContent); + } + } + + for (String block : rawBlocks) { + String cleanBlock = block.replaceAll("", ""); + cleanBlock = cleanBlock.replaceAll("-----BEGIN CERTIFICATE-----", "") + .replaceAll("-----END CERTIFICATE-----", ""); + cleanBlock = cleanBlock.replaceAll("\\s+", ""); + + if (!cleanBlock.isEmpty()) { + try { + byte[] decoded = android.util.Base64.decode(cleanBlock, android.util.Base64.DEFAULT); + try (var bis = new java.io.ByteArrayInputStream(decoded)) { + java.security.cert.Certificate cert = factory.generateCertificate(bis); + if (cert instanceof X509Certificate) { + currentCerts.add((X509Certificate) cert); + } + } + } catch (Exception ignored) {} + } + } + } + } + + if (currentCerts.isEmpty()) { + throw new AttestationException(CODE_INVALID_FILE, null); + } + + // 4. Sort certs by attestation extension + String attestationOid = "1.3.6.1.4.1.11129.2.1.17"; + X509Certificate leafCert = null; + for (X509Certificate cert : currentCerts) { + var criticalOids = cert.getCriticalExtensionOIDs(); + var nonCriticalOids = cert.getNonCriticalExtensionOIDs(); + if ((criticalOids != null && criticalOids.contains(attestationOid)) || + (nonCriticalOids != null && nonCriticalOids.contains(attestationOid))) { + leafCert = cert; + break; + } + } + if (leafCert != null) { + currentCerts.remove(leafCert); + currentCerts.add(0, leafCert); + } + + // 5. Validate that requested key type matches the loaded leaf certificate + if (!currentCerts.isEmpty()) { + String firstCertAlgo = currentCerts.get(0).getPublicKey().getAlgorithm(); + + if (preferRsa) { + // If user wants RSA, but leaf is not RSA + if (!firstCertAlgo.equalsIgnoreCase("RSA")) { + throw new AttestationException(CODE_ATTEST_RSA_KEY_EC_ONLY, null); + } + } else { + // If user wants EC, but leaf is not EC/ECDSA + if (!firstCertAlgo.equalsIgnoreCase("EC") && !firstCertAlgo.equalsIgnoreCase("ECDSA")) { + throw new AttestationException(CODE_ATTEST_EC_KEY_RSA_ONLY, null); + } + } + } + + try { + AttestationData data = AttestationData.parseCertificateChain(currentCerts); + return Resource.Companion.success(data); + } catch (Exception originalError) { + return Resource.Companion.success(KeyboxData.fromCerts(currentCerts)); + } + } catch (Exception e) { var cause = e instanceof AttestationException ? e.getCause() : e; Log.w(AppApplication.TAG, "Load attestation error.", cause); if (e instanceof AttestationException) { - return Resource.Companion.error(e, null); + return Resource.Companion.error((AttestationException) e, null); } else if (e instanceof CertificateException) { return Resource.Companion.error(new AttestationException(CODE_CANT_PARSE_CERT, e), null); } else { diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/repository/KeyboxData.java b/app/src/main/java/io/github/vvb2060/keyattestation/repository/KeyboxData.java new file mode 100644 index 00000000..f3889ecc --- /dev/null +++ b/app/src/main/java/io/github/vvb2060/keyattestation/repository/KeyboxData.java @@ -0,0 +1,18 @@ +package io.github.vvb2060.keyattestation.repository; + +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; + +import io.github.vvb2060.keyattestation.attestation.CertificateInfo; + +public class KeyboxData extends BaseData { + + public static KeyboxData fromCerts(List certs) { + var infoList = new ArrayList(certs.size()); + CertificateInfo.parse(certs, infoList); + var data = new KeyboxData(); + data.init(infoList); + return data; + } +} diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 77c09311..4309ad27 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -63,6 +63,13 @@ ⚠️ Συσκευή εκτός σύνδεσης - χρήση αποθηκευμένης CRL ❌ Εκτός σύνδεσης - Μη ενημερωμένη CRL + Δεν υπάρχει αλυσίδα πιστοποιητικών RSA +
Για εμφάνιση, απενεργοποιήστε την Βεβαίωση κλειδιού RSA.]]>
+ Δεν υπάρχει αλυσίδα πιστοποιητικών EC +
Για εμφάνιση, ενεργοποιήστε την Βεβαίωση κλειδιού RSA.]]>
+ Μη δυνατή ανάλυση αρχείου +
• Το αρχείο έχει καταστραφεί ή παραποιηθεί

• Μπορεί να περιέχει μη έγκυρους χαρακτήρες

• Μη υποστηριζόμενη μορφή]]>
+ Αναλυτικά μηνύματα: Άγνωστο σφάλμα Μη δυνατή βεβαίωση diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 16016db4..2e5937b9 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -63,6 +63,13 @@ ⚠️ Dispositivo offline - CRL in cache ❌ Offline - CRL non aggiornata + Nessuna catena di certificati RSA +
Per visualizzarla, disabilita Attesta chiave RSA.]]>
+ Nessuna catena di certificati EC +
Per visualizzarla, abilita Attesta chiave RSA.]]>
+ Impossibile analizzare il file +
• File danneggiato o manomesso

• Potrebbe contenere caratteri non validi

• Formato di file non supportato]]>
+ Messaggi dettagliati: Errore sconosciuto Impossibile attestare diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 208e2130..670df5f7 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -63,6 +63,13 @@ ⚠️ Dispositivo offline - usando CRL em cache ❌ Offline - CRL desatualizada + Nenhuma cadeia de certificados RSA +
Para exibir, desative Atestar chave RSA.]]>
+ Nenhuma cadeia de certificados EC +
Para exibir, ative Atestar chave RSA.]]>
+ Não foi possível analisar o arquivo +
• Arquivo corrompido ou adulterado

• Pode conter caracteres inválidos

• Formato de arquivo não suportado]]>
+ Mensagens detalhadas: Erro desconhecido Não é possível atestar diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index b57dc91a..d13e78c7 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -63,6 +63,13 @@ ⚠️ Пристрій офлайн — використовується кешований CRL ❌ Офлайн — CRL застарілий + Немає ланцюжка сертифікатів RSA +
Щоб переглянути, вимкніть Атестувати ключ RSA.]]>
+ Немає ланцюжка сертифікатів EC +
Щоб переглянути, увімкніть Атестувати ключ RSA.]]>
+ Не вдалося проаналізувати файл +
• Файл пошкоджено або змінено

• Може містити неприпустимі символи

• Формат файлу не підтримується]]>
+ Детальні повідомлення: Невідома помилка Неможливо атестувати diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b79820b0..4bf0abf7 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -63,6 +63,13 @@ ⚠️ 设备离线 - 使用缓存的 CRL ❌ 离线 - CRL 已过期 + 无 RSA 证书链 +
要显示,请禁用认证 RSA 密钥。]]>
+ 无 EC 证书链 +
要显示,请启用认证 RSA 密钥。]]>
+ 无法解析文件 +
• 文件已损坏或被篡改

• 可能包含无效字符

• 不支持的文件格式]]>
+ 详细信息: 未知错误 无法认证 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 492d1288..323aac22 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -63,6 +63,13 @@ ⚠️ 裝置離線 - 使用快取的 CRL ❌ 離線 - CRL 已過期 + 無 RSA 憑證鍊 +
若要顯示,請停用認證 RSA 金鑰。]]>
+ 無 EC 憑證鍊 +
若要顯示,請啟用認證 RSA 金鑰。]]>
+ 無法解析檔案 +
• 檔案已毀損或遭篡改

• 可能包含無效字元

• 不支援的檔案格式]]>
+ 詳細資訊: 未知錯誤 無法認證 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 64d08fce..586e2842 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -63,6 +63,13 @@ ⚠️ Device offline - using cached CRL ❌ Offline - CRL Out-of-date + No RSA certificate chain +
To display, disable Attest RSA key.]]>
+ No EC certificate chain +
To display, enable Attest RSA key.]]>
+ Unable to parse file +
• File corrupted or tampered

• May contain invalid characters

• Unsupported file format]]>
+ Detailed messages: Unknown error Unable to attest From 73a0d985d97940660d3c626f8d33310b1f0927e1 Mon Sep 17 00:00:00 2001 From: Kofhisho Date: Mon, 13 Jul 2026 15:09:47 +0100 Subject: [PATCH 20/27] fix: Bugs because I was trolling --- .../java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt | 2 -- .../keyattestation/repository/AttestationRepository.java | 3 +++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt index 46353322..667b3ffb 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt +++ b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt @@ -490,8 +490,6 @@ class HomeAdapter(listener: Listener) : IdBasedRecyclerViewAdapter() { list.model, list.vendorPatchLevel?.let { AuthorizationList.patchLevelToString(it) }, list.bootPatchLevel?.let { AuthorizationList.patchLevelToString(it) }, - list.vendorPatchLevel?.toString(), - list.bootPatchLevel?.toString(), list.deviceUniqueAttestation?.toString(), list.identityCredentialKey?.toString(), list.moduleHash?.let { BaseEncoding.base16().lowerCase().encode(it) }, diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationRepository.java b/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationRepository.java index 36dfa7b5..cecb756f 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationRepository.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationRepository.java @@ -3,8 +3,11 @@ import static android.security.KeyStoreException.ERROR_ATTESTATION_KEYS_UNAVAILABLE; import static android.security.KeyStoreException.ERROR_ID_ATTESTATION_FAILURE; import static android.security.KeyStoreException.ERROR_KEYMINT_FAILURE; +import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_ATTEST_EC_KEY_RSA_ONLY; +import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_ATTEST_RSA_KEY_EC_ONLY; import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_CANT_PARSE_CERT; import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_DEVICEIDS_UNAVAILABLE; +import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_INVALID_FILE; import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_KEYS_NOT_PROVISIONED; import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_OUT_OF_KEYS; import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_OUT_OF_KEYS_TRANSIENT; From 116135d71e9875026d43cc50a52ef21cf894de2d Mon Sep 17 00:00:00 2001 From: Kofhisho Date: Mon, 13 Jul 2026 15:23:38 +0100 Subject: [PATCH 21/27] fix(certs): Use preferred algorithm in fallback loader --- .../repository/AttestationRepository.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationRepository.java b/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationRepository.java index cecb756f..bcebc594 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationRepository.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationRepository.java @@ -363,6 +363,25 @@ public Resource loadCerts(ParcelFileDescriptor pfd, boolean preferRsa) } catch (Exception ignored) {} } } + + // Same algorithm filter as the binary path (2a) + // the parser doesn't distinguish key blocks, so a mixed-algorithm keybox would + // otherwise concatenate two unrelated chains into one list + if (!currentCerts.isEmpty()) { + List matching = new ArrayList<>(); + for (X509Certificate cert : currentCerts) { + var algo = cert.getPublicKey().getAlgorithm(); + if (algo.equalsIgnoreCase(preferRsa ? "RSA" : "EC") || + (!preferRsa && algo.equalsIgnoreCase("ECDSA")) + ) { + matching.add(cert); + } + } + if (!matching.isEmpty()) { + currentCerts.clear(); + currentCerts.addAll(matching); + } + } } } @@ -408,6 +427,7 @@ public Resource loadCerts(ParcelFileDescriptor pfd, boolean preferRsa) AttestationData data = AttestationData.parseCertificateChain(currentCerts); return Resource.Companion.success(data); } catch (Exception originalError) { + Log.w(AppApplication.TAG, "No attestation extension, falling back to KeyboxData.", originalError); return Resource.Companion.success(KeyboxData.fromCerts(currentCerts)); } From 7b5c2ff8b52dffe93aeac767abca875f469a4284 Mon Sep 17 00:00:00 2001 From: Kofhisho Date: Mon, 13 Jul 2026 15:30:21 +0100 Subject: [PATCH 22/27] fix(KnoxAttestation): Null record hash --- .../vvb2060/keyattestation/attestation/KnoxAttestation.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/KnoxAttestation.java b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/KnoxAttestation.java index 72ac0b24..98d5a799 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/KnoxAttestation.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/KnoxAttestation.java @@ -67,11 +67,13 @@ public byte[] getRecordHash() { @Override public String toString() { + String hash = recordHash == null ? "unknown" : BaseEncoding.base16().lowerCase().encode(recordHash); + return super.toString() + "\n\nExtension type: " + getClass().getSimpleName() + "\nID attestation: " + idAttest + "\nChallenge: " + challenge + "\nIntegrity status: " + knoxIntegrity + - "\nAttestation record hash: " + BaseEncoding.base16().lowerCase().encode(recordHash); + "\nAttestation record hash: " + hash; } } From 027a35919e0924e6de7d723900e9781f2aa263bf Mon Sep 17 00:00:00 2001 From: Kofhisho Date: Mon, 13 Jul 2026 15:32:49 +0100 Subject: [PATCH 23/27] feat(RKP): Failsafe for empty EEK curve --- .../vvb2060/keyattestation/attestation/RevocationList.java | 6 +++--- .../vvb2060/keyattestation/keystore/RemoteProvisioning.java | 3 +++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/RevocationList.java b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/RevocationList.java index 5b9ddc74..8f508902 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/RevocationList.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/RevocationList.java @@ -40,9 +40,9 @@ public enum DataSource { private static final String PREFS_NAME = "revocation_prefs"; private static final String KEY_PUBLISH_TIME = "last_publish_time"; - private static JSONObject data = null; - private static Date publishTime = null; - private static DataSource currentSource = DataSource.BUNDLED; + private static volatile JSONObject data = null; + private static volatile Date publishTime = null; + private static volatile DataSource currentSource = DataSource.BUNDLED; private static final ExecutorService asyncExecutor = Executors.newSingleThreadExecutor(); private static final ExecutorService networkExecutor = Executors.newSingleThreadExecutor(); diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/keystore/RemoteProvisioning.java b/app/src/main/java/io/github/vvb2060/keyattestation/keystore/RemoteProvisioning.java index 9b3eefa3..48b96b6d 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/keystore/RemoteProvisioning.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/keystore/RemoteProvisioning.java @@ -253,6 +253,9 @@ private byte[] generateCsr(EekResponse eekResponse) var deviceInfo = new DeviceInfo(); var protectedData = new ProtectedData(); var geekChain = eekResponse.getEekChain(hwInfo.supportedEekCurve); + if (geekChain == null) { + throw new IllegalStateException("No GEEK for supportedEekCurve=" + hwInfo.supportedEekCurve); + } var csrTag = binder.generateCertificateRequest(false, keysToSign, geekChain, eekResponse.getChallenge(), deviceInfo, protectedData); var mac0Message = buildMac0MessageForV1Csr(keysToSign[0], csrTag); From 723eccc967aa0c7fb9b7d3d317aa4150e9dd774d Mon Sep 17 00:00:00 2001 From: Kofhisho Date: Mon, 13 Jul 2026 15:35:05 +0100 Subject: [PATCH 24/27] fix Implement hashCode for AttestationApplicationId/Package with versioning --- .../attestation/AttestationApplicationId.java | 11 +++++++++++ .../attestation/AttestationPackageInfo.java | 6 ++++++ 2 files changed, 17 insertions(+) diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/AttestationApplicationId.java b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/AttestationApplicationId.java index de865f9f..8656d4d8 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/AttestationApplicationId.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/AttestationApplicationId.java @@ -30,6 +30,7 @@ import java.security.NoSuchAlgorithmException; import java.security.cert.CertificateParsingException; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; public class AttestationApplicationId implements java.lang.Comparable { @@ -137,6 +138,16 @@ public boolean equals(Object o) { && (0 == compareTo((AttestationApplicationId) o)); } + @Override + public int hashCode() { + // byte[] has identity hashCode, so combine by content to stay consistent with equals(). + int sigHash = 0; + for (byte[] digest : signatureDigests) { + sigHash = 31 * sigHash + Arrays.hashCode(digest); + } + return 31 * packageInfos.hashCode() + sigHash; + } + private List parseAttestationPackageInfos(ASN1Encodable asn1Encodable) throws CertificateParsingException { if (!(asn1Encodable instanceof ASN1Set set)) { diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/AttestationPackageInfo.java b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/AttestationPackageInfo.java index b2a1b24d..d97ab436 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/AttestationPackageInfo.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/AttestationPackageInfo.java @@ -18,6 +18,7 @@ import org.bouncycastle.asn1.ASN1Sequence; import java.security.cert.CertificateParsingException; +import java.util.Objects; public class AttestationPackageInfo implements java.lang.Comparable { private static final int PACKAGE_NAME_INDEX = 0; @@ -70,4 +71,9 @@ public boolean equals(Object o) { return (o instanceof AttestationPackageInfo) && (0 == compareTo((AttestationPackageInfo) o)); } + + @Override + public int hashCode() { + return Objects.hash(packageName, version); + } } From a8a857f64258fa90abf70c8fa9bc40115ad9c08a Mon Sep 17 00:00:00 2001 From: Kofhisho Date: Mon, 13 Jul 2026 15:37:12 +0100 Subject: [PATCH 25/27] feat(AttestationException): Wildcard import --- .../repository/AttestationRepository.java | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationRepository.java b/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationRepository.java index bcebc594..bfcf2640 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationRepository.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/repository/AttestationRepository.java @@ -3,19 +3,7 @@ import static android.security.KeyStoreException.ERROR_ATTESTATION_KEYS_UNAVAILABLE; import static android.security.KeyStoreException.ERROR_ID_ATTESTATION_FAILURE; import static android.security.KeyStoreException.ERROR_KEYMINT_FAILURE; -import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_ATTEST_EC_KEY_RSA_ONLY; -import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_ATTEST_RSA_KEY_EC_ONLY; -import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_CANT_PARSE_CERT; -import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_DEVICEIDS_UNAVAILABLE; -import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_INVALID_FILE; -import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_KEYS_NOT_PROVISIONED; -import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_OUT_OF_KEYS; -import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_OUT_OF_KEYS_TRANSIENT; -import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_RKP; -import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_STRONGBOX_UNAVAILABLE; -import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_UNAVAILABLE; -import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_UNAVAILABLE_TRANSIENT; -import static io.github.vvb2060.keyattestation.lang.AttestationException.CODE_UNKNOWN; +import static io.github.vvb2060.keyattestation.lang.AttestationException.*; import android.annotation.SuppressLint; import android.hardware.security.keymint.DeviceInfo; From 5aed1a3fb383b1ef96dc7bef42fc6ad51f5ae5cc Mon Sep 17 00:00:00 2001 From: Kofhisho Date: Mon, 13 Jul 2026 15:55:04 +0100 Subject: [PATCH 26/27] feat(EatClaim): Wildcard imports of `AuthorizationList` --- .../keyattestation/attestation/EatClaim.java | 43 +------------------ 1 file changed, 1 insertion(+), 42 deletions(-) diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/EatClaim.java b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/EatClaim.java index e9cdaa5b..a6977e7a 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/EatClaim.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/EatClaim.java @@ -1,47 +1,6 @@ package io.github.vvb2060.keyattestation.attestation; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KEYMASTER_TAG_TYPE_MASK; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ACTIVE_DATETIME; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ALGORITHM; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ALLOW_WHILE_ON_BODY; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_APPLICATION_ID; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ATTESTATION_APPLICATION_ID; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ATTESTATION_ID_BRAND; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ATTESTATION_ID_DEVICE; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ATTESTATION_ID_MANUFACTURER; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ATTESTATION_ID_MEID; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ATTESTATION_ID_MODEL; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ATTESTATION_ID_PRODUCT; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ATTESTATION_ID_SERIAL; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_AUTH_TIMEOUT; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_BLOCK_MODE; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_BOOT_PATCHLEVEL; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_CALLER_NONCE; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_DEVICE_UNIQUE_ATTESTATION; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_DIGEST; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_EARLY_BOOT_ONLY; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_EC_CURVE; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_IDENTITY_CREDENTIAL_KEY; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_KDF; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_KEY_SIZE; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_MIN_MAC_LENGTH; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_NO_AUTH_REQUIRED; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ORIGIN; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ORIGINATION_EXPIRE_DATETIME; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_OS_PATCHLEVEL; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_OS_VERSION; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_PADDING; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_PURPOSE; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ROLLBACK_RESISTANCE; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_ROLLBACK_RESISTANT; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_RSA_OAEP_MGF_DIGEST; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_RSA_PUBLIC_EXPONENT; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_TRUSTED_CONFIRMATION_REQUIRED; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_TRUSTED_USER_PRESENCE_REQUIRED; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_UNLOCKED_DEVICE_REQUIRED; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_USAGE_EXPIRE_DATETIME; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_USER_AUTH_TYPE; -import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.KM_TAG_VENDOR_PATCHLEVEL; +import static io.github.vvb2060.keyattestation.attestation.AuthorizationList.*; class EatClaim { public static final int IAT = 6; From f3951ed209191ce9b2b9b9b325058552ec521f0c Mon Sep 17 00:00:00 2001 From: Kofhisho Date: Mon, 13 Jul 2026 19:58:52 +0100 Subject: [PATCH 27/27] fix(network): Abort stuck connection on refresh timeout --- .../keyattestation/attestation/RevocationList.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/RevocationList.java b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/RevocationList.java index 8f508902..91528069 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/attestation/RevocationList.java +++ b/app/src/main/java/io/github/vvb2060/keyattestation/attestation/RevocationList.java @@ -22,6 +22,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import io.github.vvb2060.keyattestation.AppApplication; import io.github.vvb2060.keyattestation.R; @@ -89,11 +90,12 @@ private static void saveToCache(JSONObject fullJson) { } } - private static NetworkResult fetchFromNetwork(String statusUrl, long cachedTime) { + private static NetworkResult fetchFromNetwork(String statusUrl, long cachedTime, AtomicReference connRef) { HttpURLConnection connection = null; try { URL url = new URL(statusUrl); connection = (HttpURLConnection) url.openConnection(); + connRef.set(connection); connection.setRequestMethod("GET"); connection.setConnectTimeout(10_000); connection.setReadTimeout(20_000); @@ -132,12 +134,15 @@ private static NetworkResult fetchFromNetwork(String statusUrl, long cachedTime) } private static NetworkResult fetchNetworkWithTimeout(String url, long cachedTime) { - Future future = networkExecutor.submit(() -> fetchFromNetwork(url, cachedTime)); + var connRef = new AtomicReference(); + Future future = networkExecutor.submit(() -> fetchFromNetwork(url, cachedTime, connRef)); try { return future.get(3, TimeUnit.SECONDS); } catch (Exception e) { Log.w(TAG, "Network fetch dropped gracefully (Hard 3-second DNS/Connection Timeout)"); future.cancel(true); + var connection = connRef.get(); + if (connection != null) connection.disconnect(); return null; } }