Skip to content

X25519 KeyFactory can resolve to AndroidKeyStore, breaking curve25519 KEX on some Android devices #246

Description

@GlassOnTin

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:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions