From 7610b3842f2ff85baf8f0b1f3cdec634d062eea7 Mon Sep 17 00:00:00 2001
From: zzz <zzz@i2pmail.org>
Date: Thu, 16 Jun 2022 08:22:55 -0400
Subject: [PATCH] SSU2: Hole punch processing

Validate and process hole punch payload
Send session request immediately after receiving hole punch
Remove some unused code
Log tweaks
---
 .../transport/udp/EstablishmentManager.java   | 241 +++++++++++++++---
 .../udp/OutboundEstablishState2.java          |  21 +-
 2 files changed, 219 insertions(+), 43 deletions(-)

diff --git a/router/java/src/net/i2p/router/transport/udp/EstablishmentManager.java b/router/java/src/net/i2p/router/transport/udp/EstablishmentManager.java
index 05df8548ff..a51a49c239 100644
--- a/router/java/src/net/i2p/router/transport/udp/EstablishmentManager.java
+++ b/router/java/src/net/i2p/router/transport/udp/EstablishmentManager.java
@@ -160,6 +160,7 @@ class EstablishmentManager {
     // SSU 2
     private static final int MAX_TOKENS = 512;
     public static final long IB_TOKEN_EXPIRATION = 60*60*1000L;
+    private static final long MAX_SKEW = 2*60*1000;
 
 
     public EstablishmentManager(RouterContext ctx, UDPTransport transport) {
@@ -1617,7 +1618,7 @@ class EstablishmentManager {
             token = 0;
         }
         Hash bobHash = bob.getRemotePeer();
-        Hash charlieHash = charlie.getRemoteHostId().getPeerHash();
+        Hash charlieHash = charlie.getRemoteIdentity().getHash();
         RouterInfo bobRI = _context.netDb().lookupRouterInfoLocally(bobHash);
         RouterInfo charlieRI = _context.netDb().lookupRouterInfoLocally(charlieHash);
         Hash signer;
@@ -1636,16 +1637,19 @@ class EstablishmentManager {
         if (signerRI != null) {
             // validate signed data
             SigningPublicKey spk = signerRI.getIdentity().getSigningPublicKey();
-            if (SSU2Util.validateSig(_context, SSU2Util.RELAY_RESPONSE_PROLOGUE,
+            if (!SSU2Util.validateSig(_context, SSU2Util.RELAY_RESPONSE_PROLOGUE,
                                      bobHash, null, data, spk)) {
-            } else {
                 if (_log.shouldWarn())
                     _log.warn("Signature failed relay response " + code + " as alice from:\n" + signerRI);
                 istate = INTRO_STATE_FAILED;
+                charlie2.setIntroState(bobHash, istate);
+                charlie.fail();
+                return;
             }
         } else {
             if (_log.shouldWarn())
                 _log.warn("Signer RI not found " + signer);
+            return;
         }
         if (code == 0) {
             int iplen = data[9] & 0xff;
@@ -1657,7 +1661,6 @@ class EstablishmentManager {
                 charlie.fail();
                 return;
             }
-            boolean isIPv6 = iplen == 18;
             int port = (int) DataHelper.fromLong(data, 10, 2);
             byte[] ip = new byte[iplen - 2];
             System.arraycopy(data, 12, ip, 0, iplen - 2);
@@ -1674,15 +1677,6 @@ class EstablishmentManager {
                 charlie.fail();
                 return;
             }
-            InetAddress charlieIP;
-            try {
-                charlieIP = InetAddress.getByAddress(ip);
-            } catch (UnknownHostException uhe) {
-                istate = INTRO_STATE_FAILED;
-                charlie2.setIntroState(bobHash, istate);
-                charlie.fail();
-                return;
-            }
             if (_log.shouldDebug())
                 _log.debug("Received RelayResponse from " + charlie + " - they are on " +
                            Addresses.toString(ip, port));
@@ -1695,24 +1689,29 @@ class EstablishmentManager {
             }
             synchronized (charlie) {
                 RemoteHostId oldId = charlie.getRemoteHostId();
-                ((OutboundEstablishState2) charlie).introduced(ip, port, token);
-                RemoteHostId newId = charlie.getRemoteHostId();
-                addOutboundToken(newId, token, _context.clock().now() + 10*1000);
-                // Swap out the RemoteHostId the state is indexed under.
-                // It was a Hash, change it to a IP/port.
-                // Remove the entry in the byClaimedAddress map as it's now in main map.
-                // Add an entry in the byHash map so additional OB pkts can find it.
-                _outboundByHash.put(charlieHash, charlie);
-                RemoteHostId claimed = charlie.getClaimedAddress();
-                if (!oldId.equals(newId)) {
-                    _outboundStates.remove(oldId);
-                    _outboundStates.put(newId, charlie);
-                    if (_log.shouldLog(Log.INFO))
-                        _log.info("RR replaced " + oldId + " with " + newId + ", claimed address was " + claimed);
+                if (oldId.getIP() == null) {
+                    // relay response before hole punch
+                    ((OutboundEstablishState2) charlie).introduced(ip, port, token);
+                    RemoteHostId newId = charlie.getRemoteHostId();
+                    addOutboundToken(newId, token, _context.clock().now() + 10*1000);
+                    // Swap out the RemoteHostId the state is indexed under.
+                    // It was a Hash, change it to a IP/port.
+                    // Remove the entry in the byClaimedAddress map as it's now in main map.
+                    // Add an entry in the byHash map so additional OB pkts can find it.
+                    _outboundByHash.put(charlieHash, charlie);
+                    RemoteHostId claimed = charlie.getClaimedAddress();
+                    if (!oldId.equals(newId)) {
+                        _outboundStates.remove(oldId);
+                        _outboundStates.put(newId, charlie);
+                        if (_log.shouldLog(Log.INFO))
+                            _log.info("RR replaced " + oldId + " with " + newId + ", claimed address was " + claimed);
+                    }
+                    //
+                    if (claimed != null)
+                        _outboundByClaimedAddress.remove(oldId, charlie);  // only if == state
+                } else {
+                    // TODO validate same IP/port as in hole punch?
                 }
-                //
-                if (claimed != null)
-                    _outboundByClaimedAddress.remove(oldId, charlie);  // only if == state
             }
             charlie2.setIntroState(bobHash, istate);
             notifyActivity();
@@ -1745,6 +1744,8 @@ class EstablishmentManager {
         RemoteHostId id = new RemoteHostId(from.getAddress(), fromPort);
         OutboundEstablishState state = _outboundStates.get(id);
         if (state != null) {
+            // this is the usual case, we already received the RelayResponse (1 RTT)
+            // before the HolePunch (1 1/2 RTT)
             boolean sendNow = state.receiveHolePunch();
             if (sendNow) {
                 if (_log.shouldDebug())
@@ -1786,14 +1787,47 @@ class EstablishmentManager {
         chacha.initializeKey(introKey, 0);
         long n = DataHelper.fromLong(data, off + PKT_NUM_OFFSET, 4);
         chacha.setNonce(n);
+        HPCallback cb = new HPCallback(id);
+        long now = _context.clock().now();
+        long nonce;
         try {
             // decrypt in-place
             chacha.decryptWithAd(data, off, LONG_HEADER_SIZE,
                                  data, off + LONG_HEADER_SIZE, data, off + LONG_HEADER_SIZE, len - LONG_HEADER_SIZE);
             int payloadLen = len - (LONG_HEADER_SIZE + MAC_LEN);
-            SSU2Payload.PayloadCallback cb = new HPCallback(id);
             SSU2Payload.processPayload(_context, cb, data, off + LONG_HEADER_SIZE, payloadLen, false);
-            // TODO process cb fields
+            if (cb._respCode != 0) {
+                if (_log.shouldWarn())
+                    _log.warn("Bad HolePunch response: " + cb._respCode);
+                return;
+            }
+            long skew = cb._timeReceived - now;
+            if (skew > MAX_SKEW || skew < 0 - MAX_SKEW) {
+                if (_log.shouldWarn())
+                    _log.warn("Too skewed in hole punch from " + id);
+                return;
+            }
+            nonce = DataHelper.fromLong(cb._respData, 0, 4);
+            if (nonce != (rcvConnID & 0xFFFFFFFFL) ||
+                nonce != ((rcvConnID >> 32) & 0xFFFFFFFFL)) {
+                if (_log.shouldWarn())
+                    _log.warn("Bad nonce in hole punch from " + id);
+                return;
+            }
+            long time = DataHelper.fromLong(cb._respData, 4, 4) * 1000;
+            skew = time - now;
+            if (skew > MAX_SKEW || skew < 0 - MAX_SKEW) {
+                if (_log.shouldWarn())
+                    _log.warn("Too skewed in hole punch from " + id);
+                return;
+            }
+            int ver = cb._respData[8] & 0xff;
+            if (ver != 2) {
+                if (_log.shouldWarn())
+                    _log.warn("Bad hole punch version " + ver + " from " + id);
+                return;
+            }
+            // check signature below
         } catch (Exception e) {
             if (_log.shouldWarn())
                 _log.warn("Bad HolePunch packet:\n" + HexDump.dump(data, off, len), e);
@@ -1805,19 +1839,141 @@ class EstablishmentManager {
         // TODO now we can look up by nonce instead if we want
         OutboundEstablishState state = _outboundStates.get(id);
         if (state != null) {
+            if (_log.shouldInfo())
+                _log.info("Hole punch after RelayResponse from " + state);
+        } else {
+            // This is the usual case, we received the HolePunch (1 1/2 RTT)
+            // before the RelayResponse (2 RTT), lookup by nonce.
+            state = _liveIntroductions.remove(Long.valueOf(nonce));
+            if (state != null) {
+                if (_log.shouldInfo())
+                    _log.info("Hole punch before RelayResponse from " + state);
+            } else {
+                if (_log.shouldLog(Log.INFO))
+                    _log.info("No state found for SSU2 hole punch from " + id);
+                return;
+            }
+        }
+        if (state.getVersion() != 2)
+            return;
+        OutboundEstablishState2 state2 = (OutboundEstablishState2) state;
+        Hash charlieHash = state.getRemoteIdentity().getHash();
+        RouterInfo charlieRI = _context.netDb().lookupRouterInfoLocally(charlieHash);
+        if (charlieRI != null) {
+            // validate signed data, but we don't necessarily know which Bob
+            SigningPublicKey spk = charlieRI.getIdentity().getSigningPublicKey();
+            UDPAddress addr = state.getRemoteAddress();
+            int count = addr.getIntroducerCount();
+            data = Arrays.copyOfRange(cb._respData, 0, cb._respData.length - 8);
+            boolean ok = false;
+            loop:
+            for (int i = 0; i < count; i++) {
+                Hash h = addr.getIntroducerHash(i);
+                if (h != null) {
+                    OutboundEstablishState2.IntroState istate = state2.getIntroState(h);
+                    switch (istate) {
+                        // probably not signed by this introducer
+                        case INTRO_STATE_INIT:
+                        case INTRO_STATE_EXPIRED:
+                        case INTRO_STATE_REJECTED:
+                        case INTRO_STATE_CONNECT_FAILED:
+                        case INTRO_STATE_BOB_REJECT:
+                        case INTRO_STATE_CHARLIE_REJECT:
+                        case INTRO_STATE_FAILED:
+                        case INTRO_STATE_INVALID:
+                        case INTRO_STATE_DISCONNECTED:
+                             continue;
+
+                        // maybe or definitely signed by this introducer
+                        case INTRO_STATE_LOOKUP_SENT:
+                        case INTRO_STATE_HAS_RI:
+                        case INTRO_STATE_CONNECTING:
+                        case INTRO_STATE_CONNECTED:
+                        case INTRO_STATE_RELAY_REQUEST_SENT:
+                        case INTRO_STATE_RELAY_CHARLIE_ACCEPTED:
+                        case INTRO_STATE_LOOKUP_FAILED:
+                        case INTRO_STATE_RELAY_RESPONSE_TIMEOUT:
+                        case INTRO_STATE_SUCCESS:
+                        default:
+                            if (SSU2Util.validateSig(_context, SSU2Util.RELAY_RESPONSE_PROLOGUE,
+                                                     h, null, data, spk)) {
+                                if (_log.shouldInfo())
+                                    _log.info("Good sig hole punch, credit " + h.toBase64() + " on " + state);
+                                state2.setIntroState(h, INTRO_STATE_SUCCESS);
+                                ok = true;
+                                break loop;
+                            }
+                            break;
+                    }
+                }
+            }
+            if (!ok) {
+                if (_log.shouldWarn())
+                    _log.warn("Signature failed hole punch on " + state);
+                return;
+            }
+
+            int iplen = data[9] & 0xff;
+            if (iplen != 6 && iplen != 18) {
+                if (_log.shouldWarn())
+                    _log.warn("Bad IP length " + iplen + " from " + state);
+                state.fail();
+                return;
+            }
+            int port = (int) DataHelper.fromLong(data, 10, 2);
+            byte[] ip = new byte[iplen - 2];
+            System.arraycopy(data, 12, ip, 0, iplen - 2);
+            // validate
+            if (!TransportUtil.isValidPort(port) ||
+                !_transport.isValid(ip) ||
+                _transport.isTooClose(ip) ||
+                _context.blocklist().isBlocklisted(ip)) {
+                if (_log.shouldLog(Log.WARN))
+                    _log.warn("Bad hole punch from " + state + " for " + Addresses.toString(ip, port));
+                _context.statManager().addRateData("udp.relayBadIP", 1);
+                state.fail();
+                return;
+            }
+            if (_log.shouldDebug())
+                _log.debug("Received hole punch from " + state + " - they are on " +
+                           Addresses.toString(ip, port));
+            synchronized (state) {
+                RemoteHostId oldId = state.getRemoteHostId();
+                if (oldId.getIP() == null) {
+                    // hole punch before relay response
+                    long token = DataHelper.fromLong8(cb._respData, cb._respData.length - 8);
+                    state2.introduced(ip, port, token);
+                    RemoteHostId newId = state.getRemoteHostId();
+                    addOutboundToken(newId, token, now + 10*1000);
+                    // Swap out the RemoteHostId the state is indexed under.
+                    // It was a Hash, change it to a IP/port.
+                    // Remove the entry in the byClaimedAddress map as it's now in main map.
+                    // Add an entry in the byHash map so additional OB pkts can find it.
+                    _outboundByHash.put(charlieHash, state);
+                    RemoteHostId claimed = state.getClaimedAddress();
+                    if (!oldId.equals(newId)) {
+                        _outboundStates.remove(oldId);
+                        _outboundStates.put(newId, state);
+                        if (_log.shouldLog(Log.INFO))
+                            _log.info("HP replaced " + oldId + " with " + newId + ", claimed address was " + claimed);
+                    }
+                    //
+                    if (claimed != null)
+                        _outboundByClaimedAddress.remove(oldId, state);  // only if == state
+                } else {
+                    // TODO validate same IP/port as in response?
+                }
+            }
             boolean sendNow = state.receiveHolePunch();
             if (sendNow) {
-                if (_log.shouldDebug())
-                    _log.debug("Hole punch from " + state + ", sending SessionRequest now");
+                if (_log.shouldInfo())
+                    _log.info("Send SessionRequest after HolePunch from " + state);
                 notifyActivity();
-            } else {
-                if (_log.shouldLog(Log.INFO))
-                    _log.info("Hole punch from " + state + ", already sent SessionRequest");
             }
         } else {
-            // HolePunch received before RelayResponse, and we didn't know the IP/port, or it changed
-            if (_log.shouldLog(Log.INFO))
-                _log.info("No state found for SSU2 hole punch from " + id);
+            if (_log.shouldWarn())
+                _log.warn("Charlie RI not found " + state);
+            return;
         }
     }
 
@@ -2358,6 +2514,7 @@ class EstablishmentManager {
         do {
             token = _context.random().nextLong();
         } while (token == 0);
+        // TODO shorten expiration based on _inboundTokens size
         long expires = _context.clock().now() + IB_TOKEN_EXPIRATION;
         Token tok = new Token(token, expires);
         synchronized(_inboundTokens) {
@@ -2399,12 +2556,12 @@ class EstablishmentManager {
      *
      *  @since 0.9.55
      */
-    private class HPCallback implements SSU2Payload.PayloadCallback {
+    private static class HPCallback implements SSU2Payload.PayloadCallback {
         private final RemoteHostId _from;
         public long _timeReceived;
         public byte[] _aliceIP;
         public int _alicePort;
-        public int _respCode;
+        public int _respCode = 999;
         public byte[] _respData;
 
         public HPCallback(RemoteHostId from) {
diff --git a/router/java/src/net/i2p/router/transport/udp/OutboundEstablishState2.java b/router/java/src/net/i2p/router/transport/udp/OutboundEstablishState2.java
index da01ec7f4d..5c644cd683 100644
--- a/router/java/src/net/i2p/router/transport/udp/OutboundEstablishState2.java
+++ b/router/java/src/net/i2p/router/transport/udp/OutboundEstablishState2.java
@@ -358,6 +358,25 @@ class OutboundEstablishState2 extends OutboundEstablishState implements SSU2Payl
         return rv;
     }
 
+    /**
+     *  Overridden because we don't have to wait for Relay Response first.
+     *
+     *  @return true if we should send the SessionRequest now
+     *  @since 0.9.55
+     */
+    @Override
+    synchronized boolean receiveHolePunch() {
+        if (_currentState == OutboundState.OB_STATE_PENDING_INTRO)
+            _currentState = OutboundState.OB_STATE_INTRODUCED;
+        else if (_currentState != OutboundState.OB_STATE_INTRODUCED)
+            return false;
+        if (_requestSentCount > 0)
+            return false;
+        long now = _context.clock().now();
+        _nextSend = now;
+        return true;
+    }
+
     // SSU 2 things
 
     @Override
@@ -666,7 +685,7 @@ class OutboundEstablishState2 extends OutboundEstablishState implements SSU2Payl
         synchronized(_introducers) {
             old = _introducers.put(h, state);
         }
-        if (_log.shouldDebug())
+        if (old != state && _log.shouldDebug())
             _log.debug("Change state for introducer " + h.toBase64() + " from " + old + " to " + state + " on " + this);
     }
 
-- 
GitLab