Skip to content

Commit 7a28aa1

Browse files
committed
security(trust): use RS256 instead of HS256 for device token signing
1 parent 94b0f31 commit 7a28aa1

File tree

1 file changed

+48
-26
lines changed

1 file changed

+48
-26
lines changed

src/main/java/ch/jacem/for_keycloak/email_otp_authenticator/EmailOTPFormAuthenticator.java

Lines changed: 48 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66

77
import java.nio.charset.StandardCharsets;
88
import java.security.MessageDigest;
9+
import java.security.PrivateKey;
10+
import java.security.PublicKey;
911
import java.security.SecureRandom;
12+
import java.security.Signature;
1013
import java.util.HashMap;
1114
import java.util.List;
1215
import java.util.Map;
@@ -23,7 +26,6 @@
2326
import org.keycloak.events.Errors;
2427
import org.keycloak.forms.login.LoginFormsProvider;
2528
import org.keycloak.jose.jws.Algorithm;
26-
import org.keycloak.jose.jws.crypto.HMACProvider;
2729
import org.keycloak.jose.jws.crypto.HashUtils;
2830
import org.keycloak.models.AuthenticatorConfigModel;
2931
import 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

Comments
 (0)