diff --git a/bramble-android/src/main/java/org/briarproject/bramble/plugin/tor/AndroidTorPlugin.java b/bramble-android/src/main/java/org/briarproject/bramble/plugin/tor/AndroidTorPlugin.java
index ae68db6e1c5e500601984a861e27f24a2c72ba5b..f367736152b0d494f1283eff97254c29857346b9 100644
--- a/bramble-android/src/main/java/org/briarproject/bramble/plugin/tor/AndroidTorPlugin.java
+++ b/bramble-android/src/main/java/org/briarproject/bramble/plugin/tor/AndroidTorPlugin.java
@@ -41,11 +41,12 @@ class AndroidTorPlugin extends TorPlugin {
 			Clock clock, ResourceProvider resourceProvider,
 			CircumventionProvider circumventionProvider,
 			BatteryManager batteryManager, Backoff backoff,
+			TorRendezvousCrypto torRendezvousCrypto,
 			PluginCallback callback, String architecture, int maxLatency,
 			int maxIdleTime) {
 		super(ioExecutor, networkManager, locationUtils, torSocketFactory,
 				clock, resourceProvider, circumventionProvider, batteryManager,
-				backoff, callback, architecture, maxLatency, maxIdleTime,
+				backoff, torRendezvousCrypto, callback, architecture, maxLatency, maxIdleTime,
 				appContext.getDir("tor", MODE_PRIVATE));
 		this.appContext = appContext;
 		PowerManager pm = (PowerManager)
diff --git a/bramble-android/src/main/java/org/briarproject/bramble/plugin/tor/AndroidTorPluginFactory.java b/bramble-android/src/main/java/org/briarproject/bramble/plugin/tor/AndroidTorPluginFactory.java
index 560072343a52cb2d4f084248ad9f3805a29cdac8..40535497588885bbb11f2515aa01079d191f21c6 100644
--- a/bramble-android/src/main/java/org/briarproject/bramble/plugin/tor/AndroidTorPluginFactory.java
+++ b/bramble-android/src/main/java/org/briarproject/bramble/plugin/tor/AndroidTorPluginFactory.java
@@ -106,10 +106,12 @@ public class AndroidTorPluginFactory implements DuplexPluginFactory {
 
 		Backoff backoff = backoffFactory.createBackoff(MIN_POLLING_INTERVAL,
 				MAX_POLLING_INTERVAL, BACKOFF_BASE);
+		TorRendezvousCrypto torRendezvousCrypto = new TorRendezvousCryptoImpl();
 		AndroidTorPlugin plugin = new AndroidTorPlugin(ioExecutor, scheduler,
 				appContext, networkManager, locationUtils, torSocketFactory,
 				clock, resourceProvider, circumventionProvider, batteryManager,
-				backoff, callback, architecture, MAX_LATENCY, MAX_IDLE_TIME);
+				backoff, torRendezvousCrypto, callback, architecture,
+				MAX_LATENCY, MAX_IDLE_TIME);
 		eventBus.addListener(plugin);
 		return plugin;
 	}
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/plugin/PluginManagerImpl.java b/bramble-core/src/main/java/org/briarproject/bramble/plugin/PluginManagerImpl.java
index cbaac0943070806f70d1926aec677bca4117aded..d4d69ca565f168ab62a49766884eae516e0358b2 100644
--- a/bramble-core/src/main/java/org/briarproject/bramble/plugin/PluginManagerImpl.java
+++ b/bramble-core/src/main/java/org/briarproject/bramble/plugin/PluginManagerImpl.java
@@ -250,6 +250,7 @@ class PluginManagerImpl implements PluginManager, Service {
 	private class Callback implements PluginCallback {
 
 		private final TransportId id;
+		private final AtomicBoolean enabled = new AtomicBoolean(false);
 
 		private Callback(TransportId id) {
 			this.id = id;
@@ -295,12 +296,14 @@ class PluginManagerImpl implements PluginManager, Service {
 
 		@Override
 		public void transportEnabled() {
-			eventBus.broadcast(new TransportEnabledEvent(id));
+			if (!enabled.getAndSet(true))
+				eventBus.broadcast(new TransportEnabledEvent(id));
 		}
 
 		@Override
 		public void transportDisabled() {
-			eventBus.broadcast(new TransportDisabledEvent(id));
+			if (enabled.getAndSet(false))
+				eventBus.broadcast(new TransportDisabledEvent(id));
 		}
 
 		@Override
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/plugin/tor/TorPlugin.java b/bramble-core/src/main/java/org/briarproject/bramble/plugin/tor/TorPlugin.java
index 76d8f6109a99bf7913cbde2809ef8cafad325862..e2494e8307017224b8550705c88ee29123b6a4a5 100644
--- a/bramble-core/src/main/java/org/briarproject/bramble/plugin/tor/TorPlugin.java
+++ b/bramble-core/src/main/java/org/briarproject/bramble/plugin/tor/TorPlugin.java
@@ -32,7 +32,6 @@ import org.briarproject.bramble.api.settings.event.SettingsUpdatedEvent;
 import org.briarproject.bramble.api.system.Clock;
 import org.briarproject.bramble.api.system.LocationUtils;
 import org.briarproject.bramble.api.system.ResourceProvider;
-import org.briarproject.bramble.util.IoUtils;
 
 import java.io.EOFException;
 import java.io.File;
@@ -55,7 +54,6 @@ import java.util.logging.Logger;
 import java.util.regex.Pattern;
 import java.util.zip.ZipInputStream;
 
-import javax.annotation.Nullable;
 import javax.net.SocketFactory;
 
 import static java.util.Arrays.asList;
@@ -78,7 +76,9 @@ import static org.briarproject.bramble.api.plugin.TorConstants.PREF_TOR_ONLY_WHE
 import static org.briarproject.bramble.api.plugin.TorConstants.PREF_TOR_PORT;
 import static org.briarproject.bramble.api.plugin.TorConstants.PROP_ONION_V2;
 import static org.briarproject.bramble.api.plugin.TorConstants.PROP_ONION_V3;
+import static org.briarproject.bramble.plugin.tor.TorRendezvousCrypto.SEED_BYTES;
 import static org.briarproject.bramble.util.IoUtils.copyAndClose;
+import static org.briarproject.bramble.util.IoUtils.tryToClose;
 import static org.briarproject.bramble.util.LogUtils.logException;
 import static org.briarproject.bramble.util.PrivacyUtils.scrubOnion;
 import static org.briarproject.bramble.util.StringUtils.isNullOrEmpty;
@@ -105,6 +105,7 @@ abstract class TorPlugin implements DuplexPlugin, EventHandler, EventListener {
 	private final Clock clock;
 	private final BatteryManager batteryManager;
 	private final Backoff backoff;
+	private final TorRendezvousCrypto torRendezvousCrypto;
 	private final PluginCallback callback;
 	private final String architecture;
 	private final CircumventionProvider circumventionProvider;
@@ -131,6 +132,7 @@ abstract class TorPlugin implements DuplexPlugin, EventHandler, EventListener {
 			Clock clock, ResourceProvider resourceProvider,
 			CircumventionProvider circumventionProvider,
 			BatteryManager batteryManager, Backoff backoff,
+			TorRendezvousCrypto torRendezvousCrypto,
 			PluginCallback callback, String architecture, int maxLatency,
 			int maxIdleTime, File torDirectory) {
 		this.ioExecutor = ioExecutor;
@@ -142,6 +144,7 @@ abstract class TorPlugin implements DuplexPlugin, EventHandler, EventListener {
 		this.circumventionProvider = circumventionProvider;
 		this.batteryManager = batteryManager;
 		this.backoff = backoff;
+		this.torRendezvousCrypto = torRendezvousCrypto;
 		this.callback = callback;
 		this.architecture = architecture;
 		this.maxLatency = maxLatency;
@@ -311,8 +314,8 @@ abstract class TorPlugin implements DuplexPlugin, EventHandler, EventListener {
 			if (!doneFile.createNewFile())
 				LOG.warning("Failed to create done file");
 		} catch (IOException e) {
-			IoUtils.tryToClose(in, LOG, WARNING);
-			IoUtils.tryToClose(out, LOG, WARNING);
+			tryToClose(in, LOG, WARNING);
+			tryToClose(out, LOG, WARNING);
 			throw new PluginException(e);
 		}
 	}
@@ -371,7 +374,7 @@ abstract class TorPlugin implements DuplexPlugin, EventHandler, EventListener {
 			}
 			return b;
 		} finally {
-			IoUtils.tryToClose(in, LOG, WARNING);
+			tryToClose(in, LOG, WARNING);
 		}
 	}
 
@@ -389,11 +392,11 @@ abstract class TorPlugin implements DuplexPlugin, EventHandler, EventListener {
 				ss.bind(new InetSocketAddress("127.0.0.1", port));
 			} catch (IOException e) {
 				logException(LOG, WARNING, e);
-				tryToClose(ss);
+				tryToClose(ss, LOG, WARNING);
 				return;
 			}
 			if (!running) {
-				tryToClose(ss);
+				tryToClose(ss, LOG, WARNING);
 				return;
 			}
 			socket = ss;
@@ -410,11 +413,6 @@ abstract class TorPlugin implements DuplexPlugin, EventHandler, EventListener {
 		});
 	}
 
-	private void tryToClose(@Nullable ServerSocket ss) {
-		IoUtils.tryToClose(ss, LOG, WARNING);
-		callback.transportDisabled();
-	}
-
 	private void publishHiddenService(String port) {
 		if (!running) return;
 		LOG.info("Creating hidden service");
@@ -499,7 +497,8 @@ abstract class TorPlugin implements DuplexPlugin, EventHandler, EventListener {
 	@Override
 	public void stop() {
 		running = false;
-		tryToClose(socket);
+		tryToClose(socket, LOG, WARNING);
+		callback.transportDisabled();
 		if (controlSocket != null && controlConnection != null) {
 			try {
 				LOG.info("Stopping Tor");
@@ -586,7 +585,7 @@ abstract class TorPlugin implements DuplexPlugin, EventHandler, EventListener {
 				LOG.info("Could not connect to " + scrubOnion(bestOnion)
 						+ ": " + e.toString());
 			}
-			IoUtils.tryToClose(s, LOG, WARNING);
+			tryToClose(s, LOG, WARNING);
 			return null;
 		}
 	}
@@ -609,13 +608,58 @@ abstract class TorPlugin implements DuplexPlugin, EventHandler, EventListener {
 
 	@Override
 	public boolean supportsRendezvous() {
-		return false;
+		return true;
 	}
 
 	@Override
 	public RendezvousEndpoint createRendezvousEndpoint(KeyMaterialSource k,
 			boolean alice, ConnectionHandler incoming) {
-		throw new UnsupportedOperationException();
+		byte[] aliceSeed = k.getKeyMaterial(SEED_BYTES);
+		byte[] bobSeed = k.getKeyMaterial(SEED_BYTES);
+		byte[] localSeed = alice ? aliceSeed : bobSeed;
+		byte[] remoteSeed = alice ? bobSeed : aliceSeed;
+		String blob = torRendezvousCrypto.getPrivateKeyBlob(localSeed);
+		String localOnion = torRendezvousCrypto.getOnionAddress(localSeed);
+		String remoteOnion = torRendezvousCrypto.getOnionAddress(remoteSeed);
+		TransportProperties remoteProperties = new TransportProperties();
+		remoteProperties.put(PROP_ONION_V3, remoteOnion);
+		try {
+			ServerSocket ss = new ServerSocket();
+			ss.bind(new InetSocketAddress("127.0.0.1", 0));
+			int port = ss.getLocalPort();
+			ioExecutor.execute(() -> {
+				try {
+					//noinspection InfiniteLoopStatement
+					while (true) {
+						Socket s = ss.accept();
+						incoming.handleConnection(
+								new TorTransportConnection(this, s));
+					}
+				} catch (IOException e) {
+					// This is expected when the socket is closed
+					if (LOG.isLoggable(INFO)) LOG.info(e.toString());
+				}
+			});
+			Map<Integer, String> portLines =
+					singletonMap(80, "127.0.0.1:" + port);
+			controlConnection.addOnion(blob, portLines);
+			return new RendezvousEndpoint() {
+
+				@Override
+				public TransportProperties getRemoteTransportProperties() {
+					return remoteProperties;
+				}
+
+				@Override
+				public void close() throws IOException {
+					controlConnection.delOnion(localOnion);
+					tryToClose(ss, LOG, WARNING);
+				}
+			};
+		} catch (IOException e) {
+			logException(LOG, WARNING, e);
+			return null;
+		}
 	}
 
 	@Override
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/plugin/tor/TorRendezvousCrypto.java b/bramble-core/src/main/java/org/briarproject/bramble/plugin/tor/TorRendezvousCrypto.java
new file mode 100644
index 0000000000000000000000000000000000000000..a5b32630e804701fcca2ca7c58f9a00ebee734cf
--- /dev/null
+++ b/bramble-core/src/main/java/org/briarproject/bramble/plugin/tor/TorRendezvousCrypto.java
@@ -0,0 +1,10 @@
+package org.briarproject.bramble.plugin.tor;
+
+interface TorRendezvousCrypto {
+
+	static final int SEED_BYTES = 32;
+
+	String getOnionAddress(byte[] seed);
+
+	String getPrivateKeyBlob(byte[] seed);
+}
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/plugin/tor/TorRendezvousCryptoImpl.java b/bramble-core/src/main/java/org/briarproject/bramble/plugin/tor/TorRendezvousCryptoImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..1545bcfba5c6f67c20eb365b85cf37065081cac4
--- /dev/null
+++ b/bramble-core/src/main/java/org/briarproject/bramble/plugin/tor/TorRendezvousCryptoImpl.java
@@ -0,0 +1,49 @@
+package org.briarproject.bramble.plugin.tor;
+
+import net.i2p.crypto.eddsa.spec.EdDSANamedCurveSpec;
+import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable;
+import net.i2p.crypto.eddsa.spec.EdDSAPrivateKeySpec;
+
+import org.briarproject.bramble.util.Base32;
+import org.spongycastle.crypto.Digest;
+import org.spongycastle.crypto.digests.SHA3Digest;
+import org.spongycastle.util.encoders.Base64;
+
+import java.nio.charset.Charset;
+
+import static java.lang.System.arraycopy;
+
+public class TorRendezvousCryptoImpl implements TorRendezvousCrypto {
+
+	private static final EdDSANamedCurveSpec CURVE_SPEC =
+			EdDSANamedCurveTable.getByName("Ed25519");
+
+	private static final byte HS_PROTOCOL_VERSION = 3;
+	private static final int CHECKSUM_BYTES = 2;
+
+	@Override
+	public String getOnionAddress(byte[] seed) {
+		EdDSAPrivateKeySpec spec = new EdDSAPrivateKeySpec(seed, CURVE_SPEC);
+		byte[] publicKey = spec.getA().toByteArray();
+		Digest digest = new SHA3Digest(256);
+		byte[] label = ".onion checksum".getBytes(Charset.forName("US-ASCII"));
+		digest.update(label, 0, label.length);
+		digest.update(publicKey, 0, publicKey.length);
+		digest.update(HS_PROTOCOL_VERSION);
+		byte[] checksum = new byte[digest.getDigestSize()];
+		digest.doFinal(checksum, 0);
+		byte[] address = new byte[publicKey.length + CHECKSUM_BYTES + 1];
+		arraycopy(publicKey, 0, address, 0, publicKey.length);
+		arraycopy(checksum, 0, address, publicKey.length, CHECKSUM_BYTES);
+		address[address.length - 1] = HS_PROTOCOL_VERSION;
+		return Base32.encode(address).toLowerCase();
+	}
+
+	@Override
+	public String getPrivateKeyBlob(byte[] seed) {
+		EdDSAPrivateKeySpec spec = new EdDSAPrivateKeySpec(seed, CURVE_SPEC);
+		byte[] hash = spec.getH();
+		byte[] base64 = Base64.encode(hash);
+		return "ED25519-V3:" + new String(base64, Charset.forName("US-ASCII"));
+	}
+}
diff --git a/bramble-java/src/main/java/org/briarproject/bramble/plugin/tor/JavaTorPlugin.java b/bramble-java/src/main/java/org/briarproject/bramble/plugin/tor/JavaTorPlugin.java
index d08300b2af61e796ad668f21482b299512bb4c1d..9b213cba7e332eff85d3a0ed887bf3a7bb386dbc 100644
--- a/bramble-java/src/main/java/org/briarproject/bramble/plugin/tor/JavaTorPlugin.java
+++ b/bramble-java/src/main/java/org/briarproject/bramble/plugin/tor/JavaTorPlugin.java
@@ -25,12 +25,13 @@ abstract class JavaTorPlugin extends TorPlugin {
 			Clock clock, ResourceProvider resourceProvider,
 			CircumventionProvider circumventionProvider,
 			BatteryManager batteryManager, Backoff backoff,
+			TorRendezvousCrypto torRendezvousCrypto,
 			PluginCallback callback, String architecture, int maxLatency,
 			int maxIdleTime, File torDirectory) {
 		super(ioExecutor, networkManager, locationUtils, torSocketFactory,
 				clock, resourceProvider, circumventionProvider, batteryManager,
-				backoff, callback, architecture, maxLatency, maxIdleTime,
-				torDirectory);
+				backoff, torRendezvousCrypto, callback, architecture,
+				maxLatency, maxIdleTime, torDirectory);
 	}
 
 	@Override
diff --git a/bramble-java/src/main/java/org/briarproject/bramble/plugin/tor/UnixTorPlugin.java b/bramble-java/src/main/java/org/briarproject/bramble/plugin/tor/UnixTorPlugin.java
index 753840a76b53acc27ae83da5c20e967b10473bbf..e57ea83f3e806cbb80acf77c87730628d6db13ed 100644
--- a/bramble-java/src/main/java/org/briarproject/bramble/plugin/tor/UnixTorPlugin.java
+++ b/bramble-java/src/main/java/org/briarproject/bramble/plugin/tor/UnixTorPlugin.java
@@ -25,12 +25,13 @@ class UnixTorPlugin extends JavaTorPlugin {
 			Clock clock, ResourceProvider resourceProvider,
 			CircumventionProvider circumventionProvider,
 			BatteryManager batteryManager, Backoff backoff,
+			TorRendezvousCrypto torRendezvousCrypto,
 			PluginCallback callback, String architecture, int maxLatency,
 			int maxIdleTime, File torDirectory) {
 		super(ioExecutor, networkManager, locationUtils, torSocketFactory,
 				clock, resourceProvider, circumventionProvider, batteryManager,
-				backoff, callback, architecture, maxLatency, maxIdleTime,
-				torDirectory);
+				backoff, torRendezvousCrypto, callback, architecture,
+				maxLatency, maxIdleTime, torDirectory);
 	}
 
 	@Override
diff --git a/bramble-java/src/main/java/org/briarproject/bramble/plugin/tor/UnixTorPluginFactory.java b/bramble-java/src/main/java/org/briarproject/bramble/plugin/tor/UnixTorPluginFactory.java
index 0e84257129a805cc4a87bf7e2f306102c9b28240..07cbead567577ad9da962c9584f08e101ee0dab8 100644
--- a/bramble-java/src/main/java/org/briarproject/bramble/plugin/tor/UnixTorPluginFactory.java
+++ b/bramble-java/src/main/java/org/briarproject/bramble/plugin/tor/UnixTorPluginFactory.java
@@ -96,10 +96,12 @@ public class UnixTorPluginFactory implements DuplexPluginFactory {
 
 		Backoff backoff = backoffFactory.createBackoff(MIN_POLLING_INTERVAL,
 				MAX_POLLING_INTERVAL, BACKOFF_BASE);
+		TorRendezvousCrypto torRendezvousCrypto = new TorRendezvousCryptoImpl();
 		UnixTorPlugin plugin = new UnixTorPlugin(ioExecutor, networkManager,
 				locationUtils, torSocketFactory, clock, resourceProvider,
-				circumventionProvider, batteryManager, backoff, callback,
-				architecture, MAX_LATENCY, MAX_IDLE_TIME, torDirectory);
+				circumventionProvider, batteryManager, backoff,
+				torRendezvousCrypto, callback, architecture, MAX_LATENCY,
+				MAX_IDLE_TIME, torDirectory);
 		eventBus.addListener(plugin);
 		return plugin;
 	}