diff --git a/router/java/src/net/i2p/router/crypto/ratchet/ECIESAEADEngine.java b/router/java/src/net/i2p/router/crypto/ratchet/ECIESAEADEngine.java
index 6b92a20f024294e7ff3a0f63de9edb3fd853bbb7..1a203eec00fa92653905fe46dfc58e4d8f739f56 100644
--- a/router/java/src/net/i2p/router/crypto/ratchet/ECIESAEADEngine.java
+++ b/router/java/src/net/i2p/router/crypto/ratchet/ECIESAEADEngine.java
@@ -61,6 +61,8 @@ public final class ECIESAEADEngine {
     private static final int MIN_ENCRYPTED_SIZE = MIN_ES_SIZE;
     private static final byte[] NULLPK = new byte[KEYLEN];
     private static final int MAXPAD = 16;
+    static final long MAX_NS_AGE = 5*60*1000;
+    private static final long MAX_NS_FUTURE = 2*60*1000;
     // debug, send ACKREQ in every ES
     private static final boolean ACKREQ_IN_ES = false;
 
@@ -145,6 +147,8 @@ public final class ECIESAEADEngine {
         try {
             return x_decrypt(data, targetPrivateKey, keyManager);
         } catch (DataFormatException dfe) {
+            if (_log.shouldWarn())
+                _log.warn("ECIES decrypt error", dfe);
             throw dfe;
         } catch (Exception e) {
             _log.error("ECIES decrypt error", e);
@@ -176,11 +180,11 @@ public final class ECIESAEADEngine {
             HandshakeState state = key.getHandshakeState();
             if (state == null) {
                 if (shouldDebug)
-                    _log.debug("Decrypting ES with tag: " + st.toBase64() + ": key: " + key.toBase64() + ": " + data.length + " bytes");
+                    _log.debug("Decrypting ES with tag: " + st.toBase64() + " key: " + key.toBase64() + ": " + data.length + " bytes");
                 decrypted = decryptExistingSession(tag, data, key, targetPrivateKey, keyManager);
             } else if (data.length >= MIN_NSR_SIZE) {
                 if (shouldDebug)
-                    _log.debug("Decrypting NSR with tag: " + st.toBase64() + ": key: " + key.toBase64() + ": " + data.length + " bytes");
+                    _log.debug("Decrypting NSR with tag: " + st.toBase64() + " key: " + key.toBase64() + ": " + data.length + " bytes");
                 decrypted = decryptNewSessionReply(tag, data, state, keyManager);
             } else {
                 decrypted = null;
@@ -270,6 +274,15 @@ public final class ECIESAEADEngine {
             }
             return null;
         }
+        // bloom filter here based on ephemeral key
+        // or should we do it based on apparent elg2-encoded key
+        // at the very top, to prevent excess DH resource usage?
+        // But that would put everything in the bloom filter.
+        if (keyManager.isDuplicate(pk)) {
+            if (_log.shouldWarn())
+                _log.warn("Dup eph. key in IB NS: " + pk);
+            return null;
+        }
 
         byte[] bobPK = new byte[KEYLEN];
         state.getRemotePublicKey().getPublicKey(bobPK, 0);
@@ -298,7 +311,13 @@ public final class ECIESAEADEngine {
         } catch (DataFormatException e) {
             throw e;
         } catch (Exception e) {
-            throw new DataFormatException("Msg 1 payload error", e);
+            throw new DataFormatException("NS payload error", e);
+        }
+
+        if (pc.datetime == 0) {
+            if (_log.shouldWarn())
+                _log.warn("No datetime block in IB NS");
+            return null;
         }
 
         // tell the SKM
@@ -862,7 +881,7 @@ public final class ECIESAEADEngine {
         public PLCallback() {
             this(null, null);
         }
-
+ 
         /**
          * ES
          * @param keyManager only for ES, otherwise null
@@ -874,12 +893,17 @@ public final class ECIESAEADEngine {
             remote = remoteKey;
         }
 
-        public void gotDateTime(long time) {
+        public void gotDateTime(long time) throws DataFormatException {
             if (_log.shouldDebug())
                 _log.debug("Got DATE block: " + DataHelper.formatTime(time));
             if (datetime != 0)
-                throw new IllegalArgumentException("Multiple DATETIME blocks");
+                throw new DataFormatException("Multiple DATETIME blocks");
             datetime = time;
+            long now = _context.clock().now();
+            if (time < now - MAX_NS_AGE ||
+                time > now + MAX_NS_FUTURE) {
+                throw new DataFormatException("Excess clock skew in IB NS: " + DataHelper.formatTime(time));
+            }
         }
 
         public void gotOptions(byte[] options, boolean isHandshake) {
diff --git a/router/java/src/net/i2p/router/crypto/ratchet/RatchetSKM.java b/router/java/src/net/i2p/router/crypto/ratchet/RatchetSKM.java
index 4c08ec57c21b86ae5449057f1b8b322a7f4becbe..2330c84db6385a91f414d3cdf94be8b27535ee5f 100644
--- a/router/java/src/net/i2p/router/crypto/ratchet/RatchetSKM.java
+++ b/router/java/src/net/i2p/router/crypto/ratchet/RatchetSKM.java
@@ -30,6 +30,7 @@ import net.i2p.data.PublicKey;
 import net.i2p.data.SessionKey;
 import net.i2p.data.SessionTag;
 import net.i2p.router.RouterContext;
+import net.i2p.router.util.DecayingHashSet;
 import net.i2p.util.Log;
 import net.i2p.util.SimpleTimer2;
 
@@ -49,6 +50,7 @@ public class RatchetSKM extends SessionKeyManager implements SessionTagListener
     protected final I2PAppContext _context;
     private volatile boolean _alive;
     private final HKDF _hkdf;
+    private final DecayingHashSet _replayFilter;
 
     /**
      * Let outbound session tags sit around for this long before expiring them.
@@ -95,17 +97,25 @@ public class RatchetSKM extends SessionKeyManager implements SessionTagListener
         _pendingOutboundSessions = new HashMap<PublicKey, List<OutboundSession>>(64);
         _inboundTagSets = new ConcurrentHashMap<RatchetSessionTag, RatchetTagSet>(128);
         _hkdf = new HKDF(context);
+        _replayFilter = new DecayingHashSet(context, (int) ECIESAEADEngine.MAX_NS_AGE, 32, "Ratchet-NS");
         // start the precalc of Elg2 keys if it wasn't already started
         context.eciesEngine().startup();
          _alive = true;
         new CleanupEvent();
     }
 
+    /**
+     *  Cannot be restarted
+     */
     @Override
     public void shutdown() {
          _alive = false;
         _inboundTagSets.clear();
         _outboundSessions.clear();
+        synchronized (_pendingOutboundSessions) {
+            _pendingOutboundSessions.clear();
+        }
+        _replayFilter.stopDecaying();
     }
 
     private class CleanupEvent extends SimpleTimer2.TimedEvent {
@@ -159,6 +169,14 @@ public class RatchetSKM extends SessionKeyManager implements SessionTagListener
         throw new UnsupportedOperationException();
     }
 
+    /**
+     *  @return true if a dup
+     *  @since 0.9.46
+     */
+    boolean isDuplicate(PublicKey pk) {
+        return _replayFilter.add(pk.getData(), 0, 32);
+    }
+
     /**
      * Inbound or outbound. Checks state.getRole() to determine.
      * For outbound (NS sent), adds to list of pending inbound sessions and returns true.
@@ -591,6 +609,7 @@ public class RatchetSKM extends SessionKeyManager implements SessionTagListener
             OutboundSession old = _outboundSessions.putIfAbsent(sess.getTarget(), sess);
             boolean rv = old == null;
             if (!rv) {
+                // TODO fix
                 if (isInbound && old.getLastUsedDate() < _context.clock().now() - SESSION_TAG_DURATION_MS - (60*1000)) {
                     _outboundSessions.put(sess.getTarget(), sess);
                     rv = true;