diff --git a/router/java/src/net/i2p/router/transport/udp/IntroductionManager.java b/router/java/src/net/i2p/router/transport/udp/IntroductionManager.java
index 88e9c258c4236e4afe0c725b05661449b4543b4d..1013de01b15bd487fab55e169c85efad0fb4c94a 100644
--- a/router/java/src/net/i2p/router/transport/udp/IntroductionManager.java
+++ b/router/java/src/net/i2p/router/transport/udp/IntroductionManager.java
@@ -13,7 +13,11 @@ import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 
 import net.i2p.data.Base64;
+import net.i2p.data.DataHelper;
+import net.i2p.data.Hash;
 import net.i2p.data.SessionKey;
+import net.i2p.data.SigningPrivateKey;
+import net.i2p.data.SigningPublicKey;
 import net.i2p.data.router.RouterAddress;
 import net.i2p.data.router.RouterInfo;
 import net.i2p.router.RouterContext;
@@ -437,13 +441,15 @@ class IntroductionManager {
 
     /**
      *  We are Charlie and we got this from Bob.
-     *  Send a HolePunch to Alice, who will soon be sending us a RelayRequest.
+     *  Send a HolePunch to Alice, who will soon be sending us a SessionRequest.
      *  We should already have a session with Bob, but probably not with Alice.
      *
      *  If we don't have a session with Bob, we removed the relay tag from
      *  our _outbound table, so this won't work.
      *
      *  We do some throttling here.
+     *
+     *  SSU 1 only.
      */
     void receiveRelayIntro(RemoteHostId bob, UDPPacketReader reader) {
         if (_context.router().isHidden())
@@ -541,6 +547,8 @@ class IntroductionManager {
      *  We are Bob and we got this from Alice.
      *  Send a RelayIntro to Charlie and a RelayResponse to Alice.
      *  We should already have a session with Charlie, but not necessarily with Alice.
+     *
+     *  SSU 1 only.
      */
     void receiveRelayRequest(RemoteHostId alice, UDPPacketReader reader) {
         if (_context.router().isHidden())
@@ -649,6 +657,155 @@ class IntroductionManager {
                                                     cipherKey, macKey));
     }
 
+    /**
+     *  We are Bob and we got this from Alice.
+     *  Send Alice's RI and a RelayIntro to Charlie, or reject with a RelayResponse to Alice.
+     *  We should already have a session with Charlie and definitely with Alice.
+     *
+     *  SSU 2 only.
+     *
+     *  @since 0.9.55
+     */
+    void receiveRelayRequest(PeerState2 alice, byte[] data) {
+    }
+
+    /**
+     *  We are Charlie and we got this from Bob.
+     *  Send a HolePunch to Alice, who will soon be sending us a SessionRequest.
+     *  And send a RelayResponse to bob.
+     *
+     *  SSU 2 only.
+     *
+     *  @since 0.9.55
+     */
+    void receiveRelayIntro(PeerState2 bob, Hash alice, byte[] data) {
+        long nonce = DataHelper.fromLong(data, 0, 4);
+        long tag = DataHelper.fromLong(data, 4, 4);
+        long time = DataHelper.fromLong(data, 8, 4) * 1000;
+        int ver = data[12] & 0xff;
+        if (ver != 2) {
+            if (_log.shouldWarn())
+                _log.warn("Bad relay intro version " + ver + " from " + bob);
+            return;
+        }
+        int iplen = data[13] & 0xff;
+        if (iplen != 6 && iplen != 18) {
+            if (_log.shouldWarn())
+                _log.warn("Bad IP length " + iplen + " from " + bob);
+            return;
+        }
+        boolean isIPv6 = iplen == 18;
+        int testPort = (int) DataHelper.fromLong(data, 14, 2);
+        byte[] testIP = new byte[iplen - 2];
+        System.arraycopy(data, 16, testIP, 0, iplen - 2);
+        InetAddress aliceIP;
+        try {
+            aliceIP = InetAddress.getByAddress(testIP);
+        } catch (UnknownHostException uhe) {
+            return;
+        }
+
+        RouterInfo aliceRI = null;
+        SessionKey aliceIntroKey = null;
+        int rcode;
+        PeerState aps = _transport.getPeerState(alice);
+        if (aps != null && aps.isIPv6() == isIPv6) {
+            rcode = SSU2Util.RELAY_REJECT_CHARLIE_CONNECTED;
+        } else if (_context.banlist().isBanlisted(alice)) {
+            rcode = SSU2Util.RELAY_REJECT_CHARLIE_BANNED;
+        } else if (!TransportUtil.isValidPort(testPort) ||
+                  !_transport.isValid(testIP) ||
+                 _transport.isTooClose(testIP) ||
+                 _context.blocklist().isBlocklisted(testIP)) {
+            rcode = SSU2Util.RELAY_REJECT_CHARLIE_ADDRESS;
+        } else {
+            // bob should have sent it to us. Don't bother to lookup
+            // remotely if he didn't, or it was out-of-order or lost.
+            aliceRI = _context.netDb().lookupRouterInfoLocally(alice);
+            if (aliceRI != null) {
+                // validate signed data
+                SigningPublicKey spk = aliceRI.getIdentity().getSigningPublicKey();
+                if (SSU2Util.validateSig(_context, SSU2Util.RELAY_REQUEST_PROLOGUE,
+                                         bob.getRemotePeer(), _context.routerHash(), data, spk)) {
+                    aliceIntroKey = PeerTestManager.getIntroKey(getAddress(aliceRI, isIPv6));
+                    if (aliceIntroKey != null)
+                        rcode = SSU2Util.RELAY_ACCEPT;
+                    else
+                        rcode = SSU2Util.RELAY_REJECT_CHARLIE_ADDRESS;
+                } else {
+                    if (_log.shouldWarn())
+                        _log.warn("Signature failed relay intro\n" + aliceRI);
+                    rcode = SSU2Util.RELAY_REJECT_CHARLIE_SIGFAIL;
+                }
+            } else {
+                if (_log.shouldWarn())
+                    _log.warn("Alice RI not found " + alice);
+                rcode = SSU2Util.RELAY_REJECT_CHARLIE_UNKNOWN_ALICE;
+            }
+        }
+
+        // generate our signed data
+        // we sign it even if rejecting, not required though
+        SigningPrivateKey spk = _context.keyManager().getSigningPrivateKey();
+        data = SSU2Util.createRelayResponseData(_context, bob.getRemotePeer(), rcode,
+                                                nonce, testIP, testPort, spk);
+        if (data == null) {
+            if (_log.shouldWarn())
+                _log.warn("sig fail");
+             return;
+        }
+        UDPPacket packet = _builder2.buildRelayResponse(data, bob);
+        if (_log.shouldDebug())
+            _log.debug("Send relay response " + " nonce " + nonce + " to " + bob);
+        _transport.send(packet);
+        if (rcode == SSU2Util.RELAY_ACCEPT) {
+            // send hole punch with the same data we sent to Bob
+            if (_log.shouldDebug())
+                _log.debug("Send hole punch to " + Addresses.toString(testIP, testPort));
+            long rcvId = (nonce << 32) | nonce;
+            long sendId = ~rcvId;
+            packet = _builder2.buildHolePunch(aliceIP, testPort, aliceIntroKey, sendId, rcvId, data);
+            _transport.send(packet);
+        }
+    }
+
+    /**
+     *  We are Bob and we got this from Charlie, OR
+     *  we are Alice and we got this from Bob.
+     *
+     *  If we are Bob, send to Alice.
+     *  If we are Alice, send a SessionRequest to Charlie.
+     *  We should already have a session with Charlie, but not necessarily with Alice.
+     *
+     *  SSU 2 only.
+     *
+     *  @since 0.9.55
+     */
+    void receiveRelayResponse(PeerState2 peer, int status, byte[] data) {
+    }
+
+    /**
+     *  We are Alice and we got this from Charlie.
+     *  Send a SessionRequest to Charlie, whether or not we got the Relay Response already.
+     *
+     *  SSU 2 only, out-of-session.
+     *
+     *  @since 0.9.55
+     */
+    void receiveHolePunch(RemoteHostId charlie, byte[] data) {
+    }
+
+    /**
+     *  Get an address out of a RI. SSU2 only.
+     *
+     *  @return address or null
+     *  @since 0.9.55
+     */
+    private RouterAddress getAddress(RouterInfo ri, boolean isIPv6) {
+        List<RouterAddress> addrs = _transport.getTargetAddresses(ri);
+        return PeerTestManager.getAddress(addrs, isIPv6);
+    }
+
     /**
      *  Are IP and port valid?
      *  Reject all IPv6, for now, even if we are configured for it.
diff --git a/router/java/src/net/i2p/router/transport/udp/PacketBuilder2.java b/router/java/src/net/i2p/router/transport/udp/PacketBuilder2.java
index b1da0f0ad9255cbeb677f5235fb5c0de27230e6d..d70f8ff9a6cd4ff56e70f34668fe492df486cbba 100644
--- a/router/java/src/net/i2p/router/transport/udp/PacketBuilder2.java
+++ b/router/java/src/net/i2p/router/transport/udp/PacketBuilder2.java
@@ -761,19 +761,22 @@ class PacketBuilder2 {
     }
 
     /**
-     *  Creates an empty unauthenticated packet for hole punching.
-     *  Parameters must be validated previously.
+     *  Out-of-session, containing a RelayResponse block.
+     *
      */
-    public UDPPacket buildHolePunch(InetAddress to, int port) {
-        UDPPacket packet = UDPPacket.acquire(_context, false);
+    public UDPPacket buildHolePunch(InetAddress to, int port, SessionKey introKey,
+                                    long sendID, long rcvID, byte[] signedData) {
+        long n = _context.random().signedNextInt() & 0xFFFFFFFFL;
+        long token = _context.random().nextLong();
+        UDPPacket packet = buildLongPacketHeader(sendID, n, HOLE_PUNCH_FLAG_BYTE, rcvID, token);
+        Block block = new SSU2Payload.RelayResponseBlock(signedData);
         if (_log.shouldLog(Log.INFO))
             _log.info("Sending relay hole punch to " + to + ":" + port);
 
-        // the packet is empty and does not need to be authenticated, since
-        // its just for hole punching
-        packet.getPacket().setLength(0);
+        byte[] ik = introKey.getData();
+        packet.getPacket().setLength(LONG_HEADER_SIZE);
+        encryptPeerTest(packet, ik, n, ik, ik, to.getAddress(), port, block);
         setTo(packet, to, port);
-        
         packet.setMessageType(TYPE_PUNCH);
         packet.setPriority(PRIORITY_HIGH);
         return packet;
diff --git a/router/java/src/net/i2p/router/transport/udp/PeerState2.java b/router/java/src/net/i2p/router/transport/udp/PeerState2.java
index d4eea32d1fac9f09e61af0a2cc74650fc2ee22fb..1b35bb2824e5e90bc3ece7cfc19d79f25afc36c8 100644
--- a/router/java/src/net/i2p/router/transport/udp/PeerState2.java
+++ b/router/java/src/net/i2p/router/transport/udp/PeerState2.java
@@ -434,6 +434,7 @@ public class PeerState2 extends PeerState implements SSU2Payload.PayloadCallback
     public void gotRelayRequest(byte[] data) {
         if (!ENABLE_RELAY)
             return;
+        _transport.getIntroManager().receiveRelayRequest(this, data);
         // Relay blocks are ACK-eliciting
         messagePartiallyReceived();
     }
@@ -441,6 +442,7 @@ public class PeerState2 extends PeerState implements SSU2Payload.PayloadCallback
     public void gotRelayResponse(int status, byte[] data) {
         if (!ENABLE_RELAY)
             return;
+        _transport.getIntroManager().receiveRelayResponse(this, status, data);
         // Relay blocks are ACK-eliciting
         messagePartiallyReceived();
     }
@@ -448,6 +450,7 @@ public class PeerState2 extends PeerState implements SSU2Payload.PayloadCallback
     public void gotRelayIntro(Hash aliceHash, byte[] data) {
         if (!ENABLE_RELAY)
             return;
+        _transport.getIntroManager().receiveRelayIntro(this, aliceHash, data);
         // Relay blocks are ACK-eliciting
         messagePartiallyReceived();
     }
diff --git a/router/java/src/net/i2p/router/transport/udp/PeerTestManager.java b/router/java/src/net/i2p/router/transport/udp/PeerTestManager.java
index a26f7c5aae665b36f456f3e06e6bf6be26ca6919..86fb060bd4542e29bdb5646b622a3ddc32c1a1ba 100644
--- a/router/java/src/net/i2p/router/transport/udp/PeerTestManager.java
+++ b/router/java/src/net/i2p/router/transport/udp/PeerTestManager.java
@@ -1336,10 +1336,21 @@ class PeerTestManager {
     /**
      *  Get an address out of a RI. SSU2 only.
      *
+     *  @return address or null
      *  @since 0.9.54
      */
     private RouterAddress getAddress(RouterInfo ri, boolean isIPv6) {
         List<RouterAddress> addrs = _transport.getTargetAddresses(ri);
+        return getAddress(addrs, isIPv6);
+    }
+
+    /**
+     *  Get an address out of a list of addresses. SSU2 only.
+     *
+     *  @return address or null
+     *  @since 0.9.55
+     */
+    static RouterAddress getAddress(List<RouterAddress> addrs, boolean isIPv6) {
         RouterAddress ra = null;
         for (RouterAddress addr : addrs) {
             // skip SSU 1 address w/o "s"
@@ -1367,9 +1378,9 @@ class PeerTestManager {
     /**
      *  Get an intro key out of an address. SSU2 only.
      *
-     *  @since 0.9.54
+     *  @since 0.9.54, pkg private since 0.9.55 for IntroManager
      */
-    private static SessionKey getIntroKey(RouterAddress ra) {
+    static SessionKey getIntroKey(RouterAddress ra) {
         if (ra == null)
             return null;
         String siv = ra.getOption("i");
diff --git a/router/java/src/net/i2p/router/transport/udp/SSU2Util.java b/router/java/src/net/i2p/router/transport/udp/SSU2Util.java
index f2b357e372252c34d881c87754994dfb0d1d591f..15a6be50d7642d1ae9e5de9609e42ae7e1738652 100644
--- a/router/java/src/net/i2p/router/transport/udp/SSU2Util.java
+++ b/router/java/src/net/i2p/router/transport/udp/SSU2Util.java
@@ -106,6 +106,7 @@ final class SSU2Util {
     public static final byte PEER_TEST_FLAG_BYTE = UDPPacket.PAYLOAD_TYPE_TEST;
     public static final byte RETRY_FLAG_BYTE = 9;
     public static final byte TOKEN_REQUEST_FLAG_BYTE = 10;
+    public static final byte HOLE_PUNCH_FLAG_BYTE = 11;
 
     public static final String INFO_CREATED =   "SessCreateHeader";
     public static final String INFO_CONFIRMED = "SessionConfirmed";
@@ -132,6 +133,20 @@ final class SSU2Util {
     public static final int TEST_REJECT_CHARLIE_BANNED = 69;
     public static final int TEST_REJECT_CHARLIE_UNKNOWN_ALICE = 70;
 
+    public static final int RELAY_ACCEPT = 0;
+    public static final int RELAY_REJECT_BOB_UNSPEC = 1;
+    public static final int RELAY_REJECT_BOB_BANNED_CHARLIE = 2;
+    public static final int RELAY_REJECT_BOB_LIMIT = 3;
+    public static final int RELAY_REJECT_BOB_SIGFAIL = 4;
+    public static final int RELAY_REJECT_BOB_NO_TAG = 5;
+    public static final int RELAY_REJECT_CHARLIE_UNSPEC = 64;
+    public static final int RELAY_REJECT_CHARLIE_ADDRESS = 65;
+    public static final int RELAY_REJECT_CHARLIE_LIMIT = 66;
+    public static final int RELAY_REJECT_CHARLIE_SIGFAIL = 67;
+    public static final int RELAY_REJECT_CHARLIE_CONNECTED = 68;
+    public static final int RELAY_REJECT_CHARLIE_BANNED = 69;
+    public static final int RELAY_REJECT_CHARLIE_UNKNOWN_ALICE = 70;
+
     // termination reason codes
     public static final int REASON_UNSPEC = 0;
     public static final int REASON_TERMINATION = 1;
@@ -199,6 +214,65 @@ final class SSU2Util {
         return data;
     }
 
+    /**
+     *  Make the data for the relay request block
+     *
+     *  @param h Bob hash to be included in sig, not included in data
+     *  @param h2 Charlie hash to be included in sig, not included in data
+     *  @param ip non-null
+     *  @return null on failure
+     *  @since 0.9.55
+     */
+    public static byte[] createRelayRequestData(I2PAppContext ctx, Hash h, Hash h2,
+                                                long nonce, long tag, byte[] ip, int port,
+                                                SigningPrivateKey spk) {
+        int datalen = 17 + ip.length;
+        byte[] data = new byte[datalen + spk.getType().getSigLen()];
+        //data[0] = 0;  // flag
+        DataHelper.toLong(data, 1, 4, nonce);
+        DataHelper.toLong(data, 5, 4, tag);
+        DataHelper.toLong(data, 9, 4, ctx.clock().now() / 1000);
+        data[13] = 2;  // version
+        data[14] = (byte) (ip.length + 2);
+        DataHelper.toLong(data, 15, 2, port);
+        System.arraycopy(ip, 0, data, 17, ip.length);
+        Signature sig = sign(ctx, RELAY_REQUEST_PROLOGUE, h, h2, data, datalen, spk);
+        if (sig == null)
+            return null;
+        byte[] s = sig.getData();
+        System.arraycopy(s, 0, data, datalen, s.length);
+        return data;
+    }
+
+    /**
+     *  Make the data for the relay response block
+     *
+     *  @param h Bob hash to be included in sig, not included in data
+     *  @param ip non-null
+     *  @return null on failure
+     *  @since 0.9.55
+     */
+    public static byte[] createRelayResponseData(I2PAppContext ctx, Hash h, int code,
+                                                 long nonce, byte[] ip, int port,
+                                                 SigningPrivateKey spk) {
+        int datalen = 14 + ip.length;
+        byte[] data = new byte[datalen + spk.getType().getSigLen()];
+        //data[0] = 0;  // flag
+        data[1] = (byte) code;
+        DataHelper.toLong(data, 2, 4, nonce);
+        DataHelper.toLong(data, 6, 4, ctx.clock().now() / 1000);
+        data[10] = 2;  // version
+        data[11] = (byte) (ip.length + 2);
+        DataHelper.toLong(data, 12, 2, port);
+        System.arraycopy(ip, 0, data, 14, ip.length);
+        Signature sig = sign(ctx, RELAY_RESPONSE_PROLOGUE, h, null, data, datalen, spk);
+        if (sig == null)
+            return null;
+        byte[] s = sig.getData();
+        System.arraycopy(s, 0, data, datalen, s.length);
+        return data;
+    }
+
     /**
      *  Sign the relay or peer test data, using
      *  the prologue and hash as the initial data,