From cceaa91ae77553af1b393e134dbaed6658c10ee2 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Thu, 16 Jul 2026 19:35:35 -0700 Subject: [PATCH 1/7] ADFA-4633: Remove BouncyCastle by replacing X.509 cert generation with pure-Java DER encoder Replaces both usages of BouncyCastle (ToolsManager.generateKeystore and AdbKey certificate creation) with a minimal self-signed X.509 DER encoder written in standard Java/Kotlin APIs, eliminating the bcprov-jdk18on and bcpkix-jdk18on runtime dependencies entirely. Co-Authored-By: Claude Sonnet 4.6 --- common/build.gradle.kts | 3 - .../androidide/managers/ToolsManager.java | 25 +-- .../itsaky/androidide/utils/SelfSignedCert.kt | 182 ++++++++++++++++++ gradle/libs.versions.toml | 3 - settings.gradle.kts | 8 - subprojects/shizuku-manager/build.gradle.kts | 1 - .../java/moe/shizuku/manager/adb/AdbKey.kt | 31 +-- 7 files changed, 196 insertions(+), 57 deletions(-) create mode 100644 common/src/main/java/com/itsaky/androidide/utils/SelfSignedCert.kt diff --git a/common/build.gradle.kts b/common/build.gradle.kts index bc1f3141b1..131fbf3806 100755 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -51,7 +51,4 @@ dependencies { // brotli4j implementation(libs.brotli4j) - implementation(libs.bcprov.jdk18on) - implementation(libs.bcpkix.jdk18on) - } diff --git a/common/src/main/java/com/itsaky/androidide/managers/ToolsManager.java b/common/src/main/java/com/itsaky/androidide/managers/ToolsManager.java index 92090781a8..8e31e60732 100755 --- a/common/src/main/java/com/itsaky/androidide/managers/ToolsManager.java +++ b/common/src/main/java/com/itsaky/androidide/managers/ToolsManager.java @@ -34,6 +34,7 @@ import com.itsaky.androidide.app.configuration.IJdkDistributionProvider; import com.itsaky.androidide.utils.Environment; import com.itsaky.androidide.utils.IoUtilsKt; +import com.itsaky.androidide.utils.SelfSignedCertUtils; import java.io.File; import java.io.FileOutputStream; import java.io.FileReader; @@ -47,20 +48,12 @@ import java.security.KeyPairGenerator; import java.security.KeyStore; import java.security.SecureRandom; -import java.security.Security; import java.security.cert.X509Certificate; import java.util.Arrays; import java.util.Date; import java.util.Objects; import java.util.Properties; import java.util.concurrent.CompletableFuture; -import org.bouncycastle.asn1.x500.X500Name; -import org.bouncycastle.cert.X509v3CertificateBuilder; -import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; -import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; -import org.bouncycastle.jce.provider.BouncyCastleProvider; -import org.bouncycastle.operator.ContentSigner; -import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.jetbrains.annotations.Contract; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -206,20 +199,12 @@ private static void generateKeystore() { Date now = new Date(); Date expiry = new Date(now.getTime() + Environment.KEYSTORE_EXPIRY_5YRS); - X500Name issuer = new X500Name(generateIssuerDN()); - X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( - issuer, + X509Certificate certificate = SelfSignedCertUtils.generateSelfSignedCert( + keyPair, + generateIssuerDN(), BigInteger.valueOf(System.currentTimeMillis()), now, - expiry, - issuer, - keyPair.getPublic()); - - ContentSigner signer = new JcaContentSignerBuilder("SHA256WithRSAEncryption") - .build(keyPair.getPrivate()); - - X509Certificate certificate = new JcaX509CertificateConverter() - .getCertificate(certBuilder.build(signer)); + expiry); KeyStore keyStore = KeyStore.getInstance("PKCS12"); keyStore.load(null, null); diff --git a/common/src/main/java/com/itsaky/androidide/utils/SelfSignedCert.kt b/common/src/main/java/com/itsaky/androidide/utils/SelfSignedCert.kt new file mode 100644 index 0000000000..8a151b682f --- /dev/null +++ b/common/src/main/java/com/itsaky/androidide/utils/SelfSignedCert.kt @@ -0,0 +1,182 @@ +@file:JvmName("SelfSignedCertUtils") + +package com.itsaky.androidide.utils + +import java.io.ByteArrayOutputStream +import java.math.BigInteger +import java.security.KeyPair +import java.security.Signature +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.util.Calendar +import java.util.Date + +/** + * Generates a self-signed X.509 v3 certificate using only standard Java SE / Android APIs — + * no BouncyCastle required. Encodes the DER structure manually. + * + * Supports a simple distinguished name with C, O, and CN components (empty values are omitted). + */ +fun generateSelfSignedCert( + keyPair: KeyPair, + dn: String, + serial: BigInteger, + notBefore: Date, + notAfter: Date, +): X509Certificate { + val tbs = buildTbsCertificate(keyPair.public.encoded, dn, serial, notBefore, notAfter) + val sig = Signature.getInstance("SHA256WithRSA") + sig.initSign(keyPair.private) + sig.update(tbs) + val sigBytes = sig.sign() + + val certDer = derSeq(tbs + sha256WithRsaAlgId() + derBitString(sigBytes)) + return CertificateFactory.getInstance("X.509") + .generateCertificate(certDer.inputStream()) as X509Certificate +} + +// --------------------------------------------------------------------------- +// TBSCertificate +// --------------------------------------------------------------------------- + +private fun buildTbsCertificate( + spkiBytes: ByteArray, + dn: String, + serial: BigInteger, + notBefore: Date, + notAfter: Date, +): ByteArray { + val version = derContextTagged(0, derInteger(BigInteger.TWO)) // v3 + val serialNum = derInteger(serial) + val sigAlg = sha256WithRsaAlgId() + val issuer = encodeDn(dn) + val validity = derSeq(derTime(notBefore) + derTime(notAfter)) + val subject = issuer // self-signed + val spki = spkiBytes // already DER-encoded by JCA + + return derSeq(version + serialNum + sigAlg + issuer + validity + subject + spki) +} + +// --------------------------------------------------------------------------- +// Distinguished Name encoding +// Parses "C=XX, O=Foo, CN=Bar"-style strings; skips RDNs with empty values. +// --------------------------------------------------------------------------- + +// OIDs for common attribute types +private val OID_COMMON_NAME = oidBytes(2, 5, 4, 3) +private val OID_ORGANIZATION = oidBytes(2, 5, 4, 10) +private val OID_COUNTRY = oidBytes(2, 5, 4, 6) + +private fun encodeDn(dn: String): ByteArray { + val rdns = mutableListOf() + for (part in dn.split(",")) { + val eq = part.indexOf('=') + if (eq < 0) continue + val key = part.substring(0, eq).trim().uppercase() + val value = part.substring(eq + 1).trim() + if (value.isEmpty()) continue + + val oidBytes = when (key) { + "CN" -> OID_COMMON_NAME + "O" -> OID_ORGANIZATION + "C" -> OID_COUNTRY + else -> continue + } + val attrType = derSeq(oidBytes + derUtf8String(value)) + rdns += derSet(attrType) + } + return derSeq(rdns.fold(ByteArray(0)) { acc, b -> acc + b }) +} + +// --------------------------------------------------------------------------- +// Algorithm identifier: SHA256WithRSA (OID 1.2.840.113549.1.1.11) + NULL +// --------------------------------------------------------------------------- + +private val SHA256_WITH_RSA_OID = oidBytes(1, 2, 840, 113549, 1, 1, 11) + +private fun sha256WithRsaAlgId(): ByteArray = + derSeq(SHA256_WITH_RSA_OID + byteArrayOf(0x05, 0x00)) // NULL + +// --------------------------------------------------------------------------- +// DER encoding primitives +// --------------------------------------------------------------------------- + +private fun derSeq(content: ByteArray): ByteArray = tlv(0x30, content) +private fun derSet(content: ByteArray): ByteArray = tlv(0x31, content) +private fun derBitString(bytes: ByteArray): ByteArray = tlv(0x03, byteArrayOf(0x00) + bytes) +private fun derUtf8String(s: String): ByteArray = tlv(0x0C, s.toByteArray(Charsets.UTF_8)) + +private fun derInteger(value: BigInteger): ByteArray = + // BigInteger.toByteArray() already prepends a 0x00 sign byte for positive integers whose + // high bit would otherwise be set — exactly what DER requires. + tlv(0x02, value.toByteArray()) + +private fun derContextTagged(tag: Int, content: ByteArray): ByteArray = + tlv(0xA0 or tag, content) + +private fun derTime(date: Date): ByteArray { + // GeneralizedTime (tag 0x18) for dates in or after 2050, UTCTime (tag 0x17) otherwise. + val cal = Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC")) + cal.time = date + return if (cal.get(Calendar.YEAR) >= 2050) { + val s = "%04d%02d%02d%02d%02d%02dZ".format( + cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH), + cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE), cal.get(Calendar.SECOND) + ) + tlv(0x18, s.toByteArray(Charsets.US_ASCII)) + } else { + val s = "%02d%02d%02d%02d%02d%02dZ".format( + cal.get(Calendar.YEAR) % 100, cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH), + cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE), cal.get(Calendar.SECOND) + ) + tlv(0x17, s.toByteArray(Charsets.US_ASCII)) + } +} + +// --------------------------------------------------------------------------- +// OID encoding helpers +// --------------------------------------------------------------------------- + +private fun oidBytes(vararg arcs: Int): ByteArray { + val out = ByteArrayOutputStream() + // First two arcs are combined: first*40 + second + out.write(encodeBase128(arcs[0] * 40 + arcs[1])) + for (i in 2 until arcs.size) { + out.write(encodeBase128(arcs[i])) + } + return tlv(0x06, out.toByteArray()) +} + +private fun encodeBase128(value: Int): ByteArray { + if (value == 0) return byteArrayOf(0) + val buf = ByteArray(5) + var v = value + var pos = 4 + while (v > 0) { + buf[pos--] = (v and 0x7F).toByte() + v = v ushr 7 + } + val start = pos + 1 + val result = ByteArray(5 - start) + for (i in result.indices) { + result[i] = if (i < result.size - 1) (buf[start + i].toInt() or 0x80).toByte() else buf[start + i] + } + return result +} + +// --------------------------------------------------------------------------- +// Type-Length-Value builder +// --------------------------------------------------------------------------- + +private fun tlv(tag: Int, content: ByteArray): ByteArray { + val out = ByteArrayOutputStream() + out.write(tag) + val len = content.size + when { + len < 0x80 -> out.write(len) + len < 0x100 -> { out.write(0x81); out.write(len) } + else -> { out.write(0x82); out.write(len ushr 8); out.write(len and 0xFF) } + } + out.write(content) + return out.toByteArray() +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 714f8f7941..2d7d08314c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,6 @@ agp = "8.8.2" agp-tooling = "8.11.0" androidx-sqlite = "2.6.2" appcompatVersion = "1.7.1" -bcprovJdk18on = "1.80" colorpickerview = "2.3.0" commonsCompress = "1.27.1" tukaaniXz = "1.9" @@ -102,8 +101,6 @@ androidx-security-crypto = { module = "androidx.security:security-crypto", versi androidx-sqlite-ktx = { module = "androidx.sqlite:sqlite-ktx", version.ref = "androidx-sqlite" } androidx-sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "androidx-sqlite" } androidx-viewpager2-v110beta02 = { module = "androidx.viewpager2:viewpager2", version.ref = "viewpager2" } -bcpkix-jdk18on = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bcprovJdk18on" } -bcprov-jdk18on = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bcprovJdk18on" } colorpickerview = { module = "com.github.skydoves:colorpickerview", version.ref = "colorpickerview" } commons-compress = { module = "org.apache.commons:commons-compress", version.ref = "commonsCompress" } tukaani-xz = { module = "org.tukaani:xz", version.ref = "tukaaniXz" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 9cdf548b86..61006df155 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -81,14 +81,6 @@ dependencyResolutionManagement { } } -buildscript { - repositories { - mavenCentral() - } - dependencies { - classpath("org.bouncycastle:bcutil-jdk18on:1.78.1") - } -} FDroidConfig.load(rootDir) diff --git a/subprojects/shizuku-manager/build.gradle.kts b/subprojects/shizuku-manager/build.gradle.kts index 61d9c4e8a1..71e57e0ac9 100644 --- a/subprojects/shizuku-manager/build.gradle.kts +++ b/subprojects/shizuku-manager/build.gradle.kts @@ -55,7 +55,6 @@ dependencies { implementation(libs.libsu.core) implementation(libs.common.hiddenApiBypass) implementation(libs.boringssl) - implementation(libs.bcpkix.jdk18on) //noinspection UseTomlInstead implementation("org.lsposed.libcxx:libcxx:${BuildConfig.NDK_VERSION}") diff --git a/subprojects/shizuku-manager/src/main/java/moe/shizuku/manager/adb/AdbKey.kt b/subprojects/shizuku-manager/src/main/java/moe/shizuku/manager/adb/AdbKey.kt index ea98a93bc3..5333b3e932 100644 --- a/subprojects/shizuku-manager/src/main/java/moe/shizuku/manager/adb/AdbKey.kt +++ b/subprojects/shizuku-manager/src/main/java/moe/shizuku/manager/adb/AdbKey.kt @@ -10,24 +10,20 @@ import android.util.Log import androidx.annotation.RequiresApi import androidx.core.content.edit import com.itsaky.androidide.buildinfo.BuildInfo +import com.itsaky.androidide.utils.generateSelfSignedCert import moe.shizuku.manager.utils.unsafeLazy -import org.bouncycastle.asn1.x500.X500Name -import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo -import org.bouncycastle.cert.X509v3CertificateBuilder -import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder -import java.io.ByteArrayInputStream import java.math.BigInteger import java.net.Socket import java.nio.ByteBuffer import java.nio.ByteOrder import java.security.Key import java.security.KeyFactory +import java.security.KeyPair import java.security.KeyPairGenerator import java.security.KeyStore import java.security.Principal import java.security.PrivateKey import java.security.SecureRandom -import java.security.cert.CertificateFactory import java.security.cert.X509Certificate import java.security.interfaces.RSAPrivateKey import java.security.interfaces.RSAPublicKey @@ -35,7 +31,6 @@ import java.security.spec.PKCS8EncodedKeySpec import java.security.spec.RSAKeyGenParameterSpec import java.security.spec.RSAPublicKeySpec import java.util.Date -import java.util.Locale import javax.crypto.Cipher import javax.crypto.KeyGenerator import javax.crypto.spec.GCMParameterSpec @@ -313,21 +308,13 @@ class AdbKey( this.publicKey = KeyFactory.getInstance("RSA").generatePublic(RSAPublicKeySpec(privateKey.modulus, RSAKeyGenParameterSpec.F4)) as RSAPublicKey - val signer = JcaContentSignerBuilder("SHA256withRSA").build(privateKey) - val x509Certificate = - X509v3CertificateBuilder( - X500Name("CN=00"), - BigInteger.ONE, - Date(0), - Date(2461449600 * 1000), - Locale.ROOT, - X500Name("CN=00"), - SubjectPublicKeyInfo.getInstance(publicKey.encoded), - ).build(signer) - this.certificate = - CertificateFactory - .getInstance("X.509") - .generateCertificate(ByteArrayInputStream(x509Certificate.encoded)) as X509Certificate + this.certificate = generateSelfSignedCert( + KeyPair(publicKey, privateKey), + "CN=00", + BigInteger.ONE, + Date(0), + Date(2461449600 * 1000), + ) Log.d(TAG, privateKey.toString()) } From b5cc8c84d19f9b23a76a73719233865d8ff9b6d6 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 17 Jul 2026 09:59:49 -0700 Subject: [PATCH 2/7] Apply spotless formatting Co-Authored-By: Claude Sonnet 4.6 --- common/build.gradle.kts | 1 - .../androidide/managers/ToolsManager.java | 3 +- .../itsaky/androidide/utils/SelfSignedCert.kt | 228 ++++++++++-------- settings.gradle.kts | 1 - .../java/moe/shizuku/manager/adb/AdbKey.kt | 15 +- 5 files changed, 136 insertions(+), 112 deletions(-) diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 131fbf3806..9fbc503015 100755 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -50,5 +50,4 @@ dependencies { // brotli4j implementation(libs.brotli4j) - } diff --git a/common/src/main/java/com/itsaky/androidide/managers/ToolsManager.java b/common/src/main/java/com/itsaky/androidide/managers/ToolsManager.java index 8e31e60732..c810b56f9c 100755 --- a/common/src/main/java/com/itsaky/androidide/managers/ToolsManager.java +++ b/common/src/main/java/com/itsaky/androidide/managers/ToolsManager.java @@ -189,10 +189,9 @@ private static void generateKeystore() { LOG.debug("Generating keystore at: {}", keystorePath); - String storePassword = generateRandomPassword(Environment.KEYSTORE_PWD_LEN); - KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); keyGen.initialize(Environment.KEYSTORE_KEY_SIZE); KeyPair keyPair = keyGen.generateKeyPair(); diff --git a/common/src/main/java/com/itsaky/androidide/utils/SelfSignedCert.kt b/common/src/main/java/com/itsaky/androidide/utils/SelfSignedCert.kt index 8a151b682f..70f4a01efe 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/SelfSignedCert.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/SelfSignedCert.kt @@ -18,21 +18,22 @@ import java.util.Date * Supports a simple distinguished name with C, O, and CN components (empty values are omitted). */ fun generateSelfSignedCert( - keyPair: KeyPair, - dn: String, - serial: BigInteger, - notBefore: Date, - notAfter: Date, + keyPair: KeyPair, + dn: String, + serial: BigInteger, + notBefore: Date, + notAfter: Date, ): X509Certificate { - val tbs = buildTbsCertificate(keyPair.public.encoded, dn, serial, notBefore, notAfter) - val sig = Signature.getInstance("SHA256WithRSA") - sig.initSign(keyPair.private) - sig.update(tbs) - val sigBytes = sig.sign() - - val certDer = derSeq(tbs + sha256WithRsaAlgId() + derBitString(sigBytes)) - return CertificateFactory.getInstance("X.509") - .generateCertificate(certDer.inputStream()) as X509Certificate + val tbs = buildTbsCertificate(keyPair.public.encoded, dn, serial, notBefore, notAfter) + val sig = Signature.getInstance("SHA256WithRSA") + sig.initSign(keyPair.private) + sig.update(tbs) + val sigBytes = sig.sign() + + val certDer = derSeq(tbs + sha256WithRsaAlgId() + derBitString(sigBytes)) + return CertificateFactory + .getInstance("X.509") + .generateCertificate(certDer.inputStream()) as X509Certificate } // --------------------------------------------------------------------------- @@ -40,21 +41,21 @@ fun generateSelfSignedCert( // --------------------------------------------------------------------------- private fun buildTbsCertificate( - spkiBytes: ByteArray, - dn: String, - serial: BigInteger, - notBefore: Date, - notAfter: Date, + spkiBytes: ByteArray, + dn: String, + serial: BigInteger, + notBefore: Date, + notAfter: Date, ): ByteArray { - val version = derContextTagged(0, derInteger(BigInteger.TWO)) // v3 - val serialNum = derInteger(serial) - val sigAlg = sha256WithRsaAlgId() - val issuer = encodeDn(dn) - val validity = derSeq(derTime(notBefore) + derTime(notAfter)) - val subject = issuer // self-signed - val spki = spkiBytes // already DER-encoded by JCA - - return derSeq(version + serialNum + sigAlg + issuer + validity + subject + spki) + val version = derContextTagged(0, derInteger(BigInteger.TWO)) // v3 + val serialNum = derInteger(serial) + val sigAlg = sha256WithRsaAlgId() + val issuer = encodeDn(dn) + val validity = derSeq(derTime(notBefore) + derTime(notAfter)) + val subject = issuer // self-signed + val spki = spkiBytes // already DER-encoded by JCA + + return derSeq(version + serialNum + sigAlg + issuer + validity + subject + spki) } // --------------------------------------------------------------------------- @@ -68,24 +69,25 @@ private val OID_ORGANIZATION = oidBytes(2, 5, 4, 10) private val OID_COUNTRY = oidBytes(2, 5, 4, 6) private fun encodeDn(dn: String): ByteArray { - val rdns = mutableListOf() - for (part in dn.split(",")) { - val eq = part.indexOf('=') - if (eq < 0) continue - val key = part.substring(0, eq).trim().uppercase() - val value = part.substring(eq + 1).trim() - if (value.isEmpty()) continue - - val oidBytes = when (key) { - "CN" -> OID_COMMON_NAME - "O" -> OID_ORGANIZATION - "C" -> OID_COUNTRY - else -> continue - } - val attrType = derSeq(oidBytes + derUtf8String(value)) - rdns += derSet(attrType) - } - return derSeq(rdns.fold(ByteArray(0)) { acc, b -> acc + b }) + val rdns = mutableListOf() + for (part in dn.split(",")) { + val eq = part.indexOf('=') + if (eq < 0) continue + val key = part.substring(0, eq).trim().uppercase() + val value = part.substring(eq + 1).trim() + if (value.isEmpty()) continue + + val oidBytes = + when (key) { + "CN" -> OID_COMMON_NAME + "O" -> OID_ORGANIZATION + "C" -> OID_COUNTRY + else -> continue + } + val attrType = derSeq(oidBytes + derUtf8String(value)) + rdns += derSet(attrType) + } + return derSeq(rdns.fold(ByteArray(0)) { acc, b -> acc + b }) } // --------------------------------------------------------------------------- @@ -94,43 +96,57 @@ private fun encodeDn(dn: String): ByteArray { private val SHA256_WITH_RSA_OID = oidBytes(1, 2, 840, 113549, 1, 1, 11) -private fun sha256WithRsaAlgId(): ByteArray = - derSeq(SHA256_WITH_RSA_OID + byteArrayOf(0x05, 0x00)) // NULL +private fun sha256WithRsaAlgId(): ByteArray = derSeq(SHA256_WITH_RSA_OID + byteArrayOf(0x05, 0x00)) // NULL // --------------------------------------------------------------------------- // DER encoding primitives // --------------------------------------------------------------------------- private fun derSeq(content: ByteArray): ByteArray = tlv(0x30, content) + private fun derSet(content: ByteArray): ByteArray = tlv(0x31, content) + private fun derBitString(bytes: ByteArray): ByteArray = tlv(0x03, byteArrayOf(0x00) + bytes) + private fun derUtf8String(s: String): ByteArray = tlv(0x0C, s.toByteArray(Charsets.UTF_8)) private fun derInteger(value: BigInteger): ByteArray = - // BigInteger.toByteArray() already prepends a 0x00 sign byte for positive integers whose - // high bit would otherwise be set — exactly what DER requires. - tlv(0x02, value.toByteArray()) + // BigInteger.toByteArray() already prepends a 0x00 sign byte for positive integers whose + // high bit would otherwise be set — exactly what DER requires. + tlv(0x02, value.toByteArray()) -private fun derContextTagged(tag: Int, content: ByteArray): ByteArray = - tlv(0xA0 or tag, content) +private fun derContextTagged( + tag: Int, + content: ByteArray, +): ByteArray = tlv(0xA0 or tag, content) private fun derTime(date: Date): ByteArray { - // GeneralizedTime (tag 0x18) for dates in or after 2050, UTCTime (tag 0x17) otherwise. - val cal = Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC")) - cal.time = date - return if (cal.get(Calendar.YEAR) >= 2050) { - val s = "%04d%02d%02d%02d%02d%02dZ".format( - cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH), - cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE), cal.get(Calendar.SECOND) - ) - tlv(0x18, s.toByteArray(Charsets.US_ASCII)) - } else { - val s = "%02d%02d%02d%02d%02d%02dZ".format( - cal.get(Calendar.YEAR) % 100, cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH), - cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE), cal.get(Calendar.SECOND) - ) - tlv(0x17, s.toByteArray(Charsets.US_ASCII)) - } + // GeneralizedTime (tag 0x18) for dates in or after 2050, UTCTime (tag 0x17) otherwise. + val cal = Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC")) + cal.time = date + return if (cal.get(Calendar.YEAR) >= 2050) { + val s = + "%04d%02d%02d%02d%02d%02dZ".format( + cal.get(Calendar.YEAR), + cal.get(Calendar.MONTH) + 1, + cal.get(Calendar.DAY_OF_MONTH), + cal.get(Calendar.HOUR_OF_DAY), + cal.get(Calendar.MINUTE), + cal.get(Calendar.SECOND), + ) + tlv(0x18, s.toByteArray(Charsets.US_ASCII)) + } else { + val s = + "%02d%02d%02d%02d%02d%02dZ".format( + cal.get(Calendar.YEAR) % 100, + cal.get(Calendar.MONTH) + 1, + cal.get(Calendar.DAY_OF_MONTH), + cal.get(Calendar.HOUR_OF_DAY), + cal.get(Calendar.MINUTE), + cal.get(Calendar.SECOND), + ) + tlv(0x17, s.toByteArray(Charsets.US_ASCII)) + } } // --------------------------------------------------------------------------- @@ -138,45 +154,55 @@ private fun derTime(date: Date): ByteArray { // --------------------------------------------------------------------------- private fun oidBytes(vararg arcs: Int): ByteArray { - val out = ByteArrayOutputStream() - // First two arcs are combined: first*40 + second - out.write(encodeBase128(arcs[0] * 40 + arcs[1])) - for (i in 2 until arcs.size) { - out.write(encodeBase128(arcs[i])) - } - return tlv(0x06, out.toByteArray()) + val out = ByteArrayOutputStream() + // First two arcs are combined: first*40 + second + out.write(encodeBase128(arcs[0] * 40 + arcs[1])) + for (i in 2 until arcs.size) { + out.write(encodeBase128(arcs[i])) + } + return tlv(0x06, out.toByteArray()) } private fun encodeBase128(value: Int): ByteArray { - if (value == 0) return byteArrayOf(0) - val buf = ByteArray(5) - var v = value - var pos = 4 - while (v > 0) { - buf[pos--] = (v and 0x7F).toByte() - v = v ushr 7 - } - val start = pos + 1 - val result = ByteArray(5 - start) - for (i in result.indices) { - result[i] = if (i < result.size - 1) (buf[start + i].toInt() or 0x80).toByte() else buf[start + i] - } - return result + if (value == 0) return byteArrayOf(0) + val buf = ByteArray(5) + var v = value + var pos = 4 + while (v > 0) { + buf[pos--] = (v and 0x7F).toByte() + v = v ushr 7 + } + val start = pos + 1 + val result = ByteArray(5 - start) + for (i in result.indices) { + result[i] = if (i < result.size - 1) (buf[start + i].toInt() or 0x80).toByte() else buf[start + i] + } + return result } // --------------------------------------------------------------------------- // Type-Length-Value builder // --------------------------------------------------------------------------- -private fun tlv(tag: Int, content: ByteArray): ByteArray { - val out = ByteArrayOutputStream() - out.write(tag) - val len = content.size - when { - len < 0x80 -> out.write(len) - len < 0x100 -> { out.write(0x81); out.write(len) } - else -> { out.write(0x82); out.write(len ushr 8); out.write(len and 0xFF) } - } - out.write(content) - return out.toByteArray() +private fun tlv( + tag: Int, + content: ByteArray, +): ByteArray { + val out = ByteArrayOutputStream() + out.write(tag) + val len = content.size + when { + len < 0x80 -> out.write(len) + len < 0x100 -> { + out.write(0x81) + out.write(len) + } + else -> { + out.write(0x82) + out.write(len ushr 8) + out.write(len and 0xFF) + } + } + out.write(content) + return out.toByteArray() } diff --git a/settings.gradle.kts b/settings.gradle.kts index 86884d4001..29fb8afcd8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -79,7 +79,6 @@ dependencyResolutionManagement { } } - FDroidConfig.load(rootDir) if (FDroidConfig.hasRead && FDroidConfig.isFDroidBuild) { diff --git a/subprojects/shizuku-manager/src/main/java/moe/shizuku/manager/adb/AdbKey.kt b/subprojects/shizuku-manager/src/main/java/moe/shizuku/manager/adb/AdbKey.kt index 5333b3e932..92c33ea0f2 100644 --- a/subprojects/shizuku-manager/src/main/java/moe/shizuku/manager/adb/AdbKey.kt +++ b/subprojects/shizuku-manager/src/main/java/moe/shizuku/manager/adb/AdbKey.kt @@ -308,13 +308,14 @@ class AdbKey( this.publicKey = KeyFactory.getInstance("RSA").generatePublic(RSAPublicKeySpec(privateKey.modulus, RSAKeyGenParameterSpec.F4)) as RSAPublicKey - this.certificate = generateSelfSignedCert( - KeyPair(publicKey, privateKey), - "CN=00", - BigInteger.ONE, - Date(0), - Date(2461449600 * 1000), - ) + this.certificate = + generateSelfSignedCert( + KeyPair(publicKey, privateKey), + "CN=00", + BigInteger.ONE, + Date(0), + Date(2461449600 * 1000), + ) Log.d(TAG, privateKey.toString()) } From 589a859ed130e9a0394b8abdef117d51d0bc76ae Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 17 Jul 2026 10:10:18 -0700 Subject: [PATCH 3/7] Fix nitpicks in SelfSignedCert.kt and AdbKey.kt - Replace em dashes with ASCII hyphens in two comments - Remove decorative separator lines from section headers - Add L suffix to long literal in AdbKey to prevent integer overflow Co-Authored-By: Claude Sonnet 4.6 --- .../itsaky/androidide/utils/SelfSignedCert.kt | 16 ++-------------- .../main/java/moe/shizuku/manager/adb/AdbKey.kt | 2 +- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/common/src/main/java/com/itsaky/androidide/utils/SelfSignedCert.kt b/common/src/main/java/com/itsaky/androidide/utils/SelfSignedCert.kt index 70f4a01efe..30155f7f84 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/SelfSignedCert.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/SelfSignedCert.kt @@ -12,7 +12,7 @@ import java.util.Calendar import java.util.Date /** - * Generates a self-signed X.509 v3 certificate using only standard Java SE / Android APIs — + * Generates a self-signed X.509 v3 certificate using only standard Java SE / Android APIs - * no BouncyCastle required. Encodes the DER structure manually. * * Supports a simple distinguished name with C, O, and CN components (empty values are omitted). @@ -36,9 +36,7 @@ fun generateSelfSignedCert( .generateCertificate(certDer.inputStream()) as X509Certificate } -// --------------------------------------------------------------------------- // TBSCertificate -// --------------------------------------------------------------------------- private fun buildTbsCertificate( spkiBytes: ByteArray, @@ -58,10 +56,8 @@ private fun buildTbsCertificate( return derSeq(version + serialNum + sigAlg + issuer + validity + subject + spki) } -// --------------------------------------------------------------------------- // Distinguished Name encoding // Parses "C=XX, O=Foo, CN=Bar"-style strings; skips RDNs with empty values. -// --------------------------------------------------------------------------- // OIDs for common attribute types private val OID_COMMON_NAME = oidBytes(2, 5, 4, 3) @@ -90,17 +86,13 @@ private fun encodeDn(dn: String): ByteArray { return derSeq(rdns.fold(ByteArray(0)) { acc, b -> acc + b }) } -// --------------------------------------------------------------------------- // Algorithm identifier: SHA256WithRSA (OID 1.2.840.113549.1.1.11) + NULL -// --------------------------------------------------------------------------- private val SHA256_WITH_RSA_OID = oidBytes(1, 2, 840, 113549, 1, 1, 11) private fun sha256WithRsaAlgId(): ByteArray = derSeq(SHA256_WITH_RSA_OID + byteArrayOf(0x05, 0x00)) // NULL -// --------------------------------------------------------------------------- // DER encoding primitives -// --------------------------------------------------------------------------- private fun derSeq(content: ByteArray): ByteArray = tlv(0x30, content) @@ -112,7 +104,7 @@ private fun derUtf8String(s: String): ByteArray = tlv(0x0C, s.toByteArray(Charse private fun derInteger(value: BigInteger): ByteArray = // BigInteger.toByteArray() already prepends a 0x00 sign byte for positive integers whose - // high bit would otherwise be set — exactly what DER requires. + // high bit would otherwise be set - exactly what DER requires. tlv(0x02, value.toByteArray()) private fun derContextTagged( @@ -149,9 +141,7 @@ private fun derTime(date: Date): ByteArray { } } -// --------------------------------------------------------------------------- // OID encoding helpers -// --------------------------------------------------------------------------- private fun oidBytes(vararg arcs: Int): ByteArray { val out = ByteArrayOutputStream() @@ -180,9 +170,7 @@ private fun encodeBase128(value: Int): ByteArray { return result } -// --------------------------------------------------------------------------- // Type-Length-Value builder -// --------------------------------------------------------------------------- private fun tlv( tag: Int, diff --git a/subprojects/shizuku-manager/src/main/java/moe/shizuku/manager/adb/AdbKey.kt b/subprojects/shizuku-manager/src/main/java/moe/shizuku/manager/adb/AdbKey.kt index 92c33ea0f2..b8ce81b36a 100644 --- a/subprojects/shizuku-manager/src/main/java/moe/shizuku/manager/adb/AdbKey.kt +++ b/subprojects/shizuku-manager/src/main/java/moe/shizuku/manager/adb/AdbKey.kt @@ -314,7 +314,7 @@ class AdbKey( "CN=00", BigInteger.ONE, Date(0), - Date(2461449600 * 1000), + Date(2461449600L * 1000), ) Log.d(TAG, privateKey.toString()) From 49706815ca8b2396dbb9fdf654905f1d7426884b Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 17 Jul 2026 10:37:53 -0700 Subject: [PATCH 4/7] Apply spotless formatting to SelfSignedCert.kt Co-Authored-By: Claude Sonnet 4.6 --- .../main/java/com/itsaky/androidide/utils/SelfSignedCert.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/common/src/main/java/com/itsaky/androidide/utils/SelfSignedCert.kt b/common/src/main/java/com/itsaky/androidide/utils/SelfSignedCert.kt index 30155f7f84..a0a761a6f8 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/SelfSignedCert.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/SelfSignedCert.kt @@ -180,11 +180,15 @@ private fun tlv( out.write(tag) val len = content.size when { - len < 0x80 -> out.write(len) + len < 0x80 -> { + out.write(len) + } + len < 0x100 -> { out.write(0x81) out.write(len) } + else -> { out.write(0x82) out.write(len ushr 8) From dc6374339d2e2f4aab7d47b064844f732a292523 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 17 Jul 2026 15:45:02 -0700 Subject: [PATCH 5/7] Fix two runtime-breaking bugs in SelfSignedCert.kt - BigInteger.TWO is API 33+ (NoSuchFieldError on Android 9-12); replace with BigInteger.valueOf(2) (as AdbKey.kt already did). - String.format with default locale corrupts UTCTime/GeneralizedTime bytes on Arabic/Persian/Bengali locales; pass Locale.ROOT explicitly. Co-Authored-By: Claude Sonnet 4.6 --- .../com/itsaky/androidide/utils/SelfSignedCert.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/common/src/main/java/com/itsaky/androidide/utils/SelfSignedCert.kt b/common/src/main/java/com/itsaky/androidide/utils/SelfSignedCert.kt index a0a761a6f8..97ce42dcb6 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/SelfSignedCert.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/SelfSignedCert.kt @@ -10,6 +10,7 @@ import java.security.cert.CertificateFactory import java.security.cert.X509Certificate import java.util.Calendar import java.util.Date +import java.util.Locale /** * Generates a self-signed X.509 v3 certificate using only standard Java SE / Android APIs - @@ -45,7 +46,7 @@ private fun buildTbsCertificate( notBefore: Date, notAfter: Date, ): ByteArray { - val version = derContextTagged(0, derInteger(BigInteger.TWO)) // v3 + val version = derContextTagged(0, derInteger(BigInteger.valueOf(2))) // v3 val serialNum = derInteger(serial) val sigAlg = sha256WithRsaAlgId() val issuer = encodeDn(dn) @@ -118,7 +119,9 @@ private fun derTime(date: Date): ByteArray { cal.time = date return if (cal.get(Calendar.YEAR) >= 2050) { val s = - "%04d%02d%02d%02d%02d%02dZ".format( + String.format( + Locale.ROOT, + "%04d%02d%02d%02d%02d%02dZ", cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH), @@ -129,7 +132,9 @@ private fun derTime(date: Date): ByteArray { tlv(0x18, s.toByteArray(Charsets.US_ASCII)) } else { val s = - "%02d%02d%02d%02d%02d%02dZ".format( + String.format( + Locale.ROOT, + "%02d%02d%02d%02d%02d%02dZ", cal.get(Calendar.YEAR) % 100, cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH), From 005b50ec36970bbbbbe8850abc3aad5895f83fe2 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 17 Jul 2026 17:12:05 -0700 Subject: [PATCH 6/7] Address remaining SelfSignedCert.kt review feedback - Encode countryName as PrintableString (tag 0x13) instead of UTF8String, per RFC 5280 App. A. - Generalize tlv() long-form length encoding to support content >= 64KiB (was hardcoded to 2 length octets, silently truncating longer content). - Split DN parts on unescaped commas only (RFC 4514-style backslash escaping), so values like "O=Foo\, Inc." don't get split into two RDNs. - Add round-trip unit tests: signature verification, a non-default-locale regression test, and an escaped-comma DN test. Co-Authored-By: Claude Sonnet 5 --- .../itsaky/androidide/utils/SelfSignedCert.kt | 76 ++++++++++++++----- .../androidide/utils/SelfSignedCertTest.kt | 60 +++++++++++++++ 2 files changed, 115 insertions(+), 21 deletions(-) create mode 100644 common/src/test/java/com/itsaky/androidide/utils/SelfSignedCertTest.kt diff --git a/common/src/main/java/com/itsaky/androidide/utils/SelfSignedCert.kt b/common/src/main/java/com/itsaky/androidide/utils/SelfSignedCert.kt index 97ce42dcb6..8c4aa61f92 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/SelfSignedCert.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/SelfSignedCert.kt @@ -67,26 +67,56 @@ private val OID_COUNTRY = oidBytes(2, 5, 4, 6) private fun encodeDn(dn: String): ByteArray { val rdns = mutableListOf() - for (part in dn.split(",")) { + for (part in splitDnParts(dn)) { val eq = part.indexOf('=') if (eq < 0) continue val key = part.substring(0, eq).trim().uppercase() val value = part.substring(eq + 1).trim() if (value.isEmpty()) continue - val oidBytes = + // RFC 5280 App. A pins countryName to PrintableString; other attributes may be UTF8String. + val (oidBytes, encodedValue) = when (key) { - "CN" -> OID_COMMON_NAME - "O" -> OID_ORGANIZATION - "C" -> OID_COUNTRY + "CN" -> OID_COMMON_NAME to derUtf8String(value) + "O" -> OID_ORGANIZATION to derUtf8String(value) + "C" -> OID_COUNTRY to derPrintableString(value) else -> continue } - val attrType = derSeq(oidBytes + derUtf8String(value)) + val attrType = derSeq(oidBytes + encodedValue) rdns += derSet(attrType) } return derSeq(rdns.fold(ByteArray(0)) { acc, b -> acc + b }) } +// Splits on unescaped commas per RFC 4514 (a backslash escapes the following character). +private fun splitDnParts(dn: String): List { + val parts = mutableListOf() + val current = StringBuilder() + var i = 0 + while (i < dn.length) { + val c = dn[i] + when { + c == '\\' && i + 1 < dn.length -> { + current.append(dn[i + 1]) + i += 2 + } + + c == ',' -> { + parts += current.toString() + current.setLength(0) + i++ + } + + else -> { + current.append(c) + i++ + } + } + } + parts += current.toString() + return parts +} + // Algorithm identifier: SHA256WithRSA (OID 1.2.840.113549.1.1.11) + NULL private val SHA256_WITH_RSA_OID = oidBytes(1, 2, 840, 113549, 1, 1, 11) @@ -103,6 +133,8 @@ private fun derBitString(bytes: ByteArray): ByteArray = tlv(0x03, byteArrayOf(0x private fun derUtf8String(s: String): ByteArray = tlv(0x0C, s.toByteArray(Charsets.UTF_8)) +private fun derPrintableString(s: String): ByteArray = tlv(0x13, s.toByteArray(Charsets.US_ASCII)) + private fun derInteger(value: BigInteger): ByteArray = // BigInteger.toByteArray() already prepends a 0x00 sign byte for positive integers whose // high bit would otherwise be set - exactly what DER requires. @@ -184,22 +216,24 @@ private fun tlv( val out = ByteArrayOutputStream() out.write(tag) val len = content.size - when { - len < 0x80 -> { - out.write(len) - } - - len < 0x100 -> { - out.write(0x81) - out.write(len) - } - - else -> { - out.write(0x82) - out.write(len ushr 8) - out.write(len and 0xFF) - } + if (len < 0x80) { + out.write(len) + } else { + val lenBytes = encodeLength(len) + out.write(0x80 or lenBytes.size) + out.write(lenBytes) } out.write(content) return out.toByteArray() } + +// Minimal big-endian byte encoding of a non-negative length, for DER's long-form length octets. +private fun encodeLength(len: Int): ByteArray { + val bytes = mutableListOf() + var l = len + while (l > 0) { + bytes.add(0, (l and 0xFF).toByte()) + l = l ushr 8 + } + return bytes.toByteArray() +} diff --git a/common/src/test/java/com/itsaky/androidide/utils/SelfSignedCertTest.kt b/common/src/test/java/com/itsaky/androidide/utils/SelfSignedCertTest.kt new file mode 100644 index 0000000000..a3243bda6e --- /dev/null +++ b/common/src/test/java/com/itsaky/androidide/utils/SelfSignedCertTest.kt @@ -0,0 +1,60 @@ +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import java.math.BigInteger +import java.security.KeyPairGenerator +import java.util.Date +import java.util.Locale + +/** Round-trip and regression coverage for the hand-rolled DER encoder in SelfSignedCert.kt. */ +class SelfSignedCertTest { + private fun rsaKeyPair() = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() + + @Test + fun `generated certificate round-trips through CertificateFactory and verifies`() { + val keyPair = rsaKeyPair() + val cert = + generateSelfSignedCert( + keyPair, + "C=US, O=Test Org, CN=test", + BigInteger.ONE, + Date(0), + Date(4102444800000), // 2100-01-01, exercises the GeneralizedTime branch + ) + + cert.verify(keyPair.public) + assertThat(cert.subjectX500Principal.name).contains("CN=test") + assertThat(cert.subjectX500Principal.name).contains("O=Test Org") + assertThat(cert.subjectX500Principal.name).contains("C=US") + } + + @Test + fun `certificate generation is unaffected by the default locale`() { + val original = Locale.getDefault() + try { + Locale.setDefault(Locale.Builder().setLanguage("ar").build()) + val keyPair = rsaKeyPair() + val cert = generateSelfSignedCert(keyPair, "CN=00", BigInteger.ONE, Date(0), Date(2461449600L * 1000)) + cert.verify(keyPair.public) + } finally { + Locale.setDefault(original) + } + } + + @Test + fun `escaped comma in a DN value does not split the RDN`() { + val keyPair = rsaKeyPair() + val cert = + generateSelfSignedCert( + keyPair, + "O=Foo\\, Inc., CN=test", + BigInteger.ONE, + Date(0), + Date(4102444800000), + ) + + cert.verify(keyPair.public) + assertThat(cert.subjectX500Principal.name).contains("Foo\\, Inc.") + } +} From 8c4f98653e076cb8c20326372ce6ad71f8697de0 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 17 Jul 2026 17:54:04 -0700 Subject: [PATCH 7/7] Fix Calendar locale bug and harden SelfSignedCert.kt's DER encoder Fixes found by an xhigh code-review pass (4 independent finder angles converged on the Calendar bug): - derTime called Calendar.getInstance(TimeZone) without a Locale, so on a device whose default locale uses a non-Gregorian calendar (e.g. Thai th-TH) it silently returned a BuddhistCalendar with YEAR = Gregorian + 543, corrupting cert validity dates for both the release keystore and ADB pairing. Pass Locale.ROOT explicitly. - derTime now also emits GeneralizedTime for years < 1950 (previously only checked the upper 2050 bound), matching BouncyCastle's Time class, and the UTCTime string is now derived from the GeneralizedTime one instead of duplicating the format/cal.get() calls. - derPrintableString validated nothing and encoded with plain US_ASCII, which silently replaces any non-ASCII character with '?' instead of failing; it now validates against PrintableString's legal charset. - encodeDn silently dropped empty-valued RDNs and unrecognized attribute keys, diverging from BouncyCastle's X500Name (which kept empty RDNs and threw on genuinely unrecognized ones). It now preserves empty values and throws IllegalArgumentException for malformed components or unsupported attribute types instead of silently dropping them. - Removed four one-line decorative section comments per CLAUDE.md's no-separator-comments rule. Test suite: the existing locale-regression test used "ar", which never changes the JVM's Calendar implementation and so never exercised the bug above; switched it to "th"-TH and added assertions on the decoded notBefore/notAfter (the prior test only checked cert.verify(), which validates the signature but not the date values, so it couldn't have caught this class of bug even with the right locale). Added coverage for the pre-1950 boundary, preserved empty DN values, and the three new IllegalArgumentException cases. Co-Authored-By: Claude Sonnet 5 --- .../itsaky/androidide/utils/SelfSignedCert.kt | 74 ++++++++---------- .../androidide/utils/SelfSignedCertTest.kt | 76 ++++++++++++++++--- 2 files changed, 98 insertions(+), 52 deletions(-) diff --git a/common/src/main/java/com/itsaky/androidide/utils/SelfSignedCert.kt b/common/src/main/java/com/itsaky/androidide/utils/SelfSignedCert.kt index 8c4aa61f92..b26a489198 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/SelfSignedCert.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/SelfSignedCert.kt @@ -16,7 +16,9 @@ import java.util.Locale * Generates a self-signed X.509 v3 certificate using only standard Java SE / Android APIs - * no BouncyCastle required. Encodes the DER structure manually. * - * Supports a simple distinguished name with C, O, and CN components (empty values are omitted). + * Supports a simple distinguished name with C, O, and CN components; empty values are encoded + * as empty strings (not omitted). Throws [IllegalArgumentException] for any other attribute + * type or a malformed "key=value" component. */ fun generateSelfSignedCert( keyPair: KeyPair, @@ -37,8 +39,6 @@ fun generateSelfSignedCert( .generateCertificate(certDer.inputStream()) as X509Certificate } -// TBSCertificate - private fun buildTbsCertificate( spkiBytes: ByteArray, dn: String, @@ -58,7 +58,7 @@ private fun buildTbsCertificate( } // Distinguished Name encoding -// Parses "C=XX, O=Foo, CN=Bar"-style strings; skips RDNs with empty values. +// Parses "C=XX, O=Foo, CN=Bar"-style strings. // OIDs for common attribute types private val OID_COMMON_NAME = oidBytes(2, 5, 4, 3) @@ -69,10 +69,9 @@ private fun encodeDn(dn: String): ByteArray { val rdns = mutableListOf() for (part in splitDnParts(dn)) { val eq = part.indexOf('=') - if (eq < 0) continue + require(eq >= 0) { "Malformed DN component: \"$part\"" } val key = part.substring(0, eq).trim().uppercase() val value = part.substring(eq + 1).trim() - if (value.isEmpty()) continue // RFC 5280 App. A pins countryName to PrintableString; other attributes may be UTF8String. val (oidBytes, encodedValue) = @@ -80,7 +79,7 @@ private fun encodeDn(dn: String): ByteArray { "CN" -> OID_COMMON_NAME to derUtf8String(value) "O" -> OID_ORGANIZATION to derUtf8String(value) "C" -> OID_COUNTRY to derPrintableString(value) - else -> continue + else -> throw IllegalArgumentException("Unsupported DN attribute type: \"$key\"") } val attrType = derSeq(oidBytes + encodedValue) rdns += derSet(attrType) @@ -123,8 +122,6 @@ private val SHA256_WITH_RSA_OID = oidBytes(1, 2, 840, 113549, 1, 1, 11) private fun sha256WithRsaAlgId(): ByteArray = derSeq(SHA256_WITH_RSA_OID + byteArrayOf(0x05, 0x00)) // NULL -// DER encoding primitives - private fun derSeq(content: ByteArray): ByteArray = tlv(0x30, content) private fun derSet(content: ByteArray): ByteArray = tlv(0x31, content) @@ -133,7 +130,13 @@ private fun derBitString(bytes: ByteArray): ByteArray = tlv(0x03, byteArrayOf(0x private fun derUtf8String(s: String): ByteArray = tlv(0x0C, s.toByteArray(Charsets.UTF_8)) -private fun derPrintableString(s: String): ByteArray = tlv(0x13, s.toByteArray(Charsets.US_ASCII)) +// X.680 Section 41: PrintableString is restricted to A-Za-z0-9 and a handful of punctuation. +private val PRINTABLE_STRING_CHARS = (('A'..'Z') + ('a'..'z') + ('0'..'9') + " '()+,-./:=?".toList()).toSet() + +private fun derPrintableString(s: String): ByteArray { + require(s.all { it in PRINTABLE_STRING_CHARS }) { "\"$s\" is not a valid PrintableString" } + return tlv(0x13, s.toByteArray(Charsets.US_ASCII)) +} private fun derInteger(value: BigInteger): ByteArray = // BigInteger.toByteArray() already prepends a 0x00 sign byte for positive integers whose @@ -146,40 +149,31 @@ private fun derContextTagged( ): ByteArray = tlv(0xA0 or tag, content) private fun derTime(date: Date): ByteArray { - // GeneralizedTime (tag 0x18) for dates in or after 2050, UTCTime (tag 0x17) otherwise. - val cal = Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC")) + // Calendar.getInstance(TimeZone) without a Locale picks up the default locale's calendar + // *system* (e.g. a Thai default locale returns a Buddhist calendar, year = Gregorian + 543), + // not just digit formatting - Locale.ROOT forces the Gregorian calendar. + val cal = Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"), Locale.ROOT) cal.time = date - return if (cal.get(Calendar.YEAR) >= 2050) { - val s = - String.format( - Locale.ROOT, - "%04d%02d%02d%02d%02d%02dZ", - cal.get(Calendar.YEAR), - cal.get(Calendar.MONTH) + 1, - cal.get(Calendar.DAY_OF_MONTH), - cal.get(Calendar.HOUR_OF_DAY), - cal.get(Calendar.MINUTE), - cal.get(Calendar.SECOND), - ) - tlv(0x18, s.toByteArray(Charsets.US_ASCII)) + val year = cal.get(Calendar.YEAR) + val fullYear = + String.format( + Locale.ROOT, + "%04d%02d%02d%02d%02d%02dZ", + year, + cal.get(Calendar.MONTH) + 1, + cal.get(Calendar.DAY_OF_MONTH), + cal.get(Calendar.HOUR_OF_DAY), + cal.get(Calendar.MINUTE), + cal.get(Calendar.SECOND), + ) + // UTCTime's 2-digit year can only unambiguously represent 1950-2049; GeneralizedTime otherwise. + return if (year < 1950 || year >= 2050) { + tlv(0x18, fullYear.toByteArray(Charsets.US_ASCII)) } else { - val s = - String.format( - Locale.ROOT, - "%02d%02d%02d%02d%02d%02dZ", - cal.get(Calendar.YEAR) % 100, - cal.get(Calendar.MONTH) + 1, - cal.get(Calendar.DAY_OF_MONTH), - cal.get(Calendar.HOUR_OF_DAY), - cal.get(Calendar.MINUTE), - cal.get(Calendar.SECOND), - ) - tlv(0x17, s.toByteArray(Charsets.US_ASCII)) + tlv(0x17, fullYear.substring(2).toByteArray(Charsets.US_ASCII)) } } -// OID encoding helpers - private fun oidBytes(vararg arcs: Int): ByteArray { val out = ByteArrayOutputStream() // First two arcs are combined: first*40 + second @@ -207,8 +201,6 @@ private fun encodeBase128(value: Int): ByteArray { return result } -// Type-Length-Value builder - private fun tlv( tag: Int, content: ByteArray, diff --git a/common/src/test/java/com/itsaky/androidide/utils/SelfSignedCertTest.kt b/common/src/test/java/com/itsaky/androidide/utils/SelfSignedCertTest.kt index a3243bda6e..acfaa24ad8 100644 --- a/common/src/test/java/com/itsaky/androidide/utils/SelfSignedCertTest.kt +++ b/common/src/test/java/com/itsaky/androidide/utils/SelfSignedCertTest.kt @@ -11,37 +11,67 @@ import java.util.Locale class SelfSignedCertTest { private fun rsaKeyPair() = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() + // DER time encoding truncates to whole seconds. + private fun assertSameInstant( + actual: Date, + expected: Date, + ) { + assertThat(actual.time / 1000).isEqualTo(expected.time / 1000) + } + @Test fun `generated certificate round-trips through CertificateFactory and verifies`() { val keyPair = rsaKeyPair() - val cert = - generateSelfSignedCert( - keyPair, - "C=US, O=Test Org, CN=test", - BigInteger.ONE, - Date(0), - Date(4102444800000), // 2100-01-01, exercises the GeneralizedTime branch - ) + val notBefore = Date(0) + val notAfter = Date(4102444800000) // 2100-01-01, exercises the GeneralizedTime branch + val cert = generateSelfSignedCert(keyPair, "C=US, O=Test Org, CN=test", BigInteger.ONE, notBefore, notAfter) cert.verify(keyPair.public) assertThat(cert.subjectX500Principal.name).contains("CN=test") assertThat(cert.subjectX500Principal.name).contains("O=Test Org") assertThat(cert.subjectX500Principal.name).contains("C=US") + assertSameInstant(cert.notBefore, notBefore) + assertSameInstant(cert.notAfter, notAfter) } @Test - fun `certificate generation is unaffected by the default locale`() { + fun `certificate generation is unaffected by a non-Gregorian default locale`() { val original = Locale.getDefault() try { - Locale.setDefault(Locale.Builder().setLanguage("ar").build()) + // Thai triggers a Buddhist (non-Gregorian) Calendar from Calendar.getInstance() + // unless Locale.ROOT is passed explicitly - see derTime in SelfSignedCert.kt. + Locale.setDefault( + Locale + .Builder() + .setLanguage("th") + .setRegion("TH") + .build(), + ) val keyPair = rsaKeyPair() - val cert = generateSelfSignedCert(keyPair, "CN=00", BigInteger.ONE, Date(0), Date(2461449600L * 1000)) + val notBefore = Date(0) + val notAfter = Date(2461449600L * 1000) + val cert = generateSelfSignedCert(keyPair, "CN=00", BigInteger.ONE, notBefore, notAfter) + cert.verify(keyPair.public) + assertSameInstant(cert.notBefore, notBefore) + assertSameInstant(cert.notAfter, notAfter) } finally { Locale.setDefault(original) } } + @Test + fun `dates before 1950 are encoded as GeneralizedTime`() { + val keyPair = rsaKeyPair() + val notBefore = Date(-946771200000) // 1940-01-01 + val notAfter = Date(0) + val cert = generateSelfSignedCert(keyPair, "CN=test", BigInteger.ONE, notBefore, notAfter) + + cert.verify(keyPair.public) + assertSameInstant(cert.notBefore, notBefore) + assertSameInstant(cert.notAfter, notAfter) + } + @Test fun `escaped comma in a DN value does not split the RDN`() { val keyPair = rsaKeyPair() @@ -57,4 +87,28 @@ class SelfSignedCertTest { cert.verify(keyPair.public) assertThat(cert.subjectX500Principal.name).contains("Foo\\, Inc.") } + + @Test + fun `empty DN attribute values are preserved, not dropped`() { + val keyPair = rsaKeyPair() + val cert = generateSelfSignedCert(keyPair, "C=US, O=, CN=", BigInteger.ONE, Date(0), Date(4102444800000)) + + cert.verify(keyPair.public) + assertThat(cert.subjectX500Principal.name).contains("C=US") + } + + @Test(expected = IllegalArgumentException::class) + fun `unsupported DN attribute type throws`() { + generateSelfSignedCert(rsaKeyPair(), "OU=Eng, CN=test", BigInteger.ONE, Date(0), Date(4102444800000)) + } + + @Test(expected = IllegalArgumentException::class) + fun `malformed DN component throws`() { + generateSelfSignedCert(rsaKeyPair(), "CN=test,", BigInteger.ONE, Date(0), Date(4102444800000)) + } + + @Test(expected = IllegalArgumentException::class) + fun `non-PrintableString country value throws`() { + generateSelfSignedCert(rsaKeyPair(), "C=U!", BigInteger.ONE, Date(0), Date(4102444800000)) + } }