On some Android devices, every connection that negotiates curve25519-sha256 fails during key exchange with:
java.security.spec.InvalidKeySpecException: To generate a key pair in Android Keystore,
use KeyPairGenerator initialized with android.security.keystore.KeyGenParameterSpec
at android.security.keystore2.AndroidKeyStoreKeyFactorySpi.engineGeneratePublic(AndroidKeyStoreKeyFactorySpi.java:127)
at java.security.KeyFactory.generatePublic(KeyFactory.java:373)
Reported against Haven on Android 16 (GlassHaven/Haven#451). Not universal — I run the same version on Android 16 without hitting it — so it depends on which JCE provider wins the lookup on a given build.
Cause
PlatformX25519Provider resolves both primitives without naming a provider:
private val keyPairGenerator = KeyPairGenerator.getInstance(ALGORITHM)
private val keyFactory = KeyFactory.getInstance(ALGORITHM)
On the affected device the KeyFactory lookup lands on AndroidKeyStore, which by design only produces keys backed by the keystore. Converting a peer's ephemeral X25519 public key from an X509EncodedKeySpec is something it can never do, so createPublicKey() throws on the first shared-secret computation.
Why the availability probe does not catch it
X25519ProviderFactory decides between platform and Tink like this:
private fun isPlatformNativeAvailable(): Boolean = try {
KeyPairGenerator.getInstance("X25519")
Class.forName("java.security.spec.XECPrivateKeySpec")
true
} catch (_: Exception) { false }
It probes the KeyPairGenerator and then the provider uses a separately-resolved KeyFactory. Those two lookups can resolve to different providers, and on the affected device they do: key generation succeeds — the reported failure is at generatePublic, which is reached only after generatePrivateKey() has already worked — while the KeyFactory is AndroidKeyStore. So the probe passes and the operation still fails.
Constructing the objects also proves nothing here: KeyFactory.getInstance succeeds for AndroidKeyStore; only the later generatePublic call rejects the spec.
Suggested fix
Whichever of these you prefer:
- Name the provider. Both lookups want a software implementation of raw X25519 — on Android that is
AndroidOpenSSL (Conscrypt). AndroidKeyStore is never a valid answer for raw key material, so it could also simply be excluded by name.
- Probe with a real round-trip. Generate a key pair, encode the public key, and feed it back through
keyFactory.generatePublic() inside the try. That exercises the exact call that fails and falls back to Tink correctly.
The second is the more robust shape, since it validates the primitives actually used rather than a proxy for them.
Note for the fallback path
Worth flagging because it affects how the fallback behaves in practice: consumers who exclude Tink (Haven does, to keep the JVM Tink artifact out of an Android build) have no fallback available if the platform path is rejected. That is our problem rather than yours, but it means fix (1) — making the platform path work — is materially more useful to us than fix (2) alone.
Happy to test a patch against the affected device via the reporter; I cannot reproduce it on my own hardware.
On some Android devices, every connection that negotiates
curve25519-sha256fails during key exchange with:Reported against Haven on Android 16 (GlassHaven/Haven#451). Not universal — I run the same version on Android 16 without hitting it — so it depends on which JCE provider wins the lookup on a given build.
Cause
PlatformX25519Providerresolves both primitives without naming a provider:On the affected device the
KeyFactorylookup lands on AndroidKeyStore, which by design only produces keys backed by the keystore. Converting a peer's ephemeral X25519 public key from anX509EncodedKeySpecis something it can never do, socreatePublicKey()throws on the first shared-secret computation.Why the availability probe does not catch it
X25519ProviderFactorydecides between platform and Tink like this:It probes the KeyPairGenerator and then the provider uses a separately-resolved KeyFactory. Those two lookups can resolve to different providers, and on the affected device they do: key generation succeeds — the reported failure is at
generatePublic, which is reached only aftergeneratePrivateKey()has already worked — while the KeyFactory is AndroidKeyStore. So the probe passes and the operation still fails.Constructing the objects also proves nothing here:
KeyFactory.getInstancesucceeds for AndroidKeyStore; only the latergeneratePubliccall rejects the spec.Suggested fix
Whichever of these you prefer:
AndroidOpenSSL(Conscrypt). AndroidKeyStore is never a valid answer for raw key material, so it could also simply be excluded by name.keyFactory.generatePublic()inside thetry. That exercises the exact call that fails and falls back to Tink correctly.The second is the more robust shape, since it validates the primitives actually used rather than a proxy for them.
Note for the fallback path
Worth flagging because it affects how the fallback behaves in practice: consumers who exclude Tink (Haven does, to keep the JVM Tink artifact out of an Android build) have no fallback available if the platform path is rejected. That is our problem rather than yours, but it means fix (1) — making the platform path work — is materially more useful to us than fix (2) alone.
Happy to test a patch against the affected device via the reporter; I cannot reproduce it on my own hardware.