mirror of
https://github.com/google/nomulus
synced 2026-07-08 09:06:58 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b1266c95e8 | |||
| bc9aab6790 |
@@ -47,6 +47,7 @@ import google.registry.model.eppinput.EppInput.Options;
|
||||
import google.registry.model.eppinput.EppInput.Services;
|
||||
import google.registry.model.eppoutput.EppResponse;
|
||||
import google.registry.model.registrar.Registrar;
|
||||
import google.registry.util.PasswordUtils;
|
||||
import google.registry.util.StopwatchLogger;
|
||||
import jakarta.inject.Inject;
|
||||
import java.util.Optional;
|
||||
@@ -150,8 +151,19 @@ public class LoginFlow implements MutatingFlow {
|
||||
throw new RegistrarAccountNotActiveException();
|
||||
}
|
||||
|
||||
if (login.getNewPassword().isPresent()) {
|
||||
String newPassword = login.getNewPassword().get();
|
||||
// TODO(b/458423787): Remove this circa March 2026 after enough time has passed for the logins
|
||||
// to have transitioned to Argon2 hashing.
|
||||
if (login.getNewPassword().isPresent()
|
||||
|| registrar.get().getCurrentHashAlgorithm(login.getPassword()).orElse(null)
|
||||
!= PasswordUtils.HashAlgorithm.ARGON_2_ID) {
|
||||
String newPassword =
|
||||
login
|
||||
.getNewPassword()
|
||||
.orElseGet(
|
||||
() -> {
|
||||
logger.atInfo().log("Rehashing existing registrar password with ARGON_2_ID");
|
||||
return login.getPassword();
|
||||
});
|
||||
// Load fresh from database (bypassing the cache) to ensure we don't save stale data.
|
||||
Optional<Registrar> freshRegistrar = Registrar.loadByRegistrarId(login.getClientId());
|
||||
stopwatch.tick("LoginFlow reload freshRegistrar");
|
||||
|
||||
@@ -37,6 +37,7 @@ import google.registry.tools.IamClient;
|
||||
import google.registry.tools.ServiceConnection;
|
||||
import google.registry.tools.server.UpdateUserGroupAction;
|
||||
import google.registry.util.PasswordUtils;
|
||||
import google.registry.util.PasswordUtils.HashAlgorithm;
|
||||
import google.registry.util.RegistryEnvironment;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Embeddable;
|
||||
@@ -229,6 +230,10 @@ public class User extends UpdateAutoTimestampEntity implements Buildable {
|
||||
|| isNullOrEmpty(registryLockPasswordHash)) {
|
||||
return false;
|
||||
}
|
||||
return getCurrentHashAlgorithm(registryLockPassword).isPresent();
|
||||
}
|
||||
|
||||
public Optional<HashAlgorithm> getCurrentHashAlgorithm(String registryLockPassword) {
|
||||
return PasswordUtils.verifyPassword(
|
||||
registryLockPassword, registryLockPasswordHash, registryLockPasswordSalt);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ import google.registry.persistence.converter.CurrencyToStringMapUserType;
|
||||
import google.registry.persistence.transaction.TransactionManager;
|
||||
import google.registry.util.CidrAddressBlock;
|
||||
import google.registry.util.PasswordUtils;
|
||||
import google.registry.util.PasswordUtils.HashAlgorithm;
|
||||
import jakarta.mail.internet.AddressException;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
import jakarta.persistence.AttributeOverride;
|
||||
@@ -672,6 +673,10 @@ public class Registrar extends UpdateAutoTimestampEntity implements Buildable, J
|
||||
}
|
||||
|
||||
public boolean verifyPassword(String password) {
|
||||
return getCurrentHashAlgorithm(password).isPresent();
|
||||
}
|
||||
|
||||
public Optional<HashAlgorithm> getCurrentHashAlgorithm(String password) {
|
||||
return PasswordUtils.verifyPassword(password, passwordHash, salt);
|
||||
}
|
||||
|
||||
|
||||
+22
-4
@@ -23,6 +23,7 @@ import static jakarta.servlet.http.HttpServletResponse.SC_OK;
|
||||
import static jakarta.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
|
||||
|
||||
import com.google.common.collect.ImmutableList;
|
||||
import com.google.common.flogger.FluentLogger;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import google.registry.flows.EppException;
|
||||
import google.registry.flows.domain.DomainFlowUtils;
|
||||
@@ -40,6 +41,7 @@ import google.registry.request.Response;
|
||||
import google.registry.request.auth.Auth;
|
||||
import google.registry.tools.DomainLockUtils;
|
||||
import google.registry.util.EmailMessage;
|
||||
import google.registry.util.PasswordUtils.HashAlgorithm;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.mail.internet.AddressException;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
@@ -61,13 +63,16 @@ import org.joda.time.Duration;
|
||||
auth = Auth.AUTH_PUBLIC_LOGGED_IN)
|
||||
public class ConsoleRegistryLockAction extends ConsoleApiAction {
|
||||
|
||||
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
|
||||
|
||||
static final String PATH = "/console-api/registry-lock";
|
||||
static final String VERIFICATION_EMAIL_TEMPLATE =
|
||||
"""
|
||||
Please click the link below to perform the lock / unlock action on domain %s. Note: this\
|
||||
code will expire in one hour.
|
||||
|
||||
%s""";
|
||||
%s\
|
||||
""";
|
||||
|
||||
private final DomainLockUtils domainLockUtils;
|
||||
private final GmailClient gmailClient;
|
||||
@@ -114,7 +119,6 @@ public class ConsoleRegistryLockAction extends ConsoleApiAction {
|
||||
optionalPostInput.orElseThrow(() -> new IllegalArgumentException("No POST input provided"));
|
||||
String domainName = postInput.domainName();
|
||||
boolean isLock = postInput.isLock();
|
||||
Optional<String> maybePassword = Optional.ofNullable(postInput.password());
|
||||
Optional<Long> relockDurationMillis = Optional.ofNullable(postInput.relockDurationMillis());
|
||||
|
||||
try {
|
||||
@@ -126,11 +130,25 @@ public class ConsoleRegistryLockAction extends ConsoleApiAction {
|
||||
// Passwords aren't required for admin users, otherwise we need to validate it
|
||||
boolean isAdmin = user.getUserRoles().isAdmin();
|
||||
if (!isAdmin) {
|
||||
checkArgument(maybePassword.isPresent(), "No password provided");
|
||||
if (!user.verifyRegistryLockPassword(maybePassword.get())) {
|
||||
checkArgument(postInput.password != null, "No password provided");
|
||||
Optional<HashAlgorithm> hashAlgorithm = user.getCurrentHashAlgorithm(postInput.password);
|
||||
if (hashAlgorithm.isEmpty()) {
|
||||
setFailedResponse("Incorrect registry lock password", SC_UNAUTHORIZED);
|
||||
return;
|
||||
}
|
||||
// TODO(b/458423787): Remove this circa March 2026 after enough time has passed for the logins
|
||||
// to have transitioned to Argon2 hashing.
|
||||
if (hashAlgorithm.get() != HashAlgorithm.ARGON_2_ID) {
|
||||
logger.atInfo().log("Rehashing existing registry lock password with ARGON_2_ID.");
|
||||
tm().transact(
|
||||
() ->
|
||||
tm().update(
|
||||
tm().loadByEntity(user)
|
||||
.asBuilder()
|
||||
.removeRegistryLockPassword()
|
||||
.setRegistryLockPassword(postInput.password)
|
||||
.build()));
|
||||
}
|
||||
}
|
||||
|
||||
String registryLockEmail =
|
||||
|
||||
@@ -10,36 +10,30 @@
|
||||
<import namespace="urn:ietf:params:xml:ns:domain-1.0" />
|
||||
|
||||
<annotation>
|
||||
<documentation>Extensible Provisioning Protocol
|
||||
v1.0 extension schema for fee
|
||||
information.</documentation>
|
||||
<documentation>
|
||||
Extensible Provisioning Protocol v1.0 Fee Extension
|
||||
</documentation>
|
||||
</annotation>
|
||||
|
||||
<!--
|
||||
Child elements found in EPP commands and responses
|
||||
-->
|
||||
<element name="check" type="fee:checkType" />
|
||||
<element name="chkData" type="fee:chkDataType" />
|
||||
<element name="create" type="fee:transformCommandType" />
|
||||
<element name="creData" type="fee:transformResultType" />
|
||||
<element name="renew" type="fee:transformCommandType" />
|
||||
<element name="renData" type="fee:transformResultType" />
|
||||
<!-- Child elements found in EPP commands and responses -->
|
||||
<element name="check" type="fee:checkType" />
|
||||
<element name="chkData" type="fee:chkDataType" />
|
||||
<element name="create" type="fee:transformCommandType" />
|
||||
<element name="creData" type="fee:transformResultType" />
|
||||
<element name="renew" type="fee:transformCommandType" />
|
||||
<element name="renData" type="fee:transformResultType" />
|
||||
<element name="transfer" type="fee:transformCommandType" />
|
||||
<element name="trnData" type="fee:transferResultType" />
|
||||
<element name="update" type="fee:transformCommandType" />
|
||||
<element name="updData" type="fee:transformResultType" />
|
||||
<element name="delData" type="fee:transformResultType" />
|
||||
<element name="trnData" type="fee:transferResultType" />
|
||||
<element name="update" type="fee:transformCommandType" />
|
||||
<element name="updData" type="fee:transformResultType" />
|
||||
<element name="delData" type="fee:transformResultType" />
|
||||
|
||||
<!--
|
||||
client <check> command
|
||||
-->
|
||||
<!-- client <check> command -->
|
||||
<complexType name="checkType">
|
||||
<sequence>
|
||||
<element name="currency"
|
||||
type="fee:currencyType"
|
||||
<element name="currency" type="fee:currencyType"
|
||||
minOccurs="0" />
|
||||
<element name="command"
|
||||
type="fee:commandCheckType"
|
||||
<element name="command" type="fee:commandCheckType"
|
||||
maxOccurs="unbounded" />
|
||||
</sequence>
|
||||
</complexType>
|
||||
@@ -61,17 +55,9 @@
|
||||
<attribute name="subphase" type="token" />
|
||||
</complexType>
|
||||
|
||||
<!--
|
||||
server <check> result
|
||||
-->
|
||||
<!-- server <check> result -->
|
||||
<complexType name="chkDataType">
|
||||
<sequence>
|
||||
<!--
|
||||
Dialog is ongoing with Gavin Brown about this. His XSD has a
|
||||
mandatory currency field. But what if the different domains being
|
||||
checked have different currencies? I am trying to figure out what
|
||||
he intends here. - Brian
|
||||
-->
|
||||
<element name="currency" type="fee:currencyType" minOccurs="0"/>
|
||||
<element name="cd" type="fee:objectCDType"
|
||||
maxOccurs="unbounded" />
|
||||
@@ -123,9 +109,7 @@
|
||||
<attribute name="subphase" type="token" />
|
||||
</complexType>
|
||||
|
||||
<!--
|
||||
general transform (create, renew, update, transfer) command
|
||||
-->
|
||||
<!-- general transform (create, renew, update, transfer) command-->
|
||||
<complexType name="transformCommandType">
|
||||
<sequence>
|
||||
<element name="currency" type="fee:currencyType"
|
||||
@@ -137,9 +121,7 @@
|
||||
</sequence>
|
||||
</complexType>
|
||||
|
||||
<!--
|
||||
general transform (create, renew, update, delete) result
|
||||
-->
|
||||
<!-- general transform (create, renew, update, delete) result -->
|
||||
<complexType name="transformResultType">
|
||||
<sequence>
|
||||
<element name="currency" type="fee:currencyType" />
|
||||
@@ -154,9 +136,7 @@
|
||||
</sequence>
|
||||
</complexType>
|
||||
|
||||
<!--
|
||||
transfer result
|
||||
-->
|
||||
<!-- transfer result -->
|
||||
<complexType name="transferResultType">
|
||||
<sequence>
|
||||
<element name="currency" type="fee:currencyType" />
|
||||
@@ -169,7 +149,6 @@
|
||||
maxOccurs="unbounded" />
|
||||
<element name="credit" type="fee:creditType"
|
||||
minOccurs="0" maxOccurs="unbounded" />
|
||||
|
||||
<element name="balance" type="fee:balanceType"
|
||||
minOccurs="0" />
|
||||
<element name="creditLimit" type="fee:creditLimitType"
|
||||
@@ -177,9 +156,7 @@
|
||||
</sequence>
|
||||
</complexType>
|
||||
|
||||
<!--
|
||||
common types
|
||||
-->
|
||||
<!-- common types -->
|
||||
<simpleType name="currencyType">
|
||||
<restriction base="string">
|
||||
<pattern value="[A-Z]{3}" />
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
|
||||
package google.registry.flows.session;
|
||||
|
||||
import static com.google.common.io.BaseEncoding.base64;
|
||||
import static com.google.common.truth.Truth.assertThat;
|
||||
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
|
||||
import static google.registry.testing.DatabaseHelper.deleteResource;
|
||||
import static google.registry.testing.DatabaseHelper.loadRegistrar;
|
||||
import static google.registry.testing.DatabaseHelper.persistResource;
|
||||
@@ -38,6 +40,8 @@ import google.registry.model.eppoutput.EppOutput;
|
||||
import google.registry.model.registrar.Registrar;
|
||||
import google.registry.model.registrar.Registrar.State;
|
||||
import google.registry.testing.DatabaseHelper;
|
||||
import google.registry.util.PasswordUtils;
|
||||
import google.registry.util.PasswordUtils.HashAlgorithm;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -186,6 +190,32 @@ public abstract class LoginFlowTestCase extends FlowTestCase<LoginFlow> {
|
||||
doFailingTest("login_valid.xml", RegistrarAccountNotActiveException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuccess_scryptPasswordToArgon2() throws Exception {
|
||||
String password = "foo-BAR2";
|
||||
tm().transact(
|
||||
() -> {
|
||||
// The salt is not exposed by Registrar (nor should it be), so we query it directly.
|
||||
String encodedSalt =
|
||||
tm().query("SELECT salt FROM Registrar WHERE registrarId = :id", String.class)
|
||||
.setParameter("id", registrar.getRegistrarId())
|
||||
.getSingleResult();
|
||||
byte[] salt = base64().decode(encodedSalt);
|
||||
String newHash = PasswordUtils.hashPassword(password, salt, HashAlgorithm.SCRYPT_P_1);
|
||||
// Set password directly, as the Java method would have used Argon2.
|
||||
tm().query("UPDATE Registrar SET passwordHash = :hash WHERE registrarId = :id")
|
||||
.setParameter("id", registrar.getRegistrarId())
|
||||
.setParameter("hash", newHash)
|
||||
.executeUpdate();
|
||||
});
|
||||
assertThat(loadRegistrar("NewRegistrar").getCurrentHashAlgorithm(password).get())
|
||||
.isEqualTo(HashAlgorithm.SCRYPT_P_1);
|
||||
doSuccessfulTest("login_valid.xml");
|
||||
// Verifies that after successfully login, the password is re-hased with Scrypt.
|
||||
assertThat(loadRegistrar("NewRegistrar").getCurrentHashAlgorithm(password).get())
|
||||
.isEqualTo(HashAlgorithm.ARGON_2_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFailure_incorrectPassword() {
|
||||
persistResource(getRegistrarBuilder().setPassword("diff password").build());
|
||||
|
||||
+49
-10
@@ -14,7 +14,9 @@
|
||||
|
||||
package google.registry.ui.server.console;
|
||||
|
||||
import static com.google.common.io.BaseEncoding.base64;
|
||||
import static com.google.common.truth.Truth.assertThat;
|
||||
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
|
||||
import static google.registry.testing.DatabaseHelper.createTld;
|
||||
import static google.registry.testing.DatabaseHelper.loadByEntity;
|
||||
import static google.registry.testing.DatabaseHelper.loadRegistrar;
|
||||
@@ -49,6 +51,8 @@ import google.registry.testing.DeterministicStringGenerator;
|
||||
import google.registry.testing.FakeResponse;
|
||||
import google.registry.tools.DomainLockUtils;
|
||||
import google.registry.util.EmailMessage;
|
||||
import google.registry.util.PasswordUtils;
|
||||
import google.registry.util.PasswordUtils.HashAlgorithm;
|
||||
import google.registry.util.StringGenerator;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
import java.io.IOException;
|
||||
@@ -88,16 +92,17 @@ public class ConsoleRegistryLockActionTest extends ConsoleActionBaseTestCase {
|
||||
createTld("test");
|
||||
defaultDomain = persistActiveDomain("example.test");
|
||||
user =
|
||||
new User.Builder()
|
||||
.setEmailAddress("user@theregistrar.com")
|
||||
.setRegistryLockEmailAddress("registrylock@theregistrar.com")
|
||||
.setUserRoles(
|
||||
new UserRoles.Builder()
|
||||
.setRegistrarRoles(
|
||||
ImmutableMap.of("TheRegistrar", RegistrarRole.PRIMARY_CONTACT))
|
||||
.build())
|
||||
.setRegistryLockPassword("registryLockPassword")
|
||||
.build();
|
||||
persistResource(
|
||||
new User.Builder()
|
||||
.setEmailAddress("user@theregistrar.com")
|
||||
.setRegistryLockEmailAddress("registrylock@theregistrar.com")
|
||||
.setUserRoles(
|
||||
new UserRoles.Builder()
|
||||
.setRegistrarRoles(
|
||||
ImmutableMap.of("TheRegistrar", RegistrarRole.PRIMARY_CONTACT))
|
||||
.build())
|
||||
.setRegistryLockPassword("registryLockPassword")
|
||||
.build());
|
||||
action = createGetAction();
|
||||
}
|
||||
|
||||
@@ -277,6 +282,40 @@ public class ConsoleRegistryLockActionTest extends ConsoleActionBaseTestCase {
|
||||
assertThat(loadByEntity(defaultDomain).getStatusValues()).containsExactly(StatusValue.INACTIVE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPost_lock_scryptPasswordToArgon2() throws Exception {
|
||||
tm().transact(
|
||||
() -> {
|
||||
// The salt is not exposed by User (nor should it be), so we query it directly.
|
||||
String encodedSalt =
|
||||
tm().query(
|
||||
"SELECT registryLockPasswordSalt FROM User WHERE emailAddress ="
|
||||
+ " :emailAddress",
|
||||
String.class)
|
||||
.setParameter("emailAddress", user.getEmailAddress())
|
||||
.getSingleResult();
|
||||
byte[] salt = base64().decode(encodedSalt);
|
||||
String newHash =
|
||||
PasswordUtils.hashPassword(
|
||||
"registryLockPassword", salt, HashAlgorithm.SCRYPT_P_1);
|
||||
// Set password directly, as the Java method would have used Argon2.
|
||||
tm().query("UPDATE User SET registryLockPasswordHash = :hash")
|
||||
.setParameter("hash", newHash)
|
||||
.executeUpdate();
|
||||
});
|
||||
user = loadByEntity(user);
|
||||
assertThat(user.getCurrentHashAlgorithm("registryLockPassword").get())
|
||||
.isEqualTo(HashAlgorithm.SCRYPT_P_1);
|
||||
action = createDefaultPostAction(true);
|
||||
action.run();
|
||||
verifyEmail();
|
||||
assertThat(response.getStatus()).isEqualTo(SC_OK);
|
||||
assertThat(getMostRecentRegistryLockByRepoId(defaultDomain.getRepoId())).isPresent();
|
||||
user = loadByEntity(user);
|
||||
assertThat(user.getCurrentHashAlgorithm("registryLockPassword").get())
|
||||
.isEqualTo(HashAlgorithm.ARGON_2_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPost_unlock() throws Exception {
|
||||
saveRegistryLock(createDefaultLockBuilder().setLockCompletionTime(clock.nowUtc()).build());
|
||||
|
||||
@@ -15,28 +15,40 @@
|
||||
package google.registry.util;
|
||||
|
||||
import static com.google.common.io.BaseEncoding.base64;
|
||||
import static google.registry.util.PasswordUtils.HashAlgorithm.ARGON_2_ID;
|
||||
import static google.registry.util.PasswordUtils.HashAlgorithm.SCRYPT_P_1;
|
||||
import static java.nio.charset.StandardCharsets.US_ASCII;
|
||||
|
||||
import com.google.common.base.Supplier;
|
||||
import com.google.common.flogger.FluentLogger;
|
||||
import com.google.common.primitives.Bytes;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import java.util.Optional;
|
||||
import org.bouncycastle.crypto.generators.Argon2BytesGenerator;
|
||||
import org.bouncycastle.crypto.generators.SCrypt;
|
||||
import org.bouncycastle.crypto.params.Argon2Parameters;
|
||||
|
||||
/**
|
||||
* Common utility class to handle password hashing and salting /*
|
||||
* Common utility class to handle password hashing and salting
|
||||
*
|
||||
* <p>We use a memory-hard hashing algorithm (Scrypt) to prevent brute-force attacks on passwords.
|
||||
* <p>We use a memory-hard hashing algorithm (Argon2id) to prevent brute-force attacks on passwords.
|
||||
*
|
||||
* <p>Note that in tests, we simply concatenate the password and salt which is much faster and
|
||||
* reduces the overall test run time by a half. Our tests are not verifying that SCRYPT is
|
||||
* reduces the overall test run time by a half. Our tests are not verifying that Argon2 is
|
||||
* implemented correctly anyway.
|
||||
*
|
||||
* @see <a href="https://en.wikipedia.org/wiki/Scrypt">Scrypt</a>
|
||||
* @see <a href="https://en.wikipedia.org/wiki/Argon2">Argon2</a>
|
||||
*/
|
||||
public final class PasswordUtils {
|
||||
|
||||
private PasswordUtils() {}
|
||||
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
|
||||
// Argon2 requires an "init" call on the generator each time the salt is changed. As a result,
|
||||
// each thread needs its own generator, then we can init it with the new salt every time a new
|
||||
// hash is required.
|
||||
private static final ThreadLocal<Argon2BytesGenerator> argon2BytesGenerator =
|
||||
ThreadLocal.withInitial(Argon2BytesGenerator::new);
|
||||
|
||||
public static final Supplier<byte[]> SALT_SUPPLIER =
|
||||
() -> {
|
||||
@@ -46,25 +58,101 @@ public final class PasswordUtils {
|
||||
return salt;
|
||||
};
|
||||
|
||||
private static byte[] hashPassword(byte[] password, byte[] salt) {
|
||||
return RegistryEnvironment.get() == RegistryEnvironment.UNITTEST
|
||||
? Bytes.concat(password, salt)
|
||||
: SCrypt.generate(password, salt, 32768, 8, 1, 256);
|
||||
public enum HashAlgorithm {
|
||||
/**
|
||||
* SCrypt algorithm using a parallelization factor of 1.
|
||||
*
|
||||
* <p>Note that in tests, we simply concatenate the password and salt which is much faster and
|
||||
* reduces the overall test run time by a half. Our tests are not verifying that Scrypt is
|
||||
* implemented correctly anyway.
|
||||
*
|
||||
* <p>TODO(b/458423787): Remove this circa March 2026 after enough time has passed for the
|
||||
* logins to have transitioned to Argon2 hashing.
|
||||
*
|
||||
* @see <a href="https://en.wikipedia.org/wiki/Scrypt">Scrypt</a>
|
||||
*/
|
||||
@Deprecated
|
||||
SCRYPT_P_1 {
|
||||
@Override
|
||||
byte[] hash(byte[] password, byte[] salt) {
|
||||
return RegistryEnvironment.get() == RegistryEnvironment.UNITTEST
|
||||
? Bytes.concat(password, salt)
|
||||
: SCrypt.generate(password, salt, 32768, 8, 1, 256);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Argon2id algorithm using 3 iterations and 12 MiB.
|
||||
*
|
||||
* <p>Note that in tests, we simply concatenate the salt and password (the inverse order from
|
||||
* Scrypt) which is much faster and reduces the overall test run time by a half. Our tests are
|
||||
* not verifying that Argon2 is implemented correctly anyway.
|
||||
*
|
||||
* @see <a href="https://en.wikipedia.org/wiki/Argon2">Argon2</a>
|
||||
* @see <a
|
||||
* href="https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id">OWASP
|
||||
* cheat sheet</a>
|
||||
*/
|
||||
ARGON_2_ID {
|
||||
@Override
|
||||
byte[] hash(byte[] password, byte[] salt) {
|
||||
if (RegistryEnvironment.get() == RegistryEnvironment.UNITTEST) {
|
||||
return Bytes.concat(salt, password);
|
||||
}
|
||||
Argon2BytesGenerator generator = argon2BytesGenerator.get();
|
||||
// For Argon2, the salt is part of the parameters so we must reinitialize each time
|
||||
generator.init(
|
||||
new Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
|
||||
.withVersion(Argon2Parameters.ARGON2_VERSION_13)
|
||||
.withIterations(3)
|
||||
.withMemoryAsKB(12288)
|
||||
.withParallelism(1)
|
||||
.withSalt(salt)
|
||||
.build());
|
||||
byte[] result = new byte[256];
|
||||
generator.generateBytes(password, result);
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
abstract byte[] hash(byte[] password, byte[] salt);
|
||||
}
|
||||
|
||||
/** Returns the hash of the password using the provided salt. */
|
||||
public static String hashPassword(String password, byte[] salt) {
|
||||
return base64().encode(hashPassword(password.getBytes(US_ASCII), salt));
|
||||
return hashPassword(password, salt, ARGON_2_ID);
|
||||
}
|
||||
|
||||
/** Returns the hash of the password using the provided salt and {@link HashAlgorithm}. */
|
||||
public static String hashPassword(String password, byte[] salt, HashAlgorithm algorithm) {
|
||||
return base64().encode(algorithm.hash(password.getBytes(US_ASCII), salt));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a password by regenerating the hash with the provided salt and comparing it to the
|
||||
* provided hash.
|
||||
*
|
||||
* <p>This method will first try to use {@link HashAlgorithm#ARGON_2_ID} to verify the password,
|
||||
* and falls back to {@link HashAlgorithm#SCRYPT_P_1} if the former fails.
|
||||
*
|
||||
* @return the {@link HashAlgorithm} used to successfully verify the password, or {@link
|
||||
* Optional#empty()} if neither works.
|
||||
*/
|
||||
public static boolean verifyPassword(String password, String hash, String salt) {
|
||||
public static Optional<HashAlgorithm> verifyPassword(String password, String hash, String salt) {
|
||||
byte[] decodedHash = base64().decode(hash);
|
||||
byte[] decodedSalt = base64().decode(salt);
|
||||
byte[] calculatedHash = hashPassword(password.getBytes(US_ASCII), decodedSalt);
|
||||
return Arrays.equals(decodedHash, calculatedHash);
|
||||
byte[] decodedPassword = password.getBytes(US_ASCII);
|
||||
|
||||
if (Arrays.equals(decodedHash, ARGON_2_ID.hash(decodedPassword, decodedSalt))) {
|
||||
logger.atInfo().log("ARGON_2_ID hash verified.");
|
||||
return Optional.of(ARGON_2_ID);
|
||||
}
|
||||
if (Arrays.equals(decodedHash, SCRYPT_P_1.hash(decodedPassword, decodedSalt))) {
|
||||
logger.atInfo().log("SCRYPT_P_1 hash verified.");
|
||||
return Optional.of(SCRYPT_P_1);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private PasswordUtils() {}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ package google.registry.util;
|
||||
|
||||
import static com.google.common.io.BaseEncoding.base64;
|
||||
import static com.google.common.truth.Truth.assertThat;
|
||||
import static google.registry.util.PasswordUtils.HashAlgorithm.ARGON_2_ID;
|
||||
import static google.registry.util.PasswordUtils.HashAlgorithm.SCRYPT_P_1;
|
||||
import static google.registry.util.PasswordUtils.SALT_SUPPLIER;
|
||||
import static google.registry.util.PasswordUtils.hashPassword;
|
||||
import static google.registry.util.PasswordUtils.verifyPassword;
|
||||
@@ -47,12 +49,22 @@ final class PasswordUtilsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void testVerify_scrypt_default() {
|
||||
void testVerify_argon2_default() {
|
||||
byte[] salt = SALT_SUPPLIER.get();
|
||||
String password = "mySuperSecurePassword";
|
||||
String hashedPassword = hashPassword(password, salt);
|
||||
assertThat(hashedPassword).isEqualTo(hashPassword(password, salt));
|
||||
assertThat(verifyPassword(password, hashedPassword, base64().encode(salt))).isTrue();
|
||||
assertThat(hashedPassword).isEqualTo(hashPassword(password, salt, ARGON_2_ID));
|
||||
assertThat(verifyPassword(password, hashedPassword, base64().encode(salt)))
|
||||
.hasValue(ARGON_2_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testVerify_scrypt() {
|
||||
byte[] salt = SALT_SUPPLIER.get();
|
||||
String password = "mySuperSecurePassword";
|
||||
String hashedPassword = hashPassword(password, salt, SCRYPT_P_1);
|
||||
assertThat(verifyPassword(password, hashedPassword, base64().encode(salt)))
|
||||
.hasValue(SCRYPT_P_1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -60,6 +72,6 @@ final class PasswordUtilsTest {
|
||||
byte[] salt = SALT_SUPPLIER.get();
|
||||
String password = "mySuperSecurePassword";
|
||||
String hashedPassword = hashPassword(password, salt);
|
||||
assertThat(verifyPassword(password + "a", hashedPassword, base64().encode(salt))).isFalse();
|
||||
assertThat(verifyPassword(password + "a", hashedPassword, base64().encode(salt))).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user