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
-
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()
)
-
Receive a push notification whose parsed Notification.getTransactionLinkingId() is non-null
(i.e. it has an associated rich-consent / CIBA record).
-
Call guardian.fetchConsent(notification, enrollment).execute() (or .start(...)).
-
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
Description
Guardian.fetchConsent(notification, enrollment)throws aClassCastExceptionwhen theenrollment's
PrivateKey(Enrollment.getSigningKey()) is backed by the Android Keystore(
AndroidKeyStoreprovider) — i.e. exactly the kind of key the SDK's own README recommendsgenerating 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:
Root cause: in
RichConsentsAPIClient.createProofOfPossessionAssertion(source):privateKeyhere isEnrollment.getSigningKey()— whateverPrivateKeyimplementation theintegrator's
KeyStore.PrivateKeyEntryprovided at enrollment time. Android Keystore-backed keys(
android.security.keystore2.AndroidKeyStoreRSAPrivateKey/android.security.keystore.AndroidKeyStoreRSAPrivateKeyon 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:Signature.initSign(PrivateKey)works transparently with anyPrivateKeyimplementation,including opaque Keystore-backed ones — the KeyStore provider registers its own
SignatureSpifor"SHA256withRSA". This is whyallow()/reject()/device()all work fine with Keystore keys, andonly the newer (0.9.0+)
fetchConsentrich-consent path, which usesjava-jwt'sAlgorithm.RSA256(RSAPublicKey, RSAPrivateKey)instead ofSignaturedirectly, is affected.Suggested fix: build the RS256 signature the same way
GuardianAPIClient.signJWTdoes (viajava.security.Signature) instead of routing throughcom.auth0.jwt.algorithms.Algorithm.RSA256,which requires a concrete
RSAPrivateKey. Roughly:...and assemble/encode the DPoP
header.payload.signaturestring manually (assignJWTalreadydoes), rather than via
JWT.create().sign(alg).Reproduction
Enroll a device using an
AndroidKeyStore-backed RSAKeyPair, e.g.:Receive a push notification whose parsed
Notification.getTransactionLinkingId()is non-null(i.e. it has an associated rich-consent / CIBA record).
Call
guardian.fetchConsent(notification, enrollment).execute()(or.start(...)).Observe the
ClassCastExceptionabove. Reproduces consistently, every time, for any enrollmentusing an
AndroidKeyStore-backed key.We worked around this by bypassing
Guardian.fetchConsentand replicating theGET /rich-consents/{id}request + DPoP proof construction directly in our own code, signing viaSignature.getInstance("SHA256withRSA")instead ofAlgorithm.RSA256. Happy to share thatimplementation or submit a PR against this repo if useful.
Environment
com.auth0.android:guardian:0.11.0(issue present since therich-consent feature landed in 0.9.0 — https://github.com/auth0/Guardian.Android/releases/tag/0.9.0)
AndroidKeyStoreprovider[FILL IN — e.g. Pixel 7, Android 14]