66
77import java .nio .charset .StandardCharsets ;
88import java .security .MessageDigest ;
9+ import java .security .PrivateKey ;
10+ import java .security .PublicKey ;
911import java .security .SecureRandom ;
12+ import java .security .Signature ;
1013import java .util .HashMap ;
1114import java .util .List ;
1215import java .util .Map ;
2326import org .keycloak .events .Errors ;
2427import org .keycloak .forms .login .LoginFormsProvider ;
2528import org .keycloak .jose .jws .Algorithm ;
26- import org .keycloak .jose .jws .crypto .HMACProvider ;
2729import org .keycloak .jose .jws .crypto .HashUtils ;
2830import org .keycloak .models .AuthenticatorConfigModel ;
2931import org .keycloak .models .KeycloakSession ;
@@ -299,36 +301,38 @@ private String hashIpAddress(RealmModel realm, String ipAddress) {
299301 }
300302
301303 /**
302- * Signs a device token using HMAC -SHA256 with Keycloak's managed key.
303- * Uses Keycloak's key management and HMACProvider for signing.
304- * Falls back to realm name as key material if no HMAC key is configured .
304+ * Signs a device token using RSA -SHA256 with Keycloak's managed key.
305+ * Uses Keycloak's key management for signing. RS256 keys are always
306+ * available in Keycloak realms (used for JWT signing) .
305307 * Returns format: token.signature
306308 */
307309 private String signDeviceToken (KeycloakSession session , RealmModel realm , String token ) {
308310 try {
309- // Get the active HMAC key from Keycloak's key management
310- KeyWrapper key = session .keys ().getActiveKey (realm , KeyUse .SIG , Algorithm .HS256 .name ());
311+ // Get the active RS256 key from Keycloak's key management (always available)
312+ KeyWrapper key = session .keys ().getActiveKey (realm , KeyUse .SIG , Algorithm .RS256 .name ());
311313
312- byte [] signature ;
313- if (key != null && key .getSecretKey () != null ) {
314- signature = HMACProvider .sign (token .getBytes (StandardCharsets .UTF_8 ), Algorithm .HS256 , key .getSecretKey ());
315- } else {
316- // Fallback: use realm name as key material
317- logger .debug ("No active HS256 key found, using realm name as fallback key" );
318- byte [] fallbackKey = ("email-otp-device-trust:" + realm .getName ()).getBytes (StandardCharsets .UTF_8 );
319- signature = HMACProvider .sign (token .getBytes (StandardCharsets .UTF_8 ), Algorithm .HS256 , fallbackKey );
314+ if (key == null || key .getPrivateKey () == null ) {
315+ logger .error ("No RS256 signing key available in realm - this should not happen" );
316+ return null ;
320317 }
321318
322- return token + "." + Base64Url .encode (signature );
319+ // Sign using RSA-SHA256
320+ Signature signature = Signature .getInstance ("SHA256withRSA" );
321+ signature .initSign ((PrivateKey ) key .getPrivateKey ());
322+ signature .update (token .getBytes (StandardCharsets .UTF_8 ));
323+ byte [] signatureBytes = signature .sign ();
324+
325+ return token + "." + Base64Url .encode (signatureBytes );
323326 } catch (Exception e ) {
324327 logger .warn ("Failed to sign device token" , e );
325328 return null ;
326329 }
327330 }
328331
329332 /**
330- * Verifies a signed device token.
331- * Uses Keycloak's key management and HMACProvider for signature verification.
333+ * Verifies a signed device token using RSA-SHA256.
334+ * Tries all available RS256 keys (active and passive) to handle key rotation -
335+ * tokens signed with rotated keys can still be verified.
332336 * Returns the original token if valid, null if invalid.
333337 */
334338 private String verifyDeviceToken (KeycloakSession session , RealmModel realm , String signedToken ) {
@@ -342,21 +346,39 @@ private String verifyDeviceToken(KeycloakSession session, RealmModel realm, Stri
342346 }
343347
344348 String token = signedToken .substring (0 , separatorIndex );
345- String providedSignature = signedToken .substring (separatorIndex + 1 );
349+ String signatureBase64 = signedToken .substring (separatorIndex + 1 );
346350
347351 try {
348- // Re-sign and compare using constant-time comparison
349- String expectedSigned = signDeviceToken (session , realm , token );
350- if (expectedSigned == null ) {
352+ byte [] signatureBytes = Base64Url .decode (signatureBase64 );
353+
354+ // Get all RS256 keys (active and passive) for verification
355+ // This handles key rotation - old tokens signed with rotated keys can still be verified
356+ List <KeyWrapper > keys = session .keys ().getKeysStream (realm , KeyUse .SIG , Algorithm .RS256 .name ())
357+ .filter (k -> k .getStatus ().isEnabled () && k .getPublicKey () != null )
358+ .toList ();
359+
360+ if (keys .isEmpty ()) {
361+ logger .error ("No RS256 keys available for verification" );
351362 return null ;
352363 }
353- String expectedSignature = expectedSigned .substring (expectedSigned .lastIndexOf ("." ) + 1 );
354364
355- if (MessageDigest .isEqual (
356- providedSignature .getBytes (StandardCharsets .UTF_8 ),
357- expectedSignature .getBytes (StandardCharsets .UTF_8 ))) {
358- return token ;
365+ // Try each key (handles key rotation)
366+ for (KeyWrapper key : keys ) {
367+ try {
368+ Signature signature = Signature .getInstance ("SHA256withRSA" );
369+ signature .initVerify ((PublicKey ) key .getPublicKey ());
370+ signature .update (token .getBytes (StandardCharsets .UTF_8 ));
371+
372+ if (signature .verify (signatureBytes )) {
373+ return token ;
374+ }
375+ } catch (Exception e ) {
376+ // Try next key
377+ logger .debugf ("Verification with key %s failed, trying next" , key .getKid ());
378+ }
359379 }
380+
381+ logger .debug ("Device token signature verification failed - no matching key" );
360382 } catch (Exception e ) {
361383 logger .debug ("Token verification failed" , e );
362384 }
0 commit comments