From dc78c94cef236603cb2e8c15208924def1d60933 Mon Sep 17 00:00:00 2001 From: nathannewyen Date: Sun, 24 May 2026 17:31:32 -0400 Subject: [PATCH 1/2] tls: add Ed25519 certificate support --- .../kotlin/okhttp3/tls/HeldCertificate.kt | 31 +++++++++--- .../okhttp3/tls/internal/der/Certificate.kt | 1 + .../tls/internal/der/ObjectIdentifiers.kt | 1 + .../java/okhttp3/tls/HeldCertificateTest.kt | 50 +++++++++++++++++++ 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/okhttp-tls/src/main/kotlin/okhttp3/tls/HeldCertificate.kt b/okhttp-tls/src/main/kotlin/okhttp3/tls/HeldCertificate.kt index a7257ff178d5..a4914210e3e7 100644 --- a/okhttp-tls/src/main/kotlin/okhttp3/tls/HeldCertificate.kt +++ b/okhttp-tls/src/main/kotlin/okhttp3/tls/HeldCertificate.kt @@ -44,6 +44,7 @@ import okhttp3.tls.internal.der.CertificateAdapters.generalNameIpAddress import okhttp3.tls.internal.der.Extension import okhttp3.tls.internal.der.ObjectIdentifiers import okhttp3.tls.internal.der.ObjectIdentifiers.BASIC_CONSTRAINTS +import okhttp3.tls.internal.der.ObjectIdentifiers.ED25519 import okhttp3.tls.internal.der.ObjectIdentifiers.ORGANIZATIONAL_UNIT_NAME import okhttp3.tls.internal.der.ObjectIdentifiers.SHA256_WITH_ECDSA import okhttp3.tls.internal.der.ObjectIdentifiers.SHA256_WITH_RSA_ENCRYPTION @@ -341,6 +342,16 @@ class HeldCertificate( keySize = 2048 } + /** + * Configure the certificate to generate an Ed25519 key. Ed25519 provides about 128 bits of + * security and fast signing/verification. Requires Java 15 or newer at runtime. + */ + fun ed25519() = + apply { + keyAlgorithm = "Ed25519" + keySize = 0 + } + fun build(): HeldCertificate { // Subject keys & identity. val subjectKeyPair = keyPair ?: generateKeyPair() @@ -480,14 +491,21 @@ class HeldCertificate( } private fun signatureAlgorithm(signedByKeyPair: KeyPair): AlgorithmIdentifier = - when (signedByKeyPair.private) { - is RSAPrivateKey -> { + when { + signedByKeyPair.private is RSAPrivateKey -> { AlgorithmIdentifier( algorithm = SHA256_WITH_RSA_ENCRYPTION, parameters = null, ) } + signedByKeyPair.private.algorithm == "Ed25519" || signedByKeyPair.private.algorithm == "EdDSA" -> { + AlgorithmIdentifier( + algorithm = ED25519, + parameters = null, + ) + } + else -> { AlgorithmIdentifier( algorithm = SHA256_WITH_ECDSA, @@ -498,7 +516,7 @@ class HeldCertificate( private fun generateKeyPair(): KeyPair = KeyPairGenerator.getInstance(keyAlgorithm).run { - initialize(keySize, SecureRandom()) + if (keySize > 0) initialize(keySize, SecureRandom()) generateKeyPair() } @@ -581,9 +599,10 @@ class HeldCertificate( // The private key doesn't tell us its type but it's okay because the certificate knows! val keyType = - when (certificate.publicKey) { - is ECPublicKey -> "EC" - is RSAPublicKey -> "RSA" + when { + certificate.publicKey is ECPublicKey -> "EC" + certificate.publicKey is RSAPublicKey -> "RSA" + certificate.publicKey.algorithm == "Ed25519" || certificate.publicKey.algorithm == "EdDSA" -> certificate.publicKey.algorithm else -> throw IllegalArgumentException("unexpected key type: ${certificate.publicKey}") } diff --git a/okhttp-tls/src/main/kotlin/okhttp3/tls/internal/der/Certificate.kt b/okhttp-tls/src/main/kotlin/okhttp3/tls/internal/der/Certificate.kt index 4e69cc7b8e94..02db9400bc5e 100644 --- a/okhttp-tls/src/main/kotlin/okhttp3/tls/internal/der/Certificate.kt +++ b/okhttp-tls/src/main/kotlin/okhttp3/tls/internal/der/Certificate.kt @@ -111,6 +111,7 @@ internal data class TbsCertificate( return when (signature.algorithm) { ObjectIdentifiers.SHA256_WITH_RSA_ENCRYPTION -> "SHA256WithRSA" ObjectIdentifiers.SHA256_WITH_ECDSA -> "SHA256withECDSA" + ObjectIdentifiers.ED25519 -> "Ed25519" else -> error("unexpected signature algorithm: ${signature.algorithm}") } } diff --git a/okhttp-tls/src/main/kotlin/okhttp3/tls/internal/der/ObjectIdentifiers.kt b/okhttp-tls/src/main/kotlin/okhttp3/tls/internal/der/ObjectIdentifiers.kt index e66a21815dc9..0d5f89a2b777 100644 --- a/okhttp-tls/src/main/kotlin/okhttp3/tls/internal/der/ObjectIdentifiers.kt +++ b/okhttp-tls/src/main/kotlin/okhttp3/tls/internal/der/ObjectIdentifiers.kt @@ -21,6 +21,7 @@ internal object ObjectIdentifiers { const val SHA256_WITH_ECDSA = "1.2.840.10045.4.3.2" const val RSA_ENCRYPTION = "1.2.840.113549.1.1.1" const val SHA256_WITH_RSA_ENCRYPTION = "1.2.840.113549.1.1.11" + const val ED25519 = "1.3.101.112" const val SUBJECT_ALTERNATIVE_NAME = "2.5.29.17" const val BASIC_CONSTRAINTS = "2.5.29.19" const val COMMON_NAME = "2.5.4.3" diff --git a/okhttp-tls/src/test/java/okhttp3/tls/HeldCertificateTest.kt b/okhttp-tls/src/test/java/okhttp3/tls/HeldCertificateTest.kt index 4c1fe9e163bc..4cbcc68ad5a2 100644 --- a/okhttp-tls/src/test/java/okhttp3/tls/HeldCertificateTest.kt +++ b/okhttp-tls/src/test/java/okhttp3/tls/HeldCertificateTest.kt @@ -262,6 +262,56 @@ class HeldCertificateTest { assertThat(leaf.certificate.sigAlgName).isEqualTo("SHA256WITHECDSA", ignoreCase = true) } + @Test + fun ed25519() { + platform.assumeNotAndroid() + val heldCertificate = + HeldCertificate + .Builder() + .commonName("cash.app") + .ed25519() + .build() + assertThat(heldCertificate.certificate.sigAlgName).isEqualTo("Ed25519", ignoreCase = true) + assertThat(heldCertificate.keyPair.private.algorithm).isEqualTo("Ed25519", ignoreCase = true) + assertThat(heldCertificate.keyPair.public.algorithm).isEqualTo("Ed25519", ignoreCase = true) + } + + @Test + fun ed25519RoundTrip() { + platform.assumeNotAndroid() + val original = + HeldCertificate + .Builder() + .commonName("cash.app") + .ed25519() + .build() + val pem = original.certificatePem() + original.privateKeyPkcs8Pem() + val decoded = decode(pem) + assertThat(decoded.certificate.encoded).isEqualTo(original.certificate.encoded) + assertThat(decoded.keyPair.private.encoded).isEqualTo(original.keyPair.private.encoded) + assertThat(decoded.keyPair.public.encoded).isEqualTo(original.keyPair.public.encoded) + } + + @Test + fun ed25519SignedByEcdsa() { + platform.assumeNotAndroid() + val root = + HeldCertificate + .Builder() + .certificateAuthority(0) + .ecdsa256() + .build() + val leaf = + HeldCertificate + .Builder() + .ed25519() + .signedBy(root) + .build() + assertThat(root.certificate.sigAlgName).isEqualTo("SHA256WITHECDSA", ignoreCase = true) + assertThat(leaf.certificate.sigAlgName).isEqualTo("SHA256WITHECDSA", ignoreCase = true) + assertThat(leaf.keyPair.private.algorithm).isEqualTo("Ed25519", ignoreCase = true) + } + @Test fun decodeEcdsa256() { // The certificate + private key below was generated programmatically: From 9e37ee88b6c599393006b60970501e240f898a44 Mon Sep 17 00:00:00 2001 From: nathannewyen Date: Mon, 25 May 2026 15:03:10 -0400 Subject: [PATCH 2/2] tls: fix Ed25519 DER encoding and algorithm name assertions --- .../src/main/kotlin/okhttp3/tls/internal/der/Adapters.kt | 3 ++- .../src/test/java/okhttp3/tls/HeldCertificateTest.kt | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/okhttp-tls/src/main/kotlin/okhttp3/tls/internal/der/Adapters.kt b/okhttp-tls/src/main/kotlin/okhttp3/tls/internal/der/Adapters.kt index b0ec0cd75788..75190376e228 100644 --- a/okhttp-tls/src/main/kotlin/okhttp3/tls/internal/der/Adapters.kt +++ b/okhttp-tls/src/main/kotlin/okhttp3/tls/internal/der/Adapters.kt @@ -447,7 +447,8 @@ internal object Adapters { val adapter = chooser(writer.typeHint) as DerAdapter? when { adapter != null -> adapter.toDer(writer, value) - else -> writer.writeOctetString(value as ByteString) + value != null -> writer.writeOctetString(value as ByteString) + // else: unknown type with null value — field is absent, write nothing } } diff --git a/okhttp-tls/src/test/java/okhttp3/tls/HeldCertificateTest.kt b/okhttp-tls/src/test/java/okhttp3/tls/HeldCertificateTest.kt index 4cbcc68ad5a2..a2336b0755b9 100644 --- a/okhttp-tls/src/test/java/okhttp3/tls/HeldCertificateTest.kt +++ b/okhttp-tls/src/test/java/okhttp3/tls/HeldCertificateTest.kt @@ -19,6 +19,7 @@ import assertk.assertThat import assertk.assertions.containsExactly import assertk.assertions.isCloseTo import assertk.assertions.isEqualTo +import assertk.assertions.isIn import assertk.assertions.isNull import assertk.assertions.matches import java.math.BigInteger @@ -271,9 +272,9 @@ class HeldCertificateTest { .commonName("cash.app") .ed25519() .build() - assertThat(heldCertificate.certificate.sigAlgName).isEqualTo("Ed25519", ignoreCase = true) - assertThat(heldCertificate.keyPair.private.algorithm).isEqualTo("Ed25519", ignoreCase = true) - assertThat(heldCertificate.keyPair.public.algorithm).isEqualTo("Ed25519", ignoreCase = true) + assertThat(heldCertificate.certificate.sigAlgName.lowercase()).isIn("ed25519", "eddsa") + assertThat(heldCertificate.keyPair.private.algorithm.lowercase()).isIn("ed25519", "eddsa") + assertThat(heldCertificate.keyPair.public.algorithm.lowercase()).isIn("ed25519", "eddsa") } @Test @@ -309,7 +310,7 @@ class HeldCertificateTest { .build() assertThat(root.certificate.sigAlgName).isEqualTo("SHA256WITHECDSA", ignoreCase = true) assertThat(leaf.certificate.sigAlgName).isEqualTo("SHA256WITHECDSA", ignoreCase = true) - assertThat(leaf.keyPair.private.algorithm).isEqualTo("Ed25519", ignoreCase = true) + assertThat(leaf.keyPair.private.algorithm.lowercase()).isIn("ed25519", "eddsa") } @Test