[java-oidc-common] branch main updated: Port up changes to "iat" validator and new ID validator.
Scott Cantor
cantor.2 at osu.edu
Thu Dec 30 17:09:55 UTC 2021
This is an automated email from the git hooks/post-receive script.
scantor pushed a commit to branch main
in repository java-oidc-common.
View the commit online:
http://git.shibboleth.net/view/?p=java-oidc-common.git;a=commit;h=c7078ed2e9d9d32760a3fda4a89f8d08eacb86a2
The following commit(s) were added to refs/heads/main by this push:
new c7078ed Port up changes to "iat" validator and new ID validator.
c7078ed is described below
commit c7078ed2e9d9d32760a3fda4a89f8d08eacb86a2
Author: Scott Cantor <cantor.2 at osu.edu>
AuthorDate: Thu Dec 30 12:09:52 2021 -0500
Port up changes to "iat" validator and new ID validator.
---
oidc-common-crypto-impl/pom.xml | 19 +++-
.../jwt/claims/impl/IssuedAtClaimsValidator.java | 85 +++++++++++-----
.../claims/impl/JWTIdentifierClaimsValidator.java | 108 +++++++++++++++++++++
.../claims/impl/IssuedAtClaimsValidatorTest.java | 88 +++++++++++++----
.../impl/JWTIdentifierClaimsValidatorTest.java | 101 +++++++++++++++++++
5 files changed, 355 insertions(+), 46 deletions(-)
diff --git a/oidc-common-crypto-impl/pom.xml b/oidc-common-crypto-impl/pom.xml
index 9ebbc04..7e844ae 100644
--- a/oidc-common-crypto-impl/pom.xml
+++ b/oidc-common-crypto-impl/pom.xml
@@ -19,6 +19,12 @@
</properties>
<dependencies>
+ <dependency>
+ <groupId>com.nimbusds</groupId>
+ <artifactId>nimbus-jose-jwt</artifactId>
+ <scope>compile</scope>
+ </dependency>
+
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>oidc-common-crypto-api</artifactId>
@@ -34,16 +40,13 @@
<artifactId>idp-authn-api</artifactId>
<scope>provided</scope>
</dependency>
- <dependency>
- <groupId>com.nimbusds</groupId>
- <artifactId>nimbus-jose-jwt</artifactId>
- <scope>compile</scope>
- </dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<scope>provided</scope>
</dependency>
+
+ <!-- Test scope -->
<dependency>
<groupId>${spring.groupId}</groupId>
<artifactId>spring-test</artifactId>
@@ -55,6 +58,12 @@
<version>${opensaml.version}</version>
<type>test-jar</type>
<scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>${opensaml.groupId}</groupId>
+ <artifactId>opensaml-storage-impl</artifactId>
+ <version>${opensaml.version}</version>
+ <scope>test</scope>
</dependency>
<dependency>
<groupId>${idp.groupId}</groupId>
diff --git a/oidc-common-crypto-impl/src/main/java/net/shibboleth/oidc/security/jwt/claims/impl/IssuedAtClaimsValidator.java b/oidc-common-crypto-impl/src/main/java/net/shibboleth/oidc/security/jwt/claims/impl/IssuedAtClaimsValidator.java
index 540db8f..fc287fe 100644
--- a/oidc-common-crypto-impl/src/main/java/net/shibboleth/oidc/security/jwt/claims/impl/IssuedAtClaimsValidator.java
+++ b/oidc-common-crypto-impl/src/main/java/net/shibboleth/oidc/security/jwt/claims/impl/IssuedAtClaimsValidator.java
@@ -34,35 +34,62 @@ import net.shibboleth.utilities.java.support.component.ComponentSupport;
import net.shibboleth.utilities.java.support.logic.Constraint;
/**
- * Iff the 'iat' claim is present in the ID Token, verifies it is not to far away from
- * the current time. A configured window/deviation is allowed. See section 3.1.3.7 of OpenID Connect core 1.0.
+ * If the 'iat' claim is present in the JWT, verifies it is not to far away from the current time.
+ * The message lifetime, clock skew and the claim existence requirement can be configured.
*/
@ThreadSafeAfterInit
public class IssuedAtClaimsValidator extends AbstractClaimsValidator {
- /**
- * Maximum amount (in either direction from now) of duration for which a token is valid after
- * it is issued (Default value: 60 seconds).
- */
- @Nonnull private Duration iatWindow;
-
+ /** Clock skew adjustment in both directions to consider still acceptable (Default value: 1 minute). */
+ @Nonnull private Duration clockSkew;
+
+ /** Amount of time for which a message is valid after it is issued (Default value: 1 minute). */
+ @Nonnull private Duration messageLifetime;
+
+ /** Whether this rule is required to be met. */
+ private boolean requiredRule;
+
/** Constructor.*/
public IssuedAtClaimsValidator() {
- iatWindow = Duration.ofSeconds(60);
+ messageLifetime = Duration.ofMinutes(1);
+ clockSkew = Duration.ofMinutes(1);
+ requiredRule = true;
+ }
+
+ /**
+ * Sets the amount of time for which a message is valid.
+ *
+ * @param lifetime amount of time for which a message is valid
+ */
+ public void setMessageLifetime(@Nonnull final Duration lifetime) {
+ ComponentSupport.ifInitializedThrowUnmodifiabledComponentException(this);
+
+ Constraint.isNotNull(lifetime, "Token lifetime cannot be null");
+ Constraint.isFalse(lifetime.isNegative(), "Token lifetime cannot be negative");
+
+ messageLifetime = lifetime;
}
/**
- * Sets the amount of time for which a token is valid from when it was issued.
+ * Set the clock skew.
*
- * @param window amount of time for which a token is valid
+ * @param skew clock skew to set
*/
- public void setIatWindow(@Nonnull final Duration window) {
+ public void setClockSkew(@Nonnull final Duration skew) {
ComponentSupport.ifInitializedThrowUnmodifiabledComponentException(this);
- Constraint.isNotNull(window, "Token issued at window cannot be null");
- Constraint.isFalse(window.isNegative(), "Token issued at window cannot be negative");
+ clockSkew = Constraint.isNotNull(skew, "Clock skew cannot be null");
+ }
- iatWindow = window;
+ /**
+ * Sets whether this rule is required to be met.
+ *
+ * @param required whether this rule is required to be met
+ */
+ public void setRequiredRule(final boolean required) {
+ ComponentSupport.ifInitializedThrowUnmodifiabledComponentException(this);
+
+ requiredRule = required;
}
/** {@inheritDoc} */
@@ -73,17 +100,27 @@ public class IssuedAtClaimsValidator extends AbstractClaimsValidator {
final Date iatDate = claims.getIssueTime();
if (iatDate != null) {
final Instant iat = iatDate.toInstant();
- final Instant now = Instant.now();
- final Duration iatDifference = Duration.between(now, iat).abs();
-
- if (iatWindow.compareTo(iatDifference) < 0) {
- throw new JWTValidationException("JWT issued-at time is too far away from the current time. "
+ final Instant now = Instant.now();
+ final Instant latestValid = now.plus(clockSkew.abs());
+ final Instant expiration = iat.plus(clockSkew.abs()).plus(messageLifetime);
+
+ // Check message wasn't issued in the future
+ if (iat.isAfter(latestValid)) {
+ throw new JWTValidationException("JWT was rejected because it was issued in the future. "
+ + "Token issued at '"+iat+"' was too far away from the current time '"+now+"' "
+ + "with acceptable lifetime of '" + messageLifetime + "', clockSkew of '" + clockSkew + "'");
+ }
+
+ // Check message has not expired
+ if (expiration.isBefore(now)) {
+ throw new JWTValidationException("JWT was rejected due to issue instance expiration. "
+ "Token issued at '"+iat+"' was too far away from the current time '"+now+"' "
- + "with acceptable deviation of "
- + "'"+iatWindow+"', difference is '"+iatDifference+"'");
+ + "with acceptable lifetime of '" + messageLifetime + "', clockSkew of '" + clockSkew + "'");
+
}
+ } else if (requiredRule) {
+ throw new JWTValidationException("JWT was rejected due to missing required 'iat' claim.");
}
-
}
-}
+}
\ No newline at end of file
diff --git a/oidc-common-crypto-impl/src/main/java/net/shibboleth/oidc/security/jwt/claims/impl/JWTIdentifierClaimsValidator.java b/oidc-common-crypto-impl/src/main/java/net/shibboleth/oidc/security/jwt/claims/impl/JWTIdentifierClaimsValidator.java
new file mode 100644
index 0000000..ab45f20
--- /dev/null
+++ b/oidc-common-crypto-impl/src/main/java/net/shibboleth/oidc/security/jwt/claims/impl/JWTIdentifierClaimsValidator.java
@@ -0,0 +1,108 @@
+/*
+ * Licensed to the University Corporation for Advanced Internet Development,
+ * Inc. (UCAID) under one or more contributor license agreements. See the
+ * NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The UCAID licenses this file to You under the Apache
+ * License, Version 2.0 (the "License"); you may not use this file except in
+ * compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.shibboleth.oidc.security.jwt.claims.impl;
+
+import java.time.Duration;
+import java.util.Date;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.storage.ReplayCache;
+
+import com.nimbusds.jwt.JWTClaimsSet;
+
+import net.shibboleth.oidc.jwt.claims.AbstractClaimsValidator;
+import net.shibboleth.oidc.jwt.claims.JWTValidationException;
+import net.shibboleth.utilities.java.support.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.utilities.java.support.component.ComponentInitializationException;
+import net.shibboleth.utilities.java.support.component.ComponentSupport;
+import net.shibboleth.utilities.java.support.logic.Constraint;
+import net.shibboleth.utilities.java.support.primitive.StringSupport;
+
+/**
+ * Verifies the identifier (jit) from the JWT against repla via configurable {@link ReplayCache}. Single identifier is
+ * stored in the cache for the lifetime of the JWT: expiration instant is taken from the 'exp' -claim.
+ */
+public class JWTIdentifierClaimsValidator extends AbstractClaimsValidator {
+
+ /** Message replay cache instance to use. */
+ @NonnullAfterInit private ReplayCache replayCache;
+
+ /**
+ * Clock skew adjustment in both directions to consider still acceptable (Default value: 1 minute).
+ */
+ @Nonnull private Duration clockSkew;
+
+ /**
+ * Constructor.
+ */
+ public JWTIdentifierClaimsValidator() {
+ clockSkew = Duration.ofMinutes(1);
+ }
+
+ /**
+ * Set the replay cache instance to use.
+ *
+ * @param cache replay cache to set.
+ */
+ public void setReplayCache(@Nonnull final ReplayCache cache) {
+ ComponentSupport.ifInitializedThrowUnmodifiabledComponentException(this);
+ replayCache = Constraint.isNotNull(cache, "ReplayCache cannot be null");
+ }
+
+ /**
+ * Set the clock skew.
+ *
+ * @param skew clock skew to set.
+ */
+ public void setClockSkew(@Nonnull final Duration skew) {
+ ComponentSupport.ifInitializedThrowUnmodifiabledComponentException(this);
+ clockSkew = Constraint.isNotNull(skew, "Clock skew cannot be null");
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doInitialize() throws ComponentInitializationException {
+ super.doInitialize();
+
+ if (replayCache == null) {
+ throw new ComponentInitializationException("ReplayCache can not be null");
+ }
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doValidate(@Nonnull final JWTClaimsSet claims, @Nullable final ProfileRequestContext context)
+ throws JWTValidationException {
+
+ final Date exp = claims.getExpirationTime();
+ if (exp == null) {
+ throw new JWTValidationException("The claims set is missing required expiration time (exp)");
+ }
+ final String jit = claims.getJWTID();
+ if (StringSupport.trimOrNull(jit) == null) {
+ throw new JWTValidationException("The claims set is missing required JWT identifier (jit)");
+ }
+ if (!replayCache.check(getClass().getName(), jit, exp.toInstant().plus(clockSkew))) {
+ throw new JWTValidationException("Replay detected for jit '" + jit + "'");
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/oidc-common-crypto-impl/src/test/java/net/shibboleth/oidc/security/jwt/claims/impl/IssuedAtClaimsValidatorTest.java b/oidc-common-crypto-impl/src/test/java/net/shibboleth/oidc/security/jwt/claims/impl/IssuedAtClaimsValidatorTest.java
index a450b82..d796fab 100644
--- a/oidc-common-crypto-impl/src/test/java/net/shibboleth/oidc/security/jwt/claims/impl/IssuedAtClaimsValidatorTest.java
+++ b/oidc-common-crypto-impl/src/test/java/net/shibboleth/oidc/security/jwt/claims/impl/IssuedAtClaimsValidatorTest.java
@@ -23,7 +23,7 @@ import java.util.Date;
import javax.annotation.Nonnull;
-
+import org.opensaml.profile.context.ProfileRequestContext;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
@@ -33,23 +33,25 @@ import net.shibboleth.oidc.jwt.claims.JWTValidationException;
import net.shibboleth.utilities.java.support.component.ComponentInitializationException;
/** Test for the {@link IssuedAtClaimsValidator}. */
-public class IssuedAtClaimsValidatorTest extends AbstractClaimsValidatorTest {
+public class IssuedAtClaimsValidatorTest {
/** The validator to test.*/
@Nonnull private IssuedAtClaimsValidator validator;
+ @Nonnull private ProfileRequestContext prc;
@BeforeMethod
public void setup() throws ComponentInitializationException {
- super.setup();
validator = new IssuedAtClaimsValidator();
+ prc = new ProfileRequestContext();
}
@Test
public void doValidateTest() throws JWTValidationException, ComponentInitializationException {
final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder().issueTime(Date.from(Instant.now())).build();
validator.setId("test-validator");
- validator.setIatWindow(Duration.ofMinutes(1));
+ validator.setMessageLifetime(Duration.ofMinutes(1));
+ validator.setClockSkew(Duration.ofMinutes(1));
validator.initialize();
validator.validate(claimsSet, prc);
}
@@ -60,26 +62,79 @@ public class IssuedAtClaimsValidatorTest extends AbstractClaimsValidatorTest {
final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder().issueTime(
Date.from(Instant.now().minus(Duration.ofMinutes(10)))).build();
validator.setId("test-validator");
- validator.setIatWindow(Duration.ofMinutes(20));
+ validator.setMessageLifetime(Duration.ofMinutes(6));
+ validator.setClockSkew(Duration.ofMinutes(6));
validator.initialize();
validator.validate(claimsSet, prc);
}
-
+
+ @Test
+ public void doValidateTestIssuedInPastButInWindow_longClockSkew()
+ throws JWTValidationException, ComponentInitializationException {
+ final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder().issueTime(
+ Date.from(Instant.now().minus(Duration.ofMinutes(10)))).build();
+ validator.setId("test-validator");
+ validator.setMessageLifetime(Duration.ofMinutes(1));
+ validator.setClockSkew(Duration.ofMinutes(15));
+ validator.initialize();
+ validator.validate(claimsSet, prc);
+ }
+
+ @Test
+ public void doValidateTestIssuedInPastButInWindow_longLifetime()
+ throws JWTValidationException, ComponentInitializationException {
+ final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder().issueTime(
+ Date.from(Instant.now().minus(Duration.ofMinutes(10)))).build();
+ validator.setId("test-validator");
+ validator.setMessageLifetime(Duration.ofMinutes(15));
+ validator.setClockSkew(Duration.ofMinutes(1));
+ validator.initialize();
+ validator.validate(claimsSet, prc);
+ }
+
+ @Test
+ public void doValidateTestIssuedInFutureButInWindow()
+ throws JWTValidationException, ComponentInitializationException {
+ final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder().issueTime(
+ Date.from(Instant.now().plus(Duration.ofMinutes(5)))).build();
+ validator.setId("test-validator");
+ validator.setMessageLifetime(Duration.ofMinutes(1));
+ validator.setClockSkew(Duration.ofMinutes(6));
+ validator.initialize();
+ validator.validate(claimsSet, prc);
+ }
+
@Test
- public void doValidateTestNoIssuedAtClaim() throws JWTValidationException, ComponentInitializationException {
+ public void doValidateTestNoOptionalIssuedAtClaim()
+ throws JWTValidationException, ComponentInitializationException {
final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder().build();
validator.setId("test-validator");
- validator.setIatWindow(Duration.ofMinutes(1));
+ validator.setMessageLifetime(Duration.ofMinutes(1));
+ validator.setClockSkew(Duration.ofMinutes(1));
+ validator.setRequiredRule(false);
validator.initialize();
validator.validate(claimsSet, prc);
}
-
+
+ @Test(expectedExceptions = JWTValidationException.class)
+ public void doValidateTestNoRequiredIssuedAtClaim()
+ throws JWTValidationException, ComponentInitializationException {
+ final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder().build();
+ validator.setId("test-validator");
+ validator.setMessageLifetime(Duration.ofMinutes(1));
+ validator.setClockSkew(Duration.ofMinutes(1));
+ validator.setRequiredRule(true);
+ validator.initialize();
+ validator.validate(claimsSet, prc);
+ }
+
@Test(expectedExceptions = JWTValidationException.class)
public void doInValidateTestIssuedTooFarInPast() throws JWTValidationException, ComponentInitializationException {
final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder().issueTime(
- Date.from(Instant.now().minus(Duration.ofMinutes(10)))).build();
+ Date.from(Instant.now().minus(Duration.ofMinutes(11)))).build();
validator.setId("test-validator");
- validator.setIatWindow(Duration.ofMinutes(1));
+ validator.setMessageLifetime(Duration.ofMinutes(5));
+ validator.setClockSkew(Duration.ofMinutes(5));
validator.initialize();
validator.validate(claimsSet, prc);
}
@@ -87,13 +142,12 @@ public class IssuedAtClaimsValidatorTest extends AbstractClaimsValidatorTest {
@Test(expectedExceptions = JWTValidationException.class)
public void doInValidateTestIssuedTooFarInFuture() throws JWTValidationException, ComponentInitializationException {
final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder().issueTime(
- Date.from(Instant.now().plus(Duration.ofMinutes(10)))).build();
+ Date.from(Instant.now().plus(Duration.ofMinutes(6)))).build();
validator.setId("test-validator");
- validator.setIatWindow(Duration.ofMinutes(1));
+ validator.setMessageLifetime(Duration.ofMinutes(5));
+ validator.setClockSkew(Duration.ofMinutes(5));
validator.initialize();
validator.validate(claimsSet, prc);
}
-
-
-
-}
+
+}
\ No newline at end of file
diff --git a/oidc-common-crypto-impl/src/test/java/net/shibboleth/oidc/security/jwt/claims/impl/JWTIdentifierClaimsValidatorTest.java b/oidc-common-crypto-impl/src/test/java/net/shibboleth/oidc/security/jwt/claims/impl/JWTIdentifierClaimsValidatorTest.java
new file mode 100644
index 0000000..29bc89e
--- /dev/null
+++ b/oidc-common-crypto-impl/src/test/java/net/shibboleth/oidc/security/jwt/claims/impl/JWTIdentifierClaimsValidatorTest.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the University Corporation for Advanced Internet Development,
+ * Inc. (UCAID) under one or more contributor license agreements. See the
+ * NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The UCAID licenses this file to You under the Apache
+ * License, Version 2.0 (the "License"); you may not use this file except in
+ * compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.shibboleth.oidc.security.jwt.claims.impl;
+
+import java.time.Instant;
+import java.util.Date;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.storage.ReplayCache;
+import org.opensaml.storage.impl.MemoryStorageService;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.nimbusds.jwt.JWTClaimsSet;
+
+import net.shibboleth.oidc.jwt.claims.JWTValidationException;
+import net.shibboleth.utilities.java.support.component.ComponentInitializationException;
+
+/** Test for the {@link JWTIdentifierClaimsValidator}. */
+public class JWTIdentifierClaimsValidatorTest {
+
+ /** The validator to test. */
+ private JWTIdentifierClaimsValidator validator;
+
+ @Nonnull private ProfileRequestContext prc;
+
+ @BeforeMethod
+ public void setup() throws ComponentInitializationException {
+ validator = new JWTIdentifierClaimsValidator();
+ ReplayCache replayCache = new ReplayCache();
+ MemoryStorageService storageService = new MemoryStorageService();
+ storageService.setId("mockId");
+ storageService.initialize();
+ replayCache.setStorage(storageService);
+ validator.setReplayCache(replayCache);
+ validator.setId("test-validator");
+ validator.initialize();
+ prc = new ProfileRequestContext();
+ }
+
+ @Test(expectedExceptions = ComponentInitializationException.class)
+ public void testNoReplayCache() throws ComponentInitializationException {
+ validator = new JWTIdentifierClaimsValidator();
+ validator.setId("test-validator");
+ validator.initialize();
+ }
+
+ @Test(expectedExceptions = JWTValidationException.class)
+ public void doValidateTest_failsWhenNoExp() throws JWTValidationException {
+ final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
+ .jwtID("mockId")
+ .build();
+ validator.doValidate(claimsSet, prc);
+ }
+
+ @Test(expectedExceptions = JWTValidationException.class)
+ public void doValidateTest_failsWhenNoJit() throws JWTValidationException {
+ final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
+ .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+ .build();
+ validator.doValidate(claimsSet, prc);
+ }
+
+ @Test
+ public void doValidateTest_success() throws JWTValidationException {
+ final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
+ .jwtID("mockId")
+ .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+ .build();
+ validator.doValidate(claimsSet, prc);
+
+ }
+
+ @Test(expectedExceptions = JWTValidationException.class)
+ public void doValidateTest_failsWhenReplayed() throws JWTValidationException {
+ final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
+ .jwtID("mockId")
+ .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+ .build();
+ validator.doValidate(claimsSet, prc);
+ validator.doValidate(claimsSet, prc);
+ }
+
+}
--
To stop receiving notification emails like this one, please contact
the administrator of this repository.
More information about the commits
mailing list