diff --git a/sshlib/api.txt b/sshlib/api.txt index fd179a22..a0fc640e 100644 --- a/sshlib/api.txt +++ b/sshlib/api.txt @@ -704,7 +704,7 @@ package org.connectbot.sshlib { method public KeyPair decodePemPrivateKey(java.lang.String pem, optional java.lang.String? password); method public java.lang.String encodeOpenSshPrivateKey(KeyPair keyPair, optional java.lang.String? password); method public java.lang.String encodePemPrivateKey(KeyPair keyPair, optional java.lang.String? password); - method public void ensureEd25519Support(); + method @Deprecated public void ensureEd25519Support(); field public static final org.connectbot.sshlib.SshKeys INSTANCE; } diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/SshKeys.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/SshKeys.kt index cb171841..62a0b0c3 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/SshKeys.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/SshKeys.kt @@ -20,10 +20,7 @@ package org.connectbot.sshlib import org.connectbot.sshlib.crypto.OpenSshKeyWriter import org.connectbot.sshlib.crypto.PemKeyWriter import org.connectbot.sshlib.crypto.PrivateKeyReader -import org.connectbot.sshlib.crypto.ed25519.Ed25519Provider -import java.security.KeyFactory import java.security.KeyPair -import java.security.NoSuchAlgorithmException /** * Key management utilities for SSH private keys. @@ -75,19 +72,9 @@ object SshKeys { fun encodeOpenSshPrivateKey(keyPair: KeyPair, password: String? = null): String = OpenSshKeyWriter.write(keyPair, password) /** - * Ensure Ed25519 JCA support is available. - * - * On platforms that don't natively support Ed25519 (e.g., older Android versions), - * this registers a Tink-backed JCA provider. On platforms with native support, - * this is a no-op. - * - * Call this early in application startup if you need Ed25519 key support. + * Ed25519 support is now selected automatically without changing the global + * JCE provider list. */ - fun ensureEd25519Support() { - try { - KeyFactory.getInstance("Ed25519") - } catch (_: NoSuchAlgorithmException) { - Ed25519Provider.insertIfNeeded() - } - } + @Deprecated("Ed25519 support is selected automatically") + fun ensureEd25519Support() = Unit } diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/EcdhKeyExchange.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/EcdhKeyExchange.kt index bdb9a126..edc50007 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/EcdhKeyExchange.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/EcdhKeyExchange.kt @@ -20,7 +20,6 @@ package org.connectbot.sshlib.crypto import org.connectbot.sshlib.SshException import java.math.BigInteger import java.security.AlgorithmParameters -import java.security.KeyFactory import java.security.KeyPair import java.security.KeyPairGenerator import java.security.interfaces.ECPublicKey @@ -89,7 +88,7 @@ internal class EcdhKeyExchange(private val curveName: String) : KexAlgorithm { try { val serverPoint = decodeEcPoint(serverPublicKey) val serverPubKeySpec = ECPublicKeySpec(serverPoint, ecParameterSpec) - val serverPubKey = KeyFactory.getInstance("EC").generatePublic(serverPubKeySpec) + val serverPubKey = RawKeyFactory.generatePublic("EC", serverPubKeySpec) val agreement = KeyAgreement.getInstance("ECDH") agreement.init(kp.private) diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/EcdsaSignatureAlgorithm.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/EcdsaSignatureAlgorithm.kt index 0b5075e6..36892ad2 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/EcdsaSignatureAlgorithm.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/EcdsaSignatureAlgorithm.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,7 +24,6 @@ import org.connectbot.sshlib.protocol.SshPublicKey import org.connectbot.sshlib.protocol.SshSignature import java.math.BigInteger import java.security.AlgorithmParameters -import java.security.KeyFactory import java.security.PrivateKey import java.security.Signature import java.security.interfaces.ECKey @@ -52,7 +51,7 @@ internal object EcdsaSignatureAlgorithm : SshSignatureAlgorithm { val point = decodeEcPoint(qBytes, paramSpec) val keySpec = ECPublicKeySpec(point, paramSpec) - val jcaKey = KeyFactory.getInstance("EC").generatePublic(keySpec) + val jcaKey = RawKeyFactory.generatePublic("EC", keySpec) val sigBlob = sig.signatureBlob() as EcdsaSignatureBlob val r = BigInteger(1, sigBlob.blob().r().body()) diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/Ed25519SignatureAlgorithm.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/Ed25519SignatureAlgorithm.kt index b3a472c7..bfb1c6a5 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/Ed25519SignatureAlgorithm.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/Ed25519SignatureAlgorithm.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,7 +21,6 @@ import org.connectbot.sshlib.protocol.SshEd25519PublicKeyBlob import org.connectbot.sshlib.protocol.SshEd25519SignatureBlob import org.connectbot.sshlib.protocol.SshPublicKey import org.connectbot.sshlib.protocol.SshSignature -import java.security.KeyFactory import java.security.PrivateKey import java.security.Signature import java.security.spec.X509EncodedKeySpec @@ -42,7 +41,7 @@ internal object Ed25519SignatureAlgorithm : SshSignatureAlgorithm { } } val keySpec = X509EncodedKeySpec(x509Key) - val jcaKey = KeyFactory.getInstance("Ed25519").generatePublic(keySpec) + val jcaKey = RawKeyFactory.generatePublic("Ed25519", keySpec) val sigBlob = sig.signatureBlob() as SshEd25519SignatureBlob val verifier = Signature.getInstance("Ed25519") diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/Ed448SignatureAlgorithm.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/Ed448SignatureAlgorithm.kt index fb7704ab..7bbfb421 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/Ed448SignatureAlgorithm.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/Ed448SignatureAlgorithm.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,7 +21,6 @@ import org.connectbot.sshlib.protocol.SshEd448PublicKeyBlob import org.connectbot.sshlib.protocol.SshEd448SignatureBlob import org.connectbot.sshlib.protocol.SshPublicKey import org.connectbot.sshlib.protocol.SshSignature -import java.security.KeyFactory import java.security.PrivateKey import java.security.Signature import java.security.spec.X509EncodedKeySpec @@ -42,7 +41,7 @@ internal object Ed448SignatureAlgorithm : SshSignatureAlgorithm { } } val keySpec = X509EncodedKeySpec(x509Key) - val jcaKey = KeyFactory.getInstance("Ed448").generatePublic(keySpec) + val jcaKey = RawKeyFactory.generatePublic("Ed448", keySpec) val sigBlob = sig.signatureBlob() as SshEd448SignatureBlob val verifier = Signature.getInstance("Ed448") diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/OpenSshKeyReader.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/OpenSshKeyReader.kt index 1f5e6c36..806517ce 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/OpenSshKeyReader.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/OpenSshKeyReader.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,7 +24,6 @@ import org.connectbot.sshlib.protocol.readString import java.math.BigInteger import java.nio.ByteBuffer import java.security.AlgorithmParameters -import java.security.KeyFactory import java.security.KeyPair import java.security.spec.ECGenParameterSpec import java.security.spec.ECParameterSpec @@ -105,9 +104,6 @@ internal object OpenSshKeyReader { octetString(encodeDer { octetString(seed) }) } } - val privKey = KeyFactory.getInstance("Ed25519") - .generatePrivate(PKCS8EncodedKeySpec(pkcs8)) - val pubKeyBytes = privateBytes.copyOfRange(32, 64) val x509 = encodeDer { sequence { @@ -117,10 +113,11 @@ internal object OpenSshKeyReader { bitString(pubKeyBytes) } } - val pubKey = KeyFactory.getInstance("Ed25519") - .generatePublic(X509EncodedKeySpec(x509)) - - keyPair = KeyPair(pubKey, privKey) + keyPair = RawKeyFactory.generateKeyPair( + "Ed25519", + X509EncodedKeySpec(x509), + PKCS8EncodedKeySpec(pkcs8), + ) sigAlgorithm = "ssh-ed25519" } @@ -144,8 +141,7 @@ internal object OpenSshKeyReader { val pubKeySpec = ECPublicKeySpec(point, paramSpec) val privKeySpec = ECPrivateKeySpec(privateScalar, paramSpec) - val kf = KeyFactory.getInstance("EC") - keyPair = KeyPair(kf.generatePublic(pubKeySpec), kf.generatePrivate(privKeySpec)) + keyPair = RawKeyFactory.generateKeyPair("EC", pubKeySpec, privKeySpec) sigAlgorithm = sshAlg } @@ -163,8 +159,7 @@ internal object OpenSshKeyReader { val privKeySpec = RSAPrivateCrtKeySpec(n, e, d, p, q, dP, dQ, iqmp) val pubKeySpec = RSAPublicKeySpec(n, e) - val kf = KeyFactory.getInstance("RSA") - keyPair = KeyPair(kf.generatePublic(pubKeySpec), kf.generatePrivate(privKeySpec)) + keyPair = RawKeyFactory.generateKeyPair("RSA", pubKeySpec, privKeySpec) sigAlgorithm = "rsa-sha2-512" } diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/PemKeyReader.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/PemKeyReader.kt index 917eae72..efd4900c 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/PemKeyReader.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/PemKeyReader.kt @@ -17,26 +17,24 @@ package org.connectbot.sshlib.crypto +import com.google.crypto.tink.subtle.Ed25519Sign import org.connectbot.sshlib.SshException +import org.connectbot.sshlib.crypto.ed25519.Ed25519PrivateKey import java.math.BigInteger import java.security.AlgorithmParameters -import java.security.KeyFactory import java.security.KeyPair -import java.security.KeyPairGenerator import java.security.PublicKey -import java.security.SecureRandom import java.security.interfaces.ECPrivateKey -import java.security.interfaces.EdECPrivateKey import java.security.interfaces.RSAPrivateCrtKey import java.security.spec.ECGenParameterSpec import java.security.spec.ECParameterSpec import java.security.spec.ECPrivateKeySpec import java.security.spec.ECPublicKeySpec import java.security.spec.InvalidKeySpecException -import java.security.spec.NamedParameterSpec import java.security.spec.PKCS8EncodedKeySpec import java.security.spec.RSAPrivateCrtKeySpec import java.security.spec.RSAPublicKeySpec +import java.security.spec.X509EncodedKeySpec internal object PemKeyReader { @@ -178,8 +176,7 @@ internal object PemKeyReader { val privSpec = RSAPrivateCrtKeySpec(n, e, d, p, q, dP, dQ, qInv) val pubSpec = RSAPublicKeySpec(n, e) - val kf = KeyFactory.getInstance("RSA") - val keyPair = KeyPair(kf.generatePublic(pubSpec), kf.generatePrivate(privSpec)) + val keyPair = RawKeyFactory.generateKeyPair("RSA", pubSpec, privSpec) SshPrivateKey("ssh-rsa", keyPair, "rsa-sha2-512") } @@ -218,8 +215,7 @@ internal object PemKeyReader { val privKeySpec = ECPrivateKeySpec(BigInteger(1, privateBytes), paramSpec) val pubKeySpec = ECPublicKeySpec(point, paramSpec) - val kf = KeyFactory.getInstance("EC") - val keyPair = KeyPair(kf.generatePublic(pubKeySpec), kf.generatePrivate(privKeySpec)) + val keyPair = RawKeyFactory.generateKeyPair("EC", pubKeySpec, privKeySpec) SshPrivateKey(sshAlg, keyPair, sshAlg) } } @@ -227,17 +223,15 @@ internal object PemKeyReader { private fun readPkcs8(data: ByteArray): SshPrivateKey { // Use JCA's built-in PKCS#8 parsing — try each algorithm try { - val kf = KeyFactory.getInstance("Ed25519") - val privKey = kf.generatePrivate(PKCS8EncodedKeySpec(data)) - val edPriv = privKey as EdECPrivateKey - val seed = edPriv.bytes.orElseThrow { SshException("Cannot extract Ed25519 seed") } + val keySpec = PKCS8EncodedKeySpec(data) + val privKey = RawKeyFactory.generatePrivate("Ed25519", keySpec) + val seed = Ed25519PrivateKey(keySpec).getSeed() val pubKey = ed25519PublicKeyFromSeed(seed) return SshPrivateKey("ssh-ed25519", KeyPair(pubKey, privKey), "ssh-ed25519") } catch (_: InvalidKeySpecException) {} try { - val kf = KeyFactory.getInstance("EC") - val privKey = kf.generatePrivate(PKCS8EncodedKeySpec(data)) as ECPrivateKey + val privKey = RawKeyFactory.generatePrivate("EC", PKCS8EncodedKeySpec(data)) as ECPrivateKey val fieldSize = (privKey.params.order.bitLength() + 7) / 8 val sshAlg = when (fieldSize) { 32 -> "ecdsa-sha2-nistp256" @@ -250,10 +244,9 @@ internal object PemKeyReader { } catch (_: InvalidKeySpecException) {} try { - val kf = KeyFactory.getInstance("RSA") - val privKey = kf.generatePrivate(PKCS8EncodedKeySpec(data)) as RSAPrivateCrtKey + val privKey = RawKeyFactory.generatePrivate("RSA", PKCS8EncodedKeySpec(data)) as RSAPrivateCrtKey val pubSpec = RSAPublicKeySpec(privKey.modulus, privKey.publicExponent) - val pubKey = kf.generatePublic(pubSpec) + val pubKey = RawKeyFactory.generatePublic("RSA", pubSpec) return SshPrivateKey("ssh-rsa", KeyPair(pubKey, privKey), "rsa-sha2-512") } catch (_: InvalidKeySpecException) {} @@ -261,28 +254,16 @@ internal object PemKeyReader { } internal fun ed25519PublicKeyFromSeed(seed: ByteArray): PublicKey { - // Build PKCS#8 from seed, create private key, then use KPG with deterministic random - val pkcs8 = encodeDer { + val rawPublicKey = Ed25519Sign.KeyPair.newKeyPairFromSeed(seed).publicKey + val x509 = encodeDer { sequence { - integer(BigInteger.ZERO) sequence { objectIdentifier(byteArrayOf(0x2b, 0x65, 0x70)) // Ed25519 } - octetString(encodeDer { octetString(seed) }) + bitString(rawPublicKey) } } - val privKey = KeyFactory.getInstance("Ed25519") - .generatePrivate(PKCS8EncodedKeySpec(pkcs8)) - - // Use a deterministic SecureRandom that returns our seed - val deterministicRandom = object : SecureRandom() { - override fun nextBytes(bytes: ByteArray) { - System.arraycopy(seed, 0, bytes, 0, minOf(seed.size, bytes.size)) - } - } - val kpg = KeyPairGenerator.getInstance("Ed25519") - kpg.initialize(NamedParameterSpec.ED25519, deterministicRandom) - return kpg.generateKeyPair().public + return RawKeyFactory.generatePublic("Ed25519", X509EncodedKeySpec(x509)) } private fun ecPublicKeyFromPkcs8(privKey: ECPrivateKey): PublicKey { @@ -307,7 +288,7 @@ internal object PemKeyReader { EcdsaSignatureAlgorithm.decodeEcPoint(pubPoint, privKey.params), privKey.params, ) - return@readSequence KeyFactory.getInstance("EC").generatePublic(pubKeySpec) + return@readSequence RawKeyFactory.generatePublic("EC", pubKeySpec) } else -> seq.skipTag() diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/PlatformX25519Provider.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/PlatformX25519Provider.kt index 87a6d413..af3319f0 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/PlatformX25519Provider.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/PlatformX25519Provider.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,13 +21,14 @@ import java.security.InvalidKeyException import java.security.KeyFactory import java.security.KeyPairGenerator import java.security.PrivateKey +import java.security.Provider import java.security.PublicKey import java.security.spec.PKCS8EncodedKeySpec import java.security.spec.X509EncodedKeySpec import java.security.spec.XECPrivateKeySpec import javax.crypto.KeyAgreement -internal class PlatformX25519Provider : X25519Provider { +internal class PlatformX25519Provider(provider: Provider) : X25519Provider { companion object { private const val ALGORITHM = "X25519" @@ -50,8 +51,11 @@ internal class PlatformX25519Provider : X25519Provider { private val BASE_POINT = ByteArray(X25519Provider.KEY_SIZE).apply { this[0] = 9 } } - private val keyPairGenerator = KeyPairGenerator.getInstance(ALGORITHM) - private val keyFactory = KeyFactory.getInstance(ALGORITHM) + internal val providerName: String = provider.name + + private val keyPairGenerator = KeyPairGenerator.getInstance(ALGORITHM, provider) + private val keyFactory = KeyFactory.getInstance(ALGORITHM, provider) + private val keyAgreementProvider = provider override fun generatePrivateKey(): ByteArray { val keyPair = keyPairGenerator.generateKeyPair() @@ -60,7 +64,7 @@ internal class PlatformX25519Provider : X25519Provider { override fun publicFromPrivate(privateKey: ByteArray): ByteArray { val privKey = createPrivateKey(privateKey) - val ka = KeyAgreement.getInstance(ALGORITHM) + val ka = KeyAgreement.getInstance(ALGORITHM, keyAgreementProvider) ka.init(privKey) ka.doPhase(createPublicKey(BASE_POINT), true) return ka.generateSecret() @@ -69,7 +73,7 @@ internal class PlatformX25519Provider : X25519Provider { override fun computeSharedSecret(privateKey: ByteArray, publicKey: ByteArray): ByteArray { val privKey = createPrivateKey(privateKey) val pubKey = createPublicKey(publicKey) - val ka = KeyAgreement.getInstance(ALGORITHM) + val ka = KeyAgreement.getInstance(ALGORITHM, keyAgreementProvider) ka.init(privKey) ka.doPhase(pubKey, true) return ka.generateSecret() diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/RawKeyFactory.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/RawKeyFactory.kt new file mode 100644 index 00000000..e7611119 --- /dev/null +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/RawKeyFactory.kt @@ -0,0 +1,92 @@ +/* + * ConnectBot SSH Library + * Copyright 2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.crypto + +import org.connectbot.sshlib.crypto.ed25519.Ed25519Provider +import java.security.GeneralSecurityException +import java.security.KeyFactory +import java.security.KeyPair +import java.security.PrivateKey +import java.security.Provider +import java.security.PublicKey +import java.security.Security +import java.security.spec.InvalidKeySpecException +import java.security.spec.KeySpec + +/** + * Performs raw key conversion against providers that have demonstrated support + * for the requested key specification. + * + * Some Android builds advertise raw-key algorithms from AndroidKeyStore even + * though that provider only accepts keystore-backed keys. JCA's unpinned lookup + * can therefore resolve successfully and fail only when the key is converted. + */ +internal object RawKeyFactory { + private val ed25519FallbackProvider by lazy { Ed25519Provider() } + + fun generatePublic( + algorithm: String, + keySpec: KeySpec, + providers: List = installedProviders(algorithm), + ): PublicKey = withFactory(algorithm, providers) { it.generatePublic(keySpec) } + + fun generatePrivate( + algorithm: String, + keySpec: KeySpec, + providers: List = installedProviders(algorithm), + ): PrivateKey = withFactory(algorithm, providers) { it.generatePrivate(keySpec) } + + fun generateKeyPair( + algorithm: String, + publicKeySpec: KeySpec, + privateKeySpec: KeySpec, + providers: List = installedProviders(algorithm), + ): KeyPair = withFactory(algorithm, providers) { + KeyPair(it.generatePublic(publicKeySpec), it.generatePrivate(privateKeySpec)) + } + + private fun installedProviders(algorithm: String): List { + val installed = Security.getProviders().toMutableList() + if (algorithm.equals(Ed25519Provider.KEY_ALGORITHM, ignoreCase = true) && + installed.none { it.name == Ed25519Provider.NAME } + ) { + installed += ed25519FallbackProvider + } + return installed + } + + private inline fun withFactory( + algorithm: String, + providers: List, + operation: (KeyFactory) -> T, + ): T { + var lastFailure: GeneralSecurityException? = null + for (provider in providers) { + try { + return operation(KeyFactory.getInstance(algorithm, provider)) + } catch (e: GeneralSecurityException) { + lastFailure = e + } + } + + throw InvalidKeySpecException( + "No JCE provider could convert raw $algorithm key material", + lastFailure, + ) + } +} diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/RsaSignatureAlgorithm.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/RsaSignatureAlgorithm.kt index 411395c6..482b619e 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/RsaSignatureAlgorithm.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/RsaSignatureAlgorithm.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,7 +22,6 @@ import org.connectbot.sshlib.protocol.SshRsaPublicKeyBlob import org.connectbot.sshlib.protocol.SshRsaSignatureBlob import org.connectbot.sshlib.protocol.SshSignature import java.math.BigInteger -import java.security.KeyFactory import java.security.PrivateKey import java.security.Signature import java.security.spec.RSAPublicKeySpec @@ -33,7 +32,7 @@ internal object RsaSignatureAlgorithm : SshSignatureAlgorithm { val e = BigInteger(1, keyBlob.e().body()) val n = BigInteger(1, keyBlob.n().body()) val spec = RSAPublicKeySpec(n, e) - val jcaKey = KeyFactory.getInstance("RSA").generatePublic(spec) + val jcaKey = RawKeyFactory.generatePublic("RSA", spec) val jcaAlgorithm = toJcaAlgorithm(sig.algorithmName()) diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/X25519ProviderFactory.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/X25519ProviderFactory.kt index c8f8e3f3..73fcb817 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/X25519ProviderFactory.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/X25519ProviderFactory.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,34 +18,57 @@ package org.connectbot.sshlib.crypto import org.slf4j.LoggerFactory -import java.security.KeyPairGenerator +import java.security.Provider +import java.security.Security internal object X25519ProviderFactory { private val logger = LoggerFactory.getLogger(X25519ProviderFactory::class.java) + private val probePrivateKey = decodeHex( + "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a", + ) + private val probePublicKey = decodeHex( + "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a", + ) + private val probePeerPublicKey = decodeHex( + "de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f", + ) + private val probeSharedSecret = decodeHex( + "4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742", + ) + internal val provider: X25519Provider by lazy { - if (isPlatformNativeAvailable()) { - try { - val p = PlatformX25519Provider() - logger.debug("Using platform-native X25519 implementation") - p - } catch (e: Exception) { - logger.debug("Platform X25519 class loading failed, falling back to Tink") - createTinkProvider() - } + val platform = selectPlatformProvider(Security.getProviders().toList()) + if (platform != null) { + logger.debug("Using platform-native X25519 implementation from {}", platform.providerName) + platform } else { - logger.debug("Using Tink X25519 implementation") + logger.debug("No platform provider passed the X25519 capability probe; using Tink") createTinkProvider() } } - private fun isPlatformNativeAvailable(): Boolean = try { - KeyPairGenerator.getInstance("X25519") - Class.forName("java.security.spec.XECPrivateKeySpec") - true - } catch (_: Exception) { - false + internal fun selectPlatformProvider(providers: List): PlatformX25519Provider? { + for (provider in providers) { + try { + Class.forName("java.security.spec.XECPrivateKeySpec") + val candidate = PlatformX25519Provider(provider) + check(candidate.generatePrivateKey().size == X25519Provider.KEY_SIZE) + check(candidate.publicFromPrivate(probePrivateKey).contentEquals(probePublicKey)) + check(candidate.computeSharedSecret(probePrivateKey, probePeerPublicKey).contentEquals(probeSharedSecret)) + return candidate + } catch (e: Exception) { + logger.trace("JCE provider {} failed the X25519 capability probe", provider.name, e) + } catch (e: LinkageError) { + logger.trace("JCE provider {} cannot load the X25519 platform API", provider.name, e) + } + } + return null } private fun createTinkProvider(): X25519Provider = TinkX25519Provider() + + private fun decodeHex(value: String): ByteArray = value.chunked(2) + .map { it.toInt(16).toByte() } + .toByteArray() } diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/ed25519/Ed25519Provider.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/ed25519/Ed25519Provider.kt index 614f9de3..760d1fed 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/ed25519/Ed25519Provider.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/ed25519/Ed25519Provider.kt @@ -22,7 +22,6 @@ package org.connectbot.sshlib.crypto.ed25519 import java.security.AccessController import java.security.PrivilegedAction import java.security.Provider -import java.security.Security // Android only has the Provider(String, double, String) constructor. internal class Ed25519Provider : Provider(NAME, 1.0, "ConnectBot Ed25519 JCA Provider") { @@ -52,17 +51,5 @@ internal class Ed25519Provider : Provider(NAME, 1.0, "ConnectBot Ed25519 JCA Pro companion object { const val NAME = "ConnectBot Ed25519 Provider" const val KEY_ALGORITHM = "Ed25519" - - private val initLock = Any() - private var initialized = false - - fun insertIfNeeded() { - synchronized(initLock) { - if (!initialized) { - Security.insertProviderAt(Ed25519Provider(), 1) - initialized = true - } - } - } } } diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/SshKeysTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/SshKeysTest.kt index aa85b9b4..af5674b6 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/SshKeysTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/SshKeysTest.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,6 +18,7 @@ package org.connectbot.sshlib import org.junit.jupiter.api.Test +import java.security.Security import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNotNull @@ -123,8 +124,11 @@ class SshKeysTest { } @Test + @Suppress("DEPRECATION") fun `ensureEd25519Support does not throw`() { + val providersBefore = Security.getProviders().map { it.name } SshKeys.ensureEd25519Support() + assertEquals(providersBefore, Security.getProviders().map { it.name }) } @Test diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/Curve25519KeyExchangeTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/Curve25519KeyExchangeTest.kt index fda93dcd..f522ec63 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/Curve25519KeyExchangeTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/Curve25519KeyExchangeTest.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,6 +20,7 @@ package org.connectbot.sshlib.crypto import org.connectbot.sshlib.SshException import org.junit.jupiter.api.Test import java.math.BigInteger +import java.security.Security import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -56,6 +57,23 @@ class Curve25519KeyExchangeTest { assertContentEquals(expectedSharedSecret, secret) } + @Test + fun `platform selection probes providers instead of trusting registration`() { + val selected = X25519ProviderFactory.selectPlatformProvider( + listOf(RejectingRawKeyProvider()) + Security.getProviders(), + ) + + if (selected != null) { + assertContentEquals(alicePublic, selected.publicFromPrivate(alicePrivate)) + assertContentEquals(expectedSharedSecret, selected.computeSharedSecret(alicePrivate, bobPublic)) + } + } + + @Test + fun `platform selection returns null when no provider is capable`() { + assertEquals(null, X25519ProviderFactory.selectPlatformProvider(listOf(RejectingRawKeyProvider()))) + } + @Test fun `generateClientKeys returns 32-byte public key`() { val kex = Curve25519KeyExchange() diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/RawKeyFactoryTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/RawKeyFactoryTest.kt new file mode 100644 index 00000000..6ff845f1 --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/RawKeyFactoryTest.kt @@ -0,0 +1,119 @@ +/* + * ConnectBot SSH Library + * Copyright 2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.crypto + +import org.connectbot.sshlib.crypto.ed25519.Ed25519Provider +import org.junit.jupiter.api.Test +import java.security.InvalidKeyException +import java.security.Key +import java.security.KeyFactorySpi +import java.security.KeyPairGenerator +import java.security.PrivateKey +import java.security.Provider +import java.security.PublicKey +import java.security.Security +import java.security.spec.InvalidKeySpecException +import java.security.spec.KeySpec +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.X509EncodedKeySpec +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class RawKeyFactoryTest { + private val rejectingProvider = RejectingRawKeyProvider() + + @Test + fun `skips provider that advertises algorithm but rejects raw keys`() { + val keyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() + val providers = listOf(rejectingProvider) + Security.getProviders() + + val restored = RawKeyFactory.generateKeyPair( + "RSA", + X509EncodedKeySpec(keyPair.public.encoded), + PKCS8EncodedKeySpec(keyPair.private.encoded), + providers, + ) + + assertContentEquals(keyPair.public.encoded, restored.public.encoded) + assertContentEquals(keyPair.private.encoded, restored.private.encoded) + } + + @Test + fun `uses bundled Ed25519 provider without registering it`() { + val providerNamesBefore = Security.getProviders().map { it.name } + val seed = ByteArray(32) { it.toByte() } + val privateSpec = PKCS8EncodedKeySpec( + encodeDer { + sequence { + integer(java.math.BigInteger.ZERO) + sequence { objectIdentifier(byteArrayOf(0x2b, 0x65, 0x70)) } + octetString(encodeDer { octetString(seed) }) + } + }, + ) + val publicSpec = X509EncodedKeySpec( + encodeDer { + sequence { + sequence { objectIdentifier(byteArrayOf(0x2b, 0x65, 0x70)) } + bitString(com.google.crypto.tink.subtle.Ed25519Sign.KeyPair.newKeyPairFromSeed(seed).publicKey) + } + }, + ) + + val restored = RawKeyFactory.generateKeyPair( + "Ed25519", + publicSpec, + privateSpec, + listOf(rejectingProvider, Ed25519Provider()), + ) + + assertEquals("EdDSA", restored.public.algorithm) + assertEquals("EdDSA", restored.private.algorithm) + assertEquals(providerNamesBefore, Security.getProviders().map { it.name }) + } + + @Test + fun `fails when no provider accepts key material`() { + assertFailsWith { + RawKeyFactory.generatePublic( + "Ed25519", + X509EncodedKeySpec(ByteArray(32)), + listOf(rejectingProvider), + ) + } + } +} + +class RejectingRawKeyProvider : Provider("RejectingRawKeyProvider", 1.0, "Rejects raw key material") { + init { + for (algorithm in listOf("RSA", "EC", "Ed25519", "Ed448", "X25519")) { + put("KeyFactory.$algorithm", RejectingRawKeyFactory::class.java.name) + } + } +} + +class RejectingRawKeyFactory : KeyFactorySpi() { + override fun engineGeneratePublic(keySpec: KeySpec): PublicKey = throw InvalidKeySpecException("Raw keys rejected") + + override fun engineGeneratePrivate(keySpec: KeySpec): PrivateKey = throw InvalidKeySpecException("Raw keys rejected") + + override fun engineGetKeySpec(key: Key, keySpec: Class): T = throw InvalidKeySpecException("Raw keys rejected") + + override fun engineTranslateKey(key: Key): Key = throw InvalidKeyException("Raw keys rejected") +}