[java-oidfed-common] branch main updated: Improved the trust chain resolution and validation

Codeberg noreply at shibboleth.net
Mon Sep 28 15:54:35 UTC 2026


This is an automated email from the git hooks/post-receive script.

codeberg pushed a commit to branch main
in repository java-oidfed-common.

View the commit online:
https://codeberg.org/Shibboleth/java-oidfed-common/commit/90dd88ec0630fac965fbbfa93c57acdf7cac1443

The following commit(s) were added to refs/heads/main by this push:
     new 90dd88e  Improved the trust chain resolution and validation
90dd88e is described below

commit 90dd88ec0630fac965fbbfa93c57acdf7cac1443
Author: Henri Mikkonen <henri.mikkonen at iki.fi>
AuthorDate: Mon Sep 28 18:18:26 2026 +0300

    Improved the trust chain resolution and validation
    
    - Exploit expiration times from statements to calculate statement and trust chain validity in the caches
      - Filter out the expired chains, exploit the clockSkew (idp.policy.clockSkew)
    - Require expiration claim via claims validators for entity configurations and subordinate statements
    - Verify the subject/issuer links in trust chain before signature validation
    - Included some basic tests (subject, issuer, anchors) to the remote resolver response
---
 .../META-INF/net.shibboleth.idp/postconfig.xml     |  7 ++-
 ...DefaultEntityConfigurationFetchingStrategy.java |  9 +++-
 ...efaultSubordinateStatementFetchingStrategy.java |  9 +++-
 .../DefaultTrustChainFetchingStrategy.java         | 50 +++++++++++++++++++++-
 ...rustChainSignatureValidationFilterStrategy.java |  8 ++++
 .../metadata/util/EntityStatementHelper.java       | 34 ++++++++++++++-
 .../oidfed/profile/impl/CallResolveEntityApi.java  | 22 +++++++++-
 .../DefaultTrustChainFetchingStrategyTest.java     |  3 ++
 8 files changed, 136 insertions(+), 6 deletions(-)

diff --git a/oidfed-common-conf-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml b/oidfed-common-conf-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
index 155c061..4c1797c 100644
--- a/oidfed-common-conf-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
+++ b/oidfed-common-conf-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
@@ -144,6 +144,8 @@
                     p:requiredClaims="sub" />
                 <bean class="net.shibboleth.oidc.security.jwt.claims.impl.RequiredClaimsValidator"
                     p:requiredClaims="jwks" />
+                <bean class="net.shibboleth.oidc.security.jwt.claims.impl.RequiredClaimsValidator"
+                    p:requiredClaims="exp" />
                 <bean class="net.shibboleth.oidc.security.jwt.claims.impl.ExpiryClaimsValidator"
                     p:clockSkew="%{idp.policy.clockSkew:PT1M}" />
                 <bean class="net.shibboleth.oidc.security.jwt.claims.impl.ProhibitedClaimsValidator"
@@ -312,6 +314,8 @@
                     p:requiredClaims="sub" />
                 <bean class="net.shibboleth.oidc.security.jwt.claims.impl.RequiredClaimsValidator"
                     p:requiredClaims="jwks" />
+                <bean class="net.shibboleth.oidc.security.jwt.claims.impl.RequiredClaimsValidator"
+                    p:requiredClaims="exp" />
                 <bean class="net.shibboleth.oidc.security.jwt.claims.impl.ExpiryClaimsValidator"
                     p:clockSkew="%{idp.policy.clockSkew:PT1M}" />
                 <bean class="net.shibboleth.oidfed.security.jwt.claims.impl.CritClaimsValidator"
@@ -390,7 +394,8 @@
                 p:criteriaToSubjectEntityIdStrategy-ref="shibboleth.oidfed.DefaultSubjectEntityIDCriteriaToIdentifierStrategy"
                 p:entityConfigurationCache-ref="shibboleth.oidfed.EntityConfigurationMetadataCache"
                 p:subordinateStatementCache-ref="shibboleth.oidfed.SubordinateEntityStatementMetadataCache"
-                p:localTrustAnchorsCache-ref="shibboleth.oidfed.LocalTrustAnchorsMetadataCache">
+                p:localTrustAnchorsCache-ref="shibboleth.oidfed.LocalTrustAnchorsMetadataCache"
+                p:clockSkew="%{idp.policy.clockSkew:PT1M}">
                 <property name="criteriaToValidContainerLifetimeStrategy">
                     <bean parent="shibboleth.Functions.Constant">
                         <constructor-arg>
diff --git a/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/metadata/cache/configuration/DefaultEntityConfigurationFetchingStrategy.java b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/metadata/cache/configuration/DefaultEntityConfigurationFetchingStrategy.java
index 5176257..838bf16 100644
--- a/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/metadata/cache/configuration/DefaultEntityConfigurationFetchingStrategy.java
+++ b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/metadata/cache/configuration/DefaultEntityConfigurationFetchingStrategy.java
@@ -18,6 +18,9 @@ import java.io.IOException;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.time.Instant;
+import java.util.Comparator;
+import java.util.Objects;
+import java.util.stream.Stream;
 
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
@@ -115,8 +118,12 @@ public class DefaultEntityConfigurationFetchingStrategy
                 }
                 assert objectMapper != null;
                 final EntityConfiguration entityConfiguration = EntityConfigurationImpl.parse(jwt, objectMapper);
+                final Instant expiration =
+                        Stream.of(validExpiration, entityConfiguration.getParsedPayload().getExpiration())
+                        .filter(Objects::nonNull).min(Comparator.naturalOrder()).get();
+                assert expiration != null;
                 return new EntityConfigurationContainer(
-                        entityId, entityConfiguration, validExpiration, invalidExpiration);
+                        entityId, entityConfiguration, expiration, invalidExpiration);
             } catch (final JsonProcessingException e) {
                 log.warn("Could not deserialize entity statement payload from the response", e);
             } catch (final ConstraintViolationException e) {
diff --git a/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/metadata/cache/subordinate/DefaultSubordinateStatementFetchingStrategy.java b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/metadata/cache/subordinate/DefaultSubordinateStatementFetchingStrategy.java
index 985af41..c7fdd45 100644
--- a/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/metadata/cache/subordinate/DefaultSubordinateStatementFetchingStrategy.java
+++ b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/metadata/cache/subordinate/DefaultSubordinateStatementFetchingStrategy.java
@@ -20,10 +20,13 @@ import java.net.URISyntaxException;
 import java.nio.charset.StandardCharsets;
 import java.time.Instant;
 import java.util.ArrayList;
+import java.util.Comparator;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.function.BiConsumer;
+import java.util.stream.Stream;
 
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
@@ -212,8 +215,12 @@ public class DefaultSubordinateStatementFetchingStrategy
                 }
                 assert objectMapper != null;
                 final SubordinateStatement statement = SubordinateStatementImpl.parse(jwt, objectMapper);
+                final Instant expiration =
+                        Stream.of(validExpiration, statement.getParsedPayload().getExpiration())
+                        .filter(Objects::nonNull).min(Comparator.naturalOrder()).get();
+                assert expiration != null;
                 log.trace("Subordinate statement successfully parsed and stored into the container");
-                return new SubordinateStatementContainer(id, statement, validExpiration, invalidExpiration);
+                return new SubordinateStatementContainer(id, statement, expiration, invalidExpiration);
             } catch (final JsonProcessingException e) {
                 log.warn("Could not deserialize entity statement payload from the response", e);
             } catch (final ConstraintViolationException e) {
diff --git a/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/metadata/cache/trustchain/DefaultTrustChainFetchingStrategy.java b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/metadata/cache/trustchain/DefaultTrustChainFetchingStrategy.java
index 0072b9d..d2e4c36 100644
--- a/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/metadata/cache/trustchain/DefaultTrustChainFetchingStrategy.java
+++ b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/metadata/cache/trustchain/DefaultTrustChainFetchingStrategy.java
@@ -17,6 +17,7 @@ package net.shibboleth.oidfed.metadata.cache.trustchain;
 import java.time.Duration;
 import java.time.Instant;
 import java.util.ArrayList;
+import java.util.Comparator;
 import java.util.List;
 import java.util.Map;
 import java.util.function.Function;
@@ -87,6 +88,18 @@ public class DefaultTrustChainFetchingStrategy extends AbstractIdentifiableIniti
     /** Strategy to fetch maximum amount of authority hints to be processed. */
     @NonnullAfterInit private Function<CriteriaSet, Integer> criteriaToMaximumAuthorityHintsStrategy;
 
+    /** Positive clock skew adjustment to consider when checking trust chain expiration. */
+    @Nonnull private Duration clockSkew;
+
+    /**
+     * Constructor.
+     */
+    public DefaultTrustChainFetchingStrategy() {
+        final Duration sixtySeconds = Duration.ofSeconds(60);
+        assert sixtySeconds != null;
+        clockSkew = sixtySeconds;
+    }
+
     /**
      * Set the strategy for fetching entity ID from the criteria set.
      * 
@@ -174,6 +187,17 @@ public class DefaultTrustChainFetchingStrategy extends AbstractIdentifiableIniti
                 Constraint.isNotNull(strategy, "Criteria to maximum authority hints strategy cannot be null");
     }
 
+    /**
+     * Set the clock skew.
+     * 
+     * @param skew clock skew to set
+     */
+    public void setClockSkew(@Nonnull final Duration skew) {
+        checkSetterPreconditions();
+        clockSkew = Constraint.isNotNull(skew, "Clock skew cannot be null");
+        Constraint.isFalse(clockSkew.isNegative(), "Clock skew cannot be negative");
+    }
+
     /** {@inheritDoc} */
     @Override
     protected void doInitialize() throws ComponentInitializationException {
@@ -276,9 +300,17 @@ public class DefaultTrustChainFetchingStrategy extends AbstractIdentifiableIniti
         final List<List<EntityStatement<?>>> result = populatedWrappers.stream()
                 .filter(wrapper -> wrapper.isComplete())
                 .map(wrapper -> wrapper.getTrustChain())
+                .filter(chain -> parseTrustChainExpiration(chain).isAfter(Instant.now().minus(clockSkew)))
                 .toList();
         assert result != null; assert invalidExpiration != null; assert validExpiration != null;
-        return new TrustChainsContainer(result.isEmpty() ? invalidExpiration : validExpiration,
+        final Instant chainsExpiration = result.stream()
+                .map(chain -> parseTrustChainExpiration(chain))
+                .min(Comparator.naturalOrder())
+                .orElse(Instant.now());
+        final Instant shortestExpiration = chainsExpiration.plus(clockSkew).compareTo(validExpiration) > 0 ?
+                validExpiration : chainsExpiration.plus(clockSkew);
+        assert shortestExpiration != null;
+        return new TrustChainsContainer(result.isEmpty() ? invalidExpiration : shortestExpiration,
                 entityConfiguration.getSubject(), result);
     }
 
@@ -466,6 +498,22 @@ public class DefaultTrustChainFetchingStrategy extends AbstractIdentifiableIniti
         }
     }
 
+    /**
+     * Parse the expiration time from the trust chain: the lowest expiration time value is taken from the chain.
+     * 
+     * @param trustChain trust chain
+     * @return expiration instant
+     */
+    @Nonnull protected Instant parseTrustChainExpiration(@Nullable final List<EntityStatement<?>> trustChain) {
+        if (trustChain != null) {
+            return EntityStatementHelper.parseTrustChainExpiration(trustChain);
+        } else {
+            final Instant now = Instant.now();
+            assert now != null;
+            return now;
+        }
+    }
+
     /**
      * Wrapper class containing trust chain (potentially under construction) and a flag for indicating whether it's
      * complete.
diff --git a/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/metadata/cache/trustchain/DefaultTrustChainSignatureValidationFilterStrategy.java b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/metadata/cache/trustchain/DefaultTrustChainSignatureValidationFilterStrategy.java
index 9f07dc5..d76c0be 100644
--- a/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/metadata/cache/trustchain/DefaultTrustChainSignatureValidationFilterStrategy.java
+++ b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/metadata/cache/trustchain/DefaultTrustChainSignatureValidationFilterStrategy.java
@@ -29,6 +29,7 @@ import net.shibboleth.oidc.metadata.filter.MetadataFilterContext;
 import net.shibboleth.oidfed.metadata.EntityStatement;
 import net.shibboleth.oidfed.metadata.cache.AbstractTrustEngineSignatureValidationComponent;
 import net.shibboleth.oidfed.metadata.cache.SignatureValidationKeyContainerJwtCriterion;
+import net.shibboleth.oidfed.metadata.util.EntityStatementHelper;
 import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
 import net.shibboleth.shared.annotation.constraint.ThreadSafeAfterInit;
 import net.shibboleth.shared.collection.CollectionSupport;
@@ -116,6 +117,13 @@ public class DefaultTrustChainSignatureValidationFilterStrategy
         }
 
         trustChainLoop: for (final List<EntityStatement<?>> trustChain : container.getTrustChains()) {
+            assert trustChain != null;
+            if (!EntityStatementHelper.verifyTrustChainIssuerLinks(trustChain)) {
+                log.warn("The trust chain identity link validation failed, ignoring trust chain anchored by {}",
+                        trustChain.get(trustChain.size() - 1).getSubject());
+                container.removeTrustChain(trustChain);
+                continue trustChainLoop;
+            }
             for (int i = 0; i < trustChain.size() - 2; i++) {
                 final EntityStatement<?> trustChainEntry = trustChain.get(i);
                 final SignedJWT keyContainer = trustChain.get(i + 1).getJwt();
diff --git a/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/metadata/util/EntityStatementHelper.java b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/metadata/util/EntityStatementHelper.java
index 03d16ed..65f2351 100644
--- a/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/metadata/util/EntityStatementHelper.java
+++ b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/metadata/util/EntityStatementHelper.java
@@ -141,6 +141,11 @@ public class EntityStatementHelper {
             log.warn("Trust chain does not end with an entity configuration");
             return null;
         }
+        if (!verifyTrustChainIssuerLinks(result)) {
+            log.warn("The trust chain identity link validation failed, ignoring trust chain anchored by {}",
+                    result.get(result.size() - 1).getSubject());
+            return null;
+        }
         return result;
     }
 
@@ -195,9 +200,36 @@ public class EntityStatementHelper {
     @Nonnull public static Instant parseTrustChainExpiration(@Nonnull final List<EntityStatement<?>> trustChain) {
         final Instant result = trustChain.stream()
                 .map(statement -> statement.getParsedPayload().getExpiration())
-                .min(Comparator.comparing(Instant::toEpochMilli))
+                .filter(Objects::nonNull)
+                .min(Comparator.naturalOrder())
                 .orElse(Instant.now());
         assert result != null;
         return result;
     }
+
+    /**
+     * Verifies the expected links between subjects and issuers in a standard trust chain.
+     * 
+     * @param trustChain trust chain to be evaluated
+     * @return true if links are expected, false otherwise
+     */
+    public static boolean verifyTrustChainIssuerLinks(@Nonnull final List<EntityStatement<?>> trustChain) {
+        if (trustChain.size() < 3) {
+            return false;
+        }
+        final EntityStatement<?> leaf = trustChain.get(0);
+        if (!leaf.getSubject().equals(leaf.getIssuer())) {
+            return false;
+        }
+        for (int i = 0; i < trustChain.size() - 1; i++) {
+            if (!trustChain.get(i).getIssuer().equals(trustChain.get(i + 1).getSubject())) {
+                return false;
+            }
+        }
+        final EntityStatement<?> anchor = trustChain.get(trustChain.size() - 1);
+        if (!anchor.getSubject().equals(anchor.getIssuer())) {
+            return false;
+        }
+        return true;
+    }
 }
\ No newline at end of file
diff --git a/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/CallResolveEntityApi.java b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/CallResolveEntityApi.java
index d31819b..f929ead 100644
--- a/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/CallResolveEntityApi.java
+++ b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/CallResolveEntityApi.java
@@ -442,7 +442,17 @@ public class CallResolveEntityApi extends AbstractProfileAction {
             }
             final ResolveEntityResponse statement = cacheResult.get(0).getStatement();
             if (statement != null) {
+                if (!statement.getIssuer().equals(trustedEntity.getEntityId())) {
+                    log.warn("{} The resolve entity response issuer {} is not matching the expected trusted entity {}",
+                            getLogPrefix(), statement.getIssuer(), trustedEntity.getEntityId());
+                    continue;
+                }
                 final ResolveEntityResponsePayload payload = statement.getParsedPayload();
+                if (!nonNullEntityId.equals(payload.getSubject())) {
+                    log.warn("{} The resolve entity response subject {} is not matching the requested entity {}",
+                            getLogPrefix(), payload.getSubject(), nonNullEntityId);
+                    continue;
+                }
                 final List<String> rawTrustChain = payload.getTrustChain();
                 final Metadata metadata = payload.getMetadata();
                 final List<Map<String,String>> rawTrustMarks = payload.getTrustMarks();
@@ -455,7 +465,7 @@ public class CallResolveEntityApi extends AbstractProfileAction {
                 final List<EntityStatement<?>> chain =
                         EntityStatementHelper.deserializeTrustChain(rawTrustChain, objectMapper);
                 if (chain == null) {
-                    log.warn("Could not parse the trust chain into list of entity statements");
+                    log.warn("{} Could not parse the trust chain into list of entity statements", getLogPrefix());
                     continue;
                 }
                 if (!preSelectedChain.isEmpty() && !preSelectedChain.equals(trustChainIDsLookupStrategy.apply(chain))) {
@@ -463,6 +473,16 @@ public class CallResolveEntityApi extends AbstractProfileAction {
                             getLogPrefix());
                     continue;
                 }
+                if (!nonNullEntityId.equals(chain.get(0).getSubject())) {
+                    log.warn("{} The trust chain subject {} is not matching the requested entity {}", getLogPrefix(),
+                            chain.get(0).getSubject(), nonNullEntityId);
+                    continue;
+                }
+                if (!trustedEntity.getTrustAnchors().contains(chain.get(chain.size() - 1).getSubject())) {
+                    log.warn("{} The trust chain anchor {} is not among the requested anchors {}", getLogPrefix(),
+                            chain.get(chain.size() - 1).getSubject(), trustedEntity.getTrustAnchors());
+                    continue;
+                }
                 final List<VerifiedTrustChain> policyCompliantChains = new ArrayList<>();
                 policyCompliantChains.add(new VerifiedTrustChain(chain, metadata));
                 trustChainContext.setPolicyCompliantTrustChains(policyCompliantChains);
diff --git a/oidfed-common-impl/src/test/java/net/shibboleth/oidfed/metadata/cache/trustchain/DefaultTrustChainFetchingStrategyTest.java b/oidfed-common-impl/src/test/java/net/shibboleth/oidfed/metadata/cache/trustchain/DefaultTrustChainFetchingStrategyTest.java
index 124a893..77ae525 100644
--- a/oidfed-common-impl/src/test/java/net/shibboleth/oidfed/metadata/cache/trustchain/DefaultTrustChainFetchingStrategyTest.java
+++ b/oidfed-common-impl/src/test/java/net/shibboleth/oidfed/metadata/cache/trustchain/DefaultTrustChainFetchingStrategyTest.java
@@ -38,6 +38,7 @@ import net.shibboleth.oidfed.metadata.cache.local.LocalKeyContainer;
 import net.shibboleth.oidfed.metadata.cache.subordinate.SubordinateStatementCacheIdentifier;
 import net.shibboleth.oidfed.metadata.cache.subordinate.SubordinateStatementContainer;
 import net.shibboleth.oidfed.metadata.payload.EntityConfigurationPayload;
+import net.shibboleth.oidfed.metadata.payload.SubordinateStatementPayload;
 import net.shibboleth.oidfed.metadata.util.EntityStatementHelper;
 import net.shibboleth.shared.collection.CollectionSupport;
 import net.shibboleth.shared.component.ComponentInitializationException;
@@ -577,8 +578,10 @@ public class DefaultTrustChainFetchingStrategyTest {
     protected SubordinateStatementContainer ssContainer(final String subject, final String issuer) {
         final Instant expiration = Instant.now().plusSeconds(300);
         final SubordinateStatement statement = mock(SubordinateStatement.class);
+        final SubordinateStatementPayload payload = mock(SubordinateStatementPayload.class);
         when(statement.getSubject()).thenReturn(subject);
         when(statement.getIssuer()).thenReturn(issuer);
+        when(statement.getParsedPayload()).thenReturn(payload);
         return new SubordinateStatementContainer(
                 new SubordinateStatementCacheIdentifier(issuer, subject), statement, expiration, expiration);
     }

-- 
To stop receiving notification emails like this one, please contact
the administrator of this repository.


More information about the commits mailing list