diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/CredentialValidation.java b/server-spi-private/src/main/java/org/keycloak/models/utils/CredentialValidation.java index 58a48d8dede..3fd96afb4ff 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/CredentialValidation.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/CredentialValidation.java @@ -30,11 +30,11 @@ public class CredentialValidation { TimeBasedOTP validator = new TimeBasedOTP(credentialModel.getOTPCredentialData().getAlgorithm(), credentialModel.getOTPCredentialData().getDigits(), credentialModel.getOTPCredentialData().getPeriod(), lookAheadWindow); - return validator.validateTOTP(token, credentialModel.getOTPSecretData().getValue().getBytes()); + return validator.validateTOTP(token, credentialModel.getDecodedSecret()); } else { HmacOTP validator = new HmacOTP(credentialModel.getOTPCredentialData().getDigits(), credentialModel.getOTPCredentialData().getAlgorithm(), lookAheadWindow); - int c = validator.validateHOTP(token, credentialModel.getOTPSecretData().getValue(), + int c = validator.validateHOTP(token, credentialModel.getDecodedSecret(), credentialModel.getOTPCredentialData().getCounter()); return c > -1; } diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java index 75eb0896da2..9cb91b0d31f 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java @@ -230,7 +230,7 @@ public class RepresentationToModel { cred.setSecretData("{\"value\":\"" + cred.getHashedSaltedValue() + "\",\"salt\":\"" + cred.getSalt() + "\"}"); cred.setPriority(10); } else if (OTPCredentialModel.TOTP.equals(cred.getType()) || OTPCredentialModel.HOTP.equals(cred.getType())) { - OTPCredentialData credentialData = new OTPCredentialData(cred.getType(), cred.getDigits(), cred.getCounter(), cred.getPeriod(), cred.getAlgorithm()); + OTPCredentialData credentialData = new OTPCredentialData(cred.getType(), cred.getDigits(), cred.getCounter(), cred.getPeriod(), cred.getAlgorithm(), null); OTPSecretData secretData = new OTPSecretData(cred.getHashedSaltedValue()); cred.setCredentialData(JsonSerialization.writeValueAsString(credentialData)); cred.setSecretData(JsonSerialization.writeValueAsString(secretData)); diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/TimeBasedOTP.java b/server-spi-private/src/main/java/org/keycloak/models/utils/TimeBasedOTP.java index 49a1c902286..fd983ce28ec 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/TimeBasedOTP.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/TimeBasedOTP.java @@ -54,7 +54,7 @@ public class TimeBasedOTP extends HmacOTP { * * @param secretKey the secret key to derive the token from. */ - public String generateTOTP(String secretKey) { + public String generateTOTP(byte[] secretKey) { long T = this.clock.getCurrentInterval(); String steps = Long.toHexString(T).toUpperCase(); @@ -67,6 +67,10 @@ public class TimeBasedOTP extends HmacOTP { return generateOTP(secretKey, steps, this.numberDigits, this.algorithm); } + public String generateTOTP(String secretKey) { + return generateTOTP(secretKey.getBytes()); + } + /** *

Validates a token using a secret key.

* @@ -88,7 +92,7 @@ public class TimeBasedOTP extends HmacOTP { steps = "0" + steps; } - String candidate = generateOTP(new String(secret), steps, this.numberDigits, this.algorithm); + String candidate = generateOTP(secret, steps, this.numberDigits, this.algorithm); if (candidate.equals(token)) { return true; diff --git a/server-spi-private/src/test/java/org/keycloak/models/TotpTest.java b/server-spi-private/src/test/java/org/keycloak/models/TotpTest.java index a7cd0ba886a..cb0f9b1e84b 100644 --- a/server-spi-private/src/test/java/org/keycloak/models/TotpTest.java +++ b/server-spi-private/src/test/java/org/keycloak/models/TotpTest.java @@ -18,6 +18,9 @@ package org.keycloak.models; import org.junit.Assert; import org.junit.Test; +import org.keycloak.models.credential.OTPCredentialModel; +import org.keycloak.models.credential.OTPCredentialModel.SecretEncoding; +import org.keycloak.models.utils.Base32; import org.keycloak.models.utils.TimeBasedOTP; import java.nio.charset.StandardCharsets; @@ -55,4 +58,32 @@ public class TotpTest { Assert.assertTrue("Should accept code with skew offset " + i,totp.validateTOTP(otp, secret.getBytes(StandardCharsets.UTF_8))); } } + + @Test + public void testBase32EncodedSecret() { + TimeBasedOTP totp = new TimeBasedOTP("HmacSHA1", 8, 60, 1); + String rawSecret = "JNSVMMTEKZCUGSKJIVGHMNSQOZBDA5JT"; + String otp = totp.generateTOTP(Base32.decode(rawSecret)); + OTPCredentialModel credentialModel = OTPCredentialModel.createTOTP(rawSecret, 8, 30, "HmacSHA1"); + + Assert.assertFalse(totp.validateTOTP(otp, credentialModel.getDecodedSecret())); + + OTPCredentialModel encodedCredential = OTPCredentialModel.createTOTP(rawSecret, 8, 30, "HmacSHA1", SecretEncoding.BASE32.name()); + + Assert.assertTrue(totp.validateTOTP(otp, encodedCredential.getDecodedSecret())); + } + + @Test + public void testBase32BinaryEncodedSecret() { + TimeBasedOTP totp = new TimeBasedOTP("HmacSHA1", 8, 60, 1); + String rawSecret = "CDLYAYRJ73ORTU4PUWWATWSYQCP4H2QL"; + String otp = totp.generateTOTP(Base32.decode(rawSecret)); + OTPCredentialModel credentialModel = OTPCredentialModel.createTOTP(rawSecret, 8, 30, "HmacSHA1"); + + Assert.assertFalse(totp.validateTOTP(otp, credentialModel.getDecodedSecret())); + + OTPCredentialModel encodedCredential = OTPCredentialModel.createTOTP(rawSecret, 8, 30, "HmacSHA1", SecretEncoding.BASE32.name()); + + Assert.assertTrue(totp.validateTOTP(otp, encodedCredential.getDecodedSecret())); + } } diff --git a/server-spi/src/main/java/org/keycloak/models/credential/OTPCredentialModel.java b/server-spi/src/main/java/org/keycloak/models/credential/OTPCredentialModel.java index bb85fa55070..8c15da25867 100644 --- a/server-spi/src/main/java/org/keycloak/models/credential/OTPCredentialModel.java +++ b/server-spi/src/main/java/org/keycloak/models/credential/OTPCredentialModel.java @@ -6,22 +6,34 @@ import org.keycloak.models.credential.dto.OTPCredentialData; import org.keycloak.models.credential.dto.OTPSecretData; import org.keycloak.models.OTPPolicy; import org.keycloak.models.RealmModel; +import org.keycloak.models.utils.Base32; import org.keycloak.util.JsonSerialization; import java.io.IOException; +import java.nio.charset.StandardCharsets; public class OTPCredentialModel extends CredentialModel { public static final String TYPE = "otp"; - public static final String TOTP = "totp"; public static final String HOTP = "hotp"; + /** + * The supported encodings when reading the raw secret from the storage + */ + public enum SecretEncoding { + BASE32 + } + private final OTPCredentialData credentialData; private final OTPSecretData secretData; private OTPCredentialModel(String secretValue, String subType, int digits, int counter, int period, String algorithm) { - credentialData = new OTPCredentialData(subType, digits, counter, period, algorithm); + this(secretValue, subType, digits, counter, period, algorithm, null); + } + + private OTPCredentialModel(String secretValue, String subType, int digits, int counter, int period, String algorithm, String secretEncoding) { + credentialData = new OTPCredentialData(subType, digits, counter, period, algorithm, secretEncoding); secretData = new OTPSecretData(secretValue); } @@ -31,7 +43,11 @@ public class OTPCredentialModel extends CredentialModel { } public static OTPCredentialModel createTOTP(String secretValue, int digits, int period, String algorithm){ - OTPCredentialModel credentialModel = new OTPCredentialModel(secretValue, TOTP, digits, 0, period, algorithm); + return createTOTP(secretValue, digits, period, algorithm, null); + } + + public static OTPCredentialModel createTOTP(String secretValue, int digits, int period, String algorithm, String encoding){ + OTPCredentialModel credentialModel = new OTPCredentialModel(secretValue, TOTP, digits, 0, period, algorithm, encoding); credentialModel.fillCredentialModelFields(); return credentialModel; } @@ -92,6 +108,24 @@ public class OTPCredentialModel extends CredentialModel { return secretData; } + public byte[] getDecodedSecret() { + String encoding = credentialData.getSecretEncoding(); + + if (encoding == null) { + return secretData.getValue().getBytes(StandardCharsets.UTF_8); + } + + try { + if (SecretEncoding.BASE32.equals(SecretEncoding.valueOf(encoding.toUpperCase()))) { + return Base32.decode(secretData.getValue()); + } + + throw new RuntimeException("Unsupported secret encoding: " + encoding); + } catch (Exception cause) { + throw new RuntimeException("Failed to decode otp secret using encoding [" + encoding + "]", cause); + } + } + private void fillCredentialModelFields(){ try { setCredentialData(JsonSerialization.writeValueAsString(credentialData)); diff --git a/server-spi/src/main/java/org/keycloak/models/credential/dto/OTPCredentialData.java b/server-spi/src/main/java/org/keycloak/models/credential/dto/OTPCredentialData.java index a9cfec3acf7..1c3bd8817fb 100644 --- a/server-spi/src/main/java/org/keycloak/models/credential/dto/OTPCredentialData.java +++ b/server-spi/src/main/java/org/keycloak/models/credential/dto/OTPCredentialData.java @@ -10,17 +10,21 @@ public class OTPCredentialData { private final int period; private final String algorithm; + private final String secretEncoding; + @JsonCreator public OTPCredentialData(@JsonProperty("subType") String subType, @JsonProperty("digits") int digits, @JsonProperty("counter") int counter, @JsonProperty("period") int period, - @JsonProperty("algorithm") String algorithm) { + @JsonProperty("algorithm") String algorithm, + @JsonProperty("secretEncoding") String secretEncoding) { this.subType = subType; this.digits = digits; this.counter = counter; this.period = period; this.algorithm = algorithm; + this.secretEncoding = secretEncoding; } public String getSubType() { @@ -46,4 +50,8 @@ public class OTPCredentialData { public String getAlgorithm() { return algorithm; } + + public String getSecretEncoding() { + return secretEncoding; + } } diff --git a/server-spi/src/main/java/org/keycloak/models/utils/HmacOTP.java b/server-spi/src/main/java/org/keycloak/models/utils/HmacOTP.java index f8489d4cee9..3456f11b267 100755 --- a/server-spi/src/main/java/org/keycloak/models/utils/HmacOTP.java +++ b/server-spi/src/main/java/org/keycloak/models/utils/HmacOTP.java @@ -55,7 +55,7 @@ public class HmacOTP { return sb.toString(); } - public String generateHOTP(String key, int counter) { + public String generateHOTP(byte[] key, int counter) { String steps = Integer.toHexString(counter).toUpperCase(); // Just get a 16 digit string @@ -66,6 +66,10 @@ public class HmacOTP { } + public String generateHOTP(String key, int counter) { + return generateHOTP(key.getBytes(), counter); + } + /** * * @param token @@ -73,7 +77,7 @@ public class HmacOTP { * @param counter * @return -1 if not a match. A positive number means successful validation. This positive number is also the new value of the counter */ - public int validateHOTP(String token, String key, int counter) { + public int validateHOTP(String token, byte[] key, int counter) { int newCounter = counter; for (newCounter = counter; newCounter <= counter + lookAheadWindow; newCounter++) { @@ -86,6 +90,10 @@ public class HmacOTP { return -1; } + public int validateHOTP(String token, String key, int counter) { + return validateHOTP(token, key.getBytes(), counter); + } + /** * This method generates an OTP value for the given set of parameters. * @@ -97,7 +105,7 @@ public class HmacOTP { * @throws java.security.GeneralSecurityException * */ - public String generateOTP(String key, String counter, int returnDigits, String crypto) { + public String generateOTP(byte[] key, String counter, int returnDigits, String crypto) { String result = null; byte[] hash; @@ -112,9 +120,8 @@ public class HmacOTP { // Adding one byte to get the right conversion // byte[] k = hexStr2Bytes(key); - byte[] k = key.getBytes(); - hash = hmac_sha1(crypto, k, msg); + hash = hmac_sha1(crypto, key, msg); // put selected bytes into result int int offset = hash[hash.length - 1] & 0xf; diff --git a/services/src/main/java/org/keycloak/credential/OTPCredentialProvider.java b/services/src/main/java/org/keycloak/credential/OTPCredentialProvider.java index b72d1226664..f5c3ac62836 100644 --- a/services/src/main/java/org/keycloak/credential/OTPCredentialProvider.java +++ b/services/src/main/java/org/keycloak/credential/OTPCredentialProvider.java @@ -31,8 +31,6 @@ import org.keycloak.models.credential.dto.OTPSecretData; import org.keycloak.models.utils.HmacOTP; import org.keycloak.models.utils.TimeBasedOTP; -import java.nio.charset.StandardCharsets; - /** * @author Bill Burke * @version $Revision: 1 $ @@ -103,7 +101,7 @@ public class OTPCredentialProvider implements CredentialProvider credentials = user.credentials(); + CredentialRepresentation otpCredential = credentials.stream() + .filter(c -> OTPCredentialModel.TYPE.equals(c.getType())) + .findAny().orElse(null); + + Assert.assertNotNull(otpCredential); + + OTPCredentialData credentialData = JsonSerialization.readValue(otpCredential.getCredentialData(), OTPCredentialData.class); + OTPCredentialData newCredentialData = new OTPCredentialData(credentialData.getSubType(), credentialData.getDigits(), credentialData.getCounter(), credentialData.getPeriod(), credentialData.getAlgorithm(), + SecretEncoding.BASE32.name()); + UserRepresentation newUser = UserBuilder.create().username("test-otp-user@localhost").password("password").enabled(true).build(); + CredentialRepresentation credential = new CredentialRepresentation(); + + credential.setType(otpCredential.getType()); + credential.setTemporary(false); + credential.setUserLabel("my-otp"); + credential.setCredentialData(JsonSerialization.writeValueAsString(newCredentialData)); + + String rawSecret = "JXGDDKNLXTBKGTA2KV6QJGAF4SS4R75X"; + + credential.setSecretData(JsonSerialization.writeValueAsString(new OTPSecretData(rawSecret))); + + newUser.getCredentials().add(credential); + + testRealm().users().create(newUser).close(); + + loginPage.open(); + loginPage.login(newUser.getUsername(), "password"); + + Assert.assertTrue(loginTotpPage.isCurrent()); + + setOtpTimeOffset(TimeBasedOTP.DEFAULT_INTERVAL_SECONDS, totp); + + loginTotpPage.login(totp.generateTOTP(Base32.decode(rawSecret))); + + Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + } }