You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@mina.apache.org by lg...@apache.org on 2019/09/23 17:21:24 UTC

[mina-sshd] 01/02: [SSHD-941] Lazy-load and cache internal moduli used in Diffie-Helman group key exchange

This is an automated email from the ASF dual-hosted git repository.

lgoldstein pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/mina-sshd.git

commit 6e63c9d0d42a33fb98a82d4b5dac66da08398db3
Author: Lyor Goldstein <lg...@apache.org>
AuthorDate: Mon Sep 23 09:12:49 2019 +0300

    [SSHD-941] Lazy-load and cache internal moduli used in Diffie-Helman group key exchange
---
 CHANGES.md                                         |  3 +
 README.md                                          |  1 +
 .../org/apache/sshd/client/kex/DHGEXClient.java    | 18 ++++-
 .../org/apache/sshd/server/kex/DHGEXServer.java    | 87 ++++++++++++++--------
 .../java/org/apache/sshd/server/kex/Moduli.java    | 51 ++++++++++++-
 .../org/apache/sshd/server/kex/ModuliTest.java     | 70 +++++++++++++++++
 6 files changed, 192 insertions(+), 38 deletions(-)

diff --git a/CHANGES.md b/CHANGES.md
index 1de1064..70c4c90 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -40,6 +40,9 @@ peer version data is received.
 by tracking the initialized channels identifiers and being lenient only if command is received for a channel that was
 initialized in the past.
 
+* The internal moduli used in Diffie-Hellman group exchange are **cached** - lazy-loaded the 1st time such an exchange
+occurs. The cache can be invalidated (and thus force a re-load) by invoking `Moduli#clearInternalModuliCache`.
+
 ## Behavioral changes and enhancements
 
 * [SSHD-926](https://issues.apache.org/jira/browse/SSHD-930) - Add support for OpenSSH 'lsetstat@openssh.com' SFTP protocol extension.
diff --git a/README.md b/README.md
index 49b4e38..ac35e7b 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,7 @@ based applications requiring SSH support.
 * [RFC 4335 - The Secure Shell (SSH) Session Channel Break Extension](https://tools.ietf.org/html/rfc4335)
 * [RFC 4344 - The Secure Shell (SSH) Transport Layer Encryption Modes](https://tools.ietf.org/html/rfc4344)
 * [RFC 4345 - Improved Arcfour Modes for the Secure Shell (SSH) Transport Layer Protocol](https://tools.ietf.org/html/rfc4345)
+* [RFC 4419 - Diffie-Hellman Group Exchange for the Secure Shell (SSH) Transport Layer Protocol](https://tools.ietf.org/html/rfc4419)
 * [RFC 4716 - The Secure Shell (SSH) Public Key File Format](https://tools.ietf.org/html/rfc4716)
 * [RFC 5480 - Elliptic Curve Cryptography Subject Public Key Information](https://tools.ietf.org/html/rfc5480)
 * [RFC 6668 - SHA-2 Data Integrity Verification for the Secure Shell (SSH) Transport Layer Protocol](https://tools.ietf.org/html/rfc6668)
diff --git a/sshd-core/src/main/java/org/apache/sshd/client/kex/DHGEXClient.java b/sshd-core/src/main/java/org/apache/sshd/client/kex/DHGEXClient.java
index 597079b..ced2125 100644
--- a/sshd-core/src/main/java/org/apache/sshd/client/kex/DHGEXClient.java
+++ b/sshd-core/src/main/java/org/apache/sshd/client/kex/DHGEXClient.java
@@ -87,8 +87,10 @@ public class DHGEXClient extends AbstractDHClientKeyExchange {
     public void init(Session s, byte[] v_s, byte[] v_c, byte[] i_s, byte[] i_c) throws Exception {
         super.init(s, v_s, v_c, i_s, i_c);
         if (log.isDebugEnabled()) {
-            log.debug("init({}) Send SSH_MSG_KEX_DH_GEX_REQUEST", s);
+            log.debug("init({})[{}] Send SSH_MSG_KEX_DH_GEX_REQUEST - min={}, prf={}, max={}",
+                this, s, min, prf, max);
         }
+
         Buffer buffer = s.createBuffer(SshConstants.SSH_MSG_KEX_DH_GEX_REQUEST, Integer.SIZE);
         buffer.putInt(min);
         buffer.putInt(prf);
@@ -104,8 +106,9 @@ public class DHGEXClient extends AbstractDHClientKeyExchange {
         Session session = getSession();
         boolean debugEnabled = log.isDebugEnabled();
         if (debugEnabled) {
-            log.debug("next({})[{}] process command={}",
-                this, session, KeyExchange.getGroupKexOpcodeName(cmd));
+            log.debug("next({})[{}] process command={} (expected={})",
+                this, session, KeyExchange.getGroupKexOpcodeName(cmd),
+                KeyExchange.getGroupKexOpcodeName(expected));
         }
 
         if (cmd != expected) {
@@ -126,6 +129,7 @@ public class DHGEXClient extends AbstractDHClientKeyExchange {
             if (debugEnabled) {
                 log.debug("next({})[{}] Send SSH_MSG_KEX_DH_GEX_INIT", this, session);
             }
+
             buffer = session.createBuffer(
                 SshConstants.SSH_MSG_KEX_DH_GEX_INIT, e.length + Byte.SIZE);
             buffer.putMPInt(e);
@@ -135,6 +139,11 @@ public class DHGEXClient extends AbstractDHClientKeyExchange {
         }
 
         if (cmd == SshConstants.SSH_MSG_KEX_DH_GEX_REPLY) {
+            if (debugEnabled) {
+                log.debug("next({})[{}] validate SSH_MSG_KEX_DH_GEX_REPLY - min={}, prf={}, max={}",
+                    this, session, min, prf, max);
+            }
+
             byte[] k_s = buffer.getBytes();
             f = buffer.getMPIntAsBytes();
             byte[] sig = buffer.getBytes();
@@ -146,7 +155,8 @@ public class DHGEXClient extends AbstractDHClientKeyExchange {
 
             String keyAlg = KeyUtils.getKeyType(serverKey);
             if (GenericUtils.isEmpty(keyAlg)) {
-                throw new SshException("Unsupported server key type");
+                throw new SshException("Unsupported server key type: " + serverKey.getAlgorithm()
+                    + " [" + serverKey.getFormat() + "]");
             }
 
             buffer = new ByteArrayBuffer();
diff --git a/sshd-core/src/main/java/org/apache/sshd/server/kex/DHGEXServer.java b/sshd-core/src/main/java/org/apache/sshd/server/kex/DHGEXServer.java
index 914c5b0..55d791f 100644
--- a/sshd-core/src/main/java/org/apache/sshd/server/kex/DHGEXServer.java
+++ b/sshd-core/src/main/java/org/apache/sshd/server/kex/DHGEXServer.java
@@ -55,7 +55,6 @@ import org.apache.sshd.server.session.ServerSession;
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
  */
 public class DHGEXServer extends AbstractDHServerKeyExchange {
-
     protected final DHFactory factory;
     protected DHG dh;
     protected int min;
@@ -73,7 +72,7 @@ public class DHGEXServer extends AbstractDHServerKeyExchange {
         return factory.getName();
     }
 
-    public static KeyExchangeFactory newFactory(final DHFactory factory) {
+    public static KeyExchangeFactory newFactory(DHFactory factory) {
         return new KeyExchangeFactory() {
             @Override
             public KeyExchange create() {
@@ -105,10 +104,13 @@ public class DHGEXServer extends AbstractDHServerKeyExchange {
         ServerSession session = getServerSession();
         boolean debugEnabled = log.isDebugEnabled();
         if (debugEnabled) {
-            log.debug("next({})[{}] process command={}", this, session, KeyExchange.getGroupKexOpcodeName(cmd));
+            log.debug("next({})[{}] process command={} (expected={})",
+                this, session, KeyExchange.getGroupKexOpcodeName(cmd),
+                KeyExchange.getGroupKexOpcodeName(expected));
         }
 
-        if (cmd == SshConstants.SSH_MSG_KEX_DH_GEX_REQUEST_OLD && expected == SshConstants.SSH_MSG_KEX_DH_GEX_REQUEST) {
+        if ((cmd == SshConstants.SSH_MSG_KEX_DH_GEX_REQUEST_OLD)
+                && (expected == SshConstants.SSH_MSG_KEX_DH_GEX_REQUEST)) {
             oldRequest = true;
             min = SecurityUtils.MIN_DHGEX_KEY_SIZE;
             prf = buffer.getInt();
@@ -116,15 +118,17 @@ public class DHGEXServer extends AbstractDHServerKeyExchange {
 
             if ((max < min) || (prf < min) || (max < prf)) {
                 throw new SshException(SshConstants.SSH2_DISCONNECT_KEY_EXCHANGE_FAILED,
-                        "Protocol error: bad parameters " + min + " !< " + prf + " !< " + max);
+                    "Protocol error: bad parameters " + min + " !< " + prf + " !< " + max);
             }
+
             dh = chooseDH(min, prf, max);
             f = dh.getE();
             hash = dh.getHash();
             hash.init();
 
             if (debugEnabled) {
-                log.debug("next({})[{}] send SSH_MSG_KEX_DH_GEX_GROUP", this, session);
+                log.debug("next({})[{}] send (old request) SSH_MSG_KEX_DH_GEX_GROUP - min={}, prf={}, max={}",
+                    this, session, min, prf, max);
             }
 
             buffer = session.createBuffer(SshConstants.SSH_MSG_KEX_DH_GEX_GROUP);
@@ -136,21 +140,25 @@ public class DHGEXServer extends AbstractDHServerKeyExchange {
             return false;
         }
 
-        if (cmd == SshConstants.SSH_MSG_KEX_DH_GEX_REQUEST && expected == SshConstants.SSH_MSG_KEX_DH_GEX_REQUEST) {
+        if ((cmd == SshConstants.SSH_MSG_KEX_DH_GEX_REQUEST)
+                && (expected == SshConstants.SSH_MSG_KEX_DH_GEX_REQUEST)) {
             min = buffer.getInt();
             prf = buffer.getInt();
             max = buffer.getInt();
+
             if ((prf < min) || (max < prf)) {
                 throw new SshException(SshConstants.SSH2_DISCONNECT_KEY_EXCHANGE_FAILED,
-                        "Protocol error: bad parameters " + min + " !< " + prf + " !< " + max);
+                    "Protocol error: bad parameters " + min + " !< " + prf + " !< " + max);
             }
+
             dh = chooseDH(min, prf, max);
             f = dh.getE();
             hash = dh.getHash();
             hash.init();
 
             if (debugEnabled) {
-                log.debug("next({})[{}] Send SSH_MSG_KEX_DH_GEX_GROUP", this, session);
+                log.debug("next({})[{}] Send SSH_MSG_KEX_DH_GEX_GROUP - min={}, prf={}, max={}",
+                    this, session, min, prf, max);
             }
             buffer = session.createBuffer(SshConstants.SSH_MSG_KEX_DH_GEX_GROUP);
             buffer.putMPInt(dh.getP());
@@ -163,8 +171,8 @@ public class DHGEXServer extends AbstractDHServerKeyExchange {
 
         if (cmd != expected) {
             throw new SshException(SshConstants.SSH2_DISCONNECT_KEY_EXCHANGE_FAILED,
-                    "Protocol error: expected packet " + KeyExchange.getGroupKexOpcodeName(expected)
-                  + ", got " + KeyExchange.getGroupKexOpcodeName(cmd));
+                "Protocol error: expected packet " + KeyExchange.getGroupKexOpcodeName(expected)
+              + ", got " + KeyExchange.getGroupKexOpcodeName(cmd));
         }
 
         if (cmd == SshConstants.SSH_MSG_KEX_DH_GEX_INIT) {
@@ -190,6 +198,7 @@ public class DHGEXServer extends AbstractDHServerKeyExchange {
             buffer.putBytes(i_c);
             buffer.putBytes(i_s);
             buffer.putBytes(k_s);
+
             if (oldRequest) {
                 buffer.putInt(prf);
             } else {
@@ -197,6 +206,7 @@ public class DHGEXServer extends AbstractDHServerKeyExchange {
                 buffer.putInt(prf);
                 buffer.putInt(max);
             }
+
             buffer.putMPInt(dh.getP());
             buffer.putMPInt(dh.getG());
             buffer.putMPInt(e);
@@ -220,10 +230,12 @@ public class DHGEXServer extends AbstractDHServerKeyExchange {
 
             // Send response
             if (debugEnabled) {
-                log.debug("next({})[{}] Send SSH_MSG_KEX_DH_GEX_REPLY", this, session);
+                log.debug("next({})[{}] Send SSH_MSG_KEX_DH_GEX_REPLY - old={}, min={}, prf={}, max={}",
+                    this, session, oldRequest, min, prf, max);
             }
 
-            buffer = session.prepareBuffer(SshConstants.SSH_MSG_KEX_DH_GEX_REPLY, BufferUtils.clear(buffer));
+            buffer = session.prepareBuffer(
+                SshConstants.SSH_MSG_KEX_DH_GEX_REPLY, BufferUtils.clear(buffer));
             buffer.putBytes(k_s);
             buffer.putBytes(f);
             buffer.putBytes(sigH);
@@ -235,20 +247,23 @@ public class DHGEXServer extends AbstractDHServerKeyExchange {
     }
 
     protected DHG chooseDH(int min, int prf, int max) throws Exception {
-        List<Moduli.DhGroup> groups = loadModuliGroups();
-
+        int maxDHGroupExchangeKeySize = SecurityUtils.getMaxDHGroupExchangeKeySize();
         min = Math.max(min, SecurityUtils.MIN_DHGEX_KEY_SIZE);
         prf = Math.max(prf, SecurityUtils.MIN_DHGEX_KEY_SIZE);
-        prf = Math.min(prf, SecurityUtils.getMaxDHGroupExchangeKeySize());
-        max = Math.min(max, SecurityUtils.getMaxDHGroupExchangeKeySize());
-        int bestSize = 0;
+        prf = Math.min(prf, maxDHGroupExchangeKeySize);
+        max = Math.min(max, maxDHGroupExchangeKeySize);
+
+        List<Moduli.DhGroup> groups = loadModuliGroups();
+        Session session = getServerSession();
         List<Moduli.DhGroup> selected = new ArrayList<>();
+        int bestSize = 0;
         boolean traceEnabled = log.isTraceEnabled();
         for (Moduli.DhGroup group : groups) {
             int size = group.getSize();
             if ((size < min) || (size > max)) {
                 if (traceEnabled) {
-                    log.trace("chooseDH - skip group={} - size not in range [{}-{}]", group, min, max);
+                    log.trace("chooseDH({})[{}] - skip group={} - size not in range [{}-{}]",
+                        this, session, group, min, max);
                 }
                 continue;
             }
@@ -256,22 +271,24 @@ public class DHGEXServer extends AbstractDHServerKeyExchange {
             if (((size > prf) && (size < bestSize)) || ((size > bestSize) && (bestSize < prf))) {
                 bestSize = size;
                 if (traceEnabled) {
-                    log.trace("chooseDH(prf={}, min={}, max={}) new best size={} from group={}", prf, min, max, bestSize, group);
+                    log.trace("chooseDH({})[{}][prf={}, min={}, max={}] new best size={} from group={}",
+                        this, session, prf, min, max, bestSize, group);
                 }
                 selected.clear();
             }
 
             if (size == bestSize) {
                 if (traceEnabled) {
-                    log.trace("chooseDH(prf={}, min={}, max={}) selected {}", prf, min, max, group);
+                    log.trace("chooseDH({})[{}][prf={}, min={}, max={}] selected {}",
+                        this, session, prf, min, max, group);
                 }
                 selected.add(group);
             }
         }
 
-        ServerSession session = getServerSession();
         if (selected.isEmpty()) {
-            log.warn("chooseDH({})[{}] No suitable primes found, defaulting to DHG1", this, session);
+            log.warn("chooseDH({})[{}][prf={}, min={}, max={}] No suitable primes found, defaulting to DHG1",
+                this, session, prf, min, max);
             return getDH(new BigInteger(DHGroupData.getP1()), new BigInteger(DHGroupData.getG()));
         }
 
@@ -280,42 +297,48 @@ public class DHGEXServer extends AbstractDHServerKeyExchange {
         Random random = Objects.requireNonNull(factory.create(), "No random generator");
         int which = random.random(selected.size());
         Moduli.DhGroup group = selected.get(which);
+        if (traceEnabled) {
+            log.trace("chooseDH({})[{}][prf={}, min={}, max={}] selected {}",
+                this, session, prf, min, max, group);
+        }
         return getDH(group.getP(), group.getG());
     }
 
     protected List<Moduli.DhGroup> loadModuliGroups() throws IOException {
-        ServerSession session = getServerSession();
+        Session session = getServerSession();
         String moduliStr = session.getString(ServerFactoryManager.MODULI_URL);
 
         List<Moduli.DhGroup> groups = null;
-        URL moduli;
         if (!GenericUtils.isEmpty(moduliStr)) {
             try {
-                moduli = new URL(moduliStr);
+                URL moduli = new URL(moduliStr);
                 groups = Moduli.parseModuli(moduli);
             } catch (IOException e) {   // OK - use internal moduli
-                log.warn("Error (" + e.getClass().getSimpleName() + ") loading external moduli from " + moduliStr + ": " + e.getMessage());
+                log.warn("loadModuliGroups({})[{}] Error ({}) loading external moduli from {}: {}",
+                    this, session, e.getClass().getSimpleName(), moduliStr, e.getMessage());
             }
         }
 
         if (groups == null) {
-            moduliStr = "/org/apache/sshd/moduli";
+            moduliStr = Moduli.INTERNAL_MODULI_RESPATH;
             try {
-                moduli = getClass().getResource(moduliStr);
+                URL moduli = getClass().getResource(moduliStr);
                 if (moduli == null) {
                     throw new FileNotFoundException("Missing internal moduli file");
                 }
 
                 moduliStr = moduli.toExternalForm();
-                groups = Moduli.parseModuli(moduli);
+                groups = Moduli.loadInternalModuli(moduli);
             } catch (IOException e) {
-                log.warn("Error (" + e.getClass().getSimpleName() + ") loading internal moduli from " + moduliStr + ": " + e.getMessage());
+                log.warn("loadModuliGroups({})[{}] Error ({}) loading internal moduli from {}: {}",
+                    this, session, e.getClass().getSimpleName(), moduliStr, e.getMessage());
                 throw e;    // this time we MUST throw the exception
             }
         }
 
         if (log.isDebugEnabled()) {
-            log.debug("Loaded moduli groups from {}", moduliStr);
+            log.debug("loadModuliGroups({})[{}] Loaded moduli groups from {}",
+                this, session, moduliStr);
         }
         return groups;
     }
diff --git a/sshd-core/src/main/java/org/apache/sshd/server/kex/Moduli.java b/sshd-core/src/main/java/org/apache/sshd/server/kex/Moduli.java
index adb7098..481b697 100644
--- a/sshd-core/src/main/java/org/apache/sshd/server/kex/Moduli.java
+++ b/sshd-core/src/main/java/org/apache/sshd/server/kex/Moduli.java
@@ -19,14 +19,21 @@
 package org.apache.sshd.server.kex;
 
 import java.io.BufferedReader;
+import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.InputStreamReader;
 import java.math.BigInteger;
 import java.net.URL;
 import java.nio.charset.StandardCharsets;
+import java.util.AbstractMap.SimpleImmutableEntry;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.sshd.common.util.GenericUtils;
 
 /**
  * Helper class to load DH group primes from a file.
@@ -34,6 +41,10 @@ import java.util.Objects;
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
  */
 public final class Moduli {
+    /**
+     * Resource path of internal moduli file
+     */
+    public static final String INTERNAL_MODULI_RESPATH = "/org/apache/sshd/moduli";
 
     public static final int MODULI_TYPE_SAFE = 2;
     public static final int MODULI_TESTS_COMPOSITE = 0x01;
@@ -63,20 +74,53 @@ public final class Moduli {
 
         @Override
         public String toString() {
-            return "[size=" + getSize() + ",G=" + getG() + ",P=" + getP() + "]";
+            return "[size=" + getSize() + ", G=" + getG() + ", P=" + getP() + "]";
         }
     }
 
+    private static final AtomicReference<Map.Entry<String, List<DhGroup>>> INTERNAL_MODULI_HOLDER = new AtomicReference<>();
+
     // Private constructor
     private Moduli() {
         throw new UnsupportedOperationException("No instance allowed");
     }
 
+    public static Map.Entry<String, List<DhGroup>> clearInternalModuliCache() {
+        return INTERNAL_MODULI_HOLDER.getAndSet(null);
+    }
+
+    public static List<DhGroup> loadInternalModuli(URL url) throws IOException {
+        if (url == null) {
+            throw new FileNotFoundException("No internal moduli resource specified");
+        }
+
+        String moduliStr = url.toExternalForm();
+        Map.Entry<String, List<DhGroup>> lastModuli = INTERNAL_MODULI_HOLDER.get();
+        String lastResource = (lastModuli == null) ? null : lastModuli.getKey();
+        if (Objects.equals(lastResource, moduliStr)) {
+            return lastModuli.getValue();
+        }
+
+        List<DhGroup> groups = parseModuli(url);
+        if (GenericUtils.isEmpty(groups)) {
+            groups = Collections.emptyList();
+        } else {
+            groups = Collections.unmodifiableList(groups);
+        }
+
+        INTERNAL_MODULI_HOLDER.set(new SimpleImmutableEntry<>(moduliStr, groups));
+        return groups;
+    }
+
     public static List<DhGroup> parseModuli(URL url) throws IOException {
         List<DhGroup> groups = new ArrayList<>();
         try (BufferedReader r = new BufferedReader(new InputStreamReader(url.openStream(), StandardCharsets.UTF_8))) {
             for (String line = r.readLine(); line != null; line = r.readLine()) {
                 line = line.trim();
+                if (line.isEmpty()) {
+                    continue;
+                }
+
                 if (line.startsWith("#")) {
                     continue;
                 }
@@ -105,7 +149,10 @@ public final class Moduli {
                     continue;
                 }
 
-                DhGroup group = new DhGroup(Integer.parseInt(parts[4]) + 1, new BigInteger(parts[5], 16), new BigInteger(parts[6], 16));
+                DhGroup group = new DhGroup(
+                    Integer.parseInt(parts[4]) + 1,
+                    new BigInteger(parts[5], 16),
+                    new BigInteger(parts[6], 16));
                 groups.add(group);
             }
 
diff --git a/sshd-core/src/test/java/org/apache/sshd/server/kex/ModuliTest.java b/sshd-core/src/test/java/org/apache/sshd/server/kex/ModuliTest.java
new file mode 100644
index 0000000..ef37662
--- /dev/null
+++ b/sshd-core/src/test/java/org/apache/sshd/server/kex/ModuliTest.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF 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 org.apache.sshd.server.kex;
+
+import java.io.IOException;
+import java.net.URL;
+import java.util.List;
+
+import org.apache.sshd.common.util.GenericUtils;
+import org.apache.sshd.server.kex.Moduli.DhGroup;
+import org.apache.sshd.util.test.JUnitTestSupport;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.FixMethodOrder;
+import org.junit.Test;
+import org.junit.runners.MethodSorters;
+
+/**
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+@FixMethodOrder(MethodSorters.NAME_ASCENDING)
+public class ModuliTest extends JUnitTestSupport {
+    public ModuliTest() {
+        super();
+    }
+
+    @BeforeClass
+    @AfterClass
+    public static void clearInternalModuliCache() {
+        Moduli.clearInternalModuliCache();
+    }
+
+    @Before
+    @After
+    public void clearCache() {
+        clearInternalModuliCache();
+    }
+
+    @Test
+    public void testLoadInternalModuli() throws IOException {
+        URL moduli = getClass().getResource(Moduli.INTERNAL_MODULI_RESPATH);
+        assertNotNull("Missing internal moduli resource", moduli);
+
+        List<DhGroup> expected = Moduli.loadInternalModuli(moduli);
+        assertTrue("No moduli groups parsed", GenericUtils.isNotEmpty(expected));
+
+        for (int index = 1; index <= Byte.SIZE; index++) {
+            List<DhGroup> actual = Moduli.loadInternalModuli(moduli);
+            assertSame("Mismatched cached instance at retry #" + index, expected, actual);
+        }
+    }
+}