Skip to content

fetchConsent throws ClassCastException when the enrollment's private key is Android Keystore-backed #148

Description

@Taaqif

Description

Guardian.fetchConsent(notification, enrollment) throws a ClassCastException when the
enrollment's PrivateKey (Enrollment.getSigningKey()) is backed by the Android Keystore
(AndroidKeyStore provider) — i.e. exactly the kind of key the SDK's own README recommends
generating for enrollment ("A note about key generation").

Expected: the rich consent record is fetched successfully, the same way allow(...)/reject(...)
succeed with the same enrollment and key.

Actual:

java.lang.ClassCastException: android.security.keystore2.AndroidKeyStoreRSAPrivateKey cannot be
cast to java.security.interfaces.RSAPrivateKey

Root cause: in RichConsentsAPIClient.createProofOfPossessionAssertion (source):

Algorithm alg = Algorithm.RSA256(null, (RSAPrivateKey) privateKey);
return JWT.create()
    .withHeader(headers)
    .withPayload(claims)
    .sign(alg);

privateKey here is Enrollment.getSigningKey() — whatever PrivateKey implementation the
integrator's KeyStore.PrivateKeyEntry provided at enrollment time. Android Keystore-backed keys
(android.security.keystore2.AndroidKeyStoreRSAPrivateKey / android.security.keystore.AndroidKeyStoreRSAPrivateKey
on older API levels) intentionally do not implement java.security.interfaces.RSAPrivateKey,
because that interface would require exposing the raw modulus/private exponent, which
hardware/StrongBox-backed keys never release. This is long-documented Android platform behavior
(see e.g. issuetracker.google.com/issues/37091211),
not a bug in the key itself.

Every other signing path in this SDK (GuardianAPIClient.signJWT) correctly avoids this by using:

final Signature signer = Signature.getInstance("SHA256withRSA");
signer.initSign(privateKey);
signer.update(messageBytes);
byte[] signature = signer.sign();

Signature.initSign(PrivateKey) works transparently with any PrivateKey implementation,
including opaque Keystore-backed ones — the KeyStore provider registers its own SignatureSpi for
"SHA256withRSA". This is why allow()/reject()/device() all work fine with Keystore keys, and
only the newer (0.9.0+) fetchConsent rich-consent path, which uses java-jwt's
Algorithm.RSA256(RSAPublicKey, RSAPrivateKey) instead of Signature directly, is affected.

Suggested fix: build the RS256 signature the same way GuardianAPIClient.signJWT does (via
java.security.Signature) instead of routing through com.auth0.jwt.algorithms.Algorithm.RSA256,
which requires a concrete RSAPrivateKey. Roughly:

private static byte[] signWithRsaSha256(PrivateKey privateKey, byte[] signingInput) {
    Signature signer = Signature.getInstance("SHA256withRSA");
    signer.initSign(privateKey);
    signer.update(signingInput);
    return signer.sign();
}

...and assemble/encode the DPoP header.payload.signature string manually (as signJWT already
does), rather than via JWT.create().sign(alg).

Reproduction

  1. Enroll a device using an AndroidKeyStore-backed RSA KeyPair, e.g.:

    val keyPairGenerator = KeyPairGenerator.getInstance(
      KeyProperties.KEY_ALGORITHM_RSA,
      "AndroidKeyStore"
    )
    keyPairGenerator.initialize(
      KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY)
        .setDigests(KeyProperties.DIGEST_SHA256)
        .setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1)
        .setKeySize(2048)
        .build()
    )
  2. Receive a push notification whose parsed Notification.getTransactionLinkingId() is non-null
    (i.e. it has an associated rich-consent / CIBA record).

  3. Call guardian.fetchConsent(notification, enrollment).execute() (or .start(...)).

  4. Observe the ClassCastException above. Reproduces consistently, every time, for any enrollment
    using an AndroidKeyStore-backed key.

We worked around this by bypassing Guardian.fetchConsent and replicating the
GET /rich-consents/{id} request + DPoP proof construction directly in our own code, signing via
Signature.getInstance("SHA256withRSA") instead of Algorithm.RSA256. Happy to share that
implementation or submit a PR against this repo if useful.

Environment

  • Version of this library used: com.auth0.android:guardian:0.11.0 (issue present since the
    rich-consent feature landed in 0.9.0 — https://github.com/auth0/Guardian.Android/releases/tag/0.9.0)
  • Version of the platform or framework used, if applicable: Android, AndroidKeyStore provider
  • Other relevant versions (language, server software, OS, browser): Kotlin; reproduced on
    [FILL IN — e.g. Pixel 7, Android 14]
  • Other modules/plugins/libraries that might be involved: N/A — reproduces with the SDK alone

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions