diff --git a/src/net/sf/briar/api/crypto/CryptoComponent.java b/src/net/sf/briar/api/crypto/CryptoComponent.java
index c8cbace86e982bf4fe53972fc69ce3c9a4c9be23..0c792b068d3723da04da7590095321a73739723a 100644
--- a/src/net/sf/briar/api/crypto/CryptoComponent.java
+++ b/src/net/sf/briar/api/crypto/CryptoComponent.java
@@ -1,7 +1,7 @@
 package net.sf.briar.api.crypto;
 
+import java.security.GeneralSecurityException;
 import java.security.KeyPair;
-import java.security.PrivateKey;
 import java.security.SecureRandom;
 import java.security.Signature;
 
@@ -32,8 +32,8 @@ public interface CryptoComponent {
 	 * corresponding private keys.
 	 * @param alice indicates whether the private key belongs to Alice or Bob.
 	 */
-	byte[] deriveInitialSecret(byte[] ourPublicKey, byte[] theirPublicKey,
-			PrivateKey ourPrivateKey, boolean alice);
+	byte[] deriveInitialSecret(byte[] theirPublicKey, KeyPair ourKeyPair,
+			boolean alice) throws GeneralSecurityException;
 
 	/**
 	 * Generates a random invitation code.
diff --git a/src/net/sf/briar/api/plugins/InvitationConstants.java b/src/net/sf/briar/api/plugins/InvitationConstants.java
index f27d35b825690f62a5d3e79c7231ddaf6e104dcf..8e76a32a9225e4e48e7ed18af75324940b7e3988 100644
--- a/src/net/sf/briar/api/plugins/InvitationConstants.java
+++ b/src/net/sf/briar/api/plugins/InvitationConstants.java
@@ -2,11 +2,11 @@ package net.sf.briar.api.plugins;
 
 public interface InvitationConstants {
 
-	long INVITATION_TIMEOUT = 60 * 1000; // 1 minute
+	long INVITATION_TIMEOUT = 30 * 1000; // Milliseconds
 
 	int CODE_BITS = 19; // Codes must fit into six decimal digits
 
-	int MAX_CODE = (1 << CODE_BITS) - 1;
+	int MAX_CODE = (1 << CODE_BITS) - 1; // 524287
 
 	int HASH_LENGTH = 48; // Bytes
 
diff --git a/src/net/sf/briar/api/serial/Writer.java b/src/net/sf/briar/api/serial/Writer.java
index 1bf72c21f7825820ba2a909dc3076a7bb00d1573..d13b711c6156ca2f981194654b230ff29b615bcc 100644
--- a/src/net/sf/briar/api/serial/Writer.java
+++ b/src/net/sf/briar/api/serial/Writer.java
@@ -6,6 +6,9 @@ import java.util.Map;
 
 public interface Writer {
 
+	void flush() throws IOException;
+	void close() throws IOException;
+
 	void addConsumer(Consumer c);
 	void removeConsumer(Consumer c);
 
diff --git a/src/net/sf/briar/crypto/CryptoComponentImpl.java b/src/net/sf/briar/crypto/CryptoComponentImpl.java
index 7e41e8c1c661d8ecb1ae48c0b9754b52b877ba54..8783c0ca8dcc6c13cef1b864cc4d6d37c2ac9a0b 100644
--- a/src/net/sf/briar/crypto/CryptoComponentImpl.java
+++ b/src/net/sf/briar/crypto/CryptoComponentImpl.java
@@ -8,7 +8,6 @@ import static net.sf.briar.util.ByteUtils.MAX_32_BIT_UNSIGNED;
 import java.security.GeneralSecurityException;
 import java.security.KeyPair;
 import java.security.KeyPairGenerator;
-import java.security.PrivateKey;
 import java.security.PublicKey;
 import java.security.SecureRandom;
 import java.security.Security;
@@ -142,38 +141,34 @@ class CryptoComponentImpl implements CryptoComponent {
 		}
 	}
 
-	public byte[] deriveInitialSecret(byte[] ourPublicKey,
-			byte[] theirPublicKey, PrivateKey ourPrivateKey, boolean alice) {
-		try {
-			PublicKey theirPublic = agreementKeyParser.parsePublicKey(
-					theirPublicKey);
-			MessageDigest messageDigest = getMessageDigest();
-			byte[] ourHash = messageDigest.digest(ourPublicKey);
-			byte[] theirHash = messageDigest.digest(theirPublicKey);
-			byte[] aliceInfo, bobInfo;
-			if(alice) {
-				aliceInfo = ourHash;
-				bobInfo = theirHash;
-			} else {
-				aliceInfo = theirHash;
-				bobInfo = ourHash;
-			}
-			// The raw secret comes from the key agreement algorithm
-			KeyAgreement keyAgreement = KeyAgreement.getInstance(AGREEMENT_ALGO,
-					PROVIDER);
-			keyAgreement.init(ourPrivateKey);
-			keyAgreement.doPhase(theirPublic, true);
-			byte[] rawSecret = keyAgreement.generateSecret();
-			// Derive the cooked secret from the raw secret using the
-			// concatenation KDF
-			byte[] cookedSecret = concatenationKdf(rawSecret, FIRST, aliceInfo,
-					bobInfo);
-			ByteUtils.erase(rawSecret);
-			return cookedSecret;
-		} catch(GeneralSecurityException e) {
-			// FIXME: Throw instead of returning null?
-			return null;
+	public byte[] deriveInitialSecret(byte[] theirPublicKey,
+			KeyPair ourKeyPair, boolean alice) throws GeneralSecurityException {
+		PublicKey theirPublic = agreementKeyParser.parsePublicKey(
+				theirPublicKey);
+		MessageDigest messageDigest = getMessageDigest();
+		byte[] ourPublicKey = ourKeyPair.getPublic().getEncoded();
+		byte[] ourHash = messageDigest.digest(ourPublicKey);
+		byte[] theirHash = messageDigest.digest(theirPublicKey);
+		byte[] aliceInfo, bobInfo;
+		if(alice) {
+			aliceInfo = ourHash;
+			bobInfo = theirHash;
+		} else {
+			aliceInfo = theirHash;
+			bobInfo = ourHash;
 		}
+		// The raw secret comes from the key agreement algorithm
+		KeyAgreement keyAgreement = KeyAgreement.getInstance(AGREEMENT_ALGO,
+				PROVIDER);
+		keyAgreement.init(ourKeyPair.getPrivate());
+		keyAgreement.doPhase(theirPublic, true);
+		byte[] rawSecret = keyAgreement.generateSecret();
+		// Derive the cooked secret from the raw secret using the
+		// concatenation KDF
+		byte[] cookedSecret = concatenationKdf(rawSecret, FIRST, aliceInfo,
+				bobInfo);
+		ByteUtils.erase(rawSecret);
+		return cookedSecret;
 	}
 
 	// Key derivation function based on a hash function - see NIST SP 800-56A,
diff --git a/src/net/sf/briar/invitation/AliceConnector.java b/src/net/sf/briar/invitation/AliceConnector.java
index 0d8bd5b43f55bce09b3b75d3279de40aa365e5f0..984e3e643a5590887f045f1589e6e949498ecfce 100644
--- a/src/net/sf/briar/invitation/AliceConnector.java
+++ b/src/net/sf/briar/invitation/AliceConnector.java
@@ -2,48 +2,47 @@ package net.sf.briar.invitation;
 
 import static java.util.logging.Level.INFO;
 import static java.util.logging.Level.WARNING;
-import static net.sf.briar.api.plugins.InvitationConstants.HASH_LENGTH;
 import static net.sf.briar.api.plugins.InvitationConstants.INVITATION_TIMEOUT;
 
-import java.io.EOFException;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
+import java.security.GeneralSecurityException;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.logging.Logger;
 
+import net.sf.briar.api.crypto.CryptoComponent;
 import net.sf.briar.api.crypto.PseudoRandom;
 import net.sf.briar.api.invitation.ConnectionCallback;
 import net.sf.briar.api.plugins.duplex.DuplexPlugin;
 import net.sf.briar.api.plugins.duplex.DuplexTransportConnection;
+import net.sf.briar.api.serial.Reader;
+import net.sf.briar.api.serial.ReaderFactory;
+import net.sf.briar.api.serial.Writer;
+import net.sf.briar.api.serial.WriterFactory;
 
-class AliceConnector extends Thread {
+class AliceConnector extends Connector {
 
 	private static final Logger LOG =
 			Logger.getLogger(AliceConnector.class.getName());
 
-	private final DuplexPlugin plugin;
-	private final PseudoRandom random;
-	private final ConnectionCallback callback;
-	private final AtomicBoolean connected, succeeded;
-	private final String pluginName;
-
-	AliceConnector(DuplexPlugin plugin, PseudoRandom random,
-			ConnectionCallback callback, AtomicBoolean connected,
-			AtomicBoolean succeeded) {
-		this.plugin = plugin;
-		this.random = random;
-		this.callback = callback;
-		this.connected = connected;
-		this.succeeded = succeeded;
-		pluginName = plugin.getClass().getName();
+	AliceConnector(CryptoComponent crypto, ReaderFactory readerFactory,
+			WriterFactory writerFactory, DuplexPlugin plugin,
+			PseudoRandom random, ConnectionCallback callback,
+			AtomicBoolean connected, AtomicBoolean succeeded) {
+		super(crypto, readerFactory, writerFactory, plugin, random, callback,
+				connected, succeeded);
 	}
 
 	@Override
 	public void run() {
+		// Try an outgoing connection first, then an incoming connection
 		long halfTime = System.currentTimeMillis() + INVITATION_TIMEOUT / 2;
 		DuplexTransportConnection conn = makeOutgoingConnection();
-		if(conn == null) conn = acceptIncomingConnection(halfTime);
+		if(conn == null) {
+			waitForHalfTime(halfTime);
+			conn = acceptIncomingConnection();
+		}
 		if(conn == null) return;
 		if(LOG.isLoggable(INFO)) LOG.info(pluginName + " connected");
 		// Don't proceed with more than one connection
@@ -52,34 +51,42 @@ class AliceConnector extends Thread {
 			tryToClose(conn, false);
 			return;
 		}
-		// FIXME: Carry out the real invitation protocol
+		// Carry out the key agreement protocol
 		InputStream in;
+		OutputStream out;
+		Reader r;
+		Writer w;
+		byte[] secret;
 		try {
 			in = conn.getInputStream();
-			OutputStream out = conn.getOutputStream();
-			byte[] hash = random.nextBytes(HASH_LENGTH);
-			out.write(hash);
-			out.flush();
-			if(LOG.isLoggable(INFO)) LOG.info(pluginName + " sent hash");
-			int offset = 0;
-			while(offset < hash.length) {
-				int read = in.read(hash, offset, hash.length - offset);
-				if(read == -1) break;
-				offset += read;
-			}
-			if(offset < HASH_LENGTH) throw new EOFException();
-			if(LOG.isLoggable(INFO)) LOG.info(pluginName + " received hash");
-			if(LOG.isLoggable(INFO)) LOG.info(pluginName + " succeeded");
-			succeeded.set(true);
-			callback.connectionEstablished(123456, 123456,
-					new ConfirmationSender(out));
+			out = conn.getOutputStream();
+			r = readerFactory.createReader(in);
+			w = writerFactory.createWriter(out);
+			// Alice goes first
+			sendPublicKeyHash(w);
+			byte[] hash = receivePublicKeyHash(r);
+			sendPublicKey(w);
+			byte[] key = receivePublicKey(r);
+			secret = deriveSharedSecret(hash, key, true);
 		} catch(IOException e) {
 			if(LOG.isLoggable(WARNING)) LOG.warning(e.toString());
 			tryToClose(conn, true);
 			return;
+		} catch(GeneralSecurityException e) {
+			if(LOG.isLoggable(WARNING)) LOG.warning(e.toString());
+			tryToClose(conn, true);
+			return;
 		}
+		// The key agreement succeeded
+		if(LOG.isLoggable(INFO)) LOG.info(pluginName + " succeeded");
+		succeeded.set(true);
+		// Derive the confirmation codes
+		int[] codes = crypto.deriveConfirmationCodes(secret);
+		callback.connectionEstablished(codes[0], codes[1],
+				new ConfirmationSender(w));
+		// Check whether the remote peer's confirmation codes matched
 		try {
-			if(in.read() == 1) callback.codesMatch();
+			if(r.readBoolean()) callback.codesMatch();
 			else callback.codesDoNotMatch();
 		} catch(IOException e) {
 			if(LOG.isLoggable(WARNING)) LOG.warning(e.toString());
@@ -87,35 +94,4 @@ class AliceConnector extends Thread {
 			callback.codesDoNotMatch();
 		}
 	}
-
-	private DuplexTransportConnection makeOutgoingConnection() {
-		if(LOG.isLoggable(INFO))
-			LOG.info(pluginName + " making outgoing connection");
-		return plugin.sendInvitation(random, INVITATION_TIMEOUT / 2);
-	}
-
-	private DuplexTransportConnection acceptIncomingConnection(long halfTime) {
-		long now = System.currentTimeMillis();
-		if(now < halfTime) {
-			if(LOG.isLoggable(INFO))
-				LOG.info(pluginName + " sleeping until half-time");
-			try {
-				Thread.sleep(halfTime - now);
-			} catch(InterruptedException e) {
-				if(LOG.isLoggable(INFO)) LOG.info("Interrupted while sleeping");
-				return null;
-			}
-		}
-		if(LOG.isLoggable(INFO))
-			LOG.info(pluginName + " accepting incoming connection");
-		return plugin.acceptInvitation(random, INVITATION_TIMEOUT / 2);
-	}
-
-	private void tryToClose(DuplexTransportConnection conn, boolean exception) {
-		try {
-			conn.dispose(exception, true);
-		} catch(IOException e) {
-			if(LOG.isLoggable(WARNING)) LOG.warning(e.toString());
-		}
-	}
 }
\ No newline at end of file
diff --git a/src/net/sf/briar/invitation/BobConnector.java b/src/net/sf/briar/invitation/BobConnector.java
index a895d638ecbf66fcef674f9eefe9707521024ee2..7b75dc3211c39231ed8dbf8e8ae4f870665aff8c 100644
--- a/src/net/sf/briar/invitation/BobConnector.java
+++ b/src/net/sf/briar/invitation/BobConnector.java
@@ -2,84 +2,85 @@ package net.sf.briar.invitation;
 
 import static java.util.logging.Level.INFO;
 import static java.util.logging.Level.WARNING;
-import static net.sf.briar.api.plugins.InvitationConstants.HASH_LENGTH;
 import static net.sf.briar.api.plugins.InvitationConstants.INVITATION_TIMEOUT;
 
-import java.io.EOFException;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
+import java.security.GeneralSecurityException;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.logging.Logger;
 
+import net.sf.briar.api.crypto.CryptoComponent;
 import net.sf.briar.api.crypto.PseudoRandom;
 import net.sf.briar.api.invitation.ConnectionCallback;
 import net.sf.briar.api.plugins.duplex.DuplexPlugin;
 import net.sf.briar.api.plugins.duplex.DuplexTransportConnection;
+import net.sf.briar.api.serial.Reader;
+import net.sf.briar.api.serial.ReaderFactory;
+import net.sf.briar.api.serial.Writer;
+import net.sf.briar.api.serial.WriterFactory;
 
-class BobConnector extends Thread {
+class BobConnector extends Connector {
 
 	private static final Logger LOG =
 			Logger.getLogger(BobConnector.class.getName());
 
-	private final DuplexPlugin plugin;
-	private final PseudoRandom random;
-	private final ConnectionCallback callback;
-	private final AtomicBoolean connected, succeeded;
-	private final String pluginName;
-
-	BobConnector(DuplexPlugin plugin, PseudoRandom random,
-			ConnectionCallback callback, AtomicBoolean connected,
-			AtomicBoolean succeeded) {
-		this.plugin = plugin;
-		this.random = random;
-		this.callback = callback;
-		this.connected = connected;
-		this.succeeded = succeeded;
-		pluginName = plugin.getClass().getName();
+	BobConnector(CryptoComponent crypto, ReaderFactory readerFactory,
+			WriterFactory writerFactory, DuplexPlugin plugin,
+			PseudoRandom random, ConnectionCallback callback,
+			AtomicBoolean connected, AtomicBoolean succeeded) {
+		super(crypto, readerFactory, writerFactory, plugin, random, callback,
+				connected, succeeded);
 	}
 
 	@Override
 	public void run() {
+		// Try an incoming connection first, then an outgoing connection
 		long halfTime = System.currentTimeMillis() + INVITATION_TIMEOUT / 2;
 		DuplexTransportConnection conn = acceptIncomingConnection();
-		if(conn == null) conn = makeOutgoingConnection(halfTime);
+		if(conn == null) {
+			waitForHalfTime(halfTime);
+			conn = makeOutgoingConnection();
+		}
 		if(conn == null) return;
 		if(LOG.isLoggable(INFO)) LOG.info(pluginName + " connected");
-		// FIXME: Carry out the real invitation protocol
+		// Carry out the key agreement protocol
 		InputStream in;
+		OutputStream out;
+		Reader r;
+		Writer w;
+		byte[] secret;
 		try {
 			in = conn.getInputStream();
-			OutputStream out = conn.getOutputStream();
-			byte[] hash = new byte[HASH_LENGTH];
-			int offset = 0;
-			while(offset < hash.length) {
-				int read = in.read(hash, offset, hash.length - offset);
-				if(read == -1) break;
-				offset += read;
-			}
-			if(offset < HASH_LENGTH) throw new EOFException();
-			if(LOG.isLoggable(INFO)) LOG.info(pluginName + " received hash");
-			// Don't proceed with more than one connection
-			if(connected.getAndSet(true)) {
-				if(LOG.isLoggable(INFO))
-					LOG.info(pluginName + " redundant");
-				tryToClose(conn, false);
-				return;
-			}
-			out.write(hash);
-			out.flush();
-			if(LOG.isLoggable(INFO)) LOG.info(pluginName + " sent hash");
-			succeeded.set(true);
-			callback.connectionEstablished(123456, 123456,
-					new ConfirmationSender(out));
+			out = conn.getOutputStream();
+			r = readerFactory.createReader(in);
+			w = writerFactory.createWriter(out);
+			// Alice goes first
+			byte[] hash = receivePublicKeyHash(r);
+			sendPublicKeyHash(w);
+			byte[] key = receivePublicKey(r);
+			sendPublicKey(w);
+			secret = deriveSharedSecret(hash, key, false);
 		} catch(IOException e) {
 			if(LOG.isLoggable(WARNING)) LOG.warning(e.toString());
 			tryToClose(conn, true);
 			return;
+		} catch(GeneralSecurityException e) {
+			if(LOG.isLoggable(WARNING)) LOG.warning(e.toString());
+			tryToClose(conn, true);
+			return;
 		}
+		// The key agreement succeeded
+		if(LOG.isLoggable(INFO)) LOG.info(pluginName + " succeeded");
+		succeeded.set(true);
+		// Derive the confirmation codes
+		int[] codes = crypto.deriveConfirmationCodes(secret);
+		callback.connectionEstablished(codes[1], codes[0],
+				new ConfirmationSender(w));
+		// Check whether the remote peer's confirmation codes matched
 		try {
-			if(in.read() == 1) callback.codesMatch();
+			if(r.readBoolean()) callback.codesMatch();
 			else callback.codesDoNotMatch();
 		} catch(IOException e) {
 			if(LOG.isLoggable(WARNING)) LOG.warning(e.toString());
@@ -87,35 +88,4 @@ class BobConnector extends Thread {
 			callback.codesDoNotMatch();
 		}
 	}
-
-	private DuplexTransportConnection acceptIncomingConnection() {
-		if(LOG.isLoggable(INFO))
-			LOG.info(pluginName + " accepting incoming connection");
-		return plugin.acceptInvitation(random, INVITATION_TIMEOUT / 2);
-	}
-
-	private DuplexTransportConnection makeOutgoingConnection(long halfTime) {
-		long now = System.currentTimeMillis();
-		if(now < halfTime) {
-			if(LOG.isLoggable(INFO))
-				LOG.info(pluginName + " sleeping until half-time");
-			try {
-				Thread.sleep(halfTime - now);
-			} catch(InterruptedException e) {
-				if(LOG.isLoggable(INFO)) LOG.info("Interrupted while sleeping");
-				return null;
-			}
-		}
-		if(LOG.isLoggable(INFO))
-			LOG.info(pluginName + " making outgoing connection");
-		return plugin.sendInvitation(random, INVITATION_TIMEOUT / 2);
-	}
-
-	private void tryToClose(DuplexTransportConnection conn, boolean exception) {
-		try {
-			conn.dispose(exception, true);
-		} catch(IOException e) {
-			if(LOG.isLoggable(WARNING)) LOG.warning(e.toString());
-		}
-	}
 }
diff --git a/src/net/sf/briar/invitation/ConfirmationSender.java b/src/net/sf/briar/invitation/ConfirmationSender.java
deleted file mode 100644
index 7542b49f02c4ee4e77b2a473c96ad43fdb20f837..0000000000000000000000000000000000000000
--- a/src/net/sf/briar/invitation/ConfirmationSender.java
+++ /dev/null
@@ -1,38 +0,0 @@
-package net.sf.briar.invitation;
-
-import static java.util.logging.Level.WARNING;
-
-import java.io.IOException;
-import java.io.OutputStream;
-import java.util.logging.Logger;
-
-import net.sf.briar.api.invitation.ConfirmationCallback;
-
-class ConfirmationSender implements ConfirmationCallback {
-
-	private static final Logger LOG =
-			Logger.getLogger(ConfirmationSender.class.getName());
-
-	private final OutputStream out;
-
-	ConfirmationSender(OutputStream out) {
-		this.out = out;
-	}
-
-	public void codesMatch() {
-		write(1);
-	}
-
-	public void codesDoNotMatch() {
-		write(0);
-	}
-
-	private void write(int b) {
-		try {
-			out.write(b);
-			out.flush();
-		} catch(IOException e) {
-			if(LOG.isLoggable(WARNING)) LOG.warning(e.toString());
-		}
-	}
-}
diff --git a/src/net/sf/briar/invitation/Connector.java b/src/net/sf/briar/invitation/Connector.java
new file mode 100644
index 0000000000000000000000000000000000000000..ce269ab8d5cd8dd1467b2772a33e033dbaad7546
--- /dev/null
+++ b/src/net/sf/briar/invitation/Connector.java
@@ -0,0 +1,169 @@
+package net.sf.briar.invitation;
+
+import static java.util.logging.Level.INFO;
+import static java.util.logging.Level.WARNING;
+import static net.sf.briar.api.plugins.InvitationConstants.HASH_LENGTH;
+import static net.sf.briar.api.plugins.InvitationConstants.INVITATION_TIMEOUT;
+import static net.sf.briar.api.plugins.InvitationConstants.MAX_PUBLIC_KEY_LENGTH;
+
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.security.KeyPair;
+import java.util.Arrays;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.logging.Logger;
+
+import net.sf.briar.api.FormatException;
+import net.sf.briar.api.crypto.CryptoComponent;
+import net.sf.briar.api.crypto.KeyParser;
+import net.sf.briar.api.crypto.MessageDigest;
+import net.sf.briar.api.crypto.PseudoRandom;
+import net.sf.briar.api.invitation.ConfirmationCallback;
+import net.sf.briar.api.invitation.ConnectionCallback;
+import net.sf.briar.api.plugins.duplex.DuplexPlugin;
+import net.sf.briar.api.plugins.duplex.DuplexTransportConnection;
+import net.sf.briar.api.serial.Reader;
+import net.sf.briar.api.serial.ReaderFactory;
+import net.sf.briar.api.serial.Writer;
+import net.sf.briar.api.serial.WriterFactory;
+
+abstract class Connector extends Thread {
+
+	private static final Logger LOG =
+			Logger.getLogger(Connector.class.getName());
+
+	protected final CryptoComponent crypto;
+	protected final ReaderFactory readerFactory;
+	protected final WriterFactory writerFactory;
+	protected final DuplexPlugin plugin;
+	protected final PseudoRandom random;
+	protected final ConnectionCallback callback;
+	protected final AtomicBoolean connected, succeeded;
+	protected final String pluginName;
+
+	private final KeyPair keyPair;
+	private final KeyParser keyParser;
+	private final MessageDigest messageDigest;
+
+	Connector(CryptoComponent crypto, ReaderFactory readerFactory,
+			WriterFactory writerFactory, DuplexPlugin plugin,
+			PseudoRandom random, ConnectionCallback callback,
+			AtomicBoolean connected, AtomicBoolean succeeded) {
+		this.crypto = crypto;
+		this.readerFactory = readerFactory;
+		this.writerFactory = writerFactory;
+		this.plugin = plugin;
+		this.random = random;
+		this.callback = callback;
+		this.connected = connected;
+		this.succeeded = succeeded;
+		pluginName = plugin.getClass().getName();
+		keyPair = crypto.generateAgreementKeyPair();
+		keyParser = crypto.getAgreementKeyParser();
+		messageDigest = crypto.getMessageDigest();
+	}
+
+	protected DuplexTransportConnection acceptIncomingConnection() {
+		if(LOG.isLoggable(INFO))
+			LOG.info(pluginName + " accepting incoming connection");
+		return plugin.acceptInvitation(random, INVITATION_TIMEOUT / 2);
+	}
+
+	protected DuplexTransportConnection makeOutgoingConnection() {
+		if(LOG.isLoggable(INFO))
+			LOG.info(pluginName + " making outgoing connection");
+		return plugin.sendInvitation(random, INVITATION_TIMEOUT / 2);
+	}
+
+	protected void waitForHalfTime(long halfTime) {
+		long now = System.currentTimeMillis();
+		if(now < halfTime) {
+			if(LOG.isLoggable(INFO))
+				LOG.info(pluginName + " sleeping until half-time");
+			try {
+				Thread.sleep(halfTime - now);
+			} catch(InterruptedException e) {
+				if(LOG.isLoggable(INFO)) LOG.info("Interrupted while sleeping");
+				Thread.currentThread().interrupt();
+				return;
+			}
+		}
+	}
+
+	protected void tryToClose(DuplexTransportConnection conn,
+			boolean exception) {
+		try {
+			conn.dispose(exception, true);
+		} catch(IOException e) {
+			if(LOG.isLoggable(WARNING)) LOG.warning(e.toString());
+		}
+	}
+
+	protected void sendPublicKeyHash(Writer w) throws IOException {
+		w.writeBytes(messageDigest.digest(keyPair.getPublic().getEncoded()));
+		w.flush();
+		if(LOG.isLoggable(INFO)) LOG.info(pluginName + " sent hash");
+	}
+
+	protected byte[] receivePublicKeyHash(Reader r) throws IOException {
+		byte[] b = r.readBytes(HASH_LENGTH);
+		if(b.length != HASH_LENGTH) throw new FormatException();
+		if(LOG.isLoggable(INFO)) LOG.info(pluginName + " received hash");
+		return b;
+	}
+
+	protected void sendPublicKey(Writer w) throws IOException {
+		w.writeBytes(keyPair.getPublic().getEncoded());
+		w.flush();
+		if(LOG.isLoggable(INFO)) LOG.info(pluginName + " sent key");
+	}
+
+	protected byte[] receivePublicKey(Reader r) throws IOException {
+		byte[] b = r.readBytes(MAX_PUBLIC_KEY_LENGTH);
+		try {
+			keyParser.parsePublicKey(b);
+		} catch(GeneralSecurityException e) {
+			throw new FormatException();
+		}
+		if(LOG.isLoggable(INFO)) LOG.info(pluginName + " received hash");
+		return b;
+	}
+
+	protected byte[] deriveSharedSecret(byte[] hash, byte[] key, boolean alice)
+			throws GeneralSecurityException {
+		// Check that the hash matches the key
+		if(!Arrays.equals(hash, messageDigest.digest(key))) {
+			if(LOG.isLoggable(INFO))
+				LOG.info(pluginName + " hash does not match key");
+			throw new GeneralSecurityException();
+		}
+		//  Derive the shared secret
+		return crypto.deriveInitialSecret(key, keyPair, alice);
+	}
+
+	protected static class ConfirmationSender implements ConfirmationCallback {
+
+		private final Writer writer;
+
+		protected ConfirmationSender(Writer writer) {
+			this.writer = writer;
+		}
+
+		public void codesMatch() {
+			write(true);
+		}
+
+		public void codesDoNotMatch() {
+			write(false);
+		}
+
+		private void write(boolean match) {
+			try {
+				writer.writeBoolean(match);
+				writer.flush();
+			} catch(IOException e) {
+				if(LOG.isLoggable(WARNING)) LOG.warning(e.toString());
+			}
+		}
+	}
+}
diff --git a/src/net/sf/briar/invitation/InvitationManagerImpl.java b/src/net/sf/briar/invitation/InvitationManagerImpl.java
index a098add93ac3e36fa86b0dd0ee8deb5a4fd76a14..ade452233bba4752f08fd271c97e7258492f880c 100644
--- a/src/net/sf/briar/invitation/InvitationManagerImpl.java
+++ b/src/net/sf/briar/invitation/InvitationManagerImpl.java
@@ -10,17 +10,24 @@ import net.sf.briar.api.invitation.ConnectionCallback;
 import net.sf.briar.api.invitation.InvitationManager;
 import net.sf.briar.api.plugins.PluginManager;
 import net.sf.briar.api.plugins.duplex.DuplexPlugin;
+import net.sf.briar.api.serial.ReaderFactory;
+import net.sf.briar.api.serial.WriterFactory;
 
 import com.google.inject.Inject;
 
 class InvitationManagerImpl implements InvitationManager {
 
 	private final CryptoComponent crypto;
+	private final ReaderFactory readerFactory;
+	private final WriterFactory writerFactory;
 	private final PluginManager pluginManager;
 
 	@Inject
-	InvitationManagerImpl(CryptoComponent crypto, PluginManager pluginManager) {
+	InvitationManagerImpl(CryptoComponent crypto, ReaderFactory readerFactory,
+			WriterFactory writerFactory, PluginManager pluginManager) {
 		this.crypto = crypto;
+		this.readerFactory = readerFactory;
+		this.writerFactory = writerFactory;
 		this.pluginManager = pluginManager;
 	}
 
@@ -41,7 +48,8 @@ class InvitationManagerImpl implements InvitationManager {
 		Collection<Thread> workers = new ArrayList<Thread>();
 		for(DuplexPlugin p : plugins) {
 			PseudoRandom r = crypto.getPseudoRandom(localCode, remoteCode);
-			Thread worker = new AliceConnector(p, r, c, connected, succeeded);
+			Thread worker = new AliceConnector(crypto, readerFactory,
+					writerFactory, p, r, c, connected, succeeded);
 			workers.add(worker);
 			worker.start();
 		}
@@ -55,7 +63,8 @@ class InvitationManagerImpl implements InvitationManager {
 		Collection<Thread> workers = new ArrayList<Thread>();
 		for(DuplexPlugin p : plugins) {
 			PseudoRandom r = crypto.getPseudoRandom(remoteCode, localCode);
-			Thread worker = new BobConnector(p, r, c, connected, succeeded);
+			Thread worker = new BobConnector(crypto, readerFactory,
+					writerFactory, p, r, c, connected, succeeded);
 			workers.add(worker);
 			worker.start();
 		}
diff --git a/src/net/sf/briar/serial/WriterImpl.java b/src/net/sf/briar/serial/WriterImpl.java
index 239e15fc3c42051a8e4f68ef81ba707ef9851f79..8fdd7467b0fff34d90145cd94a6ae5bfb04ad4df 100644
--- a/src/net/sf/briar/serial/WriterImpl.java
+++ b/src/net/sf/briar/serial/WriterImpl.java
@@ -22,6 +22,14 @@ class WriterImpl implements Writer {
 		this.out = out;
 	}
 
+	public void flush() throws IOException {
+		out.flush();
+	}
+
+	public void close() throws IOException {
+		out.close();
+	}
+
 	public void addConsumer(Consumer c) {
 		consumers.add(c);
 	}