1
0
mirror of https://github.com/google/nomulus synced 2026-01-03 11:45:39 +00:00

Add and default to Argon2 hashing (#2877)

We've previously been using Scrypt since PR #2191 which, while being a
memory-hard slow function, isn't the optimal solution according to the
OWASP recommendations. While we could get away with increasing the
parallelization parameter to 3, it's better to just switch to the
most-recommended solution if we're switching things up anyway.

For the transition, we do something similar to PR #2191 where if the
previous-algorithm's hash is successful, we re-hash with Argo2id and
store that version. By doing this, we should not need any intervention
for registrars who log in at any point during the transition period.

Much of this PR, especially the parts where we re-hash the passwords in
Argon2 instead of Scrypt upon login, is based on the code that was
eventually removed in #2310.
This commit is contained in:
gbrodman
2025-11-17 15:11:22 -05:00
committed by GitHub
parent bc9aab6790
commit b1266c95e8
8 changed files with 241 additions and 32 deletions

View File

@@ -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");

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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 =

View File

@@ -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());

View File

@@ -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());