Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ public class DomainFlowUtils {
+ "name collision.";

/** Strict validator for ascii lowercase letters, digits, and "-", allowing "." as a separator */
private static final CharMatcher ALLOWED_CHARS =
public static final CharMatcher ALLOWED_CHARS =
CharMatcher.inRange('a', 'z').or(CharMatcher.inRange('0', '9').or(CharMatcher.anyOf("-.")));

/** Default validator used to determine if an IDN name can be provisioned on a TLD. */
Expand Down
82 changes: 82 additions & 0 deletions core/src/main/java/google/registry/model/registrar/Registrar.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@
import static com.google.common.collect.Streams.stream;
import static com.google.common.io.BaseEncoding.base64;
import static google.registry.config.RegistryConfig.getDefaultRegistrarWhoisServer;
import static google.registry.flows.domain.DomainFlowUtils.ALLOWED_CHARS;
import static google.registry.model.CacheUtils.memoizeWithShortExpiration;
import static google.registry.model.tld.Tlds.assertTldsExist;
import static google.registry.persistence.transaction.TransactionManagerFactory.replicaTm;
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
import static google.registry.util.CollectionUtils.nullToEmptyImmutableCopy;
import static google.registry.util.CollectionUtils.nullToEmptyImmutableSortedCopy;
import static google.registry.util.DateTimeUtils.START_INSTANT;
import static google.registry.util.DomainNameUtils.canonicalizeHostname;
import static google.registry.util.PasswordUtils.SALT_SUPPLIER;
import static google.registry.util.PasswordUtils.hashPassword;
import static google.registry.util.PreconditionsUtils.checkArgumentNotNull;
Expand All @@ -50,6 +52,7 @@
import com.google.common.collect.Maps;
import com.google.common.collect.Range;
import com.google.common.collect.Sets;
import com.google.common.net.InternetDomainName;
import com.google.gson.annotations.Expose;
import com.google.re2j.Pattern;
import google.registry.model.Buildable;
Expand Down Expand Up @@ -185,6 +188,12 @@ public enum State {
/** Regex for E.164 phone number format. */
private static final Pattern E164_PATTERN = Pattern.compile("\\+[0-9]{1,3}\\.[0-9]{1,14}");

/** Maximum number of hostnames in the EPP client certificate allowlist. */
public static final int MAX_EPP_CLIENT_CERTIFICATE_HOSTNAMES = 20;

/** Maximum number of revoked client certificate hashes. */
public static final int MAX_REVOKED_CLIENT_CERTIFICATE_HASHES = 20;

/** Regex for telephone support passcode (5 digit string). */
public static final Pattern PHONE_PASSCODE_PATTERN = Pattern.compile("\\d{5}");

Expand Down Expand Up @@ -295,6 +304,23 @@ public enum State {
/** Base64 encoded SHA256 hash of {@link #failoverClientCertificate}. */
String failoverClientCertificateHash;

/**
* Hostnames for which a client certificate issued by a publicly trusted CA is accepted at EPP
* login, as an alternative to the pinned {@link #clientCertificate}.
*
* <p>Entries are stored in canonical A-label (punycode) lowercase form without a trailing dot.
* They are only consulted while the {@code ALLOW_EPP_CLIENT_CERT_HOSTNAME_AUTH} feature flag is
* active.
*/
@Expose Set<String> allowedEppClientCertificateHostnames;

/**
* Base64 encoded SHA256 hashes of client certificates the registrar has revoked, in the same form
* as {@link #clientCertificateHash}. A certificate with one of these hashes is rejected at EPP
* login regardless of which mechanism would otherwise have accepted it.
*/
@Expose Set<String> revokedClientCertificateHashes;

/** An allow list of netmasks (in CIDR notation) which the client is allowed to connect from. */
@org.hibernate.annotations.Type(CidrBlockListUserType.class)
@Column(columnDefinition = "text[]")
Expand Down Expand Up @@ -540,6 +566,14 @@ public ImmutableList<CidrAddressBlock> getIpAddressAllowList() {
return nullToEmptyImmutableCopy(ipAddressAllowList);
}

public ImmutableSet<String> getAllowedEppClientCertificateHostnames() {
return nullToEmptyImmutableCopy(allowedEppClientCertificateHostnames);
}

public ImmutableSet<String> getRevokedClientCertificateHashes() {
return nullToEmptyImmutableCopy(revokedClientCertificateHashes);
}

public RegistrarAddress getLocalizedAddress() {
return localizedAddress;
}
Expand Down Expand Up @@ -662,6 +696,9 @@ public Map<String, Object> toJsonMap() {
.put("clientCertificateHash", clientCertificateHash)
.put("failoverClientCertificate", failoverClientCertificate)
.put("failoverClientCertificateHash", failoverClientCertificateHash)
.putListOfStrings(
"allowedEppClientCertificateHostnames", allowedEppClientCertificateHostnames)
.putListOfStrings("revokedClientCertificateHashes", revokedClientCertificateHashes)
.put("localizedAddress", localizedAddress)
.put("internationalizedAddress", internationalizedAddress)
.put("phoneNumber", phoneNumber)
Expand Down Expand Up @@ -853,6 +890,51 @@ public Builder setLastPocVerificationDate(Instant now) {
return this;
}

/**
* Sets the hostnames for which CA-issued client certificates are accepted at EPP login.
*
* <p>Each entry is canonicalized to lowercase A-label (punycode) form and validated as a
* multi-label DNS name; duplicates collapse to one entry.
*/
public Builder setAllowedEppClientCertificateHostnames(Iterable<String> hostnames) {
checkArgumentNotNull(hostnames, "EPP client certificate hostnames cannot be null");
ImmutableSet<String> validated =
stream(hostnames)
.map(
hostname -> {
InternetDomainName domainName =
InternetDomainName.from(canonicalizeHostname(hostname));
// InternetDomainName allows underscores and single-label names; require
// strict ASCII LDH characters and at least two labels.
checkArgument(
ALLOWED_CHARS.matchesAllOf(domainName.toString()) && domainName.hasParent(),
"Not a valid fully qualified hostname: %s",
hostname);
return domainName.toString();
})
.collect(toImmutableSet());
checkArgument(
validated.size() <= MAX_EPP_CLIENT_CERTIFICATE_HOSTNAMES,
"At most %s EPP client certificate hostnames may be configured, got %s",
MAX_EPP_CLIENT_CERTIFICATE_HOSTNAMES,
validated.size());
getInstance().allowedEppClientCertificateHostnames = validated;
return this;
}

/** Sets the hashes of client certificates that are rejected at EPP login. */
public Builder setRevokedClientCertificateHashes(Iterable<String> hashes) {
checkArgumentNotNull(hashes, "Revoked client certificate hashes cannot be null");
ImmutableSet<String> validated = ImmutableSet.copyOf(hashes);
checkArgument(
validated.size() <= MAX_REVOKED_CLIENT_CERTIFICATE_HASHES,
"At most %s revoked client certificates may be configured, got %s",
MAX_REVOKED_CLIENT_CERTIFICATE_HASHES,
validated.size());
getInstance().revokedClientCertificateHashes = validated;
return this;
}

private static String calculateHash(String clientCertificate) {
if (clientCertificate == null) {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,20 @@ private void setResponse(Registrar savedRegistrar) {
}

try {
if (!savedRegistrar
.getAllowedEppClientCertificateHostnames()
.equals(registrarParameter.getAllowedEppClientCertificateHostnames())) {
updatedRegistrarBuilder.setAllowedEppClientCertificateHostnames(
registrarParameter.getAllowedEppClientCertificateHostnames());
updates.add("EPP_CERT_HOSTNAMES_CHANGE");
}
if (!savedRegistrar
.getRevokedClientCertificateHashes()
.equals(registrarParameter.getRevokedClientCertificateHashes())) {
updatedRegistrarBuilder.setRevokedClientCertificateHashes(
registrarParameter.getRevokedClientCertificateHashes());
updates.add("REVOKED_CERTS_CHANGE");
}
if (!savedRegistrar
.getClientCertificate()
.equals(registrarParameter.getClientCertificate())) {
Expand All @@ -116,7 +130,7 @@ private void setResponse(Registrar savedRegistrar) {
updates.add("FAILOVER_SSL_CERT_CHANGE");
}
}
} catch (InsecureCertificateException e) {
} catch (InsecureCertificateException | IllegalArgumentException e) {
setFailedResponse(e.getMessage(), SC_BAD_REQUEST);
return;
}
Expand Down
186 changes: 186 additions & 0 deletions core/src/test/java/google/registry/model/registrar/RegistrarTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -802,4 +802,190 @@ void testSuccess_toJsonMap_includesExpiryAccessPeriodEnabled() {
Registrar modified = registrar.asBuilder().setExpiryAccessPeriodEnabled(true).build();
assertThat(modified.toJsonMap()).containsEntry("expiryAccessPeriodEnabled", true);
}

@Test
void testSuccess_eppClientCertificateHostnames_defaultEmpty() {
assertThat(registrar.getAllowedEppClientCertificateHostnames()).isEmpty();
assertThat(registrar.toJsonMap())
.containsEntry("allowedEppClientCertificateHostnames", ImmutableList.of());
}

@Test
void testSuccess_eppClientCertificateHostnames_persistence() {
persistResource(
registrar
.asBuilder()
.setAllowedEppClientCertificateHostnames(
ImmutableList.of("epp.registrar.example", "epp2.registrar.example"))
.build());
Registrar loaded = Registrar.loadByRegistrarId("registrar").get();
assertThat(loaded.getAllowedEppClientCertificateHostnames())
.containsExactly("epp.registrar.example", "epp2.registrar.example");
assertThat((Iterable<?>) loaded.toJsonMap().get("allowedEppClientCertificateHostnames"))
.containsExactly("epp.registrar.example", "epp2.registrar.example");
}

@Test
void testSuccess_eppClientCertificateHostnames_canonicalizedAndDeduplicated() {
Registrar modified =
registrar
.asBuilder()
.setAllowedEppClientCertificateHostnames(
ImmutableList.of(
"EPP.Example.COM",
"epp.example.com",
"Epp.Example.Com",
"b.example",
"epp.みんな"))
.build();
assertThat(modified.getAllowedEppClientCertificateHostnames())
.containsExactly("epp.example.com", "b.example", "epp.xn--q9jyb4c");
}

@Test
void testSuccess_eppClientCertificateHostnames_clearing() {
registrar =
registrar
.asBuilder()
.setAllowedEppClientCertificateHostnames(ImmutableList.of("epp.registrar.example"))
.build();
registrar =
registrar.asBuilder().setAllowedEppClientCertificateHostnames(ImmutableList.of()).build();
assertThat(registrar.getAllowedEppClientCertificateHostnames()).isEmpty();
assertThat(registrar.allowedEppClientCertificateHostnames).isNull();
}

@Test
void testSuccess_eppClientCertificateHostnames_acceptsIdnALabelAndLongNames() {
String longLabel = "a".repeat(63);
Registrar modified =
registrar
.asBuilder()
.setAllowedEppClientCertificateHostnames(
ImmutableList.of(
"xn--q9jyb4c.registrar.example",
"epp.xn--q9jyb4c",
longLabel + ".example",
"a-b.c-d.example"))
.build();
assertThat(modified.getAllowedEppClientCertificateHostnames()).hasSize(4);
}

@Test
void testFailure_eppClientCertificateHostnames_invalidNames() {
ImmutableList<String> invalid =
ImmutableList.of(
"",
"epp",
"epp.",
"*.registrar.example",
"https://epp.registrar.example",
"epp.registrar.example:700",
"epp.registrar.example/path",
"192.0.2.1",
"2001:db8::1",
"epp_1.registrar.example",
"-epp.registrar.example",
"epp-.registrar.example",
"epp.registrar.ex ample",
"a".repeat(64) + ".example",
("a".repeat(63) + ".").repeat(4) + "example");
for (String hostname : invalid) {
assertThrows(
IllegalArgumentException.class,
() ->
registrar
.asBuilder()
.setAllowedEppClientCertificateHostnames(ImmutableList.of(hostname)));
}
}

@Test
void testFailure_eppClientCertificateHostnames_tooMany() {
ImmutableList.Builder<String> hostnames = new ImmutableList.Builder<>();
for (int i = 0; i <= Registrar.MAX_EPP_CLIENT_CERTIFICATE_HOSTNAMES; i++) {
hostnames.add(String.format("epp%d.registrar.example", i));
}
IllegalArgumentException thrown =
assertThrows(
IllegalArgumentException.class,
() -> registrar.asBuilder().setAllowedEppClientCertificateHostnames(hostnames.build()));
assertThat(thrown)
.hasMessageThat()
.contains("At most 20 EPP client certificate hostnames may be configured, got 21");
}

@Test
void testFailure_eppClientCertificateHostnames_null() {
assertThrows(
IllegalArgumentException.class,
() -> registrar.asBuilder().setAllowedEppClientCertificateHostnames(null));
}

@Test
void testSuccess_revokedClientCertificateHashes_defaultEmpty() {
assertThat(registrar.getRevokedClientCertificateHashes()).isEmpty();
assertThat(registrar.toJsonMap())
.containsEntry("revokedClientCertificateHashes", ImmutableList.of());
}

@Test
void testSuccess_revokedClientCertificateHashes_persistence() {
persistResource(
registrar
.asBuilder()
.setRevokedClientCertificateHashes(
ImmutableList.of(SAMPLE_CERT_HASH, SAMPLE_CERT2_HASH))
.build());
Registrar loaded = Registrar.loadByRegistrarId("registrar").get();
assertThat(loaded.getRevokedClientCertificateHashes())
.containsExactly(SAMPLE_CERT_HASH, SAMPLE_CERT2_HASH);
assertThat((Iterable<?>) loaded.toJsonMap().get("revokedClientCertificateHashes"))
.containsExactly(SAMPLE_CERT_HASH, SAMPLE_CERT2_HASH);
}

@Test
void testSuccess_revokedClientCertificateHashes_deduplicated() {
Registrar modified =
registrar
.asBuilder()
.setRevokedClientCertificateHashes(ImmutableList.of(SAMPLE_CERT_HASH, SAMPLE_CERT_HASH))
.build();
assertThat(modified.getRevokedClientCertificateHashes()).containsExactly(SAMPLE_CERT_HASH);
}

@Test
void testSuccess_revokedClientCertificateHashes_clearing() {
registrar =
registrar
.asBuilder()
.setRevokedClientCertificateHashes(ImmutableList.of(SAMPLE_CERT_HASH))
.build();
registrar = registrar.asBuilder().setRevokedClientCertificateHashes(ImmutableList.of()).build();
assertThat(registrar.getRevokedClientCertificateHashes()).isEmpty();
assertThat(registrar.revokedClientCertificateHashes).isNull();
}

@Test
void testFailure_revokedClientCertificateHashes_tooMany() {
ImmutableList.Builder<String> hashes = new ImmutableList.Builder<>();
for (int i = 0; i <= Registrar.MAX_REVOKED_CLIENT_CERTIFICATE_HASHES; i++) {
// Vary the first character to produce distinct, well-formed hashes.
hashes.add((char) ('A' + i) + SAMPLE_CERT_HASH.substring(1));
}
IllegalArgumentException thrown =
assertThrows(
IllegalArgumentException.class,
() -> registrar.asBuilder().setRevokedClientCertificateHashes(hashes.build()));
assertThat(thrown)
.hasMessageThat()
.contains("At most 20 revoked client certificates may be configured, got 21");
}

@Test
void testFailure_revokedClientCertificateHashes_null() {
assertThrows(
IllegalArgumentException.class,
() -> registrar.asBuilder().setRevokedClientCertificateHashes(null));
}
}
Loading
Loading